feat(smarthome): add smart home features and Home Assistant integration (WebSocket protocol, discovery, factories, interfaces)
This commit is contained in:
407
ts/features/feature.climate.ts
Normal file
407
ts/features/feature.climate.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
/**
|
||||
* 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<IClimateCapabilities>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Climate Feature - thermostat and HVAC control
|
||||
*
|
||||
* Protocol-agnostic: works with Home Assistant, Nest, Ecobee, MQTT, etc.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const climate = device.getFeature<ClimateFeature>('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<void> {
|
||||
try {
|
||||
const state = await this.protocolClient.getState(this.entityId);
|
||||
this.updateStateInternal(state);
|
||||
} catch {
|
||||
// Ignore errors fetching initial state
|
||||
}
|
||||
}
|
||||
|
||||
protected async doDisconnect(): Promise<void> {
|
||||
// Nothing to disconnect
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Climate Control
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Set HVAC mode
|
||||
* @param mode HVAC mode (off, heat, cool, etc.)
|
||||
*/
|
||||
public async setHvacMode(mode: THvacMode): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<IClimateState> {
|
||||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user