Add MHI aircon manual control (Phase 2, Modbus only)
Manual on/off/mode/setpoint/fan control for MHI indoor units via the already-verified Intesis Modbus TCP gateway, deliberately NOT wired into the NewBook-driven scheduler yet — that stays deferred. Modbus doesn't depend on the MQTT broker (separate infra, still unbuilt), so this can be deployed and tested standalone. - mhi_gateways table (connection config: host/port/slave id/address base) + zone_devices columns for MHI devices (gateway id, unit index, IU hint, and the per-device register map staged from a MAPS import — ground truth, never re-derived from room/IU at runtime) - drivers/mhi-modbus.js: discover/getStatus/setTarget over modbus-serial, same interface as trv.js/homeassistant.js; per-gateway request queue since Modbus TCP requires serialised requests; falls back to the profile's stride formula (with a loud warning) only if a unit has never been through an import - routes/mhi-gateways.js: gateway CRUD, live test-connection, xlsx import (stages the parsed result for review — never auto-creates devices), per-unit 'assign to zone' as the explicit commit step - routes/override.js: GET .../mhi-status, POST .../mhi-control (control cap) - frontend: GatewaysPanel (add gateway, test connection, import + preview, assign units) and MhiControlPanel (on/off, mode, setpoint, fan) wired into Devices.tsx / ZoneDetailModal.tsx Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
a9b5703c57
commit
e5c40bbb8c
14 changed files with 935 additions and 20 deletions
|
|
@ -14,6 +14,7 @@
|
|||
"fastify": "^4.28.1",
|
||||
"jose": "^5.9.6",
|
||||
"luxon": "^3.5.0",
|
||||
"modbus-serial": "^8.0.25",
|
||||
"mqtt": "^5.10.3",
|
||||
"pg": "^8.13.1",
|
||||
"sharp": "^0.33.0",
|
||||
|
|
|
|||
|
|
@ -104,8 +104,43 @@ export async function initDb() {
|
|||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS activity_log_zone_idx ON activity_log (zone_id, created_at DESC);
|
||||
|
||||
-- MHI Modbus TCP gateways (Intesis SuperLink -> Modbus, see
|
||||
-- lib/drivers/mhi-profiles/intesis-mhi-modbus.js). Connection details are
|
||||
-- UI config; the per-unit register map is ground truth from a MAPS "Excel
|
||||
-- signals file" import (lib/mhi-xlsx-import.js), never derived from
|
||||
-- room/IU at runtime. imported_units stages the last parsed export for
|
||||
-- staff review — importing never auto-creates zone_devices rows, staff
|
||||
-- explicitly assign each unit to a zone (see /units/:unitIndex/assign).
|
||||
CREATE TABLE IF NOT EXISTS mhi_gateways (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
host TEXT NOT NULL,
|
||||
port INT NOT NULL DEFAULT 502,
|
||||
slave_id INT NOT NULL DEFAULT 1,
|
||||
address_base INT NOT NULL DEFAULT 0,
|
||||
imported_filename TEXT,
|
||||
imported_units JSONB,
|
||||
imported_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
`)
|
||||
|
||||
// zone_devices predates MHI support — nullable columns added as an ALTER,
|
||||
// same pattern as auth/calendar/wages db.js (ADD COLUMN IF NOT EXISTS keeps
|
||||
// initDb() idempotent without a separate migrations runner).
|
||||
await pool.query(`ALTER TABLE zone_devices ADD COLUMN IF NOT EXISTS mhi_gateway_id INT REFERENCES mhi_gateways(id) ON DELETE SET NULL`)
|
||||
await pool.query(`ALTER TABLE zone_devices ADD COLUMN IF NOT EXISTS mhi_unit_index INT`)
|
||||
// Informational SuperLink IU hint only, shown in the UI — NEVER used for register math
|
||||
// (the register base can only be known from an actual MAPS import; see the profile file).
|
||||
await pool.query(`ALTER TABLE zone_devices ADD COLUMN IF NOT EXISTS mhi_iu_address INT`)
|
||||
// The resolved per-unit `fields` object straight from the MAPS import for this device
|
||||
// (e.g. { setpoint: { address, active, readWrite }, roomTemp: {...}, ... }) — stored
|
||||
// directly rather than re-derived from profile+index at runtime, so there's no drift
|
||||
// between what was imported and what the driver assumes.
|
||||
await pool.query(`ALTER TABLE zone_devices ADD COLUMN IF NOT EXISTS mhi_register_map JSONB`)
|
||||
|
||||
await seedDefaults()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { statusRoutes } from './routes/status.js'
|
|||
import { overrideRoutes } from './routes/override.js'
|
||||
import { configRoutes } from './routes/config.js'
|
||||
import { settingsRoutes } from './routes/settings.js'
|
||||
import { mhiGatewayRoutes } from './routes/mhi-gateways.js'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const UPLOADS_DIR = join(__dirname, '..', 'uploads')
|
||||
|
|
@ -41,6 +42,7 @@ await app.register(statusRoutes)
|
|||
await app.register(overrideRoutes)
|
||||
await app.register(configRoutes)
|
||||
await app.register(settingsRoutes)
|
||||
await app.register(mhiGatewayRoutes)
|
||||
|
||||
try {
|
||||
await initDb()
|
||||
|
|
|
|||
218
backend/src/lib/drivers/mhi-modbus.js
Normal file
218
backend/src/lib/drivers/mhi-modbus.js
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
// Driver interface implementation for the Intesis MHI SuperLink -> Modbus TCP
|
||||
// gateway (see lib/drivers/mhi-profiles/intesis-mhi-modbus.js for the fully
|
||||
// hardware-verified register map/encodings, and lib/mhi-xlsx-import.js for how
|
||||
// a MAPS export becomes a device's stored `mhi_register_map`).
|
||||
//
|
||||
// Same discover()/getStatus(externalRef)/setTarget(externalRef, {mode, tempC})
|
||||
// shape as every other driver (trv.js, homeassistant.js) EXCEPT discover(),
|
||||
// which takes a gatewayConfig argument instead of none — Modbus has no
|
||||
// broadcast "who's out there" discovery, so there's no single generic scan to
|
||||
// run; discover() here is really a connection test against ONE already-known
|
||||
// gateway (called from routes/mhi-gateways.js's test-connection route, not
|
||||
// from the generic /api/devices/discover flow). The actual units always come
|
||||
// from a MAPS register-map import, never from this call.
|
||||
import ModbusRTU from 'modbus-serial'
|
||||
import { pool } from '../../db.js'
|
||||
import { intesisMhiModbus as profile, unitRegister } from './mhi-profiles/intesis-mhi-modbus.js'
|
||||
|
||||
export const DEVICE_TYPE = 'mhi_modbus'
|
||||
|
||||
// Modbus TCP requests must be serialised, never sent concurrently down one
|
||||
// connection — this per-gateway promise chain makes sure two overlapping
|
||||
// getStatus()/setTarget() calls against the same gateway queue up instead of
|
||||
// racing on the wire. Keyed by gateway id.
|
||||
const gatewayQueues = new Map()
|
||||
|
||||
function enqueue(gatewayId, fn) {
|
||||
const prev = gatewayQueues.get(gatewayId) || Promise.resolve()
|
||||
const settled = prev.then(fn, fn) // run fn regardless of whether the previous op succeeded
|
||||
gatewayQueues.set(gatewayId, settled.catch(() => {})) // never let a rejection break the chain for the next caller
|
||||
return settled
|
||||
}
|
||||
|
||||
async function withClient(gateway, fn) {
|
||||
const client = new ModbusRTU()
|
||||
await client.connectTCP(gateway.host, { port: gateway.port || 502 })
|
||||
try {
|
||||
client.setID(gateway.slave_id || 1)
|
||||
client.setTimeout(5000)
|
||||
return await fn(client)
|
||||
} finally {
|
||||
await new Promise(resolve => client.close(resolve))
|
||||
}
|
||||
}
|
||||
|
||||
function runOnGateway(gateway, fn) {
|
||||
return enqueue(gateway.id, () => withClient(gateway, fn))
|
||||
}
|
||||
|
||||
async function loadGatewayById(gatewayId) {
|
||||
if (!gatewayId) return null
|
||||
const { rows } = await pool.query('SELECT * FROM mhi_gateways WHERE id = $1', [gatewayId])
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
async function loadDeviceByExternalRef(externalRef) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM zone_devices WHERE device_type = $1 AND external_ref = $2`,
|
||||
[DEVICE_TYPE, externalRef]
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
// Resolves the absolute wire address + profile encoding info for one field on
|
||||
// one device. Prefers the device's imported mhi_register_map (ground truth for
|
||||
// that specific unit); falls back to the profile's stride formula only if the
|
||||
// gateway has never had a MAPS export imported for it — logging a warning,
|
||||
// since the stride formula is unverified for any given unit (see the profile
|
||||
// file's long comment on why dense slot != IU != room number).
|
||||
function resolveField(device, gateway, fieldName) {
|
||||
const profileField = profile.perUnit.fields[fieldName]
|
||||
if (!profileField) throw new Error(`Unknown MHI field: ${fieldName}`)
|
||||
|
||||
const imported = device.mhi_register_map?.[fieldName]
|
||||
let address
|
||||
if (imported && imported.address != null) {
|
||||
address = (gateway.address_base ?? profile.defaults.addressBase) + imported.address
|
||||
} else {
|
||||
console.warn(
|
||||
`[mhi-modbus driver] device ${device.id}: no imported register map for field "${fieldName}" — ` +
|
||||
`falling back to the stride formula for unit index ${device.mhi_unit_index}. ` +
|
||||
`This is UNVERIFIED for this specific unit — import the gateway's MAPS export to fix.`
|
||||
)
|
||||
address = unitRegister(profile, device.mhi_unit_index, fieldName, gateway.address_base ?? profile.defaults.addressBase)
|
||||
}
|
||||
|
||||
return {
|
||||
address,
|
||||
encoding: profileField.encoding,
|
||||
values: profileField.values || null,
|
||||
enum: profileField.enum || null,
|
||||
access: imported?.readWrite || profileField.access,
|
||||
}
|
||||
}
|
||||
|
||||
function decodeValue(field, raw) {
|
||||
const enc = profile.encodings[field.encoding] || { signed: false, scale: 1 }
|
||||
let value = raw
|
||||
if (enc.signed && value >= 0x8000) value -= 0x10000
|
||||
if (enc.scale && enc.scale !== 1) value = value / enc.scale
|
||||
|
||||
if (field.values) return field.values[raw] ?? raw
|
||||
if (field.enum) {
|
||||
const enumMap = profile.enums[field.enum]
|
||||
return enumMap ? (enumMap[raw] ?? raw) : raw
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// Inverse of decodeValue — friendlyValue may be a decoded label ('heat',
|
||||
// 'on'/true) or an already-raw number; either is accepted so callers can pass
|
||||
// through UI values without needing to know the wire encoding.
|
||||
function encodeValue(field, friendlyValue) {
|
||||
const enc = profile.encodings[field.encoding] || { signed: false, scale: 1 }
|
||||
let raw
|
||||
|
||||
if (field.values) {
|
||||
if (typeof friendlyValue === 'boolean') friendlyValue = friendlyValue ? 'on' : 'off'
|
||||
const entry = Object.entries(field.values).find(([k, v]) => v === friendlyValue || k === String(friendlyValue))
|
||||
raw = entry ? Number(entry[0]) : Number(friendlyValue)
|
||||
} else if (field.enum) {
|
||||
const enumMap = profile.enums[field.enum]
|
||||
const entry = Object.entries(enumMap).find(([k, v]) => v === friendlyValue || k === String(friendlyValue))
|
||||
raw = entry ? Number(entry[0]) : Number(friendlyValue)
|
||||
} else {
|
||||
let scaled = Number(friendlyValue) * (enc.scale || 1)
|
||||
if (enc.minC != null && enc.maxC != null) {
|
||||
scaled = Math.min(Math.max(scaled, enc.minC * enc.scale), enc.maxC * enc.scale)
|
||||
}
|
||||
raw = scaled
|
||||
}
|
||||
|
||||
raw = Math.round(raw)
|
||||
if (enc.signed && raw < 0) raw += 0x10000
|
||||
return raw & 0xffff
|
||||
}
|
||||
|
||||
// Connection test against ONE already-configured gateway — reads the global
|
||||
// "Gateway Communication Status" register (confirmed live 2026-07-27, addr
|
||||
// 2995, 0 = ok). Doesn't enumerate units; that only ever comes from an import.
|
||||
export async function discover(gatewayConfig) {
|
||||
if (!gatewayConfig?.host) {
|
||||
return { ok: false, error: 'Gateway has no host configured', devices: [] }
|
||||
}
|
||||
try {
|
||||
const raw = await runOnGateway(gatewayConfig, async client => {
|
||||
const addressBase = gatewayConfig.address_base ?? profile.defaults.addressBase
|
||||
const res = await client.readHoldingRegisters(addressBase + profile.global.gatewayCommStatus.address, 1)
|
||||
return res.data[0]
|
||||
})
|
||||
const ok = raw === 0
|
||||
return {
|
||||
ok,
|
||||
devices: [],
|
||||
note: ok
|
||||
? 'Gateway reachable, comm status OK. Indoor units come from a MAPS register-map import, not discovery — use "Import register map" below.'
|
||||
: `Gateway reachable but comm status register reports a fault (raw=${raw}).`,
|
||||
}
|
||||
} catch (err) {
|
||||
return { ok: false, error: err.message, devices: [] }
|
||||
}
|
||||
}
|
||||
|
||||
// externalRef is `mhi:<gatewayId>:<unitIndex>` (set at assign time by
|
||||
// routes/mhi-gateways.js) — same "one string key, driver looks up the rest
|
||||
// from the DB" shape as trv.js.
|
||||
export async function getStatus(externalRef) {
|
||||
const device = await loadDeviceByExternalRef(externalRef)
|
||||
if (!device) return null
|
||||
const gateway = await loadGatewayById(device.mhi_gateway_id)
|
||||
if (!gateway) return { external_ref: externalRef, error: 'No gateway configured for this device' }
|
||||
|
||||
const fieldsToRead = ['onOff', 'mode', 'setpoint', 'fanSpeed', 'roomTemp', 'errorCode', 'compressor', 'commStatus', 'filterSign']
|
||||
const result = { external_ref: externalRef }
|
||||
try {
|
||||
await runOnGateway(gateway, async client => {
|
||||
for (const fieldName of fieldsToRead) {
|
||||
const field = resolveField(device, gateway, fieldName)
|
||||
const imported = device.mhi_register_map?.[fieldName]
|
||||
if (imported && imported.active === false) continue // field disabled on this gateway/firmware
|
||||
const res = await client.readHoldingRegisters(field.address, 1)
|
||||
result[fieldName] = decodeValue(field, res.data[0])
|
||||
}
|
||||
})
|
||||
return result
|
||||
} catch (err) {
|
||||
return { external_ref: externalRef, error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
// setTarget(externalRef, { mode, tempC, fanSpeed, onOff }) — writes only the
|
||||
// fields that were actually passed (FC06, write single register per field).
|
||||
export async function setTarget(externalRef, { mode, tempC, fanSpeed, onOff } = {}) {
|
||||
const device = await loadDeviceByExternalRef(externalRef)
|
||||
if (!device) return false
|
||||
const gateway = await loadGatewayById(device.mhi_gateway_id)
|
||||
if (!gateway) return false
|
||||
|
||||
const writes = []
|
||||
if (onOff !== undefined && onOff !== null) writes.push(['onOff', onOff])
|
||||
if (mode) writes.push(['mode', mode])
|
||||
if (tempC !== undefined && tempC !== null) writes.push(['setpoint', tempC])
|
||||
if (fanSpeed) writes.push(['fanSpeed', fanSpeed])
|
||||
if (!writes.length) return true
|
||||
|
||||
try {
|
||||
await runOnGateway(gateway, async client => {
|
||||
for (const [fieldName, friendlyValue] of writes) {
|
||||
const field = resolveField(device, gateway, fieldName)
|
||||
const raw = encodeValue(field, friendlyValue)
|
||||
await client.writeRegister(field.address, raw)
|
||||
}
|
||||
})
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error(`[mhi-modbus driver] setTarget failed for device ${device.id} (${externalRef}):`, err.message)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -26,13 +26,15 @@
|
|||
// 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.
|
||||
// CONFIRMED by a live Modbus read against the gateway (10.4.1.109, 2026-07-27,
|
||||
// via scripts/test-modbus-connection.py):
|
||||
// - Address base: MAPS addresses go on the wire AS-IS (0-based protocol
|
||||
// address, `addressBase: 0`). No +40001 offset. Unit 1 setpoint read 19.0 C
|
||||
// at addr 4 and room temp 25.5 C at addr 8; the -1 candidates read empty.
|
||||
// - Encoding verified live: signed x10 decode is correct (0x00BE=190 -> 19.0).
|
||||
// - Gateway comm status (addr 2995) read 0 (ok); framing/slave-id all correct.
|
||||
// The MHI Modbus protocol layer is now fully empirically verified — no open
|
||||
// questions remain on addressing or encoding.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const intesisMhiModbus = {
|
||||
|
|
@ -46,8 +48,7 @@ export const intesisMhiModbus = {
|
|||
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)
|
||||
addressBase: 0, // CONFIRMED by live read 2026-07-27: raw 0-based, no +40001
|
||||
},
|
||||
|
||||
// Registers are 16-bit holding registers throughout.
|
||||
|
|
|
|||
|
|
@ -11,13 +11,21 @@ import { createMaintenanceAsset } from '../lib/maintenance-client.js'
|
|||
const ALLOWED_IMAGES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']
|
||||
const PHOTO_TYPES = ['device', 'serial_plate']
|
||||
|
||||
// device_type -> driver implementing discover()/getStatus()/setTarget(). Phase 2/3
|
||||
// types (mhi_modbus, midea, daikin) are enum values only — no driver yet.
|
||||
// device_type -> driver implementing discover()/getStatus()/setTarget(). Phase 3
|
||||
// types (midea, daikin) are enum values only — no driver yet. mhi_modbus DOES
|
||||
// have a driver (lib/drivers/mhi-modbus.js) but it isn't wired into this generic
|
||||
// no-args discover flow — Modbus has no broadcast scan, and discover() there
|
||||
// needs a specific already-configured gateway, so it's only ever called from
|
||||
// routes/mhi-gateways.js's test-connection route. Units are onboarded via
|
||||
// "Gateways" (Devices page): configure a gateway, import its MAPS register map,
|
||||
// then assign units to zones — never via this Discover button.
|
||||
const DRIVERS = {
|
||||
shelly_trv: trvDriver,
|
||||
home_assistant: haDriver,
|
||||
}
|
||||
const NOT_YET_IMPLEMENTED = ['mhi_modbus', 'midea', 'daikin']
|
||||
const NOT_YET_IMPLEMENTED = ['midea', 'daikin']
|
||||
const MHI_MODBUS_NOTE = "MHI aircon units aren't found via Discover — configure a gateway and import its register " +
|
||||
'map under Devices -> Gateways, then assign units to zones there.'
|
||||
|
||||
export async function deviceRoutes(app, opts) {
|
||||
const UPLOADS_DIR = opts.uploadsDir
|
||||
|
|
@ -41,11 +49,15 @@ export async function deviceRoutes(app, opts) {
|
|||
const { device_type } = req.body || {}
|
||||
if (!device_type) return reply.status(400).send({ error: 'device_type required' })
|
||||
|
||||
if (device_type === 'mhi_modbus') {
|
||||
return reply.status(200).send({ ok: true, devices: [], note: MHI_MODBUS_NOTE })
|
||||
}
|
||||
|
||||
if (NOT_YET_IMPLEMENTED.includes(device_type)) {
|
||||
return reply.status(200).send({
|
||||
ok: true, devices: [],
|
||||
note: `${device_type} discovery isn't implemented yet — Phase 1 only covers Shelly TRVs. ` +
|
||||
`This device type exists as a column enum value ready for its Phase 2/3 driver.`,
|
||||
`This device type exists as a column enum value ready for its Phase 3 driver.`,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
149
backend/src/routes/mhi-gateways.js
Normal file
149
backend/src/routes/mhi-gateways.js
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import { requireAuth, requireCap } from '../auth.js'
|
||||
import { pool } from '../db.js'
|
||||
import { parseIntesisMapsExport } from '../lib/mhi-xlsx-import.js'
|
||||
import * as mhiDriver from '../lib/drivers/mhi-modbus.js'
|
||||
|
||||
// MHI Modbus TCP gateway configuration + register-map import. Manual control
|
||||
// only for now (see lib/drivers/mhi-modbus.js) — this never touches
|
||||
// lib/scheduler.js. Importing a MAPS export only STAGES the parsed result on
|
||||
// the gateway row for staff review; no zone_devices rows are created until an
|
||||
// explicit "Assign to Zone" per unit (see the /units/:unitIndex/assign route).
|
||||
export async function mhiGatewayRoutes(app) {
|
||||
app.addHook('preHandler', requireAuth)
|
||||
|
||||
// GET /api/mhi-gateways — configured gateways + how many units are assigned from each
|
||||
app.get('/api/mhi-gateways', { preHandler: requireCap('view') }, async () => {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT g.*,
|
||||
(SELECT COUNT(*)::int FROM zone_devices zd WHERE zd.mhi_gateway_id = g.id) AS assigned_count
|
||||
FROM mhi_gateways g
|
||||
ORDER BY g.name
|
||||
`)
|
||||
return rows
|
||||
})
|
||||
|
||||
// POST /api/mhi-gateways — { name, host, port, slaveId, addressBase }
|
||||
app.post('/api/mhi-gateways', { preHandler: requireCap('manage_devices') }, async (req, reply) => {
|
||||
const { name, host, port, slaveId, addressBase } = req.body || {}
|
||||
if (!name || !host) return reply.status(400).send({ error: 'name and host are required' })
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO mhi_gateways (name, host, port, slave_id, address_base)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
|
||||
[name, host, port || 502, slaveId || 1, addressBase ?? 0]
|
||||
)
|
||||
return rows[0]
|
||||
})
|
||||
|
||||
// PATCH /api/mhi-gateways/:id — edit connection details
|
||||
app.patch('/api/mhi-gateways/:id', { preHandler: requireCap('manage_devices') }, async (req, reply) => {
|
||||
const { rows: existing } = await pool.query('SELECT * FROM mhi_gateways WHERE id = $1', [req.params.id])
|
||||
if (!existing.length) return reply.status(404).send({ error: 'Gateway not found' })
|
||||
const g = existing[0]
|
||||
const { name, host, port, slaveId, addressBase } = req.body || {}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE mhi_gateways SET
|
||||
name = $1, host = $2, port = $3, slave_id = $4, address_base = $5, updated_at = NOW()
|
||||
WHERE id = $6 RETURNING *`,
|
||||
[
|
||||
name ?? g.name, host ?? g.host, port ?? g.port,
|
||||
slaveId ?? g.slave_id, addressBase ?? g.address_base, req.params.id,
|
||||
]
|
||||
)
|
||||
return rows[0]
|
||||
})
|
||||
|
||||
// POST /api/mhi-gateways/:id/test-connection — live Modbus read of the
|
||||
// gateway comm status register (same check as scripts/test-modbus-connection.py)
|
||||
app.post('/api/mhi-gateways/:id/test-connection', { preHandler: requireCap('manage_devices') }, async (req, reply) => {
|
||||
const { rows } = await pool.query('SELECT * FROM mhi_gateways WHERE id = $1', [req.params.id])
|
||||
if (!rows.length) return reply.status(404).send({ error: 'Gateway not found' })
|
||||
const result = await mhiDriver.discover(rows[0])
|
||||
return result
|
||||
})
|
||||
|
||||
// POST /api/mhi-gateways/:id/import — multipart .xlsx upload (same
|
||||
// @fastify/multipart pattern as devices.js's photo upload route). Parses and
|
||||
// STAGES the result on the gateway row — nothing else is written.
|
||||
app.post('/api/mhi-gateways/:id/import', { preHandler: requireCap('manage_devices') }, async (req, reply) => {
|
||||
const { rows: existing } = await pool.query('SELECT id FROM mhi_gateways WHERE id = $1', [req.params.id])
|
||||
if (!existing.length) return reply.status(404).send({ error: 'Gateway not found' })
|
||||
|
||||
let fileBuffer = null, fileName = null
|
||||
for await (const part of req.parts()) {
|
||||
if (part.type === 'file') {
|
||||
const chunks = []
|
||||
for await (const chunk of part.file) chunks.push(chunk)
|
||||
fileBuffer = Buffer.concat(chunks)
|
||||
fileName = part.filename
|
||||
}
|
||||
}
|
||||
if (!fileBuffer) return reply.status(400).send({ error: 'No file uploaded' })
|
||||
|
||||
let parsed
|
||||
try {
|
||||
parsed = parseIntesisMapsExport(fileBuffer)
|
||||
} catch (err) {
|
||||
return reply.status(400).send({ error: err.message })
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE mhi_gateways SET imported_filename = $1, imported_units = $2, imported_at = NOW(), updated_at = NOW()
|
||||
WHERE id = $3 RETURNING *`,
|
||||
[fileName, JSON.stringify(parsed), req.params.id]
|
||||
)
|
||||
return rows[0]
|
||||
})
|
||||
|
||||
// POST /api/mhi-gateways/:id/units/:unitIndex/assign — { zone_id, location }
|
||||
// creates/updates a zone_devices row (device_type='mhi_modbus') from the
|
||||
// gateway's last staged import. Pulls that unit's `fields` register map
|
||||
// straight from imported_units — this is the one-time "commit" step for a
|
||||
// unit; re-running an import + re-assigning refreshes the stored map.
|
||||
app.post('/api/mhi-gateways/:id/units/:unitIndex/assign', { preHandler: requireCap('manage_devices') }, async (req, reply) => {
|
||||
const { rows: gwRows } = await pool.query('SELECT * FROM mhi_gateways WHERE id = $1', [req.params.id])
|
||||
if (!gwRows.length) return reply.status(404).send({ error: 'Gateway not found' })
|
||||
const gateway = gwRows[0]
|
||||
|
||||
const unitIndex = parseInt(req.params.unitIndex, 10)
|
||||
if (!Number.isFinite(unitIndex)) return reply.status(400).send({ error: 'Invalid unit index' })
|
||||
|
||||
const unit = (gateway.imported_units?.units || []).find(u => u.unitIndex === unitIndex)
|
||||
if (!unit) {
|
||||
return reply.status(404).send({
|
||||
error: `Unit ${unitIndex} not found in this gateway's last import — import (or re-import) the MAPS export first.`,
|
||||
})
|
||||
}
|
||||
|
||||
const { zone_id, location } = req.body || {}
|
||||
const externalRef = `mhi:${gateway.id}:${unitIndex}`
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO zone_devices
|
||||
(device_type, external_ref, discovered_name, zone_id, location,
|
||||
mhi_gateway_id, mhi_unit_index, mhi_iu_address, mhi_register_map)
|
||||
VALUES ('mhi_modbus', $1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (device_type, external_ref) DO UPDATE SET
|
||||
zone_id = EXCLUDED.zone_id,
|
||||
location = EXCLUDED.location,
|
||||
mhi_gateway_id = EXCLUDED.mhi_gateway_id,
|
||||
mhi_unit_index = EXCLUDED.mhi_unit_index,
|
||||
mhi_iu_address = EXCLUDED.mhi_iu_address,
|
||||
mhi_register_map = EXCLUDED.mhi_register_map,
|
||||
updated_at = NOW()
|
||||
RETURNING *`,
|
||||
[
|
||||
externalRef,
|
||||
`MHI Unit ${unitIndex}`,
|
||||
zone_id || null,
|
||||
location || null,
|
||||
gateway.id,
|
||||
unitIndex,
|
||||
unit.iu ?? null,
|
||||
JSON.stringify(unit.fields),
|
||||
]
|
||||
)
|
||||
return rows[0]
|
||||
})
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { requireAuth, requireCap } from '../auth.js'
|
||||
import { pool, logActivity } from '../db.js'
|
||||
import { batchSetZoneTemperature } from '../lib/mqtt.js'
|
||||
import * as mhiDriver from '../lib/drivers/mhi-modbus.js'
|
||||
|
||||
// Manual force-temperature — the equivalent of the old integration's
|
||||
// newbook.force_room_temperature service. Disables auto mode for the zone so the
|
||||
|
|
@ -38,4 +39,49 @@ export async function overrideRoutes(app) {
|
|||
|
||||
return { ok: successful > 0, successful, total: devices.length, auto_mode: false }
|
||||
})
|
||||
|
||||
// GET /api/devices/:id/mhi-status — live Modbus read (onOff/mode/setpoint/
|
||||
// fanSpeed/roomTemp/errorCode/etc) for one assigned MHI aircon unit.
|
||||
app.get('/api/devices/:id/mhi-status', { preHandler: requireCap('view') }, async (req, reply) => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM zone_devices WHERE id = $1 AND device_type = 'mhi_modbus'`,
|
||||
[req.params.id]
|
||||
)
|
||||
if (!rows.length) return reply.status(404).send({ error: 'MHI device not found' })
|
||||
const status = await mhiDriver.getStatus(rows[0].external_ref)
|
||||
if (!status || status.error) {
|
||||
return reply.status(502).send({ error: status?.error || 'Failed to read device status' })
|
||||
}
|
||||
return status
|
||||
})
|
||||
|
||||
// POST /api/devices/:id/mhi-control — manual aircon control only (no
|
||||
// scheduler wiring). Body: { onOff?, mode?, tempC?, fanSpeed? } — only the
|
||||
// fields present are written, via the mhi-modbus driver's setTarget().
|
||||
app.post('/api/devices/:id/mhi-control', { preHandler: requireCap('control') }, async (req, reply) => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM zone_devices WHERE id = $1 AND device_type = 'mhi_modbus'`,
|
||||
[req.params.id]
|
||||
)
|
||||
if (!rows.length) return reply.status(404).send({ error: 'MHI device not found' })
|
||||
const device = rows[0]
|
||||
const { onOff, mode, tempC, fanSpeed } = req.body || {}
|
||||
|
||||
const ok = await mhiDriver.setTarget(device.external_ref, { onOff, mode, tempC, fanSpeed })
|
||||
|
||||
if (ok && tempC !== undefined && tempC !== null) {
|
||||
await pool.query(
|
||||
`UPDATE zone_devices SET current_target_temp = $1, target_origin = 'manual', updated_at = NOW() WHERE id = $2`,
|
||||
[tempC, device.id]
|
||||
)
|
||||
}
|
||||
|
||||
await logActivity(device.zone_id, 'device_command', {
|
||||
note: `MHI manual control on ${device.location || device.discovered_name || device.external_ref}: ${JSON.stringify({ onOff, mode, tempC, fanSpeed })}${ok ? '' : ' — FAILED'}`,
|
||||
source: 'manual',
|
||||
userEmail: req.user.email,
|
||||
})
|
||||
|
||||
return { ok }
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue