Add global occupied/vacant default templates with per-room overrides

Settings gains a 'Default Templates' section: arrival-prep/departure-prep offsets,
a default aircon template (on/off, mode, setpoint, fan speed x occupied/vacant), and
default TRV setpoints. Every room can individually override any of these fields via
per-field 'Override' toggles in its Schedule modal, falling back to the global
default when not overridden.

Backend:
- zones table: occupied_temp/vacant_temp/heating_offset_min/cooling_offset_min are
  now nullable (NULL = inherit default); existing values preserved as explicit
  per-room overrides, so no room's behaviour changes on deploy. 8 new nullable
  ac_occupied_*/ac_vacant_* override columns.
- New 'hvac_defaults' config key (generic config store) holds the global template.
- scheduler.js: resolves each field as zone-override ?? global-default, and now
  also drives AC state automatically on occupancy transitions (previously AC was
  100% manual) via the same command path the manual control panel uses.
- lib/ac-control.js: factored the mhi_mqtt/mhi_modbus command logic out of
  routes/override.js so the scheduler and the manual control route share one path.
- zones.js PATCH: explicit-null-clears-override semantics for the 12 overridable
  fields (distinct from omitting the key, which leaves the value unchanged).

Frontend:
- Settings.tsx: new Default Templates section matching the requested layout.
- ZoneDetailModal.tsx: per-field Override checkboxes for offsets, AC
  occupied/vacant template, and TRV occupied/vacant setpoints.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-08-25 09:48:28 +00:00
parent bed2c9e2be
commit d10e4884b8
9 changed files with 630 additions and 107 deletions

View file

@ -23,10 +23,21 @@ export async function initDb() {
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)
-- 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
@ -152,9 +163,41 @@ export async function initDb() {
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`)
// 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 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' },
}
async function seedDefaults() {
const defaults = {
automation_enabled: true, // master kill switch — overrides every zone's own auto_mode
@ -164,6 +207,7 @@ async function seedDefaults() {
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(