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.
|
// 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`)
|
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()
|
await seedDefaults()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { dirname, join } from 'path'
|
||||||
import { initDb } from './db.js'
|
import { initDb } from './db.js'
|
||||||
import { connect as connectMqtt } from './lib/mqtt.js'
|
import { connect as connectMqtt } from './lib/mqtt.js'
|
||||||
import { startScheduler } from './lib/scheduler.js'
|
import { startScheduler } from './lib/scheduler.js'
|
||||||
|
import { startAcPoller } from './lib/ac-poller.js'
|
||||||
import { zoneRoutes } from './routes/zones.js'
|
import { zoneRoutes } from './routes/zones.js'
|
||||||
import { deviceRoutes } from './routes/devices.js'
|
import { deviceRoutes } from './routes/devices.js'
|
||||||
import { statusRoutes } from './routes/status.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}`))
|
connectMqtt().catch(err => app.log.warn(`MQTT connect failed at startup: ${err.message}`))
|
||||||
|
|
||||||
await startScheduler()
|
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' })
|
await app.listen({ port: 3001, host: '0.0.0.0' })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
app.log.error(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)
|
const gateway = await loadGatewayById(device.mhi_gateway_id)
|
||||||
if (!gateway) return { external_ref: externalRef, error: 'No gateway configured for this device' }
|
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 }
|
const result = { external_ref: externalRef }
|
||||||
try {
|
try {
|
||||||
await runOnGateway(gateway, async client => {
|
await runOnGateway(gateway, async client => {
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,37 @@ function topicMatchesStatus(topic) {
|
||||||
return /^shellies\/(.+)\/thermostat\/0\/target_t$/.exec(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() {
|
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)]
|
||||||
|
|
@ -184,7 +215,7 @@ 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'])
|
c.subscribe(['shellies/announce', 'shellies/+/settings', 'shellies/+/thermostat/0/target_t', 'shellies/+/info'])
|
||||||
resolve()
|
resolve()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -204,6 +235,11 @@ export async function connect() {
|
||||||
const statusMatch = topicMatchesStatus(topic)
|
const statusMatch = topicMatchesStatus(topic)
|
||||||
if (statusMatch) {
|
if (statusMatch) {
|
||||||
handleStatus(statusMatch[1], payload).catch(() => {})
|
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(`
|
const { rows: devices } = await pool.query(`
|
||||||
SELECT id, zone_id, device_type, external_ref, discovered_name, location,
|
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
|
FROM zone_devices WHERE zone_id IS NOT NULL
|
||||||
`)
|
`)
|
||||||
const devicesByZone = new Map()
|
const devicesByZone = new Map()
|
||||||
|
|
|
||||||
|
|
@ -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 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<string, typeof Snowflake> = {
|
||||||
|
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 }) {
|
export default function ZoneCard({ zone, onClick }: { zone: ZoneStatus; onClick: () => void }) {
|
||||||
const unhealthy = zone.devices.filter(d => d.health_state !== 'healthy').length
|
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 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 (
|
return (
|
||||||
<div className={`card zone-card st-${zone.room_state}`} onClick={onClick}>
|
<div className={`card zone-card st-${zone.room_state}`} onClick={onClick}>
|
||||||
<div className="zone-card-title">
|
<div className="zone-card-title">
|
||||||
<span>{zone.name}</span>
|
<span>
|
||||||
|
{zone.name}
|
||||||
|
{unhealthy > 0 && (
|
||||||
|
<TriangleAlert
|
||||||
|
size={14}
|
||||||
|
strokeWidth={1.75}
|
||||||
|
color={worstHealth ? 'var(--health-unresponsive)' : 'var(--health-degraded)'}
|
||||||
|
style={{ verticalAlign: '-2px', marginLeft: 6 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
{!zone.auto_mode && <span className="badge badge-outline">Manual</span>}
|
{!zone.auto_mode && <span className="badge badge-outline">Manual</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className={`badge badge-st-${zone.room_state}`}>{ROOM_STATE_LABELS[zone.room_state]}</div>
|
<div className={`badge badge-st-${zone.room_state}`}>{ROOM_STATE_LABELS[zone.room_state]}</div>
|
||||||
|
|
||||||
|
<div className="zone-card-body">
|
||||||
|
<div className="zone-card-main">
|
||||||
<div className="zone-card-temp">
|
<div className="zone-card-temp">
|
||||||
<Thermometer size={16} strokeWidth={1.75} style={{ verticalAlign: '-2px', marginRight: 4 }} />
|
<Thermometer size={16} strokeWidth={1.75} style={{ verticalAlign: '-2px', marginRight: 4 }} />
|
||||||
{Number(zone.target_temp).toFixed(1)}°C
|
{Number(zone.target_temp).toFixed(1)}°C / {fmtTemp(avgRoomTemp)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{trvs.length > 0 && (
|
||||||
|
<div className="rad-row">
|
||||||
|
{trvs.slice(0, 3).map(d => (
|
||||||
|
<div key={d.id} className={`rad-icon ${d.heating_active ? 'heating' : 'idle'}`}>
|
||||||
|
<Heater size={16} strokeWidth={1.75} />
|
||||||
|
<span>{fmtTemp(d.current_target_temp)}/{fmtTemp(d.current_room_temp)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ac && (
|
||||||
|
<div className={`ac-block${acOn ? '' : ' off'}`}>
|
||||||
|
{ac.remote_locked && <Lock size={12} strokeWidth={1.75} className="ac-lock" />}
|
||||||
|
<div className="ac-mode-row">
|
||||||
|
<ModeIcon size={20} strokeWidth={1.75} />
|
||||||
|
<div className="fan-bar">
|
||||||
|
{[1, 2, 3, 4].map(n => <span key={n} className={n <= fanLevel ? 'on' : ''} />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ac-temp">{fmtTemp(ac.current_target_temp)} / {fmtTemp(ac.current_room_temp)}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="zone-card-meta">
|
<div className="zone-card-meta">
|
||||||
<span>{zone.device_count ?? zone.devices.length} device{(zone.device_count ?? zone.devices.length) === 1 ? '' : 's'}</span>
|
<span>{zone.device_count ?? zone.devices.length} device{(zone.device_count ?? zone.devices.length) === 1 ? '' : 's'}</span>
|
||||||
{unhealthy > 0 && <span style={{ color: 'var(--health-degraded)' }}>{unhealthy} needs attention</span>}
|
|
||||||
{lowBattery && <BatteryLow size={13} strokeWidth={1.75} color="var(--health-degraded)" />}
|
{lowBattery && <BatteryLow size={13} strokeWidth={1.75} color="var(--health-degraded)" />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -205,7 +205,7 @@ html, body, #root { height: 100%; margin: 0; font-size: 14px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Zone grid / cards ─────────────────────────────────────── */
|
/* ── 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 {
|
.zone-card {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: box-shadow .12s, transform .12s;
|
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-occupied { border-left-color: var(--st-occupied); }
|
||||||
.zone-card.st-cooling_down { border-left-color: var(--st-cooling-down); }
|
.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-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-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 ────────────────────────────────────────────────── */
|
/* ── Badges ────────────────────────────────────────────────── */
|
||||||
.badge {
|
.badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,15 @@ export interface DeviceSummary {
|
||||||
current_target_temp: string | null
|
current_target_temp: string | null
|
||||||
target_origin: string | null
|
target_origin: string | null
|
||||||
last_seen: 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 {
|
export interface Device extends DeviceSummary {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue