Backend: - New valve_position column, populated from shellies/<id>/info alongside wifi_rssi (was already a column, never actually written to). - New GET/POST /api/devices/:id/trv-status and /trv-control routes, mirroring the mhi-status/mhi-control and daikin-status/daikin-control pattern. Control goes through the existing setTargetWithRetry() reliability pipeline. Frontend: - New TrvControlPanel.tsx: +/- setpoint control debounced 1s after the last click before firing a command (these are battery/deep-sleep valves going through a real retry/backoff pipeline, not a cheap register write like MHI/ Daikin), plus room temp, valve position %, wifi signal, and a live "time since last update" readout. Wired into ZoneDetailModal for shelly_trv rows. - ZoneCard dashboard tile: radiator icon now just shows red when heating_active (valve open) / grey when idle, with only the target setpoint alongside it (dropped the room-temp half, redundant with the zone-level temp line above).
310 lines
9.6 KiB
TypeScript
310 lines
9.6 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']
|
|
|
|
// 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
|
|
}
|
|
|
|
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)
|
|
}
|