feat: 컨트롤러 설정 생성 스크립트 + map_master 로더 + 스캔시간 측정
- build_controllers_config.py: gateway-config.json 생성 - load_map_master.py: hc900_map_master 테이블 적재 - measure_scan_time.py: HC900 스캔 시간 측정 유틸
This commit is contained in:
107
scripts/build_controllers_config.py
Normal file
107
scripts/build_controllers_config.py
Normal file
@@ -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 <controllerIp> <registerMapPath> <pollIntervalMs> <grpcPort> <controllerPort>
|
||||
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}")
|
||||
107
scripts/load_map_master.py
Normal file
107
scripts/load_map_master.py
Normal file
@@ -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()
|
||||
83
test/measure_scan_time.py
Normal file
83
test/measure_scan_time.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user