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

128
frontend/src/types.ts Normal file
View file

@ -0,0 +1,128 @@
export type ZoneType = 'room' | 'public_area'
export type ZoneSource = 'newbook' | 'manual'
export type RoomState = 'vacant' | 'booked' | 'heating_up' | 'occupied' | 'cooling_down'
export type DeviceType = 'shelly_trv' | 'mhi_modbus' | 'midea' | 'daikin' | 'home_assistant'
export type HealthState = 'healthy' | 'degraded' | 'poor' | 'unresponsive' | 'calibration_error'
export type PhotoType = 'device' | 'serial_plate'
export const ROOM_STATE_LABELS: Record<RoomState, string> = {
vacant: 'Vacant',
booked: 'Booked',
heating_up: 'Heating Up',
occupied: 'Occupied',
cooling_down: 'Cooling Down',
}
export const DEVICE_TYPE_LABELS: Record<DeviceType, string> = {
shelly_trv: 'Shelly TRV',
mhi_modbus: 'MHI Aircon (Modbus)',
midea: 'Midea Split',
daikin: 'Daikin Split',
home_assistant: 'Home Assistant',
}
// Device types with an implemented Phase 1 driver — everything else in
// DeviceType is an enum value only, ready for its Phase 2/3 driver.
export const IMPLEMENTED_DEVICE_TYPES: DeviceType[] = ['shelly_trv', 'home_assistant']
export interface Zone {
id: number
zone_type: ZoneType
source: ZoneSource
newbook_site_id: string | null
name: string
active: boolean
occupied_temp: string
vacant_temp: string
heating_offset_min: number
cooling_offset_min: number
auto_mode: boolean
sync_valves: boolean
exclude_bathroom: boolean
created_at: string
device_count?: number
}
export interface ZoneStatus extends Zone {
room_state: RoomState
target_temp: string
last_transition_at: string | null
last_booking_status: string | null
devices: DeviceSummary[]
}
export interface DeviceSummary {
id: number
zone_id: number | null
device_type: DeviceType
external_ref: string
discovered_name: string | null
location: string | null
health_state: HealthState
battery_pct: number | null
wifi_rssi: number | null
current_target_temp: string | null
target_origin: string | null
last_seen: string | null
}
export interface Device extends DeviceSummary {
zone_name: string | null
maintenance_asset_id: number | null
device_ip: string | null
photo_count: number
created_at: string
updated_at: string
}
export interface DevicePhoto {
id: number
device_id: number
file_name: string
file_path: string
mime_type: string
photo_type: PhotoType
uploaded_by: string
uploaded_at: string
}
export interface ActivityEntry {
id: number
zone_id: number | null
zone_name: string | null
event_type: string
from_state: string | null
to_state: string | null
note: string | null
source: string
user_email: string | null
created_at: string
}
export interface NewbookSite {
site_id: string
site_name: string
category_name: string | null
excluded: boolean
}
export interface AppConfig {
poll_interval_minutes: number
default_arrival_time: string
default_departure_time: string
maintenance_url: string
maintenance_api_key: string
excluded_site_ids?: string[]
}
export interface User {
user_id: number
name: string
email: string
is_admin: boolean
caps: string[] // bare slugs — verify?app=hvac strips the prefix
}
export function can(user: User, cap: string): boolean {
return user.is_admin || user.caps.includes(cap)
}