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:
parent
bed2c9e2be
commit
d10e4884b8
9 changed files with 630 additions and 107 deletions
|
|
@ -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(
|
||||
|
|
|
|||
58
backend/src/lib/ac-control.js
Normal file
58
backend/src/lib/ac-control.js
Normal 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' }
|
||||
}
|
||||
|
|
@ -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' })
|
||||
|
|
|
|||
|
|
@ -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'}`,
|
||||
|
|
|
|||
|
|
@ -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) || [],
|
||||
})),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,11 +1,56 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { X, Thermometer, History } from 'lucide-react'
|
||||
import type { ZoneStatus, ActivityEntry } from '../types'
|
||||
import { ROOM_STATE_LABELS, can } from '../types'
|
||||
import type { ZoneStatus, ActivityEntry, HvacDefaults } from '../types'
|
||||
import { ROOM_STATE_LABELS, DEFAULT_HVAC_TEMPLATE, MHI_MODES, MHI_FAN_SPEEDS, can } from '../types'
|
||||
import { useAuth } from './AuthGate'
|
||||
import { updateZone, overrideZone, fetchActivity } from '../api'
|
||||
import { updateZone, overrideZone, fetchActivity, fetchConfig } from '../api'
|
||||
import MhiControlPanel from './MhiControlPanel'
|
||||
|
||||
// The 12 fields a room can individually override — each is `null` on the zone when
|
||||
// inheriting the matching field from the global hvac_defaults template (Settings page),
|
||||
// or an explicit value when overridden. Mirrors the OVERRIDABLE_FIELDS list in
|
||||
// backend/src/routes/zones.js.
|
||||
type OverridableField =
|
||||
| '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'
|
||||
|
||||
function defaultFor(field: OverridableField, d: HvacDefaults): string | number {
|
||||
switch (field) {
|
||||
case 'occupied_temp': return d.trv_occupied_temp
|
||||
case 'vacant_temp': return d.trv_vacant_temp
|
||||
case 'heating_offset_min': return d.arrival_prep_offset_min
|
||||
case 'cooling_offset_min': return d.departure_prep_offset_min
|
||||
case 'ac_occupied_power': return d.ac_occupied.power
|
||||
case 'ac_occupied_mode': return d.ac_occupied.mode
|
||||
case 'ac_occupied_setpoint': return d.ac_occupied.setpoint
|
||||
case 'ac_occupied_fan_speed': return d.ac_occupied.fan_speed
|
||||
case 'ac_vacant_power': return d.ac_vacant.power
|
||||
case 'ac_vacant_mode': return d.ac_vacant.mode
|
||||
case 'ac_vacant_setpoint': return d.ac_vacant.setpoint
|
||||
case 'ac_vacant_fan_speed': return d.ac_vacant.fan_speed
|
||||
}
|
||||
}
|
||||
|
||||
// Checkbox + either the override input (children) or the inherited default value,
|
||||
// depending on whether this field is currently overridden.
|
||||
function OverrideCell({ checked, defaultLabel, onToggle, children }: {
|
||||
checked: boolean
|
||||
defaultLabel: string
|
||||
onToggle: (checked: boolean) => void
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--text-mid)', marginBottom: 4, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={checked} onChange={e => onToggle(e.target.checked)} style={{ width: 13, height: 13 }} />
|
||||
Override
|
||||
</label>
|
||||
{checked ? children : <span className="muted" style={{ fontSize: 13 }}>{defaultLabel}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ZoneDetailModal({ zone, onClose, onSaved }: {
|
||||
zone: ZoneStatus
|
||||
onClose: () => void
|
||||
|
|
@ -15,14 +60,25 @@ export default function ZoneDetailModal({ zone, onClose, onSaved }: {
|
|||
const canEdit = can(user, 'schedule_edit')
|
||||
const canControl = can(user, 'control')
|
||||
|
||||
const [occupiedTemp, setOccupiedTemp] = useState(zone.occupied_temp)
|
||||
const [vacantTemp, setVacantTemp] = useState(zone.vacant_temp)
|
||||
const [heatingOffset, setHeatingOffset] = useState(zone.heating_offset_min)
|
||||
const [coolingOffset, setCoolingOffset] = useState(zone.cooling_offset_min)
|
||||
const [hvacDefaults, setHvacDefaults] = useState<HvacDefaults>(DEFAULT_HVAC_TEMPLATE)
|
||||
const [overrides, setOverrides] = useState<Record<OverridableField, string | number | null>>({
|
||||
occupied_temp: zone.occupied_temp,
|
||||
vacant_temp: zone.vacant_temp,
|
||||
heating_offset_min: zone.heating_offset_min,
|
||||
cooling_offset_min: zone.cooling_offset_min,
|
||||
ac_occupied_power: zone.ac_occupied_power,
|
||||
ac_occupied_mode: zone.ac_occupied_mode,
|
||||
ac_occupied_setpoint: zone.ac_occupied_setpoint,
|
||||
ac_occupied_fan_speed: zone.ac_occupied_fan_speed,
|
||||
ac_vacant_power: zone.ac_vacant_power,
|
||||
ac_vacant_mode: zone.ac_vacant_mode,
|
||||
ac_vacant_setpoint: zone.ac_vacant_setpoint,
|
||||
ac_vacant_fan_speed: zone.ac_vacant_fan_speed,
|
||||
})
|
||||
const [autoMode, setAutoMode] = useState(zone.auto_mode)
|
||||
const [syncValves, setSyncValves] = useState(zone.sync_valves)
|
||||
const [excludeBathroom, setExcludeBathroom] = useState(zone.exclude_bathroom)
|
||||
const [overrideTemp, setOverrideTemp] = useState(zone.occupied_temp)
|
||||
const [overrideTemp, setOverrideTemp] = useState(zone.occupied_temp ?? String(hvacDefaults.trv_occupied_temp))
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [msg, setMsg] = useState('')
|
||||
|
|
@ -30,16 +86,32 @@ export default function ZoneDetailModal({ zone, onClose, onSaved }: {
|
|||
|
||||
useEffect(() => {
|
||||
fetchActivity(zone.id, 20).then(setActivity).catch(() => {})
|
||||
fetchConfig().then(cfg => setHvacDefaults(cfg.hvac_defaults ?? DEFAULT_HVAC_TEMPLATE)).catch(() => {})
|
||||
}, [zone.id])
|
||||
|
||||
function toggleOverride(field: OverridableField, checked: boolean) {
|
||||
setOverrides(o => ({ ...o, [field]: checked ? defaultFor(field, hvacDefaults) : null }))
|
||||
}
|
||||
function setOverrideValue(field: OverridableField, value: string | number) {
|
||||
setOverrides(o => ({ ...o, [field]: value }))
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSaving(true); setError(''); setMsg('')
|
||||
try {
|
||||
await updateZone(zone.id, {
|
||||
occupied_temp: Number(occupiedTemp),
|
||||
vacant_temp: Number(vacantTemp),
|
||||
heating_offset_min: heatingOffset,
|
||||
cooling_offset_min: coolingOffset,
|
||||
occupied_temp: overrides.occupied_temp != null ? Number(overrides.occupied_temp) : null,
|
||||
vacant_temp: overrides.vacant_temp != null ? Number(overrides.vacant_temp) : null,
|
||||
heating_offset_min: overrides.heating_offset_min != null ? Number(overrides.heating_offset_min) : null,
|
||||
cooling_offset_min: overrides.cooling_offset_min != null ? Number(overrides.cooling_offset_min) : null,
|
||||
ac_occupied_power: overrides.ac_occupied_power || null,
|
||||
ac_occupied_mode: overrides.ac_occupied_mode || null,
|
||||
ac_occupied_setpoint: overrides.ac_occupied_setpoint != null ? Number(overrides.ac_occupied_setpoint) : null,
|
||||
ac_occupied_fan_speed: overrides.ac_occupied_fan_speed || null,
|
||||
ac_vacant_power: overrides.ac_vacant_power || null,
|
||||
ac_vacant_mode: overrides.ac_vacant_mode || null,
|
||||
ac_vacant_setpoint: overrides.ac_vacant_setpoint != null ? Number(overrides.ac_vacant_setpoint) : null,
|
||||
ac_vacant_fan_speed: overrides.ac_vacant_fan_speed || null,
|
||||
auto_mode: autoMode,
|
||||
sync_valves: syncValves,
|
||||
exclude_bathroom: excludeBathroom,
|
||||
|
|
@ -122,26 +194,182 @@ export default function ZoneDetailModal({ zone, onClose, onSaved }: {
|
|||
{canEdit && (
|
||||
<>
|
||||
<div className="section-title">Schedule</div>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>Occupied temp (°C)</label>
|
||||
<input type="number" step="0.5" value={occupiedTemp} onChange={e => setOccupiedTemp(e.target.value)} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Vacant temp (°C)</label>
|
||||
<input type="number" step="0.5" value={vacantTemp} onChange={e => setVacantTemp(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="field-hint" style={{ marginBottom: 10 }}>
|
||||
Every value below inherits the global default (Settings → Default Templates) unless overridden here.
|
||||
</p>
|
||||
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>Heating offset (mins before arrival)</label>
|
||||
<input type="number" value={heatingOffset} onChange={e => setHeatingOffset(parseInt(e.target.value) || 0)} />
|
||||
<OverrideCell
|
||||
checked={overrides.heating_offset_min != null}
|
||||
defaultLabel={`Default: ${hvacDefaults.arrival_prep_offset_min} mins`}
|
||||
onToggle={c => toggleOverride('heating_offset_min', c)}
|
||||
>
|
||||
<input
|
||||
type="number" value={overrides.heating_offset_min ?? 0}
|
||||
onChange={e => setOverrideValue('heating_offset_min', parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
</OverrideCell>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Cooling offset (mins after departure)</label>
|
||||
<input type="number" value={coolingOffset} onChange={e => setCoolingOffset(parseInt(e.target.value) || 0)} />
|
||||
<OverrideCell
|
||||
checked={overrides.cooling_offset_min != null}
|
||||
defaultLabel={`Default: ${hvacDefaults.departure_prep_offset_min} mins`}
|
||||
onToggle={c => toggleOverride('cooling_offset_min', c)}
|
||||
>
|
||||
<input
|
||||
type="number" value={overrides.cooling_offset_min ?? 0}
|
||||
onChange={e => setOverrideValue('cooling_offset_min', parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
</OverrideCell>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ fontWeight: 600, fontSize: 13, margin: '10px 0 6px' }}>Aircon</div>
|
||||
<div className="table-wrap" style={{ marginBottom: 16 }}>
|
||||
<table className="data">
|
||||
<thead><tr><th></th><th>Occupied</th><th>Vacant</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>On/Off</td>
|
||||
<td>
|
||||
<OverrideCell
|
||||
checked={overrides.ac_occupied_power != null}
|
||||
defaultLabel={hvacDefaults.ac_occupied.power === 'on' ? 'Default: On' : 'Default: Off'}
|
||||
onToggle={c => toggleOverride('ac_occupied_power', c)}
|
||||
>
|
||||
<select value={overrides.ac_occupied_power ?? 'on'} onChange={e => setOverrideValue('ac_occupied_power', e.target.value)}>
|
||||
<option value="on">On</option><option value="off">Off</option>
|
||||
</select>
|
||||
</OverrideCell>
|
||||
</td>
|
||||
<td>
|
||||
<OverrideCell
|
||||
checked={overrides.ac_vacant_power != null}
|
||||
defaultLabel={hvacDefaults.ac_vacant.power === 'on' ? 'Default: On' : 'Default: Off'}
|
||||
onToggle={c => toggleOverride('ac_vacant_power', c)}
|
||||
>
|
||||
<select value={overrides.ac_vacant_power ?? 'off'} onChange={e => setOverrideValue('ac_vacant_power', e.target.value)}>
|
||||
<option value="on">On</option><option value="off">Off</option>
|
||||
</select>
|
||||
</OverrideCell>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Mode</td>
|
||||
<td>
|
||||
<OverrideCell
|
||||
checked={overrides.ac_occupied_mode != null}
|
||||
defaultLabel={`Default: ${hvacDefaults.ac_occupied.mode}`}
|
||||
onToggle={c => toggleOverride('ac_occupied_mode', c)}
|
||||
>
|
||||
<select value={overrides.ac_occupied_mode ?? 'cool'} onChange={e => setOverrideValue('ac_occupied_mode', e.target.value)}>
|
||||
{MHI_MODES.map(m => <option key={m} value={m}>{m[0].toUpperCase() + m.slice(1)}</option>)}
|
||||
</select>
|
||||
</OverrideCell>
|
||||
</td>
|
||||
<td>
|
||||
<OverrideCell
|
||||
checked={overrides.ac_vacant_mode != null}
|
||||
defaultLabel={`Default: ${hvacDefaults.ac_vacant.mode}`}
|
||||
onToggle={c => toggleOverride('ac_vacant_mode', c)}
|
||||
>
|
||||
<select value={overrides.ac_vacant_mode ?? 'cool'} onChange={e => setOverrideValue('ac_vacant_mode', e.target.value)}>
|
||||
{MHI_MODES.map(m => <option key={m} value={m}>{m[0].toUpperCase() + m.slice(1)}</option>)}
|
||||
</select>
|
||||
</OverrideCell>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Setpoint (°C)</td>
|
||||
<td>
|
||||
<OverrideCell
|
||||
checked={overrides.ac_occupied_setpoint != null}
|
||||
defaultLabel={`Default: ${hvacDefaults.ac_occupied.setpoint}°C`}
|
||||
onToggle={c => toggleOverride('ac_occupied_setpoint', c)}
|
||||
>
|
||||
<input
|
||||
type="number" step="0.5" style={{ width: 90 }}
|
||||
value={overrides.ac_occupied_setpoint ?? 0}
|
||||
onChange={e => setOverrideValue('ac_occupied_setpoint', parseFloat(e.target.value) || 0)}
|
||||
/>
|
||||
</OverrideCell>
|
||||
</td>
|
||||
<td>
|
||||
<OverrideCell
|
||||
checked={overrides.ac_vacant_setpoint != null}
|
||||
defaultLabel={`Default: ${hvacDefaults.ac_vacant.setpoint}°C`}
|
||||
onToggle={c => toggleOverride('ac_vacant_setpoint', c)}
|
||||
>
|
||||
<input
|
||||
type="number" step="0.5" style={{ width: 90 }}
|
||||
value={overrides.ac_vacant_setpoint ?? 0}
|
||||
onChange={e => setOverrideValue('ac_vacant_setpoint', parseFloat(e.target.value) || 0)}
|
||||
/>
|
||||
</OverrideCell>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Fan speed</td>
|
||||
<td>
|
||||
<OverrideCell
|
||||
checked={overrides.ac_occupied_fan_speed != null}
|
||||
defaultLabel={`Default: ${hvacDefaults.ac_occupied.fan_speed}`}
|
||||
onToggle={c => toggleOverride('ac_occupied_fan_speed', c)}
|
||||
>
|
||||
<select value={overrides.ac_occupied_fan_speed ?? 'medium'} onChange={e => setOverrideValue('ac_occupied_fan_speed', e.target.value)}>
|
||||
{MHI_FAN_SPEEDS.map(f => <option key={f} value={f}>{f[0].toUpperCase() + f.slice(1)}</option>)}
|
||||
</select>
|
||||
</OverrideCell>
|
||||
</td>
|
||||
<td>
|
||||
<OverrideCell
|
||||
checked={overrides.ac_vacant_fan_speed != null}
|
||||
defaultLabel={`Default: ${hvacDefaults.ac_vacant.fan_speed}`}
|
||||
onToggle={c => toggleOverride('ac_vacant_fan_speed', c)}
|
||||
>
|
||||
<select value={overrides.ac_vacant_fan_speed ?? 'low'} onChange={e => setOverrideValue('ac_vacant_fan_speed', e.target.value)}>
|
||||
{MHI_FAN_SPEEDS.map(f => <option key={f} value={f}>{f[0].toUpperCase() + f.slice(1)}</option>)}
|
||||
</select>
|
||||
</OverrideCell>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div style={{ fontWeight: 600, fontSize: 13, margin: '10px 0 6px' }}>TRVs</div>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>Occupied setpoint (°C)</label>
|
||||
<OverrideCell
|
||||
checked={overrides.occupied_temp != null}
|
||||
defaultLabel={`Default: ${hvacDefaults.trv_occupied_temp}°C`}
|
||||
onToggle={c => toggleOverride('occupied_temp', c)}
|
||||
>
|
||||
<input
|
||||
type="number" step="0.5" value={overrides.occupied_temp ?? 0}
|
||||
onChange={e => setOverrideValue('occupied_temp', e.target.value)}
|
||||
/>
|
||||
</OverrideCell>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Vacant setpoint (°C)</label>
|
||||
<OverrideCell
|
||||
checked={overrides.vacant_temp != null}
|
||||
defaultLabel={`Default: ${hvacDefaults.trv_vacant_temp}°C`}
|
||||
onToggle={c => toggleOverride('vacant_temp', c)}
|
||||
>
|
||||
<input
|
||||
type="number" step="0.5" value={overrides.vacant_temp ?? 0}
|
||||
onChange={e => setOverrideValue('vacant_temp', e.target.value)}
|
||||
/>
|
||||
</OverrideCell>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="field-check">
|
||||
<input type="checkbox" checked={autoMode} onChange={e => setAutoMode(e.target.checked)} />
|
||||
Auto mode (NewBook-driven scheduling)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import {
|
|||
fetchNewbookSites, saveExcludedSites, testNewbookConnection, syncZonesFromNewbook,
|
||||
fetchConfig, updateConfig, fetchMqttStatus, createZone,
|
||||
} from '../api'
|
||||
import type { NewbookSite, AppConfig } from '../types'
|
||||
import type { NewbookSite, AppConfig, HvacDefaults, HvacAcTemplate } from '../types'
|
||||
import { DEFAULT_HVAC_TEMPLATE, MHI_MODES, MHI_FAN_SPEEDS } from '../types'
|
||||
|
||||
export default function Settings() {
|
||||
const [sites, setSites] = useState<NewbookSite[]>([])
|
||||
|
|
@ -24,6 +25,11 @@ export default function Settings() {
|
|||
const [maintKey, setMaintKey] = useState('')
|
||||
const [configSaving, setConfigSaving] = useState(false)
|
||||
|
||||
// Default occupied/vacant templates — every room inherits these unless it sets its
|
||||
// own override (ZoneDetailModal's per-field "Override" toggles).
|
||||
const [defaults, setDefaults] = useState<HvacDefaults>(DEFAULT_HVAC_TEMPLATE)
|
||||
const [defaultsSaving, setDefaultsSaving] = useState(false)
|
||||
|
||||
const [mqttConnected, setMqttConnected] = useState<boolean | null>(null)
|
||||
const [mqttNote, setMqttNote] = useState('')
|
||||
|
||||
|
|
@ -47,6 +53,7 @@ export default function Settings() {
|
|||
setDefaultDeparture(cfg.default_departure_time ?? '10:00:00')
|
||||
setMaintUrl(cfg.maintenance_url ?? '')
|
||||
setMaintKey(cfg.maintenance_api_key ?? '')
|
||||
setDefaults(cfg.hvac_defaults ?? DEFAULT_HVAC_TEMPLATE)
|
||||
}).catch(() => {})
|
||||
fetchMqttStatus().then(s => { setMqttConnected(s.connected); setMqttNote(s.note) }).catch(() => {})
|
||||
}, [])
|
||||
|
|
@ -122,6 +129,22 @@ export default function Settings() {
|
|||
}
|
||||
}
|
||||
|
||||
function updateAcDefault(which: 'ac_occupied' | 'ac_vacant', field: keyof HvacAcTemplate, value: string | number) {
|
||||
setDefaults(d => ({ ...d, [which]: { ...d[which], [field]: value } }))
|
||||
}
|
||||
|
||||
async function saveDefaults() {
|
||||
setDefaultsSaving(true); setError(''); setMsg('')
|
||||
try {
|
||||
await updateConfig('hvac_defaults', defaults)
|
||||
setMsg('Default templates saved'); setTimeout(() => setMsg(''), 2500)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Save failed')
|
||||
} finally {
|
||||
setDefaultsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreatePublicArea(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!publicAreaName.trim()) return
|
||||
|
|
@ -226,6 +249,103 @@ export default function Settings() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section-title">Default Templates</div>
|
||||
<p className="field-hint" style={{ marginBottom: 10 }}>
|
||||
Fallback occupied/vacant state for every room's TRVs and aircon. Any room can override
|
||||
individual values on its own card (Dashboard → click the room → Schedule section).
|
||||
</p>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>Default arrival preparing offset (mins)</label>
|
||||
<input
|
||||
type="number" value={defaults.arrival_prep_offset_min}
|
||||
onChange={e => setDefaults(d => ({ ...d, arrival_prep_offset_min: parseInt(e.target.value) || 0 }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Default departing preparation offset (mins)</label>
|
||||
<input
|
||||
type="number" value={defaults.departure_prep_offset_min}
|
||||
onChange={e => setDefaults(d => ({ ...d, departure_prep_offset_min: parseInt(e.target.value) || 0 }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ fontWeight: 600, fontSize: 13, margin: '10px 0 6px' }}>Default Aircon</div>
|
||||
<div className="table-wrap" style={{ marginBottom: 16 }}>
|
||||
<table className="data">
|
||||
<thead><tr><th></th><th>Occupied</th><th>Vacant</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>On/Off</td>
|
||||
{(['ac_occupied', 'ac_vacant'] as const).map(which => (
|
||||
<td key={which}>
|
||||
<select value={defaults[which].power} onChange={e => updateAcDefault(which, 'power', e.target.value)}>
|
||||
<option value="on">On</option>
|
||||
<option value="off">Off</option>
|
||||
</select>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Mode</td>
|
||||
{(['ac_occupied', 'ac_vacant'] as const).map(which => (
|
||||
<td key={which}>
|
||||
<select value={defaults[which].mode} onChange={e => updateAcDefault(which, 'mode', e.target.value)}>
|
||||
{MHI_MODES.map(m => <option key={m} value={m}>{m[0].toUpperCase() + m.slice(1)}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Setpoint (°C)</td>
|
||||
{(['ac_occupied', 'ac_vacant'] as const).map(which => (
|
||||
<td key={which}>
|
||||
<input
|
||||
type="number" step="0.5" style={{ width: 90 }}
|
||||
value={defaults[which].setpoint}
|
||||
onChange={e => updateAcDefault(which, 'setpoint', parseFloat(e.target.value) || 0)}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Fan speed</td>
|
||||
{(['ac_occupied', 'ac_vacant'] as const).map(which => (
|
||||
<td key={which}>
|
||||
<select value={defaults[which].fan_speed} onChange={e => updateAcDefault(which, 'fan_speed', e.target.value)}>
|
||||
{MHI_FAN_SPEEDS.map(f => <option key={f} value={f}>{f[0].toUpperCase() + f.slice(1)}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div style={{ fontWeight: 600, fontSize: 13, margin: '10px 0 6px' }}>Default TRVs</div>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>Occupied setpoint (°C)</label>
|
||||
<input
|
||||
type="number" step="0.5" value={defaults.trv_occupied_temp}
|
||||
onChange={e => setDefaults(d => ({ ...d, trv_occupied_temp: parseFloat(e.target.value) || 0 }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Vacant setpoint (°C)</label>
|
||||
<input
|
||||
type="number" step="0.5" value={defaults.trv_vacant_temp}
|
||||
onChange={e => setDefaults(d => ({ ...d, trv_vacant_temp: parseFloat(e.target.value) || 0 }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<button className="btn btn-primary" disabled={defaultsSaving} onClick={saveDefaults}>
|
||||
{defaultsSaving ? 'Saving…' : 'Save Default Templates'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="section-title">Maintenance Integration</div>
|
||||
<p className="field-hint" style={{ marginBottom: 10 }}>
|
||||
Not yet available — maintenance needs its own Settings → API Keys page first.
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ export const DEVICE_TYPE_LABELS: Record<DeviceType, string> = {
|
|||
// DeviceType is an enum value only, ready for its Phase 3 driver.
|
||||
export const IMPLEMENTED_DEVICE_TYPES: DeviceType[] = ['shelly_trv', 'mhi_mqtt', 'home_assistant']
|
||||
|
||||
// TRV temps/offsets and aircon template fields are all nullable on a zone — NULL means
|
||||
// "inherit the matching field from hvac_defaults" (see HvacDefaults below); a non-null
|
||||
// value is that room's explicit override. Set via ZoneDetailModal's per-field toggles.
|
||||
export interface Zone {
|
||||
id: number
|
||||
zone_type: ZoneType
|
||||
|
|
@ -33,10 +36,18 @@ export interface Zone {
|
|||
newbook_site_id: string | null
|
||||
name: string
|
||||
active: boolean
|
||||
occupied_temp: string
|
||||
vacant_temp: string
|
||||
heating_offset_min: number
|
||||
cooling_offset_min: number
|
||||
occupied_temp: string | null
|
||||
vacant_temp: string | null
|
||||
heating_offset_min: number | null
|
||||
cooling_offset_min: number | null
|
||||
ac_occupied_power: 'on' | 'off' | null
|
||||
ac_occupied_mode: string | null
|
||||
ac_occupied_setpoint: string | null
|
||||
ac_occupied_fan_speed: string | null
|
||||
ac_vacant_power: 'on' | 'off' | null
|
||||
ac_vacant_mode: string | null
|
||||
ac_vacant_setpoint: string | null
|
||||
ac_vacant_fan_speed: string | null
|
||||
auto_mode: boolean
|
||||
sync_valves: boolean
|
||||
exclude_bathroom: boolean
|
||||
|
|
@ -179,6 +190,33 @@ export interface NewbookSite {
|
|||
excluded: boolean
|
||||
}
|
||||
|
||||
// Global occupied/vacant template — the fallback every room inherits from unless it
|
||||
// sets its own override (see Zone's nullable ac_*/occupied_temp/vacant_temp fields).
|
||||
export interface HvacAcTemplate {
|
||||
power: 'on' | 'off'
|
||||
mode: string
|
||||
setpoint: number
|
||||
fan_speed: string
|
||||
}
|
||||
|
||||
export interface HvacDefaults {
|
||||
arrival_prep_offset_min: number
|
||||
departure_prep_offset_min: number
|
||||
trv_occupied_temp: number
|
||||
trv_vacant_temp: number
|
||||
ac_occupied: HvacAcTemplate
|
||||
ac_vacant: HvacAcTemplate
|
||||
}
|
||||
|
||||
export const DEFAULT_HVAC_TEMPLATE: HvacDefaults = {
|
||||
arrival_prep_offset_min: 120,
|
||||
departure_prep_offset_min: -30,
|
||||
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' },
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
automation_enabled?: boolean
|
||||
poll_interval_minutes: number
|
||||
|
|
@ -187,6 +225,7 @@ export interface AppConfig {
|
|||
maintenance_url: string
|
||||
maintenance_api_key: string
|
||||
excluded_site_ids?: string[]
|
||||
hvac_defaults?: HvacDefaults
|
||||
}
|
||||
|
||||
export interface User {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue