The firmware actually running on the deployed TRVs (SHTRV-01 v2.2.4, confirmed against live hardware) publishes a combined status blob on shellies/<id>/status, not the per-field shellies/<id>/thermostat/0/target_t topic handleStatus() was written against — so current_target_temp was permanently NULL and the dashboard modal always showed "—". Adds handleStatusBlob() to parse the real payload shape and populate current_target_temp/current_room_temp/battery_pct. This firmware also has no guest-vs-automation 'source' field, so guest detection is redesigned to diff an incoming target_t against a new last_commanded_target_t column, stamped by publishTarget() right before every command is sent. A mismatch means the guest changed it via the dial; a match means it's an echo of our own automation. Old handleStatus()/topicMatchesStatus() path left in place, dormant — harmless if some future firmware does use per-field topics. TRV command topic (shellies/<id>/thermostat/0/command/target_t) is still unverified against live hardware — flagged in code comments.
275 lines
16 KiB
JavaScript
275 lines
16 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).
|
|
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)`)
|
|
|
|
// 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,
|
|
}
|
|
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]
|
|
)
|
|
}
|