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>
412 lines
19 KiB
TypeScript
412 lines
19 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { X, Thermometer, History } from 'lucide-react'
|
|
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, 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
|
|
onSaved: () => void
|
|
}) {
|
|
const { user } = useAuth()
|
|
const canEdit = can(user, 'schedule_edit')
|
|
const canControl = can(user, 'control')
|
|
|
|
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 ?? String(hvacDefaults.trv_occupied_temp))
|
|
const [saving, setSaving] = useState(false)
|
|
const [error, setError] = useState('')
|
|
const [msg, setMsg] = useState('')
|
|
const [activity, setActivity] = useState<ActivityEntry[]>([])
|
|
|
|
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: 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,
|
|
})
|
|
setMsg('Saved')
|
|
onSaved()
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Save failed')
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
async function forceTemp() {
|
|
setSaving(true); setError(''); setMsg('')
|
|
try {
|
|
const res = await overrideZone(zone.id, Number(overrideTemp))
|
|
if (res.ok) {
|
|
setMsg(`Set to ${overrideTemp}°C (${res.successful}/${res.total} devices) — auto mode disabled`)
|
|
setAutoMode(false)
|
|
onSaved()
|
|
} else {
|
|
setError(res.error || 'Override failed')
|
|
}
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Override failed')
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="modal-overlay" onClick={onClose}>
|
|
<div className="modal" onClick={e => e.stopPropagation()}>
|
|
<div className="modal-header">
|
|
<h2>{zone.name}</h2>
|
|
<button className="modal-close" onClick={onClose}><X size={18} strokeWidth={1.75} /></button>
|
|
</div>
|
|
|
|
<div className={`badge badge-st-${zone.room_state}`} style={{ marginBottom: 10 }}>
|
|
{ROOM_STATE_LABELS[zone.room_state]}
|
|
</div>
|
|
|
|
{error && <div className="error-banner">{error}</div>}
|
|
{msg && <div className="ok-banner">{msg}</div>}
|
|
|
|
<div className="section-title">Devices</div>
|
|
{zone.devices.length === 0 && <p className="muted">No devices mapped to this zone yet — assign some on the Devices page.</p>}
|
|
{zone.devices.map(d => (
|
|
<div key={d.id} className="card">
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
<div>
|
|
<div style={{ fontWeight: 600, fontSize: 13 }}>{d.location || d.discovered_name || d.external_ref}</div>
|
|
<div className="muted" style={{ fontSize: 12 }}>
|
|
{d.current_target_temp != null ? `${Number(d.current_target_temp).toFixed(1)}°C` : '—'}
|
|
{d.battery_pct != null && ` · ${d.battery_pct}% battery`}
|
|
</div>
|
|
</div>
|
|
<span className={`badge badge-health-${d.health_state}`}>{d.health_state.replace('_', ' ')}</span>
|
|
</div>
|
|
{(d.device_type === 'mhi_modbus' || d.device_type === 'mhi_mqtt') && <MhiControlPanel deviceId={d.id} canControl={canControl} />}
|
|
</div>
|
|
))}
|
|
|
|
{canControl && (
|
|
<>
|
|
<div className="section-title">Manual Override</div>
|
|
<div className="field-row">
|
|
<div className="field" style={{ flex: 'none', width: 120 }}>
|
|
<input type="number" step="0.5" value={overrideTemp} onChange={e => setOverrideTemp(e.target.value)} />
|
|
</div>
|
|
<button className="btn" disabled={saving} onClick={forceTemp}>
|
|
<Thermometer size={14} strokeWidth={1.75} /> Force temperature
|
|
</button>
|
|
</div>
|
|
<p className="field-hint">Disables auto mode for this zone until re-enabled below.</p>
|
|
</>
|
|
)}
|
|
|
|
{canEdit && (
|
|
<>
|
|
<div className="section-title">Schedule</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>
|
|
<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>
|
|
<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)
|
|
</label>
|
|
<label className="field-check">
|
|
<input type="checkbox" checked={syncValves} onChange={e => setSyncValves(e.target.checked)} />
|
|
Sync guest adjustments across valves in this zone
|
|
</label>
|
|
<label className="field-check">
|
|
<input type="checkbox" checked={excludeBathroom} onChange={e => setExcludeBathroom(e.target.checked)} />
|
|
Exclude bathroom valve from sync
|
|
</label>
|
|
|
|
<div className="modal-actions">
|
|
<button className="btn btn-primary" disabled={saving} onClick={save}>
|
|
{saving ? 'Saving…' : 'Save'}
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{activity.length > 0 && (
|
|
<>
|
|
<div className="section-title"><History size={12} strokeWidth={1.75} style={{ verticalAlign: '-1px', marginRight: 4 }} />Recent activity</div>
|
|
<div className="timeline">
|
|
{activity.map(a => (
|
|
<div key={a.id} className="timeline-item">
|
|
<div className="timeline-body">
|
|
{a.note}
|
|
<div className="timeline-meta">{new Date(a.created_at).toLocaleString()} · {a.source}</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|