ics-simlab-config-gen-claude/tools/repair_config.py

901 lines
33 KiB
Python

#!/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