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:
jtricerolph 2026-07-27 14:19:58 +00:00
parent 276c04f8c6
commit a9b5703c57
7 changed files with 431 additions and 1 deletions

View 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()