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:
jtricerolph 2026-07-27 20:25:50 +00:00
parent ab14d4dfe1
commit 0929285651
9 changed files with 230 additions and 12 deletions

View file

@ -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<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 }) {
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 (
<div className={`card zone-card st-${zone.room_state}`} onClick={onClick}>
<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>}
</div>
<div className={`badge badge-st-${zone.room_state}`}>{ROOM_STATE_LABELS[zone.room_state]}</div>
<div className="zone-card-temp">
<Thermometer size={16} strokeWidth={1.75} style={{ verticalAlign: '-2px', marginRight: 4 }} />
{Number(zone.target_temp).toFixed(1)}°C
<div className="zone-card-body">
<div className="zone-card-main">
<div className="zone-card-temp">
<Thermometer size={16} strokeWidth={1.75} style={{ verticalAlign: '-2px', marginRight: 4 }} />
{Number(zone.target_temp).toFixed(1)}°C / {fmtTemp(avgRoomTemp)}
</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">
<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)" />}
</div>
</div>

View file

@ -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;

View file

@ -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 {