#!/usr/bin/env python3 """ Minimal deterministic repair for ICS-SimLab configuration issues. These repairs fix P0 semantic issues that would cause open-loop systems: - Orphan sensors: attach to first PLC as monitors - Orphan actuators: attach to first PLC as controllers - Boolean type rules: move boolean signals to correct register types Repairs are deterministic: same input always produces same output. Address allocation uses a simple incrementing scheme. """ from dataclasses import dataclass from typing import Any, Dict, List, Tuple # Address allocation ranges (avoid collision with existing addresses) MONITOR_ADDRESS_START = 1000 CONTROLLER_ADDRESS_START = 2000 @dataclass class RepairAction: """A repair action that was applied.""" entity: str action: str details: str def __str__(self) -> str: return f"{self.entity}: {self.action} - {self.details}" def _find_orphan_sensors(config: Dict[str, Any]) -> List[Dict[str, Any]]: """Find sensors not referenced by any PLC monitor.""" # Collect all sensor IPs sensor_by_ip: Dict[str, Dict[str, Any]] = {} for sensor in config.get("sensors", []): net = sensor.get("network", {}) if ip := net.get("ip"): sensor_by_ip[ip] = sensor # Collect all IPs targeted by PLC monitors monitored_ips: set = set() for plc in config.get("plcs", []): # Build connection_id -> IP mapping conn_to_ip: Dict[str, str] = {} for conn in plc.get("outbound_connections", []): if conn.get("type") == "tcp" and conn.get("id"): conn_to_ip[conn["id"]] = conn.get("ip", "") for monitor in plc.get("monitors", []): conn_id = monitor.get("outbound_connection_id", "") if conn_id in conn_to_ip: monitored_ips.add(conn_to_ip[conn_id]) # Find orphans orphans = [] for ip, sensor in sensor_by_ip.items(): if ip not in monitored_ips: orphans.append(sensor) return orphans def _find_orphan_actuators(config: Dict[str, Any]) -> List[Dict[str, Any]]: """Find actuators not referenced by any PLC controller.""" # Collect all actuator IPs actuator_by_ip: Dict[str, Dict[str, Any]] = {} for actuator in config.get("actuators", []): net = actuator.get("network", {}) if ip := net.get("ip"): actuator_by_ip[ip] = actuator # Collect all IPs targeted by PLC controllers controlled_ips: set = set() for plc in config.get("plcs", []): # Build connection_id -> IP mapping conn_to_ip: Dict[str, str] = {} for conn in plc.get("outbound_connections", []): if conn.get("type") == "tcp" and conn.get("id"): conn_to_ip[conn["id"]] = conn.get("ip", "") for controller in plc.get("controllers", []): conn_id = controller.get("outbound_connection_id", "") if conn_id in conn_to_ip: controlled_ips.add(conn_to_ip[conn_id]) # Find orphans orphans = [] for ip, actuator in actuator_by_ip.items(): if ip not in controlled_ips: orphans.append(actuator) return orphans def _get_first_register_info(device: Dict[str, Any]) -> Tuple[str, int, str]: """ Get info from first register of a device. Returns: (physical_value, address, value_type) """ registers = device.get("registers", {}) # Check input_register first (sensors typically use this) for reg in registers.get("input_register", []): pv = reg.get("physical_value") or reg.get("id") or device.get("name", "unknown") addr = reg.get("address", 0) return (pv, addr, "input_register") # Check discrete_input (for boolean sensors) for reg in registers.get("discrete_input", []): pv = reg.get("physical_value") or reg.get("id") or device.get("name", "unknown") addr = reg.get("address", 0) return (pv, addr, "discrete_input") # Check coil (for actuators) for reg in registers.get("coil", []): pv = reg.get("physical_value") or reg.get("id") or device.get("name", "unknown") addr = reg.get("address", 0) return (pv, addr, "coil") # Check holding_register for reg in registers.get("holding_register", []): pv = reg.get("physical_value") or reg.get("id") or device.get("name", "unknown") addr = reg.get("address", 0) return (pv, addr, "holding_register") # Fallback return (device.get("name", "unknown"), 0, "input_register") def repair_orphan_devices(config: Dict[str, Any]) -> Tuple[Dict[str, Any], List[RepairAction]]: """ Attach orphan sensors/actuators to the first PLC. This is a minimal repair that ensures the system is not open-loop. It adds outbound_connections and monitors/controllers to the first PLC. Args: config: Configuration dict (will be modified in place) Returns: (modified_config, list_of_repair_actions) """ actions: List[RepairAction] = [] plcs = config.get("plcs", []) if not plcs: return config, actions # No PLCs to attach to first_plc = plcs[0] plc_name = first_plc.get("name", "plc1") # Ensure lists exist if "outbound_connections" not in first_plc: first_plc["outbound_connections"] = [] if "monitors" not in first_plc: first_plc["monitors"] = [] if "controllers" not in first_plc: first_plc["controllers"] = [] # Track existing connection IDs to avoid duplicates existing_conn_ids = { conn.get("id") for conn in first_plc["outbound_connections"] if conn.get("id") } # Address counters for deterministic allocation monitor_addr = MONITOR_ADDRESS_START controller_addr = CONTROLLER_ADDRESS_START # Repair orphan sensors orphan_sensors = _find_orphan_sensors(config) for sensor in orphan_sensors: sensor_name = sensor.get("name", "unknown_sensor") sensor_net = sensor.get("network", {}) sensor_ip = sensor_net.get("ip") if not sensor_ip: continue # Create connection ID conn_id = f"to_{sensor_name}" if conn_id in existing_conn_ids: # Already have a connection, skip continue # Add outbound connection first_plc["outbound_connections"].append({ "type": "tcp", "ip": sensor_ip, "port": 502, "id": conn_id }) existing_conn_ids.add(conn_id) # Get register info from sensor pv, orig_addr, value_type = _get_first_register_info(sensor) # Add monitor first_plc["monitors"].append({ "outbound_connection_id": conn_id, "id": pv, "value_type": value_type, "slave_id": 1, "address": orig_addr, "count": 1, "interval": 0.5 }) actions.append(RepairAction( entity=f"sensors['{sensor_name}']", action="attached to PLC", details=f"Added connection '{conn_id}' and monitor for '{pv}' on {plc_name}" )) monitor_addr += 1 # Repair orphan actuators orphan_actuators = _find_orphan_actuators(config) for actuator in orphan_actuators: actuator_name = actuator.get("name", "unknown_actuator") actuator_net = actuator.get("network", {}) actuator_ip = actuator_net.get("ip") if not actuator_ip: continue # Create connection ID conn_id = f"to_{actuator_name}" if conn_id in existing_conn_ids: # Already have a connection, skip continue # Add outbound connection first_plc["outbound_connections"].append({ "type": "tcp", "ip": actuator_ip, "port": 502, "id": conn_id }) existing_conn_ids.add(conn_id) # Get register info from actuator pv, orig_addr, value_type = _get_first_register_info(actuator) # For actuators, prefer coil type if value_type == "input_register": value_type = "coil" # Add controller first_plc["controllers"].append({ "outbound_connection_id": conn_id, "id": pv, "value_type": value_type, "slave_id": 1, "address": orig_addr, "count": 1 }) actions.append(RepairAction( entity=f"actuators['{actuator_name}']", action="attached to PLC", details=f"Added connection '{conn_id}' and controller for '{pv}' on {plc_name}" )) controller_addr += 1 return config, actions # Boolean indicator patterns (case-insensitive) - same as in semantic_validation.py BOOLEAN_PATTERNS = [ "switch", "state", "status", "at_", "is_", "_on", "_off", "enable", "active", "running", "alarm", "fault", "ready", "open", "close", "start", "stop", "button", "flag" ] def _looks_like_boolean(name: str) -> bool: """Check if a physical_value/id name suggests boolean semantics.""" if not name: return False name_lower = name.lower() return any(pattern in name_lower for pattern in BOOLEAN_PATTERNS) def repair_boolean_types(config: Dict[str, Any]) -> Tuple[Dict[str, Any], List[RepairAction]]: """ Move boolean signals to correct Modbus register types. Modbus type rules: - Boolean measured values -> discrete_input (read-only, function code 2) - Boolean commanded values -> coil (write, function code 5/15) This repair: 1. Moves sensor boolean registers from input_register to discrete_input 2. Moves actuator boolean registers from holding_register to coil 3. Updates PLC monitors/controllers to use correct value_type Args: config: Configuration dict (will be modified in place) Returns: (modified_config, list_of_repair_actions) """ actions: List[RepairAction] = [] # Track changes for updating monitors/controllers # Key: (device_name, physical_value) -> new_value_type type_changes: Dict[Tuple[str, str], str] = {} # Repair sensors: move boolean input_register -> discrete_input for sensor in config.get("sensors", []): sensor_name = sensor.get("name", "unknown") registers = sensor.get("registers", {}) input_regs = registers.get("input_register", []) discrete_regs = registers.get("discrete_input", []) # Find boolean registers to move to_move = [] for i, reg in enumerate(input_regs): pv = reg.get("physical_value") or "" if _looks_like_boolean(pv): to_move.append((i, reg, pv)) # Move them (reverse order to preserve indices) for i, reg, pv in reversed(to_move): input_regs.pop(i) discrete_regs.append(reg) type_changes[(sensor_name, pv)] = "discrete_input" actions.append(RepairAction( entity=f"sensors['{sensor_name}'].registers", action="moved to discrete_input", details=f"Boolean signal '{pv}' moved from input_register to discrete_input" )) # Update registers in sensor registers["input_register"] = input_regs registers["discrete_input"] = discrete_regs # Repair actuators: move boolean holding_register -> coil for actuator in config.get("actuators", []): actuator_name = actuator.get("name", "unknown") registers = actuator.get("registers", {}) holding_regs = registers.get("holding_register", []) coil_regs = registers.get("coil", []) # Find boolean registers to move to_move = [] for i, reg in enumerate(holding_regs): pv = reg.get("physical_value") or "" if _looks_like_boolean(pv): to_move.append((i, reg, pv)) # Move them (reverse order to preserve indices) for i, reg, pv in reversed(to_move): holding_regs.pop(i) coil_regs.append(reg) type_changes[(actuator_name, pv)] = "coil" actions.append(RepairAction( entity=f"actuators['{actuator_name}'].registers", action="moved to coil", details=f"Boolean signal '{pv}' moved from holding_register to coil" )) # Update registers in actuator registers["holding_register"] = holding_regs registers["coil"] = coil_regs # Repair PLCs: move boolean input_register -> discrete_input, holding_register -> coil for plc in config.get("plcs", []): plc_name = plc.get("name", "unknown") registers = plc.get("registers", {}) # input_register -> discrete_input for boolean inputs input_regs = registers.get("input_register", []) discrete_regs = registers.get("discrete_input", []) to_move = [] for i, reg in enumerate(input_regs): reg_id = reg.get("id") or "" if _looks_like_boolean(reg_id): to_move.append((i, reg, reg_id)) for i, reg, reg_id in reversed(to_move): input_regs.pop(i) discrete_regs.append(reg) actions.append(RepairAction( entity=f"plcs['{plc_name}'].registers", action="moved to discrete_input", details=f"Boolean signal '{reg_id}' moved from input_register to discrete_input" )) registers["input_register"] = input_regs registers["discrete_input"] = discrete_regs # holding_register -> coil for boolean outputs holding_regs = registers.get("holding_register", []) coil_regs = registers.get("coil", []) to_move = [] for i, reg in enumerate(holding_regs): reg_id = reg.get("id") or "" if _looks_like_boolean(reg_id): to_move.append((i, reg, reg_id)) for i, reg, reg_id in reversed(to_move): holding_regs.pop(i) coil_regs.append(reg) actions.append(RepairAction( entity=f"plcs['{plc_name}'].registers", action="moved to coil", details=f"Boolean signal '{reg_id}' moved from holding_register to coil" )) registers["holding_register"] = holding_regs registers["coil"] = coil_regs # Update PLC monitors to match new sensor register types for plc in config.get("plcs", []): for monitor in plc.get("monitors", []): monitor_id = monitor.get("id", "") # Check if this monitor's target was changed for (device_name, pv), new_type in type_changes.items(): if monitor_id == pv and monitor.get("value_type") != new_type: old_type = monitor.get("value_type") monitor["value_type"] = new_type actions.append(RepairAction( entity=f"plcs['{plc.get('name')}'].monitors", action="updated value_type", details=f"Monitor '{monitor_id}' changed from {old_type} to {new_type}" )) # Update PLC controllers to match new actuator register types for plc in config.get("plcs", []): for controller in plc.get("controllers", []): controller_id = controller.get("id", "") # Check if this controller's target was changed for (device_name, pv), new_type in type_changes.items(): if controller_id == pv and controller.get("value_type") != new_type: old_type = controller.get("value_type") controller["value_type"] = new_type actions.append(RepairAction( entity=f"plcs['{plc.get('name')}'].controllers", action="updated value_type", details=f"Controller '{controller_id}' changed from {old_type} to {new_type}" )) # Update HMI monitors to match new register types for hmi in config.get("hmis", []): for monitor in hmi.get("monitors", []): monitor_id = monitor.get("id", "") for (device_name, pv), new_type in type_changes.items(): if monitor_id == pv and monitor.get("value_type") != new_type: old_type = monitor.get("value_type") monitor["value_type"] = new_type actions.append(RepairAction( entity=f"hmis['{hmi.get('name')}'].monitors", action="updated value_type", details=f"Monitor '{monitor_id}' changed from {old_type} to {new_type}" )) return config, actions def repair_plc_local_registers(config: Dict[str, Any]) -> Tuple[Dict[str, Any], List[RepairAction]]: """ Create missing PLC local registers for monitors and controllers, and fix IO mismatches. Native ICS-SimLab pattern requires that: - For each PLC monitor with id=X and value_type=T, there should be a local register in plc.registers[T] with id=X and io="input" - For each PLC controller with id=Y and value_type=T, there should be a local register in plc.registers[T] with id=Y and io="output" This repair: 1. Creates missing registers with minimal changes 2. Fixes IO mismatches on existing registers Args: config: Configuration dict (will be modified in place) Returns: (modified_config, list_of_repair_actions) """ actions: List[RepairAction] = [] for plc in config.get("plcs", []): plc_name = plc.get("name", "unknown") registers = plc.get("registers", {}) # Ensure register type lists exist for reg_type in ["coil", "discrete_input", "holding_register", "input_register"]: if reg_type not in registers: registers[reg_type] = [] # Build mapping of existing register IDs by type -> id -> register object existing_regs: Dict[str, Dict[str, Dict]] = { "coil": {}, "discrete_input": {}, "holding_register": {}, "input_register": {}, } for reg_type in existing_regs: for reg in registers.get(reg_type, []): if isinstance(reg, dict) and reg.get("id"): existing_regs[reg_type][reg["id"]] = reg # Process monitors: create missing local registers with io="input" or fix io for monitor in plc.get("monitors", []): monitor_id = monitor.get("id") value_type = monitor.get("value_type") # e.g., "input_register" address = monitor.get("address", 0) count = monitor.get("count", 1) if not monitor_id or not value_type: continue if value_type not in existing_regs: continue if monitor_id not in existing_regs[value_type]: # Create new register entry new_reg = { "address": address, "count": count, "id": monitor_id, "io": "input" } registers[value_type].append(new_reg) existing_regs[value_type][monitor_id] = new_reg actions.append(RepairAction( entity=f"plcs['{plc_name}'].registers.{value_type}", action="created local register", details=f"Added register id='{monitor_id}' io='input' for monitor (native pattern)" )) else: # Check and fix IO mismatch reg = existing_regs[value_type][monitor_id] if reg.get("io") and reg["io"] != "input": old_io = reg["io"] reg["io"] = "input" actions.append(RepairAction( entity=f"plcs['{plc_name}'].registers.{value_type}['{monitor_id}']", action="fixed io mismatch", details=f"Changed io from '{old_io}' to 'input' for monitor (native pattern)" )) # Process controllers: create missing local registers with io="output" or fix io for controller in plc.get("controllers", []): controller_id = controller.get("id") value_type = controller.get("value_type") # e.g., "coil" address = controller.get("address", 0) count = controller.get("count", 1) if not controller_id or not value_type: continue if value_type not in existing_regs: continue if controller_id not in existing_regs[value_type]: # Create new register entry new_reg = { "address": address, "count": count, "id": controller_id, "io": "output" } registers[value_type].append(new_reg) existing_regs[value_type][controller_id] = new_reg actions.append(RepairAction( entity=f"plcs['{plc_name}'].registers.{value_type}", action="created local register", details=f"Added register id='{controller_id}' io='output' for controller (native pattern)" )) else: # Check and fix IO mismatch reg = existing_regs[value_type][controller_id] if reg.get("io") and reg["io"] != "output": old_io = reg["io"] reg["io"] = "output" actions.append(RepairAction( entity=f"plcs['{plc_name}'].registers.{value_type}['{controller_id}']", action="fixed io mismatch", details=f"Changed io from '{old_io}' to 'output' for controller (native pattern)" )) return config, actions def repair_hmi_controller_registers(config: Dict[str, Any]) -> Tuple[Dict[str, Any], List[RepairAction]]: """ Create missing PLC registers for HMI controllers. HMI controllers write to PLC registers. If the referenced register doesn't exist on the target PLC, this repair creates it with io="output". Args: config: Configuration dict (will be modified in place) Returns: (modified_config, list_of_repair_actions) """ actions: List[RepairAction] = [] # Build IP -> PLC mapping plc_by_ip: Dict[str, Dict[str, Any]] = {} for plc in config.get("plcs", []): net = plc.get("network", {}) if ip := net.get("ip"): plc_by_ip[ip] = plc # Process each HMI for hmi in config.get("hmis", []): hmi_name = hmi.get("name", "unknown") # Build connection_id -> IP mapping conn_to_ip: Dict[str, str] = {} for conn in hmi.get("outbound_connections", []): if conn.get("type") == "tcp" and conn.get("id"): conn_to_ip[conn["id"]] = conn.get("ip", "") # Process controllers for controller in hmi.get("controllers", []): controller_id = controller.get("id") value_type = controller.get("value_type") # e.g., "coil" conn_id = controller.get("outbound_connection_id") address = controller.get("address", 0) count = controller.get("count", 1) if not controller_id or not value_type or not conn_id: continue # Find target IP target_ip = conn_to_ip.get(conn_id) if not target_ip: continue # Find target PLC target_plc = plc_by_ip.get(target_ip) if not target_plc: continue plc_name = target_plc.get("name", "unknown") registers = target_plc.get("registers", {}) # Ensure register type list exists if value_type not in registers: registers[value_type] = [] # Check if register already exists existing_ids = { reg.get("id") for reg in registers.get(value_type, []) if isinstance(reg, dict) and reg.get("id") } if controller_id not in existing_ids: # Create new register entry on target PLC new_reg = { "address": address, "count": count, "id": controller_id, "io": "output" # HMI controllers write to PLC, so output } registers[value_type].append(new_reg) actions.append(RepairAction( entity=f"plcs['{plc_name}'].registers.{value_type}", action="created register for HMI controller", details=f"Added register id='{controller_id}' io='output' for HMI '{hmi_name}' controller" )) return config, actions def _get_next_free_address(registers: Dict[str, Any], reg_type: str) -> int: """ Get next free address for a register type. Finds the maximum existing address and returns max + 1. If no registers exist, starts at address 1. """ max_addr = 0 for reg in registers.get(reg_type, []): if isinstance(reg, dict): addr = reg.get("address", 0) count = reg.get("count", 1) max_addr = max(max_addr, addr + count - 1) return max_addr + 1 if max_addr > 0 else 1 def _register_exists_on_device(registers: Dict[str, Any], register_id: str) -> bool: """ Check if a register with given id/physical_value exists on any register type. """ for reg_type in ["coil", "discrete_input", "holding_register", "input_register"]: for reg in registers.get(reg_type, []): if isinstance(reg, dict): if reg.get("id") == register_id or reg.get("physical_value") == register_id: return True return False def repair_target_device_registers(config: Dict[str, Any]) -> Tuple[Dict[str, Any], List[RepairAction]]: """ Create missing registers on target devices (actuators, sensors, PLCs). When a PLC controller references a register id on a target device (via outbound_connection IP), the target device must have that register defined. This repair: 1. For each PLC controller, ensures target actuator/PLC has the register 2. For each PLC monitor, ensures target sensor/PLC has the register Register creation rules: - Actuators: use physical_value field, io not needed (device receives commands) - Sensors: use physical_value field, io not needed (device provides data) - PLCs (PLC-to-PLC): use id field, io="input" (receiving from another PLC) Args: config: Configuration dict (will be modified in place) Returns: (modified_config, list_of_repair_actions) """ actions: List[RepairAction] = [] # Build IP -> device mappings device_by_ip: Dict[str, Tuple[str, Dict[str, Any]]] = {} for plc in config.get("plcs", []): net = plc.get("network", {}) if ip := net.get("ip"): device_by_ip[ip] = ("plc", plc) for sensor in config.get("sensors", []): net = sensor.get("network", {}) if ip := net.get("ip"): device_by_ip[ip] = ("sensor", sensor) for actuator in config.get("actuators", []): net = actuator.get("network", {}) if ip := net.get("ip"): device_by_ip[ip] = ("actuator", actuator) # Process each PLC's controllers and monitors for plc in config.get("plcs", []): plc_name = plc.get("name", "unknown") # Build connection_id -> IP mapping conn_to_ip: Dict[str, str] = {} for conn in plc.get("outbound_connections", []): if conn.get("type") == "tcp" and conn.get("id"): conn_to_ip[conn["id"]] = conn.get("ip", "") # Process controllers: ensure target device has the register for controller in plc.get("controllers", []): controller_id = controller.get("id") value_type = controller.get("value_type") # e.g., "coil" conn_id = controller.get("outbound_connection_id") address = controller.get("address", 0) count = controller.get("count", 1) if not controller_id or not value_type or not conn_id: continue # Skip if connection not found (could be RTU) target_ip = conn_to_ip.get(conn_id) if not target_ip: continue # Find target device if target_ip not in device_by_ip: continue device_type, target_device = device_by_ip[target_ip] target_name = target_device.get("name", "unknown") registers = target_device.get("registers", {}) # Ensure register type list exists if value_type not in registers: registers[value_type] = [] # Check if register already exists if _register_exists_on_device(registers, controller_id): continue # Determine address (use controller's address or find free one) new_addr = address if address > 0 else _get_next_free_address(registers, value_type) # Create register based on device type if device_type == "plc": # PLC-to-PLC: check if target PLC has a controller using same id # If so, skip - repair_plc_local_registers will handle it with io="output" target_has_controller = any( c.get("id") == controller_id for c in target_device.get("controllers", []) ) if target_has_controller: # Skip - the target PLC's own controller takes precedence continue # Target PLC receives writes, so io="input" new_reg = { "address": new_addr, "count": count, "id": controller_id, "io": "input" # Target receives commands from source PLC } else: # Actuator: use physical_value (no io field needed) new_reg = { "address": new_addr, "count": count, "physical_value": controller_id } registers[value_type].append(new_reg) actions.append(RepairAction( entity=f"{device_type}s['{target_name}'].registers.{value_type}", action="created register for PLC controller", details=f"Added register '{controller_id}' for {plc_name}.controller target" )) # Process monitors: ensure target device has the register for monitor in plc.get("monitors", []): monitor_id = monitor.get("id") value_type = monitor.get("value_type") # e.g., "input_register" conn_id = monitor.get("outbound_connection_id") address = monitor.get("address", 0) count = monitor.get("count", 1) if not monitor_id or not value_type or not conn_id: continue # Skip if connection not found (could be RTU) target_ip = conn_to_ip.get(conn_id) if not target_ip: continue # Find target device if target_ip not in device_by_ip: continue device_type, target_device = device_by_ip[target_ip] target_name = target_device.get("name", "unknown") registers = target_device.get("registers", {}) # Ensure register type list exists if value_type not in registers: registers[value_type] = [] # Check if register already exists if _register_exists_on_device(registers, monitor_id): continue # Determine address (use monitor's address or find free one) new_addr = address if address > 0 else _get_next_free_address(registers, value_type) # Create register based on device type if device_type == "plc": # PLC-to-PLC: check if target PLC has a monitor using same id # If so, skip - repair_plc_local_registers will handle it with io="input" target_has_monitor = any( m.get("id") == monitor_id for m in target_device.get("monitors", []) ) if target_has_monitor: # Skip - the target PLC's own monitor takes precedence continue # Target PLC provides data, so io="output" new_reg = { "address": new_addr, "count": count, "id": monitor_id, "io": "output" # Target provides data to source PLC } else: # Sensor: use physical_value (no io field needed) new_reg = { "address": new_addr, "count": count, "physical_value": monitor_id } registers[value_type].append(new_reg) actions.append(RepairAction( entity=f"{device_type}s['{target_name}'].registers.{value_type}", action="created register for PLC monitor", details=f"Added register '{monitor_id}' for {plc_name}.monitor target" )) return config, actions