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
108
frontend/src/api.ts
Normal file
108
frontend/src/api.ts
Normal 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')
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue