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
|
|
@ -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 GUEST_SOURCES = ['button', 'WS']
|
||||||
const AUTOMATION_SOURCES = ['mqtt', 'http']
|
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 client = null
|
||||||
let connecting = false
|
let connecting = false
|
||||||
let reconnectAttempt = 0
|
let reconnectAttempt = 0
|
||||||
|
|
@ -36,6 +82,22 @@ let reconnectAttempt = 0
|
||||||
const discovered = new Map()
|
const discovered = new Map()
|
||||||
|
|
||||||
async function getCredentials() {
|
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 url = `${process.env.SETTINGS_URL}/settings/api/internal/integration/mqtt?client=${CLIENT_NAME}`
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
headers: { Authorization: `Bearer ${process.env.SETTINGS_SECRET}` },
|
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() {
|
function scheduleReconnect() {
|
||||||
if (connecting) return
|
if (connecting) return
|
||||||
const delay = BROKER_RECONNECT_BACKOFF_MS[Math.min(reconnectAttempt, BROKER_RECONNECT_BACKOFF_MS.length - 1)]
|
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
|
reconnectAttempt = 0
|
||||||
connecting = false
|
connecting = false
|
||||||
console.log('[mqtt] connected to broker')
|
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()
|
resolve()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -223,6 +370,13 @@ export async function connect() {
|
||||||
let payload
|
let payload
|
||||||
try { payload = JSON.parse(message.toString()) } catch { return }
|
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') {
|
if (topic === 'shellies/announce') {
|
||||||
handleAnnounce(payload).catch(() => {})
|
handleAnnounce(payload).catch(() => {})
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ const DRIVERS = {
|
||||||
const NOT_YET_IMPLEMENTED = ['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 " +
|
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.'
|
'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) {
|
export async function deviceRoutes(app, opts) {
|
||||||
const UPLOADS_DIR = opts.uploadsDir
|
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 })
|
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)) {
|
if (NOT_YET_IMPLEMENTED.includes(device_type)) {
|
||||||
return reply.status(200).send({
|
return reply.status(200).send({
|
||||||
ok: true, devices: [],
|
ok: true, devices: [],
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { requireAuth, requireCap } from '../auth.js'
|
import { requireAuth, requireCap } from '../auth.js'
|
||||||
import { pool, logActivity } from '../db.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'
|
import * as mhiDriver from '../lib/drivers/mhi-modbus.js'
|
||||||
|
|
||||||
// Manual force-temperature — the equivalent of the old integration's
|
// Manual force-temperature — the equivalent of the old integration's
|
||||||
|
|
@ -40,41 +40,87 @@ export async function overrideRoutes(app) {
|
||||||
return { ok: successful > 0, successful, total: devices.length, auto_mode: false }
|
return { ok: successful > 0, successful, total: devices.length, auto_mode: false }
|
||||||
})
|
})
|
||||||
|
|
||||||
// GET /api/devices/:id/mhi-status — live Modbus read (onOff/mode/setpoint/
|
// GET /api/devices/:id/mhi-status — live status for one assigned MHI aircon unit.
|
||||||
// fanSpeed/roomTemp/errorCode/etc) 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) => {
|
app.get('/api/devices/:id/mhi-status', { preHandler: requireCap('view') }, async (req, reply) => {
|
||||||
const { rows } = await pool.query(
|
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]
|
[req.params.id]
|
||||||
)
|
)
|
||||||
if (!rows.length) return reply.status(404).send({ error: 'MHI device not found' })
|
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) {
|
if (!status || status.error) {
|
||||||
return reply.status(502).send({ error: status?.error || 'Failed to read device status' })
|
return reply.status(502).send({ error: status?.error || 'Failed to read device status' })
|
||||||
}
|
}
|
||||||
return status
|
return status
|
||||||
})
|
})
|
||||||
|
|
||||||
// POST /api/devices/:id/mhi-control — manual aircon control only (no
|
// POST /api/devices/:id/mhi-control — manual aircon control only (no scheduler
|
||||||
// scheduler wiring). Body: { onOff?, mode?, tempC?, fanSpeed? } — only the
|
// wiring). Body: { onOff?, mode?, tempC?, fanSpeed? } — only the fields present
|
||||||
// fields present are written, via the mhi-modbus driver's setTarget().
|
// 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) => {
|
app.post('/api/devices/:id/mhi-control', { preHandler: requireCap('control') }, async (req, reply) => {
|
||||||
const { rows } = await pool.query(
|
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]
|
[req.params.id]
|
||||||
)
|
)
|
||||||
if (!rows.length) return reply.status(404).send({ error: 'MHI device not found' })
|
if (!rows.length) return reply.status(404).send({ error: 'MHI device not found' })
|
||||||
const device = rows[0]
|
const device = rows[0]
|
||||||
const { onOff, mode, tempC, fanSpeed } = req.body || {}
|
const { onOff, mode, tempC, fanSpeed } = req.body || {}
|
||||||
|
|
||||||
const ok = await mhiDriver.setTarget(device.external_ref, { onOff, mode, tempC, fanSpeed })
|
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) {
|
if (ok && tempC !== undefined && tempC !== null) {
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`UPDATE zone_devices SET current_target_temp = $1, target_origin = 'manual', updated_at = NOW() WHERE id = $2`,
|
`UPDATE zone_devices SET current_target_temp = $1, target_origin = 'manual', updated_at = NOW() WHERE id = $2`,
|
||||||
[tempC, device.id]
|
[tempC, device.id]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await logActivity(device.zone_id, 'device_command', {
|
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'}`,
|
note: `MHI manual control on ${device.location || device.discovered_name || device.external_ref}: ${JSON.stringify({ onOff, mode, tempC, fanSpeed })}${ok ? '' : ' — FAILED'}`,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,10 @@ services:
|
||||||
- CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET}
|
- CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET}
|
||||||
- SETTINGS_URL=${SETTINGS_URL}
|
- SETTINGS_URL=${SETTINGS_URL}
|
||||||
- SETTINGS_SECRET=${SETTINGS_SECRET}
|
- 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
|
- APP_SLUG=hvac
|
||||||
- OFFICE_IP_CHECK=${OFFICE_IP_CHECK:-disabled}
|
- OFFICE_IP_CHECK=${OFFICE_IP_CHECK:-disabled}
|
||||||
- NEWBOOK_LOCATION_ID=${NEWBOOK_LOCATION_ID:-}
|
- NEWBOOK_LOCATION_ID=${NEWBOOK_LOCATION_ID:-}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { Thermometer, BatteryLow, TriangleAlert, Snowflake, Flame, Fan, Droplet,
|
||||||
import type { ZoneStatus } from '../types'
|
import type { ZoneStatus } from '../types'
|
||||||
import { ROOM_STATE_LABELS, MHI_FAN_SPEEDS } 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> = {
|
const MODE_ICONS: Record<string, typeof Snowflake> = {
|
||||||
cool: Snowflake,
|
cool: Snowflake,
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,7 @@ export default function ZoneDetailModal({ zone, onClose, onSaved }: {
|
||||||
</div>
|
</div>
|
||||||
<span className={`badge badge-health-${d.health_state}`}>{d.health_state.replace('_', ' ')}</span>
|
<span className={`badge badge-health-${d.health_state}`}>{d.health_state.replace('_', ' ')}</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import GatewaysPanel from '../components/GatewaysPanel'
|
||||||
import { useAuth } from '../components/AuthGate'
|
import { useAuth } from '../components/AuthGate'
|
||||||
import { can } from '../types'
|
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() {
|
export default function Devices() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
export type ZoneType = 'room' | 'public_area'
|
export type ZoneType = 'room' | 'public_area'
|
||||||
export type ZoneSource = 'newbook' | 'manual'
|
export type ZoneSource = 'newbook' | 'manual'
|
||||||
export type RoomState = 'vacant' | 'booked' | 'heating_up' | 'occupied' | 'cooling_down'
|
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 HealthState = 'healthy' | 'degraded' | 'poor' | 'unresponsive' | 'calibration_error'
|
||||||
export type PhotoType = 'device' | 'serial_plate'
|
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> = {
|
export const DEVICE_TYPE_LABELS: Record<DeviceType, string> = {
|
||||||
shelly_trv: 'Shelly TRV',
|
shelly_trv: 'Shelly TRV',
|
||||||
mhi_modbus: 'MHI Aircon (Modbus)',
|
mhi_modbus: 'MHI Aircon (Modbus)',
|
||||||
|
mhi_mqtt: 'MHI Aircon (MQTT)',
|
||||||
midea: 'Midea Split',
|
midea: 'Midea Split',
|
||||||
daikin: 'Daikin Split',
|
daikin: 'Daikin Split',
|
||||||
home_assistant: 'Home Assistant',
|
home_assistant: 'Home Assistant',
|
||||||
}
|
}
|
||||||
|
|
||||||
// Device types with an implemented Phase 1 driver — everything else in
|
// Device types with an implemented Phase 1/2 driver — everything else in
|
||||||
// DeviceType is an enum value only, ready for its Phase 2/3 driver.
|
// DeviceType is an enum value only, ready for its Phase 3 driver.
|
||||||
export const IMPLEMENTED_DEVICE_TYPES: DeviceType[] = ['shelly_trv', 'home_assistant']
|
export const IMPLEMENTED_DEVICE_TYPES: DeviceType[] = ['shelly_trv', 'mhi_mqtt', 'home_assistant']
|
||||||
|
|
||||||
export interface Zone {
|
export interface Zone {
|
||||||
id: number
|
id: number
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue