diff --git a/.gitignore b/.gitignore index 1c878b1..eb1d2e1 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ dist/ .env uploads/ *.log +__pycache__/ +*.pyc diff --git a/backend/package.json b/backend/package.json index e8f2d57..ba9383e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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" } } diff --git a/backend/scripts/test-modbus-connection.py b/backend/scripts/test-modbus-connection.py new file mode 100644 index 0000000..2ad4e1f --- /dev/null +++ b/backend/scripts/test-modbus-connection.py @@ -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 [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() diff --git a/backend/src/lib/drivers/mhi-profiles/intesis-mhi-modbus.js b/backend/src/lib/drivers/mhi-profiles/intesis-mhi-modbus.js new file mode 100644 index 0000000..bafaee4 --- /dev/null +++ b/backend/src/lib/drivers/mhi-profiles/intesis-mhi-modbus.js @@ -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 diff --git a/backend/src/lib/mhi-xlsx-import.js b/backend/src/lib/mhi-xlsx-import.js new file mode 100644 index 0000000..a134e11 --- /dev/null +++ b/backend/src/lib/mhi-xlsx-import.js @@ -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, + * units: Array<{ unitIndex:number, iu:number|null, ou:number|null, + * fields: Record }>, + * 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 diff --git a/docs/intesis-maps-signals-sample-11units-2026-07-27.xlsx b/docs/intesis-maps-signals-sample-11units-2026-07-27.xlsx new file mode 100644 index 0000000..0577e01 Binary files /dev/null and b/docs/intesis-maps-signals-sample-11units-2026-07-27.xlsx differ diff --git a/docs/intesis-maps-signals-sample-2026-07-27.xlsx b/docs/intesis-maps-signals-sample-2026-07-27.xlsx new file mode 100644 index 0000000..e9c1601 Binary files /dev/null and b/docs/intesis-maps-signals-sample-2026-07-27.xlsx differ