Scaffold Phase 1 hvac app — NewBook-driven room TRV heating scheduler

Ports the state machine, retry/backoff, and guest-override detection from
the retired homeassistant-newbook-heating-component, without depending on
Home Assistant. Backend (Fastify/pg) + frontend (React/Vite/TS) following
standard stack conventions; LXC 128 (127 was already taken by utilities).

MHI/Midea/Daikin/boiler drivers and the shared MQTT broker (LXC 104) are
later phases/infra, not included here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-26 18:00:42 +00:00
commit 276c04f8c6
42 changed files with 5461 additions and 0 deletions

View file

@ -0,0 +1,213 @@
import { useEffect, useState } from 'react'
import { Wifi, WifiOff } from 'lucide-react'
import {
fetchNewbookSites, saveExcludedSites, testNewbookConnection, syncZonesFromNewbook,
fetchConfig, updateConfig, fetchMqttStatus, createZone,
} from '../api'
import type { NewbookSite, AppConfig } from '../types'
export default function Settings() {
const [sites, setSites] = useState<NewbookSite[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [msg, setMsg] = useState('')
const [testing, setTesting] = useState(false)
const [syncing, setSyncing] = useState(false)
const [config, setConfig] = useState<AppConfig | null>(null)
const [pollMinutes, setPollMinutes] = useState(10)
const [defaultArrival, setDefaultArrival] = useState('15:00:00')
const [defaultDeparture, setDefaultDeparture] = useState('10:00:00')
const [maintUrl, setMaintUrl] = useState('')
const [maintKey, setMaintKey] = useState('')
const [configSaving, setConfigSaving] = useState(false)
const [mqttConnected, setMqttConnected] = useState<boolean | null>(null)
const [mqttNote, setMqttNote] = useState('')
const [publicAreaName, setPublicAreaName] = useState('')
const [creatingZone, setCreatingZone] = useState(false)
function loadSites() {
fetchNewbookSites()
.then(res => setSites(res.sites))
.catch(e => setError(e instanceof Error ? e.message : 'Failed to load NewBook sites'))
.finally(() => setLoading(false))
}
useEffect(() => {
loadSites()
fetchConfig().then(cfg => {
setConfig(cfg)
setPollMinutes(cfg.poll_interval_minutes ?? 10)
setDefaultArrival(cfg.default_arrival_time ?? '15:00:00')
setDefaultDeparture(cfg.default_departure_time ?? '10:00:00')
setMaintUrl(cfg.maintenance_url ?? '')
setMaintKey(cfg.maintenance_api_key ?? '')
}).catch(() => {})
fetchMqttStatus().then(s => { setMqttConnected(s.connected); setMqttNote(s.note) }).catch(() => {})
}, [])
function toggleExcluded(siteId: string) {
setSites(sites.map(s => s.site_id === siteId ? { ...s, excluded: !s.excluded } : s))
}
async function saveSites() {
setError(''); setMsg('')
try {
await saveExcludedSites(sites.filter(s => s.excluded).map(s => s.site_id))
setMsg('Saved'); setTimeout(() => setMsg(''), 2500)
} catch (e) {
setError(e instanceof Error ? e.message : 'Save failed')
}
}
async function handleTest() {
setTesting(true); setError(''); setMsg('')
try {
const res = await testNewbookConnection()
setMsg(res.ok ? `Connection OK: ${res.message || ''}` : `Failed: ${res.error || 'unknown'}`)
} catch (e) {
setError(e instanceof Error ? e.message : 'Test failed')
} finally {
setTesting(false)
}
}
async function handleSync() {
setSyncing(true); setError(''); setMsg('')
try {
const res = await syncZonesFromNewbook()
setMsg(`Synced: ${res.created} created, ${res.updated} updated (${res.total} active sites)`)
} catch (e) {
setError(e instanceof Error ? e.message : 'Sync failed')
} finally {
setSyncing(false)
}
}
async function saveGeneralConfig() {
setConfigSaving(true); setError(''); setMsg('')
try {
await Promise.all([
updateConfig('poll_interval_minutes', pollMinutes),
updateConfig('default_arrival_time', defaultArrival),
updateConfig('default_departure_time', defaultDeparture),
updateConfig('maintenance_url', maintUrl),
updateConfig('maintenance_api_key', maintKey),
])
setMsg('Settings saved'); setTimeout(() => setMsg(''), 2500)
} catch (e) {
setError(e instanceof Error ? e.message : 'Save failed')
} finally {
setConfigSaving(false)
}
}
async function handleCreatePublicArea(e: React.FormEvent) {
e.preventDefault()
if (!publicAreaName.trim()) return
setCreatingZone(true); setError(''); setMsg('')
try {
await createZone({ name: publicAreaName.trim(), zone_type: 'public_area' })
setPublicAreaName('')
setMsg('Public area zone created — assign devices to it on the Devices page.')
} catch (e) {
setError(e instanceof Error ? e.message : 'Create failed')
} finally {
setCreatingZone(false)
}
}
if (loading) return <div className="page"><p className="muted">Loading…</p></div>
return (
<div className="page">
<div className="page-header">
<h1>Settings</h1>
</div>
{error && <div className="error-banner">{error}</div>}
{msg && <div className="ok-banner">{msg}</div>}
<div className="section-title">MQTT Broker</div>
<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)" />}
<span>{mqttNote || 'Checking…'}</span>
</div>
<div className="section-title">NewBook Rooms</div>
<p className="field-hint" style={{ marginBottom: 10 }}>
Untick rooms that shouldn't get NewBook-driven heating scheduling (e.g. owner-occupied or out-of-service rooms).
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 12 }}>
{sites.map(s => (
<label key={s.site_id} className="field-check" style={{
padding: '8px 12px', border: '1px solid var(--card-border)', borderRadius: 8,
background: s.excluded ? 'var(--body-bg)' : 'var(--card-bg)',
}}>
<input type="checkbox" checked={!s.excluded} onChange={() => toggleExcluded(s.site_id)} />
<span style={{ flex: 1, textDecoration: s.excluded ? 'line-through' : 'none', color: s.excluded ? 'var(--text-mid)' : 'var(--text-dark)' }}>
{s.site_name}
</span>
{s.category_name && <span className="muted" style={{ fontSize: 12 }}>{s.category_name}</span>}
</label>
))}
{sites.length === 0 && <p className="muted">No sites returned — check NewBook connection.</p>}
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 24 }}>
<button className="btn btn-primary" onClick={saveSites}>Save Visibility</button>
<button className="btn" disabled={testing} onClick={handleTest}>{testing ? 'Testing…' : 'Test NewBook Connection'}</button>
<button className="btn" disabled={syncing} onClick={handleSync}>{syncing ? 'Syncing…' : 'Sync Zones from NewBook'}</button>
</div>
<div className="section-title">Add a Public Area</div>
<form onSubmit={handleCreatePublicArea} style={{ display: 'flex', gap: 8, marginBottom: 24 }}>
<input
type="text" placeholder="e.g. Lobby, Restaurant, Bar"
value={publicAreaName} onChange={e => setPublicAreaName(e.target.value)}
style={{ flex: 1, border: '1px solid var(--card-border)', borderRadius: 8, padding: '8px 10px' }}
/>
<button className="btn btn-primary" disabled={creatingZone} type="submit">Add zone</button>
</form>
<div className="section-title">Scheduling</div>
<div className="field-row">
<div className="field">
<label>Poll interval (minutes)</label>
<input type="number" min={1} value={pollMinutes} onChange={e => setPollMinutes(parseInt(e.target.value) || 10)} />
</div>
<div className="field">
<label>Default arrival time</label>
<input type="text" value={defaultArrival} onChange={e => setDefaultArrival(e.target.value)} placeholder="15:00:00" />
</div>
<div className="field">
<label>Default departure time</label>
<input type="text" value={defaultDeparture} onChange={e => setDefaultDeparture(e.target.value)} placeholder="10:00:00" />
</div>
</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.
Once that lands, generate a key there and paste it here to enable "Create asset in Maintenance" on the Devices page.
</p>
<div className="field-row">
<div className="field">
<label>Maintenance URL</label>
<input type="text" value={maintUrl} onChange={e => setMaintUrl(e.target.value)} placeholder="https://hotel.example.com/maintenance" />
</div>
<div className="field">
<label>Maintenance API key</label>
<input type="text" value={maintKey} onChange={e => setMaintKey(e.target.value)} placeholder="paste key" />
</div>
</div>
<div style={{ marginBottom: 24 }}>
<button className="btn btn-primary" disabled={configSaving} onClick={saveGeneralConfig}>
{configSaving ? 'Saving…' : 'Save Settings'}
</button>
</div>
</div>
)
}