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:
commit
276c04f8c6
42 changed files with 5461 additions and 0 deletions
180
frontend/src/components/ZoneDetailModal.tsx
Normal file
180
frontend/src/components/ZoneDetailModal.tsx
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { X, Thermometer, History } from 'lucide-react'
|
||||
import type { ZoneStatus, ActivityEntry } from '../types'
|
||||
import { ROOM_STATE_LABELS, can } from '../types'
|
||||
import { useAuth } from './AuthGate'
|
||||
import { updateZone, overrideZone, fetchActivity } from '../api'
|
||||
|
||||
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 [occupiedTemp, setOccupiedTemp] = useState(zone.occupied_temp)
|
||||
const [vacantTemp, setVacantTemp] = useState(zone.vacant_temp)
|
||||
const [heatingOffset, setHeatingOffset] = useState(zone.heating_offset_min)
|
||||
const [coolingOffset, setCoolingOffset] = useState(zone.cooling_offset_min)
|
||||
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)
|
||||
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(() => {})
|
||||
}, [zone.id])
|
||||
|
||||
async function save() {
|
||||
setSaving(true); setError(''); setMsg('')
|
||||
try {
|
||||
await updateZone(zone.id, {
|
||||
occupied_temp: Number(occupiedTemp),
|
||||
vacant_temp: Number(vacantTemp),
|
||||
heating_offset_min: heatingOffset,
|
||||
cooling_offset_min: coolingOffset,
|
||||
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" 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>
|
||||
))}
|
||||
|
||||
{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>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>Occupied temp (°C)</label>
|
||||
<input type="number" step="0.5" value={occupiedTemp} onChange={e => setOccupiedTemp(e.target.value)} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Vacant temp (°C)</label>
|
||||
<input type="number" step="0.5" value={vacantTemp} onChange={e => setVacantTemp(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>Heating offset (mins before arrival)</label>
|
||||
<input type="number" value={heatingOffset} onChange={e => setHeatingOffset(parseInt(e.target.value) || 0)} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Cooling offset (mins after departure)</label>
|
||||
<input type="number" value={coolingOffset} onChange={e => setCoolingOffset(parseInt(e.target.value) || 0)} />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue