Add device telemetry history + per-device graph view

activity_log only ever recorded state transitions/overrides/sync/errors —
useful for "what did the app decide" but not "what was the room actually
doing" when a guest/staff complaint comes in after the fact.

Backend:
- New device_readings time-series table (room_temp, target_temp, mode,
  power_state, fan_speed, valve_position, heating_active), throttled to
  ~1 row/device/minute via recordReading() in db.js regardless of how
  often a caller fires — MQTT pushes can be far chattier than that.
- Wired into every place zone_devices' live-status cache already gets
  updated: ac-poller.js, daikin-poller.js, and mqtt.js's TRV
  (handleStatusBlob/handleInfo) and native-MQTT MHI (writeMhiState)
  handlers.
- 4-day retention by default (config key history_retention_days),
  pruned every 6h by the new history-pruner.js.
- GET /api/devices/:id/history?hours=96 to read it back.

Frontend:
- DeviceHistoryModal: room temp vs setpoint line chart, plus a
  valve-position chart (TRVs) or fan-speed step chart (AC), plus a
  diffed mode/power/fan change log — 24h/4d/7d range picker. Uses
  recharts, matching the convention already established in
  utilities/wages/reports.
- Graph icon on each device in both ZoneDetailModal and the Devices
  page opens it.

Side effect: frontend/package-lock.json was missing vite-plugin-pwa's
whole dependency tree even though package.json already declared it —
pre-existing drift, reconciled by this npm install (unrelated to the
recharts addition, just came along with it).
This commit is contained in:
jtricerolph 2026-08-25 17:28:45 +00:00
parent 59a3e091d8
commit f0b4ac3c41
15 changed files with 5172 additions and 38 deletions

View file

@ -1,6 +1,6 @@
import type {
Zone, ZoneStatus, Device, DevicePhoto, ActivityEntry, NewbookSite, AppConfig,
MhiGateway, MhiStatus, ActiveAcTemplate, DaikinStatus, TrvStatus,
MhiGateway, MhiStatus, ActiveAcTemplate, DaikinStatus, TrvStatus, DeviceReading,
} from './types'
const BASE = '/hvac/api'
@ -83,6 +83,9 @@ export function deleteDevice(id: number): Promise<{ ok: boolean }> {
export function createMaintenanceAsset(id: number): Promise<{ ok: boolean; notImplemented?: boolean; error?: string }> {
return request(`/devices/${id}/create-maintenance-asset`, { method: 'POST', body: JSON.stringify({}) })
}
export function fetchDeviceHistory(id: number, hours = 96): Promise<DeviceReading[]> {
return request(`/devices/${id}/history?hours=${hours}`)
}
// Device photos — multipart, so no JSON content-type header
export async function uploadDevicePhoto(deviceId: number, file: File, photoType: 'device' | 'serial_plate'): Promise<DevicePhoto> {

View file

@ -0,0 +1,225 @@
import { useEffect, useMemo, useState } from 'react'
import {
LineChart, Line, AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
} from 'recharts'
import { X, History } from 'lucide-react'
import type { DeviceReading, DeviceSummary } from '../types'
import { MHI_FAN_SPEEDS, DAIKIN_FAN_SPEEDS } from '../types'
import { fetchDeviceHistory } from '../api'
// Ordinal ranking for the fan-speed step chart — a superset of both drivers' enums
// (DAIKIN_FAN_SPEEDS adds 'auto' at the bottom; MHI has no 'auto'). Any value not in
// this list (shouldn't happen, but a driver could send something unexpected) renders
// as a gap rather than crashing the chart.
const FAN_SPEED_ORDER = Array.from(new Set([...DAIKIN_FAN_SPEEDS, ...MHI_FAN_SPEEDS]))
function fanLevel(speed: string | null): number | null {
if (!speed) return null
const i = FAN_SPEED_ORDER.indexOf(speed as typeof FAN_SPEED_ORDER[number])
return i === -1 ? null : i + 1
}
const RANGES = [
{ label: '24h', hours: 24 },
{ label: '4 days', hours: 96 },
{ label: '7 days', hours: 168 },
]
function fmtTick(ts: number, hours: number) {
const d = new Date(ts)
return hours > 48
? d.toLocaleDateString(undefined, { day: '2-digit', month: 'short' }) + ' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
: d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
}
function fmtFull(ts: number) {
return new Date(ts).toLocaleString()
}
type TempPoint = { t: number; roomTemp: number | null; targetTemp: number | null }
type StatePoint = { t: number; valvePosition: number | null; fanLevel: number | null; fanSpeed: string | null }
function TempTooltip({ active, payload }: { active?: boolean; payload?: { payload: TempPoint }[] }) {
if (!active || !payload?.length) return null
const p = payload[0].payload
return (
<div className="history-tooltip">
<div className="ht-time">{fmtFull(p.t)}</div>
{p.roomTemp != null && (
<div className="ht-row"><span className="ht-dot" style={{ background: 'var(--gold)' }} />Room {p.roomTemp.toFixed(1)}°C</div>
)}
{p.targetTemp != null && (
<div className="ht-row"><span className="ht-dot" style={{ background: 'var(--st-occupied)' }} />Setpoint {p.targetTemp.toFixed(1)}°C</div>
)}
</div>
)
}
function ValveTooltip({ active, payload }: { active?: boolean; payload?: { payload: StatePoint }[] }) {
if (!active || !payload?.length) return null
const p = payload[0].payload
return (
<div className="history-tooltip">
<div className="ht-time">{fmtFull(p.t)}</div>
{p.valvePosition != null && <div className="ht-row">Valve {p.valvePosition}%</div>}
</div>
)
}
function FanTooltip({ active, payload }: { active?: boolean; payload?: { payload: StatePoint }[] }) {
if (!active || !payload?.length) return null
const p = payload[0].payload
return (
<div className="history-tooltip">
<div className="ht-time">{fmtFull(p.t)}</div>
{p.fanSpeed != null && <div className="ht-row">Fan {p.fanSpeed}</div>}
</div>
)
}
// Debug-friendly diff log of mode/power/fan/heating changes, built by comparing each
// reading against the last known value of each field independently (readings are
// sparse — see DeviceReading's comment — so a naive row-to-row diff would treat every
// "field wasn't reported this tick" as a change). Capped at the most recent 30 like
// ZoneDetailModal's activity feed.
interface ChangeEntry { at: string; text: string }
function buildChangeLog(readings: DeviceReading[]): ChangeEntry[] {
const entries: ChangeEntry[] = []
const last: { mode?: string | null; power_state?: string | null; fan_speed?: string | null; heating_active?: boolean | null } = {}
for (const r of readings) {
const changed: string[] = []
if (r.mode != null && r.mode !== last.mode) { changed.push(`Mode → ${r.mode}`); last.mode = r.mode }
if (r.power_state != null && r.power_state !== last.power_state) { changed.push(`Power → ${r.power_state}`); last.power_state = r.power_state }
if (r.fan_speed != null && r.fan_speed !== last.fan_speed) { changed.push(`Fan → ${r.fan_speed}`); last.fan_speed = r.fan_speed }
if (r.heating_active != null && r.heating_active !== last.heating_active) { changed.push(r.heating_active ? 'Heating started' : 'Heating stopped'); last.heating_active = r.heating_active }
if (changed.length) entries.push({ at: r.recorded_at, text: changed.join(' · ') })
}
return entries.slice(-30).reverse()
}
export default function DeviceHistoryModal({ device, onClose }: { device: DeviceSummary; onClose: () => void }) {
const [hours, setHours] = useState(96)
const [readings, setReadings] = useState<DeviceReading[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
setLoading(true); setError('')
fetchDeviceHistory(device.id, hours)
.then(setReadings)
.catch(e => setError(e instanceof Error ? e.message : 'Failed to load history'))
.finally(() => setLoading(false))
}, [device.id, hours])
const tempData = useMemo<TempPoint[]>(() => readings.map(r => ({
t: new Date(r.recorded_at).getTime(),
roomTemp: r.room_temp != null ? Number(r.room_temp) : null,
targetTemp: r.target_temp != null ? Number(r.target_temp) : null,
})), [readings])
const stateData = useMemo<StatePoint[]>(() => readings.map(r => ({
t: new Date(r.recorded_at).getTime(),
valvePosition: r.valve_position,
fanLevel: fanLevel(r.fan_speed),
fanSpeed: r.fan_speed,
})), [readings])
const isTrv = device.device_type === 'shelly_trv'
const hasFanData = !isTrv && stateData.some(p => p.fanLevel != null)
const changeLog = useMemo(() => buildChangeLog(readings), [readings])
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal modal-lg" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2>{device.location || device.discovered_name || device.external_ref} — history</h2>
<button className="modal-close" onClick={onClose}><X size={18} strokeWidth={1.75} /></button>
</div>
<div className="history-range-row">
{RANGES.map(r => (
<button key={r.hours} className={`btn btn-sm${hours === r.hours ? ' btn-primary' : ''}`} onClick={() => setHours(r.hours)}>
{r.label}
</button>
))}
</div>
{error && <div className="error-banner">{error}</div>}
{loading ? (
<p className="muted">Loading history…</p>
) : readings.length === 0 ? (
<div className="empty-state">No readings recorded yet for this range.</div>
) : (
<>
<div className="history-chart-title">Room temp vs setpoint</div>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={tempData} margin={{ top: 8, right: 8, left: 8, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--card-border)" />
<XAxis dataKey="t" type="number" domain={['dataMin', 'dataMax']} scale="time"
tickFormatter={(v: number) => fmtTick(v, hours)} tick={{ fontSize: 10 }} minTickGap={40} />
<YAxis tick={{ fontSize: 10 }} width={40} unit="°C" />
<Tooltip content={<TempTooltip />} />
<Line type="monotone" dataKey="roomTemp" name="Room temp" stroke="var(--gold)" strokeWidth={2} dot={false} connectNulls />
<Line type="monotone" dataKey="targetTemp" name="Setpoint" stroke="var(--st-occupied)" strokeWidth={2} strokeDasharray="4 3" dot={false} connectNulls />
</LineChart>
</ResponsiveContainer>
<div style={{ display: 'flex', gap: 14, fontSize: 11.5, color: 'var(--text-mid)', marginTop: 2 }}>
<span><span className="ht-dot" style={{ display: 'inline-block', width: 8, height: 8, borderRadius: '50%', background: 'var(--gold)', marginRight: 4 }} />Room temp</span>
<span><span className="ht-dot" style={{ display: 'inline-block', width: 8, height: 8, borderRadius: '50%', background: 'var(--st-occupied)', marginRight: 4 }} />Setpoint</span>
</div>
{isTrv && (
<>
<div className="history-chart-title">Valve position</div>
<ResponsiveContainer width="100%" height={120}>
<AreaChart data={stateData} margin={{ top: 8, right: 8, left: 8, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--card-border)" />
<XAxis dataKey="t" type="number" domain={['dataMin', 'dataMax']} scale="time"
tickFormatter={(v: number) => fmtTick(v, hours)} tick={{ fontSize: 10 }} minTickGap={40} />
<YAxis tick={{ fontSize: 10 }} width={40} unit="%" domain={[0, 100]} />
<Tooltip content={<ValveTooltip />} />
<Area type="stepAfter" dataKey="valvePosition" name="Valve %" stroke="var(--st-heating-up)" fill="var(--st-heating-up)" fillOpacity={0.2} strokeWidth={2} connectNulls />
</AreaChart>
</ResponsiveContainer>
</>
)}
{hasFanData && (
<>
<div className="history-chart-title">Fan speed</div>
<ResponsiveContainer width="100%" height={120}>
<AreaChart data={stateData} margin={{ top: 8, right: 8, left: 8, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--card-border)" />
<XAxis dataKey="t" type="number" domain={['dataMin', 'dataMax']} scale="time"
tickFormatter={(v: number) => fmtTick(v, hours)} tick={{ fontSize: 10 }} minTickGap={40} />
<YAxis tick={{ fontSize: 10 }} width={70} domain={[0, FAN_SPEED_ORDER.length]}
ticks={FAN_SPEED_ORDER.map((_, i) => i + 1)}
tickFormatter={(v: number) => FAN_SPEED_ORDER[v - 1] || ''} />
<Tooltip content={<FanTooltip />} />
<Area type="stepAfter" dataKey="fanLevel" name="Fan" stroke="var(--app-primary)" fill="var(--app-primary)" fillOpacity={0.15} strokeWidth={2} connectNulls />
</AreaChart>
</ResponsiveContainer>
</>
)}
{changeLog.length > 0 && (
<>
<div className="section-title">
<History size={12} strokeWidth={1.75} style={{ verticalAlign: '-1px', marginRight: 4 }} />Mode &amp; power changes
</div>
<div className="timeline">
{changeLog.map((c, i) => (
<div key={i} className="timeline-item">
<div className="timeline-body">
{c.text}
<div className="timeline-meta">{new Date(c.at).toLocaleString()}</div>
</div>
</div>
))}
</div>
</>
)}
</>
)}
</div>
</div>
)
}

View file

@ -1,12 +1,13 @@
import { useEffect, useState } from 'react'
import { X, Thermometer, History } from 'lucide-react'
import type { ZoneStatus, ActivityEntry, HvacDefaults } from '../types'
import { X, Thermometer, History, LineChart } from 'lucide-react'
import type { ZoneStatus, ActivityEntry, HvacDefaults, DeviceSummary } from '../types'
import { ROOM_STATE_LABELS, DEFAULT_HVAC_TEMPLATE, MHI_MODES, MHI_FAN_SPEEDS, can } from '../types'
import { useAuth } from './AuthGate'
import { updateZone, overrideZone, fetchActivity, fetchConfig } from '../api'
import MhiControlPanel from './MhiControlPanel'
import DaikinControlPanel from './DaikinControlPanel'
import TrvControlPanel from './TrvControlPanel'
import DeviceHistoryModal from './DeviceHistoryModal'
// The 12 fields a room can individually override — each is `null` on the zone when
// inheriting the matching field from the global hvac_defaults template (Settings page),
@ -85,6 +86,7 @@ export default function ZoneDetailModal({ zone, onClose, onSaved }: {
const [error, setError] = useState('')
const [msg, setMsg] = useState('')
const [activity, setActivity] = useState<ActivityEntry[]>([])
const [historyDevice, setHistoryDevice] = useState<DeviceSummary | null>(null)
useEffect(() => {
fetchActivity(zone.id, 20).then(setActivity).catch(() => {})
@ -172,7 +174,12 @@ export default function ZoneDetailModal({ zone, onClose, onSaved }: {
{d.battery_pct != null && ` · ${d.battery_pct}% battery`}
</div>
</div>
<span className={`badge badge-health-${d.health_state}`}>{d.health_state.replace('_', ' ')}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className={`badge badge-health-${d.health_state}`}>{d.health_state.replace('_', ' ')}</span>
<button className="btn btn-sm" title="View history" onClick={() => setHistoryDevice(d)}>
<LineChart size={13} strokeWidth={1.75} />
</button>
</div>
</div>
{(d.device_type === 'mhi_modbus' || d.device_type === 'mhi_mqtt') && <MhiControlPanel deviceId={d.id} canControl={canControl} />}
{d.device_type === 'daikin' && <DaikinControlPanel deviceId={d.id} canControl={canControl} />}
@ -411,6 +418,8 @@ export default function ZoneDetailModal({ zone, onClose, onSaved }: {
</>
)}
</div>
{historyDevice && <DeviceHistoryModal device={historyDevice} onClose={() => setHistoryDevice(null)} />}
</div>
)
}

View file

@ -307,6 +307,22 @@ html, body, #root { height: 100%; margin: 0; font-size: 14px; }
display: flex;
}
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; flex-wrap: wrap; }
.modal-lg { max-width: 820px; }
/* ── Device history chart ─────────────────────────────────── */
.history-range-row { display: flex; gap: 6px; margin-bottom: 12px; }
.history-chart-title { font-size: 12.5px; font-weight: 600; color: var(--text-mid); margin: 14px 0 4px; }
.history-tooltip {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 8px;
box-shadow: var(--shadow-sm);
padding: 8px 10px;
font-size: 12px;
}
.history-tooltip .ht-time { font-weight: 600; color: var(--text-dark); margin-bottom: 4px; }
.history-tooltip .ht-row { display: flex; align-items: center; gap: 6px; color: var(--text-mid); }
.history-tooltip .ht-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
/* ── Timeline / activity log ──────────────────────────────── */
.timeline { margin: 8px 0; }

View file

@ -1,5 +1,5 @@
import { Fragment, useEffect, useState } from 'react'
import { RadioTower, ChevronDown, ChevronRight, Wrench, Trash2 } from 'lucide-react'
import { RadioTower, ChevronDown, ChevronRight, Wrench, Trash2, LineChart } from 'lucide-react'
import {
fetchDevices, discoverDevices, updateDevice, deleteDevice, fetchZones, fetchDevicePhotos, createMaintenanceAsset,
} from '../api'
@ -7,6 +7,7 @@ import type { Device, Zone, DevicePhoto, DeviceType } from '../types'
import { DEVICE_TYPE_LABELS, IMPLEMENTED_DEVICE_TYPES } from '../types'
import DevicePhotoUpload from '../components/DevicePhotoUpload'
import GatewaysPanel from '../components/GatewaysPanel'
import DeviceHistoryModal from '../components/DeviceHistoryModal'
import { useAuth } from '../components/AuthGate'
import { can } from '../types'
@ -24,6 +25,7 @@ export default function Devices() {
const [discovering, setDiscovering] = useState<DeviceType | null>(null)
const [expanded, setExpanded] = useState<number | null>(null)
const [photosByDevice, setPhotosByDevice] = useState<Record<number, DevicePhoto[]>>({})
const [historyDevice, setHistoryDevice] = useState<Device | null>(null)
function load() {
Promise.all([fetchDevices(), fetchZones()])
@ -194,6 +196,9 @@ export default function Devices() {
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<button className="btn btn-sm" onClick={() => setHistoryDevice(d)}>
<LineChart size={13} strokeWidth={1.75} /> View history
</button>
{canManage && d.zone_id && (
<button className="btn btn-sm" onClick={() => createAsset(d.id)}>
<Wrench size={13} strokeWidth={1.75} /> Create asset in Maintenance
@ -214,6 +219,8 @@ export default function Devices() {
</table>
</div>
)}
{historyDevice && <DeviceHistoryModal device={historyDevice} onClose={() => setHistoryDevice(null)} />}
</div>
)
}

View file

@ -215,6 +215,21 @@ export interface DevicePhoto {
uploaded_at: string
}
// One row of GET /api/devices/:id/history — sparse by design (see recordReading()
// in backend/src/db.js): whichever fields the event that produced this row actually
// knew about are set, the rest are null. The history chart treats each column
// independently rather than assuming a full snapshot on every row.
export interface DeviceReading {
room_temp: string | null
target_temp: string | null
mode: string | null
power_state: 'on' | 'off' | null
fan_speed: string | null
valve_position: number | null
heating_active: boolean | null
recorded_at: string
}
export interface ActivityEntry {
id: number
zone_id: number | null