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:
parent
d43ed903f8
commit
c873e4a270
8 changed files with 235 additions and 24 deletions
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue