feat(smarthome): add smart home features and Home Assistant integration (WebSocket protocol, discovery, factories, interfaces)

This commit is contained in:
2026-01-09 16:20:54 +00:00
parent 7bcec69658
commit 38a6e5c250
23 changed files with 4786 additions and 5 deletions

View File

@@ -1,5 +1,14 @@
# Changelog
## 2026-01-09 - 2.2.0 - feat(smarthome)
add smart home features and Home Assistant integration (WebSocket protocol, discovery, factories, interfaces)
- Add concrete smart home feature implementations: light, climate, sensor, switch, cover, lock, fan, camera.
- Introduce Home Assistant WebSocket protocol handler (protocol.homeassistant) and Home Assistant discovery via mDNS (discovery.classes.homeassistant).
- Add generic smart home interfaces and Home Assistant-specific interfaces (smarthome.interfaces, homeassistant.interfaces) and export them.
- Add smart home factories to create devices for discovered/declared smart home entities and export factory helpers.
- Update plugins to include WebSocket (ws) and add ws dependency and @types/ws in package.json.
## 2026-01-09 - 2.1.0 - feat(devicemanager)
prefer higher-priority discovery source when resolving device names and track per-device name source

View File

@@ -17,7 +17,8 @@
"@git.zone/tsbuild": "^4.1.0",
"@git.zone/tsrun": "^2.0.0",
"@git.zone/tstest": "^3.1.3",
"@types/node": "^25.0.3"
"@types/node": "^25.0.3",
"@types/ws": "^8.18.1"
},
"dependencies": {
"@push.rocks/smartdelay": "^3.0.5",
@@ -32,6 +33,7 @@
"ipp": "^2.0.1",
"net-snmp": "^3.26.0",
"node-ssdp": "^4.0.1",
"sonos": "^1.14.2"
"sonos": "^1.14.2",
"ws": "^8.19.0"
}
}

6
pnpm-lock.yaml generated
View File

@@ -47,6 +47,9 @@ importers:
sonos:
specifier: ^1.14.2
version: 1.14.2
ws:
specifier: ^8.19.0
version: 8.19.0
devDependencies:
'@git.zone/tsbuild':
specifier: ^4.1.0
@@ -60,6 +63,9 @@ importers:
'@types/node':
specifier: ^25.0.3
version: 25.0.3
'@types/ws':
specifier: ^8.18.1
version: 8.18.1
packages:

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@ecobridge.xyz/devicemanager',
version: '2.1.0',
version: '2.2.0',
description: 'a device manager for talking to devices on network and over usb'
}

View File

@@ -0,0 +1,376 @@
import * as plugins from '../plugins.js';
import type {
IHomeAssistantInstanceConfig,
IHomeAssistantEntity,
IHomeAssistantDiscoveredInstance,
THomeAssistantDomain,
THomeAssistantDiscoveryEvents,
} from '../interfaces/homeassistant.interfaces.js';
import { HomeAssistantProtocol } from '../protocols/protocol.homeassistant.js';
/**
* mDNS service type for Home Assistant discovery
*/
const HA_SERVICE_TYPE = '_home-assistant._tcp';
/**
* Default domains to discover
*/
const DEFAULT_DOMAINS: THomeAssistantDomain[] = [
'light',
'switch',
'sensor',
'binary_sensor',
'climate',
'fan',
'cover',
'lock',
'camera',
'media_player',
];
/**
* Home Assistant Discovery
* Discovers HA instances via mDNS and/or manual configuration,
* connects to them, and enumerates all entities
*/
export class HomeAssistantDiscovery extends plugins.events.EventEmitter {
private bonjour: plugins.bonjourService.Bonjour | null = null;
private browser: plugins.bonjourService.Browser | null = null;
private discoveredInstances: Map<string, IHomeAssistantDiscoveredInstance> = new Map();
private connectedProtocols: Map<string, HomeAssistantProtocol> = new Map();
private entityCache: Map<string, IHomeAssistantEntity> = new Map();
private enabledDomains: THomeAssistantDomain[];
private isRunning: boolean = false;
constructor(options?: { enabledDomains?: THomeAssistantDomain[] }) {
super();
this.enabledDomains = options?.enabledDomains || DEFAULT_DOMAINS;
}
/**
* Check if discovery is running
*/
public get running(): boolean {
return this.isRunning;
}
/**
* Get all discovered HA instances
*/
public getInstances(): IHomeAssistantDiscoveredInstance[] {
return Array.from(this.discoveredInstances.values());
}
/**
* Get connected protocol for an instance
*/
public getProtocol(instanceId: string): HomeAssistantProtocol | undefined {
return this.connectedProtocols.get(instanceId);
}
/**
* Get all connected protocols
*/
public getProtocols(): Map<string, HomeAssistantProtocol> {
return this.connectedProtocols;
}
/**
* Get all cached entities
*/
public getEntities(): IHomeAssistantEntity[] {
return Array.from(this.entityCache.values());
}
/**
* Get entities by domain
*/
public getEntitiesByDomain(domain: THomeAssistantDomain): IHomeAssistantEntity[] {
return this.getEntities().filter((e) => e.entity_id.startsWith(`${domain}.`));
}
/**
* Get entities for a specific instance
*/
public getEntitiesForInstance(instanceId: string): IHomeAssistantEntity[] {
const protocol = this.connectedProtocols.get(instanceId);
if (!protocol) return [];
return Array.from(protocol.entities.values());
}
/**
* Start mDNS discovery for Home Assistant instances
*/
public async startMdnsDiscovery(): Promise<void> {
if (this.isRunning) {
return;
}
this.bonjour = new plugins.bonjourService.Bonjour();
this.isRunning = true;
this.browser = this.bonjour.find({ type: HA_SERVICE_TYPE }, (service) => {
this.handleInstanceFound(service);
});
this.browser.on('down', (service) => {
this.handleInstanceLost(service);
});
}
/**
* Stop mDNS discovery
*/
public async stopMdnsDiscovery(): Promise<void> {
if (!this.isRunning) {
return;
}
if (this.browser) {
this.browser.stop();
this.browser = null;
}
if (this.bonjour) {
this.bonjour.destroy();
this.bonjour = null;
}
this.isRunning = false;
}
/**
* Add a manually configured HA instance
*/
public async addInstance(config: IHomeAssistantInstanceConfig): Promise<HomeAssistantProtocol> {
const instanceId = this.generateInstanceId(config.host, config.port || 8123);
// Check if already connected
if (this.connectedProtocols.has(instanceId)) {
return this.connectedProtocols.get(instanceId)!;
}
// Create protocol and connect
const protocol = new HomeAssistantProtocol(config);
// Set up event handlers
this.setupProtocolHandlers(protocol, instanceId);
// Connect
await protocol.connect();
// Subscribe to state changes
await protocol.subscribeToStateChanges();
// Cache entities
const entities = await protocol.getStates();
for (const entity of entities) {
if (this.isEnabledDomain(entity.entity_id)) {
const cacheKey = `${instanceId}:${entity.entity_id}`;
this.entityCache.set(cacheKey, entity);
this.emit('entity:found', entity);
}
}
// Store protocol
this.connectedProtocols.set(instanceId, protocol);
// Also store as discovered instance
this.discoveredInstances.set(instanceId, {
id: instanceId,
host: config.host,
port: config.port || 8123,
base_url: `http://${config.host}:${config.port || 8123}`,
txtRecords: {},
requires_api_password: true,
friendlyName: config.friendlyName,
});
return protocol;
}
/**
* Remove an HA instance
*/
public async removeInstance(instanceId: string): Promise<void> {
const protocol = this.connectedProtocols.get(instanceId);
if (protocol) {
await protocol.disconnect();
this.connectedProtocols.delete(instanceId);
}
this.discoveredInstances.delete(instanceId);
// Remove cached entities for this instance
for (const key of this.entityCache.keys()) {
if (key.startsWith(`${instanceId}:`)) {
this.entityCache.delete(key);
}
}
this.emit('instance:lost', instanceId);
}
/**
* Stop all and cleanup
*/
public async stop(): Promise<void> {
await this.stopMdnsDiscovery();
// Disconnect all protocols
for (const [instanceId, protocol] of this.connectedProtocols) {
await protocol.disconnect();
}
this.connectedProtocols.clear();
this.discoveredInstances.clear();
this.entityCache.clear();
}
/**
* Handle mDNS service found
*/
private handleInstanceFound(service: plugins.bonjourService.Service): void {
const addresses = service.addresses ?? [];
const address = addresses.find((a) => !a.includes(':')) ?? addresses[0] ?? service.host;
if (!address) {
return;
}
const instanceId = this.generateInstanceId(address, service.port);
const txtRecords = this.parseTxtRecords(service.txt);
const instance: IHomeAssistantDiscoveredInstance = {
id: instanceId,
host: address,
port: service.port,
base_url: txtRecords['base_url'] || `http://${address}:${service.port}`,
txtRecords,
requires_api_password: txtRecords['requires_api_password'] === 'true',
friendlyName: service.name,
};
// Check if this is a new instance
const existing = this.discoveredInstances.get(instanceId);
if (!existing) {
this.discoveredInstances.set(instanceId, instance);
this.emit('instance:found', instance);
}
}
/**
* Handle mDNS service lost
*/
private handleInstanceLost(service: plugins.bonjourService.Service): void {
const addresses = service.addresses ?? [];
const address = addresses.find((a) => !a.includes(':')) ?? addresses[0] ?? service.host;
if (!address) {
return;
}
const instanceId = this.generateInstanceId(address, service.port);
if (this.discoveredInstances.has(instanceId)) {
// Don't remove if we have an active connection (manually added)
if (!this.connectedProtocols.has(instanceId)) {
this.discoveredInstances.delete(instanceId);
}
this.emit('instance:lost', instanceId);
}
}
/**
* Set up event handlers for a protocol
*/
private setupProtocolHandlers(protocol: HomeAssistantProtocol, instanceId: string): void {
protocol.on('state:changed', (event) => {
const cacheKey = `${instanceId}:${event.entity_id}`;
if (event.new_state) {
if (this.isEnabledDomain(event.entity_id)) {
const existing = this.entityCache.has(cacheKey);
this.entityCache.set(cacheKey, event.new_state);
if (existing) {
this.emit('entity:updated', event.new_state);
} else {
this.emit('entity:found', event.new_state);
}
}
} else {
// Entity removed
if (this.entityCache.has(cacheKey)) {
this.entityCache.delete(cacheKey);
this.emit('entity:removed', event.entity_id);
}
}
});
protocol.on('disconnected', () => {
// Clear cached entities for this instance on disconnect
for (const key of this.entityCache.keys()) {
if (key.startsWith(`${instanceId}:`)) {
this.entityCache.delete(key);
}
}
});
protocol.on('error', (error) => {
this.emit('error', error);
});
}
/**
* Check if entity domain is enabled
*/
private isEnabledDomain(entityId: string): boolean {
const domain = entityId.split('.')[0] as THomeAssistantDomain;
return this.enabledDomains.includes(domain);
}
/**
* Generate unique instance ID
*/
private generateInstanceId(host: string, port: number): string {
return `ha:${host}:${port}`;
}
/**
* Parse TXT records from mDNS service
*/
private parseTxtRecords(txt: Record<string, unknown> | undefined): Record<string, string> {
const records: Record<string, string> = {};
if (!txt) {
return records;
}
for (const [key, value] of Object.entries(txt)) {
if (typeof value === 'string') {
records[key] = value;
} else if (Buffer.isBuffer(value)) {
records[key] = value.toString('utf-8');
} else if (value !== undefined && value !== null) {
records[key] = String(value);
}
}
return records;
}
/**
* Probe if a host has Home Assistant running
*/
public static async probe(
host: string,
port: number = 8123,
secure: boolean = false,
timeout: number = 5000
): Promise<boolean> {
return HomeAssistantProtocol.probe(host, port, secure, timeout);
}
}
export { HA_SERVICE_TYPE };

View File

@@ -353,6 +353,324 @@ function parseScanSources(txtRecords: Record<string, string>): TScanSource[] {
return sources.length > 0 ? sources : ['flatbed'];
}
// ============================================================================
// Smart Home Factories
// ============================================================================
import { SwitchFeature, type ISwitchFeatureOptions } from '../features/feature.switch.js';
import { SensorFeature, type ISensorFeatureOptions } from '../features/feature.sensor.js';
import { LightFeature, type ILightFeatureOptions } from '../features/feature.light.js';
import { CoverFeature, type ICoverFeatureOptions } from '../features/feature.cover.js';
import { LockFeature, type ILockFeatureOptions } from '../features/feature.lock.js';
import { FanFeature, type IFanFeatureOptions } from '../features/feature.fan.js';
import { ClimateFeature, type IClimateFeatureOptions } from '../features/feature.climate.js';
import { CameraFeature, type ICameraFeatureOptions } from '../features/feature.camera.js';
import type {
TSwitchProtocol,
TSensorProtocol,
TLightProtocol,
TCoverProtocol,
TLockProtocol,
TFanProtocol,
TClimateProtocol,
TCameraProtocol,
ISwitchProtocolClient,
ISensorProtocolClient,
ILightProtocolClient,
ICoverProtocolClient,
ILockProtocolClient,
IFanProtocolClient,
IClimateProtocolClient,
ICameraProtocolClient,
ILightCapabilities,
ICoverCapabilities,
IFanCapabilities,
IClimateCapabilities,
ICameraCapabilities,
TSensorDeviceClass,
TSensorStateClass,
TCoverDeviceClass,
} from '../interfaces/smarthome.interfaces.js';
// Smart Switch Factory
export interface ISmartSwitchDiscoveryInfo {
id: string;
name: string;
address: string;
port: number;
entityId: string;
protocol: TSwitchProtocol;
protocolClient: ISwitchProtocolClient;
deviceClass?: 'outlet' | 'switch';
}
export function createSmartSwitch(
info: ISmartSwitchDiscoveryInfo,
retryOptions?: IRetryOptions
): UniversalDevice {
const device = new UniversalDevice(info.address, info.port, {
name: info.name,
retryOptions,
});
(device as { id: string }).id = info.id;
const switchFeature = new SwitchFeature(device.getDeviceReference(), info.port, {
protocol: info.protocol,
entityId: info.entityId,
protocolClient: info.protocolClient,
deviceClass: info.deviceClass,
});
device.addFeature(switchFeature);
return device;
}
// Smart Sensor Factory
export interface ISmartSensorDiscoveryInfo {
id: string;
name: string;
address: string;
port: number;
entityId: string;
protocol: TSensorProtocol;
protocolClient: ISensorProtocolClient;
deviceClass?: TSensorDeviceClass;
stateClass?: TSensorStateClass;
unit?: string;
}
export function createSmartSensor(
info: ISmartSensorDiscoveryInfo,
retryOptions?: IRetryOptions
): UniversalDevice {
const device = new UniversalDevice(info.address, info.port, {
name: info.name,
retryOptions,
});
(device as { id: string }).id = info.id;
const sensorFeature = new SensorFeature(device.getDeviceReference(), info.port, {
protocol: info.protocol,
entityId: info.entityId,
protocolClient: info.protocolClient,
deviceClass: info.deviceClass,
stateClass: info.stateClass,
unit: info.unit,
});
device.addFeature(sensorFeature);
return device;
}
// Smart Light Factory
export interface ISmartLightDiscoveryInfo {
id: string;
name: string;
address: string;
port: number;
entityId: string;
protocol: TLightProtocol;
protocolClient: ILightProtocolClient;
capabilities?: Partial<ILightCapabilities>;
}
export function createSmartLight(
info: ISmartLightDiscoveryInfo,
retryOptions?: IRetryOptions
): UniversalDevice {
const device = new UniversalDevice(info.address, info.port, {
name: info.name,
retryOptions,
});
(device as { id: string }).id = info.id;
const lightFeature = new LightFeature(device.getDeviceReference(), info.port, {
protocol: info.protocol,
entityId: info.entityId,
protocolClient: info.protocolClient,
capabilities: info.capabilities,
});
device.addFeature(lightFeature);
return device;
}
// Smart Cover Factory
export interface ISmartCoverDiscoveryInfo {
id: string;
name: string;
address: string;
port: number;
entityId: string;
protocol: TCoverProtocol;
protocolClient: ICoverProtocolClient;
deviceClass?: TCoverDeviceClass;
capabilities?: Partial<ICoverCapabilities>;
}
export function createSmartCover(
info: ISmartCoverDiscoveryInfo,
retryOptions?: IRetryOptions
): UniversalDevice {
const device = new UniversalDevice(info.address, info.port, {
name: info.name,
retryOptions,
});
(device as { id: string }).id = info.id;
const coverFeature = new CoverFeature(device.getDeviceReference(), info.port, {
protocol: info.protocol,
entityId: info.entityId,
protocolClient: info.protocolClient,
deviceClass: info.deviceClass,
capabilities: info.capabilities,
});
device.addFeature(coverFeature);
return device;
}
// Smart Lock Factory
export interface ISmartLockDiscoveryInfo {
id: string;
name: string;
address: string;
port: number;
entityId: string;
protocol: TLockProtocol;
protocolClient: ILockProtocolClient;
supportsOpen?: boolean;
}
export function createSmartLock(
info: ISmartLockDiscoveryInfo,
retryOptions?: IRetryOptions
): UniversalDevice {
const device = new UniversalDevice(info.address, info.port, {
name: info.name,
retryOptions,
});
(device as { id: string }).id = info.id;
const lockFeature = new LockFeature(device.getDeviceReference(), info.port, {
protocol: info.protocol,
entityId: info.entityId,
protocolClient: info.protocolClient,
supportsOpen: info.supportsOpen,
});
device.addFeature(lockFeature);
return device;
}
// Smart Fan Factory
export interface ISmartFanDiscoveryInfo {
id: string;
name: string;
address: string;
port: number;
entityId: string;
protocol: TFanProtocol;
protocolClient: IFanProtocolClient;
capabilities?: Partial<IFanCapabilities>;
}
export function createSmartFan(
info: ISmartFanDiscoveryInfo,
retryOptions?: IRetryOptions
): UniversalDevice {
const device = new UniversalDevice(info.address, info.port, {
name: info.name,
retryOptions,
});
(device as { id: string }).id = info.id;
const fanFeature = new FanFeature(device.getDeviceReference(), info.port, {
protocol: info.protocol,
entityId: info.entityId,
protocolClient: info.protocolClient,
capabilities: info.capabilities,
});
device.addFeature(fanFeature);
return device;
}
// Smart Climate Factory
export interface ISmartClimateDiscoveryInfo {
id: string;
name: string;
address: string;
port: number;
entityId: string;
protocol: TClimateProtocol;
protocolClient: IClimateProtocolClient;
capabilities?: Partial<IClimateCapabilities>;
}
export function createSmartClimate(
info: ISmartClimateDiscoveryInfo,
retryOptions?: IRetryOptions
): UniversalDevice {
const device = new UniversalDevice(info.address, info.port, {
name: info.name,
retryOptions,
});
(device as { id: string }).id = info.id;
const climateFeature = new ClimateFeature(device.getDeviceReference(), info.port, {
protocol: info.protocol,
entityId: info.entityId,
protocolClient: info.protocolClient,
capabilities: info.capabilities,
});
device.addFeature(climateFeature);
return device;
}
// Smart Camera Factory
export interface ISmartCameraDiscoveryInfo {
id: string;
name: string;
address: string;
port: number;
entityId: string;
protocol: TCameraProtocol;
protocolClient: ICameraProtocolClient;
capabilities?: Partial<ICameraCapabilities>;
}
export function createSmartCamera(
info: ISmartCameraDiscoveryInfo,
retryOptions?: IRetryOptions
): UniversalDevice {
const device = new UniversalDevice(info.address, info.port, {
name: info.name,
retryOptions,
});
(device as { id: string }).id = info.id;
const cameraFeature = new CameraFeature(device.getDeviceReference(), info.port, {
protocol: info.protocol,
entityId: info.entityId,
protocolClient: info.protocolClient,
capabilities: info.capabilities,
});
device.addFeature(cameraFeature);
return device;
}
// ============================================================================
// Exports
// ============================================================================
@@ -366,4 +684,13 @@ export {
VolumeFeature,
PowerFeature,
SnmpFeature,
// Smart home features
SwitchFeature,
SensorFeature,
LightFeature,
CoverFeature,
LockFeature,
FanFeature,
ClimateFeature,
CameraFeature,
};

View 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(),
};
}
}

View 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(),
};
}
}

View 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
View 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(),
};
}
}

View 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
View 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(),
};
}
}

View 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,
},
};
}
}

View 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,
},
};
}
}

View File

@@ -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';

View File

@@ -30,6 +30,15 @@ export {
VolumeFeature,
PowerFeature,
SnmpFeature,
// Smart home features
SwitchFeature,
SensorFeature,
LightFeature,
CoverFeature,
LockFeature,
FanFeature,
ClimateFeature,
CameraFeature,
type TDeviceReference,
type IScanFeatureOptions,
type IPrintFeatureOptions,
@@ -38,6 +47,14 @@ export {
type IVolumeController,
type IPowerFeatureOptions,
type ISnmpFeatureOptions,
type ISwitchFeatureOptions,
type ISensorFeatureOptions,
type ILightFeatureOptions,
type ICoverFeatureOptions,
type ILockFeatureOptions,
type IFanFeatureOptions,
type IClimateFeatureOptions,
type ICameraFeatureOptions,
} from './features/index.js';
// ============================================================================
@@ -51,12 +68,29 @@ export {
createUpsDevice,
createSpeaker,
createDlnaRenderer,
// Smart home factories
createSmartSwitch,
createSmartSensor,
createSmartLight,
createSmartCover,
createSmartLock,
createSmartFan,
createSmartClimate,
createSmartCamera,
type IScannerDiscoveryInfo,
type IPrinterDiscoveryInfo,
type ISnmpDiscoveryInfo,
type IUpsDiscoveryInfo,
type ISpeakerDiscoveryInfo,
type IDlnaRendererDiscoveryInfo,
type ISmartSwitchDiscoveryInfo,
type ISmartSensorDiscoveryInfo,
type ISmartLightDiscoveryInfo,
type ISmartCoverDiscoveryInfo,
type ISmartLockDiscoveryInfo,
type ISmartFanDiscoveryInfo,
type ISmartClimateDiscoveryInfo,
type ISmartCameraDiscoveryInfo,
} from './factories/index.js';
// ============================================================================
@@ -77,6 +111,8 @@ export {
UPNP_DEVICE_TYPES,
UpsSnmpHandler,
UPS_SNMP_OIDS,
// Home Assistant protocol
HomeAssistantProtocol,
type ISnmpOptions,
type ISnmpVarbind,
type TSnmpValueType,
@@ -96,6 +132,9 @@ export {
type IUpsSnmpStatus,
} from './protocols/index.js';
// Home Assistant Discovery
export { HomeAssistantDiscovery, HA_SERVICE_TYPE } from './discovery/discovery.classes.homeassistant.js';
// ============================================================================
// Helpers
// ============================================================================

View File

@@ -13,16 +13,29 @@ import type { IRetryOptions } from './index.js';
* All supported feature types
*/
export type TFeatureType =
// Document handling
| 'scan' // Can scan documents (eSCL, SANE)
| 'print' // Can print documents (IPP, JetDirect)
| 'fax' // Can send/receive fax
| 'copy' // Can copy (scan + print combined)
// Media playback
| 'playback' // Can play media (audio/video)
| 'volume' // Has volume control
// Infrastructure
| 'power' // Has power status (UPS, smart plug)
| 'snmp' // SNMP queryable
// DLNA
| 'dlna-render' // DLNA renderer
| 'dlna-serve' // DLNA server (content provider)
// Smart home (protocol-agnostic: home-assistant, hue, mqtt, etc.)
| 'light' // Brightness, color, effects
| 'climate' // Temperature, HVAC modes
| 'sensor' // Read-only state values
| 'camera' // Snapshots, streams
| 'cover' // Blinds, garage doors
| 'switch' // Binary on/off
| 'lock' // Lock/unlock
| 'fan' // Speed, oscillation
;
/**

View File

@@ -0,0 +1,666 @@
/**
* Home Assistant Specific Interfaces
* Types for Home Assistant WebSocket API, entities, and configuration
*/
// ============================================================================
// Configuration
// ============================================================================
/**
* Configuration for connecting to a Home Assistant instance
*/
export interface IHomeAssistantInstanceConfig {
/** Home Assistant host (IP or hostname) */
host: string;
/** Port number (default: 8123) */
port?: number;
/** Long-lived access token from HA */
token: string;
/** Use secure WebSocket (wss://) */
secure?: boolean;
/** Friendly name for this instance */
friendlyName?: string;
/** Auto-reconnect on disconnect (default: true) */
autoReconnect?: boolean;
/** Reconnect delay in ms (default: 5000) */
reconnectDelay?: number;
}
/**
* Home Assistant configuration in DeviceManager options
*/
export interface IHomeAssistantOptions {
/** Enable mDNS auto-discovery of HA instances */
autoDiscovery?: boolean;
/** Manually configured HA instances */
instances?: IHomeAssistantInstanceConfig[];
/** Filter: only discover these domains (default: all) */
enabledDomains?: THomeAssistantDomain[];
/** Auto-reconnect on disconnect (default: true) */
autoReconnect?: boolean;
/** Reconnect delay in ms (default: 5000) */
reconnectDelay?: number;
}
// ============================================================================
// Entity Types
// ============================================================================
/**
* Supported Home Assistant domains that map to features
*/
export type THomeAssistantDomain =
| 'light'
| 'switch'
| 'sensor'
| 'binary_sensor'
| 'climate'
| 'fan'
| 'cover'
| 'lock'
| 'camera'
| 'media_player';
/**
* Home Assistant entity state
*/
export interface IHomeAssistantEntity {
/** Entity ID (e.g., "light.living_room") */
entity_id: string;
/** Current state value (e.g., "on", "off", "25.5") */
state: string;
/** Additional attributes */
attributes: IHomeAssistantEntityAttributes;
/** Last changed timestamp */
last_changed: string;
/** Last updated timestamp */
last_updated: string;
/** Context information */
context: IHomeAssistantContext;
}
/**
* Common entity attributes
*/
export interface IHomeAssistantEntityAttributes {
/** Friendly name */
friendly_name?: string;
/** Device class */
device_class?: string;
/** Unit of measurement */
unit_of_measurement?: string;
/** Icon */
icon?: string;
/** Entity category */
entity_category?: string;
/** Assumed state (for optimistic updates) */
assumed_state?: boolean;
/** Supported features bitmask */
supported_features?: number;
/** Additional dynamic attributes */
[key: string]: unknown;
}
/**
* Light-specific attributes
*/
export interface IHomeAssistantLightAttributes extends IHomeAssistantEntityAttributes {
brightness?: number; // 0-255
color_temp?: number; // Mireds
color_temp_kelvin?: number; // Kelvin
hs_color?: [number, number]; // [hue 0-360, saturation 0-100]
rgb_color?: [number, number, number];
xy_color?: [number, number];
rgbw_color?: [number, number, number, number];
rgbww_color?: [number, number, number, number, number];
effect?: string;
effect_list?: string[];
color_mode?: string;
supported_color_modes?: string[];
min_mireds?: number;
max_mireds?: number;
min_color_temp_kelvin?: number;
max_color_temp_kelvin?: number;
}
/**
* Climate-specific attributes
*/
export interface IHomeAssistantClimateAttributes extends IHomeAssistantEntityAttributes {
hvac_modes?: string[];
hvac_action?: string;
current_temperature?: number;
target_temp_high?: number;
target_temp_low?: number;
temperature?: number;
preset_mode?: string;
preset_modes?: string[];
fan_mode?: string;
fan_modes?: string[];
swing_mode?: string;
swing_modes?: string[];
aux_heat?: boolean;
current_humidity?: number;
humidity?: number;
min_temp?: number;
max_temp?: number;
target_temp_step?: number;
min_humidity?: number;
max_humidity?: number;
}
/**
* Sensor-specific attributes
*/
export interface IHomeAssistantSensorAttributes extends IHomeAssistantEntityAttributes {
state_class?: 'measurement' | 'total' | 'total_increasing';
native_unit_of_measurement?: string;
native_value?: string | number;
}
/**
* Cover-specific attributes
*/
export interface IHomeAssistantCoverAttributes extends IHomeAssistantEntityAttributes {
current_position?: number; // 0-100
current_tilt_position?: number; // 0-100
}
/**
* Fan-specific attributes
*/
export interface IHomeAssistantFanAttributes extends IHomeAssistantEntityAttributes {
percentage?: number; // 0-100
percentage_step?: number;
preset_mode?: string;
preset_modes?: string[];
oscillating?: boolean;
direction?: 'forward' | 'reverse';
}
/**
* Lock-specific attributes
*/
export interface IHomeAssistantLockAttributes extends IHomeAssistantEntityAttributes {
is_locked?: boolean;
is_locking?: boolean;
is_unlocking?: boolean;
is_jammed?: boolean;
}
/**
* Camera-specific attributes
*/
export interface IHomeAssistantCameraAttributes extends IHomeAssistantEntityAttributes {
access_token?: string;
entity_picture?: string;
frontend_stream_type?: 'hls' | 'web_rtc';
is_streaming?: boolean;
motion_detection?: boolean;
}
/**
* Media player-specific attributes
*/
export interface IHomeAssistantMediaPlayerAttributes extends IHomeAssistantEntityAttributes {
volume_level?: number; // 0-1
is_volume_muted?: boolean;
media_content_id?: string;
media_content_type?: string;
media_duration?: number;
media_position?: number;
media_position_updated_at?: string;
media_title?: string;
media_artist?: string;
media_album_name?: string;
media_album_artist?: string;
media_track?: number;
media_series_title?: string;
media_season?: number;
media_episode?: number;
app_id?: string;
app_name?: string;
source?: string;
source_list?: string[];
sound_mode?: string;
sound_mode_list?: string[];
shuffle?: boolean;
repeat?: 'off' | 'all' | 'one';
entity_picture_local?: string;
}
/**
* Context for entity state changes
*/
export interface IHomeAssistantContext {
id: string;
parent_id?: string;
user_id?: string;
}
// ============================================================================
// WebSocket Message Types
// ============================================================================
/**
* Base message structure
*/
export interface IHomeAssistantMessage {
id?: number;
type: string;
}
/**
* Authentication required message
*/
export interface IHomeAssistantAuthRequired extends IHomeAssistantMessage {
type: 'auth_required';
ha_version: string;
}
/**
* Authentication message to send
*/
export interface IHomeAssistantAuth extends IHomeAssistantMessage {
type: 'auth';
access_token: string;
}
/**
* Authentication success
*/
export interface IHomeAssistantAuthOk extends IHomeAssistantMessage {
type: 'auth_ok';
ha_version: string;
}
/**
* Authentication invalid
*/
export interface IHomeAssistantAuthInvalid extends IHomeAssistantMessage {
type: 'auth_invalid';
message: string;
}
/**
* Result message
*/
export interface IHomeAssistantResult extends IHomeAssistantMessage {
id: number;
type: 'result';
success: boolean;
result?: unknown;
error?: {
code: string;
message: string;
};
}
/**
* Event message
*/
export interface IHomeAssistantEvent extends IHomeAssistantMessage {
id: number;
type: 'event';
event: {
event_type: string;
data: unknown;
origin: string;
time_fired: string;
context: IHomeAssistantContext;
};
}
/**
* State changed event data
*/
export interface IHomeAssistantStateChangedEvent {
entity_id: string;
old_state: IHomeAssistantEntity | null;
new_state: IHomeAssistantEntity | null;
}
/**
* Subscribe events request
*/
export interface IHomeAssistantSubscribeEvents extends IHomeAssistantMessage {
id: number;
type: 'subscribe_events';
event_type?: string;
}
/**
* Get states request
*/
export interface IHomeAssistantGetStates extends IHomeAssistantMessage {
id: number;
type: 'get_states';
}
/**
* Call service request
*/
export interface IHomeAssistantCallService extends IHomeAssistantMessage {
id: number;
type: 'call_service';
domain: string;
service: string;
target?: {
entity_id?: string | string[];
device_id?: string | string[];
area_id?: string | string[];
};
service_data?: Record<string, unknown>;
}
/**
* Get services request
*/
export interface IHomeAssistantGetServices extends IHomeAssistantMessage {
id: number;
type: 'get_services';
}
/**
* Get config request
*/
export interface IHomeAssistantGetConfig extends IHomeAssistantMessage {
id: number;
type: 'get_config';
}
/**
* Home Assistant config response
*/
export interface IHomeAssistantConfig {
latitude: number;
longitude: number;
elevation: number;
unit_system: {
length: string;
mass: string;
pressure: string;
temperature: string;
volume: string;
};
location_name: string;
time_zone: string;
components: string[];
config_dir: string;
allowlist_external_dirs: string[];
allowlist_external_urls: string[];
version: string;
config_source: string;
safe_mode: boolean;
state: 'NOT_RUNNING' | 'STARTING' | 'RUNNING' | 'STOPPING' | 'FINAL_WRITE';
external_url: string | null;
internal_url: string | null;
currency: string;
country: string;
language: string;
}
// ============================================================================
// Service Definitions
// ============================================================================
/**
* Light service data
*/
export interface IHomeAssistantLightServiceData {
brightness?: number; // 0-255
brightness_pct?: number; // 0-100
brightness_step?: number; // Step to increase/decrease
brightness_step_pct?: number; // Step percentage
color_temp?: number; // Mireds
color_temp_kelvin?: number; // Kelvin
hs_color?: [number, number]; // [hue, saturation]
rgb_color?: [number, number, number];
xy_color?: [number, number];
rgbw_color?: [number, number, number, number];
rgbww_color?: [number, number, number, number, number];
color_name?: string;
kelvin?: number;
effect?: string;
transition?: number; // Seconds
flash?: 'short' | 'long';
profile?: string;
[key: string]: unknown; // Index signature for Record<string, unknown>
}
/**
* Climate service data
*/
export interface IHomeAssistantClimateServiceData {
hvac_mode?: string;
temperature?: number;
target_temp_high?: number;
target_temp_low?: number;
humidity?: number;
fan_mode?: string;
swing_mode?: string;
preset_mode?: string;
aux_heat?: boolean;
[key: string]: unknown; // Index signature for Record<string, unknown>
}
/**
* Cover service data
*/
export interface IHomeAssistantCoverServiceData {
position?: number; // 0-100
tilt_position?: number; // 0-100
}
/**
* Fan service data
*/
export interface IHomeAssistantFanServiceData {
percentage?: number; // 0-100
percentage_step?: number;
preset_mode?: string;
direction?: 'forward' | 'reverse';
oscillating?: boolean;
[key: string]: unknown; // Index signature for Record<string, unknown>
}
/**
* Media player service data
*/
export interface IHomeAssistantMediaPlayerServiceData {
volume_level?: number; // 0-1
is_volume_muted?: boolean;
media_content_id?: string;
media_content_type?: string;
enqueue?: 'play' | 'next' | 'add' | 'replace';
seek_position?: number;
source?: string;
sound_mode?: string;
shuffle?: boolean;
repeat?: 'off' | 'all' | 'one';
}
// ============================================================================
// Discovery Types
// ============================================================================
/**
* Discovered Home Assistant instance via mDNS
*/
export interface IHomeAssistantDiscoveredInstance {
/** Instance ID (derived from host) */
id: string;
/** Host address */
host: string;
/** Port number */
port: number;
/** Base URL */
base_url: string;
/** mDNS TXT records */
txtRecords: Record<string, string>;
/** Whether connection requires token */
requires_api_password: boolean;
/** Friendly name from mDNS */
friendlyName?: string;
}
// ============================================================================
// Protocol Events
// ============================================================================
/**
* Events emitted by HomeAssistantProtocol
*/
export type THomeAssistantProtocolEvents = {
'connected': () => void;
'disconnected': () => void;
'reconnecting': (attempt: number) => void;
'authenticated': (config: IHomeAssistantConfig) => void;
'auth:failed': (message: string) => void;
'state:changed': (event: IHomeAssistantStateChangedEvent) => void;
'states:loaded': (entities: IHomeAssistantEntity[]) => void;
'error': (error: Error) => void;
};
/**
* Events emitted by HomeAssistantDiscovery
*/
export type THomeAssistantDiscoveryEvents = {
'instance:found': (instance: IHomeAssistantDiscoveredInstance) => void;
'instance:lost': (instanceId: string) => void;
'entity:found': (entity: IHomeAssistantEntity) => void;
'entity:updated': (entity: IHomeAssistantEntity) => void;
'entity:removed': (entityId: string) => void;
'error': (error: Error) => void;
};
// ============================================================================
// Helper Types
// ============================================================================
/**
* Extract domain from entity_id
*/
export function getEntityDomain(entityId: string): THomeAssistantDomain | null {
const domain = entityId.split('.')[0];
const validDomains: THomeAssistantDomain[] = [
'light', 'switch', 'sensor', 'binary_sensor', 'climate',
'fan', 'cover', 'lock', 'camera', 'media_player'
];
return validDomains.includes(domain as THomeAssistantDomain)
? domain as THomeAssistantDomain
: null;
}
/**
* Map HA domain to feature type
*/
export function domainToFeatureType(domain: THomeAssistantDomain): string {
const mapping: Record<THomeAssistantDomain, string> = {
'light': 'light',
'switch': 'switch',
'sensor': 'sensor',
'binary_sensor': 'sensor',
'climate': 'climate',
'fan': 'fan',
'cover': 'cover',
'lock': 'lock',
'camera': 'camera',
'media_player': 'playback',
};
return mapping[domain];
}
/**
* Supported light color modes in HA
*/
export type THomeAssistantColorMode =
| 'unknown'
| 'onoff'
| 'brightness'
| 'color_temp'
| 'hs'
| 'xy'
| 'rgb'
| 'rgbw'
| 'rgbww'
| 'white';
/**
* Light supported features bitmask
*/
export const LIGHT_SUPPORT = {
EFFECT: 4,
FLASH: 8,
TRANSITION: 32,
} as const;
/**
* Climate supported features bitmask
*/
export const CLIMATE_SUPPORT = {
TARGET_TEMPERATURE: 1,
TARGET_TEMPERATURE_RANGE: 2,
TARGET_HUMIDITY: 4,
FAN_MODE: 8,
PRESET_MODE: 16,
SWING_MODE: 32,
AUX_HEAT: 64,
} as const;
/**
* Cover supported features bitmask
*/
export const COVER_SUPPORT = {
OPEN: 1,
CLOSE: 2,
SET_POSITION: 4,
STOP: 8,
OPEN_TILT: 16,
CLOSE_TILT: 32,
STOP_TILT: 64,
SET_TILT_POSITION: 128,
} as const;
/**
* Fan supported features bitmask
*/
export const FAN_SUPPORT = {
SET_SPEED: 1,
OSCILLATE: 2,
DIRECTION: 4,
PRESET_MODE: 8,
} as const;
/**
* Lock supported features bitmask
*/
export const LOCK_SUPPORT = {
OPEN: 1,
} as const;
/**
* Media player supported features bitmask
*/
export const MEDIA_PLAYER_SUPPORT = {
PAUSE: 1,
SEEK: 2,
VOLUME_SET: 4,
VOLUME_MUTE: 8,
PREVIOUS_TRACK: 16,
NEXT_TRACK: 32,
TURN_ON: 128,
TURN_OFF: 256,
PLAY_MEDIA: 512,
VOLUME_STEP: 1024,
SELECT_SOURCE: 2048,
STOP: 4096,
CLEAR_PLAYLIST: 8192,
PLAY: 16384,
SHUFFLE_SET: 32768,
SELECT_SOUND_MODE: 65536,
BROWSE_MEDIA: 131072,
REPEAT_SET: 262144,
GROUPING: 524288,
} as const;

View File

@@ -376,3 +376,15 @@ export type TNetworkScannerEvents = {
// ============================================================================
export * from './feature.interfaces.js';
// ============================================================================
// Smart Home Types (Generic, Protocol-agnostic)
// ============================================================================
export * from './smarthome.interfaces.js';
// ============================================================================
// Home Assistant Specific Types
// ============================================================================
export * from './homeassistant.interfaces.js';

View File

@@ -0,0 +1,421 @@
/**
* Smart Home Device Interfaces
* Generic types for smart home features (lights, climate, sensors, etc.)
* Protocol-agnostic - can be implemented by Home Assistant, Hue, MQTT, etc.
*/
import type { TFeatureState, IFeatureInfo } from './feature.interfaces.js';
// ============================================================================
// Light Feature Types
// ============================================================================
export type TLightProtocol = 'home-assistant' | 'hue' | 'mqtt' | 'zigbee';
export interface ILightCapabilities {
supportsBrightness: boolean;
supportsColorTemp: boolean;
supportsRgb: boolean;
supportsHs: boolean; // Hue/Saturation
supportsXy: boolean; // CIE xy color
supportsEffects: boolean;
supportsTransition: boolean;
effects?: string[];
minMireds?: number;
maxMireds?: number;
minColorTempKelvin?: number;
maxColorTempKelvin?: number;
}
export interface ILightState {
isOn: boolean;
brightness?: number; // 0-255
colorTemp?: number; // Kelvin
colorTempMireds?: number; // Mireds (1000000/Kelvin)
rgbColor?: [number, number, number];
hsColor?: [number, number]; // [hue 0-360, saturation 0-100]
xyColor?: [number, number]; // CIE xy
effect?: string;
}
export interface ILightFeatureInfo extends IFeatureInfo {
type: 'light';
protocol: TLightProtocol;
capabilities: ILightCapabilities;
currentState: ILightState;
}
// ============================================================================
// Climate/Thermostat Feature Types
// ============================================================================
export type TClimateProtocol = 'home-assistant' | 'nest' | 'ecobee' | 'mqtt';
export type THvacMode =
| 'off'
| 'heat'
| 'cool'
| 'heat_cool' // Auto dual setpoint
| 'auto'
| 'dry'
| 'fan_only';
export type THvacAction =
| 'off'
| 'heating'
| 'cooling'
| 'drying'
| 'idle'
| 'fan';
export interface IClimateCapabilities {
hvacModes: THvacMode[];
presetModes?: string[]; // 'away', 'eco', 'boost', 'sleep'
fanModes?: string[]; // 'auto', 'low', 'medium', 'high'
swingModes?: string[]; // 'off', 'vertical', 'horizontal', 'both'
supportsTargetTemp: boolean;
supportsTargetTempRange: boolean; // For heat_cool mode
supportsHumidity: boolean;
supportsAuxHeat: boolean;
minTemp: number;
maxTemp: number;
tempStep: number; // Temperature increment (e.g., 0.5, 1)
minHumidity?: number;
maxHumidity?: number;
}
export interface IClimateState {
currentTemp?: number;
targetTemp?: number;
targetTempHigh?: number; // For heat_cool mode
targetTempLow?: number; // For heat_cool mode
hvacMode: THvacMode;
hvacAction?: THvacAction;
presetMode?: string;
fanMode?: string;
swingMode?: string;
humidity?: number;
targetHumidity?: number;
auxHeat?: boolean;
}
export interface IClimateFeatureInfo extends IFeatureInfo {
type: 'climate';
protocol: TClimateProtocol;
capabilities: IClimateCapabilities;
currentState: IClimateState;
}
// ============================================================================
// Sensor Feature Types
// ============================================================================
export type TSensorProtocol = 'home-assistant' | 'mqtt' | 'snmp';
export type TSensorDeviceClass =
| 'temperature'
| 'humidity'
| 'pressure'
| 'illuminance'
| 'battery'
| 'power'
| 'energy'
| 'voltage'
| 'current'
| 'frequency'
| 'gas'
| 'co2'
| 'pm25'
| 'pm10'
| 'signal_strength'
| 'timestamp'
| 'duration'
| 'distance'
| 'speed'
| 'weight'
| 'monetary'
| 'data_size'
| 'data_rate'
| 'water'
| 'irradiance'
| 'precipitation'
| 'precipitation_intensity'
| 'wind_speed';
export type TSensorStateClass =
| 'measurement' // Instantaneous reading
| 'total' // Cumulative total
| 'total_increasing'; // Monotonically increasing total
export interface ISensorCapabilities {
deviceClass?: TSensorDeviceClass;
stateClass?: TSensorStateClass;
unit?: string;
nativeUnit?: string;
precision?: number; // Decimal places
}
export interface ISensorState {
value: string | number | boolean;
numericValue?: number;
unit?: string;
lastUpdated: Date;
}
export interface ISensorFeatureInfo extends IFeatureInfo {
type: 'sensor';
protocol: TSensorProtocol;
capabilities: ISensorCapabilities;
currentState: ISensorState;
}
// ============================================================================
// Camera Feature Types
// ============================================================================
export type TCameraProtocol = 'home-assistant' | 'onvif' | 'rtsp';
export interface ICameraCapabilities {
supportsStream: boolean;
supportsPtz: boolean; // Pan-tilt-zoom
supportsSnapshot: boolean;
supportsMotionDetection: boolean;
frontendStreamType?: 'hls' | 'web_rtc';
streamUrl?: string;
}
export interface ICameraState {
isRecording: boolean;
isStreaming: boolean;
motionDetected: boolean;
}
export interface ICameraFeatureInfo extends IFeatureInfo {
type: 'camera';
protocol: TCameraProtocol;
capabilities: ICameraCapabilities;
currentState: ICameraState;
}
// ============================================================================
// Cover/Blind Feature Types
// ============================================================================
export type TCoverProtocol = 'home-assistant' | 'mqtt' | 'somfy';
export type TCoverDeviceClass =
| 'awning'
| 'blind'
| 'curtain'
| 'damper'
| 'door'
| 'garage'
| 'gate'
| 'shade'
| 'shutter'
| 'window';
export type TCoverState = 'open' | 'opening' | 'closed' | 'closing' | 'stopped' | 'unknown';
export interface ICoverCapabilities {
deviceClass?: TCoverDeviceClass;
supportsOpen: boolean;
supportsClose: boolean;
supportsStop: boolean;
supportsPosition: boolean; // set_cover_position
supportsTilt: boolean; // set_cover_tilt_position
}
export interface ICoverStateInfo {
state: TCoverState;
position?: number; // 0-100, 0 = closed, 100 = fully open
tiltPosition?: number; // 0-100
}
export interface ICoverFeatureInfo extends IFeatureInfo {
type: 'cover';
protocol: TCoverProtocol;
capabilities: ICoverCapabilities;
currentState: ICoverStateInfo;
}
// ============================================================================
// Switch Feature Types
// ============================================================================
export type TSwitchProtocol = 'home-assistant' | 'mqtt' | 'tasmota' | 'tuya';
export type TSwitchDeviceClass = 'outlet' | 'switch';
export interface ISwitchCapabilities {
deviceClass?: TSwitchDeviceClass;
}
export interface ISwitchState {
isOn: boolean;
}
export interface ISwitchFeatureInfo extends IFeatureInfo {
type: 'switch';
protocol: TSwitchProtocol;
capabilities: ISwitchCapabilities;
currentState: ISwitchState;
}
// ============================================================================
// Lock Feature Types
// ============================================================================
export type TLockProtocol = 'home-assistant' | 'mqtt' | 'august' | 'yale';
export type TLockState =
| 'locked'
| 'unlocked'
| 'locking'
| 'unlocking'
| 'jammed'
| 'unknown';
export interface ILockCapabilities {
supportsOpen: boolean; // Physical open (some locks can open the door)
}
export interface ILockStateInfo {
state: TLockState;
isLocked: boolean;
}
export interface ILockFeatureInfo extends IFeatureInfo {
type: 'lock';
protocol: TLockProtocol;
capabilities: ILockCapabilities;
currentState: ILockStateInfo;
}
// ============================================================================
// Fan Feature Types
// ============================================================================
export type TFanProtocol = 'home-assistant' | 'mqtt' | 'bond';
export type TFanDirection = 'forward' | 'reverse';
export interface IFanCapabilities {
supportsSpeed: boolean;
supportsOscillate: boolean;
supportsDirection: boolean;
supportsPresetModes: boolean;
presetModes?: string[];
speedCount?: number; // Number of discrete speed levels
}
export interface IFanState {
isOn: boolean;
percentage?: number; // 0-100 speed
presetMode?: string;
oscillating?: boolean;
direction?: TFanDirection;
}
export interface IFanFeatureInfo extends IFeatureInfo {
type: 'fan';
protocol: TFanProtocol;
capabilities: IFanCapabilities;
currentState: IFanState;
}
// ============================================================================
// Protocol Client Interfaces (for dependency injection)
// ============================================================================
/**
* Light protocol client interface
* Implemented by HomeAssistantProtocol, HueProtocol, etc.
*/
export interface ILightProtocolClient {
turnOn(entityId: string, options?: { brightness?: number; colorTemp?: number; rgb?: [number, number, number]; transition?: number }): Promise<void>;
turnOff(entityId: string, options?: { transition?: number }): Promise<void>;
toggle(entityId: string): Promise<void>;
setBrightness(entityId: string, brightness: number, transition?: number): Promise<void>;
setColorTemp(entityId: string, kelvin: number, transition?: number): Promise<void>;
setRgbColor(entityId: string, r: number, g: number, b: number, transition?: number): Promise<void>;
setEffect(entityId: string, effect: string): Promise<void>;
getState(entityId: string): Promise<ILightState>;
}
/**
* Climate protocol client interface
*/
export interface IClimateProtocolClient {
setHvacMode(entityId: string, mode: THvacMode): Promise<void>;
setTargetTemp(entityId: string, temp: number): Promise<void>;
setTargetTempRange(entityId: string, low: number, high: number): Promise<void>;
setPresetMode(entityId: string, preset: string): Promise<void>;
setFanMode(entityId: string, mode: string): Promise<void>;
setSwingMode(entityId: string, mode: string): Promise<void>;
setAuxHeat(entityId: string, enabled: boolean): Promise<void>;
getState(entityId: string): Promise<IClimateState>;
}
/**
* Sensor protocol client interface (read-only)
*/
export interface ISensorProtocolClient {
getState(entityId: string): Promise<ISensorState>;
}
/**
* Camera protocol client interface
*/
export interface ICameraProtocolClient {
getSnapshot(entityId: string): Promise<Buffer>;
getSnapshotUrl(entityId: string): Promise<string>;
getStreamUrl(entityId: string): Promise<string>;
getState(entityId: string): Promise<ICameraState>;
}
/**
* Cover protocol client interface
*/
export interface ICoverProtocolClient {
open(entityId: string): Promise<void>;
close(entityId: string): Promise<void>;
stop(entityId: string): Promise<void>;
setPosition(entityId: string, position: number): Promise<void>;
setTiltPosition(entityId: string, position: number): Promise<void>;
getState(entityId: string): Promise<ICoverStateInfo>;
}
/**
* Switch protocol client interface
*/
export interface ISwitchProtocolClient {
turnOn(entityId: string): Promise<void>;
turnOff(entityId: string): Promise<void>;
toggle(entityId: string): Promise<void>;
getState(entityId: string): Promise<ISwitchState>;
}
/**
* Lock protocol client interface
*/
export interface ILockProtocolClient {
lock(entityId: string): Promise<void>;
unlock(entityId: string): Promise<void>;
open(entityId: string): Promise<void>; // Physical open if supported
getState(entityId: string): Promise<ILockStateInfo>;
}
/**
* Fan protocol client interface
*/
export interface IFanProtocolClient {
turnOn(entityId: string, percentage?: number): Promise<void>;
turnOff(entityId: string): Promise<void>;
toggle(entityId: string): Promise<void>;
setPercentage(entityId: string, percentage: number): Promise<void>;
setPresetMode(entityId: string, mode: string): Promise<void>;
setOscillating(entityId: string, oscillating: boolean): Promise<void>;
setDirection(entityId: string, direction: TFanDirection): Promise<void>;
getState(entityId: string): Promise<IFanState>;
}

View File

@@ -30,6 +30,7 @@ import nodeSsdpModule from 'node-ssdp';
import * as netSnmp from 'net-snmp';
import * as sonos from 'sonos';
import * as castv2Client from 'castv2-client';
import WebSocket from 'ws';
// node-ssdp exports Client/Server under default in ESM
const nodeSsdp = {
@@ -37,4 +38,4 @@ const nodeSsdp = {
Server: nodeSsdpModule.Server,
};
export { bonjourService, ipp, nodeSsdp, netSnmp, sonos, castv2Client };
export { bonjourService, ipp, nodeSsdp, netSnmp, sonos, castv2Client, WebSocket };

View File

@@ -54,3 +54,6 @@ export {
type TUpsTestResult,
type IUpsSnmpStatus,
} from './protocol.upssnmp.js';
// Home Assistant WebSocket protocol
export { HomeAssistantProtocol } from './protocol.homeassistant.js';

View File

@@ -0,0 +1,737 @@
import * as plugins from '../plugins.js';
import type {
IHomeAssistantInstanceConfig,
IHomeAssistantEntity,
IHomeAssistantConfig,
IHomeAssistantStateChangedEvent,
IHomeAssistantMessage,
IHomeAssistantAuthRequired,
IHomeAssistantAuthOk,
IHomeAssistantAuthInvalid,
IHomeAssistantResult,
IHomeAssistantEvent,
THomeAssistantProtocolEvents,
IHomeAssistantLightServiceData,
IHomeAssistantClimateServiceData,
IHomeAssistantCoverServiceData,
IHomeAssistantFanServiceData,
IHomeAssistantMediaPlayerServiceData,
} from '../interfaces/homeassistant.interfaces.js';
/**
* Home Assistant WebSocket Protocol Handler
* Connects to HA via WebSocket, handles authentication, state subscriptions, and service calls
*/
export class HomeAssistantProtocol extends plugins.events.EventEmitter {
private ws: InstanceType<typeof plugins.WebSocket> | null = null;
private config: IHomeAssistantInstanceConfig;
private messageId: number = 1;
private pendingRequests: Map<number, {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timeout: ReturnType<typeof setTimeout>;
}> = new Map();
private isAuthenticated: boolean = false;
private haConfig: IHomeAssistantConfig | null = null;
private reconnectAttempt: number = 0;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private stateSubscriptionId: number | null = null;
private entityStates: Map<string, IHomeAssistantEntity> = new Map();
private intentionalDisconnect: boolean = false;
constructor(config: IHomeAssistantInstanceConfig) {
super();
this.config = {
port: 8123,
secure: false,
autoReconnect: true,
reconnectDelay: 5000,
...config,
};
}
/**
* Get the WebSocket URL for this HA instance
*/
private get wsUrl(): string {
const protocol = this.config.secure ? 'wss' : 'ws';
return `${protocol}://${this.config.host}:${this.config.port}/api/websocket`;
}
/**
* Get connection state
*/
public get isConnected(): boolean {
return this.ws !== null && this.ws.readyState === plugins.WebSocket.OPEN && this.isAuthenticated;
}
/**
* Get HA config if authenticated
*/
public get homeAssistantConfig(): IHomeAssistantConfig | null {
return this.haConfig;
}
/**
* Get all cached entity states
*/
public get entities(): Map<string, IHomeAssistantEntity> {
return this.entityStates;
}
/**
* Connect to Home Assistant
*/
public async connect(): Promise<void> {
if (this.ws && this.ws.readyState === plugins.WebSocket.OPEN) {
return;
}
this.intentionalDisconnect = false;
return new Promise((resolve, reject) => {
try {
this.ws = new plugins.WebSocket(this.wsUrl);
const connectionTimeout = setTimeout(() => {
if (this.ws) {
this.ws.close();
this.ws = null;
}
reject(new Error(`Connection timeout to ${this.wsUrl}`));
}, 10000);
this.ws.on('open', () => {
// Connection established, waiting for auth_required
});
this.ws.on('message', async (data: Buffer | string) => {
const message = JSON.parse(data.toString()) as IHomeAssistantMessage;
if (message.type === 'auth_required') {
// Send authentication
await this.sendAuth();
} else if (message.type === 'auth_ok') {
clearTimeout(connectionTimeout);
this.isAuthenticated = true;
this.reconnectAttempt = 0;
// Get HA config
try {
this.haConfig = await this.getConfig();
this.emit('authenticated', this.haConfig);
} catch (err) {
// Non-fatal, continue anyway
}
this.emit('connected');
resolve();
} else if (message.type === 'auth_invalid') {
clearTimeout(connectionTimeout);
const authInvalid = message as IHomeAssistantAuthInvalid;
this.emit('auth:failed', authInvalid.message);
reject(new Error(`Authentication failed: ${authInvalid.message}`));
} else {
// Handle other messages
this.handleMessage(message);
}
});
this.ws.on('error', (error: Error) => {
this.emit('error', error);
});
this.ws.on('close', () => {
this.isAuthenticated = false;
this.stateSubscriptionId = null;
// Reject all pending requests
for (const [id, request] of this.pendingRequests) {
clearTimeout(request.timeout);
request.reject(new Error('Connection closed'));
this.pendingRequests.delete(id);
}
this.emit('disconnected');
// Auto-reconnect if not intentional
if (this.config.autoReconnect && !this.intentionalDisconnect) {
this.scheduleReconnect();
}
});
} catch (err) {
reject(err);
}
});
}
/**
* Disconnect from Home Assistant
*/
public async disconnect(): Promise<void> {
this.intentionalDisconnect = true;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
// Clear all pending requests
for (const [id, request] of this.pendingRequests) {
clearTimeout(request.timeout);
request.reject(new Error('Disconnecting'));
this.pendingRequests.delete(id);
}
this.ws.close();
this.ws = null;
}
this.isAuthenticated = false;
this.stateSubscriptionId = null;
this.entityStates.clear();
}
/**
* Send authentication message
*/
private async sendAuth(): Promise<void> {
if (!this.ws) return;
const authMessage = {
type: 'auth',
access_token: this.config.token,
};
this.ws.send(JSON.stringify(authMessage));
}
/**
* Handle incoming messages
*/
private handleMessage(message: IHomeAssistantMessage): void {
if (message.type === 'result') {
const result = message as IHomeAssistantResult;
const pending = this.pendingRequests.get(result.id);
if (pending) {
clearTimeout(pending.timeout);
this.pendingRequests.delete(result.id);
if (result.success) {
pending.resolve(result.result);
} else {
pending.reject(new Error(result.error?.message || 'Unknown error'));
}
}
} else if (message.type === 'event') {
const event = message as IHomeAssistantEvent;
if (event.event.event_type === 'state_changed') {
const stateChanged = event.event.data as IHomeAssistantStateChangedEvent;
// Update cached state
if (stateChanged.new_state) {
this.entityStates.set(stateChanged.entity_id, stateChanged.new_state);
} else {
this.entityStates.delete(stateChanged.entity_id);
}
this.emit('state:changed', stateChanged);
}
}
}
/**
* Send a request and wait for response
*/
private async sendRequest<T>(type: string, data: Record<string, unknown> = {}): Promise<T> {
if (!this.ws || !this.isAuthenticated) {
throw new Error('Not connected to Home Assistant');
}
const id = this.messageId++;
const message = { id, type, ...data };
return new Promise<T>((resolve, reject) => {
const timeout = setTimeout(() => {
this.pendingRequests.delete(id);
reject(new Error(`Request timeout: ${type}`));
}, 30000);
this.pendingRequests.set(id, {
resolve: resolve as (value: unknown) => void,
reject,
timeout,
});
this.ws!.send(JSON.stringify(message));
});
}
/**
* Schedule reconnection attempt
*/
private scheduleReconnect(): void {
if (this.reconnectTimer) return;
this.reconnectAttempt++;
const delay = Math.min(
this.config.reconnectDelay! * Math.pow(1.5, this.reconnectAttempt - 1),
60000 // Max 60 seconds
);
this.emit('reconnecting', this.reconnectAttempt);
this.reconnectTimer = setTimeout(async () => {
this.reconnectTimer = null;
try {
await this.connect();
// Re-subscribe to state changes
if (this.isConnected) {
await this.subscribeToStateChanges();
}
} catch {
// connect() will schedule another reconnect on failure
}
}, delay);
}
// ==========================================================================
// Public API - State
// ==========================================================================
/**
* Get HA config
*/
public async getConfig(): Promise<IHomeAssistantConfig> {
return this.sendRequest<IHomeAssistantConfig>('get_config');
}
/**
* Subscribe to state change events
*/
public async subscribeToStateChanges(): Promise<number> {
const result = await this.sendRequest<{ context: { id: string } }>('subscribe_events', {
event_type: 'state_changed',
});
// Get all current states after subscribing
const states = await this.getStates();
for (const entity of states) {
this.entityStates.set(entity.entity_id, entity);
}
this.emit('states:loaded', states);
this.stateSubscriptionId = this.messageId - 1;
return this.stateSubscriptionId;
}
/**
* Get all entity states
*/
public async getStates(): Promise<IHomeAssistantEntity[]> {
return this.sendRequest<IHomeAssistantEntity[]>('get_states');
}
/**
* Get a specific entity state
*/
public async getState(entityId: string): Promise<IHomeAssistantEntity | null> {
// First check cache
const cached = this.entityStates.get(entityId);
if (cached) return cached;
// Otherwise fetch all states and find it
const states = await this.getStates();
return states.find((s) => s.entity_id === entityId) || null;
}
/**
* Get entities by domain
*/
public async getEntitiesByDomain(domain: string): Promise<IHomeAssistantEntity[]> {
const states = await this.getStates();
return states.filter((s) => s.entity_id.startsWith(`${domain}.`));
}
// ==========================================================================
// Public API - Service Calls
// ==========================================================================
/**
* Call a Home Assistant service
*/
public async callService(
domain: string,
service: string,
target?: { entity_id?: string | string[]; device_id?: string | string[]; area_id?: string | string[] },
serviceData?: Record<string, unknown>
): Promise<void> {
await this.sendRequest('call_service', {
domain,
service,
target,
service_data: serviceData,
});
}
/**
* Turn on an entity
*/
public async turnOn(entityId: string, data?: Record<string, unknown>): Promise<void> {
const domain = entityId.split('.')[0];
await this.callService(domain, 'turn_on', { entity_id: entityId }, data);
}
/**
* Turn off an entity
*/
public async turnOff(entityId: string): Promise<void> {
const domain = entityId.split('.')[0];
await this.callService(domain, 'turn_off', { entity_id: entityId });
}
/**
* Toggle an entity
*/
public async toggle(entityId: string): Promise<void> {
const domain = entityId.split('.')[0];
await this.callService(domain, 'toggle', { entity_id: entityId });
}
// ==========================================================================
// Light Services
// ==========================================================================
/**
* Control a light
*/
public async lightTurnOn(entityId: string, options?: IHomeAssistantLightServiceData): Promise<void> {
await this.callService('light', 'turn_on', { entity_id: entityId }, options);
}
public async lightTurnOff(entityId: string, options?: { transition?: number }): Promise<void> {
await this.callService('light', 'turn_off', { entity_id: entityId }, options);
}
public async lightToggle(entityId: string, options?: IHomeAssistantLightServiceData): Promise<void> {
await this.callService('light', 'toggle', { entity_id: entityId }, options);
}
// ==========================================================================
// Climate Services
// ==========================================================================
/**
* Set HVAC mode
*/
public async climateSetHvacMode(entityId: string, hvacMode: string): Promise<void> {
await this.callService('climate', 'set_hvac_mode', { entity_id: entityId }, { hvac_mode: hvacMode });
}
/**
* Set target temperature
*/
public async climateSetTemperature(entityId: string, options: IHomeAssistantClimateServiceData): Promise<void> {
await this.callService('climate', 'set_temperature', { entity_id: entityId }, options);
}
/**
* Set fan mode
*/
public async climateSetFanMode(entityId: string, fanMode: string): Promise<void> {
await this.callService('climate', 'set_fan_mode', { entity_id: entityId }, { fan_mode: fanMode });
}
/**
* Set preset mode
*/
public async climateSetPresetMode(entityId: string, presetMode: string): Promise<void> {
await this.callService('climate', 'set_preset_mode', { entity_id: entityId }, { preset_mode: presetMode });
}
/**
* Set swing mode
*/
public async climateSetSwingMode(entityId: string, swingMode: string): Promise<void> {
await this.callService('climate', 'set_swing_mode', { entity_id: entityId }, { swing_mode: swingMode });
}
/**
* Set aux heat
*/
public async climateSetAuxHeat(entityId: string, auxHeat: boolean): Promise<void> {
await this.callService('climate', 'set_aux_heat', { entity_id: entityId }, { aux_heat: auxHeat });
}
// ==========================================================================
// Cover Services
// ==========================================================================
/**
* Open cover
*/
public async coverOpen(entityId: string): Promise<void> {
await this.callService('cover', 'open_cover', { entity_id: entityId });
}
/**
* Close cover
*/
public async coverClose(entityId: string): Promise<void> {
await this.callService('cover', 'close_cover', { entity_id: entityId });
}
/**
* Stop cover
*/
public async coverStop(entityId: string): Promise<void> {
await this.callService('cover', 'stop_cover', { entity_id: entityId });
}
/**
* Set cover position
*/
public async coverSetPosition(entityId: string, position: number): Promise<void> {
await this.callService('cover', 'set_cover_position', { entity_id: entityId }, { position });
}
/**
* Set cover tilt position
*/
public async coverSetTiltPosition(entityId: string, tiltPosition: number): Promise<void> {
await this.callService('cover', 'set_cover_tilt_position', { entity_id: entityId }, { tilt_position: tiltPosition });
}
// ==========================================================================
// Fan Services
// ==========================================================================
/**
* Turn on fan
*/
public async fanTurnOn(entityId: string, options?: IHomeAssistantFanServiceData): Promise<void> {
await this.callService('fan', 'turn_on', { entity_id: entityId }, options);
}
/**
* Turn off fan
*/
public async fanTurnOff(entityId: string): Promise<void> {
await this.callService('fan', 'turn_off', { entity_id: entityId });
}
/**
* Set fan percentage
*/
public async fanSetPercentage(entityId: string, percentage: number): Promise<void> {
await this.callService('fan', 'set_percentage', { entity_id: entityId }, { percentage });
}
/**
* Set fan preset mode
*/
public async fanSetPresetMode(entityId: string, presetMode: string): Promise<void> {
await this.callService('fan', 'set_preset_mode', { entity_id: entityId }, { preset_mode: presetMode });
}
/**
* Oscillate fan
*/
public async fanOscillate(entityId: string, oscillating: boolean): Promise<void> {
await this.callService('fan', 'oscillate', { entity_id: entityId }, { oscillating });
}
/**
* Set fan direction
*/
public async fanSetDirection(entityId: string, direction: 'forward' | 'reverse'): Promise<void> {
await this.callService('fan', 'set_direction', { entity_id: entityId }, { direction });
}
// ==========================================================================
// Lock Services
// ==========================================================================
/**
* Lock
*/
public async lockLock(entityId: string): Promise<void> {
await this.callService('lock', 'lock', { entity_id: entityId });
}
/**
* Unlock
*/
public async lockUnlock(entityId: string): Promise<void> {
await this.callService('lock', 'unlock', { entity_id: entityId });
}
/**
* Open (if supported)
*/
public async lockOpen(entityId: string): Promise<void> {
await this.callService('lock', 'open', { entity_id: entityId });
}
// ==========================================================================
// Switch Services
// ==========================================================================
/**
* Turn on switch
*/
public async switchTurnOn(entityId: string): Promise<void> {
await this.callService('switch', 'turn_on', { entity_id: entityId });
}
/**
* Turn off switch
*/
public async switchTurnOff(entityId: string): Promise<void> {
await this.callService('switch', 'turn_off', { entity_id: entityId });
}
/**
* Toggle switch
*/
public async switchToggle(entityId: string): Promise<void> {
await this.callService('switch', 'toggle', { entity_id: entityId });
}
// ==========================================================================
// Media Player Services
// ==========================================================================
/**
* Play media
*/
public async mediaPlayerPlay(entityId: string): Promise<void> {
await this.callService('media_player', 'media_play', { entity_id: entityId });
}
/**
* Pause media
*/
public async mediaPlayerPause(entityId: string): Promise<void> {
await this.callService('media_player', 'media_pause', { entity_id: entityId });
}
/**
* Stop media
*/
public async mediaPlayerStop(entityId: string): Promise<void> {
await this.callService('media_player', 'media_stop', { entity_id: entityId });
}
/**
* Next track
*/
public async mediaPlayerNext(entityId: string): Promise<void> {
await this.callService('media_player', 'media_next_track', { entity_id: entityId });
}
/**
* Previous track
*/
public async mediaPlayerPrevious(entityId: string): Promise<void> {
await this.callService('media_player', 'media_previous_track', { entity_id: entityId });
}
/**
* Set volume
*/
public async mediaPlayerSetVolume(entityId: string, volumeLevel: number): Promise<void> {
await this.callService('media_player', 'volume_set', { entity_id: entityId }, { volume_level: volumeLevel });
}
/**
* Mute/unmute
*/
public async mediaPlayerMute(entityId: string, isMuted: boolean): Promise<void> {
await this.callService('media_player', 'volume_mute', { entity_id: entityId }, { is_volume_muted: isMuted });
}
/**
* Seek to position
*/
public async mediaPlayerSeek(entityId: string, position: number): Promise<void> {
await this.callService('media_player', 'media_seek', { entity_id: entityId }, { seek_position: position });
}
/**
* Select source
*/
public async mediaPlayerSelectSource(entityId: string, source: string): Promise<void> {
await this.callService('media_player', 'select_source', { entity_id: entityId }, { source });
}
// ==========================================================================
// Camera Services
// ==========================================================================
/**
* Get camera snapshot URL
*/
public getCameraSnapshotUrl(entityId: string): string {
const protocol = this.config.secure ? 'https' : 'http';
const entity = this.entityStates.get(entityId);
const accessToken = (entity?.attributes as { access_token?: string })?.access_token || '';
return `${protocol}://${this.config.host}:${this.config.port}/api/camera_proxy/${entityId}?token=${accessToken}`;
}
/**
* Get camera stream URL
*/
public getCameraStreamUrl(entityId: string): string {
const protocol = this.config.secure ? 'https' : 'http';
return `${protocol}://${this.config.host}:${this.config.port}/api/camera_proxy_stream/${entityId}`;
}
// ==========================================================================
// Static Helpers
// ==========================================================================
/**
* Probe if a Home Assistant instance is reachable
*/
public static async probe(host: string, port: number = 8123, secure: boolean = false, timeout: number = 5000): Promise<boolean> {
return new Promise((resolve) => {
const protocol = secure ? 'wss' : 'ws';
const url = `${protocol}://${host}:${port}/api/websocket`;
try {
const ws = new plugins.WebSocket(url);
const timer = setTimeout(() => {
ws.close();
resolve(false);
}, timeout);
ws.on('open', () => {
// Wait for auth_required message
});
ws.on('message', (data: Buffer | string) => {
const message = JSON.parse(data.toString()) as IHomeAssistantMessage;
if (message.type === 'auth_required') {
clearTimeout(timer);
ws.close();
resolve(true);
}
});
ws.on('error', () => {
clearTimeout(timer);
resolve(false);
});
} catch {
resolve(false);
}
});
}
}