feat(smarthome): add smart home features and Home Assistant integration (WebSocket protocol, discovery, factories, interfaces)
This commit is contained in:
214
ts/features/feature.camera.ts
Normal file
214
ts/features/feature.camera.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Camera Feature
|
||||
* Provides control for smart cameras (snapshots, streams)
|
||||
*/
|
||||
|
||||
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
||||
import type { IFeatureOptions } from '../interfaces/feature.interfaces.js';
|
||||
import type {
|
||||
TCameraProtocol,
|
||||
ICameraCapabilities,
|
||||
ICameraState,
|
||||
ICameraFeatureInfo,
|
||||
ICameraProtocolClient,
|
||||
} from '../interfaces/smarthome.interfaces.js';
|
||||
|
||||
/**
|
||||
* Options for creating a CameraFeature
|
||||
*/
|
||||
export interface ICameraFeatureOptions extends IFeatureOptions {
|
||||
/** Protocol type */
|
||||
protocol: TCameraProtocol;
|
||||
/** Entity ID (for Home Assistant) */
|
||||
entityId: string;
|
||||
/** Protocol client for the camera */
|
||||
protocolClient: ICameraProtocolClient;
|
||||
/** Camera capabilities */
|
||||
capabilities?: Partial<ICameraCapabilities>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Camera Feature - snapshot and stream access
|
||||
*
|
||||
* Protocol-agnostic: works with Home Assistant, ONVIF, RTSP, etc.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const camera = device.getFeature<CameraFeature>('camera');
|
||||
* if (camera) {
|
||||
* const snapshot = await camera.getSnapshot();
|
||||
* const streamUrl = await camera.getStreamUrl();
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class CameraFeature extends Feature {
|
||||
public readonly type = 'camera' as const;
|
||||
public readonly protocol: TCameraProtocol;
|
||||
|
||||
/** Entity ID (e.g., "camera.front_door") */
|
||||
public readonly entityId: string;
|
||||
|
||||
/** Capabilities */
|
||||
public readonly capabilities: ICameraCapabilities;
|
||||
|
||||
/** Current state */
|
||||
protected _isRecording: boolean = false;
|
||||
protected _isStreaming: boolean = false;
|
||||
protected _motionDetected: boolean = false;
|
||||
|
||||
/** Protocol client for the camera */
|
||||
private protocolClient: ICameraProtocolClient;
|
||||
|
||||
constructor(
|
||||
device: TDeviceReference,
|
||||
port: number,
|
||||
options: ICameraFeatureOptions
|
||||
) {
|
||||
super(device, port, options);
|
||||
this.protocol = options.protocol;
|
||||
this.entityId = options.entityId;
|
||||
this.protocolClient = options.protocolClient;
|
||||
|
||||
this.capabilities = {
|
||||
supportsStream: options.capabilities?.supportsStream ?? true,
|
||||
supportsPtz: options.capabilities?.supportsPtz ?? false,
|
||||
supportsSnapshot: options.capabilities?.supportsSnapshot ?? true,
|
||||
supportsMotionDetection: options.capabilities?.supportsMotionDetection ?? false,
|
||||
frontendStreamType: options.capabilities?.frontendStreamType,
|
||||
streamUrl: options.capabilities?.streamUrl,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Properties
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Check if recording (cached)
|
||||
*/
|
||||
public get isRecording(): boolean {
|
||||
return this._isRecording;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if streaming (cached)
|
||||
*/
|
||||
public get isStreaming(): boolean {
|
||||
return this._isStreaming;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if motion detected (cached)
|
||||
*/
|
||||
public get motionDetected(): boolean {
|
||||
return this._motionDetected;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Camera Access
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get a snapshot image from the camera
|
||||
* @returns Buffer containing image data
|
||||
*/
|
||||
public async getSnapshot(): Promise<Buffer> {
|
||||
if (!this.capabilities.supportsSnapshot) {
|
||||
throw new Error('Camera does not support snapshots');
|
||||
}
|
||||
|
||||
return this.protocolClient.getSnapshot(this.entityId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get snapshot URL
|
||||
* @returns URL for the snapshot image
|
||||
*/
|
||||
public async getSnapshotUrl(): Promise<string> {
|
||||
if (!this.capabilities.supportsSnapshot) {
|
||||
throw new Error('Camera does not support snapshots');
|
||||
}
|
||||
|
||||
return this.protocolClient.getSnapshotUrl(this.entityId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stream URL
|
||||
* @returns URL for the video stream
|
||||
*/
|
||||
public async getStreamUrl(): Promise<string> {
|
||||
if (!this.capabilities.supportsStream) {
|
||||
throw new Error('Camera does not support streaming');
|
||||
}
|
||||
|
||||
return this.protocolClient.getStreamUrl(this.entityId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current state as object
|
||||
*/
|
||||
public getState(): ICameraState {
|
||||
return {
|
||||
isRecording: this._isRecording,
|
||||
isStreaming: this._isStreaming,
|
||||
motionDetected: this._motionDetected,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh state from the device
|
||||
*/
|
||||
public async refreshState(): Promise<ICameraState> {
|
||||
const state = await this.protocolClient.getState(this.entityId);
|
||||
this.updateStateInternal(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update state from external source
|
||||
*/
|
||||
public updateState(state: ICameraState): void {
|
||||
this.updateStateInternal(state);
|
||||
this.emit('state:changed', state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal state update
|
||||
*/
|
||||
private updateStateInternal(state: ICameraState): void {
|
||||
this._isRecording = state.isRecording;
|
||||
this._isStreaming = state.isStreaming;
|
||||
this._motionDetected = state.motionDetected;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Feature Info
|
||||
// ============================================================================
|
||||
|
||||
public getFeatureInfo(): ICameraFeatureInfo {
|
||||
return {
|
||||
...this.getBaseFeatureInfo(),
|
||||
type: 'camera',
|
||||
protocol: this.protocol,
|
||||
capabilities: this.capabilities,
|
||||
currentState: this.getState(),
|
||||
};
|
||||
}
|
||||
}
|
||||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
278
ts/features/feature.cover.ts
Normal file
278
ts/features/feature.cover.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* Cover Feature
|
||||
* Provides control for covers, blinds, garage doors, etc.
|
||||
*/
|
||||
|
||||
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
||||
import type { IFeatureOptions } from '../interfaces/feature.interfaces.js';
|
||||
import type {
|
||||
TCoverProtocol,
|
||||
TCoverDeviceClass,
|
||||
TCoverState,
|
||||
ICoverCapabilities,
|
||||
ICoverStateInfo,
|
||||
ICoverFeatureInfo,
|
||||
ICoverProtocolClient,
|
||||
} from '../interfaces/smarthome.interfaces.js';
|
||||
|
||||
/**
|
||||
* Options for creating a CoverFeature
|
||||
*/
|
||||
export interface ICoverFeatureOptions extends IFeatureOptions {
|
||||
/** Protocol type */
|
||||
protocol: TCoverProtocol;
|
||||
/** Entity ID (for Home Assistant) */
|
||||
entityId: string;
|
||||
/** Protocol client for controlling the cover */
|
||||
protocolClient: ICoverProtocolClient;
|
||||
/** Device class */
|
||||
deviceClass?: TCoverDeviceClass;
|
||||
/** Cover capabilities */
|
||||
capabilities?: Partial<ICoverCapabilities>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cover Feature - control for blinds, garage doors, etc.
|
||||
*
|
||||
* Protocol-agnostic: works with Home Assistant, MQTT, Somfy, etc.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const cover = device.getFeature<CoverFeature>('cover');
|
||||
* if (cover) {
|
||||
* await cover.open();
|
||||
* await cover.setPosition(50); // 50% open
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class CoverFeature extends Feature {
|
||||
public readonly type = 'cover' as const;
|
||||
public readonly protocol: TCoverProtocol;
|
||||
|
||||
/** Entity ID (e.g., "cover.garage_door") */
|
||||
public readonly entityId: string;
|
||||
|
||||
/** Capabilities */
|
||||
public readonly capabilities: ICoverCapabilities;
|
||||
|
||||
/** Current cover state (not connection state) */
|
||||
protected _coverState: TCoverState = 'unknown';
|
||||
protected _position?: number;
|
||||
protected _tiltPosition?: number;
|
||||
|
||||
/** Protocol client for controlling the cover */
|
||||
private protocolClient: ICoverProtocolClient;
|
||||
|
||||
constructor(
|
||||
device: TDeviceReference,
|
||||
port: number,
|
||||
options: ICoverFeatureOptions
|
||||
) {
|
||||
super(device, port, options);
|
||||
this.protocol = options.protocol;
|
||||
this.entityId = options.entityId;
|
||||
this.protocolClient = options.protocolClient;
|
||||
|
||||
this.capabilities = {
|
||||
deviceClass: options.deviceClass,
|
||||
supportsOpen: options.capabilities?.supportsOpen ?? true,
|
||||
supportsClose: options.capabilities?.supportsClose ?? true,
|
||||
supportsStop: options.capabilities?.supportsStop ?? true,
|
||||
supportsPosition: options.capabilities?.supportsPosition ?? false,
|
||||
supportsTilt: options.capabilities?.supportsTilt ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Properties
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get current state (cached)
|
||||
*/
|
||||
public get coverState(): TCoverState {
|
||||
return this._coverState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current position 0-100 (cached)
|
||||
* 0 = closed, 100 = fully open
|
||||
*/
|
||||
public get position(): number | undefined {
|
||||
return this._position;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current tilt position 0-100 (cached)
|
||||
*/
|
||||
public get tiltPosition(): number | undefined {
|
||||
return this._tiltPosition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cover is open
|
||||
*/
|
||||
public get isOpen(): boolean {
|
||||
return this._coverState === 'open';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cover is closed
|
||||
*/
|
||||
public get isClosed(): boolean {
|
||||
return this._coverState === 'closed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cover is opening
|
||||
*/
|
||||
public get isOpening(): boolean {
|
||||
return this._coverState === 'opening';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cover is closing
|
||||
*/
|
||||
public get isClosing(): boolean {
|
||||
return this._coverState === 'closing';
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Cover Control
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Open the cover
|
||||
*/
|
||||
public async open(): Promise<void> {
|
||||
if (!this.capabilities.supportsOpen) {
|
||||
throw new Error('Cover does not support open');
|
||||
}
|
||||
await this.protocolClient.open(this.entityId);
|
||||
this._coverState = 'opening';
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the cover
|
||||
*/
|
||||
public async close(): Promise<void> {
|
||||
if (!this.capabilities.supportsClose) {
|
||||
throw new Error('Cover does not support close');
|
||||
}
|
||||
await this.protocolClient.close(this.entityId);
|
||||
this._coverState = 'closing';
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the cover
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
if (!this.capabilities.supportsStop) {
|
||||
throw new Error('Cover does not support stop');
|
||||
}
|
||||
await this.protocolClient.stop(this.entityId);
|
||||
this._coverState = 'stopped';
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set cover position
|
||||
* @param position Position 0-100 (0 = closed, 100 = open)
|
||||
*/
|
||||
public async setPosition(position: number): Promise<void> {
|
||||
if (!this.capabilities.supportsPosition) {
|
||||
throw new Error('Cover does not support position control');
|
||||
}
|
||||
|
||||
const clamped = Math.max(0, Math.min(100, Math.round(position)));
|
||||
await this.protocolClient.setPosition(this.entityId, clamped);
|
||||
this._position = clamped;
|
||||
this._coverState = clamped === 0 ? 'closed' : clamped === 100 ? 'open' : 'stopped';
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set tilt position
|
||||
* @param position Tilt position 0-100
|
||||
*/
|
||||
public async setTiltPosition(position: number): Promise<void> {
|
||||
if (!this.capabilities.supportsTilt) {
|
||||
throw new Error('Cover does not support tilt control');
|
||||
}
|
||||
|
||||
const clamped = Math.max(0, Math.min(100, Math.round(position)));
|
||||
await this.protocolClient.setTiltPosition(this.entityId, clamped);
|
||||
this._tiltPosition = clamped;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current state as object
|
||||
*/
|
||||
public getState(): ICoverStateInfo {
|
||||
return {
|
||||
state: this._coverState,
|
||||
position: this._position,
|
||||
tiltPosition: this._tiltPosition,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh state from the device
|
||||
*/
|
||||
public async refreshState(): Promise<ICoverStateInfo> {
|
||||
const state = await this.protocolClient.getState(this.entityId);
|
||||
this.updateStateInternal(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update state from external source
|
||||
*/
|
||||
public updateState(state: ICoverStateInfo): void {
|
||||
this.updateStateInternal(state);
|
||||
this.emit('state:changed', state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal state update
|
||||
*/
|
||||
private updateStateInternal(state: ICoverStateInfo): void {
|
||||
this._coverState = state.state;
|
||||
this._position = state.position;
|
||||
this._tiltPosition = state.tiltPosition;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Feature Info
|
||||
// ============================================================================
|
||||
|
||||
public getFeatureInfo(): ICoverFeatureInfo {
|
||||
return {
|
||||
...this.getBaseFeatureInfo(),
|
||||
type: 'cover',
|
||||
protocol: this.protocol,
|
||||
capabilities: this.capabilities,
|
||||
currentState: this.getState(),
|
||||
};
|
||||
}
|
||||
}
|
||||
296
ts/features/feature.fan.ts
Normal file
296
ts/features/feature.fan.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* Fan Feature
|
||||
* Provides control for fans (speed, oscillation, direction)
|
||||
*/
|
||||
|
||||
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
||||
import type { IFeatureOptions } from '../interfaces/feature.interfaces.js';
|
||||
import type {
|
||||
TFanProtocol,
|
||||
TFanDirection,
|
||||
IFanCapabilities,
|
||||
IFanState,
|
||||
IFanFeatureInfo,
|
||||
IFanProtocolClient,
|
||||
} from '../interfaces/smarthome.interfaces.js';
|
||||
|
||||
/**
|
||||
* Options for creating a FanFeature
|
||||
*/
|
||||
export interface IFanFeatureOptions extends IFeatureOptions {
|
||||
/** Protocol type */
|
||||
protocol: TFanProtocol;
|
||||
/** Entity ID (for Home Assistant) */
|
||||
entityId: string;
|
||||
/** Protocol client for controlling the fan */
|
||||
protocolClient: IFanProtocolClient;
|
||||
/** Fan capabilities */
|
||||
capabilities?: Partial<IFanCapabilities>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fan Feature - speed, oscillation, and direction control
|
||||
*
|
||||
* Protocol-agnostic: works with Home Assistant, MQTT, Bond, etc.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const fan = device.getFeature<FanFeature>('fan');
|
||||
* if (fan) {
|
||||
* await fan.turnOn(75); // 75% speed
|
||||
* await fan.setOscillating(true);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class FanFeature extends Feature {
|
||||
public readonly type = 'fan' as const;
|
||||
public readonly protocol: TFanProtocol;
|
||||
|
||||
/** Entity ID (e.g., "fan.bedroom") */
|
||||
public readonly entityId: string;
|
||||
|
||||
/** Capabilities */
|
||||
public readonly capabilities: IFanCapabilities;
|
||||
|
||||
/** Current state */
|
||||
protected _isOn: boolean = false;
|
||||
protected _percentage?: number;
|
||||
protected _presetMode?: string;
|
||||
protected _oscillating?: boolean;
|
||||
protected _direction?: TFanDirection;
|
||||
|
||||
/** Protocol client for controlling the fan */
|
||||
private protocolClient: IFanProtocolClient;
|
||||
|
||||
constructor(
|
||||
device: TDeviceReference,
|
||||
port: number,
|
||||
options: IFanFeatureOptions
|
||||
) {
|
||||
super(device, port, options);
|
||||
this.protocol = options.protocol;
|
||||
this.entityId = options.entityId;
|
||||
this.protocolClient = options.protocolClient;
|
||||
|
||||
this.capabilities = {
|
||||
supportsSpeed: options.capabilities?.supportsSpeed ?? true,
|
||||
supportsOscillate: options.capabilities?.supportsOscillate ?? false,
|
||||
supportsDirection: options.capabilities?.supportsDirection ?? false,
|
||||
supportsPresetModes: options.capabilities?.supportsPresetModes ?? false,
|
||||
presetModes: options.capabilities?.presetModes,
|
||||
speedCount: options.capabilities?.speedCount,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Properties
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get current on/off state (cached)
|
||||
*/
|
||||
public get isOn(): boolean {
|
||||
return this._isOn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current speed percentage 0-100 (cached)
|
||||
*/
|
||||
public get percentage(): number | undefined {
|
||||
return this._percentage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current preset mode (cached)
|
||||
*/
|
||||
public get presetMode(): string | undefined {
|
||||
return this._presetMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get oscillating state (cached)
|
||||
*/
|
||||
public get oscillating(): boolean | undefined {
|
||||
return this._oscillating;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get direction (cached)
|
||||
*/
|
||||
public get direction(): TFanDirection | undefined {
|
||||
return this._direction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available preset modes
|
||||
*/
|
||||
public get presetModes(): string[] | undefined {
|
||||
return this.capabilities.presetModes;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Fan Control
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Turn on the fan
|
||||
* @param percentage Optional speed percentage
|
||||
*/
|
||||
public async turnOn(percentage?: number): Promise<void> {
|
||||
await this.protocolClient.turnOn(this.entityId, percentage);
|
||||
this._isOn = true;
|
||||
if (percentage !== undefined) {
|
||||
this._percentage = percentage;
|
||||
}
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn off the fan
|
||||
*/
|
||||
public async turnOff(): Promise<void> {
|
||||
await this.protocolClient.turnOff(this.entityId);
|
||||
this._isOn = false;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the fan
|
||||
*/
|
||||
public async toggle(): Promise<void> {
|
||||
await this.protocolClient.toggle(this.entityId);
|
||||
this._isOn = !this._isOn;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set speed percentage
|
||||
* @param percentage Speed 0-100
|
||||
*/
|
||||
public async setPercentage(percentage: number): Promise<void> {
|
||||
if (!this.capabilities.supportsSpeed) {
|
||||
throw new Error('Fan does not support speed control');
|
||||
}
|
||||
|
||||
const clamped = Math.max(0, Math.min(100, Math.round(percentage)));
|
||||
await this.protocolClient.setPercentage(this.entityId, clamped);
|
||||
this._percentage = clamped;
|
||||
this._isOn = clamped > 0;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set preset mode
|
||||
* @param mode Preset mode name
|
||||
*/
|
||||
public async setPresetMode(mode: string): Promise<void> {
|
||||
if (!this.capabilities.supportsPresetModes) {
|
||||
throw new Error('Fan does not support preset modes');
|
||||
}
|
||||
|
||||
await this.protocolClient.setPresetMode(this.entityId, mode);
|
||||
this._presetMode = mode;
|
||||
this._isOn = true;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set oscillating state
|
||||
* @param oscillating Whether to oscillate
|
||||
*/
|
||||
public async setOscillating(oscillating: boolean): Promise<void> {
|
||||
if (!this.capabilities.supportsOscillate) {
|
||||
throw new Error('Fan does not support oscillation');
|
||||
}
|
||||
|
||||
await this.protocolClient.setOscillating(this.entityId, oscillating);
|
||||
this._oscillating = oscillating;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set direction
|
||||
* @param direction forward or reverse
|
||||
*/
|
||||
public async setDirection(direction: TFanDirection): Promise<void> {
|
||||
if (!this.capabilities.supportsDirection) {
|
||||
throw new Error('Fan does not support direction control');
|
||||
}
|
||||
|
||||
await this.protocolClient.setDirection(this.entityId, direction);
|
||||
this._direction = direction;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current state as object
|
||||
*/
|
||||
public getState(): IFanState {
|
||||
return {
|
||||
isOn: this._isOn,
|
||||
percentage: this._percentage,
|
||||
presetMode: this._presetMode,
|
||||
oscillating: this._oscillating,
|
||||
direction: this._direction,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh state from the device
|
||||
*/
|
||||
public async refreshState(): Promise<IFanState> {
|
||||
const state = await this.protocolClient.getState(this.entityId);
|
||||
this.updateStateInternal(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update state from external source
|
||||
*/
|
||||
public updateState(state: IFanState): void {
|
||||
this.updateStateInternal(state);
|
||||
this.emit('state:changed', state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal state update
|
||||
*/
|
||||
private updateStateInternal(state: IFanState): void {
|
||||
this._isOn = state.isOn;
|
||||
this._percentage = state.percentage;
|
||||
this._presetMode = state.presetMode;
|
||||
this._oscillating = state.oscillating;
|
||||
this._direction = state.direction;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Feature Info
|
||||
// ============================================================================
|
||||
|
||||
public getFeatureInfo(): IFanFeatureInfo {
|
||||
return {
|
||||
...this.getBaseFeatureInfo(),
|
||||
type: 'fan',
|
||||
protocol: this.protocol,
|
||||
capabilities: this.capabilities,
|
||||
currentState: this.getState(),
|
||||
};
|
||||
}
|
||||
}
|
||||
369
ts/features/feature.light.ts
Normal file
369
ts/features/feature.light.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* Light Feature
|
||||
* Provides control for smart lights (brightness, color, effects)
|
||||
*/
|
||||
|
||||
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
||||
import type { IFeatureOptions } from '../interfaces/feature.interfaces.js';
|
||||
import type {
|
||||
TLightProtocol,
|
||||
ILightCapabilities,
|
||||
ILightState,
|
||||
ILightFeatureInfo,
|
||||
ILightProtocolClient,
|
||||
} from '../interfaces/smarthome.interfaces.js';
|
||||
|
||||
/**
|
||||
* Options for creating a LightFeature
|
||||
*/
|
||||
export interface ILightFeatureOptions extends IFeatureOptions {
|
||||
/** Protocol type */
|
||||
protocol: TLightProtocol;
|
||||
/** Entity ID (for Home Assistant) */
|
||||
entityId: string;
|
||||
/** Protocol client for controlling the light */
|
||||
protocolClient: ILightProtocolClient;
|
||||
/** Light capabilities */
|
||||
capabilities?: Partial<ILightCapabilities>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Light Feature - brightness, color, and effect control
|
||||
*
|
||||
* Protocol-agnostic: works with Home Assistant, Hue, MQTT, Zigbee, etc.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const light = device.getFeature<LightFeature>('light');
|
||||
* if (light) {
|
||||
* await light.turnOn({ brightness: 200 });
|
||||
* await light.setColorTemp(4000); // 4000K warm white
|
||||
* await light.setRgbColor(255, 100, 50);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class LightFeature extends Feature {
|
||||
public readonly type = 'light' as const;
|
||||
public readonly protocol: TLightProtocol;
|
||||
|
||||
/** Entity ID (e.g., "light.living_room") */
|
||||
public readonly entityId: string;
|
||||
|
||||
/** Capabilities */
|
||||
public readonly capabilities: ILightCapabilities;
|
||||
|
||||
/** Current state */
|
||||
protected _isOn: boolean = false;
|
||||
protected _brightness?: number;
|
||||
protected _colorTemp?: number;
|
||||
protected _colorTempMireds?: number;
|
||||
protected _rgbColor?: [number, number, number];
|
||||
protected _hsColor?: [number, number];
|
||||
protected _xyColor?: [number, number];
|
||||
protected _effect?: string;
|
||||
|
||||
/** Protocol client for controlling the light */
|
||||
private protocolClient: ILightProtocolClient;
|
||||
|
||||
constructor(
|
||||
device: TDeviceReference,
|
||||
port: number,
|
||||
options: ILightFeatureOptions
|
||||
) {
|
||||
super(device, port, options);
|
||||
this.protocol = options.protocol;
|
||||
this.entityId = options.entityId;
|
||||
this.protocolClient = options.protocolClient;
|
||||
|
||||
// Set capabilities with defaults
|
||||
this.capabilities = {
|
||||
supportsBrightness: options.capabilities?.supportsBrightness ?? false,
|
||||
supportsColorTemp: options.capabilities?.supportsColorTemp ?? false,
|
||||
supportsRgb: options.capabilities?.supportsRgb ?? false,
|
||||
supportsHs: options.capabilities?.supportsHs ?? false,
|
||||
supportsXy: options.capabilities?.supportsXy ?? false,
|
||||
supportsEffects: options.capabilities?.supportsEffects ?? false,
|
||||
supportsTransition: options.capabilities?.supportsTransition ?? true,
|
||||
effects: options.capabilities?.effects,
|
||||
minMireds: options.capabilities?.minMireds,
|
||||
maxMireds: options.capabilities?.maxMireds,
|
||||
minColorTempKelvin: options.capabilities?.minColorTempKelvin,
|
||||
maxColorTempKelvin: options.capabilities?.maxColorTempKelvin,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Properties
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get current on/off state (cached)
|
||||
*/
|
||||
public get isOn(): boolean {
|
||||
return this._isOn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current brightness 0-255 (cached)
|
||||
*/
|
||||
public get brightness(): number | undefined {
|
||||
return this._brightness;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current color temperature in Kelvin (cached)
|
||||
*/
|
||||
public get colorTemp(): number | undefined {
|
||||
return this._colorTemp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current color temperature in Mireds (cached)
|
||||
*/
|
||||
public get colorTempMireds(): number | undefined {
|
||||
return this._colorTempMireds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current RGB color (cached)
|
||||
*/
|
||||
public get rgbColor(): [number, number, number] | undefined {
|
||||
return this._rgbColor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current HS color [hue 0-360, saturation 0-100] (cached)
|
||||
*/
|
||||
public get hsColor(): [number, number] | undefined {
|
||||
return this._hsColor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current XY color (cached)
|
||||
*/
|
||||
public get xyColor(): [number, number] | undefined {
|
||||
return this._xyColor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current effect (cached)
|
||||
*/
|
||||
public get effect(): string | undefined {
|
||||
return this._effect;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available effects
|
||||
*/
|
||||
public get effects(): string[] | undefined {
|
||||
return this.capabilities.effects;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Connection
|
||||
// ============================================================================
|
||||
|
||||
protected async doConnect(): Promise<void> {
|
||||
// Fetch initial state
|
||||
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
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Light Control
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Turn on the light
|
||||
* @param options Optional settings to apply when turning on
|
||||
*/
|
||||
public async turnOn(options?: {
|
||||
brightness?: number;
|
||||
colorTemp?: number;
|
||||
rgb?: [number, number, number];
|
||||
transition?: number;
|
||||
}): Promise<void> {
|
||||
await this.protocolClient.turnOn(this.entityId, options);
|
||||
this._isOn = true;
|
||||
if (options?.brightness !== undefined) {
|
||||
this._brightness = options.brightness;
|
||||
}
|
||||
if (options?.colorTemp !== undefined) {
|
||||
this._colorTemp = options.colorTemp;
|
||||
}
|
||||
if (options?.rgb !== undefined) {
|
||||
this._rgbColor = options.rgb;
|
||||
}
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn off the light
|
||||
* @param options Optional transition time
|
||||
*/
|
||||
public async turnOff(options?: { transition?: number }): Promise<void> {
|
||||
await this.protocolClient.turnOff(this.entityId, options);
|
||||
this._isOn = false;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the light
|
||||
*/
|
||||
public async toggle(): Promise<void> {
|
||||
await this.protocolClient.toggle(this.entityId);
|
||||
this._isOn = !this._isOn;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set brightness level
|
||||
* @param brightness Brightness 0-255
|
||||
* @param transition Optional transition time in seconds
|
||||
*/
|
||||
public async setBrightness(brightness: number, transition?: number): Promise<void> {
|
||||
if (!this.capabilities.supportsBrightness) {
|
||||
throw new Error('Light does not support brightness control');
|
||||
}
|
||||
|
||||
const clamped = Math.max(0, Math.min(255, Math.round(brightness)));
|
||||
await this.protocolClient.setBrightness(this.entityId, clamped, transition);
|
||||
this._brightness = clamped;
|
||||
if (clamped > 0) {
|
||||
this._isOn = true;
|
||||
}
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set color temperature in Kelvin
|
||||
* @param kelvin Color temperature in Kelvin (e.g., 2700 warm, 6500 cool)
|
||||
* @param transition Optional transition time in seconds
|
||||
*/
|
||||
public async setColorTemp(kelvin: number, transition?: number): Promise<void> {
|
||||
if (!this.capabilities.supportsColorTemp) {
|
||||
throw new Error('Light does not support color temperature');
|
||||
}
|
||||
|
||||
// Clamp to supported range if available
|
||||
let clamped = kelvin;
|
||||
if (this.capabilities.minColorTempKelvin && this.capabilities.maxColorTempKelvin) {
|
||||
clamped = Math.max(
|
||||
this.capabilities.minColorTempKelvin,
|
||||
Math.min(this.capabilities.maxColorTempKelvin, kelvin)
|
||||
);
|
||||
}
|
||||
|
||||
await this.protocolClient.setColorTemp(this.entityId, clamped, transition);
|
||||
this._colorTemp = clamped;
|
||||
this._colorTempMireds = Math.round(1000000 / clamped);
|
||||
this._isOn = true;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set RGB color
|
||||
* @param r Red 0-255
|
||||
* @param g Green 0-255
|
||||
* @param b Blue 0-255
|
||||
* @param transition Optional transition time in seconds
|
||||
*/
|
||||
public async setRgbColor(r: number, g: number, b: number, transition?: number): Promise<void> {
|
||||
if (!this.capabilities.supportsRgb) {
|
||||
throw new Error('Light does not support RGB color');
|
||||
}
|
||||
|
||||
const clampedR = Math.max(0, Math.min(255, Math.round(r)));
|
||||
const clampedG = Math.max(0, Math.min(255, Math.round(g)));
|
||||
const clampedB = Math.max(0, Math.min(255, Math.round(b)));
|
||||
|
||||
await this.protocolClient.setRgbColor(this.entityId, clampedR, clampedG, clampedB, transition);
|
||||
this._rgbColor = [clampedR, clampedG, clampedB];
|
||||
this._isOn = true;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set light effect
|
||||
* @param effect Effect name from available effects
|
||||
*/
|
||||
public async setEffect(effect: string): Promise<void> {
|
||||
if (!this.capabilities.supportsEffects) {
|
||||
throw new Error('Light does not support effects');
|
||||
}
|
||||
|
||||
await this.protocolClient.setEffect(this.entityId, effect);
|
||||
this._effect = effect;
|
||||
this._isOn = true;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current state as object
|
||||
*/
|
||||
public getState(): ILightState {
|
||||
return {
|
||||
isOn: this._isOn,
|
||||
brightness: this._brightness,
|
||||
colorTemp: this._colorTemp,
|
||||
colorTempMireds: this._colorTempMireds,
|
||||
rgbColor: this._rgbColor,
|
||||
hsColor: this._hsColor,
|
||||
xyColor: this._xyColor,
|
||||
effect: this._effect,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh state from the device
|
||||
*/
|
||||
public async refreshState(): Promise<ILightState> {
|
||||
const state = await this.protocolClient.getState(this.entityId);
|
||||
this.updateStateInternal(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update state from external source (e.g., state change event)
|
||||
*/
|
||||
public updateState(state: ILightState): void {
|
||||
this.updateStateInternal(state);
|
||||
this.emit('state:changed', state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal state update
|
||||
*/
|
||||
private updateStateInternal(state: ILightState): void {
|
||||
this._isOn = state.isOn;
|
||||
this._brightness = state.brightness;
|
||||
this._colorTemp = state.colorTemp;
|
||||
this._colorTempMireds = state.colorTempMireds;
|
||||
this._rgbColor = state.rgbColor;
|
||||
this._hsColor = state.hsColor;
|
||||
this._xyColor = state.xyColor;
|
||||
this._effect = state.effect;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Feature Info
|
||||
// ============================================================================
|
||||
|
||||
public getFeatureInfo(): ILightFeatureInfo {
|
||||
return {
|
||||
...this.getBaseFeatureInfo(),
|
||||
type: 'light',
|
||||
protocol: this.protocol,
|
||||
capabilities: this.capabilities,
|
||||
currentState: this.getState(),
|
||||
};
|
||||
}
|
||||
}
|
||||
223
ts/features/feature.lock.ts
Normal file
223
ts/features/feature.lock.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Lock Feature
|
||||
* Provides control for smart locks
|
||||
*/
|
||||
|
||||
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
||||
import type { IFeatureOptions } from '../interfaces/feature.interfaces.js';
|
||||
import type {
|
||||
TLockProtocol,
|
||||
TLockState,
|
||||
ILockCapabilities,
|
||||
ILockStateInfo,
|
||||
ILockFeatureInfo,
|
||||
ILockProtocolClient,
|
||||
} from '../interfaces/smarthome.interfaces.js';
|
||||
|
||||
/**
|
||||
* Options for creating a LockFeature
|
||||
*/
|
||||
export interface ILockFeatureOptions extends IFeatureOptions {
|
||||
/** Protocol type */
|
||||
protocol: TLockProtocol;
|
||||
/** Entity ID (for Home Assistant) */
|
||||
entityId: string;
|
||||
/** Protocol client for controlling the lock */
|
||||
protocolClient: ILockProtocolClient;
|
||||
/** Whether the lock supports physical open */
|
||||
supportsOpen?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock Feature - control for smart locks
|
||||
*
|
||||
* Protocol-agnostic: works with Home Assistant, MQTT, August, Yale, etc.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const lock = device.getFeature<LockFeature>('lock');
|
||||
* if (lock) {
|
||||
* await lock.lock();
|
||||
* console.log(`Lock is ${lock.isLocked ? 'locked' : 'unlocked'}`);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class LockFeature extends Feature {
|
||||
public readonly type = 'lock' as const;
|
||||
public readonly protocol: TLockProtocol;
|
||||
|
||||
/** Entity ID (e.g., "lock.front_door") */
|
||||
public readonly entityId: string;
|
||||
|
||||
/** Capabilities */
|
||||
public readonly capabilities: ILockCapabilities;
|
||||
|
||||
/** Current state */
|
||||
protected _lockState: TLockState = 'unknown';
|
||||
protected _isLocked: boolean = false;
|
||||
|
||||
/** Protocol client for controlling the lock */
|
||||
private protocolClient: ILockProtocolClient;
|
||||
|
||||
constructor(
|
||||
device: TDeviceReference,
|
||||
port: number,
|
||||
options: ILockFeatureOptions
|
||||
) {
|
||||
super(device, port, options);
|
||||
this.protocol = options.protocol;
|
||||
this.entityId = options.entityId;
|
||||
this.protocolClient = options.protocolClient;
|
||||
|
||||
this.capabilities = {
|
||||
supportsOpen: options.supportsOpen ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Properties
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get current lock state (cached)
|
||||
*/
|
||||
public get lockState(): TLockState {
|
||||
return this._lockState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if locked (cached)
|
||||
*/
|
||||
public get isLocked(): boolean {
|
||||
return this._isLocked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if unlocked
|
||||
*/
|
||||
public get isUnlocked(): boolean {
|
||||
return this._lockState === 'unlocked';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if currently locking
|
||||
*/
|
||||
public get isLocking(): boolean {
|
||||
return this._lockState === 'locking';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if currently unlocking
|
||||
*/
|
||||
public get isUnlocking(): boolean {
|
||||
return this._lockState === 'unlocking';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if jammed
|
||||
*/
|
||||
public get isJammed(): boolean {
|
||||
return this._lockState === 'jammed';
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Lock Control
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Lock the lock
|
||||
*/
|
||||
public async lock(): Promise<void> {
|
||||
await this.protocolClient.lock(this.entityId);
|
||||
this._lockState = 'locking';
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlock the lock
|
||||
*/
|
||||
public async unlock(): Promise<void> {
|
||||
await this.protocolClient.unlock(this.entityId);
|
||||
this._lockState = 'unlocking';
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the lock (physically open the door if supported)
|
||||
*/
|
||||
public async open(): Promise<void> {
|
||||
if (!this.capabilities.supportsOpen) {
|
||||
throw new Error('Lock does not support physical open');
|
||||
}
|
||||
await this.protocolClient.open(this.entityId);
|
||||
this._lockState = 'unlocked';
|
||||
this._isLocked = false;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current state as object
|
||||
*/
|
||||
public getState(): ILockStateInfo {
|
||||
return {
|
||||
state: this._lockState,
|
||||
isLocked: this._isLocked,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh state from the device
|
||||
*/
|
||||
public async refreshState(): Promise<ILockStateInfo> {
|
||||
const state = await this.protocolClient.getState(this.entityId);
|
||||
this.updateStateInternal(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update state from external source
|
||||
*/
|
||||
public updateState(state: ILockStateInfo): void {
|
||||
this.updateStateInternal(state);
|
||||
this.emit('state:changed', state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal state update
|
||||
*/
|
||||
private updateStateInternal(state: ILockStateInfo): void {
|
||||
this._lockState = state.state;
|
||||
this._isLocked = state.isLocked;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Feature Info
|
||||
// ============================================================================
|
||||
|
||||
public getFeatureInfo(): ILockFeatureInfo {
|
||||
return {
|
||||
...this.getBaseFeatureInfo(),
|
||||
type: 'lock',
|
||||
protocol: this.protocol,
|
||||
capabilities: this.capabilities,
|
||||
currentState: this.getState(),
|
||||
};
|
||||
}
|
||||
}
|
||||
202
ts/features/feature.sensor.ts
Normal file
202
ts/features/feature.sensor.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Sensor Feature
|
||||
* Provides read-only state for sensors (temperature, humidity, power, etc.)
|
||||
*/
|
||||
|
||||
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
||||
import type { IFeatureOptions } from '../interfaces/feature.interfaces.js';
|
||||
import type {
|
||||
TSensorProtocol,
|
||||
TSensorDeviceClass,
|
||||
TSensorStateClass,
|
||||
ISensorCapabilities,
|
||||
ISensorState,
|
||||
ISensorFeatureInfo,
|
||||
ISensorProtocolClient,
|
||||
} from '../interfaces/smarthome.interfaces.js';
|
||||
|
||||
/**
|
||||
* Options for creating a SensorFeature
|
||||
*/
|
||||
export interface ISensorFeatureOptions extends IFeatureOptions {
|
||||
/** Protocol type */
|
||||
protocol: TSensorProtocol;
|
||||
/** Entity ID (for Home Assistant) */
|
||||
entityId: string;
|
||||
/** Protocol client for reading sensor state */
|
||||
protocolClient: ISensorProtocolClient;
|
||||
/** Device class (temperature, humidity, etc.) */
|
||||
deviceClass?: TSensorDeviceClass;
|
||||
/** State class (measurement, total, etc.) */
|
||||
stateClass?: TSensorStateClass;
|
||||
/** Unit of measurement */
|
||||
unit?: string;
|
||||
/** Precision (decimal places) */
|
||||
precision?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sensor Feature - read-only state values
|
||||
*
|
||||
* Protocol-agnostic: works with Home Assistant, MQTT, SNMP, etc.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const sensor = device.getFeature<SensorFeature>('sensor');
|
||||
* if (sensor) {
|
||||
* await sensor.refreshState();
|
||||
* console.log(`Temperature: ${sensor.value} ${sensor.unit}`);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class SensorFeature extends Feature {
|
||||
public readonly type = 'sensor' as const;
|
||||
public readonly protocol: TSensorProtocol;
|
||||
|
||||
/** Entity ID (e.g., "sensor.living_room_temperature") */
|
||||
public readonly entityId: string;
|
||||
|
||||
/** Capabilities */
|
||||
public readonly capabilities: ISensorCapabilities;
|
||||
|
||||
/** Current state */
|
||||
protected _value: string | number | boolean = '';
|
||||
protected _numericValue?: number;
|
||||
protected _unit?: string;
|
||||
protected _lastUpdated: Date = new Date();
|
||||
|
||||
/** Protocol client for reading sensor state */
|
||||
private protocolClient: ISensorProtocolClient;
|
||||
|
||||
constructor(
|
||||
device: TDeviceReference,
|
||||
port: number,
|
||||
options: ISensorFeatureOptions
|
||||
) {
|
||||
super(device, port, options);
|
||||
this.protocol = options.protocol;
|
||||
this.entityId = options.entityId;
|
||||
this.protocolClient = options.protocolClient;
|
||||
this.capabilities = {
|
||||
deviceClass: options.deviceClass,
|
||||
stateClass: options.stateClass,
|
||||
unit: options.unit,
|
||||
precision: options.precision,
|
||||
};
|
||||
this._unit = options.unit;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Properties
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get current value (cached)
|
||||
*/
|
||||
public get value(): string | number | boolean {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get numeric value if available (cached)
|
||||
*/
|
||||
public get numericValue(): number | undefined {
|
||||
return this._numericValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unit of measurement
|
||||
*/
|
||||
public get unit(): string | undefined {
|
||||
return this._unit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get device class
|
||||
*/
|
||||
public get deviceClass(): TSensorDeviceClass | undefined {
|
||||
return this.capabilities.deviceClass;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get state class
|
||||
*/
|
||||
public get stateClass(): TSensorStateClass | undefined {
|
||||
return this.capabilities.stateClass;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last updated timestamp
|
||||
*/
|
||||
public get lastUpdated(): Date {
|
||||
return this._lastUpdated;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Connection
|
||||
// ============================================================================
|
||||
|
||||
protected async doConnect(): Promise<void> {
|
||||
// Fetch initial state
|
||||
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
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Sensor Reading
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Refresh state from the device
|
||||
*/
|
||||
public async refreshState(): Promise<ISensorState> {
|
||||
const state = await this.protocolClient.getState(this.entityId);
|
||||
this.updateStateInternal(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update state from external source (e.g., state change event)
|
||||
*/
|
||||
public updateState(state: ISensorState): void {
|
||||
this.updateStateInternal(state);
|
||||
this.emit('state:changed', state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal state update
|
||||
*/
|
||||
private updateStateInternal(state: ISensorState): void {
|
||||
this._value = state.value;
|
||||
this._numericValue = state.numericValue;
|
||||
this._unit = state.unit || this._unit;
|
||||
this._lastUpdated = state.lastUpdated;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Feature Info
|
||||
// ============================================================================
|
||||
|
||||
public getFeatureInfo(): ISensorFeatureInfo {
|
||||
return {
|
||||
...this.getBaseFeatureInfo(),
|
||||
type: 'sensor',
|
||||
protocol: this.protocol,
|
||||
capabilities: this.capabilities,
|
||||
currentState: {
|
||||
value: this._value,
|
||||
numericValue: this._numericValue,
|
||||
unit: this._unit,
|
||||
lastUpdated: this._lastUpdated,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
170
ts/features/feature.switch.ts
Normal file
170
ts/features/feature.switch.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Switch Feature
|
||||
* Provides binary on/off control for smart switches, outlets, etc.
|
||||
*/
|
||||
|
||||
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
||||
import type { IFeatureOptions } from '../interfaces/feature.interfaces.js';
|
||||
import type {
|
||||
TSwitchProtocol,
|
||||
ISwitchCapabilities,
|
||||
ISwitchState,
|
||||
ISwitchFeatureInfo,
|
||||
ISwitchProtocolClient,
|
||||
} from '../interfaces/smarthome.interfaces.js';
|
||||
|
||||
/**
|
||||
* Options for creating a SwitchFeature
|
||||
*/
|
||||
export interface ISwitchFeatureOptions extends IFeatureOptions {
|
||||
/** Protocol type */
|
||||
protocol: TSwitchProtocol;
|
||||
/** Entity ID (for Home Assistant) */
|
||||
entityId: string;
|
||||
/** Protocol client for controlling the switch */
|
||||
protocolClient: ISwitchProtocolClient;
|
||||
/** Device class */
|
||||
deviceClass?: 'outlet' | 'switch';
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch Feature - binary on/off control
|
||||
*
|
||||
* Protocol-agnostic: works with Home Assistant, MQTT, Tasmota, Tuya, etc.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const sw = device.getFeature<SwitchFeature>('switch');
|
||||
* if (sw) {
|
||||
* await sw.turnOn();
|
||||
* await sw.toggle();
|
||||
* console.log(`Switch is ${sw.isOn ? 'on' : 'off'}`);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class SwitchFeature extends Feature {
|
||||
public readonly type = 'switch' as const;
|
||||
public readonly protocol: TSwitchProtocol;
|
||||
|
||||
/** Entity ID (e.g., "switch.living_room") */
|
||||
public readonly entityId: string;
|
||||
|
||||
/** Capabilities */
|
||||
public readonly capabilities: ISwitchCapabilities;
|
||||
|
||||
/** Current state */
|
||||
protected _isOn: boolean = false;
|
||||
|
||||
/** Protocol client for controlling the switch */
|
||||
private protocolClient: ISwitchProtocolClient;
|
||||
|
||||
constructor(
|
||||
device: TDeviceReference,
|
||||
port: number,
|
||||
options: ISwitchFeatureOptions
|
||||
) {
|
||||
super(device, port, options);
|
||||
this.protocol = options.protocol;
|
||||
this.entityId = options.entityId;
|
||||
this.protocolClient = options.protocolClient;
|
||||
this.capabilities = {
|
||||
deviceClass: options.deviceClass,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Properties
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get current on/off state (cached)
|
||||
*/
|
||||
public get isOn(): boolean {
|
||||
return this._isOn;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Connection
|
||||
// ============================================================================
|
||||
|
||||
protected async doConnect(): Promise<void> {
|
||||
// Fetch initial state
|
||||
try {
|
||||
const state = await this.protocolClient.getState(this.entityId);
|
||||
this._isOn = state.isOn;
|
||||
} catch {
|
||||
// Ignore errors fetching initial state
|
||||
}
|
||||
}
|
||||
|
||||
protected async doDisconnect(): Promise<void> {
|
||||
// Nothing to disconnect
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Switch Control
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Turn on the switch
|
||||
*/
|
||||
public async turnOn(): Promise<void> {
|
||||
await this.protocolClient.turnOn(this.entityId);
|
||||
this._isOn = true;
|
||||
this.emit('state:changed', { isOn: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn off the switch
|
||||
*/
|
||||
public async turnOff(): Promise<void> {
|
||||
await this.protocolClient.turnOff(this.entityId);
|
||||
this._isOn = false;
|
||||
this.emit('state:changed', { isOn: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the switch
|
||||
*/
|
||||
public async toggle(): Promise<void> {
|
||||
await this.protocolClient.toggle(this.entityId);
|
||||
this._isOn = !this._isOn;
|
||||
this.emit('state:changed', { isOn: this._isOn });
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh state from the device
|
||||
*/
|
||||
public async refreshState(): Promise<ISwitchState> {
|
||||
const state = await this.protocolClient.getState(this.entityId);
|
||||
this._isOn = state.isOn;
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update state from external source (e.g., state change event)
|
||||
*/
|
||||
public updateState(state: ISwitchState): void {
|
||||
const changed = this._isOn !== state.isOn;
|
||||
this._isOn = state.isOn;
|
||||
if (changed) {
|
||||
this.emit('state:changed', state);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Feature Info
|
||||
// ============================================================================
|
||||
|
||||
public getFeatureInfo(): ISwitchFeatureInfo {
|
||||
return {
|
||||
...this.getBaseFeatureInfo(),
|
||||
type: 'switch',
|
||||
protocol: this.protocol,
|
||||
capabilities: this.capabilities,
|
||||
currentState: {
|
||||
isOn: this._isOn,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,20 @@
|
||||
// Abstract base
|
||||
export { Feature, type TDeviceReference } from './feature.abstract.js';
|
||||
|
||||
// Concrete features
|
||||
// Concrete features - Document/Infrastructure
|
||||
export { ScanFeature, type IScanFeatureOptions } from './feature.scan.js';
|
||||
export { PrintFeature, type IPrintFeatureOptions } from './feature.print.js';
|
||||
export { PlaybackFeature, type IPlaybackFeatureOptions } from './feature.playback.js';
|
||||
export { VolumeFeature, type IVolumeFeatureOptions, type IVolumeController } from './feature.volume.js';
|
||||
export { PowerFeature, type IPowerFeatureOptions } from './feature.power.js';
|
||||
export { SnmpFeature, type ISnmpFeatureOptions } from './feature.snmp.js';
|
||||
|
||||
// Smart Home Features (protocol-agnostic: home-assistant, hue, mqtt, etc.)
|
||||
export { SwitchFeature, type ISwitchFeatureOptions } from './feature.switch.js';
|
||||
export { SensorFeature, type ISensorFeatureOptions } from './feature.sensor.js';
|
||||
export { LightFeature, type ILightFeatureOptions } from './feature.light.js';
|
||||
export { CoverFeature, type ICoverFeatureOptions } from './feature.cover.js';
|
||||
export { LockFeature, type ILockFeatureOptions } from './feature.lock.js';
|
||||
export { FanFeature, type IFanFeatureOptions } from './feature.fan.js';
|
||||
export { ClimateFeature, type IClimateFeatureOptions } from './feature.climate.js';
|
||||
export { CameraFeature, type ICameraFeatureOptions } from './feature.camera.js';
|
||||
|
||||
Reference in New Issue
Block a user