diff --git a/backend/src/lib/mqtt.js b/backend/src/lib/mqtt.js index 90d4183..d55d895 100644 --- a/backend/src/lib/mqtt.js +++ b/backend/src/lib/mqtt.js @@ -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/status/` (the `hvac/mhi` prefix is literally its MQTT +// Client ID) and subscribes to commands at `hvac/mhi/IU/cmd/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 diff --git a/backend/src/routes/devices.js b/backend/src/routes/devices.js index cd3e55f..d71fce1 100644 --- a/backend/src/routes/devices.js +++ b/backend/src/routes/devices.js @@ -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: [], diff --git a/backend/src/routes/override.js b/backend/src/routes/override.js index ac53346..35b67f7 100644 --- a/backend/src/routes/override.js +++ b/backend/src/routes/override.js @@ -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', { diff --git a/docker-compose.yml b/docker-compose.yml index bf707b7..2becb4d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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:-} diff --git a/frontend/src/components/ZoneCard.tsx b/frontend/src/components/ZoneCard.tsx index 07f448d..5dc8cf5 100644 --- a/frontend/src/components/ZoneCard.tsx +++ b/frontend/src/components/ZoneCard.tsx @@ -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 = { cool: Snowflake, diff --git a/frontend/src/components/ZoneDetailModal.tsx b/frontend/src/components/ZoneDetailModal.tsx index 3f79061..6f581c1 100644 --- a/frontend/src/components/ZoneDetailModal.tsx +++ b/frontend/src/components/ZoneDetailModal.tsx @@ -100,7 +100,7 @@ export default function ZoneDetailModal({ zone, onClose, onSaved }: { {d.health_state.replace('_', ' ')} - {d.device_type === 'mhi_modbus' && } + {(d.device_type === 'mhi_modbus' || d.device_type === 'mhi_mqtt') && } ))} diff --git a/frontend/src/pages/Devices.tsx b/frontend/src/pages/Devices.tsx index 713f910..202d8ab 100644 --- a/frontend/src/pages/Devices.tsx +++ b/frontend/src/pages/Devices.tsx @@ -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() diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 0fcfe99..5606081 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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 = { export const DEVICE_TYPE_LABELS: Record = { 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