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:
commit
276c04f8c6
42 changed files with 5461 additions and 0 deletions
148
backend/src/db.js
Normal file
148
backend/src/db.js
Normal 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]
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue