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

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