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

@ -1,5 +1,5 @@
import { requireAuth, requireCap } from '../auth.js'
import { pool } from '../db.js'
import { pool, getConfig, DEFAULT_HVAC_TEMPLATE } from '../db.js'
import { isConnected as mqttConnected } from '../lib/mqtt.js'
export async function statusRoutes(app) {
@ -7,6 +7,7 @@ export async function statusRoutes(app) {
// GET /api/status — live status per zone: room state, target temp, device health/battery
app.get('/api/status', { preHandler: requireCap('view') }, async () => {
const hvacDefaults = await getConfig('hvac_defaults', DEFAULT_HVAC_TEMPLATE)
const { rows: zones } = await pool.query(`
SELECT z.*, zs.room_state, zs.last_transition_at, zs.last_booking_status
FROM zones z
@ -33,7 +34,9 @@ export async function statusRoutes(app) {
zones: zones.map(z => ({
...z,
room_state: z.room_state || 'vacant',
target_temp: ['heating_up', 'occupied'].includes(z.room_state) ? z.occupied_temp : z.vacant_temp,
target_temp: ['heating_up', 'occupied'].includes(z.room_state)
? (z.occupied_temp ?? hvacDefaults.trv_occupied_temp)
: (z.vacant_temp ?? hvacDefaults.trv_vacant_temp),
devices: devicesByZone.get(z.id) || [],
})),
}