513 lines
19 KiB
Python
513 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Enrich configuration.json with PLC monitors and outbound connections to sensors.
|
|
|
|
This tool analyzes the configuration and:
|
|
1. For each PLC input register, finds the corresponding sensor
|
|
2. Adds outbound_connections from PLC to sensor IP
|
|
3. Adds monitors to poll sensor values
|
|
4. For each HMI monitor, derives value_type/address/count from target PLC registers
|
|
|
|
Usage:
|
|
python3 -m tools.enrich_config --config outputs/configuration.json --out outputs/configuration_enriched.json
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional, Set, Tuple
|
|
|
|
|
|
def find_register_mapping(device: Dict, register_id: str) -> Optional[Tuple[str, int, int]]:
|
|
"""
|
|
Search device registers for a matching id and return (value_type, address, count).
|
|
|
|
Args:
|
|
device: Device dict with "registers" section (PLC, sensor, actuator)
|
|
register_id: The register id to find
|
|
|
|
Returns:
|
|
(value_type, address, count) if found, None otherwise
|
|
"""
|
|
registers = device.get("registers", {})
|
|
for reg_type in ["coil", "discrete_input", "holding_register", "input_register"]:
|
|
for reg in registers.get(reg_type, []):
|
|
# Match by id or physical_value
|
|
if reg.get("id") == register_id or reg.get("physical_value") == register_id:
|
|
return reg_type, reg.get("address", 1), reg.get("count", 1)
|
|
return None
|
|
|
|
|
|
def find_sensor_for_pv(sensors: List[Dict], actuators: List[Dict], pv_name: str) -> Optional[Dict]:
|
|
"""
|
|
Find the sensor that exposes a physical_value matching pv_name.
|
|
Returns sensor dict or None.
|
|
"""
|
|
# Check sensors
|
|
for sensor in sensors:
|
|
for reg_type in ["holding_register", "input_register", "discrete_input", "coil"]:
|
|
for reg in sensor.get("registers", {}).get(reg_type, []):
|
|
if reg.get("physical_value") == pv_name:
|
|
return sensor
|
|
return None
|
|
|
|
|
|
def find_actuator_for_pv(actuators: List[Dict], pv_name: str) -> Optional[Dict]:
|
|
"""
|
|
Find the actuator that has a physical_value matching pv_name.
|
|
"""
|
|
for actuator in actuators:
|
|
for pv in actuator.get("physical_values", []):
|
|
if pv.get("name") == pv_name:
|
|
return actuator
|
|
return None
|
|
|
|
|
|
def get_sensor_register_info(sensor: Dict, pv_name: str) -> Tuple[Optional[str], int, int]:
|
|
"""
|
|
Get register type and address for a physical_value in a sensor.
|
|
Returns (value_type, address, count) or (None, 0, 0) if not found.
|
|
"""
|
|
for reg_type in ["holding_register", "input_register", "discrete_input", "coil"]:
|
|
for reg in sensor.get("registers", {}).get(reg_type, []):
|
|
if reg.get("physical_value") == pv_name:
|
|
return reg_type, reg.get("address", 1), reg.get("count", 1)
|
|
return None, 0, 0
|
|
|
|
|
|
def get_plc_input_registers(plc: Dict) -> List[Tuple[str, str]]:
|
|
"""
|
|
Get list of (register_id, register_type) for all io:"input" registers in PLC.
|
|
"""
|
|
inputs = []
|
|
registers = plc.get("registers", {})
|
|
|
|
for reg_type in ["holding_register", "input_register", "discrete_input", "coil"]:
|
|
for reg in registers.get(reg_type, []):
|
|
if reg.get("io") == "input":
|
|
reg_id = reg.get("id")
|
|
if reg_id:
|
|
inputs.append((reg_id, reg_type))
|
|
|
|
return inputs
|
|
|
|
|
|
def get_plc_output_registers(plc: Dict) -> List[Tuple[str, str]]:
|
|
"""
|
|
Get list of (register_id, register_type) for all io:"output" registers in PLC.
|
|
"""
|
|
outputs = []
|
|
registers = plc.get("registers", {})
|
|
|
|
for reg_type in ["holding_register", "input_register", "discrete_input", "coil"]:
|
|
for reg in registers.get(reg_type, []):
|
|
if reg.get("io") == "output":
|
|
reg_id = reg.get("id")
|
|
if reg_id:
|
|
outputs.append((reg_id, reg_type))
|
|
|
|
return outputs
|
|
|
|
|
|
def map_plc_input_to_hil_output(plc_input_id: str, hils: List[Dict]) -> Optional[str]:
|
|
"""
|
|
Map a PLC input register name to a HIL output physical_value name.
|
|
|
|
Convention: PLC reads "water_tank_level" -> HIL outputs "water_tank_level_output"
|
|
"""
|
|
# Direct mapping patterns
|
|
patterns = [
|
|
(plc_input_id, f"{plc_input_id}_output"), # water_tank_level -> water_tank_level_output
|
|
(plc_input_id, plc_input_id), # exact match
|
|
]
|
|
|
|
for hil in hils:
|
|
for pv in hil.get("physical_values", []):
|
|
pv_name = pv.get("name", "")
|
|
pv_io = pv.get("io", "")
|
|
if pv_io == "output":
|
|
for _, mapped_name in patterns:
|
|
if pv_name == mapped_name:
|
|
return pv_name
|
|
# Also check if PLC input name is contained in HIL output name
|
|
if plc_input_id in pv_name and "output" in pv_name:
|
|
return pv_name
|
|
|
|
return None
|
|
|
|
|
|
def find_plc_input_matching_output(plcs: List[Dict], output_id: str, source_plc_name: str) -> Optional[Tuple[Dict, str, int]]:
|
|
"""
|
|
Find a PLC that has an input register matching the given output_id.
|
|
Returns (target_plc, register_type, address) or None.
|
|
"""
|
|
for plc in plcs:
|
|
if plc.get("name") == source_plc_name:
|
|
continue # Skip self
|
|
|
|
registers = plc.get("registers", {})
|
|
for reg_type in ["coil", "discrete_input", "holding_register", "input_register"]:
|
|
for reg in registers.get(reg_type, []):
|
|
if reg.get("io") == "input" and reg.get("id") == output_id:
|
|
return plc, reg_type, reg.get("address", 1)
|
|
|
|
return None
|
|
|
|
|
|
def enrich_plc_connections(config: Dict) -> Dict:
|
|
"""
|
|
Enrich configuration with PLC outbound_connections and monitors for sensor inputs.
|
|
|
|
For each PLC input register:
|
|
1. Find the HIL output it corresponds to
|
|
2. Find the sensor that exposes that HIL output
|
|
3. Add outbound_connection to that sensor
|
|
4. Add monitor entry to poll the sensor
|
|
"""
|
|
plcs = config.get("plcs", [])
|
|
hils = config.get("hils", [])
|
|
sensors = config.get("sensors", [])
|
|
actuators = config.get("actuators", [])
|
|
|
|
for plc in plcs:
|
|
plc_name = plc.get("name", "plc")
|
|
existing_outbound = plc.get("outbound_connections", [])
|
|
existing_monitors = plc.get("monitors", [])
|
|
|
|
# Track which connections/monitors we've added
|
|
existing_conn_ids = {c.get("id") for c in existing_outbound}
|
|
existing_monitor_ids = {m.get("id") for m in existing_monitors}
|
|
|
|
# Get PLC inputs and outputs
|
|
plc_inputs = get_plc_input_registers(plc)
|
|
plc_outputs = get_plc_output_registers(plc)
|
|
|
|
# Process each PLC input - find sensor to read from
|
|
for input_id, input_reg_type in plc_inputs:
|
|
# Skip if monitor already exists
|
|
if input_id in existing_monitor_ids:
|
|
continue
|
|
|
|
# Map PLC input to HIL output
|
|
hil_output = map_plc_input_to_hil_output(input_id, hils)
|
|
if not hil_output:
|
|
continue
|
|
|
|
# Find sensor that exposes this HIL output
|
|
sensor = find_sensor_for_pv(sensors, actuators, hil_output)
|
|
if not sensor:
|
|
continue
|
|
|
|
sensor_name = sensor.get("name", "sensor")
|
|
sensor_ip = sensor.get("network", {}).get("ip")
|
|
if not sensor_ip:
|
|
continue
|
|
|
|
# Get sensor register info
|
|
value_type, address, count = get_sensor_register_info(sensor, hil_output)
|
|
if not value_type:
|
|
continue
|
|
|
|
# Create connection ID
|
|
conn_id = f"to_{sensor_name}"
|
|
|
|
# Add outbound connection if not exists
|
|
if conn_id not in existing_conn_ids:
|
|
new_conn = {
|
|
"type": "tcp",
|
|
"ip": sensor_ip,
|
|
"port": 502,
|
|
"id": conn_id
|
|
}
|
|
existing_outbound.append(new_conn)
|
|
existing_conn_ids.add(conn_id)
|
|
|
|
# Add monitor
|
|
new_monitor = {
|
|
"outbound_connection_id": conn_id,
|
|
"id": input_id,
|
|
"value_type": value_type,
|
|
"address": address,
|
|
"count": count,
|
|
"interval": 0.2,
|
|
"slave_id": 1
|
|
}
|
|
existing_monitors.append(new_monitor)
|
|
existing_monitor_ids.add(input_id)
|
|
|
|
# Process each PLC output - find actuator to write to
|
|
for output_id, output_reg_type in plc_outputs:
|
|
# Map output to actuator physical_value name
|
|
# Convention: PLC output "tank_input_valve" -> actuator pv "tank_input_valve_input"
|
|
actuator_pv_name = f"{output_id}_input"
|
|
|
|
actuator = find_actuator_for_pv(actuators, actuator_pv_name)
|
|
if not actuator:
|
|
continue
|
|
|
|
actuator_name = actuator.get("name", "actuator")
|
|
actuator_ip = actuator.get("network", {}).get("ip")
|
|
if not actuator_ip:
|
|
continue
|
|
|
|
# Create connection ID
|
|
conn_id = f"to_{actuator_name}"
|
|
|
|
# Add outbound connection if not exists
|
|
if conn_id not in existing_conn_ids:
|
|
new_conn = {
|
|
"type": "tcp",
|
|
"ip": actuator_ip,
|
|
"port": 502,
|
|
"id": conn_id
|
|
}
|
|
existing_outbound.append(new_conn)
|
|
existing_conn_ids.add(conn_id)
|
|
|
|
# Check if controller already exists for this output
|
|
existing_controllers = plc.get("controllers", [])
|
|
existing_controller_ids = {c.get("id") for c in existing_controllers}
|
|
|
|
if output_id not in existing_controller_ids:
|
|
# Get actuator register info
|
|
actuator_regs = actuator.get("registers", {})
|
|
for reg_type in ["coil", "holding_register"]:
|
|
for reg in actuator_regs.get(reg_type, []):
|
|
if reg.get("physical_value") == actuator_pv_name:
|
|
new_controller = {
|
|
"outbound_connection_id": conn_id,
|
|
"id": output_id,
|
|
"value_type": reg_type,
|
|
"address": reg.get("address", 1),
|
|
"count": reg.get("count", 1),
|
|
"interval": 0.5,
|
|
"slave_id": 1
|
|
}
|
|
existing_controllers.append(new_controller)
|
|
existing_controller_ids.add(output_id)
|
|
break
|
|
|
|
plc["controllers"] = existing_controllers
|
|
|
|
# Process PLC outputs that should go to other PLCs (PLC-to-PLC communication)
|
|
for output_id, output_reg_type in plc_outputs:
|
|
# Check if this output should be sent to another PLC
|
|
result = find_plc_input_matching_output(plcs, output_id, plc_name)
|
|
if not result:
|
|
continue
|
|
|
|
target_plc, target_reg_type, target_address = result
|
|
target_plc_name = target_plc.get("name", "plc")
|
|
target_plc_ip = target_plc.get("network", {}).get("ip")
|
|
if not target_plc_ip:
|
|
continue
|
|
|
|
# Create connection ID
|
|
conn_id = f"to_{target_plc_name}"
|
|
|
|
# Add outbound connection if not exists
|
|
if conn_id not in existing_conn_ids:
|
|
new_conn = {
|
|
"type": "tcp",
|
|
"ip": target_plc_ip,
|
|
"port": 502,
|
|
"id": conn_id
|
|
}
|
|
existing_outbound.append(new_conn)
|
|
existing_conn_ids.add(conn_id)
|
|
|
|
# Check if controller already exists
|
|
existing_controllers = plc.get("controllers", [])
|
|
existing_controller_ids = {c.get("id") for c in existing_controllers}
|
|
|
|
if output_id not in existing_controller_ids:
|
|
new_controller = {
|
|
"outbound_connection_id": conn_id,
|
|
"id": output_id,
|
|
"value_type": target_reg_type,
|
|
"address": target_address,
|
|
"count": 1,
|
|
"interval": 0.2,
|
|
"slave_id": 1
|
|
}
|
|
existing_controllers.append(new_controller)
|
|
existing_controller_ids.add(output_id)
|
|
|
|
plc["controllers"] = existing_controllers
|
|
|
|
# Update PLC
|
|
plc["outbound_connections"] = existing_outbound
|
|
plc["monitors"] = existing_monitors
|
|
|
|
return config
|
|
|
|
|
|
def enrich_hmi_connections(config: Dict) -> Dict:
|
|
"""
|
|
Fix HMI monitors/controllers by deriving value_type/address/count from target PLC registers.
|
|
|
|
For each HMI monitor that polls a PLC:
|
|
1. Find the target PLC from outbound_connection
|
|
2. Look up the register by id in the PLC's registers
|
|
3. Fix value_type, address, count to match the PLC's actual register
|
|
"""
|
|
hmis = config.get("hmis", [])
|
|
plcs = config.get("plcs", [])
|
|
|
|
# Build PLC lookup by IP
|
|
plc_by_ip: Dict[str, Dict] = {}
|
|
for plc in plcs:
|
|
plc_ip = plc.get("network", {}).get("ip")
|
|
if plc_ip:
|
|
plc_by_ip[plc_ip] = plc
|
|
|
|
for hmi in hmis:
|
|
hmi_name = hmi.get("name", "hmi")
|
|
outbound_conns = hmi.get("outbound_connections", [])
|
|
|
|
# Build connection id -> target IP mapping
|
|
conn_to_ip: Dict[str, str] = {}
|
|
for conn in outbound_conns:
|
|
conn_id = conn.get("id")
|
|
conn_ip = conn.get("ip")
|
|
if conn_id and conn_ip:
|
|
conn_to_ip[conn_id] = conn_ip
|
|
|
|
# Fix monitors
|
|
monitors = hmi.get("monitors", [])
|
|
for monitor in monitors:
|
|
monitor_id = monitor.get("id")
|
|
conn_id = monitor.get("outbound_connection_id")
|
|
if not monitor_id or not conn_id:
|
|
continue
|
|
|
|
# Find target PLC
|
|
target_ip = conn_to_ip.get(conn_id)
|
|
if not target_ip:
|
|
print(f" WARNING: {hmi_name} monitor '{monitor_id}': outbound_connection '{conn_id}' not found")
|
|
continue
|
|
|
|
target_plc = plc_by_ip.get(target_ip)
|
|
if not target_plc:
|
|
# Target might be a sensor, not a PLC - skip silently
|
|
continue
|
|
|
|
target_plc_name = target_plc.get("name", "plc")
|
|
|
|
# Look up register in target PLC
|
|
mapping = find_register_mapping(target_plc, monitor_id)
|
|
if mapping:
|
|
value_type, address, count = mapping
|
|
old_type = monitor.get("value_type")
|
|
old_addr = monitor.get("address")
|
|
if old_type != value_type or old_addr != address:
|
|
print(f" FIX: {hmi_name} monitor '{monitor_id}': {old_type}@{old_addr} -> {value_type}@{address} (from {target_plc_name})")
|
|
monitor["value_type"] = value_type
|
|
monitor["address"] = address
|
|
monitor["count"] = count
|
|
else:
|
|
print(f" WARNING: {hmi_name} monitor '{monitor_id}': register not found in {target_plc_name}, keeping current config")
|
|
|
|
# Fix controllers
|
|
controllers = hmi.get("controllers", [])
|
|
for controller in controllers:
|
|
ctrl_id = controller.get("id")
|
|
conn_id = controller.get("outbound_connection_id")
|
|
if not ctrl_id or not conn_id:
|
|
continue
|
|
|
|
# Find target PLC
|
|
target_ip = conn_to_ip.get(conn_id)
|
|
if not target_ip:
|
|
print(f" WARNING: {hmi_name} controller '{ctrl_id}': outbound_connection '{conn_id}' not found")
|
|
continue
|
|
|
|
target_plc = plc_by_ip.get(target_ip)
|
|
if not target_plc:
|
|
continue
|
|
|
|
target_plc_name = target_plc.get("name", "plc")
|
|
|
|
# Look up register in target PLC
|
|
mapping = find_register_mapping(target_plc, ctrl_id)
|
|
if mapping:
|
|
value_type, address, count = mapping
|
|
old_type = controller.get("value_type")
|
|
old_addr = controller.get("address")
|
|
if old_type != value_type or old_addr != address:
|
|
print(f" FIX: {hmi_name} controller '{ctrl_id}': {old_type}@{old_addr} -> {value_type}@{address} (from {target_plc_name})")
|
|
controller["value_type"] = value_type
|
|
controller["address"] = address
|
|
controller["count"] = count
|
|
else:
|
|
print(f" WARNING: {hmi_name} controller '{ctrl_id}': register not found in {target_plc_name}, keeping current config")
|
|
|
|
return config
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(
|
|
description="Enrich configuration.json with PLC monitors and sensor connections"
|
|
)
|
|
parser.add_argument(
|
|
"--config",
|
|
required=True,
|
|
help="Input configuration.json path"
|
|
)
|
|
parser.add_argument(
|
|
"--out",
|
|
required=True,
|
|
help="Output enriched configuration.json path"
|
|
)
|
|
parser.add_argument(
|
|
"--overwrite",
|
|
action="store_true",
|
|
help="Overwrite output file if exists"
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
config_path = Path(args.config)
|
|
out_path = Path(args.out)
|
|
|
|
if not config_path.exists():
|
|
raise SystemExit(f"ERROR: Config file not found: {config_path}")
|
|
|
|
if out_path.exists() and not args.overwrite:
|
|
raise SystemExit(f"ERROR: Output file exists: {out_path} (use --overwrite)")
|
|
|
|
# Load config
|
|
config = json.loads(config_path.read_text(encoding="utf-8"))
|
|
|
|
# Enrich PLCs (monitors to sensors, controllers to actuators)
|
|
print("Enriching PLC connections...")
|
|
enriched = enrich_plc_connections(config)
|
|
|
|
# Fix HMI monitors/controllers (derive from PLC register maps)
|
|
print("Fixing HMI monitors/controllers...")
|
|
enriched = enrich_hmi_connections(enriched)
|
|
|
|
# Write
|
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
out_path.write_text(json.dumps(enriched, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
|
|
print(f"\nEnriched configuration written to: {out_path}")
|
|
|
|
# Summary
|
|
print("\nSummary:")
|
|
for plc in enriched.get("plcs", []):
|
|
plc_name = plc.get("name", "plc")
|
|
n_conn = len(plc.get("outbound_connections", []))
|
|
n_mon = len(plc.get("monitors", []))
|
|
n_ctrl = len(plc.get("controllers", []))
|
|
print(f" {plc_name}: {n_conn} outbound_connections, {n_mon} monitors, {n_ctrl} controllers")
|
|
|
|
for hmi in enriched.get("hmis", []):
|
|
hmi_name = hmi.get("name", "hmi")
|
|
n_mon = len(hmi.get("monitors", []))
|
|
n_ctrl = len(hmi.get("controllers", []))
|
|
print(f" {hmi_name}: {n_mon} monitors, {n_ctrl} controllers")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|