diff --git a/changelog.md b/changelog.md index 3cdcc96..dfb4c33 100644 --- a/changelog.md +++ b/changelog.md @@ -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 diff --git a/package.json b/package.json index 403d7df..5969922 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a372417..b4ca18d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 3dcbad2..56186d7 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -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' } diff --git a/ts/discovery/discovery.classes.homeassistant.ts b/ts/discovery/discovery.classes.homeassistant.ts new file mode 100644 index 0000000..8fbfe2d --- /dev/null +++ b/ts/discovery/discovery.classes.homeassistant.ts @@ -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 = new Map(); + private connectedProtocols: Map = new Map(); + private entityCache: Map = 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 | undefined): Record { + const records: Record = {}; + + 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 { + return HomeAssistantProtocol.probe(host, port, secure, timeout); + } +} + +export { HA_SERVICE_TYPE }; diff --git a/ts/factories/index.ts b/ts/factories/index.ts index 1b52550..956d772 100644 --- a/ts/factories/index.ts +++ b/ts/factories/index.ts @@ -353,6 +353,324 @@ function parseScanSources(txtRecords: Record): 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; +} + +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; +} + +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; +} + +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; +} + +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; +} + +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, }; diff --git a/ts/features/feature.camera.ts b/ts/features/feature.camera.ts new file mode 100644 index 0000000..73c0178 --- /dev/null +++ b/ts/features/feature.camera.ts @@ -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; +} + +/** + * Camera Feature - snapshot and stream access + * + * Protocol-agnostic: works with Home Assistant, ONVIF, RTSP, etc. + * + * @example + * ```typescript + * const camera = device.getFeature('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 { + try { + const state = await this.protocolClient.getState(this.entityId); + this.updateStateInternal(state); + } catch { + // Ignore errors fetching initial state + } + } + + protected async doDisconnect(): Promise { + // Nothing to disconnect + } + + // ============================================================================ + // Camera Access + // ============================================================================ + + /** + * Get a snapshot image from the camera + * @returns Buffer containing image data + */ + public async getSnapshot(): Promise { + 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 { + 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 { + 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 { + 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(), + }; + } +} diff --git a/ts/features/feature.climate.ts b/ts/features/feature.climate.ts new file mode 100644 index 0000000..72d1015 --- /dev/null +++ b/ts/features/feature.climate.ts @@ -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; +} + +/** + * Climate Feature - thermostat and HVAC control + * + * Protocol-agnostic: works with Home Assistant, Nest, Ecobee, MQTT, etc. + * + * @example + * ```typescript + * const climate = device.getFeature('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 { + try { + const state = await this.protocolClient.getState(this.entityId); + this.updateStateInternal(state); + } catch { + // Ignore errors fetching initial state + } + } + + protected async doDisconnect(): Promise { + // Nothing to disconnect + } + + // ============================================================================ + // Climate Control + // ============================================================================ + + /** + * Set HVAC mode + * @param mode HVAC mode (off, heat, cool, etc.) + */ + public async setHvacMode(mode: THvacMode): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(), + }; + } +} diff --git a/ts/features/feature.cover.ts b/ts/features/feature.cover.ts new file mode 100644 index 0000000..fd110d8 --- /dev/null +++ b/ts/features/feature.cover.ts @@ -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; +} + +/** + * Cover Feature - control for blinds, garage doors, etc. + * + * Protocol-agnostic: works with Home Assistant, MQTT, Somfy, etc. + * + * @example + * ```typescript + * const cover = device.getFeature('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 { + try { + const state = await this.protocolClient.getState(this.entityId); + this.updateStateInternal(state); + } catch { + // Ignore errors fetching initial state + } + } + + protected async doDisconnect(): Promise { + // Nothing to disconnect + } + + // ============================================================================ + // Cover Control + // ============================================================================ + + /** + * Open the cover + */ + public async open(): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(), + }; + } +} diff --git a/ts/features/feature.fan.ts b/ts/features/feature.fan.ts new file mode 100644 index 0000000..a587c3c --- /dev/null +++ b/ts/features/feature.fan.ts @@ -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; +} + +/** + * Fan Feature - speed, oscillation, and direction control + * + * Protocol-agnostic: works with Home Assistant, MQTT, Bond, etc. + * + * @example + * ```typescript + * const fan = device.getFeature('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 { + try { + const state = await this.protocolClient.getState(this.entityId); + this.updateStateInternal(state); + } catch { + // Ignore errors fetching initial state + } + } + + protected async doDisconnect(): Promise { + // Nothing to disconnect + } + + // ============================================================================ + // Fan Control + // ============================================================================ + + /** + * Turn on the fan + * @param percentage Optional speed percentage + */ + public async turnOn(percentage?: number): Promise { + 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 { + await this.protocolClient.turnOff(this.entityId); + this._isOn = false; + this.emit('state:changed', this.getState()); + } + + /** + * Toggle the fan + */ + public async toggle(): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(), + }; + } +} diff --git a/ts/features/feature.light.ts b/ts/features/feature.light.ts new file mode 100644 index 0000000..d774d58 --- /dev/null +++ b/ts/features/feature.light.ts @@ -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; +} + +/** + * Light Feature - brightness, color, and effect control + * + * Protocol-agnostic: works with Home Assistant, Hue, MQTT, Zigbee, etc. + * + * @example + * ```typescript + * const light = device.getFeature('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 { + // 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 { + // 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 { + 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 { + await this.protocolClient.turnOff(this.entityId, options); + this._isOn = false; + this.emit('state:changed', this.getState()); + } + + /** + * Toggle the light + */ + public async toggle(): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(), + }; + } +} diff --git a/ts/features/feature.lock.ts b/ts/features/feature.lock.ts new file mode 100644 index 0000000..d22e02f --- /dev/null +++ b/ts/features/feature.lock.ts @@ -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('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 { + try { + const state = await this.protocolClient.getState(this.entityId); + this.updateStateInternal(state); + } catch { + // Ignore errors fetching initial state + } + } + + protected async doDisconnect(): Promise { + // Nothing to disconnect + } + + // ============================================================================ + // Lock Control + // ============================================================================ + + /** + * Lock the lock + */ + public async lock(): Promise { + await this.protocolClient.lock(this.entityId); + this._lockState = 'locking'; + this.emit('state:changed', this.getState()); + } + + /** + * Unlock the lock + */ + public async unlock(): Promise { + 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 { + 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 { + 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(), + }; + } +} diff --git a/ts/features/feature.sensor.ts b/ts/features/feature.sensor.ts new file mode 100644 index 0000000..42172a1 --- /dev/null +++ b/ts/features/feature.sensor.ts @@ -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('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 { + // 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 { + // Nothing to disconnect + } + + // ============================================================================ + // Sensor Reading + // ============================================================================ + + /** + * Refresh state from the device + */ + public async refreshState(): Promise { + 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, + }, + }; + } +} diff --git a/ts/features/feature.switch.ts b/ts/features/feature.switch.ts new file mode 100644 index 0000000..7aeffc6 --- /dev/null +++ b/ts/features/feature.switch.ts @@ -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('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 { + // 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 { + // Nothing to disconnect + } + + // ============================================================================ + // Switch Control + // ============================================================================ + + /** + * Turn on the switch + */ + public async turnOn(): Promise { + await this.protocolClient.turnOn(this.entityId); + this._isOn = true; + this.emit('state:changed', { isOn: true }); + } + + /** + * Turn off the switch + */ + public async turnOff(): Promise { + await this.protocolClient.turnOff(this.entityId); + this._isOn = false; + this.emit('state:changed', { isOn: false }); + } + + /** + * Toggle the switch + */ + public async toggle(): Promise { + 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 { + 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, + }, + }; + } +} diff --git a/ts/features/index.ts b/ts/features/index.ts index 957a368..6639c63 100644 --- a/ts/features/index.ts +++ b/ts/features/index.ts @@ -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'; diff --git a/ts/index.ts b/ts/index.ts index d6ebbe9..21cae34 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -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 // ============================================================================ diff --git a/ts/interfaces/feature.interfaces.ts b/ts/interfaces/feature.interfaces.ts index 1b60d8a..e9e72a9 100644 --- a/ts/interfaces/feature.interfaces.ts +++ b/ts/interfaces/feature.interfaces.ts @@ -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 ; /** diff --git a/ts/interfaces/homeassistant.interfaces.ts b/ts/interfaces/homeassistant.interfaces.ts new file mode 100644 index 0000000..13f00e2 --- /dev/null +++ b/ts/interfaces/homeassistant.interfaces.ts @@ -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; +} + +/** + * 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 +} + +/** + * 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 +} + +/** + * 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 +} + +/** + * 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; + /** 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 = { + '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; diff --git a/ts/interfaces/index.ts b/ts/interfaces/index.ts index 4082d7d..93f8946 100644 --- a/ts/interfaces/index.ts +++ b/ts/interfaces/index.ts @@ -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'; diff --git a/ts/interfaces/smarthome.interfaces.ts b/ts/interfaces/smarthome.interfaces.ts new file mode 100644 index 0000000..4dac354 --- /dev/null +++ b/ts/interfaces/smarthome.interfaces.ts @@ -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; + turnOff(entityId: string, options?: { transition?: number }): Promise; + toggle(entityId: string): Promise; + setBrightness(entityId: string, brightness: number, transition?: number): Promise; + setColorTemp(entityId: string, kelvin: number, transition?: number): Promise; + setRgbColor(entityId: string, r: number, g: number, b: number, transition?: number): Promise; + setEffect(entityId: string, effect: string): Promise; + getState(entityId: string): Promise; +} + +/** + * Climate protocol client interface + */ +export interface IClimateProtocolClient { + setHvacMode(entityId: string, mode: THvacMode): Promise; + setTargetTemp(entityId: string, temp: number): Promise; + setTargetTempRange(entityId: string, low: number, high: number): Promise; + setPresetMode(entityId: string, preset: string): Promise; + setFanMode(entityId: string, mode: string): Promise; + setSwingMode(entityId: string, mode: string): Promise; + setAuxHeat(entityId: string, enabled: boolean): Promise; + getState(entityId: string): Promise; +} + +/** + * Sensor protocol client interface (read-only) + */ +export interface ISensorProtocolClient { + getState(entityId: string): Promise; +} + +/** + * Camera protocol client interface + */ +export interface ICameraProtocolClient { + getSnapshot(entityId: string): Promise; + getSnapshotUrl(entityId: string): Promise; + getStreamUrl(entityId: string): Promise; + getState(entityId: string): Promise; +} + +/** + * Cover protocol client interface + */ +export interface ICoverProtocolClient { + open(entityId: string): Promise; + close(entityId: string): Promise; + stop(entityId: string): Promise; + setPosition(entityId: string, position: number): Promise; + setTiltPosition(entityId: string, position: number): Promise; + getState(entityId: string): Promise; +} + +/** + * Switch protocol client interface + */ +export interface ISwitchProtocolClient { + turnOn(entityId: string): Promise; + turnOff(entityId: string): Promise; + toggle(entityId: string): Promise; + getState(entityId: string): Promise; +} + +/** + * Lock protocol client interface + */ +export interface ILockProtocolClient { + lock(entityId: string): Promise; + unlock(entityId: string): Promise; + open(entityId: string): Promise; // Physical open if supported + getState(entityId: string): Promise; +} + +/** + * Fan protocol client interface + */ +export interface IFanProtocolClient { + turnOn(entityId: string, percentage?: number): Promise; + turnOff(entityId: string): Promise; + toggle(entityId: string): Promise; + setPercentage(entityId: string, percentage: number): Promise; + setPresetMode(entityId: string, mode: string): Promise; + setOscillating(entityId: string, oscillating: boolean): Promise; + setDirection(entityId: string, direction: TFanDirection): Promise; + getState(entityId: string): Promise; +} diff --git a/ts/plugins.ts b/ts/plugins.ts index 792524a..bd8cf88 100644 --- a/ts/plugins.ts +++ b/ts/plugins.ts @@ -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 }; diff --git a/ts/protocols/index.ts b/ts/protocols/index.ts index 937385c..f9eaec7 100644 --- a/ts/protocols/index.ts +++ b/ts/protocols/index.ts @@ -54,3 +54,6 @@ export { type TUpsTestResult, type IUpsSnmpStatus, } from './protocol.upssnmp.js'; + +// Home Assistant WebSocket protocol +export { HomeAssistantProtocol } from './protocol.homeassistant.js'; diff --git a/ts/protocols/protocol.homeassistant.ts b/ts/protocols/protocol.homeassistant.ts new file mode 100644 index 0000000..f4efc64 --- /dev/null +++ b/ts/protocols/protocol.homeassistant.ts @@ -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 | null = null; + private config: IHomeAssistantInstanceConfig; + private messageId: number = 1; + private pendingRequests: Map void; + reject: (error: Error) => void; + timeout: ReturnType; + }> = new Map(); + private isAuthenticated: boolean = false; + private haConfig: IHomeAssistantConfig | null = null; + private reconnectAttempt: number = 0; + private reconnectTimer: ReturnType | null = null; + private stateSubscriptionId: number | null = null; + private entityStates: Map = 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 { + return this.entityStates; + } + + /** + * Connect to Home Assistant + */ + public async connect(): Promise { + 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 { + 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 { + 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(type: string, data: Record = {}): Promise { + 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((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 { + return this.sendRequest('get_config'); + } + + /** + * Subscribe to state change events + */ + public async subscribeToStateChanges(): Promise { + 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 { + return this.sendRequest('get_states'); + } + + /** + * Get a specific entity state + */ + public async getState(entityId: string): Promise { + // 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 { + 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 + ): Promise { + await this.sendRequest('call_service', { + domain, + service, + target, + service_data: serviceData, + }); + } + + /** + * Turn on an entity + */ + public async turnOn(entityId: string, data?: Record): Promise { + 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 { + const domain = entityId.split('.')[0]; + await this.callService(domain, 'turn_off', { entity_id: entityId }); + } + + /** + * Toggle an entity + */ + public async toggle(entityId: string): Promise { + 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 { + await this.callService('light', 'turn_on', { entity_id: entityId }, options); + } + + public async lightTurnOff(entityId: string, options?: { transition?: number }): Promise { + await this.callService('light', 'turn_off', { entity_id: entityId }, options); + } + + public async lightToggle(entityId: string, options?: IHomeAssistantLightServiceData): Promise { + await this.callService('light', 'toggle', { entity_id: entityId }, options); + } + + // ========================================================================== + // Climate Services + // ========================================================================== + + /** + * Set HVAC mode + */ + public async climateSetHvacMode(entityId: string, hvacMode: string): Promise { + await this.callService('climate', 'set_hvac_mode', { entity_id: entityId }, { hvac_mode: hvacMode }); + } + + /** + * Set target temperature + */ + public async climateSetTemperature(entityId: string, options: IHomeAssistantClimateServiceData): Promise { + await this.callService('climate', 'set_temperature', { entity_id: entityId }, options); + } + + /** + * Set fan mode + */ + public async climateSetFanMode(entityId: string, fanMode: string): Promise { + await this.callService('climate', 'set_fan_mode', { entity_id: entityId }, { fan_mode: fanMode }); + } + + /** + * Set preset mode + */ + public async climateSetPresetMode(entityId: string, presetMode: string): Promise { + await this.callService('climate', 'set_preset_mode', { entity_id: entityId }, { preset_mode: presetMode }); + } + + /** + * Set swing mode + */ + public async climateSetSwingMode(entityId: string, swingMode: string): Promise { + await this.callService('climate', 'set_swing_mode', { entity_id: entityId }, { swing_mode: swingMode }); + } + + /** + * Set aux heat + */ + public async climateSetAuxHeat(entityId: string, auxHeat: boolean): Promise { + await this.callService('climate', 'set_aux_heat', { entity_id: entityId }, { aux_heat: auxHeat }); + } + + // ========================================================================== + // Cover Services + // ========================================================================== + + /** + * Open cover + */ + public async coverOpen(entityId: string): Promise { + await this.callService('cover', 'open_cover', { entity_id: entityId }); + } + + /** + * Close cover + */ + public async coverClose(entityId: string): Promise { + await this.callService('cover', 'close_cover', { entity_id: entityId }); + } + + /** + * Stop cover + */ + public async coverStop(entityId: string): Promise { + await this.callService('cover', 'stop_cover', { entity_id: entityId }); + } + + /** + * Set cover position + */ + public async coverSetPosition(entityId: string, position: number): Promise { + await this.callService('cover', 'set_cover_position', { entity_id: entityId }, { position }); + } + + /** + * Set cover tilt position + */ + public async coverSetTiltPosition(entityId: string, tiltPosition: number): Promise { + 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 { + await this.callService('fan', 'turn_on', { entity_id: entityId }, options); + } + + /** + * Turn off fan + */ + public async fanTurnOff(entityId: string): Promise { + await this.callService('fan', 'turn_off', { entity_id: entityId }); + } + + /** + * Set fan percentage + */ + public async fanSetPercentage(entityId: string, percentage: number): Promise { + await this.callService('fan', 'set_percentage', { entity_id: entityId }, { percentage }); + } + + /** + * Set fan preset mode + */ + public async fanSetPresetMode(entityId: string, presetMode: string): Promise { + await this.callService('fan', 'set_preset_mode', { entity_id: entityId }, { preset_mode: presetMode }); + } + + /** + * Oscillate fan + */ + public async fanOscillate(entityId: string, oscillating: boolean): Promise { + await this.callService('fan', 'oscillate', { entity_id: entityId }, { oscillating }); + } + + /** + * Set fan direction + */ + public async fanSetDirection(entityId: string, direction: 'forward' | 'reverse'): Promise { + await this.callService('fan', 'set_direction', { entity_id: entityId }, { direction }); + } + + // ========================================================================== + // Lock Services + // ========================================================================== + + /** + * Lock + */ + public async lockLock(entityId: string): Promise { + await this.callService('lock', 'lock', { entity_id: entityId }); + } + + /** + * Unlock + */ + public async lockUnlock(entityId: string): Promise { + await this.callService('lock', 'unlock', { entity_id: entityId }); + } + + /** + * Open (if supported) + */ + public async lockOpen(entityId: string): Promise { + await this.callService('lock', 'open', { entity_id: entityId }); + } + + // ========================================================================== + // Switch Services + // ========================================================================== + + /** + * Turn on switch + */ + public async switchTurnOn(entityId: string): Promise { + await this.callService('switch', 'turn_on', { entity_id: entityId }); + } + + /** + * Turn off switch + */ + public async switchTurnOff(entityId: string): Promise { + await this.callService('switch', 'turn_off', { entity_id: entityId }); + } + + /** + * Toggle switch + */ + public async switchToggle(entityId: string): Promise { + await this.callService('switch', 'toggle', { entity_id: entityId }); + } + + // ========================================================================== + // Media Player Services + // ========================================================================== + + /** + * Play media + */ + public async mediaPlayerPlay(entityId: string): Promise { + await this.callService('media_player', 'media_play', { entity_id: entityId }); + } + + /** + * Pause media + */ + public async mediaPlayerPause(entityId: string): Promise { + await this.callService('media_player', 'media_pause', { entity_id: entityId }); + } + + /** + * Stop media + */ + public async mediaPlayerStop(entityId: string): Promise { + await this.callService('media_player', 'media_stop', { entity_id: entityId }); + } + + /** + * Next track + */ + public async mediaPlayerNext(entityId: string): Promise { + await this.callService('media_player', 'media_next_track', { entity_id: entityId }); + } + + /** + * Previous track + */ + public async mediaPlayerPrevious(entityId: string): Promise { + await this.callService('media_player', 'media_previous_track', { entity_id: entityId }); + } + + /** + * Set volume + */ + public async mediaPlayerSetVolume(entityId: string, volumeLevel: number): Promise { + await this.callService('media_player', 'volume_set', { entity_id: entityId }, { volume_level: volumeLevel }); + } + + /** + * Mute/unmute + */ + public async mediaPlayerMute(entityId: string, isMuted: boolean): Promise { + 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 { + await this.callService('media_player', 'media_seek', { entity_id: entityId }, { seek_position: position }); + } + + /** + * Select source + */ + public async mediaPlayerSelectSource(entityId: string, source: string): Promise { + 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 { + 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); + } + }); + } +}