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:
jtricerolph 2026-07-27 14:43:38 +00:00
parent a9b5703c57
commit e5c40bbb8c
14 changed files with 935 additions and 20 deletions

View file

@ -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",

View file

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

View file

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

View 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
}
}

View file

@ -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.

View file

@ -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.`,
})
}

View 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]
})
}

View file

@ -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 }
})
}

View file

@ -1,5 +1,6 @@
import type {
Zone, ZoneStatus, Device, DevicePhoto, ActivityEntry, NewbookSite, AppConfig,
MhiGateway, MhiStatus,
} from './types'
const BASE = '/hvac/api'
@ -96,6 +97,41 @@ export function photoUrl(filePath: string): string {
return `${BASE}/uploads${filePath}`
}
// MHI Modbus gateways (manual aircon control)
export function fetchMhiGateways(): Promise<MhiGateway[]> {
return request('/mhi-gateways')
}
export function createMhiGateway(body: { name: string; host: string; port?: number; slaveId?: number; addressBase?: number }): Promise<MhiGateway> {
return request('/mhi-gateways', { method: 'POST', body: JSON.stringify(body) })
}
export function updateMhiGateway(id: number, body: Partial<{ name: string; host: string; port: number; slaveId: number; addressBase: number }>): Promise<MhiGateway> {
return request(`/mhi-gateways/${id}`, { method: 'PATCH', body: JSON.stringify(body) })
}
export function testMhiGatewayConnection(id: number): Promise<{ ok: boolean; note?: string; error?: string }> {
return request(`/mhi-gateways/${id}/test-connection`, { method: 'POST', body: JSON.stringify({}) })
}
export async function importMhiRegisterMap(id: number, file: File): Promise<MhiGateway> {
const form = new FormData()
form.append('file', file)
const res = await fetch(`${BASE}/mhi-gateways/${id}/import`, { method: 'POST', credentials: 'include', body: form })
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.error || `Import failed: ${res.status}`)
}
return res.json()
}
export function assignMhiUnit(gatewayId: number, unitIndex: number, body: { zone_id: number | null; location: string }): Promise<Device> {
return request(`/mhi-gateways/${gatewayId}/units/${unitIndex}/assign`, { method: 'POST', body: JSON.stringify(body) })
}
// MHI manual control (device-level — mhi_modbus rows only)
export function fetchMhiStatus(deviceId: number): Promise<MhiStatus> {
return request(`/devices/${deviceId}/mhi-status`)
}
export function sendMhiControl(deviceId: number, body: { onOff?: boolean; mode?: string; tempC?: number; fanSpeed?: string }): Promise<{ ok: boolean }> {
return request(`/devices/${deviceId}/mhi-control`, { method: 'POST', body: JSON.stringify(body) })
}
// Config / settings
export function fetchConfig(): Promise<AppConfig> {
return request('/config')

View file

@ -0,0 +1,238 @@
import { useEffect, useState } from 'react'
import { Plus, Wifi, WifiOff, Upload, Loader2 } from 'lucide-react'
import type { Zone, MhiGateway } from '../types'
import {
fetchMhiGateways, createMhiGateway, testMhiGatewayConnection, importMhiRegisterMap, assignMhiUnit,
} from '../api'
// Gateway configuration + MAPS register-map import for MHI aircon units (manual
// control only — see backend lib/drivers/mhi-modbus.js). Importing only STAGES
// the parsed unit list on the gateway row for review; nothing is written to
// zone_devices until staff explicitly "Assign to Zone" a unit below.
export default function GatewaysPanel({ zones, onAssigned }: { zones: Zone[]; onAssigned: () => void }) {
const [gateways, setGateways] = useState<MhiGateway[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [msg, setMsg] = useState('')
const [name, setName] = useState('')
const [host, setHost] = useState('')
const [port, setPort] = useState('502')
const [slaveId, setSlaveId] = useState('1')
const [creating, setCreating] = useState(false)
const [testingId, setTestingId] = useState<number | null>(null)
const [testResults, setTestResults] = useState<Record<number, { ok: boolean; note?: string; error?: string }>>({})
const [importingId, setImportingId] = useState<number | null>(null)
const [assignDrafts, setAssignDrafts] = useState<Record<string, { zoneId: string; location: string }>>({})
const [assigningKey, setAssigningKey] = useState<string | null>(null)
function load() {
fetchMhiGateways()
.then(setGateways)
.catch(e => setError(e instanceof Error ? e.message : 'Failed to load gateways'))
.finally(() => setLoading(false))
}
useEffect(load, [])
async function handleCreate(e: React.FormEvent) {
e.preventDefault()
if (!name.trim() || !host.trim()) return
setCreating(true); setError(''); setMsg('')
try {
await createMhiGateway({
name: name.trim(), host: host.trim(),
port: parseInt(port) || 502, slaveId: parseInt(slaveId) || 1,
})
setName(''); setHost(''); setPort('502'); setSlaveId('1')
load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Create failed')
} finally {
setCreating(false)
}
}
async function handleTest(id: number) {
setTestingId(id); setError('')
try {
const res = await testMhiGatewayConnection(id)
setTestResults(prev => ({ ...prev, [id]: res }))
} catch (e) {
setTestResults(prev => ({ ...prev, [id]: { ok: false, error: e instanceof Error ? e.message : 'Test failed' } }))
} finally {
setTestingId(null)
}
}
async function handleImport(id: number, file: File | undefined) {
if (!file) return
setImportingId(id); setError(''); setMsg('')
try {
await importMhiRegisterMap(id, file)
setMsg('Register map imported — review the units below and assign them to zones.')
load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Import failed')
} finally {
setImportingId(null)
}
}
function draftFor(gatewayId: number, unitIndex: number) {
const key = `${gatewayId}:${unitIndex}`
return assignDrafts[key] || { zoneId: '', location: '' }
}
function setDraft(gatewayId: number, unitIndex: number, patch: Partial<{ zoneId: string; location: string }>) {
const key = `${gatewayId}:${unitIndex}`
setAssignDrafts(prev => ({ ...prev, [key]: { ...draftFor(gatewayId, unitIndex), ...patch } }))
}
async function handleAssign(gatewayId: number, unitIndex: number) {
const key = `${gatewayId}:${unitIndex}`
const draft = draftFor(gatewayId, unitIndex)
setAssigningKey(key); setError(''); setMsg('')
try {
await assignMhiUnit(gatewayId, unitIndex, {
zone_id: draft.zoneId ? Number(draft.zoneId) : null,
location: draft.location,
})
setMsg(`Unit ${unitIndex} assigned.`)
onAssigned()
} catch (e) {
setError(e instanceof Error ? e.message : 'Assign failed')
} finally {
setAssigningKey(null)
}
}
if (loading) return <p className="muted">Loading gateways</p>
return (
<div>
{error && <div className="error-banner">{error}</div>}
{msg && <div className="ok-banner">{msg}</div>}
<form onSubmit={handleCreate} className="field-row" style={{ alignItems: 'flex-end', marginBottom: 16 }}>
<div className="field" style={{ flex: 2 }}>
<label>Name</label>
<input type="text" value={name} onChange={e => setName(e.target.value)} placeholder="e.g. Main building gateway" />
</div>
<div className="field" style={{ flex: 2 }}>
<label>Host</label>
<input type="text" value={host} onChange={e => setHost(e.target.value)} placeholder="10.4.1.109" />
</div>
<div className="field" style={{ flex: 1 }}>
<label>Port</label>
<input type="number" value={port} onChange={e => setPort(e.target.value)} />
</div>
<div className="field" style={{ flex: 1 }}>
<label>Slave ID</label>
<input type="number" value={slaveId} onChange={e => setSlaveId(e.target.value)} />
</div>
<div className="field" style={{ flex: 'none' }}>
<button className="btn btn-primary" type="submit" disabled={creating}>
<Plus size={14} strokeWidth={1.75} /> Add gateway
</button>
</div>
</form>
{gateways.length === 0 ? (
<div className="empty-state">No MHI gateways configured yet add one above.</div>
) : (
gateways.map(gw => {
const test = testResults[gw.id]
const units = gw.imported_units?.units || []
return (
<div key={gw.id} className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
<div>
<div style={{ fontWeight: 600, fontSize: 14 }}>{gw.name}</div>
<div className="muted" style={{ fontSize: 12 }}>
{gw.host}:{gw.port} · slave {gw.slave_id} · {gw.assigned_count} unit{gw.assigned_count === 1 ? '' : 's'} assigned
</div>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
{test && (
test.ok
? <span className="badge" style={{ background: 'var(--health-healthy)' }}><Wifi size={12} strokeWidth={1.75} /> OK</span>
: <span className="badge" style={{ background: 'var(--health-unresponsive)' }}><WifiOff size={12} strokeWidth={1.75} /> {test.error || 'Fault'}</span>
)}
<button className="btn btn-sm" disabled={testingId === gw.id} onClick={() => handleTest(gw.id)}>
{testingId === gw.id ? <Loader2 size={13} strokeWidth={1.75} className="spin" /> : <Wifi size={13} strokeWidth={1.75} />}
Test Connection
</button>
<label className="btn btn-sm" style={{ cursor: 'pointer' }}>
{importingId === gw.id ? <Loader2 size={13} strokeWidth={1.75} className="spin" /> : <Upload size={13} strokeWidth={1.75} />}
Import register map
<input
type="file" accept=".xlsx" style={{ display: 'none' }}
onChange={e => { handleImport(gw.id, e.target.files?.[0]); e.target.value = '' }}
/>
</label>
</div>
</div>
{test?.note && <p className="field-hint" style={{ marginTop: 6 }}>{test.note}</p>}
{gw.imported_filename && (
<p className="field-hint" style={{ marginTop: 8 }}>
Last import: {gw.imported_filename} ({new Date(gw.imported_at!).toLocaleString()}) {units.length} unit{units.length === 1 ? '' : 's'} found
{gw.imported_units?.unmatchedRows?.length ? `, ${gw.imported_units.unmatchedRows.length} unmatched row(s)` : ''}
</p>
)}
{units.length > 0 && (
<div className="table-wrap" style={{ marginTop: 10 }}>
<table className="data">
<thead>
<tr>
<th>Unit</th>
<th>IU hint</th>
<th>Fields</th>
<th>Zone</th>
<th>Location</th>
<th></th>
</tr>
</thead>
<tbody>
{units.map(u => {
const draft = draftFor(gw.id, u.unitIndex)
const key = `${gw.id}:${u.unitIndex}`
return (
<tr key={u.unitIndex}>
<td>Unit {u.unitIndex}</td>
<td>{u.iu ?? '—'}</td>
<td>{Object.keys(u.fields).length}</td>
<td>
<select value={draft.zoneId} onChange={e => setDraft(gw.id, u.unitIndex, { zoneId: e.target.value })}>
<option value="">Unassigned</option>
{zones.map(z => <option key={z.id} value={z.id}>{z.name}</option>)}
</select>
</td>
<td>
<input
type="text" value={draft.location} placeholder="bedroom / lounge…"
onChange={e => setDraft(gw.id, u.unitIndex, { location: e.target.value })}
style={{ width: 110, border: '1px solid var(--card-border)', borderRadius: 6, padding: '4px 6px', fontSize: 12.5 }}
/>
</td>
<td>
<button className="btn btn-sm" disabled={assigningKey === key} onClick={() => handleAssign(gw.id, u.unitIndex)}>
{assigningKey === key ? 'Assigning…' : 'Assign to Zone'}
</button>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
)
})
)}
</div>
)
}

View file

@ -0,0 +1,102 @@
import { useEffect, useState } from 'react'
import { Power, Minus, Plus, RefreshCw, Loader2 } from 'lucide-react'
import { MHI_MODES, MHI_FAN_SPEEDS } from '../types'
import { fetchMhiStatus, sendMhiControl } from '../api'
// Manual on/off + mode + setpoint + fan speed controls for one assigned MHI
// aircon unit (mhi_modbus). No NewBook/scheduler integration — every change
// here is an explicit staff action via POST /devices/:id/mhi-control.
export default function MhiControlPanel({ deviceId, canControl }: { deviceId: number; canControl: boolean }) {
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [applying, setApplying] = useState(false)
const [onOff, setOnOff] = useState(false)
const [mode, setMode] = useState<string>('auto')
const [setpoint, setSetpoint] = useState(21)
const [fanSpeed, setFanSpeed] = useState<string>('medium')
const [roomTemp, setRoomTemp] = useState<number | null>(null)
function load() {
setLoading(true); setError('')
fetchMhiStatus(deviceId)
.then(s => {
setOnOff(s.onOff === 'on')
if (s.mode) setMode(s.mode)
if (s.setpoint != null) setSetpoint(s.setpoint)
if (s.fanSpeed) setFanSpeed(s.fanSpeed)
setRoomTemp(s.roomTemp ?? null)
})
.catch(e => setError(e instanceof Error ? e.message : 'Failed to read device status'))
.finally(() => setLoading(false))
}
useEffect(load, [deviceId])
async function apply(patch: Partial<{ onOff: boolean; mode: string; tempC: number; fanSpeed: string }>) {
setApplying(true); setError('')
try {
const res = await sendMhiControl(deviceId, patch)
if (!res.ok) setError('Device did not accept the command')
} catch (e) {
setError(e instanceof Error ? e.message : 'Command failed')
} finally {
setApplying(false)
}
}
if (loading) return <p className="muted" style={{ fontSize: 12.5 }}>Reading aircon status</p>
return (
<div style={{ marginTop: 8 }}>
{error && <div className="error-banner" style={{ marginBottom: 8 }}>{error}</div>}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<button
className="btn btn-sm"
disabled={!canControl || applying}
onClick={() => { const next = !onOff; setOnOff(next); apply({ onOff: next }) }}
style={onOff ? { background: 'var(--gold)', borderColor: 'var(--gold)', color: 'var(--navy)' } : undefined}
>
<Power size={13} strokeWidth={1.75} /> {onOff ? 'On' : 'Off'}
</button>
<select
disabled={!canControl || applying}
value={mode}
onChange={e => { setMode(e.target.value); apply({ mode: e.target.value }) }}
>
{MHI_MODES.map(m => <option key={m} value={m}>{m[0].toUpperCase() + m.slice(1)}</option>)}
</select>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<button
className="btn btn-sm" disabled={!canControl || applying}
onClick={() => { const next = Math.max(16, setpoint - 0.5); setSetpoint(next); apply({ tempC: next }) }}
>
<Minus size={12} strokeWidth={1.75} />
</button>
<span style={{ minWidth: 48, textAlign: 'center', fontWeight: 600, fontSize: 13 }}>{setpoint.toFixed(1)}°C</span>
<button
className="btn btn-sm" disabled={!canControl || applying}
onClick={() => { const next = Math.min(30, setpoint + 0.5); setSetpoint(next); apply({ tempC: next }) }}
>
<Plus size={12} strokeWidth={1.75} />
</button>
</div>
<select
disabled={!canControl || applying}
value={fanSpeed}
onChange={e => { setFanSpeed(e.target.value); apply({ fanSpeed: e.target.value }) }}
>
{MHI_FAN_SPEEDS.map(f => <option key={f} value={f}>{f[0].toUpperCase() + f.slice(1)}</option>)}
</select>
{roomTemp != null && <span className="muted" style={{ fontSize: 12 }}>Room: {roomTemp.toFixed(1)}°C</span>}
<button className="btn btn-sm" disabled={loading} onClick={load} title="Refresh live status">
{applying ? <Loader2 size={13} strokeWidth={1.75} className="spin" /> : <RefreshCw size={13} strokeWidth={1.75} />}
</button>
</div>
</div>
)
}

View file

@ -4,6 +4,7 @@ import type { ZoneStatus, ActivityEntry } from '../types'
import { ROOM_STATE_LABELS, can } from '../types'
import { useAuth } from './AuthGate'
import { updateZone, overrideZone, fetchActivity } from '../api'
import MhiControlPanel from './MhiControlPanel'
export default function ZoneDetailModal({ zone, onClose, onSaved }: {
zone: ZoneStatus
@ -88,15 +89,18 @@ export default function ZoneDetailModal({ zone, onClose, onSaved }: {
<div className="section-title">Devices</div>
{zone.devices.length === 0 && <p className="muted">No devices mapped to this zone yet assign some on the Devices page.</p>}
{zone.devices.map(d => (
<div key={d.id} className="card" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div style={{ fontWeight: 600, fontSize: 13 }}>{d.location || d.discovered_name || d.external_ref}</div>
<div className="muted" style={{ fontSize: 12 }}>
{d.current_target_temp != null ? `${Number(d.current_target_temp).toFixed(1)}°C` : '—'}
{d.battery_pct != null && ` · ${d.battery_pct}% battery`}
<div key={d.id} className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div style={{ fontWeight: 600, fontSize: 13 }}>{d.location || d.discovered_name || d.external_ref}</div>
<div className="muted" style={{ fontSize: 12 }}>
{d.current_target_temp != null ? `${Number(d.current_target_temp).toFixed(1)}°C` : '—'}
{d.battery_pct != null && ` · ${d.battery_pct}% battery`}
</div>
</div>
<span className={`badge badge-health-${d.health_state}`}>{d.health_state.replace('_', ' ')}</span>
</div>
<span className={`badge badge-health-${d.health_state}`}>{d.health_state.replace('_', ' ')}</span>
{d.device_type === 'mhi_modbus' && <MhiControlPanel deviceId={d.id} canControl={canControl} />}
</div>
))}

View file

@ -6,6 +6,7 @@ import {
import type { Device, Zone, DevicePhoto, DeviceType } from '../types'
import { DEVICE_TYPE_LABELS, IMPLEMENTED_DEVICE_TYPES } from '../types'
import DevicePhotoUpload from '../components/DevicePhotoUpload'
import GatewaysPanel from '../components/GatewaysPanel'
import { useAuth } from '../components/AuthGate'
import { can } from '../types'
@ -106,6 +107,13 @@ export default function Devices() {
</>
)}
{canManage && (
<>
<div className="section-title">MHI Aircon Gateways</div>
<GatewaysPanel zones={zones} onAssigned={load} />
</>
)}
<div className="section-title">Mapped &amp; Discovered Devices</div>
{devices.length === 0 ? (
<div className="empty-state">No devices discovered yet run a discover scan above.</div>

View file

@ -73,8 +73,71 @@ export interface Device extends DeviceSummary {
photo_count: number
created_at: string
updated_at: string
mhi_gateway_id?: number | null
mhi_unit_index?: number | null
mhi_iu_address?: number | null
mhi_register_map?: Record<string, MhiRegisterField> | null
}
// ── MHI Modbus gateways (manual aircon control) ──────────────────────────
export interface MhiRegisterField {
address: number
active: boolean
readWrite: string | null
}
export interface MhiImportedUnit {
unitIndex: number
iu: number | null
ou: number | null
fields: Record<string, MhiRegisterField>
}
export interface MhiImportResult {
meta: {
projectName: string | null
mapsVersion: string | null
internalProtocol: string | null
externalProtocol: string | null
timestamp: string | null
}
global: Record<string, MhiRegisterField>
units: MhiImportedUnit[]
unmatchedRows: { row: number; description: string }[]
}
export interface MhiGateway {
id: number
name: string
host: string
port: number
slave_id: number
address_base: number
imported_filename: string | null
imported_units: MhiImportResult | null
imported_at: string | null
created_at: string
updated_at: string
assigned_count: number
}
export interface MhiStatus {
external_ref: string
onOff?: string
mode?: string
setpoint?: number
fanSpeed?: string
roomTemp?: number
errorCode?: number
compressor?: string
commStatus?: string
filterSign?: string
error?: string
}
export const MHI_MODES = ['cool', 'heat', 'fan', 'auto', 'dry'] as const
export const MHI_FAN_SPEEDS = ['low', 'medium', 'high', 'powerful'] as const
export interface DevicePhoto {
id: number
device_id: number