Scaffold Phase 1 hvac app — NewBook-driven room TRV heating scheduler

Ports the state machine, retry/backoff, and guest-override detection from
the retired homeassistant-newbook-heating-component, without depending on
Home Assistant. Backend (Fastify/pg) + frontend (React/Vite/TS) following
standard stack conventions; LXC 128 (127 was already taken by utilities).

MHI/Midea/Daikin/boiler drivers and the shared MQTT broker (LXC 104) are
later phases/infra, not included here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-26 18:00:42 +00:00
commit 276c04f8c6
42 changed files with 5461 additions and 0 deletions

148
backend/src/db.js Normal file
View file

@ -0,0 +1,148 @@
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
occupied_temp NUMERIC(4,1) NOT NULL DEFAULT 22.0,
vacant_temp NUMERIC(4,1) NOT NULL DEFAULT 16.0,
heating_offset_min INT NOT NULL DEFAULT 120, -- minutes before arrival to start heating
cooling_offset_min INT NOT NULL DEFAULT -30, -- minutes after departure to stop (negative = before checkout)
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,
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);
`)
await seedDefaults()
}
async function seedDefaults() {
const defaults = {
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: '',
}
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]
)
}