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 = { vacant: 'Vacant', booked: 'Booked', heating_up: 'Preparing', occupied: 'Occupied', cooling_down: 'Cooling Down', } export const DEVICE_TYPE_LABELS: Record = { 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'] // 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 | 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 } export interface MhiImportResult { meta: { projectName: string | null mapsVersion: string | null internalProtocol: string | null externalProtocol: string | null timestamp: string | null } global: Record 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 } 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) }