Add master automation on/off switch to Settings

A single global toggle that overrides every zone's own auto_mode —
lets staff pause all HVAC automation stack-wide (e.g. during
maintenance) without touching each zone individually. Backed by the
existing config key/value store; pollOnce() short-circuits entirely
when off (no NewBook fetch, no state tracking, no device commands).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-27 15:46:12 +00:00
parent e5c40bbb8c
commit c6e3c73ead
4 changed files with 46 additions and 0 deletions

View file

@ -146,6 +146,7 @@ export async function initDb() {
async function seedDefaults() { async function seedDefaults() {
const defaults = { const defaults = {
automation_enabled: true, // master kill switch — overrides every zone's own auto_mode
excluded_site_ids: [], excluded_site_ids: [],
poll_interval_minutes: 10, // matches the old integration's DEFAULT_SCAN_INTERVAL poll_interval_minutes: 10, // matches the old integration's DEFAULT_SCAN_INTERVAL
default_arrival_time: '15:00:00', default_arrival_time: '15:00:00',

View file

@ -148,6 +148,12 @@ async function saveZoneState(zoneId, roomState, bookingStatus) {
// One poll cycle: fetch bookings once, walk every active room zone, apply heating // One poll cycle: fetch bookings once, walk every active room zone, apply heating
// logic only where the room's state has actually changed since the last cycle. // logic only where the room's state has actually changed since the last cycle.
export async function pollOnce() { export async function pollOnce() {
// Master kill switch — overrides every zone's own auto_mode. Off means a full
// pause: no NewBook fetch, no state tracking, no device commands. Deliberately
// silent (no activity_log entry) since "paused" is a normal standing state, not
// an event — the Settings toggle itself is the record of when it changed.
if (!(await getConfig('automation_enabled', true))) return
const defaults = { const defaults = {
default_arrival_time: await getConfig('default_arrival_time', '15:00:00'), default_arrival_time: await getConfig('default_arrival_time', '15:00:00'),
default_departure_time: await getConfig('default_departure_time', '10:00:00'), default_departure_time: await getConfig('default_departure_time', '10:00:00'),

View file

@ -15,6 +15,8 @@ export default function Settings() {
const [syncing, setSyncing] = useState(false) const [syncing, setSyncing] = useState(false)
const [config, setConfig] = useState<AppConfig | null>(null) const [config, setConfig] = useState<AppConfig | null>(null)
const [automationEnabled, setAutomationEnabled] = useState(true)
const [automationSaving, setAutomationSaving] = useState(false)
const [pollMinutes, setPollMinutes] = useState(10) const [pollMinutes, setPollMinutes] = useState(10)
const [defaultArrival, setDefaultArrival] = useState('15:00:00') const [defaultArrival, setDefaultArrival] = useState('15:00:00')
const [defaultDeparture, setDefaultDeparture] = useState('10:00:00') const [defaultDeparture, setDefaultDeparture] = useState('10:00:00')
@ -39,6 +41,7 @@ export default function Settings() {
loadSites() loadSites()
fetchConfig().then(cfg => { fetchConfig().then(cfg => {
setConfig(cfg) setConfig(cfg)
setAutomationEnabled(cfg.automation_enabled ?? true)
setPollMinutes(cfg.poll_interval_minutes ?? 10) setPollMinutes(cfg.poll_interval_minutes ?? 10)
setDefaultArrival(cfg.default_arrival_time ?? '15:00:00') setDefaultArrival(cfg.default_arrival_time ?? '15:00:00')
setDefaultDeparture(cfg.default_departure_time ?? '10:00:00') setDefaultDeparture(cfg.default_departure_time ?? '10:00:00')
@ -86,6 +89,21 @@ export default function Settings() {
} }
} }
async function handleAutomationToggle() {
const next = !automationEnabled
setAutomationSaving(true); setError(''); setMsg('')
try {
await updateConfig('automation_enabled', next)
setAutomationEnabled(next)
setMsg(next ? 'Automation resumed' : 'Automation paused — no setpoints will be sent to any device until resumed')
setTimeout(() => setMsg(''), 4000)
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to update automation state')
} finally {
setAutomationSaving(false)
}
}
async function saveGeneralConfig() { async function saveGeneralConfig() {
setConfigSaving(true); setError(''); setMsg('') setConfigSaving(true); setError(''); setMsg('')
try { try {
@ -130,6 +148,26 @@ export default function Settings() {
{error && <div className="error-banner">{error}</div>} {error && <div className="error-banner">{error}</div>}
{msg && <div className="ok-banner">{msg}</div>} {msg && <div className="ok-banner">{msg}</div>}
<div className="section-title">Automation</div>
<div className="card" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, marginBottom: 24 }}>
<div>
<div style={{ fontWeight: 600, color: automationEnabled ? 'var(--health-healthy)' : 'var(--danger)' }}>
{automationEnabled ? 'Automation is ON' : 'Automation is PAUSED'}
</div>
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>
Master switch for every zone overrides each zone's own auto mode. While paused, the scheduler
skips its poll entirely: no NewBook fetch, no state tracking, no setpoints sent to any device.
</div>
</div>
<button
className={`btn ${automationEnabled ? '' : 'btn-primary'}`}
disabled={automationSaving}
onClick={handleAutomationToggle}
>
{automationSaving ? 'Saving…' : automationEnabled ? 'Pause Automation' : 'Resume Automation'}
</button>
</div>
<div className="section-title">MQTT Broker</div> <div className="section-title">MQTT Broker</div>
<div className="card" style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div className="card" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{mqttConnected ? <Wifi size={16} strokeWidth={1.75} color="var(--health-healthy)" /> : <WifiOff size={16} strokeWidth={1.75} color="var(--danger)" />} {mqttConnected ? <Wifi size={16} strokeWidth={1.75} color="var(--health-healthy)" /> : <WifiOff size={16} strokeWidth={1.75} color="var(--danger)" />}

View file

@ -170,6 +170,7 @@ export interface NewbookSite {
} }
export interface AppConfig { export interface AppConfig {
automation_enabled?: boolean
poll_interval_minutes: number poll_interval_minutes: number
default_arrival_time: string default_arrival_time: string
default_departure_time: string default_departure_time: string