Redesign dashboard zone card with live temps, AC status, and per-radiator state
Adds actual room-temperature capture for TRVs over MQTT and a background Modbus poller for AC mode/fan/lock/room-temp, so the dashboard tile can show real setpoint-vs-actual readings, an AC status block, and up to 3 radiator icons per zone instead of just the setpoint. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
ab14d4dfe1
commit
0929285651
9 changed files with 230 additions and 12 deletions
|
|
@ -141,6 +141,17 @@ export async function initDb() {
|
|||
// 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`)
|
||||
|
||||
// Dashboard live-status cache — populated by mqtt.js (shelly_trv: current_room_temp,
|
||||
// heating_active) and ac-poller.js (mhi_modbus: everything else). Nullable, since
|
||||
// which columns apply depends on device_type and no reading has necessarily arrived yet.
|
||||
await pool.query(`ALTER TABLE zone_devices ADD COLUMN IF NOT EXISTS current_room_temp NUMERIC(4,1)`)
|
||||
await pool.query(`ALTER TABLE zone_devices ADD COLUMN IF NOT EXISTS heating_active BOOLEAN`)
|
||||
await pool.query(`ALTER TABLE zone_devices ADD COLUMN IF NOT EXISTS power_state TEXT`)
|
||||
await pool.query(`ALTER TABLE zone_devices ADD COLUMN IF NOT EXISTS current_mode TEXT`)
|
||||
await pool.query(`ALTER TABLE zone_devices ADD COLUMN IF NOT EXISTS fan_speed TEXT`)
|
||||
await pool.query(`ALTER TABLE zone_devices ADD COLUMN IF NOT EXISTS remote_locked BOOLEAN`)
|
||||
await pool.query(`ALTER TABLE zone_devices ADD COLUMN IF NOT EXISTS status_updated_at TIMESTAMPTZ`)
|
||||
|
||||
await seedDefaults()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { dirname, join } from 'path'
|
|||
import { initDb } from './db.js'
|
||||
import { connect as connectMqtt } from './lib/mqtt.js'
|
||||
import { startScheduler } from './lib/scheduler.js'
|
||||
import { startAcPoller } from './lib/ac-poller.js'
|
||||
import { zoneRoutes } from './routes/zones.js'
|
||||
import { deviceRoutes } from './routes/devices.js'
|
||||
import { statusRoutes } from './routes/status.js'
|
||||
|
|
@ -52,6 +53,7 @@ try {
|
|||
connectMqtt().catch(err => app.log.warn(`MQTT connect failed at startup: ${err.message}`))
|
||||
|
||||
await startScheduler()
|
||||
await startAcPoller().catch(err => app.log.warn(`AC poller failed to start: ${err.message}`))
|
||||
await app.listen({ port: 3001, host: '0.0.0.0' })
|
||||
} catch (err) {
|
||||
app.log.error(err)
|
||||
|
|
|
|||
61
backend/src/lib/ac-poller.js
Normal file
61
backend/src/lib/ac-poller.js
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
// Background refresh for AC (mhi_modbus) live status, so the dashboard can show mode/
|
||||
// fan speed/room temp/lock state without a per-card Modbus round trip. Unlike TRVs
|
||||
// (which push status over MQTT — see mqtt.js), Modbus has no push mechanism, so this
|
||||
// polls every assigned unit on a short interval and caches the result on zone_devices.
|
||||
// Same enqueue()-per-gateway serialisation as mhi-modbus.js's getStatus()/setTarget(),
|
||||
// so this never races a staff member's live MhiControlPanel read/write.
|
||||
import { pool } from '../db.js'
|
||||
import * as mhiDriver from './drivers/mhi-modbus.js'
|
||||
|
||||
const POLL_MS = 60 * 1000
|
||||
|
||||
let timerHandle = null
|
||||
let running = false
|
||||
|
||||
async function pollOnce() {
|
||||
const { rows: devices } = await pool.query(
|
||||
`SELECT id, external_ref FROM zone_devices
|
||||
WHERE device_type = 'mhi_modbus' AND zone_id IS NOT NULL AND mhi_gateway_id IS NOT NULL`
|
||||
)
|
||||
|
||||
for (const device of devices) {
|
||||
try {
|
||||
const status = await mhiDriver.getStatus(device.external_ref)
|
||||
if (!status || status.error) continue // gateway unreachable this tick — leave the last-known cache in place
|
||||
|
||||
await pool.query(
|
||||
`UPDATE zone_devices
|
||||
SET current_mode = $2, power_state = $3, fan_speed = $4, current_room_temp = $5,
|
||||
remote_locked = $6, status_updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[
|
||||
device.id,
|
||||
status.mode ?? null,
|
||||
status.onOff ?? null,
|
||||
status.fanSpeed ?? null,
|
||||
status.roomTemp ?? null,
|
||||
status.remoteLock === 'lock' ? true : status.remoteLock === 'unlock' ? false : null,
|
||||
]
|
||||
)
|
||||
} catch (err) {
|
||||
console.error(`[ac-poller] device ${device.id} poll failed:`, err.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function startAcPoller() {
|
||||
const tick = async () => {
|
||||
if (running) return
|
||||
running = true
|
||||
try { await pollOnce() } catch (err) { console.error('[ac-poller] poll failed:', err.message) }
|
||||
running = false
|
||||
}
|
||||
|
||||
await tick()
|
||||
timerHandle = setInterval(tick, POLL_MS)
|
||||
}
|
||||
|
||||
export function stopAcPoller() {
|
||||
if (timerHandle) clearInterval(timerHandle)
|
||||
timerHandle = null
|
||||
}
|
||||
|
|
@ -169,7 +169,7 @@ export async function getStatus(externalRef) {
|
|||
const gateway = await loadGatewayById(device.mhi_gateway_id)
|
||||
if (!gateway) return { external_ref: externalRef, error: 'No gateway configured for this device' }
|
||||
|
||||
const fieldsToRead = ['onOff', 'mode', 'setpoint', 'fanSpeed', 'roomTemp', 'errorCode', 'compressor', 'commStatus', 'filterSign']
|
||||
const fieldsToRead = ['onOff', 'mode', 'setpoint', 'fanSpeed', 'roomTemp', 'errorCode', 'compressor', 'commStatus', 'filterSign', 'remoteLock']
|
||||
const result = { external_ref: externalRef }
|
||||
try {
|
||||
await runOnGateway(gateway, async client => {
|
||||
|
|
|
|||
|
|
@ -157,6 +157,37 @@ function topicMatchesStatus(topic) {
|
|||
return /^shellies\/(.+)\/thermostat\/0\/target_t$/.exec(topic)
|
||||
}
|
||||
|
||||
function topicMatchesInfo(topic) {
|
||||
return /^shellies\/(.+)\/info$/.exec(topic)
|
||||
}
|
||||
|
||||
// shellies/<id>/info -> full Gen1 status snapshot, republished on every state change.
|
||||
// For TRVs (SHTRV-01) this carries thermostats[0].tmp.value (actual sensed room temp)
|
||||
// and thermostats[0].pos (valve position 0-100, >0 = actively calling for heat) — the
|
||||
// dashboard tile's "setpoint / room temp" and heating-icon colour both read from this.
|
||||
// UNVERIFIED against real hardware: confirm the field paths below with `mosquitto_sub -v
|
||||
// -t 'shellies/#'` against a live TRV before trusting the values, and adjust if the
|
||||
// actual payload shape differs.
|
||||
async function handleInfo(externalRef, payload) {
|
||||
const thermostat = payload.thermostats?.[0]
|
||||
if (!thermostat) return
|
||||
|
||||
const roomTemp = thermostat.tmp?.value
|
||||
const valvePos = thermostat.pos
|
||||
const heatingActive = typeof valvePos === 'number' ? valvePos > 0 : null
|
||||
|
||||
try {
|
||||
await pool.query(
|
||||
`UPDATE zone_devices
|
||||
SET current_room_temp = $2, heating_active = $3, status_updated_at = NOW()
|
||||
WHERE device_type = 'shelly_trv' AND external_ref = $1`,
|
||||
[externalRef, roomTemp ?? null, heatingActive]
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('[mqtt] failed to record info update:', err.message)
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (connecting) return
|
||||
const delay = BROKER_RECONNECT_BACKOFF_MS[Math.min(reconnectAttempt, BROKER_RECONNECT_BACKOFF_MS.length - 1)]
|
||||
|
|
@ -184,7 +215,7 @@ 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'])
|
||||
c.subscribe(['shellies/announce', 'shellies/+/settings', 'shellies/+/thermostat/0/target_t', 'shellies/+/info'])
|
||||
resolve()
|
||||
})
|
||||
|
||||
|
|
@ -204,6 +235,11 @@ export async function connect() {
|
|||
const statusMatch = topicMatchesStatus(topic)
|
||||
if (statusMatch) {
|
||||
handleStatus(statusMatch[1], payload).catch(() => {})
|
||||
return
|
||||
}
|
||||
const infoMatch = topicMatchesInfo(topic)
|
||||
if (infoMatch) {
|
||||
handleInfo(infoMatch[1], payload).catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,9 @@ export async function statusRoutes(app) {
|
|||
|
||||
const { rows: devices } = await pool.query(`
|
||||
SELECT id, zone_id, device_type, external_ref, discovered_name, location,
|
||||
health_state, battery_pct, wifi_rssi, current_target_temp, target_origin, last_seen
|
||||
health_state, battery_pct, wifi_rssi, current_target_temp, target_origin, last_seen,
|
||||
current_room_temp, heating_active, power_state, current_mode, fan_speed,
|
||||
remote_locked, status_updated_at
|
||||
FROM zone_devices WHERE zone_id IS NOT NULL
|
||||
`)
|
||||
const devicesByZone = new Map()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue