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(

View file

@ -0,0 +1,58 @@
// Shared "command one MHI aircon device" logic — the actual write path, factored out
// of routes/override.js's manual /mhi-control handler so the scheduler (lib/scheduler.js)
// can drive the exact same code path for automatic occupied/vacant AC control, instead
// of duplicating the mqtt-vs-modbus branching and optimistic cache updates.
import { pool } from '../db.js'
import { publishMhiCommand } from './mqtt.js'
import * as mhiDriver from './drivers/mhi-modbus.js'
// device: a zone_devices row (needs at least id, device_type, external_ref).
// patch: { onOff?: boolean, mode?: string, tempC?: number, fanSpeed?: string } — only
// fields present (not undefined/null) are written, same contract as mhi-control's body.
// origin: 'manual' (staff, via the control panel) | 'automation' (scheduler-driven).
export async function commandAcDevice(device, { onOff, mode, tempC, fanSpeed } = {}, { origin = 'manual', log } = {}) {
if (device.device_type === 'mhi_mqtt') {
let ok
try {
publishMhiCommand(device.external_ref, { onOff, mode, tempC, fanSpeed })
ok = true
} catch (err) {
ok = false
log?.warn?.(`MHI command publish failed for ${device.external_ref}: ${err.message}`)
}
if (ok) {
// Optimistic cache update so the dashboard reflects the command immediately —
// the gateway's status echo re-caches the confirmed value within a second anyway.
await pool.query(
`UPDATE zone_devices SET
power_state = COALESCE($1, power_state),
current_mode = COALESCE($2, current_mode),
current_target_temp = COALESCE($3, current_target_temp),
fan_speed = COALESCE($4, fan_speed),
updated_at = NOW()
WHERE id = $5`,
[
onOff === undefined || onOff === null ? null : (onOff ? 'on' : 'off'),
mode ?? null,
tempC ?? null,
fanSpeed ?? null,
device.id,
]
)
}
return { ok }
}
if (device.device_type === 'mhi_modbus') {
const ok = await mhiDriver.setTarget(device.external_ref, { onOff, mode, tempC, fanSpeed })
if (ok && tempC !== undefined && tempC !== null) {
await pool.query(
`UPDATE zone_devices SET current_target_temp = $1, target_origin = $2, updated_at = NOW() WHERE id = $3`,
[tempC, origin, device.id]
)
}
return { ok }
}
return { ok: false, reason: 'unsupported_device_type' }
}

View file

@ -5,14 +5,16 @@
// cooling). All time math runs in Europe/London via luxon so the BST/GMT transition
// doesn't silently break "minutes before a wall-clock time" arithmetic.
import { DateTime } from 'luxon'
import { pool, getConfig, logActivity } from '../db.js'
import { pool, getConfig, logActivity, DEFAULT_HVAC_TEMPLATE } from '../db.js'
import { fetchBookings } from './newbook.js'
import { batchSetZoneTemperature } from './mqtt.js'
import { commandAcDevice } from './ac-control.js'
const TZ = 'Europe/London'
const ACTIVE_BOOKING_STATUSES = ['confirmed', 'unconfirmed', 'arrived']
const HEAT_STATES = ['heating_up', 'occupied']
const COOL_STATES = ['vacant', 'cooling_down', 'booked']
const AC_DEVICE_TYPES = ['mhi_modbus', 'mhi_mqtt'] // matches routes/override.js's mhi-control scope
let timerHandle = null
let running = false
@ -35,7 +37,8 @@ function parseHHMMSS(str, fallback) {
// calculate_heating_schedule() — heating_start = arrival - heating_offset_minutes,
// cooling_start = departure + cooling_offset_minutes (cooling_offset can be negative
// to stop heating BEFORE checkout). Arrival uses the EARLIER of actual vs default
// arrival time; departure uses the LATER of actual vs default departure time.
// arrival time; departure uses the LATER of actual vs default departure time. Offsets
// are the zone's own override if set, else the global hvac_defaults template.
export function calculateHeatingSchedule(zone, booking, defaults) {
if (!booking) return null
const arrivalRaw = parseNbDateTime(booking.booking_arrival)
@ -55,8 +58,10 @@ export function calculateHeatingSchedule(zone, booking, defaults) {
const latestDepartureMins = Math.max(actualDepartureMins, defaultDepartureMins)
const departure = departureRaw.startOf('day').plus({ minutes: latestDepartureMins })
const heatingStart = arrival.plus({ minutes: -zone.heating_offset_min })
const coolingStart = departure.plus({ minutes: zone.cooling_offset_min })
const heatingOffset = zone.heating_offset_min ?? defaults.hvacDefaults.arrival_prep_offset_min
const coolingOffset = zone.cooling_offset_min ?? defaults.hvacDefaults.departure_prep_offset_min
const heatingStart = arrival.plus({ minutes: -heatingOffset })
const coolingStart = departure.plus({ minutes: coolingOffset })
return { heatingStart, coolingStart, arrival, departure }
}
@ -103,26 +108,72 @@ async function getZoneDevices(zoneId) {
return rows
}
async function applyTarget(zone, tempC) {
const devices = await getZoneDevices(zone.id)
async function applyTarget(zone, devices, tempC) {
const trvs = devices.filter(d => d.device_type === 'shelly_trv')
if (!trvs.length) return { ok: false, reason: 'no_devices' }
if (!trvs.length) return { ok: false, reason: 'no_devices', total: 0 }
const results = await batchSetZoneTemperature(trvs, tempC)
const successful = Object.values(results).filter(Boolean).length
return { ok: successful > 0, successful, total: trvs.length }
}
// Resolve the effective occupied/vacant aircon template for a zone: each field is the
// zone's own override if set, else the matching field from the global hvac_defaults
// template — same per-field inherit-if-NULL relationship as the TRV temps/offsets.
function resolveAcTemplate(zone, newState, hvacDefaults) {
const occupied = HEAT_STATES.includes(newState)
const prefix = occupied ? 'ac_occupied' : 'ac_vacant'
const globalTemplate = (occupied ? hvacDefaults.ac_occupied : hvacDefaults.ac_vacant) || {}
const power = zone[`${prefix}_power`] ?? globalTemplate.power
const setpoint = zone[`${prefix}_setpoint`] ?? globalTemplate.setpoint
return {
onOff: power === 'on' ? true : power === 'off' ? false : undefined,
mode: zone[`${prefix}_mode`] ?? globalTemplate.mode ?? undefined,
tempC: setpoint != null ? Number(setpoint) : undefined,
fanSpeed: zone[`${prefix}_fan_speed`] ?? globalTemplate.fan_speed ?? undefined,
}
}
async function applyAcTargets(zone, devices, newState, hvacDefaults) {
const acDevices = devices.filter(d => AC_DEVICE_TYPES.includes(d.device_type))
if (!acDevices.length) return { ok: true, successful: 0, total: 0 }
const patch = resolveAcTemplate(zone, newState, hvacDefaults)
let successful = 0
for (const device of acDevices) {
const result = await commandAcDevice(device, patch, { origin: 'automation' })
if (result.ok) successful++
}
return { ok: successful > 0, successful, total: acDevices.length }
}
// Apply heating logic ONLY at state transitions — never poll-and-reassert during
// 'occupied', which would stomp a guest's button/WS adjustment made mid-stay.
async function applyStateTransition(zone, oldState, newState) {
const target = HEAT_STATES.includes(newState) ? Number(zone.occupied_temp) : Number(zone.vacant_temp)
const result = await applyTarget(zone, target)
// 'occupied', which would stomp a guest's button/WS adjustment made mid-stay (or a
// staff member's manual aircon adjustment via the control panel).
async function applyStateTransition(zone, oldState, newState, hvacDefaults) {
const devices = await getZoneDevices(zone.id)
const target = HEAT_STATES.includes(newState)
? Number(zone.occupied_temp ?? hvacDefaults.trv_occupied_temp)
: Number(zone.vacant_temp ?? hvacDefaults.trv_vacant_temp)
const trvResult = await applyTarget(zone, devices, target)
const acResult = await applyAcTargets(zone, devices, newState, hvacDefaults)
const notes = []
if (trvResult.total > 0) {
notes.push(trvResult.ok
? `TRV ${target}°C (${trvResult.successful}/${trvResult.total})`
: `TRV failed: ${target}°C (${trvResult.successful || 0}/${trvResult.total})`)
}
if (acResult.total > 0) {
notes.push(acResult.ok
? `AC ${acResult.successful}/${acResult.total} commanded`
: `AC failed: ${acResult.successful}/${acResult.total}`)
}
await logActivity(zone.id, 'transition', {
fromState: oldState,
toState: newState,
note: result.ok
? `Set ${target}°C (${result.successful}/${result.total} devices)`
: `Failed to set ${target}°C (${result.reason || `${result.successful || 0}/${result.total || 0} devices`})`,
note: notes.length ? notes.join(' · ') : 'No devices to command',
source: 'scheduler',
})
}
@ -156,6 +207,7 @@ export async function pollOnce() {
const defaults = {
default_arrival_time: await getConfig('default_arrival_time', '15:00:00'),
default_departure_time: await getConfig('default_departure_time', '10:00:00'),
hvacDefaults: await getConfig('hvac_defaults', DEFAULT_HVAC_TEMPLATE),
}
const now = DateTime.now().setZone(TZ)
@ -214,7 +266,7 @@ export async function pollOnce() {
if (!zone.auto_mode || !automationEnabled) continue // manual override or master switch off — don't touch setpoints
if (newState === oldState) continue // no transition — never poll-and-reassert
await applyStateTransition(zone, oldState, newState)
await applyStateTransition(zone, oldState, newState, defaults.hvacDefaults)
} catch (err) {
console.error(`[scheduler] zone ${zone.id} update failed:`, err.message)
await logActivity(zone.id, 'error', { note: err.message, source: 'scheduler' })

View file

@ -1,6 +1,7 @@
import { requireAuth, requireCap } from '../auth.js'
import { pool, logActivity } from '../db.js'
import { batchSetZoneTemperature, publishMhiCommand } from '../lib/mqtt.js'
import { batchSetZoneTemperature } from '../lib/mqtt.js'
import { commandAcDevice } from '../lib/ac-control.js'
import * as mhiDriver from '../lib/drivers/mhi-modbus.js'
// Manual force-temperature — the equivalent of the old integration's
@ -69,12 +70,11 @@ export async function overrideRoutes(app) {
return status
})
// POST /api/devices/:id/mhi-control — manual aircon control only (no scheduler
// wiring). Body: { onOff?, mode?, tempC?, fanSpeed? } — only the fields present
// are written. mhi_mqtt: publish command topics (fire-and-forget, QoS 1); the
// gateway echoes the new state back on its status topics within a second, which
// mqtt.js re-caches — we also optimistically update the cache for instant UI.
// mhi_modbus: synchronous register write via the driver.
// POST /api/devices/:id/mhi-control — explicit staff control from the room modal's
// MhiControlPanel. Body: { onOff?, mode?, tempC?, fanSpeed? } — only the fields present
// are written. The actual write path (mhi_mqtt publish vs mhi_modbus register write,
// plus optimistic cache update) lives in lib/ac-control.js's commandAcDevice(), shared
// with the scheduler's automatic occupied/vacant AC control (lib/scheduler.js).
app.post('/api/devices/:id/mhi-control', { preHandler: requireCap('control') }, async (req, reply) => {
const { rows } = await pool.query(
`SELECT * FROM zone_devices WHERE id = $1 AND device_type IN ('mhi_modbus', 'mhi_mqtt')`,
@ -84,43 +84,7 @@ export async function overrideRoutes(app) {
const device = rows[0]
const { onOff, mode, tempC, fanSpeed } = req.body || {}
let ok
if (device.device_type === 'mhi_mqtt') {
try {
publishMhiCommand(device.external_ref, { onOff, mode, tempC, fanSpeed })
ok = true
} catch (err) {
ok = false
req.log.warn(`MHI command publish failed for ${device.external_ref}: ${err.message}`)
}
if (ok) {
// Optimistic cache update so the dashboard reflects the command immediately.
await pool.query(
`UPDATE zone_devices SET
power_state = COALESCE($1, power_state),
current_mode = COALESCE($2, current_mode),
current_target_temp = COALESCE($3, current_target_temp),
fan_speed = COALESCE($4, fan_speed),
updated_at = NOW()
WHERE id = $5`,
[
onOff === undefined || onOff === null ? null : (onOff ? 'on' : 'off'),
mode ?? null,
tempC ?? null,
fanSpeed ?? null,
device.id,
]
)
}
} else {
ok = await mhiDriver.setTarget(device.external_ref, { onOff, mode, tempC, fanSpeed })
if (ok && tempC !== undefined && tempC !== null) {
await pool.query(
`UPDATE zone_devices SET current_target_temp = $1, target_origin = 'manual', updated_at = NOW() WHERE id = $2`,
[tempC, device.id]
)
}
}
const { ok } = await commandAcDevice(device, { onOff, mode, tempC, fanSpeed }, { origin: 'manual', log: req.log })
await logActivity(device.zone_id, 'device_command', {
note: `MHI manual control on ${device.location || device.discovered_name || device.external_ref}: ${JSON.stringify({ onOff, mode, tempC, fanSpeed })}${ok ? '' : ' — FAILED'}`,

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) || [],
})),
}

View file

@ -17,30 +17,43 @@ export async function zoneRoutes(app) {
return rows
})
// PATCH /api/zones/:id — per-zone config: temps, offsets, auto_mode, sync_valves, exclude_bathroom
// Fields that inherit the global hvac_defaults template when NULL — sending an explicit
// `null` for one of these clears the room's override back to "inherit default"; omitting
// the key entirely leaves the existing value untouched. `??` can't tell those two apart
// (null is nullish too), so these are merged with an `in` check instead, below.
const OVERRIDABLE_FIELDS = [
'occupied_temp', 'vacant_temp', 'heating_offset_min', 'cooling_offset_min',
'ac_occupied_power', 'ac_occupied_mode', 'ac_occupied_setpoint', 'ac_occupied_fan_speed',
'ac_vacant_power', 'ac_vacant_mode', 'ac_vacant_setpoint', 'ac_vacant_fan_speed',
]
// PATCH /api/zones/:id — per-zone config: temps, offsets, aircon template overrides,
// auto_mode, sync_valves, exclude_bathroom
app.patch('/api/zones/:id', { preHandler: requireCap('schedule_edit') }, async (req, reply) => {
const { rows: existing } = await pool.query('SELECT * FROM zones WHERE id = $1', [req.params.id])
if (!existing.length) return reply.status(404).send({ error: 'Zone not found' })
const z = existing[0]
const {
name, occupied_temp, vacant_temp, heating_offset_min, cooling_offset_min,
auto_mode, sync_valves, exclude_bathroom,
} = req.body || {}
const body = req.body || {}
const { name, auto_mode, sync_valves, exclude_bathroom } = body
const merged = {}
for (const f of OVERRIDABLE_FIELDS) merged[f] = f in body ? body[f] : z[f]
const { rows } = await pool.query(
`UPDATE zones SET
name = $1, occupied_temp = $2, vacant_temp = $3, heating_offset_min = $4,
cooling_offset_min = $5, auto_mode = $6, sync_valves = $7, exclude_bathroom = $8
WHERE id = $9 RETURNING *`,
name = $1, occupied_temp = $2, vacant_temp = $3, heating_offset_min = $4, cooling_offset_min = $5,
auto_mode = $6, sync_valves = $7, exclude_bathroom = $8,
ac_occupied_power = $9, ac_occupied_mode = $10, ac_occupied_setpoint = $11, ac_occupied_fan_speed = $12,
ac_vacant_power = $13, ac_vacant_mode = $14, ac_vacant_setpoint = $15, ac_vacant_fan_speed = $16
WHERE id = $17 RETURNING *`,
[
name ?? z.name,
occupied_temp ?? z.occupied_temp,
vacant_temp ?? z.vacant_temp,
heating_offset_min ?? z.heating_offset_min,
cooling_offset_min ?? z.cooling_offset_min,
merged.occupied_temp, merged.vacant_temp, merged.heating_offset_min, merged.cooling_offset_min,
auto_mode ?? z.auto_mode,
sync_valves ?? z.sync_valves,
exclude_bathroom ?? z.exclude_bathroom,
merged.ac_occupied_power, merged.ac_occupied_mode, merged.ac_occupied_setpoint, merged.ac_occupied_fan_speed,
merged.ac_vacant_power, merged.ac_vacant_mode, merged.ac_vacant_setpoint, merged.ac_vacant_fan_speed,
req.params.id,
]
)
@ -61,10 +74,12 @@ export async function zoneRoutes(app) {
if (!['room', 'public_area'].includes(zone_type)) {
return reply.status(400).send({ error: "zone_type must be 'room' or 'public_area'" })
}
// occupied_temp/vacant_temp default to NULL (inherit the global hvac_defaults
// template) unless explicitly given — no hardcoded 22.0/16.0 fallback here.
const { rows } = await pool.query(
`INSERT INTO zones (name, zone_type, source, newbook_site_id, occupied_temp, vacant_temp)
VALUES ($1, $2, 'manual', NULL, $3, $4) RETURNING *`,
[name, zone_type, occupied_temp || 22.0, vacant_temp || 16.0]
[name, zone_type, occupied_temp ?? null, vacant_temp ?? null]
)
return rows[0]
})