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
|
|
@ -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'}`,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue