diff --git a/backend/package.json b/backend/package.json index ba9383e..568575e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/db.js b/backend/src/db.js index 9dde17b..6223a61 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -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() } diff --git a/backend/src/index.js b/backend/src/index.js index e5b0f7e..e83a019 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -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() diff --git a/backend/src/lib/drivers/mhi-modbus.js b/backend/src/lib/drivers/mhi-modbus.js new file mode 100644 index 0000000..f11f0eb --- /dev/null +++ b/backend/src/lib/drivers/mhi-modbus.js @@ -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::` (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 + } +} diff --git a/backend/src/lib/drivers/mhi-profiles/intesis-mhi-modbus.js b/backend/src/lib/drivers/mhi-profiles/intesis-mhi-modbus.js index bafaee4..72c23a8 100644 --- a/backend/src/lib/drivers/mhi-profiles/intesis-mhi-modbus.js +++ b/backend/src/lib/drivers/mhi-profiles/intesis-mhi-modbus.js @@ -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. diff --git a/backend/src/routes/devices.js b/backend/src/routes/devices.js index 805ced6..cd3e55f 100644 --- a/backend/src/routes/devices.js +++ b/backend/src/routes/devices.js @@ -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.`, }) } diff --git a/backend/src/routes/mhi-gateways.js b/backend/src/routes/mhi-gateways.js new file mode 100644 index 0000000..90e037f --- /dev/null +++ b/backend/src/routes/mhi-gateways.js @@ -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] + }) +} diff --git a/backend/src/routes/override.js b/backend/src/routes/override.js index 391d8b9..ac53346 100644 --- a/backend/src/routes/override.js +++ b/backend/src/routes/override.js @@ -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 } + }) } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 4d479d5..e517a4d 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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 { + return request('/mhi-gateways') +} +export function createMhiGateway(body: { name: string; host: string; port?: number; slaveId?: number; addressBase?: number }): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { return request('/config') diff --git a/frontend/src/components/GatewaysPanel.tsx b/frontend/src/components/GatewaysPanel.tsx new file mode 100644 index 0000000..56e3b72 --- /dev/null +++ b/frontend/src/components/GatewaysPanel.tsx @@ -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([]) + 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(null) + const [testResults, setTestResults] = useState>({}) + const [importingId, setImportingId] = useState(null) + const [assignDrafts, setAssignDrafts] = useState>({}) + const [assigningKey, setAssigningKey] = useState(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

Loading gateways…

+ + return ( +
+ {error &&
{error}
} + {msg &&
{msg}
} + +
+
+ + setName(e.target.value)} placeholder="e.g. Main building gateway" /> +
+
+ + setHost(e.target.value)} placeholder="10.4.1.109" /> +
+
+ + setPort(e.target.value)} /> +
+
+ + setSlaveId(e.target.value)} /> +
+
+ +
+
+ + {gateways.length === 0 ? ( +
No MHI gateways configured yet — add one above.
+ ) : ( + gateways.map(gw => { + const test = testResults[gw.id] + const units = gw.imported_units?.units || [] + return ( +
+
+
+
{gw.name}
+
+ {gw.host}:{gw.port} · slave {gw.slave_id} · {gw.assigned_count} unit{gw.assigned_count === 1 ? '' : 's'} assigned +
+
+
+ {test && ( + test.ok + ? OK + : {test.error || 'Fault'} + )} + + +
+
+ + {test?.note &&

{test.note}

} + + {gw.imported_filename && ( +

+ 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)` : ''} +

+ )} + + {units.length > 0 && ( +
+ + + + + + + + + + + + + {units.map(u => { + const draft = draftFor(gw.id, u.unitIndex) + const key = `${gw.id}:${u.unitIndex}` + return ( + + + + + + + + + ) + })} + +
UnitIU hintFieldsZoneLocation
Unit {u.unitIndex}{u.iu ?? '—'}{Object.keys(u.fields).length} + + + 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 }} + /> + + +
+
+ )} +
+ ) + }) + )} +
+ ) +} diff --git a/frontend/src/components/MhiControlPanel.tsx b/frontend/src/components/MhiControlPanel.tsx new file mode 100644 index 0000000..82c1327 --- /dev/null +++ b/frontend/src/components/MhiControlPanel.tsx @@ -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('auto') + const [setpoint, setSetpoint] = useState(21) + const [fanSpeed, setFanSpeed] = useState('medium') + const [roomTemp, setRoomTemp] = useState(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

Reading aircon status…

+ + return ( +
+ {error &&
{error}
} +
+ + + + +
+ + {setpoint.toFixed(1)}°C + +
+ + + + {roomTemp != null && Room: {roomTemp.toFixed(1)}°C} + + +
+
+ ) +} diff --git a/frontend/src/components/ZoneDetailModal.tsx b/frontend/src/components/ZoneDetailModal.tsx index 5cd6560..3f79061 100644 --- a/frontend/src/components/ZoneDetailModal.tsx +++ b/frontend/src/components/ZoneDetailModal.tsx @@ -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 }: {
Devices
{zone.devices.length === 0 &&

No devices mapped to this zone yet — assign some on the Devices page.

} {zone.devices.map(d => ( -
-
-
{d.location || d.discovered_name || d.external_ref}
-
- {d.current_target_temp != null ? `${Number(d.current_target_temp).toFixed(1)}°C` : '—'} - {d.battery_pct != null && ` · ${d.battery_pct}% battery`} +
+
+
+
{d.location || d.discovered_name || d.external_ref}
+
+ {d.current_target_temp != null ? `${Number(d.current_target_temp).toFixed(1)}°C` : '—'} + {d.battery_pct != null && ` · ${d.battery_pct}% battery`} +
+ {d.health_state.replace('_', ' ')}
- {d.health_state.replace('_', ' ')} + {d.device_type === 'mhi_modbus' && }
))} diff --git a/frontend/src/pages/Devices.tsx b/frontend/src/pages/Devices.tsx index fd4207f..713f910 100644 --- a/frontend/src/pages/Devices.tsx +++ b/frontend/src/pages/Devices.tsx @@ -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 && ( + <> +
MHI Aircon Gateways
+ + + )} +
Mapped & Discovered Devices
{devices.length === 0 ? (
No devices discovered yet — run a discover scan above.
diff --git a/frontend/src/types.ts b/frontend/src/types.ts index a01af37..420af65 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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 | 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 +} + +export interface MhiImportResult { + meta: { + projectName: string | null + mapsVersion: string | null + internalProtocol: string | null + externalProtocol: string | null + timestamp: string | null + } + global: Record + 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