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>
105 lines
4.6 KiB
Python
105 lines
4.6 KiB
Python
#!/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()
|