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:
parent
276c04f8c6
commit
a9b5703c57
7 changed files with 431 additions and 1 deletions
140
backend/src/lib/drivers/mhi-profiles/intesis-mhi-modbus.js
Normal file
140
backend/src/lib/drivers/mhi-profiles/intesis-mhi-modbus.js
Normal 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
|
||||
182
backend/src/lib/mhi-xlsx-import.js
Normal file
182
backend/src/lib/mhi-xlsx-import.js
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue