#!/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 [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()