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