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:
101
test/modbus_sim.py
Normal file
101
test/modbus_sim.py
Normal 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
143
test/read_tags.py
Normal 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()
|
||||
Reference in New Issue
Block a user