297 lines
7.8 KiB
TypeScript
297 lines
7.8 KiB
TypeScript
|
|
/**
|
||
|
|
* 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(),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|