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
|
-- room/IU at runtime. imported_units stages the last parsed export for
|
||||||
-- staff review — importing never auto-creates zone_devices rows, staff
|
-- staff review — importing never auto-creates zone_devices rows, staff
|
||||||
-- explicitly assign each unit to a zone (see /units/:unitIndex/assign).
|
-- 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 (
|
CREATE TABLE IF NOT EXISTS mhi_gateways (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
|
|
@ -248,6 +271,7 @@ async function seedDefaults() {
|
||||||
maintenance_url: '',
|
maintenance_url: '',
|
||||||
maintenance_api_key: '',
|
maintenance_api_key: '',
|
||||||
hvac_defaults: DEFAULT_HVAC_TEMPLATE,
|
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)) {
|
for (const [key, value] of Object.entries(defaults)) {
|
||||||
await pool.query(
|
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]
|
[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 { startScheduler } from './lib/scheduler.js'
|
||||||
import { startAcPoller } from './lib/ac-poller.js'
|
import { startAcPoller } from './lib/ac-poller.js'
|
||||||
import { startDaikinPoller } from './lib/daikin-poller.js'
|
import { startDaikinPoller } from './lib/daikin-poller.js'
|
||||||
|
import { startHistoryPruner } from './lib/history-pruner.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'
|
||||||
|
|
@ -56,6 +57,7 @@ try {
|
||||||
await startScheduler()
|
await startScheduler()
|
||||||
await startAcPoller().catch(err => app.log.warn(`AC poller failed to start: ${err.message}`))
|
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 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' })
|
await app.listen({ port: 3001, host: '0.0.0.0' })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
app.log.error(err)
|
app.log.error(err)
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
// polls every assigned unit on a short interval and caches the result on zone_devices.
|
// 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(),
|
// Same enqueue()-per-gateway serialisation as mhi-modbus.js's getStatus()/setTarget(),
|
||||||
// so this never races a staff member's live MhiControlPanel read/write.
|
// 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'
|
import * as mhiDriver from './drivers/mhi-modbus.js'
|
||||||
|
|
||||||
const POLL_MS = 60 * 1000
|
const POLL_MS = 60 * 1000
|
||||||
|
|
@ -37,6 +37,11 @@ async function pollOnce() {
|
||||||
status.remoteLock === 'lock' ? true : status.remoteLock === 'unlock' ? false : null,
|
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) {
|
} catch (err) {
|
||||||
console.error(`[ac-poller] device ${device.id} poll failed:`, err.message)
|
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
|
// 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
|
// 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).
|
// 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 * as daikinDriver from './drivers/daikin.js'
|
||||||
import { upsertDiscovered } from './device-registry.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,
|
[device.id, status.onOff ?? null, status.mode ?? null, status.fanSpeed ?? null,
|
||||||
status.setpoint ?? null, status.roomTemp ?? 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) {
|
} catch (err) {
|
||||||
console.error(`[daikin-poller] status poll failed for device ${device.id}:`, err.message)
|
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
|
// connection attempt fails fast and retries in the background; nothing here blocks
|
||||||
// backend startup.
|
// backend startup.
|
||||||
import mqtt from 'mqtt'
|
import mqtt from 'mqtt'
|
||||||
import { pool } from '../db.js'
|
import { pool, recordReading } from '../db.js'
|
||||||
|
|
||||||
const CLIENT_NAME = 'hvac-backend'
|
const CLIENT_NAME = 'hvac-backend'
|
||||||
|
|
||||||
|
|
@ -265,13 +265,15 @@ async function handleStatusBlob(externalRef, payload) {
|
||||||
origin = Math.abs(Number(lastCommanded) - targetTemp) < 0.1 ? 'automation' : 'guest'
|
origin = Math.abs(Number(lastCommanded) - targetTemp) < 0.1 ? 'automation' : 'guest'
|
||||||
}
|
}
|
||||||
|
|
||||||
await pool.query(
|
const { rows: updated } = await pool.query(
|
||||||
`UPDATE zone_devices
|
`UPDATE zone_devices
|
||||||
SET current_target_temp = $2, current_room_temp = $3, battery_pct = $4,
|
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()
|
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]
|
[externalRef, targetTemp ?? null, roomTemp ?? null, battery ?? null, origin]
|
||||||
)
|
)
|
||||||
|
await recordReading(updated[0]?.id, { roomTemp, targetTemp })
|
||||||
|
|
||||||
if (origin === 'guest' && targetTemp != null) {
|
if (origin === 'guest' && targetTemp != null) {
|
||||||
await syncGuestAdjustmentToRoom(externalRef, targetTemp).catch(err => {
|
await syncGuestAdjustmentToRoom(externalRef, targetTemp).catch(err => {
|
||||||
|
|
@ -300,12 +302,14 @@ async function handleInfo(externalRef, payload) {
|
||||||
const wifiRssi = payload.wifi_sta?.rssi
|
const wifiRssi = payload.wifi_sta?.rssi
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await pool.query(
|
const { rows: updated } = await pool.query(
|
||||||
`UPDATE zone_devices
|
`UPDATE zone_devices
|
||||||
SET current_room_temp = $2, heating_active = $3, valve_position = $4, wifi_rssi = $5, status_updated_at = NOW()
|
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]
|
[externalRef, roomTemp ?? null, heatingActive, valvePos ?? null, wifiRssi ?? null]
|
||||||
)
|
)
|
||||||
|
await recordReading(updated[0]?.id, { roomTemp, valvePosition: valvePos, heatingActive })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[mqtt] failed to record info update:', err.message)
|
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) {
|
async function writeMhiState(externalRef, iuLabel, iuIndex, state) {
|
||||||
await pool.query(
|
const { rows } = await pool.query(
|
||||||
`INSERT INTO zone_devices
|
`INSERT INTO zone_devices
|
||||||
(device_type, external_ref, discovered_name, mhi_unit_index,
|
(device_type, external_ref, discovered_name, mhi_unit_index,
|
||||||
power_state, current_mode, current_target_temp, current_room_temp, fan_speed,
|
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,
|
current_room_temp = EXCLUDED.current_room_temp,
|
||||||
fan_speed = EXCLUDED.fan_speed,
|
fan_speed = EXCLUDED.fan_speed,
|
||||||
health_state = EXCLUDED.health_state,
|
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,
|
externalRef, `MHI ${iuLabel}`, iuIndex,
|
||||||
state.power_state ?? null, state.current_mode ?? null,
|
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,
|
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
|
// 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 }
|
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)
|
// 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) => {
|
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])
|
const { rows: existing } = await pool.query('SELECT * FROM zone_devices WHERE id = $1', [req.params.id])
|
||||||
|
|
|
||||||
4770
frontend/package-lock.json
generated
4770
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -12,7 +12,8 @@
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.28.0"
|
"react-router-dom": "^6.28.0",
|
||||||
|
"recharts": "^3.9.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.3.1",
|
"@types/react": "^18.3.1",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type {
|
import type {
|
||||||
Zone, ZoneStatus, Device, DevicePhoto, ActivityEntry, NewbookSite, AppConfig,
|
Zone, ZoneStatus, Device, DevicePhoto, ActivityEntry, NewbookSite, AppConfig,
|
||||||
MhiGateway, MhiStatus, ActiveAcTemplate, DaikinStatus, TrvStatus,
|
MhiGateway, MhiStatus, ActiveAcTemplate, DaikinStatus, TrvStatus, DeviceReading,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
const BASE = '/hvac/api'
|
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 }> {
|
export function createMaintenanceAsset(id: number): Promise<{ ok: boolean; notImplemented?: boolean; error?: string }> {
|
||||||
return request(`/devices/${id}/create-maintenance-asset`, { method: 'POST', body: JSON.stringify({}) })
|
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
|
// Device photos — multipart, so no JSON content-type header
|
||||||
export async function uploadDevicePhoto(deviceId: number, file: File, photoType: 'device' | 'serial_plate'): Promise<DevicePhoto> {
|
export async function uploadDevicePhoto(deviceId: number, file: File, photoType: 'device' | 'serial_plate'): Promise<DevicePhoto> {
|
||||||
|
|
|
||||||
225
frontend/src/components/DeviceHistoryModal.tsx
Normal file
225
frontend/src/components/DeviceHistoryModal.tsx
Normal 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 & 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { X, Thermometer, History } from 'lucide-react'
|
import { X, Thermometer, History, LineChart } from 'lucide-react'
|
||||||
import type { ZoneStatus, ActivityEntry, HvacDefaults } from '../types'
|
import type { ZoneStatus, ActivityEntry, HvacDefaults, DeviceSummary } from '../types'
|
||||||
import { ROOM_STATE_LABELS, DEFAULT_HVAC_TEMPLATE, MHI_MODES, MHI_FAN_SPEEDS, can } from '../types'
|
import { ROOM_STATE_LABELS, DEFAULT_HVAC_TEMPLATE, MHI_MODES, MHI_FAN_SPEEDS, can } from '../types'
|
||||||
import { useAuth } from './AuthGate'
|
import { useAuth } from './AuthGate'
|
||||||
import { updateZone, overrideZone, fetchActivity, fetchConfig } from '../api'
|
import { updateZone, overrideZone, fetchActivity, fetchConfig } from '../api'
|
||||||
import MhiControlPanel from './MhiControlPanel'
|
import MhiControlPanel from './MhiControlPanel'
|
||||||
import DaikinControlPanel from './DaikinControlPanel'
|
import DaikinControlPanel from './DaikinControlPanel'
|
||||||
import TrvControlPanel from './TrvControlPanel'
|
import TrvControlPanel from './TrvControlPanel'
|
||||||
|
import DeviceHistoryModal from './DeviceHistoryModal'
|
||||||
|
|
||||||
// The 12 fields a room can individually override — each is `null` on the zone when
|
// 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),
|
// 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 [error, setError] = useState('')
|
||||||
const [msg, setMsg] = useState('')
|
const [msg, setMsg] = useState('')
|
||||||
const [activity, setActivity] = useState<ActivityEntry[]>([])
|
const [activity, setActivity] = useState<ActivityEntry[]>([])
|
||||||
|
const [historyDevice, setHistoryDevice] = useState<DeviceSummary | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchActivity(zone.id, 20).then(setActivity).catch(() => {})
|
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`}
|
{d.battery_pct != null && ` · ${d.battery_pct}% battery`}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
{(d.device_type === 'mhi_modbus' || d.device_type === 'mhi_mqtt') && <MhiControlPanel deviceId={d.id} canControl={canControl} />}
|
{(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} />}
|
{d.device_type === 'daikin' && <DaikinControlPanel deviceId={d.id} canControl={canControl} />}
|
||||||
|
|
@ -411,6 +418,8 @@ export default function ZoneDetailModal({ zone, onClose, onSaved }: {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{historyDevice && <DeviceHistoryModal device={historyDevice} onClose={() => setHistoryDevice(null)} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -307,6 +307,22 @@ html, body, #root { height: 100%; margin: 0; font-size: 14px; }
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; flex-wrap: wrap; }
|
.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 / activity log ──────────────────────────────── */
|
||||||
.timeline { margin: 8px 0; }
|
.timeline { margin: 8px 0; }
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Fragment, useEffect, useState } from 'react'
|
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 {
|
import {
|
||||||
fetchDevices, discoverDevices, updateDevice, deleteDevice, fetchZones, fetchDevicePhotos, createMaintenanceAsset,
|
fetchDevices, discoverDevices, updateDevice, deleteDevice, fetchZones, fetchDevicePhotos, createMaintenanceAsset,
|
||||||
} from '../api'
|
} from '../api'
|
||||||
|
|
@ -7,6 +7,7 @@ import type { Device, Zone, DevicePhoto, DeviceType } from '../types'
|
||||||
import { DEVICE_TYPE_LABELS, IMPLEMENTED_DEVICE_TYPES } from '../types'
|
import { DEVICE_TYPE_LABELS, IMPLEMENTED_DEVICE_TYPES } from '../types'
|
||||||
import DevicePhotoUpload from '../components/DevicePhotoUpload'
|
import DevicePhotoUpload from '../components/DevicePhotoUpload'
|
||||||
import GatewaysPanel from '../components/GatewaysPanel'
|
import GatewaysPanel from '../components/GatewaysPanel'
|
||||||
|
import DeviceHistoryModal from '../components/DeviceHistoryModal'
|
||||||
import { useAuth } from '../components/AuthGate'
|
import { useAuth } from '../components/AuthGate'
|
||||||
import { can } from '../types'
|
import { can } from '../types'
|
||||||
|
|
||||||
|
|
@ -24,6 +25,7 @@ export default function Devices() {
|
||||||
const [discovering, setDiscovering] = useState<DeviceType | null>(null)
|
const [discovering, setDiscovering] = useState<DeviceType | null>(null)
|
||||||
const [expanded, setExpanded] = useState<number | null>(null)
|
const [expanded, setExpanded] = useState<number | null>(null)
|
||||||
const [photosByDevice, setPhotosByDevice] = useState<Record<number, DevicePhoto[]>>({})
|
const [photosByDevice, setPhotosByDevice] = useState<Record<number, DevicePhoto[]>>({})
|
||||||
|
const [historyDevice, setHistoryDevice] = useState<Device | null>(null)
|
||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
Promise.all([fetchDevices(), fetchZones()])
|
Promise.all([fetchDevices(), fetchZones()])
|
||||||
|
|
@ -194,6 +196,9 @@ export default function Devices() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
<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 && (
|
{canManage && d.zone_id && (
|
||||||
<button className="btn btn-sm" onClick={() => createAsset(d.id)}>
|
<button className="btn btn-sm" onClick={() => createAsset(d.id)}>
|
||||||
<Wrench size={13} strokeWidth={1.75} /> Create asset in Maintenance
|
<Wrench size={13} strokeWidth={1.75} /> Create asset in Maintenance
|
||||||
|
|
@ -214,6 +219,8 @@ export default function Devices() {
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{historyDevice && <DeviceHistoryModal device={historyDevice} onClose={() => setHistoryDevice(null)} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -215,6 +215,21 @@ export interface DevicePhoto {
|
||||||
uploaded_at: string
|
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 {
|
export interface ActivityEntry {
|
||||||
id: number
|
id: number
|
||||||
zone_id: number | null
|
zone_id: number | null
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue