901 lines
33 KiB
Python
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
|