#!/usr/bin/env python3 """ Build the C# gateway process config (config/gateway-config.json) from the Experion controller export (Controller.csv). The crawler's ControllerProcessManager reads this file and launches one ``hc900_gateway`` process per enabled controller: hc900_gateway Each controller therefore gets its own gRPC port and its own register map — the same SignalTag name may exist on several controllers (peer comms) without any collision, because the namespaces are isolated per process. Controller.csv has several sections (UniversalModbus, OPC UA, Modicon); we keep only the HC900 ``UniversalModbusController`` rows and use IPAddress1 (the primary 192.168.0.x net; IPAddress2 is the redundant link). The existing file's ``shared`` block and any per-controller overrides (grpcPort, enabled) are preserved when --merge is given. Usage: python3 build_controllers_config.py --csv docs/Controller.csv \ -o config/gateway-config.json --merge """ import csv import json import argparse from pathlib import Path REPO = Path(__file__).resolve().parent.parent DEFAULT_SHARED = { "binaryPath": str(REPO / "industrial-comm/cpp/build/hc900_gateway"), "ldLibraryPath": "/tmp/grpc_local/usr/lib/aarch64-linux-gnu:/tmp/absl_local/usr/lib/aarch64-linux-gnu", "logDir": "/tmp", } def parse_controllers(csv_path: Path): """Yield (name, ip) for each HC900 UniversalModbusController in Controller.csv.""" header = None with open(csv_path, encoding="utf-8-sig") as f: for row in csv.reader(f): if not row or all(not c.strip() for c in row): continue if row[0].strip() == "ItemName": # section header header = {n.strip(): i for i, n in enumerate(row)} continue if header is None: continue if row[header.get("Class", 1)].strip() != "UniversalModbusController": continue name = row[header["ItemName"]].strip() ip = row[header["IPAddress1"]].strip() if "IPAddress1" in header else "" if name and ip: yield name, ip def build(csv_path: Path, map_dir: str, map_prefix: str, poll_ms: int, base_grpc_port: int, existing: dict | None) -> dict: shared = (existing or {}).get("shared") or dict(DEFAULT_SHARED) # index existing controllers by id to preserve per-controller overrides prev = {c.get("id"): c for c in (existing or {}).get("controllers", [])} controllers = [] for i, (name, ip) in enumerate(parse_controllers(csv_path)): old = prev.get(name, {}) controllers.append({ "id": name, "name": old.get("name") or f"HC900 {name} Controller", "controllerIp": ip, "controllerPort": old.get("controllerPort", 502), # grpcPort is assigned sequentially to guarantee uniqueness across # the per-controller processes (one bind per port). "grpcPort": base_grpc_port + i, "pollIntervalMs": old.get("pollIntervalMs", poll_ms), "registerMapPath": str(REPO / map_dir / f"{map_prefix}{name.lower()}.json"), "enabled": old.get("enabled", True), }) return {"shared": shared, "controllers": controllers} if __name__ == "__main__": p = argparse.ArgumentParser(description="Build config/gateway-config.json from Controller.csv") p.add_argument("--csv", default="docs/Controller.csv") p.add_argument("--map-dir", default="docs") p.add_argument("--map-prefix", default="register-map-") p.add_argument("--poll-ms", type=int, default=500) p.add_argument("--base-grpc-port", type=int, default=50051) p.add_argument("-o", "--output", default="config/gateway-config.json") p.add_argument("--merge", action="store_true", help="preserve shared block and per-controller overrides from the existing file") args = p.parse_args() out = Path(args.output) existing = None if args.merge and out.exists(): existing = json.loads(out.read_text(encoding="utf-8")) cfg = build(Path(args.csv), args.map_dir, args.map_prefix, args.poll_ms, args.base_grpc_port, existing) out.parent.mkdir(parents=True, exist_ok=True) out.write_text(json.dumps(cfg, indent=2, ensure_ascii=False), encoding="utf-8") print(f"✓ Wrote {out} ({len(cfg['controllers'])} controllers)") for c in cfg["controllers"]: en = "" if c["enabled"] else " (disabled)" print(f" {c['id']:4s} {c['controllerIp']:16s} grpc:{c['grpcPort']} → {Path(c['registerMapPath']).name}{en}")