/** * Climate Feature * Provides control for thermostats and HVAC systems */ import { Feature, type TDeviceReference } from './feature.abstract.js'; import type { IFeatureOptions } from '../interfaces/feature.interfaces.js'; import type { TClimateProtocol, THvacMode, THvacAction, IClimateCapabilities, IClimateState, IClimateFeatureInfo, IClimateProtocolClient, } from '../interfaces/smarthome.interfaces.js'; /** * Options for creating a ClimateFeature */ export interface IClimateFeatureOptions extends IFeatureOptions { /** Protocol type */ protocol: TClimateProtocol; /** Entity ID (for Home Assistant) */ entityId: string; /** Protocol client for controlling the climate device */ protocolClient: IClimateProtocolClient; /** Climate capabilities */ capabilities?: Partial; } /** * Climate Feature - thermostat and HVAC control * * Protocol-agnostic: works with Home Assistant, Nest, Ecobee, MQTT, etc. * * @example * ```typescript * const climate = device.getFeature('climate'); * if (climate) { * await climate.setHvacMode('heat'); * await climate.setTargetTemp(21); * console.log(`Current: ${climate.currentTemp}°C, Target: ${climate.targetTemp}°C`); * } * ``` */ export class ClimateFeature extends Feature { public readonly type = 'climate' as const; public readonly protocol: TClimateProtocol; /** Entity ID (e.g., "climate.living_room") */ public readonly entityId: string; /** Capabilities */ public readonly capabilities: IClimateCapabilities; /** Current state */ protected _currentTemp?: number; protected _targetTemp?: number; protected _targetTempHigh?: number; protected _targetTempLow?: number; protected _hvacMode: THvacMode = 'off'; protected _hvacAction?: THvacAction; protected _presetMode?: string; protected _fanMode?: string; protected _swingMode?: string; protected _humidity?: number; protected _targetHumidity?: number; protected _auxHeat?: boolean; /** Protocol client for controlling the climate device */ private protocolClient: IClimateProtocolClient; constructor( device: TDeviceReference, port: number, options: IClimateFeatureOptions ) { super(device, port, options); this.protocol = options.protocol; this.entityId = options.entityId; this.protocolClient = options.protocolClient; this.capabilities = { hvacModes: options.capabilities?.hvacModes ?? ['off', 'heat', 'cool', 'auto'], presetModes: options.capabilities?.presetModes, fanModes: options.capabilities?.fanModes, swingModes: options.capabilities?.swingModes, supportsTargetTemp: options.capabilities?.supportsTargetTemp ?? true, supportsTargetTempRange: options.capabilities?.supportsTargetTempRange ?? false, supportsHumidity: options.capabilities?.supportsHumidity ?? false, supportsAuxHeat: options.capabilities?.supportsAuxHeat ?? false, minTemp: options.capabilities?.minTemp ?? 7, maxTemp: options.capabilities?.maxTemp ?? 35, tempStep: options.capabilities?.tempStep ?? 0.5, minHumidity: options.capabilities?.minHumidity, maxHumidity: options.capabilities?.maxHumidity, }; } // ============================================================================ // Properties // ============================================================================ /** * Get current temperature (cached) */ public get currentTemp(): number | undefined { return this._currentTemp; } /** * Get target temperature (cached) */ public get targetTemp(): number | undefined { return this._targetTemp; } /** * Get target temperature high (for heat_cool mode, cached) */ public get targetTempHigh(): number | undefined { return this._targetTempHigh; } /** * Get target temperature low (for heat_cool mode, cached) */ public get targetTempLow(): number | undefined { return this._targetTempLow; } /** * Get current HVAC mode (cached) */ public get hvacMode(): THvacMode { return this._hvacMode; } /** * Get current HVAC action (cached) */ public get hvacAction(): THvacAction | undefined { return this._hvacAction; } /** * Get current preset mode (cached) */ public get presetMode(): string | undefined { return this._presetMode; } /** * Get current fan mode (cached) */ public get fanMode(): string | undefined { return this._fanMode; } /** * Get current swing mode (cached) */ public get swingMode(): string | undefined { return this._swingMode; } /** * Get current humidity (cached) */ public get humidity(): number | undefined { return this._humidity; } /** * Get target humidity (cached) */ public get targetHumidity(): number | undefined { return this._targetHumidity; } /** * Get aux heat state (cached) */ public get auxHeat(): boolean | undefined { return this._auxHeat; } /** * Get available HVAC modes */ public get hvacModes(): THvacMode[] { return this.capabilities.hvacModes; } /** * Get available preset modes */ public get presetModes(): string[] | undefined { return this.capabilities.presetModes; } /** * Get available fan modes */ public get fanModes(): string[] | undefined { return this.capabilities.fanModes; } // ============================================================================ // Connection // ============================================================================ protected async doConnect(): Promise { try { const state = await this.protocolClient.getState(this.entityId); this.updateStateInternal(state); } catch { // Ignore errors fetching initial state } } protected async doDisconnect(): Promise { // Nothing to disconnect } // ============================================================================ // Climate Control // ============================================================================ /** * Set HVAC mode * @param mode HVAC mode (off, heat, cool, etc.) */ public async setHvacMode(mode: THvacMode): Promise { if (!this.capabilities.hvacModes.includes(mode)) { throw new Error(`HVAC mode ${mode} not supported`); } await this.protocolClient.setHvacMode(this.entityId, mode); this._hvacMode = mode; this.emit('state:changed', this.getState()); } /** * Set target temperature * @param temp Target temperature */ public async setTargetTemp(temp: number): Promise { if (!this.capabilities.supportsTargetTemp) { throw new Error('Climate device does not support target temperature'); } const clamped = Math.max( this.capabilities.minTemp, Math.min(this.capabilities.maxTemp, temp) ); await this.protocolClient.setTargetTemp(this.entityId, clamped); this._targetTemp = clamped; this.emit('state:changed', this.getState()); } /** * Set target temperature range (for heat_cool mode) * @param low Low temperature * @param high High temperature */ public async setTargetTempRange(low: number, high: number): Promise { if (!this.capabilities.supportsTargetTempRange) { throw new Error('Climate device does not support temperature range'); } const clampedLow = Math.max(this.capabilities.minTemp, Math.min(this.capabilities.maxTemp, low)); const clampedHigh = Math.max(this.capabilities.minTemp, Math.min(this.capabilities.maxTemp, high)); await this.protocolClient.setTargetTempRange(this.entityId, clampedLow, clampedHigh); this._targetTempLow = clampedLow; this._targetTempHigh = clampedHigh; this.emit('state:changed', this.getState()); } /** * Set preset mode * @param preset Preset mode name */ public async setPresetMode(preset: string): Promise { if (!this.capabilities.presetModes?.includes(preset)) { throw new Error(`Preset mode ${preset} not supported`); } await this.protocolClient.setPresetMode(this.entityId, preset); this._presetMode = preset; this.emit('state:changed', this.getState()); } /** * Set fan mode * @param mode Fan mode name */ public async setFanMode(mode: string): Promise { if (!this.capabilities.fanModes?.includes(mode)) { throw new Error(`Fan mode ${mode} not supported`); } await this.protocolClient.setFanMode(this.entityId, mode); this._fanMode = mode; this.emit('state:changed', this.getState()); } /** * Set swing mode * @param mode Swing mode name */ public async setSwingMode(mode: string): Promise { if (!this.capabilities.swingModes?.includes(mode)) { throw new Error(`Swing mode ${mode} not supported`); } await this.protocolClient.setSwingMode(this.entityId, mode); this._swingMode = mode; this.emit('state:changed', this.getState()); } /** * Set aux heat * @param enabled Whether aux heat is enabled */ public async setAuxHeat(enabled: boolean): Promise { if (!this.capabilities.supportsAuxHeat) { throw new Error('Climate device does not support aux heat'); } await this.protocolClient.setAuxHeat(this.entityId, enabled); this._auxHeat = enabled; this.emit('state:changed', this.getState()); } /** * Get current state as object */ public getState(): IClimateState { return { currentTemp: this._currentTemp, targetTemp: this._targetTemp, targetTempHigh: this._targetTempHigh, targetTempLow: this._targetTempLow, hvacMode: this._hvacMode, hvacAction: this._hvacAction, presetMode: this._presetMode, fanMode: this._fanMode, swingMode: this._swingMode, humidity: this._humidity, targetHumidity: this._targetHumidity, auxHeat: this._auxHeat, }; } /** * Refresh state from the device */ public async refreshState(): Promise { const state = await this.protocolClient.getState(this.entityId); this.updateStateInternal(state); return state; } /** * Update state from external source */ public updateState(state: IClimateState): void { this.updateStateInternal(state); this.emit('state:changed', state); } /** * Internal state update */ private updateStateInternal(state: IClimateState): void { this._currentTemp = state.currentTemp; this._targetTemp = state.targetTemp; this._targetTempHigh = state.targetTempHigh; this._targetTempLow = state.targetTempLow; this._hvacMode = state.hvacMode; this._hvacAction = state.hvacAction; this._presetMode = state.presetMode; this._fanMode = state.fanMode; this._swingMode = state.swingMode; this._humidity = state.humidity; this._targetHumidity = state.targetHumidity; this._auxHeat = state.auxHeat; } // ============================================================================ // Feature Info // ============================================================================ public getFeatureInfo(): IClimateFeatureInfo { return { ...this.getBaseFeatureInfo(), type: 'climate', protocol: this.protocol, capabilities: this.capabilities, currentState: this.getState(), }; } }