hvac/backend/src/db.js
jtricerolph f0b4ac3c41 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).
2026-08-25 17:28:45 +00:00

354 lines
20 KiB
JavaScript

import pg from 'pg'
const { Pool } = pg
export const pool = new Pool({ connectionString: process.env.DATABASE_URL })
export async function initDb() {
await pool.query(`
-- Generic key/value config store (hk-planner/maintenance pattern) — holds
-- excluded_site_ids, poll_interval_minutes, maintenance integration URL/key, etc.
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Zones: rooms (synced from NewBook) and public areas (manual, Phase 3 control).
-- Single table for the whole device lifetime — Phase 2/3 aircon-specific columns
-- (cool setpoint, fan mode) land here later as nullable ADD COLUMNs, not a new table.
CREATE TABLE IF NOT EXISTS zones (
id SERIAL PRIMARY KEY,
zone_type TEXT NOT NULL DEFAULT 'room', -- room | public_area
source TEXT NOT NULL DEFAULT 'manual', -- newbook | manual
newbook_site_id TEXT UNIQUE,
name TEXT NOT NULL,
active BOOLEAN NOT NULL DEFAULT TRUE, -- soft-deactivate on NewBook sync, never hard delete
-- TRV temps + offsets: NULL = inherit the global 'hvac_defaults' config template (see seedDefaults()).
-- A non-NULL value here is an explicit per-room override.
occupied_temp NUMERIC(4,1),
vacant_temp NUMERIC(4,1),
heating_offset_min INT, -- minutes before arrival to start heating
cooling_offset_min INT, -- minutes after departure to stop (negative = before checkout)
-- Aircon template overrides — same inherit-if-NULL relationship to hvac_defaults.ac_occupied/ac_vacant.
ac_occupied_power TEXT, -- 'on' | 'off'
ac_occupied_mode TEXT, -- cool | heat | fan | auto | dry
ac_occupied_setpoint NUMERIC(4,1),
ac_occupied_fan_speed TEXT, -- low | medium | high | powerful
ac_vacant_power TEXT,
ac_vacant_mode TEXT,
ac_vacant_setpoint NUMERIC(4,1),
ac_vacant_fan_speed TEXT,
auto_mode BOOLEAN NOT NULL DEFAULT TRUE,
sync_valves BOOLEAN NOT NULL DEFAULT TRUE, -- sync guest setpoint changes across valves in the same zone
exclude_bathroom BOOLEAN NOT NULL DEFAULT TRUE, -- bathroom valve excluded from guest-adjustment sync
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Device mapping — one row per physical device, assigned to a zone by staff via
-- the Devices onboarding flow (never auto-mapped from device naming conventions).
CREATE TABLE IF NOT EXISTS zone_devices (
id SERIAL PRIMARY KEY,
zone_id INT REFERENCES zones(id) ON DELETE SET NULL,
device_type TEXT NOT NULL, -- shelly_trv | mhi_modbus | midea | daikin | home_assistant
external_ref TEXT NOT NULL, -- MAC / Modbus address / LAN device ID / HA entity_id
discovered_name TEXT, -- raw name/label from the device — display-only, never parsed
location TEXT, -- bedroom / bathroom / living / free text, set by staff
maintenance_asset_id INT, -- set once "Create asset in Maintenance" succeeds (Phase 1: stub, always NULL)
last_seen TIMESTAMPTZ,
battery_pct INT,
health_state TEXT NOT NULL DEFAULT 'healthy', -- healthy | degraded | poor | unresponsive | calibration_error
wifi_rssi INT,
current_target_temp NUMERIC(4,1),
target_origin TEXT, -- automation | guest | pending | unknown (Shelly source field)
device_ip TEXT, -- for the HTTP wake-up fallback
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS zone_devices_zone_idx ON zone_devices (zone_id);
CREATE UNIQUE INDEX IF NOT EXISTS zone_devices_type_ref_idx ON zone_devices (device_type, external_ref);
-- Two photo slots per device (device photo + serial/model plate) — same pattern
-- as maintenance's task_photos: multipart + sharp, uploads_data volume.
CREATE TABLE IF NOT EXISTS device_photos (
id SERIAL PRIMARY KEY,
device_id INT NOT NULL REFERENCES zone_devices(id) ON DELETE CASCADE,
file_name TEXT NOT NULL,
file_path TEXT NOT NULL,
mime_type TEXT,
file_size INT,
photo_type TEXT NOT NULL DEFAULT 'device', -- device | serial_plate
uploaded_by TEXT,
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS device_photos_device_idx ON device_photos (device_id);
-- Minimal, TTL'd booking cache — room-planner style (no full booking mirror).
-- Kept per-zone so a NewBook fetch failure can fall back to the last successful
-- read instead of treating "fetch failed" as "no booking" (fail-safe requirement).
CREATE TABLE IF NOT EXISTS booking_cache (
zone_id INT PRIMARY KEY REFERENCES zones(id) ON DELETE CASCADE,
booking_data JSONB,
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Persisted current room state — survives backend restarts, and lets the
-- scheduler apply setpoints only at state *transitions* (never poll-and-reassert
-- during 'occupied', which would stomp guest button/WS adjustments).
CREATE TABLE IF NOT EXISTS zone_state (
zone_id INT PRIMARY KEY REFERENCES zones(id) ON DELETE CASCADE,
room_state TEXT NOT NULL DEFAULT 'vacant', -- vacant | booked | heating_up | occupied | cooling_down
last_booking_status TEXT,
last_transition_at TIMESTAMPTZ,
-- Dashboard's takeoff/landing/stayover icon + night-count, computed alongside
-- room_state from whichever booking is currently governing the room (see
-- resolveGoverningBooking() in lib/scheduler.js). NULL when there's no booking
-- whose stay actually covers today (room_state 'booked' from a future arrival,
-- or 'vacant').
booking_kind TEXT, -- arrival | departure | stayover | NULL
current_night INT,
total_nights INT,
has_next_booking BOOLEAN NOT NULL DEFAULT FALSE, -- turnover hint: another booking follows a departure today
-- Only set when room_state = 'vacant': nights until the next known arrival
-- within the scheduler's fetch window, or NULL if nothing was found in that
-- window at all (displayed as an open-ended "7+ nights" rather than a count).
vacant_nights INT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Rolling log of state transitions, manual overrides, sync runs and errors.
CREATE TABLE IF NOT EXISTS activity_log (
id SERIAL PRIMARY KEY,
zone_id INT REFERENCES zones(id) ON DELETE CASCADE,
event_type TEXT NOT NULL, -- transition | override | sync | device_command | error
from_state TEXT,
to_state TEXT,
note TEXT,
source TEXT NOT NULL DEFAULT 'scheduler', -- scheduler | manual | sync
user_email TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS activity_log_zone_idx ON activity_log (zone_id, created_at DESC);
-- MHI Modbus TCP gateways (Intesis SuperLink -> Modbus, see
-- lib/drivers/mhi-profiles/intesis-mhi-modbus.js). Connection details are
-- UI config; the per-unit register map is ground truth from a MAPS "Excel
-- signals file" import (lib/mhi-xlsx-import.js), never derived from
-- 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,
host TEXT NOT NULL,
port INT NOT NULL DEFAULT 502,
slave_id INT NOT NULL DEFAULT 1,
address_base INT NOT NULL DEFAULT 0,
imported_filename TEXT,
imported_units JSONB,
imported_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`)
// zone_devices predates MHI support — nullable columns added as an ALTER,
// same pattern as auth/calendar/wages db.js (ADD COLUMN IF NOT EXISTS keeps
// initDb() idempotent without a separate migrations runner).
await pool.query(`ALTER TABLE zone_devices ADD COLUMN IF NOT EXISTS mhi_gateway_id INT REFERENCES mhi_gateways(id) ON DELETE SET NULL`)
await pool.query(`ALTER TABLE zone_devices ADD COLUMN IF NOT EXISTS mhi_unit_index INT`)
// Informational SuperLink IU hint only, shown in the UI — NEVER used for register math
// (the register base can only be known from an actual MAPS import; see the profile file).
await pool.query(`ALTER TABLE zone_devices ADD COLUMN IF NOT EXISTS mhi_iu_address INT`)
// The resolved per-unit `fields` object straight from the MAPS import for this device
// (e.g. { setpoint: { address, active, readWrite }, roomTemp: {...}, ... }) — stored
// directly rather than re-derived from profile+index at runtime, so there's no drift
// between what was imported and what the driver assumes.
await pool.query(`ALTER TABLE zone_devices ADD COLUMN IF NOT EXISTS mhi_register_map JSONB`)
// Dashboard live-status cache — populated by mqtt.js (shelly_trv: current_room_temp,
// heating_active) and ac-poller.js (mhi_modbus: everything else). Nullable, since
// which columns apply depends on device_type and no reading has necessarily arrived yet.
await pool.query(`ALTER TABLE zone_devices ADD COLUMN IF NOT EXISTS current_room_temp NUMERIC(4,1)`)
await pool.query(`ALTER TABLE zone_devices ADD COLUMN IF NOT EXISTS heating_active BOOLEAN`)
await pool.query(`ALTER TABLE zone_devices ADD COLUMN IF NOT EXISTS power_state TEXT`)
await pool.query(`ALTER TABLE zone_devices ADD COLUMN IF NOT EXISTS current_mode TEXT`)
await pool.query(`ALTER TABLE zone_devices ADD COLUMN IF NOT EXISTS fan_speed TEXT`)
await pool.query(`ALTER TABLE zone_devices ADD COLUMN IF NOT EXISTS remote_locked BOOLEAN`)
await pool.query(`ALTER TABLE zone_devices ADD COLUMN IF NOT EXISTS status_updated_at TIMESTAMPTZ`)
// Live TRV firmware (SHTRV-01 v2.2.4, confirmed 2026-08-25) publishes a combined
// status blob with no guest-vs-automation 'source' field the old archived logic
// relied on. Guest detection is done by diffing an incoming target_t against the
// last value hvac itself commanded — stamped here right before every publish, read
// back in mqtt.js's status handler. A mismatch = guest changed it via the dial.
await pool.query(`ALTER TABLE zone_devices ADD COLUMN IF NOT EXISTS last_commanded_target_t NUMERIC(4,1)`)
// Valve position (0-100, from thermostats[0].pos on shellies/<id>/info) — >0 already
// drives heating_active, but the modal wants the raw percentage too.
await pool.query(`ALTER TABLE zone_devices ADD COLUMN IF NOT EXISTS valve_position INT`)
// Default-template rollout: existing installs created occupied_temp/vacant_temp/
// heating_offset_min/cooling_offset_min as NOT NULL with a hardcoded default — relax
// that so NULL can mean "inherit the global hvac_defaults template" (see seedDefaults()
// below). Every existing room's current value is preserved as its explicit override —
// nothing changes behaviourally for any room on deploy. DROP NOT NULL/DROP DEFAULT are
// no-ops (not errors) if already relaxed, so this stays safe to run on every startup.
for (const col of ['occupied_temp', 'vacant_temp', 'heating_offset_min', 'cooling_offset_min']) {
await pool.query(`ALTER TABLE zones ALTER COLUMN ${col} DROP NOT NULL`)
await pool.query(`ALTER TABLE zones ALTER COLUMN ${col} DROP DEFAULT`)
}
await pool.query(`ALTER TABLE zones ADD COLUMN IF NOT EXISTS ac_occupied_power TEXT`)
await pool.query(`ALTER TABLE zones ADD COLUMN IF NOT EXISTS ac_occupied_mode TEXT`)
await pool.query(`ALTER TABLE zones ADD COLUMN IF NOT EXISTS ac_occupied_setpoint NUMERIC(4,1)`)
await pool.query(`ALTER TABLE zones ADD COLUMN IF NOT EXISTS ac_occupied_fan_speed TEXT`)
await pool.query(`ALTER TABLE zones ADD COLUMN IF NOT EXISTS ac_vacant_power TEXT`)
await pool.query(`ALTER TABLE zones ADD COLUMN IF NOT EXISTS ac_vacant_mode TEXT`)
await pool.query(`ALTER TABLE zones ADD COLUMN IF NOT EXISTS ac_vacant_setpoint NUMERIC(4,1)`)
await pool.query(`ALTER TABLE zones ADD COLUMN IF NOT EXISTS ac_vacant_fan_speed TEXT`)
await pool.query(`ALTER TABLE zone_state ADD COLUMN IF NOT EXISTS booking_kind TEXT`)
await pool.query(`ALTER TABLE zone_state ADD COLUMN IF NOT EXISTS current_night INT`)
await pool.query(`ALTER TABLE zone_state ADD COLUMN IF NOT EXISTS total_nights INT`)
await pool.query(`ALTER TABLE zone_state ADD COLUMN IF NOT EXISTS has_next_booking BOOLEAN NOT NULL DEFAULT FALSE`)
await pool.query(`ALTER TABLE zone_state ADD COLUMN IF NOT EXISTS vacant_nights INT`)
await seedDefaults()
}
// Global fallback template for every room's occupied/vacant TRV + aircon state, used
// wherever a zone's own override column is NULL. Structured as a single 'hvac_defaults'
// config value (not a dedicated table) since the generic config store already holds
// arbitrary JSONB per key — see routes/config.js.
export const DEFAULT_HVAC_TEMPLATE = {
arrival_prep_offset_min: 120, // minutes before arrival to start heating
departure_prep_offset_min: -30, // minutes after departure to start cooling (negative = before checkout)
trv_occupied_temp: 22.0,
trv_vacant_temp: 16.0,
ac_occupied: { power: 'on', mode: 'cool', setpoint: 22.0, fan_speed: 'medium' },
ac_vacant: { power: 'off', mode: 'cool', setpoint: 18.0, fan_speed: 'low' },
// Weather compensation — ordered ascending by peak_above_c. At an occupied-state
// transition, the scheduler picks the HIGHEST tier whose threshold today's forecast
// peak clears and uses its template instead of ac_occupied above; below every
// threshold (or if no forecast is available), ac_occupied is used unchanged. See
// lib/weather.js and resolveAcTemplate() in lib/scheduler.js.
ac_occupied_tiers: [
{ name: 'warm', peak_above_c: 24, power: 'off', mode: 'cool', setpoint: 20.0, fan_speed: 'medium' },
{ name: 'hot', peak_above_c: 28, power: 'on', mode: 'cool', setpoint: 20.0, fan_speed: 'high' },
{ name: 'heatwave', peak_above_c: 32, power: 'on', mode: 'cool', setpoint: 20.0, fan_speed: 'powerful' },
],
}
async function seedDefaults() {
const defaults = {
automation_enabled: true, // master kill switch — overrides every zone's own auto_mode
excluded_site_ids: [],
poll_interval_minutes: 10, // matches the old integration's DEFAULT_SCAN_INTERVAL
default_arrival_time: '15:00:00',
default_departure_time: '10:00:00',
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(
`INSERT INTO config (key, value) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING`,
[key, JSON.stringify(value)]
)
}
}
export async function getConfig(key, fallback = null) {
const { rows } = await pool.query('SELECT value FROM config WHERE key = $1', [key])
return rows.length ? rows[0].value : fallback
}
export async function setConfig(key, value) {
await pool.query(
`INSERT INTO config (key, value, updated_at) VALUES ($1, $2, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()`,
[key, JSON.stringify(value)]
)
}
export async function logActivity(zoneId, eventType, { fromState, toState, note, source = 'scheduler', userEmail } = {}) {
await pool.query(
`INSERT INTO activity_log (zone_id, event_type, from_state, to_state, note, source, user_email)
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
[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]
)
}