From 62a9631fe6fdc9542f54efe6914892fd6c262e3c Mon Sep 17 00:00:00 2001 From: windpacer Date: Thu, 4 Jun 2026 09:44:45 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=83=9D=EC=84=B1=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A6=BD=ED=8A=B8=20+=20map=5Fmaster=20=EB=A1=9C=EB=8D=94=20+?= =?UTF-8?q?=20=EC=8A=A4=EC=BA=94=EC=8B=9C=EA=B0=84=20=EC=B8=A1=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - build_controllers_config.py: gateway-config.json 생성 - load_map_master.py: hc900_map_master 테이블 적재 - measure_scan_time.py: HC900 스캔 시간 측정 유틸 --- scripts/build_controllers_config.py | 107 ++++++++++++++++++++++++++++ scripts/load_map_master.py | 107 ++++++++++++++++++++++++++++ test/measure_scan_time.py | 83 +++++++++++++++++++++ 3 files changed, 297 insertions(+) create mode 100644 scripts/build_controllers_config.py create mode 100644 scripts/load_map_master.py create mode 100644 test/measure_scan_time.py diff --git a/scripts/build_controllers_config.py b/scripts/build_controllers_config.py new file mode 100644 index 0000000..6ae93f0 --- /dev/null +++ b/scripts/build_controllers_config.py @@ -0,0 +1,107 @@ +#!/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}") diff --git a/scripts/load_map_master.py b/scripts/load_map_master.py new file mode 100644 index 0000000..adca7be --- /dev/null +++ b/scripts/load_map_master.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +""" +Load hc900_map_master from the per-controller register maps (register-map-c{n}.json). + +hc900_map_master is the DB table the web UI "태그 관리" (Tag Management) tab reads and +the realtime service uses to know which tags to poll (it sends ``hc900_tag`` to the +gateway's ReadTags). So ``hc900_tag`` must match the gateway's register-map tag — i.e. +the Experion point name (e.g. FICQ-6101.PV). We set ``tagname == hc900_tag`` so the +realtime_table (keyed by tagname) lines up with both. + +Usage: + python3 load_map_master.py --controller C3 --map docs/register-map-c3.json + python3 load_map_master.py --from-config config/gateway-config.json # all enabled +""" + +import re +import json +import argparse +from pathlib import Path + +import psycopg2 + +DSN = dict(host="localhost", port=5432, dbname="iiot_platform", + user="postgres", password="postgres") + +# Experion point-parameter suffix → param_type used by the UI for grouping/colours. +_PARAM_ALIAS = {"MD": "MODE"} + + +def classify(entry: dict) -> tuple[str, int | None]: + """Return (param_type, loop_no) for a register entry.""" + tag = entry["tag"] + desc = entry.get("description", "") + if desc.startswith("LOOP"): + m = re.search(r"#(\d+)", desc) + loop = int(m.group(1)) if m else None + suffix = tag.rsplit(".", 1)[1] if "." in tag else "LOOP" + return _PARAM_ALIAS.get(suffix, suffix), loop + if "Signal Tag" in desc: + return "SIG", None + if "Variable" in desc: + return "VAR", None + if "Custom" in desc: + return "RAW", None + suffix = tag.rsplit(".", 1)[1] if "." in tag else "OTHER" + return _PARAM_ALIAS.get(suffix, suffix), None + + +def load_controller(cur, controller: str, map_path: Path, active: bool) -> int: + data = json.loads(map_path.read_text(encoding="utf-8")) + cur.execute("DELETE FROM hc900_map_master WHERE controller_id = %s", (controller,)) + n = 0 + for e in data["registers"]: + param_type, loop_no = classify(e) + access = "R/W" if e.get("access") == "RW" else "R" + # Single consistent name everywhere: tagname == hc900_tag == the register tag + # (Experion ItemName, e.g. FICQ-6101.PV) — used by the gateway, map_master and + # realtime_table alike. No case conversion. + cur.execute( + """INSERT INTO hc900_map_master + (tagname, hc900_tag, modbus_addr, data_type, access, + is_active, loop_no, param_type, controller_id) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)""", + (e["tag"], e["tag"], e["addr"], e["type"], access, + active, loop_no, param_type, controller)) + n += 1 + return n + + +def main(): + p = argparse.ArgumentParser(description="Load hc900_map_master from register maps") + p.add_argument("--controller", help="controller id, e.g. C3") + p.add_argument("--map", help="register-map json for --controller") + p.add_argument("--from-config", help="gateway-config.json: load every enabled controller") + p.add_argument("--inactive", action="store_true", + help="insert rows as is_active=false (default: active)") + args = p.parse_args() + + jobs: list[tuple[str, Path]] = [] + if args.from_config: + cfg = json.loads(Path(args.from_config).read_text(encoding="utf-8")) + for c in cfg.get("controllers", []): + if c.get("enabled", True): + jobs.append((c["id"], Path(c["registerMapPath"]))) + elif args.controller and args.map: + jobs.append((args.controller, Path(args.map))) + else: + p.error("provide --from-config OR (--controller and --map)") + + conn = psycopg2.connect(**DSN) + cur = conn.cursor() + cur.execute("SET search_path TO hc900") + total = 0 + for ctrl, mp in jobs: + if not mp.exists(): + print(f" ⚠ {ctrl}: map not found: {mp} — skipping") + continue + n = load_controller(cur, ctrl, mp, not args.inactive) + total += n + print(f" {ctrl}: {n} rows ← {mp.name}") + conn.commit() + conn.close() + print(f"✓ hc900_map_master loaded: {total} rows total") + + +if __name__ == "__main__": + main() diff --git a/test/measure_scan_time.py b/test/measure_scan_time.py new file mode 100644 index 0000000..d16a298 --- /dev/null +++ b/test/measure_scan_time.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Measure real scan time against C3 (192.168.0.240) using EXACT gateway batching logic.""" +import json +import time +import sys +from pathlib import Path + +from pymodbus.client import ModbusTcpClient + +REG_MAP = Path(__file__).parent.parent / "docs" / "register-map-c3.json" +HOST = "192.168.0.240" +PORT = 502 +RUNS = 5 + +def load_regs(): + with open(REG_MAP) as f: + return json.load(f)["registers"] + +def build_batches(regs): + """ + EXACT gateway logic (gateway.cpp:123-173): + 1. Sort registers by address + 2. For each batch: start_addr = first register's addr + 3. Collect all registers where: addr + count - batch_start <= 120 + 4. Read count = last.addr + last.count - batch_start + """ + sorted_regs = sorted(regs, key=lambda r: r["addr"]) + batches = [] + MAX_BATCH = 120 + i = 0 + while i < len(sorted_regs): + batch_start = sorted_regs[i]["addr"] + j = i + while j < len(sorted_regs): + e = sorted_regs[j] + if e["addr"] + e["count"] - batch_start > MAX_BATCH: + break + j += 1 + # j-1 is the last register in this batch + last = sorted_regs[j - 1] + read_count = last["addr"] + last["count"] - batch_start + batches.append((batch_start, read_count)) + i = j + return batches + +def main(): + regs = load_regs() + batches = build_batches(regs) + + total_regs = len(regs) + total_words = sum(r["count"] for r in regs) + print(f"Registers: {total_regs}, Total words: {total_words}, Batches: {len(batches)}") + print(f"Target: {HOST}:{PORT}") + print() + + client = ModbusTcpClient(HOST, port=PORT, timeout=5) + if not client.connect(): + print("ERROR: connection failed"); sys.exit(1) + + times = [] + for run in range(1, RUNS + 1): + t0 = time.perf_counter() + errors = 0 + for i, (addr, count) in enumerate(batches): + resp = client.read_holding_registers(addr, count=count) + if resp.isError(): + errors += 1 + + elapsed = (time.perf_counter() - t0) * 1000 + times.append(elapsed) + print(f"run{run}: {elapsed:.1f} ms (batches {len(batches)}, errors {errors})") + + client.close() + + times.sort() + print(f"\n---") + print(f"Min: {times[0]:.1f} ms") + print(f"Max: {times[-1]:.1f} ms") + print(f"Avg: {sum(times)/len(times):.1f} ms") + print(f"Median: {times[len(times)//2]:.1f} ms") + +if __name__ == "__main__": + main()