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
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue