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

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