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

@ -136,6 +136,29 @@ export async function initDb() {
-- room/IU at runtime. imported_units stages the last parsed export for
-- staff review — importing never auto-creates zone_devices rows, staff
-- explicitly assign each unit to a zone (see /units/:unitIndex/assign).
-- Time-series device readings — one row per poll/push, independent of activity_log
-- (which only records state *transitions*, overrides and errors, not raw telemetry).
-- Populated from three places: ac-poller.js/daikin-poller.js's poll tick, and
-- mqtt.js's TRV/MHI-native status handlers — each throttled to at most one row per
-- device per RECORD_MIN_INTERVAL_MS (see recordReading() below) so a chatty MQTT
-- device can't blow up row count. Purged by pruneOldReadings() against the
-- 'history_retention_days' config key (default 4) — see lib/history-pruner.js.
-- Columns are nullable since which apply depends on device_type, same convention
-- as zone_devices' own status-cache columns.
CREATE TABLE IF NOT EXISTS device_readings (
id BIGSERIAL PRIMARY KEY,
device_id INT NOT NULL REFERENCES zone_devices(id) ON DELETE CASCADE,
room_temp NUMERIC(4,1),
target_temp NUMERIC(4,1),
mode TEXT,
power_state TEXT,
fan_speed TEXT,
valve_position INT,
heating_active BOOLEAN,
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS device_readings_device_idx ON device_readings (device_id, recorded_at DESC);
CREATE TABLE IF NOT EXISTS mhi_gateways (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
@ -248,6 +271,7 @@ async function seedDefaults() {
maintenance_url: '',
maintenance_api_key: '',
hvac_defaults: DEFAULT_HVAC_TEMPLATE,
history_retention_days: 4, // how long device_readings rows are kept — see pruneOldReadings()
}
for (const [key, value] of Object.entries(defaults)) {
await pool.query(
@ -277,3 +301,54 @@ export async function logActivity(zoneId, eventType, { fromState, toState, note,
[zoneId, eventType, fromState || null, toState || null, note || null, source, userEmail || null]
)
}
// In-memory per-device throttle for recordReading() — module-level so it survives
// across the many independent call sites (ac-poller, daikin-poller, mqtt.js) without
// needing a shared state object threaded through all of them. Not persisted, so a
// backend restart just resets the throttle window rather than losing any readings.
const RECORD_MIN_INTERVAL_MS = 55 * 1000 // just under the pollers' 60s cadence
const lastRecordedAt = new Map()
// Insert one telemetry snapshot for a device, throttled to at most one row per
// device per RECORD_MIN_INTERVAL_MS regardless of how often the caller is invoked —
// MQTT pushes (TRV status blobs, native MHI signals) can arrive far more often than
// that, and without a throttle here 3-4 days of retention would balloon into a much
// larger table for no debugging benefit. Any field left out of `fields` is stored
// NULL for this row — callers only pass what they actually know at this event
// (e.g. TRV's handleInfo doesn't know target_temp, handleStatusBlob doesn't know
// valve_position), which is fine for a time series: the history chart just treats
// each column as sparse.
export async function recordReading(deviceId, fields = {}) {
if (!deviceId) return
const last = lastRecordedAt.get(deviceId)
const now = Date.now()
if (last && now - last < RECORD_MIN_INTERVAL_MS) return
lastRecordedAt.set(deviceId, now)
await pool.query(
`INSERT INTO device_readings
(device_id, room_temp, target_temp, mode, power_state, fan_speed, valve_position, heating_active)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`,
[
deviceId,
fields.roomTemp ?? null,
fields.targetTemp ?? null,
fields.mode ?? null,
fields.powerState ?? null,
fields.fanSpeed ?? null,
fields.valvePosition ?? null,
fields.heatingActive ?? null,
]
).catch(err => console.error(`[history] failed to record reading for device ${deviceId}:`, err.message))
}
// Deletes device_readings older than the 'history_retention_days' config key
// (default 4 — see seedDefaults()). Called on startup and periodically by
// lib/history-pruner.js.
export async function pruneOldReadings() {
const days = await getConfig('history_retention_days', 4)
await pool.query(
`DELETE FROM device_readings WHERE recorded_at < NOW() - ($1 || ' days')::interval`,
[Number(days) || 4]
)
}