hvac/frontend/src/types.ts
jtricerolph 2db7d5f4d0 Add kiosk wall-panel control view
Full-screen /kiosk route, separate from the staff SPA: dedicated long-lived
kiosk login (own password, hashed, mints its own hnf_session cookie locally
via CENTRAL_AUTH_SECRET -- no central auth changes needed), scrollable grid
of small mode/setpoint blocks per live-controllable device (shelly_trv,
mhi_mqtt, daikin), tap-to-edit modal reusing the existing per-type control
panels, and a type-aware bulk multiselect (separate TRV/AC sections, only
applied to matching selected devices, TRV commands staggered).

- backend/src/routes/kiosk.js: public POST /api/kiosk/login
- backend/src/routes/settings.js: admin set/check kiosk password (bcrypt)
- backend/src/routes/config.js: exclude/protect kiosk_password_hash
- frontend/src/pages/Kiosk.tsx + components/Kiosk*: the kiosk UI
- reuses GET /api/status for the combined device list -- no new status
  endpoint needed since the kiosk token now also carries the 'view' cap

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 20:44:50 +00:00

332 lines
11 KiB
TypeScript

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' | 'mhi_mqtt' | '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: 'Preparing',
occupied: 'Occupied',
cooling_down: 'Cooling Down',
}
export const DEVICE_TYPE_LABELS: Record<DeviceType, string> = {
shelly_trv: 'Shelly TRV',
mhi_modbus: 'MHI Aircon (Modbus)',
mhi_mqtt: 'MHI Aircon (MQTT)',
midea: 'Midea Split',
daikin: 'Daikin Split',
home_assistant: 'Home Assistant',
}
// Device types with an implemented driver — everything else in DeviceType is an enum
// value only, ready for its driver (currently just 'midea').
export const IMPLEMENTED_DEVICE_TYPES: DeviceType[] = ['shelly_trv', 'mhi_mqtt', 'home_assistant', 'daikin']
// Device types the kiosk wall-panel actually shows/controls — deliberately
// narrower than IMPLEMENTED_DEVICE_TYPES (excludes home_assistant, which has no
// kiosk-friendly control shape yet). See project_hvac_kiosk memory's "v1 device
// scope" decision — widen this if/when mitsubishi_cn105/midea_mqtt/hitachi_ir land.
export const KIOSK_DEVICE_TYPES: DeviceType[] = ['shelly_trv', 'mhi_mqtt', 'daikin']
export const KIOSK_AC_DEVICE_TYPES: DeviceType[] = ['mhi_mqtt', 'daikin']
// TRV temps/offsets and aircon template fields are all nullable on a zone — NULL means
// "inherit the matching field from hvac_defaults" (see HvacDefaults below); a non-null
// value is that room's explicit override. Set via ZoneDetailModal's per-field toggles.
export interface Zone {
id: number
zone_type: ZoneType
source: ZoneSource
newbook_site_id: string | null
name: string
active: boolean
occupied_temp: string | null
vacant_temp: string | null
heating_offset_min: number | null
cooling_offset_min: number | null
ac_occupied_power: 'on' | 'off' | null
ac_occupied_mode: string | null
ac_occupied_setpoint: string | null
ac_occupied_fan_speed: string | null
ac_vacant_power: 'on' | 'off' | null
ac_vacant_mode: string | null
ac_vacant_setpoint: string | null
ac_vacant_fan_speed: string | null
auto_mode: boolean
sync_valves: boolean
exclude_bathroom: boolean
created_at: string
device_count?: number
}
// Set alongside room_state from whichever booking currently governs the room (see
// resolveGoverningBooking() in backend/src/lib/scheduler.js). NULL when today isn't
// actually one of the governing booking's nights yet (e.g. 'booked' days ahead of
// its own prep window) or there's no booking at all.
export type BookingKind = 'arrival' | 'departure' | 'stayover'
export interface ZoneStatus extends Zone {
room_state: RoomState
target_temp: string
last_transition_at: string | null
last_booking_status: string | null
booking_kind: BookingKind | null
current_night: number | null
total_nights: number | null
has_next_booking: boolean
// Only set when room_state is 'vacant' — nights until the next known arrival within
// the scheduler's fetch window, or null if nothing was found in that window at all.
vacant_nights: number | 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
// Dashboard live-status cache — shelly_trv populates current_room_temp/heating_active
// via MQTT (mqtt.js); mhi_modbus populates the rest via the AC poller (ac-poller.js).
current_room_temp: string | null
heating_active: boolean | null
valve_position: number | null
power_state: 'on' | 'off' | null
current_mode: string | null
fan_speed: string | null
remote_locked: boolean | null
status_updated_at: 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
mhi_gateway_id?: number | null
mhi_unit_index?: number | null
mhi_iu_address?: number | null
mhi_register_map?: Record<string, MhiRegisterField> | null
}
// ── MHI Modbus gateways (manual aircon control) ──────────────────────────
export interface MhiRegisterField {
address: number
active: boolean
readWrite: string | null
}
export interface MhiImportedUnit {
unitIndex: number
iu: number | null
ou: number | null
fields: Record<string, MhiRegisterField>
}
export interface MhiImportResult {
meta: {
projectName: string | null
mapsVersion: string | null
internalProtocol: string | null
externalProtocol: string | null
timestamp: string | null
}
global: Record<string, MhiRegisterField>
units: MhiImportedUnit[]
unmatchedRows: { row: number; description: string }[]
}
export interface MhiGateway {
id: number
name: string
host: string
port: number
slave_id: number
address_base: number
imported_filename: string | null
imported_units: MhiImportResult | null
imported_at: string | null
created_at: string
updated_at: string
assigned_count: number
}
export interface MhiStatus {
external_ref: string
onOff?: string
mode?: string
setpoint?: number
fanSpeed?: string
roomTemp?: number
errorCode?: number
compressor?: string
commStatus?: string
filterSign?: string
error?: string
}
export const MHI_MODES = ['cool', 'heat', 'fan', 'auto', 'dry'] as const
export const MHI_FAN_SPEEDS = ['low', 'medium', 'high', 'powerful'] as const
// Daikin local-API mode/fan enums — see backend/src/lib/drivers/daikin.js for the
// encode/decode against the unit's actual wire values (unverified against live control
// commands as of 2026-08-25, same "verify on first real write" caveat as MHI's early days).
export const DAIKIN_MODES = ['cool', 'heat', 'fan', 'auto', 'dry'] as const
export const DAIKIN_FAN_SPEEDS = ['auto', 'low', 'medium', 'high', 'powerful'] as const
export interface DaikinStatus {
external_ref: string
onOff?: string
mode?: string
setpoint?: number
fanSpeed?: string
roomTemp?: number
status_updated_at?: string | null
error?: string
}
export interface TrvStatus {
external_ref: string
setpoint?: number
roomTemp?: number
heatingActive?: boolean | null
valvePosition?: number | null
wifiRssi?: number | null
batteryPct?: number | null
targetOrigin?: string | null
lastSeen?: string | null
statusUpdatedAt?: string | null
error?: 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
}
// One row of GET /api/devices/:id/history — sparse by design (see recordReading()
// in backend/src/db.js): whichever fields the event that produced this row actually
// knew about are set, the rest are null. The history chart treats each column
// independently rather than assuming a full snapshot on every row.
export interface DeviceReading {
room_temp: string | null
target_temp: string | null
mode: string | null
power_state: 'on' | 'off' | null
fan_speed: string | null
valve_position: number | null
heating_active: boolean | null
recorded_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
}
// Global occupied/vacant template — the fallback every room inherits from unless it
// sets its own override (see Zone's nullable ac_*/occupied_temp/vacant_temp fields).
export interface HvacAcTemplate {
power: 'on' | 'off'
mode: string
setpoint: number
fan_speed: string
}
// Weather compensation tier — same shape as HvacAcTemplate plus the forecast-peak
// threshold that activates it. Tiers are stored ascending by peak_above_c; the
// scheduler picks the highest one today's forecast peak clears (see resolveAcTier()
// in backend/src/lib/scheduler.js), falling back to plain ac_occupied below all of them.
export interface HvacWeatherTier extends HvacAcTemplate {
name: string
peak_above_c: number
}
// What GET /api/status resolves as "the occupied AC template any room transitioning
// into occupied right now would get" — hvac_defaults.ac_occupied, or a weather tier
// override on top if one clears (tierApplied names it; null means the plain baseline
// applies). Doesn't reflect any individual room's own per-field overrides.
export interface ActiveAcTemplate extends HvacAcTemplate {
tierApplied: string | null
}
export interface HvacDefaults {
arrival_prep_offset_min: number
departure_prep_offset_min: number
trv_occupied_temp: number
trv_vacant_temp: number
ac_occupied: HvacAcTemplate
ac_vacant: HvacAcTemplate
ac_occupied_tiers: HvacWeatherTier[]
}
export const DEFAULT_HVAC_TEMPLATE: HvacDefaults = {
arrival_prep_offset_min: 120,
departure_prep_offset_min: -30,
trv_occupied_temp: 22.0,
trv_vacant_temp: 16.0,
ac_occupied: { power: 'on', mode: 'cool', setpoint: 22.0, fan_speed: 'medium' },
ac_vacant: { power: 'off', mode: 'cool', setpoint: 18.0, fan_speed: 'low' },
ac_occupied_tiers: [
{ name: 'warm', peak_above_c: 24, power: 'off', mode: 'cool', setpoint: 20.0, fan_speed: 'medium' },
{ name: 'hot', peak_above_c: 28, power: 'on', mode: 'cool', setpoint: 20.0, fan_speed: 'high' },
{ name: 'heatwave', peak_above_c: 32, power: 'on', mode: 'cool', setpoint: 20.0, fan_speed: 'powerful' },
],
}
export interface AppConfig {
automation_enabled?: boolean
poll_interval_minutes: number
default_arrival_time: string
default_departure_time: string
maintenance_url: string
maintenance_api_key: string
daikin_subnet_cidr?: string
excluded_site_ids?: string[]
hvac_defaults?: HvacDefaults
}
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)
}