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).
354 lines
20 KiB
JavaScript
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]
|
|
)
|
|
}
|