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,166 @@
import { useEffect, useRef, useState, createContext, useContext } from 'react'
import type { User } from '../types'
function getInactivityMs(): number | null {
if (window.matchMedia('(display-mode: standalone)').matches) return null
const c = document.cookie.split(';').map(s => s.trim()).find(s => s.startsWith('hnf_inactivity_mins='))
if (!c) return null
const mins = parseInt(c.split('=')[1])
return isNaN(mins) || mins <= 0 ? null : mins * 60 * 1000
}
// Only bounce to the central login when actually embedded in the portal shell.
// A directly-opened browser tab must never navigate away from its own scope.
function isEmbedded() {
return window.top !== window
}
interface AuthCtx { user: User }
const Ctx = createContext<AuthCtx | null>(null)
export function useAuth() {
const ctx = useContext(Ctx)
if (!ctx) throw new Error('useAuth must be used inside AuthGate')
return ctx
}
export default function AuthGate({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<'checking' | 'authed' | 'login'>('checking')
const [user, setUser] = useState<User | null>(null)
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
fetch('/api/auth/verify?app=hvac', { credentials: 'include' })
.then(async r => {
if (r.ok) {
setUser(await r.json())
setState('authed')
} else if (isEmbedded()) {
window.top!.location.href = `/login?from=${encodeURIComponent('/app/hvac')}`
} else {
setState('login')
}
})
.catch(() => { if (!isEmbedded()) setState('login') })
}, [])
// Inactivity auto-logout — configurable per device (Admin Settings → Device).
useEffect(() => {
const ms = getInactivityMs()
if (state !== 'authed' || !ms) return
const timeoutMs: number = ms
async function forceLogout() {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
setUser(null)
setState('login')
}
function reset() {
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(forceLogout, timeoutMs)
}
const events = ['mousemove', 'keydown', 'click', 'touchstart'] as const
events.forEach(e => window.addEventListener(e, reset, { passive: true }))
reset()
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
events.forEach(e => window.removeEventListener(e, reset))
}
}, [state])
async function login(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError('')
try {
const res = await fetch('/api/auth/login', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
if (!res.ok) { setError('Invalid email or password'); return }
const verify = await fetch('/api/auth/verify?app=hvac', { credentials: 'include' })
if (verify.ok) {
setUser(await verify.json())
setState('authed')
} else {
setError("You don't have access to this app.")
}
} catch {
setError('Connection error — please try again')
} finally {
setLoading(false)
}
}
if (state === 'checking') {
return (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
height: '100vh', fontFamily: 'var(--font)', color: 'var(--text-mid)'
}}>
Loading
</div>
)
}
if (state === 'login') {
return (
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center',
justifyContent: 'center', height: '100dvh', padding: '1.5rem',
background: 'var(--navy-dark)',
}}>
<div style={{
background: 'var(--navy)', borderRadius: 'var(--radius)',
padding: '2rem', width: '100%', maxWidth: '360px',
border: '1px solid var(--surface-2)',
}}>
<h1 style={{ fontSize: '1.4rem', marginBottom: '1.5rem', color: 'var(--gold)' }}>
HVAC
</h1>
<form onSubmit={login} style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<input
type="email" value={email} onChange={e => setEmail(e.target.value)}
placeholder="Email" required autoComplete="email"
style={inputStyle}
/>
<input
type="password" value={password} onChange={e => setPassword(e.target.value)}
placeholder="Password" required autoComplete="current-password"
style={inputStyle}
/>
{error && <p style={{ color: 'var(--danger)', fontSize: '0.875rem' }}>{error}</p>}
<button type="submit" disabled={loading} style={{
background: loading ? 'var(--surface-2)' : 'var(--gold)',
color: loading ? 'var(--text-muted)' : 'var(--navy-dark)',
border: 'none', borderRadius: '6px', padding: '0.625rem',
fontSize: '1rem', fontWeight: 600, marginTop: '0.25rem',
}}>
{loading ? 'Signing in…' : 'Sign in'}
</button>
</form>
</div>
</div>
)
}
return (
<Ctx.Provider value={{ user: user! }}>
{children}
</Ctx.Provider>
)
}
const inputStyle: React.CSSProperties = {
background: 'var(--navy-dark)', border: '1px solid var(--surface-2)',
borderRadius: '6px', color: 'var(--text)', padding: '0.625rem 0.75rem',
fontSize: '1rem', width: '100%', outline: 'none',
}

View file

@ -0,0 +1,93 @@
import { useRef, useState } from 'react'
import { Camera, Image as ImageIcon, Loader2, X } from 'lucide-react'
import type { DevicePhoto, PhotoType } from '../types'
import { uploadDevicePhoto, deleteDevicePhoto, photoUrl } from '../api'
// Camera/library capture split: one input with capture="environment" (opens the
// device camera directly on mobile), one plain file input (opens the photo
// library/file picker) — same split used elsewhere in the stack for mobile uploads.
export default function DevicePhotoUpload({ deviceId, photoType, photos, onChanged, canDelete }: {
deviceId: number
photoType: PhotoType
photos: DevicePhoto[]
onChanged: () => void
canDelete: boolean
}) {
const [uploading, setUploading] = useState(false)
const [error, setError] = useState('')
const [lightbox, setLightbox] = useState<string | null>(null)
const cameraInput = useRef<HTMLInputElement>(null)
const libraryInput = useRef<HTMLInputElement>(null)
const slotPhotos = photos.filter(p => p.photo_type === photoType)
async function handleFile(file: File | undefined) {
if (!file) return
setUploading(true); setError('')
try {
await uploadDevicePhoto(deviceId, file, photoType)
onChanged()
} catch (e) {
setError(e instanceof Error ? e.message : 'Upload failed')
} finally {
setUploading(false)
}
}
async function remove(id: number) {
await deleteDevicePhoto(id).catch(() => {})
onChanged()
}
return (
<div style={{ marginBottom: 10 }}>
<div className="photo-grid">
{slotPhotos.map(p => (
<div key={p.id} className="photo-thumb-wrap">
<img className="photo-thumb" src={photoUrl(p.file_path)} onClick={() => setLightbox(photoUrl(p.file_path))} />
{canDelete && (
<button className="photo-del" onClick={() => remove(p.id)} title="Delete photo">
<X size={12} strokeWidth={2} />
</button>
)}
</div>
))}
{uploading && (
<div className="photo-thumb-wrap">
<div className="photo-thumb photo-thumb-uploading">
<Loader2 size={20} strokeWidth={1.75} className="spin" />
</div>
</div>
)}
</div>
{error && <div className="error-banner">{error}</div>}
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" className="btn btn-sm" onClick={() => cameraInput.current?.click()}>
<Camera size={14} strokeWidth={1.75} /> Take photo
</button>
<input
ref={cameraInput}
type="file" accept="image/*" capture="environment" style={{ display: 'none' }}
onChange={e => { handleFile(e.target.files?.[0]); e.target.value = '' }}
/>
<button type="button" className="btn btn-sm" onClick={() => libraryInput.current?.click()}>
<ImageIcon size={14} strokeWidth={1.75} /> Choose from library
</button>
<input
ref={libraryInput}
type="file" accept="image/*" style={{ display: 'none' }}
onChange={e => { handleFile(e.target.files?.[0]); e.target.value = '' }}
/>
</div>
{lightbox && (
<div className="lightbox-overlay" onClick={() => setLightbox(null)}>
<img className="lightbox-img" src={lightbox} />
<button className="lightbox-close" onClick={() => setLightbox(null)}><X size={20} strokeWidth={1.75} /></button>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,72 @@
import { useState, useEffect } from 'react'
import { NavLink, useLocation } from 'react-router-dom'
import { Thermometer, LayoutGrid, Cpu, Settings, Menu, LogOut } from 'lucide-react'
import { useAuth } from './AuthGate'
import { can } from '../types'
const ICON_PROPS = { size: 16, strokeWidth: 1.75 }
const NAV = [
{ to: '/dashboard', label: 'Dashboard', icon: LayoutGrid, cap: 'view' },
{ to: '/devices', label: 'Devices', icon: Cpu, cap: 'manage_devices' },
{ to: '/settings', label: 'Settings', icon: Settings, cap: 'settings' },
]
export default function Layout({ children }: { children: React.ReactNode }) {
const { user } = useAuth()
const items = NAV.filter(n => can(user, n.cap))
const location = useLocation()
const [menuOpen, setMenuOpen] = useState(false)
async function logout() {
await fetch('/hvac/api/auth/logout', { method: 'POST', credentials: 'include' })
window.location.reload()
}
useEffect(() => { setMenuOpen(false) }, [location.pathname])
return (
<div className={`app-shell${menuOpen ? ' menu-open' : ''}`}>
<aside className="sidebar">
<div className="sidebar-logo">
<Thermometer size={18} strokeWidth={1.75} />
HVAC
</div>
<nav className="sidebar-nav">
{items.map(({ to, label, icon: Icon }) => (
<NavLink key={to} to={to} className={({ isActive }) => isActive ? 'active' : ''}>
<Icon {...ICON_PROPS} />
{label}
</NavLink>
))}
</nav>
<div className="sidebar-user" style={{ whiteSpace: 'normal' }}>
<div style={{ fontWeight: 600, color: 'var(--text)', fontSize: '12px', marginBottom: '2px' }}>{user.name}</div>
<div style={{ fontSize: '11px', marginBottom: '8px' }}>{user.email}</div>
<button onClick={logout} style={{
display: 'flex', alignItems: 'center', gap: '6px',
background: 'none', border: 'none', color: 'inherit',
fontSize: '12px', padding: 0, cursor: 'pointer',
}}>
<LogOut size={13} strokeWidth={1.75} />
Sign out
</button>
</div>
</aside>
{menuOpen && <div className="menu-backdrop" onClick={() => setMenuOpen(false)} />}
<header className="top-bar">
<button className="top-bar-burger" onClick={() => setMenuOpen(o => !o)}>
<Menu size={20} strokeWidth={1.75} />
</button>
<Thermometer size={18} strokeWidth={1.75} color="var(--gold)" />
<span className="top-bar-title">HVAC</span>
</header>
<main className="page-content">
{children}
</main>
</div>
)
}

View file

@ -0,0 +1,27 @@
import { Thermometer, BatteryLow } from 'lucide-react'
import type { ZoneStatus } from '../types'
import { ROOM_STATE_LABELS } from '../types'
export default function ZoneCard({ zone, onClick }: { zone: ZoneStatus; onClick: () => void }) {
const unhealthy = zone.devices.filter(d => d.health_state !== 'healthy').length
const lowBattery = zone.devices.some(d => d.battery_pct != null && d.battery_pct < 30)
return (
<div className={`card zone-card st-${zone.room_state}`} onClick={onClick}>
<div className="zone-card-title">
<span>{zone.name}</span>
{!zone.auto_mode && <span className="badge badge-outline">Manual</span>}
</div>
<div className={`badge badge-st-${zone.room_state}`}>{ROOM_STATE_LABELS[zone.room_state]}</div>
<div className="zone-card-temp">
<Thermometer size={16} strokeWidth={1.75} style={{ verticalAlign: '-2px', marginRight: 4 }} />
{Number(zone.target_temp).toFixed(1)}°C
</div>
<div className="zone-card-meta">
<span>{zone.device_count ?? zone.devices.length} device{(zone.device_count ?? zone.devices.length) === 1 ? '' : 's'}</span>
{unhealthy > 0 && <span style={{ color: 'var(--health-degraded)' }}>{unhealthy} needs attention</span>}
{lowBattery && <BatteryLow size={13} strokeWidth={1.75} color="var(--health-degraded)" />}
</div>
</div>
)
}

View 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>
)
}