diff --git a/backend/src/db.js b/backend/src/db.js index c3da3dd..0a262c4 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -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( diff --git a/backend/src/lib/ac-control.js b/backend/src/lib/ac-control.js new file mode 100644 index 0000000..2e187e8 --- /dev/null +++ b/backend/src/lib/ac-control.js @@ -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' } +} diff --git a/backend/src/lib/scheduler.js b/backend/src/lib/scheduler.js index 223efa9..39efb54 100644 --- a/backend/src/lib/scheduler.js +++ b/backend/src/lib/scheduler.js @@ -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' }) diff --git a/backend/src/routes/override.js b/backend/src/routes/override.js index 35b67f7..95f0470 100644 --- a/backend/src/routes/override.js +++ b/backend/src/routes/override.js @@ -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'}`, diff --git a/backend/src/routes/status.js b/backend/src/routes/status.js index 6c3623c..4c14129 100644 --- a/backend/src/routes/status.js +++ b/backend/src/routes/status.js @@ -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) || [], })), } diff --git a/backend/src/routes/zones.js b/backend/src/routes/zones.js index 69e8205..f2b2c35 100644 --- a/backend/src/routes/zones.js +++ b/backend/src/routes/zones.js @@ -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] }) diff --git a/frontend/src/components/ZoneDetailModal.tsx b/frontend/src/components/ZoneDetailModal.tsx index 6f581c1..882c6b7 100644 --- a/frontend/src/components/ZoneDetailModal.tsx +++ b/frontend/src/components/ZoneDetailModal.tsx @@ -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 ( +
+ + {checked ? children : {defaultLabel}} +
+ ) +} + 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(DEFAULT_HVAC_TEMPLATE) + const [overrides, setOverrides] = useState>({ + 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 && ( <>
Schedule
-
-
- - setOccupiedTemp(e.target.value)} /> -
-
- - setVacantTemp(e.target.value)} /> -
-
+

+ Every value below inherits the global default (Settings → Default Templates) unless overridden here. +

+
- setHeatingOffset(parseInt(e.target.value) || 0)} /> + toggleOverride('heating_offset_min', c)} + > + setOverrideValue('heating_offset_min', parseInt(e.target.value) || 0)} + /> +
- setCoolingOffset(parseInt(e.target.value) || 0)} /> + toggleOverride('cooling_offset_min', c)} + > + setOverrideValue('cooling_offset_min', parseInt(e.target.value) || 0)} + /> +
+ +
Aircon
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
OccupiedVacant
On/Off + toggleOverride('ac_occupied_power', c)} + > + + + + toggleOverride('ac_vacant_power', c)} + > + + +
Mode + toggleOverride('ac_occupied_mode', c)} + > + + + + toggleOverride('ac_vacant_mode', c)} + > + + +
Setpoint (°C) + toggleOverride('ac_occupied_setpoint', c)} + > + setOverrideValue('ac_occupied_setpoint', parseFloat(e.target.value) || 0)} + /> + + + toggleOverride('ac_vacant_setpoint', c)} + > + setOverrideValue('ac_vacant_setpoint', parseFloat(e.target.value) || 0)} + /> + +
Fan speed + toggleOverride('ac_occupied_fan_speed', c)} + > + + + + toggleOverride('ac_vacant_fan_speed', c)} + > + + +
+
+ +
TRVs
+
+
+ + toggleOverride('occupied_temp', c)} + > + setOverrideValue('occupied_temp', e.target.value)} + /> + +
+
+ + toggleOverride('vacant_temp', c)} + > + setOverrideValue('vacant_temp', e.target.value)} + /> + +
+
+