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

108
frontend/src/api.ts Normal file
View file

@ -0,0 +1,108 @@
import type {
Zone, ZoneStatus, Device, DevicePhoto, ActivityEntry, NewbookSite, AppConfig,
} from './types'
const BASE = '/hvac/api'
async function request<T>(path: string, opts: RequestInit = {}): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...opts.headers },
...opts,
})
if (res.status === 401) {
;(window.top ?? window).location.href = '/login'
throw new Error('Unauthenticated')
}
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.error || `Request failed: ${res.status}`)
}
return res.json()
}
// Zones
export function fetchZones(): Promise<Zone[]> {
return request('/zones')
}
export function updateZone(id: number, body: Record<string, unknown>): Promise<Zone> {
return request(`/zones/${id}`, { method: 'PATCH', body: JSON.stringify(body) })
}
export function createZone(body: { name: string; zone_type: 'room' | 'public_area'; occupied_temp?: number; vacant_temp?: number }): Promise<Zone> {
return request('/zones', { method: 'POST', body: JSON.stringify(body) })
}
export function fetchNewbookSites(): Promise<{ sites: NewbookSite[] }> {
return request('/zones/newbook-sites')
}
export function saveExcludedSites(excludedSiteIds: string[]): Promise<{ ok: boolean }> {
return request('/zones/newbook-sites', { method: 'PUT', body: JSON.stringify({ excluded_site_ids: excludedSiteIds }) })
}
export function testNewbookConnection(): Promise<{ ok: boolean; message?: string; error?: string }> {
return request('/zones/newbook-test', { method: 'POST', body: JSON.stringify({}) })
}
export function syncZonesFromNewbook(): Promise<{ ok: boolean; created: number; updated: number; total: number }> {
return request('/zones/sync-newbook', { method: 'POST', body: JSON.stringify({}) })
}
// Status / activity
export function fetchStatus(): Promise<{ mqtt_connected: boolean; zones: ZoneStatus[] }> {
return request('/status')
}
export function fetchActivity(zoneId?: number, limit = 100): Promise<ActivityEntry[]> {
const params = new URLSearchParams()
if (zoneId) params.set('zone_id', String(zoneId))
params.set('limit', String(limit))
return request(`/status/activity?${params.toString()}`)
}
// Override
export function overrideZone(id: number, tempC: number): Promise<{ ok: boolean; successful: number; total: number; auto_mode: boolean; error?: string }> {
return request(`/zones/${id}/override`, { method: 'POST', body: JSON.stringify({ temp_c: tempC }) })
}
// Devices
export function fetchDevices(): Promise<Device[]> {
return request('/devices')
}
export function discoverDevices(deviceType: string): Promise<{ ok: boolean; devices?: unknown[]; found?: number; new?: number; note?: string; error?: string }> {
return request('/devices/discover', { method: 'POST', body: JSON.stringify({ device_type: deviceType }) })
}
export function updateDevice(id: number, body: { zone_id?: number | null; location?: string }): Promise<Device> {
return request(`/devices/${id}`, { method: 'PATCH', body: JSON.stringify(body) })
}
export function createMaintenanceAsset(id: number): Promise<{ ok: boolean; notImplemented?: boolean; error?: string }> {
return request(`/devices/${id}/create-maintenance-asset`, { method: 'POST', body: JSON.stringify({}) })
}
// Device photos — multipart, so no JSON content-type header
export async function uploadDevicePhoto(deviceId: number, file: File, photoType: 'device' | 'serial_plate'): Promise<DevicePhoto> {
const form = new FormData()
form.append('photo_type', photoType)
form.append('file', file)
const res = await fetch(`${BASE}/devices/${deviceId}/photos`, { method: 'POST', credentials: 'include', body: form })
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.error || `Upload failed: ${res.status}`)
}
return res.json()
}
export function fetchDevicePhotos(deviceId: number): Promise<DevicePhoto[]> {
return request(`/devices/${deviceId}/photos`)
}
export function deleteDevicePhoto(id: number): Promise<{ ok: boolean }> {
return request(`/photos/${id}`, { method: 'DELETE' })
}
export function photoUrl(filePath: string): string {
return `${BASE}/uploads${filePath}`
}
// Config / settings
export function fetchConfig(): Promise<AppConfig> {
return request('/config')
}
export function updateConfig(key: string, value: unknown): Promise<{ ok: boolean }> {
return request(`/config/${key}`, { method: 'PUT', body: JSON.stringify({ value }) })
}
export function fetchMqttStatus(): Promise<{ connected: boolean; note: string }> {
return request('/settings/mqtt-status')
}