Initial commit: HC900 Crawler

Honeywell HC900을 Modbus TCP로 직접 폴링 → gRPC → C# 크롤러 → PostgreSQL.
기존 Experion OPC UA 데이터 경로를 HC900 직접 통신으로 대체.

- industrial-comm/cpp: C++ Modbus 게이트웨이 (gRPC 서버)
- src: C# .NET 8 ASP.NET Core 크롤러 + 웹 UI (3-Layer)
- mcp-server: Python FastMCP (RAG/NL2SQL/P&ID)
- 다중 컨트롤러(N-Controller) 지원

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
windpacer
2026-06-03 20:28:14 +09:00
commit 16fc7a2598
325 changed files with 126583 additions and 0 deletions

101
test/modbus_sim.py Normal file
View File

@@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""
HC900 Modbus TCP Simulator
Loads register-map.json and serves dummy values via pymodbus.
"""
import json
import struct
import logging
from pymodbus.server import StartTcpServer
from pymodbus.datastore import ModbusSlaveContext, ModbusServerContext
from pymodbus.device import ModbusDeviceIdentification
logging.basicConfig(level=logging.INFO, format="%(message)s")
REG_MAP_PATH = "docs/register-map.json"
def build_datastore(map_path: str) -> ModbusSlaveContext:
with open(map_path) as f:
data = json.load(f)
# store up to 0x8000 (32768) holding registers
store = [0] * (0x8000)
seed = 0.0
for r in data["registers"]:
a = r["addr"]
t = r["type"]
tag = r["tag"]
# generate dummy value based on tag name
seed += 100.0
if "MODE" in tag or "FUZZY" in tag or "EN" in tag or "STATE" in tag or "_REQ" in tag:
val = 1 # integer status
elif "SP_LO" in tag or "OP_LO" in tag:
val = 0.0
elif "SP_HI" in tag or "OP_HI" in tag:
val = 100.0
elif "PV" in tag or "SP" in tag or "OP" in tag:
val = seed * 0.01
elif "DIR" in tag:
val = 1.0
elif "LOOP_STATUS" in tag:
val = 0x8000
elif "PV_LO" in tag:
val = 0.0
elif "PV_HI" in tag:
val = 150.0
elif "DEV" in tag:
val = 2.5
elif "ALM" in tag:
val = 80.0
elif "LSP" in tag:
val = 50.0
elif "GAIN" in tag:
val = 1.0
elif "RESET" in tag:
val = 10.0
elif "RATE" in tag:
val = 0.0
elif "RSP" in tag:
val = -1.0 # none
elif "TRIP" in tag or "ESD" in tag or "IL" in tag:
val = 0.0
elif "_HS" in tag:
val = 0.0
else:
val = 42.0
if t == "float32":
raw = struct.pack(">f", val) # FP_B = big-endian IEEE 754
store[a] = struct.unpack(">H", raw[:2])[0]
store[a + 1] = struct.unpack(">H", raw[2:])[0]
elif t == "uint16":
store[a] = int(val) & 0xFFFF
ctx = ModbusSlaveContext(
zero_mode=True, # 0-based addressing (hw addr = array index)
di=None, co=None, ir=None,
hr=store,
)
print(f"Datastore built: {len(data['registers'])} registers, {len(store)} words")
return ctx
if __name__ == "__main__":
import sys, os
os.chdir(os.path.join(os.path.dirname(__file__), ".."))
ctx = build_datastore(REG_MAP_PATH)
identity = ModbusDeviceIdentification()
identity.VendorName = "HC900 Sim"
identity.ProductCode = "HC900"
identity.ModelName = "HC900-C70 Simulator"
identity.MajorMinorRevision = "4.4x"
print("Starting HC900 Modbus TCP simulator on 0.0.0.0:5020 ...")
print(" (use port 5020 to avoid root)")
StartTcpServer(
context=ModbusServerContext(slaves=ctx, single=True),
identity=identity,
address=("0.0.0.0", 5020),
)

143
test/read_tags.py Normal file
View File

@@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""
HC900 Modbus TCP Tag Reader
Usage:
# read specific tags
/tmp/hc900_venv/bin/python3 test/read_tags.py FICQ3101.PV FICQ3101.SP FICQ3101.MODE LT3101
# read all tags (will be slow)
/tmp/hc900_venv/bin/python3 test/read_tags.py --all
# connect to simulator instead
/tmp/hc900_venv/bin/python3 test/read_tags.py --port 5020 FICQ3101.PV
"""
import json
import struct
import argparse
import sys
from pathlib import Path
from pymodbus.client import ModbusTcpClient
REG_MAP_PATH = Path(__file__).parent.parent / "docs" / "register-map.json"
DEFAULT_HOST = "192.168.0.240"
DEFAULT_PORT = 502
def load_register_map(path) -> dict:
with open(path) as f:
return json.load(f)
def find_regs(map_data, tag_names: list[str]):
"""Find register entries matching the given tag names (prefix match allowed)."""
regs = map_data["registers"]
hits = []
for name in tag_names:
name = name.upper()
matched = [r for r in regs if r["tag"].upper() == name]
if not matched:
# try prefix match
matched = [r for r in regs if r["tag"].upper().startswith(name)]
if matched:
hits.extend(matched)
else:
print(f" ! tag '{name}' not found in register map", file=sys.stderr)
return hits
def read_registers(client: ModbusTcpClient, regs: list[dict]) -> list[dict]:
"""Read values from Modbus TCP and decode them in-place."""
for r in regs:
addr = r["addr"]
count = r["count"]
resp = client.read_holding_registers(addr, count=count)
if resp.isError():
r["value"] = f"<error: {resp}>"
r["raw"] = None
continue
raw = list(resp.registers)
r["raw"] = raw
if r["type"] == "float32":
packed = struct.pack(">HH", raw[0], raw[1])
r["value"] = struct.unpack(">f", packed)[0]
elif r["type"] == "uint16":
r["value"] = raw[0]
else:
r["value"] = raw
return regs
def print_results(regs: list[dict]):
header = f"{'Tag':<30s} {'Addr':>6s} {'Type':<10s} {'Value':>12s} {'Raw':>16s} {'Description'}"
print(header)
print("-" * len(header))
for r in regs:
val = r.get("value", "")
raw = r.get("raw", "")
if isinstance(val, float):
val_str = f"{val:.3f}"
else:
val_str = str(val)
# raw hex
if raw and isinstance(raw, list):
raw_str = " ".join(f"0x{v:04X}" for v in raw)
else:
raw_str = ""
desc = r.get("description", "")
print(f"{r['tag']:<30s} 0x{r['addr']:04X} {r['type']:<10s} {val_str:>12s} {raw_str:>16s} {desc}")
def main():
parser = argparse.ArgumentParser(description="Read HC900 Modbus TCP tags")
parser.add_argument("tags", nargs="*", help="Tag names to read (e.g. FICQ3101.PV)")
parser.add_argument("--all", action="store_true", help="Read ALL registers (slow!)")
parser.add_argument("--host", default=DEFAULT_HOST)
parser.add_argument("--port", type=int, default=DEFAULT_PORT)
parser.add_argument("--limit", type=int, default=20, help="Max tags to display")
args = parser.parse_args()
if not args.tags and not args.all:
parser.print_help()
sys.exit(1)
map_data = load_register_map(REG_MAP_PATH)
if args.all:
regs = map_data["registers"]
else:
regs = find_regs(map_data, args.tags)
if not regs:
print("No registers matched.")
sys.exit(1)
print(f"Connecting to {args.host}:{args.port} ...")
client = ModbusTcpClient(args.host, port=args.port, timeout=5)
client.connect()
if not client.connected:
print("ERROR: connection failed")
sys.exit(1)
try:
read_registers(client, regs)
finally:
client.close()
# deduplicate by tag name
seen = set()
unique = []
for r in regs:
if r["tag"] not in seen:
seen.add(r["tag"])
unique.append(r)
print(f"\nRead {len(unique)} tags (showing first {min(args.limit, len(unique))}):\n")
print_results(unique[:args.limit])
if __name__ == "__main__":
main()