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