Add Phase 2 MHI Modbus register-map profile, xlsx importer, and a
standalone connection test script Register map derived and validated from two real Intesis MAPS signals exports (2-unit and 11-unit) taken from the physically installed gateway. The 11-unit export overturned an earlier assumption from the config screen's "Fixed" addressing label: register slots are dense in config/commission order, not fixed-per-IU with gaps — so a unit's register base must come from the imported map (or be entered manually), never computed from room number or SuperLink IU address. - drivers/mhi-profiles/intesis-mhi-modbus.js: register map + encodings for the confirmed gateway (TCP, port 502, slave 1, single-slave mode), documenting the corrected slot-vs-IU distinction - lib/mhi-xlsx-import.js: parses an Intesis MAPS signals export into a structured per-unit register map (0 unmatched rows against both real samples) — this becomes the authoritative Phase 2 source of truth, with the static profile only a pre-first-export fallback - scripts/test-modbus-connection.py: dependency-free Modbus TCP test (validated against a local loopback mock server) to resolve the one remaining open question — the address-base convention — directly from the Proxmox host, which already sits on the trusted admin VLAN - docs/: both real MAPS export samples, preserved as provenance Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
276c04f8c6
commit
a9b5703c57
7 changed files with 431 additions and 1 deletions
105
backend/scripts/test-modbus-connection.py
Normal file
105
backend/scripts/test-modbus-connection.py
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Standalone Modbus TCP connectivity/address-base test for the Intesis MHI
|
||||
SuperLink -> Modbus gateway. Pure standard library (socket + struct) — no
|
||||
pip installs needed, so it can run directly on the Proxmox host shell,
|
||||
which already has a trusted network link on the admin VLAN.
|
||||
|
||||
This is NOT a throwaway script — it implements the same raw Modbus TCP
|
||||
request/response framing the real hvac driver (Phase 2) will use, so what
|
||||
it proves here carries over directly. See hvac/backend/src/lib/drivers/
|
||||
mhi-profiles/intesis-mhi-modbus.js for the register map this is testing.
|
||||
|
||||
Usage:
|
||||
python3 test-modbus-connection.py <gateway-ip> [port] [slave-id]
|
||||
|
||||
What it does:
|
||||
1. Reads the gateway's own "Gateway Communication Status" register
|
||||
(global block, address 2995) as a basic reachability/framing check
|
||||
(expect 0 = ok).
|
||||
2. Reads unit 1's Setpoint and Room Temperature at TWO candidate
|
||||
addresses each (the MAPS-exported address, and that address minus
|
||||
one) to resolve the one open question the MAPS export/config screen
|
||||
can't answer: whether the exported "Address" column is already the
|
||||
correct 0-based Modbus wire address, or a 1-based register number
|
||||
needing -1. Setpoint/room-temp are known to decode as a signed
|
||||
value x10 in the range 16.0-30.0 C when read correctly — whichever
|
||||
candidate decodes into that range is the right convention.
|
||||
"""
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
|
||||
def read_holding_registers(sock, slave_id, address, quantity, transaction_id=1):
|
||||
"""Send a Modbus TCP (MBAP + FC03) request, return the raw register values."""
|
||||
pdu = struct.pack('>BHH', 0x03, address, quantity)
|
||||
mbap = struct.pack('>HHHB', transaction_id, 0x0000, len(pdu) + 1, slave_id)
|
||||
sock.sendall(mbap + pdu)
|
||||
|
||||
resp = sock.recv(260)
|
||||
if len(resp) < 9:
|
||||
raise RuntimeError(f'Short response ({len(resp)} bytes): {resp!r}')
|
||||
|
||||
resp_tid, proto_id, length, resp_slave, func = struct.unpack('>HHHBB', resp[:8])
|
||||
if func & 0x80:
|
||||
exc_code = resp[8]
|
||||
raise RuntimeError(f'Modbus exception, function 0x{func & 0x7F:02X}, code {exc_code} '
|
||||
f'({modbus_exception_name(exc_code)})')
|
||||
|
||||
byte_count = resp[8]
|
||||
values = struct.unpack(f'>{quantity}H', resp[9:9 + byte_count])
|
||||
return values
|
||||
|
||||
def modbus_exception_name(code):
|
||||
return {
|
||||
1: 'Illegal Function', 2: 'Illegal Data Address', 3: 'Illegal Data Value',
|
||||
4: 'Slave Device Failure', 5: 'Acknowledge', 6: 'Slave Device Busy',
|
||||
11: 'Gateway Target Device Failed to Respond',
|
||||
}.get(code, 'Unknown')
|
||||
|
||||
def decode_temp_x10_signed(raw_u16):
|
||||
"""Registers are 16-bit; setpoint/room-temp are signed, x10 scaled."""
|
||||
signed = raw_u16 - 0x10000 if raw_u16 >= 0x8000 else raw_u16
|
||||
return signed / 10.0
|
||||
|
||||
def try_read(sock, slave_id, label, address, decode=None):
|
||||
try:
|
||||
values = read_holding_registers(sock, slave_id, address, 1)
|
||||
raw = values[0]
|
||||
decoded = decode(raw) if decode else raw
|
||||
print(f' [{label}] addr={address:>5} raw=0x{raw:04X} ({raw}) '
|
||||
f'decoded={decoded}{" <-- plausible temp (16-30 C)" if isinstance(decoded, float) and 16.0 <= decoded <= 30.0 else ""}')
|
||||
except Exception as e:
|
||||
print(f' [{label}] addr={address:>5} FAILED: {e}')
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
|
||||
host = sys.argv[1]
|
||||
port = int(sys.argv[2]) if len(sys.argv) > 2 else 502
|
||||
slave_id = int(sys.argv[3]) if len(sys.argv) > 3 else 1
|
||||
|
||||
print(f'Connecting to Modbus TCP gateway at {host}:{port} (slave id {slave_id})...')
|
||||
with socket.create_connection((host, port), timeout=5) as sock:
|
||||
print('Connected.\n')
|
||||
|
||||
print('1. Gateway communication status (global, addr 2995 — expect raw=0 => ok):')
|
||||
try_read(sock, slave_id, 'as-exported', 2995)
|
||||
|
||||
print('\n2. Unit 1 Setpoint — MAPS address 4 (expect a plausible 16-30 C reading '
|
||||
'from whichever candidate is correct):')
|
||||
try_read(sock, slave_id, 'addr=4 (as-exported)', 4, decode_temp_x10_signed)
|
||||
try_read(sock, slave_id, 'addr=3 (exported-1)', 3, decode_temp_x10_signed)
|
||||
|
||||
print('\n3. Unit 1 Room Temperature — MAPS address 8:')
|
||||
try_read(sock, slave_id, 'addr=8 (as-exported)', 8, decode_temp_x10_signed)
|
||||
try_read(sock, slave_id, 'addr=7 (exported-1)', 7, decode_temp_x10_signed)
|
||||
|
||||
print('\nDone. Whichever addr variant above decoded to a plausible 16-30 C value '
|
||||
'for BOTH setpoint and room temp is the correct addressing convention — '
|
||||
'set that in intesis-mhi-modbus.js\'s `addressBase`/perUnit offsets accordingly.')
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue