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)`) // Valve position (0-100, from thermostats[0].pos on shellies//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, } 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] ) }