hvac: native MQTT driver for MHI aircon (replaces Modbus for control/status)

The Intesis MHI gateway was switched from Modbus TCP to native MQTT mode
(2026-08-13), publishing retained per-signal JSON status to
hvac/mhi/IU<nn>/status/<signal> and subscribing to commands at
hvac/mhi/IU<nn>/cmd/<signal>Cmd. This adds an MQTT ingest + command path
alongside the now-dormant Modbus driver, under a new mhi_mqtt device type.

Backend:
- lib/mqtt.js: subscribe hvac/mhi/+/status/+, parse JSON payloads, auto-
  register each unit as an unassigned mhi_mqtt zone_devices row and cache
  live status (power/mode/setpoint/room temp/fan/health). Add
  publishMhiCommand() for on/off/mode/setpoint/fan/vanes writes (QoS 1,
  retain off — a retained command would replay stale on gateway reconnect).
  Numeric mode/fan enums map index-for-index to the frontend label arrays.
- routes/override.js: mhi-status/mhi-control branch on device_type —
  mhi_mqtt serves from the push-populated cache and publishes commands;
  mhi_modbus keeps its synchronous register path unchanged.
- routes/devices.js: discover() note for mhi_mqtt (units self-register).
- No DB migration: existing status columns cover it.

Credentials: getCredentials() now prefers env-injected creds
(MQTT_BROKER_HOST/USERNAME/PASSWORD) over the settings fetch — the broker
hashes each dynsec client password show-once, so it can't be re-served from
settings at runtime. Compose passes the new env through.

Frontend: add mhi_mqtt to DeviceType/labels/implemented list, render
MhiControlPanel and count it as an AC device type.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-08-13 13:31:01 +00:00
parent d43ed903f8
commit c873e4a270
8 changed files with 235 additions and 24 deletions

View file

@ -28,6 +28,52 @@ const BROKER_RECONNECT_BACKOFF_MS = [30, 60, 120, 300, 600, 1800].map(s => s * 1
const GUEST_SOURCES = ['button', 'WS']
const AUTOMATION_SOURCES = ['mqtt', 'http']
// ── MHI aircon over native MQTT (Intesis gateway, switched from Modbus 2026-08-13) ──
// The gateway publishes retained per-signal JSON status to
// `hvac/mhi/IU<nn>/status/<signal>` (the `hvac/mhi` prefix is literally its MQTT
// Client ID) and subscribes to commands at `hvac/mhi/IU<nn>/cmd/<signal>Cmd`.
// This entirely replaces lib/drivers/mhi-modbus.js for control/status — no register
// map, no x10 decoding: payloads carry real values ({name,timestamp,dataType,isValid,
// value,unit?}). Units self-register here as unassigned mhi_mqtt zone_devices rows the
// first time they publish (auto-discovery, NOT auto-mapping — staff still assign each
// to a zone). Numeric enums match the frontend label arrays index-for-index.
const MHI_MODE_LABELS = ['cool', 'heat', 'fan', 'auto', 'dry'] // index = gateway mode value
const MHI_FAN_LABELS = ['low', 'medium', 'high', 'powerful'] // index = gateway fan value
const MHI_STATUS_TOPIC_RE = /^(hvac\/mhi\/(IU\d+))\/status\/(.+)$/
// Command field -> topic suffix + payload envelope shape. `name` mirrors what the
// gateway's spec sheet shows (note vanes: topic `vanesCmd`, payload name `vanesUDCmd`).
const MHI_CMD_SPEC = {
onOff: { topic: 'onOffCmd', name: 'onOffCmd', dataType: 'boolean' },
mode: { topic: 'modeCmd', name: 'modeCmd', dataType: 'integer' },
tempC: { topic: 'setTempCmd', name: 'setTempCmd', dataType: 'float', unit: '°C' },
fanSpeed: { topic: 'fanSpeedCmd', name: 'fanSpeedCmd', dataType: 'integer' },
vanes: { topic: 'vanesCmd', name: 'vanesUDCmd', dataType: 'integer' },
}
// Cumulative in-memory status per unit (externalRef -> partial column set), so a
// single-signal message can still write a complete-as-known row. Repopulated from
// the broker's retained messages on every (re)connect, so a backend restart self-heals.
const mhiState = new Map()
function mqttBool(v) {
return v === true || v === 1 || v === '1' || v === 'true'
}
function numOrNull(v) {
const n = Number(v)
return Number.isFinite(n) ? n : null
}
function mhiModeToNum(m) {
if (typeof m === 'number') return m
const i = MHI_MODE_LABELS.indexOf(String(m))
return i >= 0 ? i : 0
}
function mhiFanToNum(f) {
if (typeof f === 'number') return f
const i = MHI_FAN_LABELS.indexOf(String(f))
return i >= 0 ? i : 0
}
let client = null
let connecting = false
let reconnectAttempt = 0
@ -36,6 +82,22 @@ let reconnectAttempt = 0
const discovered = new Map()
async function getCredentials() {
// Primary path: env-injected credentials for this app's own dynsec client.
// The installer provisions a `hvac-backend` broker client and writes its
// creds into /opt/hvac/.env. This is the working model because the broker
// stores each client's password show-once (Mosquitto dynsec hashes it), so
// it genuinely cannot be re-served from settings at runtime — settings only
// reveals a new client's password once, at creation.
if (process.env.MQTT_BROKER_HOST && process.env.MQTT_USERNAME && process.env.MQTT_PASSWORD) {
return {
host: process.env.MQTT_BROKER_HOST,
port: Number(process.env.MQTT_BROKER_PORT) || 1883,
username: process.env.MQTT_USERNAME,
password: process.env.MQTT_PASSWORD,
}
}
// Fallback: settings service (kept for the eventual self-service model — not
// wired yet; settings has no internal MQTT-credential serve endpoint today).
const url = `${process.env.SETTINGS_URL}/settings/api/internal/integration/mqtt?client=${CLIENT_NAME}`
const res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.SETTINGS_SECRET}` },
@ -188,6 +250,88 @@ async function handleInfo(externalRef, payload) {
}
}
// Ingest one MHI status signal: fold it into the unit's cumulative in-memory
// state, derive a health rollup, then upsert the mhi_mqtt row. The upsert
// auto-registers a newly-seen unit as unassigned and refreshes the dashboard
// status cache — it deliberately never touches zone_id/location/discovered_name
// on conflict, so a staff zone assignment is preserved across every status push.
async function handleMhiStatus(externalRef, iuLabel, signal, payload) {
const value = payload?.value
const state = mhiState.get(externalRef) || {}
switch (signal) {
case 'onOffSts': state.power_state = mqttBool(value) ? 'on' : 'off'; break
case 'modeSts': state.current_mode = MHI_MODE_LABELS[Number(value)] ?? null; break
case 'setTempSts': state.current_target_temp = numOrNull(value); break
case 'tempAmbientSts': state.current_room_temp = numOrNull(value); break
case 'fanSpeedSts': state.fan_speed = MHI_FAN_LABELS[Number(value)] ?? null; break
case 'commErrorSts': state.commError = mqttBool(value); break
// errorCodeSts declares integer but sends boolean false for "no error" — coerce.
case 'errorCodeSts': state.errorCode = Number(value) || 0; break
case 'vanesSts': state.vanes = Number(value) || 0; break // kept in memory; no column yet
default: return // unknown/ignored signal
}
state.health_state = state.commError ? 'poor' : (state.errorCode ? 'degraded' : 'healthy')
mhiState.set(externalRef, state)
const iuIndex = parseInt(String(iuLabel).replace(/\D/g, ''), 10) || null
try {
await pool.query(
`INSERT INTO zone_devices
(device_type, external_ref, discovered_name, mhi_unit_index,
power_state, current_mode, current_target_temp, current_room_temp, fan_speed,
health_state, status_updated_at, last_seen, updated_at)
VALUES ('mhi_mqtt', $1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW(), NOW())
ON CONFLICT (device_type, external_ref) DO UPDATE SET
power_state = EXCLUDED.power_state,
current_mode = EXCLUDED.current_mode,
current_target_temp = EXCLUDED.current_target_temp,
current_room_temp = EXCLUDED.current_room_temp,
fan_speed = EXCLUDED.fan_speed,
health_state = EXCLUDED.health_state,
status_updated_at = NOW(), last_seen = NOW(), updated_at = NOW()`,
[
externalRef, `MHI ${iuLabel}`, iuIndex,
state.power_state ?? null, state.current_mode ?? null,
state.current_target_temp ?? null, state.current_room_temp ?? null,
state.fan_speed ?? null, state.health_state,
]
)
} catch (err) {
console.error('[mqtt] failed to record MHI status update:', err.message)
}
}
// Publish command(s) to one MHI unit. externalRef is the unit's topic base
// (e.g. `hvac/mhi/IU02`). Only fields present in `patch` are written. QoS 1,
// retain OFF — a retained command would replay on the gateway's next reconnect
// and re-fire a stale setpoint. Accepts friendly values (mode/fanSpeed as
// labels or numbers, onOff as boolean) and encodes to the gateway's numeric form.
export function publishMhiCommand(externalRef, patch = {}) {
if (!client?.connected) throw new Error('MQTT broker not connected')
const publishOne = (field, value) => {
const spec = MHI_CMD_SPEC[field]
const envelope = {
name: spec.name,
timestamp: Math.floor(Date.now() / 1000),
dataType: spec.dataType,
isValid: true,
value,
}
if (spec.unit) envelope.unit = spec.unit
client.publish(`${externalRef}/cmd/${spec.topic}`, JSON.stringify(envelope), { qos: 1, retain: false })
}
if (patch.onOff !== undefined && patch.onOff !== null) publishOne('onOff', mqttBool(patch.onOff) ? 1 : 0)
if (patch.mode) publishOne('mode', mhiModeToNum(patch.mode))
if (patch.tempC !== undefined && patch.tempC !== null) publishOne('tempC', Number(patch.tempC))
if (patch.fanSpeed) publishOne('fanSpeed', mhiFanToNum(patch.fanSpeed))
if (patch.vanes !== undefined && patch.vanes !== null) publishOne('vanes', Number(patch.vanes))
return true
}
function scheduleReconnect() {
if (connecting) return
const delay = BROKER_RECONNECT_BACKOFF_MS[Math.min(reconnectAttempt, BROKER_RECONNECT_BACKOFF_MS.length - 1)]
@ -215,7 +359,10 @@ export async function connect() {
reconnectAttempt = 0
connecting = false
console.log('[mqtt] connected to broker')
c.subscribe(['shellies/announce', 'shellies/+/settings', 'shellies/+/thermostat/0/target_t', 'shellies/+/info'])
c.subscribe([
'shellies/announce', 'shellies/+/settings', 'shellies/+/thermostat/0/target_t', 'shellies/+/info',
'hvac/mhi/+/status/+', // MHI aircon status (retained per-signal JSON)
])
resolve()
})
@ -223,6 +370,13 @@ export async function connect() {
let payload
try { payload = JSON.parse(message.toString()) } catch { return }
const mhiMatch = MHI_STATUS_TOPIC_RE.exec(topic)
if (mhiMatch) {
// mhiMatch: [full, externalRef 'hvac/mhi/IU02', iuLabel 'IU02', signal]
handleMhiStatus(mhiMatch[1], mhiMatch[2], mhiMatch[3], payload).catch(() => {})
return
}
if (topic === 'shellies/announce') {
handleAnnounce(payload).catch(() => {})
return

View file

@ -26,6 +26,8 @@ const DRIVERS = {
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.'
const MHI_MQTT_NOTE = 'MHI aircon units on native MQTT self-register as they publish status — they appear ' +
'automatically in the device list. Assign each discovered unit to a zone; no Discover or gateway import needed.'
export async function deviceRoutes(app, opts) {
const UPLOADS_DIR = opts.uploadsDir
@ -53,6 +55,10 @@ export async function deviceRoutes(app, opts) {
return reply.status(200).send({ ok: true, devices: [], note: MHI_MODBUS_NOTE })
}
if (device_type === 'mhi_mqtt') {
return reply.status(200).send({ ok: true, devices: [], note: MHI_MQTT_NOTE })
}
if (NOT_YET_IMPLEMENTED.includes(device_type)) {
return reply.status(200).send({
ok: true, devices: [],

View file

@ -1,6 +1,6 @@
import { requireAuth, requireCap } from '../auth.js'
import { pool, logActivity } from '../db.js'
import { batchSetZoneTemperature } from '../lib/mqtt.js'
import { batchSetZoneTemperature, publishMhiCommand } from '../lib/mqtt.js'
import * as mhiDriver from '../lib/drivers/mhi-modbus.js'
// Manual force-temperature — the equivalent of the old integration's
@ -40,40 +40,86 @@ 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.
// GET /api/devices/:id/mhi-status — live status for one assigned MHI aircon unit.
// mhi_mqtt: served from the push-populated status cache (mqtt.js keeps it fresh
// from the gateway's retained topics). mhi_modbus: live Modbus round-trip read.
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'`,
`SELECT * FROM zone_devices WHERE id = $1 AND device_type IN ('mhi_modbus', 'mhi_mqtt')`,
[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)
const device = rows[0]
if (device.device_type === 'mhi_mqtt') {
return {
external_ref: device.external_ref,
onOff: device.power_state ?? undefined,
mode: device.current_mode ?? undefined,
setpoint: device.current_target_temp != null ? Number(device.current_target_temp) : undefined,
fanSpeed: device.fan_speed ?? undefined,
roomTemp: device.current_room_temp != null ? Number(device.current_room_temp) : undefined,
}
}
const status = await mhiDriver.getStatus(device.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().
// POST /api/devices/:id/mhi-control — manual aircon control only (no scheduler
// wiring). Body: { onOff?, mode?, tempC?, fanSpeed? } — only the fields present
// are written. mhi_mqtt: publish command topics (fire-and-forget, QoS 1); the
// gateway echoes the new state back on its status topics within a second, which
// mqtt.js re-caches — we also optimistically update the cache for instant UI.
// mhi_modbus: synchronous register write via the driver.
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'`,
`SELECT * FROM zone_devices WHERE id = $1 AND device_type IN ('mhi_modbus', 'mhi_mqtt')`,
[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]
)
let ok
if (device.device_type === 'mhi_mqtt') {
try {
publishMhiCommand(device.external_ref, { onOff, mode, tempC, fanSpeed })
ok = true
} catch (err) {
ok = false
req.log.warn(`MHI command publish failed for ${device.external_ref}: ${err.message}`)
}
if (ok) {
// Optimistic cache update so the dashboard reflects the command immediately.
await pool.query(
`UPDATE zone_devices SET
power_state = COALESCE($1, power_state),
current_mode = COALESCE($2, current_mode),
current_target_temp = COALESCE($3, current_target_temp),
fan_speed = COALESCE($4, fan_speed),
updated_at = NOW()
WHERE id = $5`,
[
onOff === undefined || onOff === null ? null : (onOff ? 'on' : 'off'),
mode ?? null,
tempC ?? null,
fanSpeed ?? null,
device.id,
]
)
}
} else {
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', {

View file

@ -8,6 +8,10 @@ services:
- CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET}
- SETTINGS_URL=${SETTINGS_URL}
- SETTINGS_SECRET=${SETTINGS_SECRET}
- MQTT_BROKER_HOST=${MQTT_BROKER_HOST:-10.10.10.104}
- MQTT_BROKER_PORT=${MQTT_BROKER_PORT:-1883}
- MQTT_USERNAME=${MQTT_USERNAME:-}
- MQTT_PASSWORD=${MQTT_PASSWORD:-}
- APP_SLUG=hvac
- OFFICE_IP_CHECK=${OFFICE_IP_CHECK:-disabled}
- NEWBOOK_LOCATION_ID=${NEWBOOK_LOCATION_ID:-}

View file

@ -2,7 +2,7 @@ import { Thermometer, BatteryLow, TriangleAlert, Snowflake, Flame, Fan, Droplet,
import type { ZoneStatus } from '../types'
import { ROOM_STATE_LABELS, MHI_FAN_SPEEDS } from '../types'
const AC_DEVICE_TYPES = ['mhi_modbus', 'midea', 'daikin', 'home_assistant']
const AC_DEVICE_TYPES = ['mhi_modbus', 'mhi_mqtt', 'midea', 'daikin', 'home_assistant']
const MODE_ICONS: Record<string, typeof Snowflake> = {
cool: Snowflake,

View file

@ -100,7 +100,7 @@ export default function ZoneDetailModal({ zone, onClose, onSaved }: {
</div>
<span className={`badge badge-health-${d.health_state}`}>{d.health_state.replace('_', ' ')}</span>
</div>
{d.device_type === 'mhi_modbus' && <MhiControlPanel deviceId={d.id} canControl={canControl} />}
{(d.device_type === 'mhi_modbus' || d.device_type === 'mhi_mqtt') && <MhiControlPanel deviceId={d.id} canControl={canControl} />}
</div>
))}

View file

@ -10,7 +10,7 @@ import GatewaysPanel from '../components/GatewaysPanel'
import { useAuth } from '../components/AuthGate'
import { can } from '../types'
const ALL_DEVICE_TYPES: DeviceType[] = ['shelly_trv', 'mhi_modbus', 'midea', 'daikin', 'home_assistant']
const ALL_DEVICE_TYPES: DeviceType[] = ['shelly_trv', 'mhi_mqtt', 'mhi_modbus', 'midea', 'daikin', 'home_assistant']
export default function Devices() {
const { user } = useAuth()

View file

@ -1,7 +1,7 @@
export type ZoneType = 'room' | 'public_area'
export type ZoneSource = 'newbook' | 'manual'
export type RoomState = 'vacant' | 'booked' | 'heating_up' | 'occupied' | 'cooling_down'
export type DeviceType = 'shelly_trv' | 'mhi_modbus' | 'midea' | 'daikin' | 'home_assistant'
export type DeviceType = 'shelly_trv' | 'mhi_modbus' | 'mhi_mqtt' | 'midea' | 'daikin' | 'home_assistant'
export type HealthState = 'healthy' | 'degraded' | 'poor' | 'unresponsive' | 'calibration_error'
export type PhotoType = 'device' | 'serial_plate'
@ -16,14 +16,15 @@ export const ROOM_STATE_LABELS: Record<RoomState, string> = {
export const DEVICE_TYPE_LABELS: Record<DeviceType, string> = {
shelly_trv: 'Shelly TRV',
mhi_modbus: 'MHI Aircon (Modbus)',
mhi_mqtt: 'MHI Aircon (MQTT)',
midea: 'Midea Split',
daikin: 'Daikin Split',
home_assistant: 'Home Assistant',
}
// Device types with an implemented Phase 1 driver — everything else in
// DeviceType is an enum value only, ready for its Phase 2/3 driver.
export const IMPLEMENTED_DEVICE_TYPES: DeviceType[] = ['shelly_trv', 'home_assistant']
// Device types with an implemented Phase 1/2 driver — everything else in
// DeviceType is an enum value only, ready for its Phase 3 driver.
export const IMPLEMENTED_DEVICE_TYPES: DeviceType[] = ['shelly_trv', 'mhi_mqtt', 'home_assistant']
export interface Zone {
id: number