From 09292856519878b59e167b4db9a38aeacd667894 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Mon, 27 Jul 2026 20:25:50 +0000 Subject: [PATCH] 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 --- backend/src/db.js | 11 ++++ backend/src/index.js | 2 + backend/src/lib/ac-poller.js | 61 +++++++++++++++++++ backend/src/lib/drivers/mhi-modbus.js | 2 +- backend/src/lib/mqtt.js | 38 +++++++++++- backend/src/routes/status.js | 4 +- frontend/src/components/ZoneCard.tsx | 88 ++++++++++++++++++++++++--- frontend/src/index.css | 27 +++++++- frontend/src/types.ts | 9 +++ 9 files changed, 230 insertions(+), 12 deletions(-) create mode 100644 backend/src/lib/ac-poller.js diff --git a/backend/src/db.js b/backend/src/db.js index 5331d28..c3da3dd 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -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() } diff --git a/backend/src/index.js b/backend/src/index.js index e83a019..fd255b6 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -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) diff --git a/backend/src/lib/ac-poller.js b/backend/src/lib/ac-poller.js new file mode 100644 index 0000000..d77fe5e --- /dev/null +++ b/backend/src/lib/ac-poller.js @@ -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 +} diff --git a/backend/src/lib/drivers/mhi-modbus.js b/backend/src/lib/drivers/mhi-modbus.js index f11f0eb..bb10df7 100644 --- a/backend/src/lib/drivers/mhi-modbus.js +++ b/backend/src/lib/drivers/mhi-modbus.js @@ -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 => { diff --git a/backend/src/lib/mqtt.js b/backend/src/lib/mqtt.js index 1a53b0e..90d4183 100644 --- a/backend/src/lib/mqtt.js +++ b/backend/src/lib/mqtt.js @@ -157,6 +157,37 @@ function topicMatchesStatus(topic) { return /^shellies\/(.+)\/thermostat\/0\/target_t$/.exec(topic) } +function topicMatchesInfo(topic) { + return /^shellies\/(.+)\/info$/.exec(topic) +} + +// shellies//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(() => {}) } }) diff --git a/backend/src/routes/status.js b/backend/src/routes/status.js index 1f649f6..6c3623c 100644 --- a/backend/src/routes/status.js +++ b/backend/src/routes/status.js @@ -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() diff --git a/frontend/src/components/ZoneCard.tsx b/frontend/src/components/ZoneCard.tsx index 118f585..07f448d 100644 --- a/frontend/src/components/ZoneCard.tsx +++ b/frontend/src/components/ZoneCard.tsx @@ -1,25 +1,99 @@ -import { Thermometer, BatteryLow } from 'lucide-react' +import { Thermometer, BatteryLow, TriangleAlert, Snowflake, Flame, Fan, Droplet, Lock, Heater } from 'lucide-react' import type { ZoneStatus } from '../types' -import { ROOM_STATE_LABELS } from '../types' +import { ROOM_STATE_LABELS, MHI_FAN_SPEEDS } from '../types' + +const AC_DEVICE_TYPES = ['mhi_modbus', 'midea', 'daikin', 'home_assistant'] + +const MODE_ICONS: Record = { + cool: Snowflake, + heat: Flame, + fan: Fan, + dry: Droplet, + auto: Fan, +} + +// bed -> living/lounge -> bathroom, matching the order in the sketch; anything else last. +function locationRank(location: string | null) { + const l = (location || '').toLowerCase() + if (l.includes('bed')) return 0 + if (l.includes('living') || l.includes('lounge')) return 1 + if (l.includes('bath')) return 2 + return 3 +} + +function fmtTemp(t: string | number | null | undefined) { + return t != null ? `${Number(t).toFixed(1)}°C` : '—' +} export default function ZoneCard({ zone, onClick }: { zone: ZoneStatus; onClick: () => void }) { const unhealthy = zone.devices.filter(d => d.health_state !== 'healthy').length + const worstHealth = zone.devices.some(d => d.health_state === 'unresponsive' || d.health_state === 'poor') const lowBattery = zone.devices.some(d => d.battery_pct != null && d.battery_pct < 30) + const trvs = zone.devices + .filter(d => d.device_type === 'shelly_trv') + .sort((a, b) => locationRank(a.location) - locationRank(b.location)) + const trvReadings = trvs.map(d => d.current_room_temp).filter((t): t is string => t != null).map(Number) + const avgRoomTemp = trvReadings.length ? trvReadings.reduce((a, b) => a + b, 0) / trvReadings.length : null + + const ac = zone.devices.find(d => AC_DEVICE_TYPES.includes(d.device_type)) + const acOn = ac?.power_state === 'on' + const ModeIcon = MODE_ICONS[ac?.current_mode || ''] || Fan + const fanLevel = ac ? (MHI_FAN_SPEEDS as readonly string[]).indexOf(ac.fan_speed || '') + 1 : 0 + return (
- {zone.name} + + {zone.name} + {unhealthy > 0 && ( + + )} + {!zone.auto_mode && Manual}
{ROOM_STATE_LABELS[zone.room_state]}
-
- - {Number(zone.target_temp).toFixed(1)}°C + +
+
+
+ + {Number(zone.target_temp).toFixed(1)}°C / {fmtTemp(avgRoomTemp)} +
+ + {trvs.length > 0 && ( +
+ {trvs.slice(0, 3).map(d => ( +
+ + {fmtTemp(d.current_target_temp)}/{fmtTemp(d.current_room_temp)} +
+ ))} +
+ )} +
+ + {ac && ( +
+ {ac.remote_locked && } +
+ +
+ {[1, 2, 3, 4].map(n => )} +
+
+
{fmtTemp(ac.current_target_temp)} / {fmtTemp(ac.current_room_temp)}
+
+ )}
+
{zone.device_count ?? zone.devices.length} device{(zone.device_count ?? zone.devices.length) === 1 ? '' : 's'} - {unhealthy > 0 && {unhealthy} needs attention} {lowBattery && }
diff --git a/frontend/src/index.css b/frontend/src/index.css index 21b96c2..f6cca4d 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -205,7 +205,7 @@ html, body, #root { height: 100%; margin: 0; font-size: 14px; } } /* ── Zone grid / cards ─────────────────────────────────────── */ -.zone-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; } +.zone-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 12px; } .zone-card { cursor: pointer; transition: box-shadow .12s, transform .12s; @@ -218,9 +218,32 @@ html, body, #root { height: 100%; margin: 0; font-size: 14px; } .zone-card.st-occupied { border-left-color: var(--st-occupied); } .zone-card.st-cooling_down { border-left-color: var(--st-cooling-down); } .zone-card-title { font-weight: 600; font-size: 14px; margin-bottom: 4px; display: flex; align-items: center; gap: 6px; justify-content: space-between; } -.zone-card-temp { font-size: 22px; font-weight: 700; margin: 6px 0 2px; } +.zone-card-temp { font-size: 20px; font-weight: 700; margin: 6px 0 2px; } .zone-card-meta { font-size: 12px; color: var(--text-mid); display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } +/* ── Zone card body: setpoint/radiators on the left, AC block on the right ─ */ +.zone-card-body { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; margin-top: 4px; } +.zone-card-main { flex: 1; min-width: 0; } + +.rad-row { display: flex; gap: 10px; margin-top: 8px; flex-wrap: wrap; } +.rad-icon { display: flex; align-items: center; gap: 3px; font-size: 10.5px; font-weight: 600; color: var(--text-mid); } +.rad-icon.heating { color: var(--st-occupied); } +.rad-icon.idle { color: var(--st-cooling-down); } + +.ac-block { position: relative; display: flex; flex-direction: column; align-items: center; gap: 2px; padding-top: 4px; color: var(--text-dark); } +.ac-block.off { color: var(--text-mid); opacity: .55; } +.ac-lock { position: absolute; top: -10px; left: 50%; transform: translateX(-50%); color: var(--health-degraded); } +.ac-mode-row { display: flex; align-items: center; gap: 6px; } +.ac-temp { font-size: 10.5px; font-weight: 600; color: var(--text-mid); } + +.fan-bar { display: flex; align-items: flex-end; gap: 2px; height: 16px; } +.fan-bar span { width: 3px; background: var(--card-border); border-radius: 1px; } +.fan-bar span:nth-child(1) { height: 25%; } +.fan-bar span:nth-child(2) { height: 50%; } +.fan-bar span:nth-child(3) { height: 75%; } +.fan-bar span:nth-child(4) { height: 100%; } +.fan-bar span.on { background: var(--app-primary); } + /* ── Badges ────────────────────────────────────────────────── */ .badge { display: inline-flex; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 8e8ab50..0fcfe99 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -64,6 +64,15 @@ export interface DeviceSummary { current_target_temp: string | null target_origin: string | null last_seen: string | null + // Dashboard live-status cache — shelly_trv populates current_room_temp/heating_active + // via MQTT (mqtt.js); mhi_modbus populates the rest via the AC poller (ac-poller.js). + current_room_temp: string | null + heating_active: boolean | null + power_state: 'on' | 'off' | null + current_mode: string | null + fan_speed: string | null + remote_locked: boolean | null + status_updated_at: string | null } export interface Device extends DeviceSummary {