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

2
.gitignore vendored
View file

@ -3,3 +3,5 @@ dist/
.env
uploads/
*.log
__pycache__/
*.pyc

View file

@ -16,6 +16,7 @@
"luxon": "^3.5.0",
"mqtt": "^5.10.3",
"pg": "^8.13.1",
"sharp": "^0.33.0"
"sharp": "^0.33.0",
"xlsx": "^0.18.5"
}
}

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

View file

@ -0,0 +1,140 @@
// ─────────────────────────────────────────────────────────────────────────────
// Register-map profile: Intesis MHI SuperLink → Modbus TCP gateway
// ─────────────────────────────────────────────────────────────────────────────
// Derived directly from an Intesis MAPS "Excel signals file" export taken from
// the live gateway after the first two indoor units were commissioned
// (MAPS v1.2.31.0, internal protocol "Modbus Slave", external "Mitsubishi Heavy
// Industries", 2026-07-27). See the hvac plan doc's Phase 2 section for why the
// register map lives in code as a named profile (fixed firmware-tied data) while
// the gateway's connection details (IP/port/slave id/unit list) are UI config.
//
// The gateway is the Modbus TCP *server*; hvac connects as the *client/master*.
//
// CONFIRMED from the gateway's Modbus config screen (2026-07-27):
// - Type: TCP; Port: 502; Keep Alive: 10 min.
// - Slave Number: 1 (single connection, defaulted below).
// - Slave Addressing Mode: SINGLE SLAVE -> the whole gateway is one Modbus
// slave; indoor units are addressed by REGISTER OFFSET, not by per-unit
// slave ids. (If this were "Multiple Slaves" the addressing model below
// would be wrong.)
// - Modbus Addresses: "Fixed" on the config screen — but an 11-unit export
// (2026-07-27) DISPROVED the "fixed block per IU with gaps" reading: the
// register slots are DENSE 1..11 in config-list/commission order, while the
// SuperLink IU addresses are sparse (1,2,4,6,8,10,12,21,22,23,25). So a
// unit's register base is keyed to its DENSE gateway slot position, NOT to
// its IU or room number. base=1+(slot-1)*20 holds only when `slot` is that
// dense position. This is why the real export is the source of truth and the
// stored-base-per-device path is PRIMARY, not a fallback — see unitRegister.
//
// STILL TO CONFIRM (only observable from a live Modbus read, not a config screen):
// - Address base: the `address` values below are the raw MAPS addresses. Some
// Modbus masters expect a +40001 holding-register offset. `addressBase: 0`
// here means "use the address as-is in the frame (0-based protocol address)".
// Flip to 40001 only if an integration test shows an off-by-40001 mismatch.
// The RELATIVE structure (stride, offsets, encodings) is unambiguous either
// way — only the absolute base could shift.
// ─────────────────────────────────────────────────────────────────────────────
export const intesisMhiModbus = {
key: 'intesis-mhi-modbus',
label: 'Intesis MHI SuperLink → Modbus TCP',
source: 'Intesis MAPS signals export, MAPS v1.2.31.0, 2026-07-27',
transport: 'modbus-tcp',
// Gateway is the Modbus server; these are defaults for the UI-configurable
// connection record (a per-hotel row in the mhi_gateways table).
defaults: {
port: 502, // confirmed on gateway (2026-07-27)
slaveId: 1, // confirmed: Slave Number 1, Single-Slave addressing mode
addressBase: 0, // 0 = raw protocol address; 40001 = holding-register offset
// (STILL to confirm via a live read — see header note)
},
// Registers are 16-bit holding registers throughout.
// Read with FC03; write single with FC06 (or FC16 for multi).
registerBits: 16,
// Value scaling / encoding helpers, keyed by an encoding name used below.
encodings: {
unsigned: { signed: false, scale: 1 },
// Temperatures (setpoint, room temp) are transmitted x10, two's-complement
// signed — e.g. 21.5 C -> 215. Valid setpoint range 16..30 C (160..300).
tempX10: { signed: true, scale: 10, minC: 16, maxC: 30 },
},
// Broadcast block — absolute register addresses that act on ALL units at once.
// Useful for a future "all public-area units off" style master control.
global: {
gatewayCommStatus: { address: 2995, access: 'r', encoding: 'unsigned', values: { 0: 'ok', 1: 'failure' } },
onOff: { address: 2996, access: 'rw', encoding: 'unsigned', values: { 0: 'off', 1: 'on' } },
mode: { address: 2997, access: 'rw', encoding: 'unsigned', enum: 'mode' },
setpoint: { address: 2998, access: 'rw', encoding: 'tempX10' },
fanSpeed: { address: 2999, access: 'rw', encoding: 'unsigned', enum: 'fanSpeed' },
remoteLock: { address: 3000, access: 'rw', encoding: 'unsigned', values: { 0: 'unlock', 1: 'lock' } },
},
// Per-indoor-unit layout. Each unit occupies a contiguous 20-register block;
// unit 1 begins at address 1, unit 2 at 21 -> stride 20.
// base(unit N) = firstUnitBase + (N-1)*stride.
//
// `slot` here is the gateway's DENSE register-slot position (1..N, in
// config-list / commission order) — this is what the register base is keyed
// to. Confirmed by the 11-unit export: slots dense 1..11, IUs sparse.
// It is DISTINCT from all of:
// - the room number (room 205),
// - the SuperLink IU bus address (col L; e.g. IU 25 for room 205),
// - commission chronology if units are ever reordered.
// Because the slot can only be known from the actual export (not derived from
// room/IU), the resolved register base is stored per device
// (zone_devices.mhi_register_base, set at import time) and passed to
// unitRegister() as explicitBase. The base=1+(slot-1)*20 math below is only a
// sanity check / fallback for a hand-entered slot.
perUnit: {
firstUnitBase: 1,
stride: 20,
// Offsets are relative to a unit's base address.
fields: {
commStatus: { offset: 0, access: 'r', encoding: 'unsigned', values: { 0: 'ok', 1: 'error' } },
onOff: { offset: 1, access: 'rw', encoding: 'unsigned', values: { 0: 'off', 1: 'on' } },
mode: { offset: 2, access: 'rw', encoding: 'unsigned', enum: 'mode' },
setpoint: { offset: 3, access: 'rw', encoding: 'tempX10' },
fanSpeed: { offset: 4, access: 'rw', encoding: 'unsigned', enum: 'fanSpeed' },
remoteLock: { offset: 5, access: 'rw', encoding: 'unsigned', values: { 0: 'unlock', 1: 'lock' } },
louver: { offset: 6, access: 'rw', encoding: 'unsigned', enum: 'louver' },
roomTemp: { offset: 7, access: 'r', encoding: 'tempX10' },
filterSign: { offset: 8, access: 'r', encoding: 'unsigned', values: { 0: 'off', 1: 'on' } },
errorCode: { offset: 9, access: 'r', encoding: 'unsigned' }, // 0 = no error, 1..255 = code
compressor: { offset: 10, access: 'r', encoding: 'unsigned', values: { 0: 'off', 1: 'on' } },
filterReset: { offset: 11, access: 'trigger', encoding: 'unsigned' }, // write 1 to reset
rcErrorReset: { offset: 12, access: 'trigger', encoding: 'unsigned' }, // write 1 to reset
thermoOnOff: { offset: 13, access: 'r', encoding: 'unsigned', values: { 0: 'off', 1: 'on' } },
expansionValve:{ offset: 14, access: 'r', encoding: 'unsigned' }, // 000..999 pulse
// offsets 15-19 are diagnostics (frequency / heat-exchange temps),
// inactive in the sample export — omitted until a use surfaces.
},
},
enums: {
mode: { 0: 'cool', 1: 'heat', 2: 'fan', 3: 'auto', 4: 'dry' },
fanSpeed: { 0: 'low', 1: 'medium', 2: 'high', 3: 'powerful' },
louver: { 0: 'swing', 1: 'pos1', 2: 'pos2', 3: 'pos3', 4: 'pos4' },
},
}
// Resolve a unit's absolute register address for a named field.
// `unitIndex` is the gateway's LOGICAL unit index (sparse/site-derived, e.g. 12
// or 25 — NOT the room number, NOT commission order). Assumes fixed-slot
// allocation; see the perUnit comment. Applies addressBase offset.
//
// If a device row carries an explicit `mhi_register_base` (for the
// sequential-packing case), pass it as `explicitBase` and index math is skipped.
export function unitRegister(profile, unitIndex, fieldName, addressBase = profile.defaults.addressBase, explicitBase = null) {
const field = profile.perUnit.fields[fieldName]
if (!field) throw new Error(`Unknown MHI field: ${fieldName}`)
const base = explicitBase != null
? explicitBase
: profile.perUnit.firstUnitBase + (unitIndex - 1) * profile.perUnit.stride
return addressBase + base + field.offset
}
export default intesisMhiModbus

View file

@ -0,0 +1,182 @@
import * as XLSX from 'xlsx'
// Parses an Intesis MAPS "Excel signals file" export (the file MAPS produces
// when you export the currently-configured register map for an MHI SuperLink
// -> Modbus gateway) into a structured register map.
//
// Why import instead of trusting a hardcoded stride formula: MAPS exports the
// gateway's OWN configured addresses directly — it's ground truth for that
// specific gateway/firmware, and stays correct even if a future firmware
// update or a different Intesis model changes the block layout. A static
// profile (see mhi-profiles/intesis-mhi-modbus.js) is kept as a sensible
// fallback for a fresh gateway that hasn't been exported yet, but an imported
// map always wins once one exists — see the hvac plan doc's Phase 2 section.
//
// Field descriptions are matched by substring, not by row position/count,
// because the "Active" column means some rows (usually diagnostics) can be
// disabled — and a future MAPS version might omit inactive signals from the
// export entirely rather than just flagging them false.
const FIELD_PATTERNS = [
[/gateway communication status/i, 'gatewayCommStatus'],
[/^communication status/i, 'commStatus'],
[/^on\/off/i, 'onOff'],
[/operation mode/i, 'mode'],
[/setpoint/i, 'setpoint'],
[/fan speed/i, 'fanSpeed'],
[/remote lock/i, 'remoteLock'],
[/louver|vane/i, 'louver'],
[/room temperature/i, 'roomTemp'],
[/filter sign status/i, 'filterSign'],
[/filter sign reset/i, 'filterReset'],
[/unit error code/i, 'errorCode'],
[/compressor status/i, 'compressor'],
[/rc error reset/i, 'rcErrorReset'],
[/thermo on\/off/i, 'thermoOnOff'],
[/expansion valve/i, 'expansionValve'],
[/decision frequency/i, 'decisionFrequency'],
[/demand frequency/i, 'demandFrequency'],
[/heat exchange low 1/i, 'heatExchangeLow1'],
[/heat exchange low 2/i, 'heatExchangeLow2'],
[/heat exchange low 3/i, 'heatExchangeLow3'],
]
function matchFieldKey(description) {
for (const [pattern, key] of FIELD_PATTERNS) {
if (pattern.test(description)) return key
}
return null
}
function parseReadWrite(raw) {
if (raw == null) return null
const s = String(raw)
if (/read\s*\/\s*write/i.test(s)) return 'rw'
if (/trigger/i.test(s)) return 'trigger'
if (/read/i.test(s)) return 'r'
if (/write/i.test(s)) return 'w'
return s
}
// unitIdCell looks like "Unit 12 - Indoor Unit 12" for per-unit rows, or "-"
// for the global "all units" block.
function parseUnitIndex(unitIdCell) {
if (!unitIdCell || unitIdCell === '-') return null
const m = String(unitIdCell).match(/^Unit\s+(\d+)/i)
return m ? parseInt(m[1], 10) : null
}
function toNullableInt(v) {
if (v == null || v === '-' || v === '') return null
const n = parseInt(v, 10)
return Number.isNaN(n) ? null : n
}
/**
* @param {Buffer} buffer - raw .xlsx file contents
* @returns {{
* meta: { projectName, mapsVersion, internalProtocol, externalProtocol, timestamp },
* global: Record<string, { address:number, active:boolean, readWrite:string }>,
* units: Array<{ unitIndex:number, iu:number|null, ou:number|null,
* fields: Record<string, { address:number, active:boolean, readWrite:string }> }>,
* unmatchedRows: Array<{ row:number, description:string }>
* }}
*/
export function parseIntesisMapsExport(buffer) {
const workbook = XLSX.read(buffer, { type: 'buffer' })
const sheet = workbook.Sheets[workbook.SheetNames[0]]
const rows = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: null, blankrows: false })
const meta = {
projectName: null,
mapsVersion: null,
internalProtocol: null,
externalProtocol: null,
timestamp: null,
}
let headerRowIdx = -1
const headerMap = {} // column label -> column index
for (let i = 0; i < rows.length; i++) {
const row = rows[i]
if (!row || row.length === 0) continue
const first = row[0]
if (first === 'PROJECT_NAME') meta.projectName = row[1] ?? null
else if (first === 'Intesis MAPS Version') meta.mapsVersion = row[1] ?? null
else if (first === 'Internal Protocol') meta.internalProtocol = row[1] ?? null
else if (first === 'External Protocol') meta.externalProtocol = row[1] ?? null
else if (first === 'Timestamp') meta.timestamp = row[1] ?? null
// Header row is identified by containing "Unit ID" as one of its cells,
// not by a fixed row number — MAPS export layout could shift slightly
// between versions.
if (row.includes('Unit ID')) {
headerRowIdx = i
row.forEach((label, idx) => { if (label != null) headerMap[label] = idx })
break
}
}
if (headerRowIdx === -1) {
throw new Error('Not a recognised Intesis MAPS signals export — no "Unit ID" header row found')
}
const col = {
description: headerMap['Description'],
address: headerMap['Address'],
readWrite: headerMap['Read / Write'],
unitId: headerMap['Unit ID'],
active: headerMap['Active'],
iu: headerMap['IU'],
ou: headerMap['OU'],
}
for (const [name, idx] of Object.entries(col)) {
if (idx === undefined) throw new Error(`MAPS export missing expected column: ${name}`)
}
const global = {}
const unitsByIndex = new Map()
const unmatchedRows = []
for (let i = headerRowIdx + 1; i < rows.length; i++) {
const row = rows[i]
if (!row || row[col.description] == null) continue
const description = String(row[col.description])
const address = toNullableInt(row[col.address])
if (address == null) continue // header/section rows without a real address
const active = row[col.active] === true || row[col.active] === 'True'
const readWrite = parseReadWrite(row[col.readWrite])
const unitIndex = parseUnitIndex(row[col.unitId])
const fieldKey = matchFieldKey(description)
if (!fieldKey) {
unmatchedRows.push({ row: i + 1, description })
continue
}
const entry = { address, active, readWrite }
if (unitIndex == null) {
global[fieldKey] = entry
} else {
if (!unitsByIndex.has(unitIndex)) {
unitsByIndex.set(unitIndex, {
unitIndex,
iu: toNullableInt(row[col.iu]),
ou: toNullableInt(row[col.ou]),
fields: {},
})
}
unitsByIndex.get(unitIndex).fields[fieldKey] = entry
}
}
const units = [...unitsByIndex.values()].sort((a, b) => a.unitIndex - b.unitIndex)
return { meta, global, units, unmatchedRows }
}
export default parseIntesisMapsExport

Binary file not shown.