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
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -3,3 +3,5 @@ dist/
|
|||
.env
|
||||
uploads/
|
||||
*.log
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
105
backend/scripts/test-modbus-connection.py
Normal file
105
backend/scripts/test-modbus-connection.py
Normal 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()
|
||||
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
|
||||
BIN
docs/intesis-maps-signals-sample-11units-2026-07-27.xlsx
Normal file
BIN
docs/intesis-maps-signals-sample-11units-2026-07-27.xlsx
Normal file
Binary file not shown.
BIN
docs/intesis-maps-signals-sample-2026-07-27.xlsx
Normal file
BIN
docs/intesis-maps-signals-sample-2026-07-27.xlsx
Normal file
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue