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:
parent
59a3e091d8
commit
f0b4ac3c41
15 changed files with 5172 additions and 38 deletions
|
|
@ -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]
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { connect as connectMqtt } from './lib/mqtt.js'
|
|||
import { startScheduler } from './lib/scheduler.js'
|
||||
import { startAcPoller } from './lib/ac-poller.js'
|
||||
import { startDaikinPoller } from './lib/daikin-poller.js'
|
||||
import { startHistoryPruner } from './lib/history-pruner.js'
|
||||
import { zoneRoutes } from './routes/zones.js'
|
||||
import { deviceRoutes } from './routes/devices.js'
|
||||
import { statusRoutes } from './routes/status.js'
|
||||
|
|
@ -56,6 +57,7 @@ try {
|
|||
await startScheduler()
|
||||
await startAcPoller().catch(err => app.log.warn(`AC poller failed to start: ${err.message}`))
|
||||
await startDaikinPoller().catch(err => app.log.warn(`Daikin poller failed to start: ${err.message}`))
|
||||
await startHistoryPruner().catch(err => app.log.warn(`History pruner failed to start: ${err.message}`))
|
||||
await app.listen({ port: 3001, host: '0.0.0.0' })
|
||||
} catch (err) {
|
||||
app.log.error(err)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
// 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 { pool, recordReading } from '../db.js'
|
||||
import * as mhiDriver from './drivers/mhi-modbus.js'
|
||||
|
||||
const POLL_MS = 60 * 1000
|
||||
|
|
@ -37,6 +37,11 @@ async function pollOnce() {
|
|||
status.remoteLock === 'lock' ? true : status.remoteLock === 'unlock' ? false : null,
|
||||
]
|
||||
)
|
||||
|
||||
await recordReading(device.id, {
|
||||
roomTemp: status.roomTemp, targetTemp: status.setpoint,
|
||||
mode: status.mode, powerState: status.onOff, fanSpeed: status.fanSpeed,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(`[ac-poller] device ${device.id} poll failed:`, err.message)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
// matches replies by MAC — self-healing device_ip if a unit's DHCP lease changed, and
|
||||
// auto-registering any brand-new unit as an unassigned row (never auto-mapped to a
|
||||
// zone — that's still a staff action, same convention as MHI's native-MQTT ingest).
|
||||
import { pool } from '../db.js'
|
||||
import { pool, recordReading } from '../db.js'
|
||||
import * as daikinDriver from './drivers/daikin.js'
|
||||
import { upsertDiscovered } from './device-registry.js'
|
||||
|
||||
|
|
@ -39,6 +39,11 @@ async function pollStatusOnce() {
|
|||
[device.id, status.onOff ?? null, status.mode ?? null, status.fanSpeed ?? null,
|
||||
status.setpoint ?? null, status.roomTemp ?? null]
|
||||
)
|
||||
|
||||
await recordReading(device.id, {
|
||||
roomTemp: status.roomTemp, targetTemp: status.setpoint,
|
||||
mode: status.mode, powerState: status.onOff, fanSpeed: status.fanSpeed,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(`[daikin-poller] status poll failed for device ${device.id}:`, err.message)
|
||||
}
|
||||
|
|
|
|||
22
backend/src/lib/history-pruner.js
Normal file
22
backend/src/lib/history-pruner.js
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
// Periodic cleanup for device_readings — same start/stop-handle shape as
|
||||
// ac-poller.js/daikin-poller.js, just a much slower cadence since retention is
|
||||
// measured in days, not minutes. Runs once immediately (covers a retention setting
|
||||
// that was just lowered in Settings) then every PRUNE_MS.
|
||||
import { pruneOldReadings } from '../db.js'
|
||||
|
||||
const PRUNE_MS = 6 * 60 * 60 * 1000 // 6h
|
||||
|
||||
let timerHandle = null
|
||||
|
||||
export async function startHistoryPruner() {
|
||||
const tick = async () => {
|
||||
try { await pruneOldReadings() } catch (err) { console.error('[history-pruner] prune failed:', err.message) }
|
||||
}
|
||||
await tick()
|
||||
timerHandle = setInterval(tick, PRUNE_MS)
|
||||
}
|
||||
|
||||
export function stopHistoryPruner() {
|
||||
if (timerHandle) clearInterval(timerHandle)
|
||||
timerHandle = null
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
// connection attempt fails fast and retries in the background; nothing here blocks
|
||||
// backend startup.
|
||||
import mqtt from 'mqtt'
|
||||
import { pool } from '../db.js'
|
||||
import { pool, recordReading } from '../db.js'
|
||||
|
||||
const CLIENT_NAME = 'hvac-backend'
|
||||
|
||||
|
|
@ -265,13 +265,15 @@ async function handleStatusBlob(externalRef, payload) {
|
|||
origin = Math.abs(Number(lastCommanded) - targetTemp) < 0.1 ? 'automation' : 'guest'
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
const { rows: updated } = await pool.query(
|
||||
`UPDATE zone_devices
|
||||
SET current_target_temp = $2, current_room_temp = $3, battery_pct = $4,
|
||||
target_origin = $5, last_seen = NOW(), status_updated_at = NOW(), updated_at = NOW()
|
||||
WHERE device_type = 'shelly_trv' AND external_ref = $1`,
|
||||
WHERE device_type = 'shelly_trv' AND external_ref = $1
|
||||
RETURNING id`,
|
||||
[externalRef, targetTemp ?? null, roomTemp ?? null, battery ?? null, origin]
|
||||
)
|
||||
await recordReading(updated[0]?.id, { roomTemp, targetTemp })
|
||||
|
||||
if (origin === 'guest' && targetTemp != null) {
|
||||
await syncGuestAdjustmentToRoom(externalRef, targetTemp).catch(err => {
|
||||
|
|
@ -300,12 +302,14 @@ async function handleInfo(externalRef, payload) {
|
|||
const wifiRssi = payload.wifi_sta?.rssi
|
||||
|
||||
try {
|
||||
await pool.query(
|
||||
const { rows: updated } = await pool.query(
|
||||
`UPDATE zone_devices
|
||||
SET current_room_temp = $2, heating_active = $3, valve_position = $4, wifi_rssi = $5, status_updated_at = NOW()
|
||||
WHERE device_type = 'shelly_trv' AND external_ref = $1`,
|
||||
WHERE device_type = 'shelly_trv' AND external_ref = $1
|
||||
RETURNING id`,
|
||||
[externalRef, roomTemp ?? null, heatingActive, valvePos ?? null, wifiRssi ?? null]
|
||||
)
|
||||
await recordReading(updated[0]?.id, { roomTemp, valvePosition: valvePos, heatingActive })
|
||||
} catch (err) {
|
||||
console.error('[mqtt] failed to record info update:', err.message)
|
||||
}
|
||||
|
|
@ -351,7 +355,7 @@ async function handleMhiStatus(externalRef, iuLabel, signal, payload) {
|
|||
}
|
||||
|
||||
async function writeMhiState(externalRef, iuLabel, iuIndex, state) {
|
||||
await pool.query(
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO zone_devices
|
||||
(device_type, external_ref, discovered_name, mhi_unit_index,
|
||||
power_state, current_mode, current_target_temp, current_room_temp, fan_speed,
|
||||
|
|
@ -364,7 +368,8 @@ async function writeMhiState(externalRef, iuLabel, iuIndex, state) {
|
|||
current_room_temp = EXCLUDED.current_room_temp,
|
||||
fan_speed = EXCLUDED.fan_speed,
|
||||
health_state = EXCLUDED.health_state,
|
||||
status_updated_at = NOW(), last_seen = NOW(), updated_at = NOW()`,
|
||||
status_updated_at = NOW(), last_seen = NOW(), updated_at = NOW()
|
||||
RETURNING id`,
|
||||
[
|
||||
externalRef, `MHI ${iuLabel}`, iuIndex,
|
||||
state.power_state ?? null, state.current_mode ?? null,
|
||||
|
|
@ -372,6 +377,10 @@ async function writeMhiState(externalRef, iuLabel, iuIndex, state) {
|
|||
state.fan_speed ?? null, state.health_state,
|
||||
]
|
||||
)
|
||||
await recordReading(rows[0]?.id, {
|
||||
roomTemp: state.current_room_temp, targetTemp: state.current_target_temp,
|
||||
mode: state.current_mode, powerState: state.power_state, fanSpeed: state.fan_speed,
|
||||
})
|
||||
}
|
||||
|
||||
// Publish command(s) to one MHI unit. externalRef is the unit's topic base
|
||||
|
|
|
|||
|
|
@ -82,6 +82,22 @@ export async function deviceRoutes(app, opts) {
|
|||
return { ok: true, found: result.devices.length, new: discoveredCount, note: result.note }
|
||||
})
|
||||
|
||||
// GET /api/devices/:id/history — time-series readings for the debugging chart.
|
||||
// ?hours=96 (default, matches the default 4-day retention — see history_retention_days
|
||||
// in config); hard-capped at 14 days regardless of what's asked, since that's the
|
||||
// longest anyone could plausibly have kept via config even if retention was raised.
|
||||
app.get('/api/devices/:id/history', { preHandler: requireCap('view') }, async (req) => {
|
||||
const hours = Math.min(parseInt(req.query?.hours) || 96, 24 * 14)
|
||||
const { rows } = await pool.query(
|
||||
`SELECT room_temp, target_temp, mode, power_state, fan_speed, valve_position, heating_active, recorded_at
|
||||
FROM device_readings
|
||||
WHERE device_id = $1 AND recorded_at > NOW() - ($2 || ' hours')::interval
|
||||
ORDER BY recorded_at ASC`,
|
||||
[req.params.id, hours]
|
||||
)
|
||||
return rows
|
||||
})
|
||||
|
||||
// PATCH /api/devices/:id — zone/location assignment (a living mapping, not a one-time wizard)
|
||||
app.patch('/api/devices/:id', { preHandler: requireCap('manage_devices') }, async (req, reply) => {
|
||||
const { rows: existing } = await pool.query('SELECT * FROM zone_devices WHERE id = $1', [req.params.id])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue