feat(smarthome): add smart home features and Home Assistant integration (WebSocket protocol, discovery, factories, interfaces)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
"@git.zone/tsbuild": "^4.1.0",
|
||||
"@git.zone/tsrun": "^2.0.0",
|
||||
"@git.zone/tstest": "^3.1.3",
|
||||
"@types/node": "^25.0.3"
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/ws": "^8.18.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@push.rocks/smartdelay": "^3.0.5",
|
||||
@@ -32,6 +33,7 @@
|
||||
"ipp": "^2.0.1",
|
||||
"net-snmp": "^3.26.0",
|
||||
"node-ssdp": "^4.0.1",
|
||||
"sonos": "^1.14.2"
|
||||
"sonos": "^1.14.2",
|
||||
"ws": "^8.19.0"
|
||||
}
|
||||
}
|
||||
|
||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
376
ts/discovery/discovery.classes.homeassistant.ts
Normal file
376
ts/discovery/discovery.classes.homeassistant.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type {
|
||||
IHomeAssistantInstanceConfig,
|
||||
IHomeAssistantEntity,
|
||||
IHomeAssistantDiscoveredInstance,
|
||||
THomeAssistantDomain,
|
||||
THomeAssistantDiscoveryEvents,
|
||||
} from '../interfaces/homeassistant.interfaces.js';
|
||||
import { HomeAssistantProtocol } from '../protocols/protocol.homeassistant.js';
|
||||
|
||||
/**
|
||||
* mDNS service type for Home Assistant discovery
|
||||
*/
|
||||
const HA_SERVICE_TYPE = '_home-assistant._tcp';
|
||||
|
||||
/**
|
||||
* Default domains to discover
|
||||
*/
|
||||
const DEFAULT_DOMAINS: THomeAssistantDomain[] = [
|
||||
'light',
|
||||
'switch',
|
||||
'sensor',
|
||||
'binary_sensor',
|
||||
'climate',
|
||||
'fan',
|
||||
'cover',
|
||||
'lock',
|
||||
'camera',
|
||||
'media_player',
|
||||
];
|
||||
|
||||
/**
|
||||
* Home Assistant Discovery
|
||||
* Discovers HA instances via mDNS and/or manual configuration,
|
||||
* connects to them, and enumerates all entities
|
||||
*/
|
||||
export class HomeAssistantDiscovery extends plugins.events.EventEmitter {
|
||||
private bonjour: plugins.bonjourService.Bonjour | null = null;
|
||||
private browser: plugins.bonjourService.Browser | null = null;
|
||||
private discoveredInstances: Map<string, IHomeAssistantDiscoveredInstance> = new Map();
|
||||
private connectedProtocols: Map<string, HomeAssistantProtocol> = new Map();
|
||||
private entityCache: Map<string, IHomeAssistantEntity> = new Map();
|
||||
private enabledDomains: THomeAssistantDomain[];
|
||||
private isRunning: boolean = false;
|
||||
|
||||
constructor(options?: { enabledDomains?: THomeAssistantDomain[] }) {
|
||||
super();
|
||||
this.enabledDomains = options?.enabledDomains || DEFAULT_DOMAINS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if discovery is running
|
||||
*/
|
||||
public get running(): boolean {
|
||||
return this.isRunning;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all discovered HA instances
|
||||
*/
|
||||
public getInstances(): IHomeAssistantDiscoveredInstance[] {
|
||||
return Array.from(this.discoveredInstances.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connected protocol for an instance
|
||||
*/
|
||||
public getProtocol(instanceId: string): HomeAssistantProtocol | undefined {
|
||||
return this.connectedProtocols.get(instanceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all connected protocols
|
||||
*/
|
||||
public getProtocols(): Map<string, HomeAssistantProtocol> {
|
||||
return this.connectedProtocols;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all cached entities
|
||||
*/
|
||||
public getEntities(): IHomeAssistantEntity[] {
|
||||
return Array.from(this.entityCache.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get entities by domain
|
||||
*/
|
||||
public getEntitiesByDomain(domain: THomeAssistantDomain): IHomeAssistantEntity[] {
|
||||
return this.getEntities().filter((e) => e.entity_id.startsWith(`${domain}.`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get entities for a specific instance
|
||||
*/
|
||||
public getEntitiesForInstance(instanceId: string): IHomeAssistantEntity[] {
|
||||
const protocol = this.connectedProtocols.get(instanceId);
|
||||
if (!protocol) return [];
|
||||
return Array.from(protocol.entities.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Start mDNS discovery for Home Assistant instances
|
||||
*/
|
||||
public async startMdnsDiscovery(): Promise<void> {
|
||||
if (this.isRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.bonjour = new plugins.bonjourService.Bonjour();
|
||||
this.isRunning = true;
|
||||
|
||||
this.browser = this.bonjour.find({ type: HA_SERVICE_TYPE }, (service) => {
|
||||
this.handleInstanceFound(service);
|
||||
});
|
||||
|
||||
this.browser.on('down', (service) => {
|
||||
this.handleInstanceLost(service);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop mDNS discovery
|
||||
*/
|
||||
public async stopMdnsDiscovery(): Promise<void> {
|
||||
if (!this.isRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.browser) {
|
||||
this.browser.stop();
|
||||
this.browser = null;
|
||||
}
|
||||
|
||||
if (this.bonjour) {
|
||||
this.bonjour.destroy();
|
||||
this.bonjour = null;
|
||||
}
|
||||
|
||||
this.isRunning = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a manually configured HA instance
|
||||
*/
|
||||
public async addInstance(config: IHomeAssistantInstanceConfig): Promise<HomeAssistantProtocol> {
|
||||
const instanceId = this.generateInstanceId(config.host, config.port || 8123);
|
||||
|
||||
// Check if already connected
|
||||
if (this.connectedProtocols.has(instanceId)) {
|
||||
return this.connectedProtocols.get(instanceId)!;
|
||||
}
|
||||
|
||||
// Create protocol and connect
|
||||
const protocol = new HomeAssistantProtocol(config);
|
||||
|
||||
// Set up event handlers
|
||||
this.setupProtocolHandlers(protocol, instanceId);
|
||||
|
||||
// Connect
|
||||
await protocol.connect();
|
||||
|
||||
// Subscribe to state changes
|
||||
await protocol.subscribeToStateChanges();
|
||||
|
||||
// Cache entities
|
||||
const entities = await protocol.getStates();
|
||||
for (const entity of entities) {
|
||||
if (this.isEnabledDomain(entity.entity_id)) {
|
||||
const cacheKey = `${instanceId}:${entity.entity_id}`;
|
||||
this.entityCache.set(cacheKey, entity);
|
||||
this.emit('entity:found', entity);
|
||||
}
|
||||
}
|
||||
|
||||
// Store protocol
|
||||
this.connectedProtocols.set(instanceId, protocol);
|
||||
|
||||
// Also store as discovered instance
|
||||
this.discoveredInstances.set(instanceId, {
|
||||
id: instanceId,
|
||||
host: config.host,
|
||||
port: config.port || 8123,
|
||||
base_url: `http://${config.host}:${config.port || 8123}`,
|
||||
txtRecords: {},
|
||||
requires_api_password: true,
|
||||
friendlyName: config.friendlyName,
|
||||
});
|
||||
|
||||
return protocol;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an HA instance
|
||||
*/
|
||||
public async removeInstance(instanceId: string): Promise<void> {
|
||||
const protocol = this.connectedProtocols.get(instanceId);
|
||||
if (protocol) {
|
||||
await protocol.disconnect();
|
||||
this.connectedProtocols.delete(instanceId);
|
||||
}
|
||||
|
||||
this.discoveredInstances.delete(instanceId);
|
||||
|
||||
// Remove cached entities for this instance
|
||||
for (const key of this.entityCache.keys()) {
|
||||
if (key.startsWith(`${instanceId}:`)) {
|
||||
this.entityCache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
this.emit('instance:lost', instanceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all and cleanup
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
await this.stopMdnsDiscovery();
|
||||
|
||||
// Disconnect all protocols
|
||||
for (const [instanceId, protocol] of this.connectedProtocols) {
|
||||
await protocol.disconnect();
|
||||
}
|
||||
this.connectedProtocols.clear();
|
||||
this.discoveredInstances.clear();
|
||||
this.entityCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mDNS service found
|
||||
*/
|
||||
private handleInstanceFound(service: plugins.bonjourService.Service): void {
|
||||
const addresses = service.addresses ?? [];
|
||||
const address = addresses.find((a) => !a.includes(':')) ?? addresses[0] ?? service.host;
|
||||
|
||||
if (!address) {
|
||||
return;
|
||||
}
|
||||
|
||||
const instanceId = this.generateInstanceId(address, service.port);
|
||||
const txtRecords = this.parseTxtRecords(service.txt);
|
||||
|
||||
const instance: IHomeAssistantDiscoveredInstance = {
|
||||
id: instanceId,
|
||||
host: address,
|
||||
port: service.port,
|
||||
base_url: txtRecords['base_url'] || `http://${address}:${service.port}`,
|
||||
txtRecords,
|
||||
requires_api_password: txtRecords['requires_api_password'] === 'true',
|
||||
friendlyName: service.name,
|
||||
};
|
||||
|
||||
// Check if this is a new instance
|
||||
const existing = this.discoveredInstances.get(instanceId);
|
||||
if (!existing) {
|
||||
this.discoveredInstances.set(instanceId, instance);
|
||||
this.emit('instance:found', instance);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mDNS service lost
|
||||
*/
|
||||
private handleInstanceLost(service: plugins.bonjourService.Service): void {
|
||||
const addresses = service.addresses ?? [];
|
||||
const address = addresses.find((a) => !a.includes(':')) ?? addresses[0] ?? service.host;
|
||||
|
||||
if (!address) {
|
||||
return;
|
||||
}
|
||||
|
||||
const instanceId = this.generateInstanceId(address, service.port);
|
||||
|
||||
if (this.discoveredInstances.has(instanceId)) {
|
||||
// Don't remove if we have an active connection (manually added)
|
||||
if (!this.connectedProtocols.has(instanceId)) {
|
||||
this.discoveredInstances.delete(instanceId);
|
||||
}
|
||||
this.emit('instance:lost', instanceId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up event handlers for a protocol
|
||||
*/
|
||||
private setupProtocolHandlers(protocol: HomeAssistantProtocol, instanceId: string): void {
|
||||
protocol.on('state:changed', (event) => {
|
||||
const cacheKey = `${instanceId}:${event.entity_id}`;
|
||||
|
||||
if (event.new_state) {
|
||||
if (this.isEnabledDomain(event.entity_id)) {
|
||||
const existing = this.entityCache.has(cacheKey);
|
||||
this.entityCache.set(cacheKey, event.new_state);
|
||||
|
||||
if (existing) {
|
||||
this.emit('entity:updated', event.new_state);
|
||||
} else {
|
||||
this.emit('entity:found', event.new_state);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Entity removed
|
||||
if (this.entityCache.has(cacheKey)) {
|
||||
this.entityCache.delete(cacheKey);
|
||||
this.emit('entity:removed', event.entity_id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
protocol.on('disconnected', () => {
|
||||
// Clear cached entities for this instance on disconnect
|
||||
for (const key of this.entityCache.keys()) {
|
||||
if (key.startsWith(`${instanceId}:`)) {
|
||||
this.entityCache.delete(key);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
protocol.on('error', (error) => {
|
||||
this.emit('error', error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if entity domain is enabled
|
||||
*/
|
||||
private isEnabledDomain(entityId: string): boolean {
|
||||
const domain = entityId.split('.')[0] as THomeAssistantDomain;
|
||||
return this.enabledDomains.includes(domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique instance ID
|
||||
*/
|
||||
private generateInstanceId(host: string, port: number): string {
|
||||
return `ha:${host}:${port}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse TXT records from mDNS service
|
||||
*/
|
||||
private parseTxtRecords(txt: Record<string, unknown> | undefined): Record<string, string> {
|
||||
const records: Record<string, string> = {};
|
||||
|
||||
if (!txt) {
|
||||
return records;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(txt)) {
|
||||
if (typeof value === 'string') {
|
||||
records[key] = value;
|
||||
} else if (Buffer.isBuffer(value)) {
|
||||
records[key] = value.toString('utf-8');
|
||||
} else if (value !== undefined && value !== null) {
|
||||
records[key] = String(value);
|
||||
}
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe if a host has Home Assistant running
|
||||
*/
|
||||
public static async probe(
|
||||
host: string,
|
||||
port: number = 8123,
|
||||
secure: boolean = false,
|
||||
timeout: number = 5000
|
||||
): Promise<boolean> {
|
||||
return HomeAssistantProtocol.probe(host, port, secure, timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export { HA_SERVICE_TYPE };
|
||||
@@ -353,6 +353,324 @@ function parseScanSources(txtRecords: Record<string, string>): TScanSource[] {
|
||||
return sources.length > 0 ? sources : ['flatbed'];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Smart Home Factories
|
||||
// ============================================================================
|
||||
|
||||
import { SwitchFeature, type ISwitchFeatureOptions } from '../features/feature.switch.js';
|
||||
import { SensorFeature, type ISensorFeatureOptions } from '../features/feature.sensor.js';
|
||||
import { LightFeature, type ILightFeatureOptions } from '../features/feature.light.js';
|
||||
import { CoverFeature, type ICoverFeatureOptions } from '../features/feature.cover.js';
|
||||
import { LockFeature, type ILockFeatureOptions } from '../features/feature.lock.js';
|
||||
import { FanFeature, type IFanFeatureOptions } from '../features/feature.fan.js';
|
||||
import { ClimateFeature, type IClimateFeatureOptions } from '../features/feature.climate.js';
|
||||
import { CameraFeature, type ICameraFeatureOptions } from '../features/feature.camera.js';
|
||||
|
||||
import type {
|
||||
TSwitchProtocol,
|
||||
TSensorProtocol,
|
||||
TLightProtocol,
|
||||
TCoverProtocol,
|
||||
TLockProtocol,
|
||||
TFanProtocol,
|
||||
TClimateProtocol,
|
||||
TCameraProtocol,
|
||||
ISwitchProtocolClient,
|
||||
ISensorProtocolClient,
|
||||
ILightProtocolClient,
|
||||
ICoverProtocolClient,
|
||||
ILockProtocolClient,
|
||||
IFanProtocolClient,
|
||||
IClimateProtocolClient,
|
||||
ICameraProtocolClient,
|
||||
ILightCapabilities,
|
||||
ICoverCapabilities,
|
||||
IFanCapabilities,
|
||||
IClimateCapabilities,
|
||||
ICameraCapabilities,
|
||||
TSensorDeviceClass,
|
||||
TSensorStateClass,
|
||||
TCoverDeviceClass,
|
||||
} from '../interfaces/smarthome.interfaces.js';
|
||||
|
||||
// Smart Switch Factory
|
||||
export interface ISmartSwitchDiscoveryInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
port: number;
|
||||
entityId: string;
|
||||
protocol: TSwitchProtocol;
|
||||
protocolClient: ISwitchProtocolClient;
|
||||
deviceClass?: 'outlet' | 'switch';
|
||||
}
|
||||
|
||||
export function createSmartSwitch(
|
||||
info: ISmartSwitchDiscoveryInfo,
|
||||
retryOptions?: IRetryOptions
|
||||
): UniversalDevice {
|
||||
const device = new UniversalDevice(info.address, info.port, {
|
||||
name: info.name,
|
||||
retryOptions,
|
||||
});
|
||||
|
||||
(device as { id: string }).id = info.id;
|
||||
|
||||
const switchFeature = new SwitchFeature(device.getDeviceReference(), info.port, {
|
||||
protocol: info.protocol,
|
||||
entityId: info.entityId,
|
||||
protocolClient: info.protocolClient,
|
||||
deviceClass: info.deviceClass,
|
||||
});
|
||||
|
||||
device.addFeature(switchFeature);
|
||||
return device;
|
||||
}
|
||||
|
||||
// Smart Sensor Factory
|
||||
export interface ISmartSensorDiscoveryInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
port: number;
|
||||
entityId: string;
|
||||
protocol: TSensorProtocol;
|
||||
protocolClient: ISensorProtocolClient;
|
||||
deviceClass?: TSensorDeviceClass;
|
||||
stateClass?: TSensorStateClass;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export function createSmartSensor(
|
||||
info: ISmartSensorDiscoveryInfo,
|
||||
retryOptions?: IRetryOptions
|
||||
): UniversalDevice {
|
||||
const device = new UniversalDevice(info.address, info.port, {
|
||||
name: info.name,
|
||||
retryOptions,
|
||||
});
|
||||
|
||||
(device as { id: string }).id = info.id;
|
||||
|
||||
const sensorFeature = new SensorFeature(device.getDeviceReference(), info.port, {
|
||||
protocol: info.protocol,
|
||||
entityId: info.entityId,
|
||||
protocolClient: info.protocolClient,
|
||||
deviceClass: info.deviceClass,
|
||||
stateClass: info.stateClass,
|
||||
unit: info.unit,
|
||||
});
|
||||
|
||||
device.addFeature(sensorFeature);
|
||||
return device;
|
||||
}
|
||||
|
||||
// Smart Light Factory
|
||||
export interface ISmartLightDiscoveryInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
port: number;
|
||||
entityId: string;
|
||||
protocol: TLightProtocol;
|
||||
protocolClient: ILightProtocolClient;
|
||||
capabilities?: Partial<ILightCapabilities>;
|
||||
}
|
||||
|
||||
export function createSmartLight(
|
||||
info: ISmartLightDiscoveryInfo,
|
||||
retryOptions?: IRetryOptions
|
||||
): UniversalDevice {
|
||||
const device = new UniversalDevice(info.address, info.port, {
|
||||
name: info.name,
|
||||
retryOptions,
|
||||
});
|
||||
|
||||
(device as { id: string }).id = info.id;
|
||||
|
||||
const lightFeature = new LightFeature(device.getDeviceReference(), info.port, {
|
||||
protocol: info.protocol,
|
||||
entityId: info.entityId,
|
||||
protocolClient: info.protocolClient,
|
||||
capabilities: info.capabilities,
|
||||
});
|
||||
|
||||
device.addFeature(lightFeature);
|
||||
return device;
|
||||
}
|
||||
|
||||
// Smart Cover Factory
|
||||
export interface ISmartCoverDiscoveryInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
port: number;
|
||||
entityId: string;
|
||||
protocol: TCoverProtocol;
|
||||
protocolClient: ICoverProtocolClient;
|
||||
deviceClass?: TCoverDeviceClass;
|
||||
capabilities?: Partial<ICoverCapabilities>;
|
||||
}
|
||||
|
||||
export function createSmartCover(
|
||||
info: ISmartCoverDiscoveryInfo,
|
||||
retryOptions?: IRetryOptions
|
||||
): UniversalDevice {
|
||||
const device = new UniversalDevice(info.address, info.port, {
|
||||
name: info.name,
|
||||
retryOptions,
|
||||
});
|
||||
|
||||
(device as { id: string }).id = info.id;
|
||||
|
||||
const coverFeature = new CoverFeature(device.getDeviceReference(), info.port, {
|
||||
protocol: info.protocol,
|
||||
entityId: info.entityId,
|
||||
protocolClient: info.protocolClient,
|
||||
deviceClass: info.deviceClass,
|
||||
capabilities: info.capabilities,
|
||||
});
|
||||
|
||||
device.addFeature(coverFeature);
|
||||
return device;
|
||||
}
|
||||
|
||||
// Smart Lock Factory
|
||||
export interface ISmartLockDiscoveryInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
port: number;
|
||||
entityId: string;
|
||||
protocol: TLockProtocol;
|
||||
protocolClient: ILockProtocolClient;
|
||||
supportsOpen?: boolean;
|
||||
}
|
||||
|
||||
export function createSmartLock(
|
||||
info: ISmartLockDiscoveryInfo,
|
||||
retryOptions?: IRetryOptions
|
||||
): UniversalDevice {
|
||||
const device = new UniversalDevice(info.address, info.port, {
|
||||
name: info.name,
|
||||
retryOptions,
|
||||
});
|
||||
|
||||
(device as { id: string }).id = info.id;
|
||||
|
||||
const lockFeature = new LockFeature(device.getDeviceReference(), info.port, {
|
||||
protocol: info.protocol,
|
||||
entityId: info.entityId,
|
||||
protocolClient: info.protocolClient,
|
||||
supportsOpen: info.supportsOpen,
|
||||
});
|
||||
|
||||
device.addFeature(lockFeature);
|
||||
return device;
|
||||
}
|
||||
|
||||
// Smart Fan Factory
|
||||
export interface ISmartFanDiscoveryInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
port: number;
|
||||
entityId: string;
|
||||
protocol: TFanProtocol;
|
||||
protocolClient: IFanProtocolClient;
|
||||
capabilities?: Partial<IFanCapabilities>;
|
||||
}
|
||||
|
||||
export function createSmartFan(
|
||||
info: ISmartFanDiscoveryInfo,
|
||||
retryOptions?: IRetryOptions
|
||||
): UniversalDevice {
|
||||
const device = new UniversalDevice(info.address, info.port, {
|
||||
name: info.name,
|
||||
retryOptions,
|
||||
});
|
||||
|
||||
(device as { id: string }).id = info.id;
|
||||
|
||||
const fanFeature = new FanFeature(device.getDeviceReference(), info.port, {
|
||||
protocol: info.protocol,
|
||||
entityId: info.entityId,
|
||||
protocolClient: info.protocolClient,
|
||||
capabilities: info.capabilities,
|
||||
});
|
||||
|
||||
device.addFeature(fanFeature);
|
||||
return device;
|
||||
}
|
||||
|
||||
// Smart Climate Factory
|
||||
export interface ISmartClimateDiscoveryInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
port: number;
|
||||
entityId: string;
|
||||
protocol: TClimateProtocol;
|
||||
protocolClient: IClimateProtocolClient;
|
||||
capabilities?: Partial<IClimateCapabilities>;
|
||||
}
|
||||
|
||||
export function createSmartClimate(
|
||||
info: ISmartClimateDiscoveryInfo,
|
||||
retryOptions?: IRetryOptions
|
||||
): UniversalDevice {
|
||||
const device = new UniversalDevice(info.address, info.port, {
|
||||
name: info.name,
|
||||
retryOptions,
|
||||
});
|
||||
|
||||
(device as { id: string }).id = info.id;
|
||||
|
||||
const climateFeature = new ClimateFeature(device.getDeviceReference(), info.port, {
|
||||
protocol: info.protocol,
|
||||
entityId: info.entityId,
|
||||
protocolClient: info.protocolClient,
|
||||
capabilities: info.capabilities,
|
||||
});
|
||||
|
||||
device.addFeature(climateFeature);
|
||||
return device;
|
||||
}
|
||||
|
||||
// Smart Camera Factory
|
||||
export interface ISmartCameraDiscoveryInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
port: number;
|
||||
entityId: string;
|
||||
protocol: TCameraProtocol;
|
||||
protocolClient: ICameraProtocolClient;
|
||||
capabilities?: Partial<ICameraCapabilities>;
|
||||
}
|
||||
|
||||
export function createSmartCamera(
|
||||
info: ISmartCameraDiscoveryInfo,
|
||||
retryOptions?: IRetryOptions
|
||||
): UniversalDevice {
|
||||
const device = new UniversalDevice(info.address, info.port, {
|
||||
name: info.name,
|
||||
retryOptions,
|
||||
});
|
||||
|
||||
(device as { id: string }).id = info.id;
|
||||
|
||||
const cameraFeature = new CameraFeature(device.getDeviceReference(), info.port, {
|
||||
protocol: info.protocol,
|
||||
entityId: info.entityId,
|
||||
protocolClient: info.protocolClient,
|
||||
capabilities: info.capabilities,
|
||||
});
|
||||
|
||||
device.addFeature(cameraFeature);
|
||||
return device;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Exports
|
||||
// ============================================================================
|
||||
@@ -366,4 +684,13 @@ export {
|
||||
VolumeFeature,
|
||||
PowerFeature,
|
||||
SnmpFeature,
|
||||
// Smart home features
|
||||
SwitchFeature,
|
||||
SensorFeature,
|
||||
LightFeature,
|
||||
CoverFeature,
|
||||
LockFeature,
|
||||
FanFeature,
|
||||
ClimateFeature,
|
||||
CameraFeature,
|
||||
};
|
||||
|
||||
214
ts/features/feature.camera.ts
Normal file
214
ts/features/feature.camera.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Camera Feature
|
||||
* Provides control for smart cameras (snapshots, streams)
|
||||
*/
|
||||
|
||||
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
||||
import type { IFeatureOptions } from '../interfaces/feature.interfaces.js';
|
||||
import type {
|
||||
TCameraProtocol,
|
||||
ICameraCapabilities,
|
||||
ICameraState,
|
||||
ICameraFeatureInfo,
|
||||
ICameraProtocolClient,
|
||||
} from '../interfaces/smarthome.interfaces.js';
|
||||
|
||||
/**
|
||||
* Options for creating a CameraFeature
|
||||
*/
|
||||
export interface ICameraFeatureOptions extends IFeatureOptions {
|
||||
/** Protocol type */
|
||||
protocol: TCameraProtocol;
|
||||
/** Entity ID (for Home Assistant) */
|
||||
entityId: string;
|
||||
/** Protocol client for the camera */
|
||||
protocolClient: ICameraProtocolClient;
|
||||
/** Camera capabilities */
|
||||
capabilities?: Partial<ICameraCapabilities>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Camera Feature - snapshot and stream access
|
||||
*
|
||||
* Protocol-agnostic: works with Home Assistant, ONVIF, RTSP, etc.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const camera = device.getFeature<CameraFeature>('camera');
|
||||
* if (camera) {
|
||||
* const snapshot = await camera.getSnapshot();
|
||||
* const streamUrl = await camera.getStreamUrl();
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class CameraFeature extends Feature {
|
||||
public readonly type = 'camera' as const;
|
||||
public readonly protocol: TCameraProtocol;
|
||||
|
||||
/** Entity ID (e.g., "camera.front_door") */
|
||||
public readonly entityId: string;
|
||||
|
||||
/** Capabilities */
|
||||
public readonly capabilities: ICameraCapabilities;
|
||||
|
||||
/** Current state */
|
||||
protected _isRecording: boolean = false;
|
||||
protected _isStreaming: boolean = false;
|
||||
protected _motionDetected: boolean = false;
|
||||
|
||||
/** Protocol client for the camera */
|
||||
private protocolClient: ICameraProtocolClient;
|
||||
|
||||
constructor(
|
||||
device: TDeviceReference,
|
||||
port: number,
|
||||
options: ICameraFeatureOptions
|
||||
) {
|
||||
super(device, port, options);
|
||||
this.protocol = options.protocol;
|
||||
this.entityId = options.entityId;
|
||||
this.protocolClient = options.protocolClient;
|
||||
|
||||
this.capabilities = {
|
||||
supportsStream: options.capabilities?.supportsStream ?? true,
|
||||
supportsPtz: options.capabilities?.supportsPtz ?? false,
|
||||
supportsSnapshot: options.capabilities?.supportsSnapshot ?? true,
|
||||
supportsMotionDetection: options.capabilities?.supportsMotionDetection ?? false,
|
||||
frontendStreamType: options.capabilities?.frontendStreamType,
|
||||
streamUrl: options.capabilities?.streamUrl,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Properties
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Check if recording (cached)
|
||||
*/
|
||||
public get isRecording(): boolean {
|
||||
return this._isRecording;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if streaming (cached)
|
||||
*/
|
||||
public get isStreaming(): boolean {
|
||||
return this._isStreaming;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if motion detected (cached)
|
||||
*/
|
||||
public get motionDetected(): boolean {
|
||||
return this._motionDetected;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Connection
|
||||
// ============================================================================
|
||||
|
||||
protected async doConnect(): Promise<void> {
|
||||
try {
|
||||
const state = await this.protocolClient.getState(this.entityId);
|
||||
this.updateStateInternal(state);
|
||||
} catch {
|
||||
// Ignore errors fetching initial state
|
||||
}
|
||||
}
|
||||
|
||||
protected async doDisconnect(): Promise<void> {
|
||||
// Nothing to disconnect
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Camera Access
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get a snapshot image from the camera
|
||||
* @returns Buffer containing image data
|
||||
*/
|
||||
public async getSnapshot(): Promise<Buffer> {
|
||||
if (!this.capabilities.supportsSnapshot) {
|
||||
throw new Error('Camera does not support snapshots');
|
||||
}
|
||||
|
||||
return this.protocolClient.getSnapshot(this.entityId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get snapshot URL
|
||||
* @returns URL for the snapshot image
|
||||
*/
|
||||
public async getSnapshotUrl(): Promise<string> {
|
||||
if (!this.capabilities.supportsSnapshot) {
|
||||
throw new Error('Camera does not support snapshots');
|
||||
}
|
||||
|
||||
return this.protocolClient.getSnapshotUrl(this.entityId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stream URL
|
||||
* @returns URL for the video stream
|
||||
*/
|
||||
public async getStreamUrl(): Promise<string> {
|
||||
if (!this.capabilities.supportsStream) {
|
||||
throw new Error('Camera does not support streaming');
|
||||
}
|
||||
|
||||
return this.protocolClient.getStreamUrl(this.entityId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current state as object
|
||||
*/
|
||||
public getState(): ICameraState {
|
||||
return {
|
||||
isRecording: this._isRecording,
|
||||
isStreaming: this._isStreaming,
|
||||
motionDetected: this._motionDetected,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh state from the device
|
||||
*/
|
||||
public async refreshState(): Promise<ICameraState> {
|
||||
const state = await this.protocolClient.getState(this.entityId);
|
||||
this.updateStateInternal(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update state from external source
|
||||
*/
|
||||
public updateState(state: ICameraState): void {
|
||||
this.updateStateInternal(state);
|
||||
this.emit('state:changed', state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal state update
|
||||
*/
|
||||
private updateStateInternal(state: ICameraState): void {
|
||||
this._isRecording = state.isRecording;
|
||||
this._isStreaming = state.isStreaming;
|
||||
this._motionDetected = state.motionDetected;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Feature Info
|
||||
// ============================================================================
|
||||
|
||||
public getFeatureInfo(): ICameraFeatureInfo {
|
||||
return {
|
||||
...this.getBaseFeatureInfo(),
|
||||
type: 'camera',
|
||||
protocol: this.protocol,
|
||||
capabilities: this.capabilities,
|
||||
currentState: this.getState(),
|
||||
};
|
||||
}
|
||||
}
|
||||
407
ts/features/feature.climate.ts
Normal file
407
ts/features/feature.climate.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
/**
|
||||
* Climate Feature
|
||||
* Provides control for thermostats and HVAC systems
|
||||
*/
|
||||
|
||||
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
||||
import type { IFeatureOptions } from '../interfaces/feature.interfaces.js';
|
||||
import type {
|
||||
TClimateProtocol,
|
||||
THvacMode,
|
||||
THvacAction,
|
||||
IClimateCapabilities,
|
||||
IClimateState,
|
||||
IClimateFeatureInfo,
|
||||
IClimateProtocolClient,
|
||||
} from '../interfaces/smarthome.interfaces.js';
|
||||
|
||||
/**
|
||||
* Options for creating a ClimateFeature
|
||||
*/
|
||||
export interface IClimateFeatureOptions extends IFeatureOptions {
|
||||
/** Protocol type */
|
||||
protocol: TClimateProtocol;
|
||||
/** Entity ID (for Home Assistant) */
|
||||
entityId: string;
|
||||
/** Protocol client for controlling the climate device */
|
||||
protocolClient: IClimateProtocolClient;
|
||||
/** Climate capabilities */
|
||||
capabilities?: Partial<IClimateCapabilities>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Climate Feature - thermostat and HVAC control
|
||||
*
|
||||
* Protocol-agnostic: works with Home Assistant, Nest, Ecobee, MQTT, etc.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const climate = device.getFeature<ClimateFeature>('climate');
|
||||
* if (climate) {
|
||||
* await climate.setHvacMode('heat');
|
||||
* await climate.setTargetTemp(21);
|
||||
* console.log(`Current: ${climate.currentTemp}°C, Target: ${climate.targetTemp}°C`);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class ClimateFeature extends Feature {
|
||||
public readonly type = 'climate' as const;
|
||||
public readonly protocol: TClimateProtocol;
|
||||
|
||||
/** Entity ID (e.g., "climate.living_room") */
|
||||
public readonly entityId: string;
|
||||
|
||||
/** Capabilities */
|
||||
public readonly capabilities: IClimateCapabilities;
|
||||
|
||||
/** Current state */
|
||||
protected _currentTemp?: number;
|
||||
protected _targetTemp?: number;
|
||||
protected _targetTempHigh?: number;
|
||||
protected _targetTempLow?: number;
|
||||
protected _hvacMode: THvacMode = 'off';
|
||||
protected _hvacAction?: THvacAction;
|
||||
protected _presetMode?: string;
|
||||
protected _fanMode?: string;
|
||||
protected _swingMode?: string;
|
||||
protected _humidity?: number;
|
||||
protected _targetHumidity?: number;
|
||||
protected _auxHeat?: boolean;
|
||||
|
||||
/** Protocol client for controlling the climate device */
|
||||
private protocolClient: IClimateProtocolClient;
|
||||
|
||||
constructor(
|
||||
device: TDeviceReference,
|
||||
port: number,
|
||||
options: IClimateFeatureOptions
|
||||
) {
|
||||
super(device, port, options);
|
||||
this.protocol = options.protocol;
|
||||
this.entityId = options.entityId;
|
||||
this.protocolClient = options.protocolClient;
|
||||
|
||||
this.capabilities = {
|
||||
hvacModes: options.capabilities?.hvacModes ?? ['off', 'heat', 'cool', 'auto'],
|
||||
presetModes: options.capabilities?.presetModes,
|
||||
fanModes: options.capabilities?.fanModes,
|
||||
swingModes: options.capabilities?.swingModes,
|
||||
supportsTargetTemp: options.capabilities?.supportsTargetTemp ?? true,
|
||||
supportsTargetTempRange: options.capabilities?.supportsTargetTempRange ?? false,
|
||||
supportsHumidity: options.capabilities?.supportsHumidity ?? false,
|
||||
supportsAuxHeat: options.capabilities?.supportsAuxHeat ?? false,
|
||||
minTemp: options.capabilities?.minTemp ?? 7,
|
||||
maxTemp: options.capabilities?.maxTemp ?? 35,
|
||||
tempStep: options.capabilities?.tempStep ?? 0.5,
|
||||
minHumidity: options.capabilities?.minHumidity,
|
||||
maxHumidity: options.capabilities?.maxHumidity,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Properties
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get current temperature (cached)
|
||||
*/
|
||||
public get currentTemp(): number | undefined {
|
||||
return this._currentTemp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get target temperature (cached)
|
||||
*/
|
||||
public get targetTemp(): number | undefined {
|
||||
return this._targetTemp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get target temperature high (for heat_cool mode, cached)
|
||||
*/
|
||||
public get targetTempHigh(): number | undefined {
|
||||
return this._targetTempHigh;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get target temperature low (for heat_cool mode, cached)
|
||||
*/
|
||||
public get targetTempLow(): number | undefined {
|
||||
return this._targetTempLow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current HVAC mode (cached)
|
||||
*/
|
||||
public get hvacMode(): THvacMode {
|
||||
return this._hvacMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current HVAC action (cached)
|
||||
*/
|
||||
public get hvacAction(): THvacAction | undefined {
|
||||
return this._hvacAction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current preset mode (cached)
|
||||
*/
|
||||
public get presetMode(): string | undefined {
|
||||
return this._presetMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current fan mode (cached)
|
||||
*/
|
||||
public get fanMode(): string | undefined {
|
||||
return this._fanMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current swing mode (cached)
|
||||
*/
|
||||
public get swingMode(): string | undefined {
|
||||
return this._swingMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current humidity (cached)
|
||||
*/
|
||||
public get humidity(): number | undefined {
|
||||
return this._humidity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get target humidity (cached)
|
||||
*/
|
||||
public get targetHumidity(): number | undefined {
|
||||
return this._targetHumidity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get aux heat state (cached)
|
||||
*/
|
||||
public get auxHeat(): boolean | undefined {
|
||||
return this._auxHeat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available HVAC modes
|
||||
*/
|
||||
public get hvacModes(): THvacMode[] {
|
||||
return this.capabilities.hvacModes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available preset modes
|
||||
*/
|
||||
public get presetModes(): string[] | undefined {
|
||||
return this.capabilities.presetModes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available fan modes
|
||||
*/
|
||||
public get fanModes(): string[] | undefined {
|
||||
return this.capabilities.fanModes;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Connection
|
||||
// ============================================================================
|
||||
|
||||
protected async doConnect(): Promise<void> {
|
||||
try {
|
||||
const state = await this.protocolClient.getState(this.entityId);
|
||||
this.updateStateInternal(state);
|
||||
} catch {
|
||||
// Ignore errors fetching initial state
|
||||
}
|
||||
}
|
||||
|
||||
protected async doDisconnect(): Promise<void> {
|
||||
// Nothing to disconnect
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Climate Control
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Set HVAC mode
|
||||
* @param mode HVAC mode (off, heat, cool, etc.)
|
||||
*/
|
||||
public async setHvacMode(mode: THvacMode): Promise<void> {
|
||||
if (!this.capabilities.hvacModes.includes(mode)) {
|
||||
throw new Error(`HVAC mode ${mode} not supported`);
|
||||
}
|
||||
|
||||
await this.protocolClient.setHvacMode(this.entityId, mode);
|
||||
this._hvacMode = mode;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set target temperature
|
||||
* @param temp Target temperature
|
||||
*/
|
||||
public async setTargetTemp(temp: number): Promise<void> {
|
||||
if (!this.capabilities.supportsTargetTemp) {
|
||||
throw new Error('Climate device does not support target temperature');
|
||||
}
|
||||
|
||||
const clamped = Math.max(
|
||||
this.capabilities.minTemp,
|
||||
Math.min(this.capabilities.maxTemp, temp)
|
||||
);
|
||||
|
||||
await this.protocolClient.setTargetTemp(this.entityId, clamped);
|
||||
this._targetTemp = clamped;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set target temperature range (for heat_cool mode)
|
||||
* @param low Low temperature
|
||||
* @param high High temperature
|
||||
*/
|
||||
public async setTargetTempRange(low: number, high: number): Promise<void> {
|
||||
if (!this.capabilities.supportsTargetTempRange) {
|
||||
throw new Error('Climate device does not support temperature range');
|
||||
}
|
||||
|
||||
const clampedLow = Math.max(this.capabilities.minTemp, Math.min(this.capabilities.maxTemp, low));
|
||||
const clampedHigh = Math.max(this.capabilities.minTemp, Math.min(this.capabilities.maxTemp, high));
|
||||
|
||||
await this.protocolClient.setTargetTempRange(this.entityId, clampedLow, clampedHigh);
|
||||
this._targetTempLow = clampedLow;
|
||||
this._targetTempHigh = clampedHigh;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set preset mode
|
||||
* @param preset Preset mode name
|
||||
*/
|
||||
public async setPresetMode(preset: string): Promise<void> {
|
||||
if (!this.capabilities.presetModes?.includes(preset)) {
|
||||
throw new Error(`Preset mode ${preset} not supported`);
|
||||
}
|
||||
|
||||
await this.protocolClient.setPresetMode(this.entityId, preset);
|
||||
this._presetMode = preset;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set fan mode
|
||||
* @param mode Fan mode name
|
||||
*/
|
||||
public async setFanMode(mode: string): Promise<void> {
|
||||
if (!this.capabilities.fanModes?.includes(mode)) {
|
||||
throw new Error(`Fan mode ${mode} not supported`);
|
||||
}
|
||||
|
||||
await this.protocolClient.setFanMode(this.entityId, mode);
|
||||
this._fanMode = mode;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set swing mode
|
||||
* @param mode Swing mode name
|
||||
*/
|
||||
public async setSwingMode(mode: string): Promise<void> {
|
||||
if (!this.capabilities.swingModes?.includes(mode)) {
|
||||
throw new Error(`Swing mode ${mode} not supported`);
|
||||
}
|
||||
|
||||
await this.protocolClient.setSwingMode(this.entityId, mode);
|
||||
this._swingMode = mode;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set aux heat
|
||||
* @param enabled Whether aux heat is enabled
|
||||
*/
|
||||
public async setAuxHeat(enabled: boolean): Promise<void> {
|
||||
if (!this.capabilities.supportsAuxHeat) {
|
||||
throw new Error('Climate device does not support aux heat');
|
||||
}
|
||||
|
||||
await this.protocolClient.setAuxHeat(this.entityId, enabled);
|
||||
this._auxHeat = enabled;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current state as object
|
||||
*/
|
||||
public getState(): IClimateState {
|
||||
return {
|
||||
currentTemp: this._currentTemp,
|
||||
targetTemp: this._targetTemp,
|
||||
targetTempHigh: this._targetTempHigh,
|
||||
targetTempLow: this._targetTempLow,
|
||||
hvacMode: this._hvacMode,
|
||||
hvacAction: this._hvacAction,
|
||||
presetMode: this._presetMode,
|
||||
fanMode: this._fanMode,
|
||||
swingMode: this._swingMode,
|
||||
humidity: this._humidity,
|
||||
targetHumidity: this._targetHumidity,
|
||||
auxHeat: this._auxHeat,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh state from the device
|
||||
*/
|
||||
public async refreshState(): Promise<IClimateState> {
|
||||
const state = await this.protocolClient.getState(this.entityId);
|
||||
this.updateStateInternal(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update state from external source
|
||||
*/
|
||||
public updateState(state: IClimateState): void {
|
||||
this.updateStateInternal(state);
|
||||
this.emit('state:changed', state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal state update
|
||||
*/
|
||||
private updateStateInternal(state: IClimateState): void {
|
||||
this._currentTemp = state.currentTemp;
|
||||
this._targetTemp = state.targetTemp;
|
||||
this._targetTempHigh = state.targetTempHigh;
|
||||
this._targetTempLow = state.targetTempLow;
|
||||
this._hvacMode = state.hvacMode;
|
||||
this._hvacAction = state.hvacAction;
|
||||
this._presetMode = state.presetMode;
|
||||
this._fanMode = state.fanMode;
|
||||
this._swingMode = state.swingMode;
|
||||
this._humidity = state.humidity;
|
||||
this._targetHumidity = state.targetHumidity;
|
||||
this._auxHeat = state.auxHeat;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Feature Info
|
||||
// ============================================================================
|
||||
|
||||
public getFeatureInfo(): IClimateFeatureInfo {
|
||||
return {
|
||||
...this.getBaseFeatureInfo(),
|
||||
type: 'climate',
|
||||
protocol: this.protocol,
|
||||
capabilities: this.capabilities,
|
||||
currentState: this.getState(),
|
||||
};
|
||||
}
|
||||
}
|
||||
278
ts/features/feature.cover.ts
Normal file
278
ts/features/feature.cover.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* Cover Feature
|
||||
* Provides control for covers, blinds, garage doors, etc.
|
||||
*/
|
||||
|
||||
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
||||
import type { IFeatureOptions } from '../interfaces/feature.interfaces.js';
|
||||
import type {
|
||||
TCoverProtocol,
|
||||
TCoverDeviceClass,
|
||||
TCoverState,
|
||||
ICoverCapabilities,
|
||||
ICoverStateInfo,
|
||||
ICoverFeatureInfo,
|
||||
ICoverProtocolClient,
|
||||
} from '../interfaces/smarthome.interfaces.js';
|
||||
|
||||
/**
|
||||
* Options for creating a CoverFeature
|
||||
*/
|
||||
export interface ICoverFeatureOptions extends IFeatureOptions {
|
||||
/** Protocol type */
|
||||
protocol: TCoverProtocol;
|
||||
/** Entity ID (for Home Assistant) */
|
||||
entityId: string;
|
||||
/** Protocol client for controlling the cover */
|
||||
protocolClient: ICoverProtocolClient;
|
||||
/** Device class */
|
||||
deviceClass?: TCoverDeviceClass;
|
||||
/** Cover capabilities */
|
||||
capabilities?: Partial<ICoverCapabilities>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cover Feature - control for blinds, garage doors, etc.
|
||||
*
|
||||
* Protocol-agnostic: works with Home Assistant, MQTT, Somfy, etc.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const cover = device.getFeature<CoverFeature>('cover');
|
||||
* if (cover) {
|
||||
* await cover.open();
|
||||
* await cover.setPosition(50); // 50% open
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class CoverFeature extends Feature {
|
||||
public readonly type = 'cover' as const;
|
||||
public readonly protocol: TCoverProtocol;
|
||||
|
||||
/** Entity ID (e.g., "cover.garage_door") */
|
||||
public readonly entityId: string;
|
||||
|
||||
/** Capabilities */
|
||||
public readonly capabilities: ICoverCapabilities;
|
||||
|
||||
/** Current cover state (not connection state) */
|
||||
protected _coverState: TCoverState = 'unknown';
|
||||
protected _position?: number;
|
||||
protected _tiltPosition?: number;
|
||||
|
||||
/** Protocol client for controlling the cover */
|
||||
private protocolClient: ICoverProtocolClient;
|
||||
|
||||
constructor(
|
||||
device: TDeviceReference,
|
||||
port: number,
|
||||
options: ICoverFeatureOptions
|
||||
) {
|
||||
super(device, port, options);
|
||||
this.protocol = options.protocol;
|
||||
this.entityId = options.entityId;
|
||||
this.protocolClient = options.protocolClient;
|
||||
|
||||
this.capabilities = {
|
||||
deviceClass: options.deviceClass,
|
||||
supportsOpen: options.capabilities?.supportsOpen ?? true,
|
||||
supportsClose: options.capabilities?.supportsClose ?? true,
|
||||
supportsStop: options.capabilities?.supportsStop ?? true,
|
||||
supportsPosition: options.capabilities?.supportsPosition ?? false,
|
||||
supportsTilt: options.capabilities?.supportsTilt ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Properties
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get current state (cached)
|
||||
*/
|
||||
public get coverState(): TCoverState {
|
||||
return this._coverState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current position 0-100 (cached)
|
||||
* 0 = closed, 100 = fully open
|
||||
*/
|
||||
public get position(): number | undefined {
|
||||
return this._position;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current tilt position 0-100 (cached)
|
||||
*/
|
||||
public get tiltPosition(): number | undefined {
|
||||
return this._tiltPosition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cover is open
|
||||
*/
|
||||
public get isOpen(): boolean {
|
||||
return this._coverState === 'open';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cover is closed
|
||||
*/
|
||||
public get isClosed(): boolean {
|
||||
return this._coverState === 'closed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cover is opening
|
||||
*/
|
||||
public get isOpening(): boolean {
|
||||
return this._coverState === 'opening';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cover is closing
|
||||
*/
|
||||
public get isClosing(): boolean {
|
||||
return this._coverState === 'closing';
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Connection
|
||||
// ============================================================================
|
||||
|
||||
protected async doConnect(): Promise<void> {
|
||||
try {
|
||||
const state = await this.protocolClient.getState(this.entityId);
|
||||
this.updateStateInternal(state);
|
||||
} catch {
|
||||
// Ignore errors fetching initial state
|
||||
}
|
||||
}
|
||||
|
||||
protected async doDisconnect(): Promise<void> {
|
||||
// Nothing to disconnect
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Cover Control
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Open the cover
|
||||
*/
|
||||
public async open(): Promise<void> {
|
||||
if (!this.capabilities.supportsOpen) {
|
||||
throw new Error('Cover does not support open');
|
||||
}
|
||||
await this.protocolClient.open(this.entityId);
|
||||
this._coverState = 'opening';
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the cover
|
||||
*/
|
||||
public async close(): Promise<void> {
|
||||
if (!this.capabilities.supportsClose) {
|
||||
throw new Error('Cover does not support close');
|
||||
}
|
||||
await this.protocolClient.close(this.entityId);
|
||||
this._coverState = 'closing';
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the cover
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
if (!this.capabilities.supportsStop) {
|
||||
throw new Error('Cover does not support stop');
|
||||
}
|
||||
await this.protocolClient.stop(this.entityId);
|
||||
this._coverState = 'stopped';
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set cover position
|
||||
* @param position Position 0-100 (0 = closed, 100 = open)
|
||||
*/
|
||||
public async setPosition(position: number): Promise<void> {
|
||||
if (!this.capabilities.supportsPosition) {
|
||||
throw new Error('Cover does not support position control');
|
||||
}
|
||||
|
||||
const clamped = Math.max(0, Math.min(100, Math.round(position)));
|
||||
await this.protocolClient.setPosition(this.entityId, clamped);
|
||||
this._position = clamped;
|
||||
this._coverState = clamped === 0 ? 'closed' : clamped === 100 ? 'open' : 'stopped';
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set tilt position
|
||||
* @param position Tilt position 0-100
|
||||
*/
|
||||
public async setTiltPosition(position: number): Promise<void> {
|
||||
if (!this.capabilities.supportsTilt) {
|
||||
throw new Error('Cover does not support tilt control');
|
||||
}
|
||||
|
||||
const clamped = Math.max(0, Math.min(100, Math.round(position)));
|
||||
await this.protocolClient.setTiltPosition(this.entityId, clamped);
|
||||
this._tiltPosition = clamped;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current state as object
|
||||
*/
|
||||
public getState(): ICoverStateInfo {
|
||||
return {
|
||||
state: this._coverState,
|
||||
position: this._position,
|
||||
tiltPosition: this._tiltPosition,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh state from the device
|
||||
*/
|
||||
public async refreshState(): Promise<ICoverStateInfo> {
|
||||
const state = await this.protocolClient.getState(this.entityId);
|
||||
this.updateStateInternal(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update state from external source
|
||||
*/
|
||||
public updateState(state: ICoverStateInfo): void {
|
||||
this.updateStateInternal(state);
|
||||
this.emit('state:changed', state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal state update
|
||||
*/
|
||||
private updateStateInternal(state: ICoverStateInfo): void {
|
||||
this._coverState = state.state;
|
||||
this._position = state.position;
|
||||
this._tiltPosition = state.tiltPosition;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Feature Info
|
||||
// ============================================================================
|
||||
|
||||
public getFeatureInfo(): ICoverFeatureInfo {
|
||||
return {
|
||||
...this.getBaseFeatureInfo(),
|
||||
type: 'cover',
|
||||
protocol: this.protocol,
|
||||
capabilities: this.capabilities,
|
||||
currentState: this.getState(),
|
||||
};
|
||||
}
|
||||
}
|
||||
296
ts/features/feature.fan.ts
Normal file
296
ts/features/feature.fan.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* Fan Feature
|
||||
* Provides control for fans (speed, oscillation, direction)
|
||||
*/
|
||||
|
||||
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
||||
import type { IFeatureOptions } from '../interfaces/feature.interfaces.js';
|
||||
import type {
|
||||
TFanProtocol,
|
||||
TFanDirection,
|
||||
IFanCapabilities,
|
||||
IFanState,
|
||||
IFanFeatureInfo,
|
||||
IFanProtocolClient,
|
||||
} from '../interfaces/smarthome.interfaces.js';
|
||||
|
||||
/**
|
||||
* Options for creating a FanFeature
|
||||
*/
|
||||
export interface IFanFeatureOptions extends IFeatureOptions {
|
||||
/** Protocol type */
|
||||
protocol: TFanProtocol;
|
||||
/** Entity ID (for Home Assistant) */
|
||||
entityId: string;
|
||||
/** Protocol client for controlling the fan */
|
||||
protocolClient: IFanProtocolClient;
|
||||
/** Fan capabilities */
|
||||
capabilities?: Partial<IFanCapabilities>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fan Feature - speed, oscillation, and direction control
|
||||
*
|
||||
* Protocol-agnostic: works with Home Assistant, MQTT, Bond, etc.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const fan = device.getFeature<FanFeature>('fan');
|
||||
* if (fan) {
|
||||
* await fan.turnOn(75); // 75% speed
|
||||
* await fan.setOscillating(true);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class FanFeature extends Feature {
|
||||
public readonly type = 'fan' as const;
|
||||
public readonly protocol: TFanProtocol;
|
||||
|
||||
/** Entity ID (e.g., "fan.bedroom") */
|
||||
public readonly entityId: string;
|
||||
|
||||
/** Capabilities */
|
||||
public readonly capabilities: IFanCapabilities;
|
||||
|
||||
/** Current state */
|
||||
protected _isOn: boolean = false;
|
||||
protected _percentage?: number;
|
||||
protected _presetMode?: string;
|
||||
protected _oscillating?: boolean;
|
||||
protected _direction?: TFanDirection;
|
||||
|
||||
/** Protocol client for controlling the fan */
|
||||
private protocolClient: IFanProtocolClient;
|
||||
|
||||
constructor(
|
||||
device: TDeviceReference,
|
||||
port: number,
|
||||
options: IFanFeatureOptions
|
||||
) {
|
||||
super(device, port, options);
|
||||
this.protocol = options.protocol;
|
||||
this.entityId = options.entityId;
|
||||
this.protocolClient = options.protocolClient;
|
||||
|
||||
this.capabilities = {
|
||||
supportsSpeed: options.capabilities?.supportsSpeed ?? true,
|
||||
supportsOscillate: options.capabilities?.supportsOscillate ?? false,
|
||||
supportsDirection: options.capabilities?.supportsDirection ?? false,
|
||||
supportsPresetModes: options.capabilities?.supportsPresetModes ?? false,
|
||||
presetModes: options.capabilities?.presetModes,
|
||||
speedCount: options.capabilities?.speedCount,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Properties
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get current on/off state (cached)
|
||||
*/
|
||||
public get isOn(): boolean {
|
||||
return this._isOn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current speed percentage 0-100 (cached)
|
||||
*/
|
||||
public get percentage(): number | undefined {
|
||||
return this._percentage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current preset mode (cached)
|
||||
*/
|
||||
public get presetMode(): string | undefined {
|
||||
return this._presetMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get oscillating state (cached)
|
||||
*/
|
||||
public get oscillating(): boolean | undefined {
|
||||
return this._oscillating;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get direction (cached)
|
||||
*/
|
||||
public get direction(): TFanDirection | undefined {
|
||||
return this._direction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available preset modes
|
||||
*/
|
||||
public get presetModes(): string[] | undefined {
|
||||
return this.capabilities.presetModes;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Connection
|
||||
// ============================================================================
|
||||
|
||||
protected async doConnect(): Promise<void> {
|
||||
try {
|
||||
const state = await this.protocolClient.getState(this.entityId);
|
||||
this.updateStateInternal(state);
|
||||
} catch {
|
||||
// Ignore errors fetching initial state
|
||||
}
|
||||
}
|
||||
|
||||
protected async doDisconnect(): Promise<void> {
|
||||
// Nothing to disconnect
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Fan Control
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Turn on the fan
|
||||
* @param percentage Optional speed percentage
|
||||
*/
|
||||
public async turnOn(percentage?: number): Promise<void> {
|
||||
await this.protocolClient.turnOn(this.entityId, percentage);
|
||||
this._isOn = true;
|
||||
if (percentage !== undefined) {
|
||||
this._percentage = percentage;
|
||||
}
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn off the fan
|
||||
*/
|
||||
public async turnOff(): Promise<void> {
|
||||
await this.protocolClient.turnOff(this.entityId);
|
||||
this._isOn = false;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the fan
|
||||
*/
|
||||
public async toggle(): Promise<void> {
|
||||
await this.protocolClient.toggle(this.entityId);
|
||||
this._isOn = !this._isOn;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set speed percentage
|
||||
* @param percentage Speed 0-100
|
||||
*/
|
||||
public async setPercentage(percentage: number): Promise<void> {
|
||||
if (!this.capabilities.supportsSpeed) {
|
||||
throw new Error('Fan does not support speed control');
|
||||
}
|
||||
|
||||
const clamped = Math.max(0, Math.min(100, Math.round(percentage)));
|
||||
await this.protocolClient.setPercentage(this.entityId, clamped);
|
||||
this._percentage = clamped;
|
||||
this._isOn = clamped > 0;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set preset mode
|
||||
* @param mode Preset mode name
|
||||
*/
|
||||
public async setPresetMode(mode: string): Promise<void> {
|
||||
if (!this.capabilities.supportsPresetModes) {
|
||||
throw new Error('Fan does not support preset modes');
|
||||
}
|
||||
|
||||
await this.protocolClient.setPresetMode(this.entityId, mode);
|
||||
this._presetMode = mode;
|
||||
this._isOn = true;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set oscillating state
|
||||
* @param oscillating Whether to oscillate
|
||||
*/
|
||||
public async setOscillating(oscillating: boolean): Promise<void> {
|
||||
if (!this.capabilities.supportsOscillate) {
|
||||
throw new Error('Fan does not support oscillation');
|
||||
}
|
||||
|
||||
await this.protocolClient.setOscillating(this.entityId, oscillating);
|
||||
this._oscillating = oscillating;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set direction
|
||||
* @param direction forward or reverse
|
||||
*/
|
||||
public async setDirection(direction: TFanDirection): Promise<void> {
|
||||
if (!this.capabilities.supportsDirection) {
|
||||
throw new Error('Fan does not support direction control');
|
||||
}
|
||||
|
||||
await this.protocolClient.setDirection(this.entityId, direction);
|
||||
this._direction = direction;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current state as object
|
||||
*/
|
||||
public getState(): IFanState {
|
||||
return {
|
||||
isOn: this._isOn,
|
||||
percentage: this._percentage,
|
||||
presetMode: this._presetMode,
|
||||
oscillating: this._oscillating,
|
||||
direction: this._direction,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh state from the device
|
||||
*/
|
||||
public async refreshState(): Promise<IFanState> {
|
||||
const state = await this.protocolClient.getState(this.entityId);
|
||||
this.updateStateInternal(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update state from external source
|
||||
*/
|
||||
public updateState(state: IFanState): void {
|
||||
this.updateStateInternal(state);
|
||||
this.emit('state:changed', state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal state update
|
||||
*/
|
||||
private updateStateInternal(state: IFanState): void {
|
||||
this._isOn = state.isOn;
|
||||
this._percentage = state.percentage;
|
||||
this._presetMode = state.presetMode;
|
||||
this._oscillating = state.oscillating;
|
||||
this._direction = state.direction;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Feature Info
|
||||
// ============================================================================
|
||||
|
||||
public getFeatureInfo(): IFanFeatureInfo {
|
||||
return {
|
||||
...this.getBaseFeatureInfo(),
|
||||
type: 'fan',
|
||||
protocol: this.protocol,
|
||||
capabilities: this.capabilities,
|
||||
currentState: this.getState(),
|
||||
};
|
||||
}
|
||||
}
|
||||
369
ts/features/feature.light.ts
Normal file
369
ts/features/feature.light.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* Light Feature
|
||||
* Provides control for smart lights (brightness, color, effects)
|
||||
*/
|
||||
|
||||
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
||||
import type { IFeatureOptions } from '../interfaces/feature.interfaces.js';
|
||||
import type {
|
||||
TLightProtocol,
|
||||
ILightCapabilities,
|
||||
ILightState,
|
||||
ILightFeatureInfo,
|
||||
ILightProtocolClient,
|
||||
} from '../interfaces/smarthome.interfaces.js';
|
||||
|
||||
/**
|
||||
* Options for creating a LightFeature
|
||||
*/
|
||||
export interface ILightFeatureOptions extends IFeatureOptions {
|
||||
/** Protocol type */
|
||||
protocol: TLightProtocol;
|
||||
/** Entity ID (for Home Assistant) */
|
||||
entityId: string;
|
||||
/** Protocol client for controlling the light */
|
||||
protocolClient: ILightProtocolClient;
|
||||
/** Light capabilities */
|
||||
capabilities?: Partial<ILightCapabilities>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Light Feature - brightness, color, and effect control
|
||||
*
|
||||
* Protocol-agnostic: works with Home Assistant, Hue, MQTT, Zigbee, etc.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const light = device.getFeature<LightFeature>('light');
|
||||
* if (light) {
|
||||
* await light.turnOn({ brightness: 200 });
|
||||
* await light.setColorTemp(4000); // 4000K warm white
|
||||
* await light.setRgbColor(255, 100, 50);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class LightFeature extends Feature {
|
||||
public readonly type = 'light' as const;
|
||||
public readonly protocol: TLightProtocol;
|
||||
|
||||
/** Entity ID (e.g., "light.living_room") */
|
||||
public readonly entityId: string;
|
||||
|
||||
/** Capabilities */
|
||||
public readonly capabilities: ILightCapabilities;
|
||||
|
||||
/** Current state */
|
||||
protected _isOn: boolean = false;
|
||||
protected _brightness?: number;
|
||||
protected _colorTemp?: number;
|
||||
protected _colorTempMireds?: number;
|
||||
protected _rgbColor?: [number, number, number];
|
||||
protected _hsColor?: [number, number];
|
||||
protected _xyColor?: [number, number];
|
||||
protected _effect?: string;
|
||||
|
||||
/** Protocol client for controlling the light */
|
||||
private protocolClient: ILightProtocolClient;
|
||||
|
||||
constructor(
|
||||
device: TDeviceReference,
|
||||
port: number,
|
||||
options: ILightFeatureOptions
|
||||
) {
|
||||
super(device, port, options);
|
||||
this.protocol = options.protocol;
|
||||
this.entityId = options.entityId;
|
||||
this.protocolClient = options.protocolClient;
|
||||
|
||||
// Set capabilities with defaults
|
||||
this.capabilities = {
|
||||
supportsBrightness: options.capabilities?.supportsBrightness ?? false,
|
||||
supportsColorTemp: options.capabilities?.supportsColorTemp ?? false,
|
||||
supportsRgb: options.capabilities?.supportsRgb ?? false,
|
||||
supportsHs: options.capabilities?.supportsHs ?? false,
|
||||
supportsXy: options.capabilities?.supportsXy ?? false,
|
||||
supportsEffects: options.capabilities?.supportsEffects ?? false,
|
||||
supportsTransition: options.capabilities?.supportsTransition ?? true,
|
||||
effects: options.capabilities?.effects,
|
||||
minMireds: options.capabilities?.minMireds,
|
||||
maxMireds: options.capabilities?.maxMireds,
|
||||
minColorTempKelvin: options.capabilities?.minColorTempKelvin,
|
||||
maxColorTempKelvin: options.capabilities?.maxColorTempKelvin,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Properties
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get current on/off state (cached)
|
||||
*/
|
||||
public get isOn(): boolean {
|
||||
return this._isOn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current brightness 0-255 (cached)
|
||||
*/
|
||||
public get brightness(): number | undefined {
|
||||
return this._brightness;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current color temperature in Kelvin (cached)
|
||||
*/
|
||||
public get colorTemp(): number | undefined {
|
||||
return this._colorTemp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current color temperature in Mireds (cached)
|
||||
*/
|
||||
public get colorTempMireds(): number | undefined {
|
||||
return this._colorTempMireds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current RGB color (cached)
|
||||
*/
|
||||
public get rgbColor(): [number, number, number] | undefined {
|
||||
return this._rgbColor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current HS color [hue 0-360, saturation 0-100] (cached)
|
||||
*/
|
||||
public get hsColor(): [number, number] | undefined {
|
||||
return this._hsColor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current XY color (cached)
|
||||
*/
|
||||
public get xyColor(): [number, number] | undefined {
|
||||
return this._xyColor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current effect (cached)
|
||||
*/
|
||||
public get effect(): string | undefined {
|
||||
return this._effect;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available effects
|
||||
*/
|
||||
public get effects(): string[] | undefined {
|
||||
return this.capabilities.effects;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Connection
|
||||
// ============================================================================
|
||||
|
||||
protected async doConnect(): Promise<void> {
|
||||
// Fetch initial state
|
||||
try {
|
||||
const state = await this.protocolClient.getState(this.entityId);
|
||||
this.updateStateInternal(state);
|
||||
} catch {
|
||||
// Ignore errors fetching initial state
|
||||
}
|
||||
}
|
||||
|
||||
protected async doDisconnect(): Promise<void> {
|
||||
// Nothing to disconnect
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Light Control
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Turn on the light
|
||||
* @param options Optional settings to apply when turning on
|
||||
*/
|
||||
public async turnOn(options?: {
|
||||
brightness?: number;
|
||||
colorTemp?: number;
|
||||
rgb?: [number, number, number];
|
||||
transition?: number;
|
||||
}): Promise<void> {
|
||||
await this.protocolClient.turnOn(this.entityId, options);
|
||||
this._isOn = true;
|
||||
if (options?.brightness !== undefined) {
|
||||
this._brightness = options.brightness;
|
||||
}
|
||||
if (options?.colorTemp !== undefined) {
|
||||
this._colorTemp = options.colorTemp;
|
||||
}
|
||||
if (options?.rgb !== undefined) {
|
||||
this._rgbColor = options.rgb;
|
||||
}
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn off the light
|
||||
* @param options Optional transition time
|
||||
*/
|
||||
public async turnOff(options?: { transition?: number }): Promise<void> {
|
||||
await this.protocolClient.turnOff(this.entityId, options);
|
||||
this._isOn = false;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the light
|
||||
*/
|
||||
public async toggle(): Promise<void> {
|
||||
await this.protocolClient.toggle(this.entityId);
|
||||
this._isOn = !this._isOn;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set brightness level
|
||||
* @param brightness Brightness 0-255
|
||||
* @param transition Optional transition time in seconds
|
||||
*/
|
||||
public async setBrightness(brightness: number, transition?: number): Promise<void> {
|
||||
if (!this.capabilities.supportsBrightness) {
|
||||
throw new Error('Light does not support brightness control');
|
||||
}
|
||||
|
||||
const clamped = Math.max(0, Math.min(255, Math.round(brightness)));
|
||||
await this.protocolClient.setBrightness(this.entityId, clamped, transition);
|
||||
this._brightness = clamped;
|
||||
if (clamped > 0) {
|
||||
this._isOn = true;
|
||||
}
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set color temperature in Kelvin
|
||||
* @param kelvin Color temperature in Kelvin (e.g., 2700 warm, 6500 cool)
|
||||
* @param transition Optional transition time in seconds
|
||||
*/
|
||||
public async setColorTemp(kelvin: number, transition?: number): Promise<void> {
|
||||
if (!this.capabilities.supportsColorTemp) {
|
||||
throw new Error('Light does not support color temperature');
|
||||
}
|
||||
|
||||
// Clamp to supported range if available
|
||||
let clamped = kelvin;
|
||||
if (this.capabilities.minColorTempKelvin && this.capabilities.maxColorTempKelvin) {
|
||||
clamped = Math.max(
|
||||
this.capabilities.minColorTempKelvin,
|
||||
Math.min(this.capabilities.maxColorTempKelvin, kelvin)
|
||||
);
|
||||
}
|
||||
|
||||
await this.protocolClient.setColorTemp(this.entityId, clamped, transition);
|
||||
this._colorTemp = clamped;
|
||||
this._colorTempMireds = Math.round(1000000 / clamped);
|
||||
this._isOn = true;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set RGB color
|
||||
* @param r Red 0-255
|
||||
* @param g Green 0-255
|
||||
* @param b Blue 0-255
|
||||
* @param transition Optional transition time in seconds
|
||||
*/
|
||||
public async setRgbColor(r: number, g: number, b: number, transition?: number): Promise<void> {
|
||||
if (!this.capabilities.supportsRgb) {
|
||||
throw new Error('Light does not support RGB color');
|
||||
}
|
||||
|
||||
const clampedR = Math.max(0, Math.min(255, Math.round(r)));
|
||||
const clampedG = Math.max(0, Math.min(255, Math.round(g)));
|
||||
const clampedB = Math.max(0, Math.min(255, Math.round(b)));
|
||||
|
||||
await this.protocolClient.setRgbColor(this.entityId, clampedR, clampedG, clampedB, transition);
|
||||
this._rgbColor = [clampedR, clampedG, clampedB];
|
||||
this._isOn = true;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set light effect
|
||||
* @param effect Effect name from available effects
|
||||
*/
|
||||
public async setEffect(effect: string): Promise<void> {
|
||||
if (!this.capabilities.supportsEffects) {
|
||||
throw new Error('Light does not support effects');
|
||||
}
|
||||
|
||||
await this.protocolClient.setEffect(this.entityId, effect);
|
||||
this._effect = effect;
|
||||
this._isOn = true;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current state as object
|
||||
*/
|
||||
public getState(): ILightState {
|
||||
return {
|
||||
isOn: this._isOn,
|
||||
brightness: this._brightness,
|
||||
colorTemp: this._colorTemp,
|
||||
colorTempMireds: this._colorTempMireds,
|
||||
rgbColor: this._rgbColor,
|
||||
hsColor: this._hsColor,
|
||||
xyColor: this._xyColor,
|
||||
effect: this._effect,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh state from the device
|
||||
*/
|
||||
public async refreshState(): Promise<ILightState> {
|
||||
const state = await this.protocolClient.getState(this.entityId);
|
||||
this.updateStateInternal(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update state from external source (e.g., state change event)
|
||||
*/
|
||||
public updateState(state: ILightState): void {
|
||||
this.updateStateInternal(state);
|
||||
this.emit('state:changed', state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal state update
|
||||
*/
|
||||
private updateStateInternal(state: ILightState): void {
|
||||
this._isOn = state.isOn;
|
||||
this._brightness = state.brightness;
|
||||
this._colorTemp = state.colorTemp;
|
||||
this._colorTempMireds = state.colorTempMireds;
|
||||
this._rgbColor = state.rgbColor;
|
||||
this._hsColor = state.hsColor;
|
||||
this._xyColor = state.xyColor;
|
||||
this._effect = state.effect;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Feature Info
|
||||
// ============================================================================
|
||||
|
||||
public getFeatureInfo(): ILightFeatureInfo {
|
||||
return {
|
||||
...this.getBaseFeatureInfo(),
|
||||
type: 'light',
|
||||
protocol: this.protocol,
|
||||
capabilities: this.capabilities,
|
||||
currentState: this.getState(),
|
||||
};
|
||||
}
|
||||
}
|
||||
223
ts/features/feature.lock.ts
Normal file
223
ts/features/feature.lock.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Lock Feature
|
||||
* Provides control for smart locks
|
||||
*/
|
||||
|
||||
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
||||
import type { IFeatureOptions } from '../interfaces/feature.interfaces.js';
|
||||
import type {
|
||||
TLockProtocol,
|
||||
TLockState,
|
||||
ILockCapabilities,
|
||||
ILockStateInfo,
|
||||
ILockFeatureInfo,
|
||||
ILockProtocolClient,
|
||||
} from '../interfaces/smarthome.interfaces.js';
|
||||
|
||||
/**
|
||||
* Options for creating a LockFeature
|
||||
*/
|
||||
export interface ILockFeatureOptions extends IFeatureOptions {
|
||||
/** Protocol type */
|
||||
protocol: TLockProtocol;
|
||||
/** Entity ID (for Home Assistant) */
|
||||
entityId: string;
|
||||
/** Protocol client for controlling the lock */
|
||||
protocolClient: ILockProtocolClient;
|
||||
/** Whether the lock supports physical open */
|
||||
supportsOpen?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock Feature - control for smart locks
|
||||
*
|
||||
* Protocol-agnostic: works with Home Assistant, MQTT, August, Yale, etc.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const lock = device.getFeature<LockFeature>('lock');
|
||||
* if (lock) {
|
||||
* await lock.lock();
|
||||
* console.log(`Lock is ${lock.isLocked ? 'locked' : 'unlocked'}`);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class LockFeature extends Feature {
|
||||
public readonly type = 'lock' as const;
|
||||
public readonly protocol: TLockProtocol;
|
||||
|
||||
/** Entity ID (e.g., "lock.front_door") */
|
||||
public readonly entityId: string;
|
||||
|
||||
/** Capabilities */
|
||||
public readonly capabilities: ILockCapabilities;
|
||||
|
||||
/** Current state */
|
||||
protected _lockState: TLockState = 'unknown';
|
||||
protected _isLocked: boolean = false;
|
||||
|
||||
/** Protocol client for controlling the lock */
|
||||
private protocolClient: ILockProtocolClient;
|
||||
|
||||
constructor(
|
||||
device: TDeviceReference,
|
||||
port: number,
|
||||
options: ILockFeatureOptions
|
||||
) {
|
||||
super(device, port, options);
|
||||
this.protocol = options.protocol;
|
||||
this.entityId = options.entityId;
|
||||
this.protocolClient = options.protocolClient;
|
||||
|
||||
this.capabilities = {
|
||||
supportsOpen: options.supportsOpen ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Properties
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get current lock state (cached)
|
||||
*/
|
||||
public get lockState(): TLockState {
|
||||
return this._lockState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if locked (cached)
|
||||
*/
|
||||
public get isLocked(): boolean {
|
||||
return this._isLocked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if unlocked
|
||||
*/
|
||||
public get isUnlocked(): boolean {
|
||||
return this._lockState === 'unlocked';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if currently locking
|
||||
*/
|
||||
public get isLocking(): boolean {
|
||||
return this._lockState === 'locking';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if currently unlocking
|
||||
*/
|
||||
public get isUnlocking(): boolean {
|
||||
return this._lockState === 'unlocking';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if jammed
|
||||
*/
|
||||
public get isJammed(): boolean {
|
||||
return this._lockState === 'jammed';
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Connection
|
||||
// ============================================================================
|
||||
|
||||
protected async doConnect(): Promise<void> {
|
||||
try {
|
||||
const state = await this.protocolClient.getState(this.entityId);
|
||||
this.updateStateInternal(state);
|
||||
} catch {
|
||||
// Ignore errors fetching initial state
|
||||
}
|
||||
}
|
||||
|
||||
protected async doDisconnect(): Promise<void> {
|
||||
// Nothing to disconnect
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Lock Control
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Lock the lock
|
||||
*/
|
||||
public async lock(): Promise<void> {
|
||||
await this.protocolClient.lock(this.entityId);
|
||||
this._lockState = 'locking';
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlock the lock
|
||||
*/
|
||||
public async unlock(): Promise<void> {
|
||||
await this.protocolClient.unlock(this.entityId);
|
||||
this._lockState = 'unlocking';
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the lock (physically open the door if supported)
|
||||
*/
|
||||
public async open(): Promise<void> {
|
||||
if (!this.capabilities.supportsOpen) {
|
||||
throw new Error('Lock does not support physical open');
|
||||
}
|
||||
await this.protocolClient.open(this.entityId);
|
||||
this._lockState = 'unlocked';
|
||||
this._isLocked = false;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current state as object
|
||||
*/
|
||||
public getState(): ILockStateInfo {
|
||||
return {
|
||||
state: this._lockState,
|
||||
isLocked: this._isLocked,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh state from the device
|
||||
*/
|
||||
public async refreshState(): Promise<ILockStateInfo> {
|
||||
const state = await this.protocolClient.getState(this.entityId);
|
||||
this.updateStateInternal(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update state from external source
|
||||
*/
|
||||
public updateState(state: ILockStateInfo): void {
|
||||
this.updateStateInternal(state);
|
||||
this.emit('state:changed', state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal state update
|
||||
*/
|
||||
private updateStateInternal(state: ILockStateInfo): void {
|
||||
this._lockState = state.state;
|
||||
this._isLocked = state.isLocked;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Feature Info
|
||||
// ============================================================================
|
||||
|
||||
public getFeatureInfo(): ILockFeatureInfo {
|
||||
return {
|
||||
...this.getBaseFeatureInfo(),
|
||||
type: 'lock',
|
||||
protocol: this.protocol,
|
||||
capabilities: this.capabilities,
|
||||
currentState: this.getState(),
|
||||
};
|
||||
}
|
||||
}
|
||||
202
ts/features/feature.sensor.ts
Normal file
202
ts/features/feature.sensor.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Sensor Feature
|
||||
* Provides read-only state for sensors (temperature, humidity, power, etc.)
|
||||
*/
|
||||
|
||||
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
||||
import type { IFeatureOptions } from '../interfaces/feature.interfaces.js';
|
||||
import type {
|
||||
TSensorProtocol,
|
||||
TSensorDeviceClass,
|
||||
TSensorStateClass,
|
||||
ISensorCapabilities,
|
||||
ISensorState,
|
||||
ISensorFeatureInfo,
|
||||
ISensorProtocolClient,
|
||||
} from '../interfaces/smarthome.interfaces.js';
|
||||
|
||||
/**
|
||||
* Options for creating a SensorFeature
|
||||
*/
|
||||
export interface ISensorFeatureOptions extends IFeatureOptions {
|
||||
/** Protocol type */
|
||||
protocol: TSensorProtocol;
|
||||
/** Entity ID (for Home Assistant) */
|
||||
entityId: string;
|
||||
/** Protocol client for reading sensor state */
|
||||
protocolClient: ISensorProtocolClient;
|
||||
/** Device class (temperature, humidity, etc.) */
|
||||
deviceClass?: TSensorDeviceClass;
|
||||
/** State class (measurement, total, etc.) */
|
||||
stateClass?: TSensorStateClass;
|
||||
/** Unit of measurement */
|
||||
unit?: string;
|
||||
/** Precision (decimal places) */
|
||||
precision?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sensor Feature - read-only state values
|
||||
*
|
||||
* Protocol-agnostic: works with Home Assistant, MQTT, SNMP, etc.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const sensor = device.getFeature<SensorFeature>('sensor');
|
||||
* if (sensor) {
|
||||
* await sensor.refreshState();
|
||||
* console.log(`Temperature: ${sensor.value} ${sensor.unit}`);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class SensorFeature extends Feature {
|
||||
public readonly type = 'sensor' as const;
|
||||
public readonly protocol: TSensorProtocol;
|
||||
|
||||
/** Entity ID (e.g., "sensor.living_room_temperature") */
|
||||
public readonly entityId: string;
|
||||
|
||||
/** Capabilities */
|
||||
public readonly capabilities: ISensorCapabilities;
|
||||
|
||||
/** Current state */
|
||||
protected _value: string | number | boolean = '';
|
||||
protected _numericValue?: number;
|
||||
protected _unit?: string;
|
||||
protected _lastUpdated: Date = new Date();
|
||||
|
||||
/** Protocol client for reading sensor state */
|
||||
private protocolClient: ISensorProtocolClient;
|
||||
|
||||
constructor(
|
||||
device: TDeviceReference,
|
||||
port: number,
|
||||
options: ISensorFeatureOptions
|
||||
) {
|
||||
super(device, port, options);
|
||||
this.protocol = options.protocol;
|
||||
this.entityId = options.entityId;
|
||||
this.protocolClient = options.protocolClient;
|
||||
this.capabilities = {
|
||||
deviceClass: options.deviceClass,
|
||||
stateClass: options.stateClass,
|
||||
unit: options.unit,
|
||||
precision: options.precision,
|
||||
};
|
||||
this._unit = options.unit;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Properties
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get current value (cached)
|
||||
*/
|
||||
public get value(): string | number | boolean {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get numeric value if available (cached)
|
||||
*/
|
||||
public get numericValue(): number | undefined {
|
||||
return this._numericValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unit of measurement
|
||||
*/
|
||||
public get unit(): string | undefined {
|
||||
return this._unit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get device class
|
||||
*/
|
||||
public get deviceClass(): TSensorDeviceClass | undefined {
|
||||
return this.capabilities.deviceClass;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get state class
|
||||
*/
|
||||
public get stateClass(): TSensorStateClass | undefined {
|
||||
return this.capabilities.stateClass;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last updated timestamp
|
||||
*/
|
||||
public get lastUpdated(): Date {
|
||||
return this._lastUpdated;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Connection
|
||||
// ============================================================================
|
||||
|
||||
protected async doConnect(): Promise<void> {
|
||||
// Fetch initial state
|
||||
try {
|
||||
const state = await this.protocolClient.getState(this.entityId);
|
||||
this.updateStateInternal(state);
|
||||
} catch {
|
||||
// Ignore errors fetching initial state
|
||||
}
|
||||
}
|
||||
|
||||
protected async doDisconnect(): Promise<void> {
|
||||
// Nothing to disconnect
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Sensor Reading
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Refresh state from the device
|
||||
*/
|
||||
public async refreshState(): Promise<ISensorState> {
|
||||
const state = await this.protocolClient.getState(this.entityId);
|
||||
this.updateStateInternal(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update state from external source (e.g., state change event)
|
||||
*/
|
||||
public updateState(state: ISensorState): void {
|
||||
this.updateStateInternal(state);
|
||||
this.emit('state:changed', state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal state update
|
||||
*/
|
||||
private updateStateInternal(state: ISensorState): void {
|
||||
this._value = state.value;
|
||||
this._numericValue = state.numericValue;
|
||||
this._unit = state.unit || this._unit;
|
||||
this._lastUpdated = state.lastUpdated;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Feature Info
|
||||
// ============================================================================
|
||||
|
||||
public getFeatureInfo(): ISensorFeatureInfo {
|
||||
return {
|
||||
...this.getBaseFeatureInfo(),
|
||||
type: 'sensor',
|
||||
protocol: this.protocol,
|
||||
capabilities: this.capabilities,
|
||||
currentState: {
|
||||
value: this._value,
|
||||
numericValue: this._numericValue,
|
||||
unit: this._unit,
|
||||
lastUpdated: this._lastUpdated,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
170
ts/features/feature.switch.ts
Normal file
170
ts/features/feature.switch.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Switch Feature
|
||||
* Provides binary on/off control for smart switches, outlets, etc.
|
||||
*/
|
||||
|
||||
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
||||
import type { IFeatureOptions } from '../interfaces/feature.interfaces.js';
|
||||
import type {
|
||||
TSwitchProtocol,
|
||||
ISwitchCapabilities,
|
||||
ISwitchState,
|
||||
ISwitchFeatureInfo,
|
||||
ISwitchProtocolClient,
|
||||
} from '../interfaces/smarthome.interfaces.js';
|
||||
|
||||
/**
|
||||
* Options for creating a SwitchFeature
|
||||
*/
|
||||
export interface ISwitchFeatureOptions extends IFeatureOptions {
|
||||
/** Protocol type */
|
||||
protocol: TSwitchProtocol;
|
||||
/** Entity ID (for Home Assistant) */
|
||||
entityId: string;
|
||||
/** Protocol client for controlling the switch */
|
||||
protocolClient: ISwitchProtocolClient;
|
||||
/** Device class */
|
||||
deviceClass?: 'outlet' | 'switch';
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch Feature - binary on/off control
|
||||
*
|
||||
* Protocol-agnostic: works with Home Assistant, MQTT, Tasmota, Tuya, etc.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const sw = device.getFeature<SwitchFeature>('switch');
|
||||
* if (sw) {
|
||||
* await sw.turnOn();
|
||||
* await sw.toggle();
|
||||
* console.log(`Switch is ${sw.isOn ? 'on' : 'off'}`);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class SwitchFeature extends Feature {
|
||||
public readonly type = 'switch' as const;
|
||||
public readonly protocol: TSwitchProtocol;
|
||||
|
||||
/** Entity ID (e.g., "switch.living_room") */
|
||||
public readonly entityId: string;
|
||||
|
||||
/** Capabilities */
|
||||
public readonly capabilities: ISwitchCapabilities;
|
||||
|
||||
/** Current state */
|
||||
protected _isOn: boolean = false;
|
||||
|
||||
/** Protocol client for controlling the switch */
|
||||
private protocolClient: ISwitchProtocolClient;
|
||||
|
||||
constructor(
|
||||
device: TDeviceReference,
|
||||
port: number,
|
||||
options: ISwitchFeatureOptions
|
||||
) {
|
||||
super(device, port, options);
|
||||
this.protocol = options.protocol;
|
||||
this.entityId = options.entityId;
|
||||
this.protocolClient = options.protocolClient;
|
||||
this.capabilities = {
|
||||
deviceClass: options.deviceClass,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Properties
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get current on/off state (cached)
|
||||
*/
|
||||
public get isOn(): boolean {
|
||||
return this._isOn;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Connection
|
||||
// ============================================================================
|
||||
|
||||
protected async doConnect(): Promise<void> {
|
||||
// Fetch initial state
|
||||
try {
|
||||
const state = await this.protocolClient.getState(this.entityId);
|
||||
this._isOn = state.isOn;
|
||||
} catch {
|
||||
// Ignore errors fetching initial state
|
||||
}
|
||||
}
|
||||
|
||||
protected async doDisconnect(): Promise<void> {
|
||||
// Nothing to disconnect
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Switch Control
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Turn on the switch
|
||||
*/
|
||||
public async turnOn(): Promise<void> {
|
||||
await this.protocolClient.turnOn(this.entityId);
|
||||
this._isOn = true;
|
||||
this.emit('state:changed', { isOn: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn off the switch
|
||||
*/
|
||||
public async turnOff(): Promise<void> {
|
||||
await this.protocolClient.turnOff(this.entityId);
|
||||
this._isOn = false;
|
||||
this.emit('state:changed', { isOn: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the switch
|
||||
*/
|
||||
public async toggle(): Promise<void> {
|
||||
await this.protocolClient.toggle(this.entityId);
|
||||
this._isOn = !this._isOn;
|
||||
this.emit('state:changed', { isOn: this._isOn });
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh state from the device
|
||||
*/
|
||||
public async refreshState(): Promise<ISwitchState> {
|
||||
const state = await this.protocolClient.getState(this.entityId);
|
||||
this._isOn = state.isOn;
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update state from external source (e.g., state change event)
|
||||
*/
|
||||
public updateState(state: ISwitchState): void {
|
||||
const changed = this._isOn !== state.isOn;
|
||||
this._isOn = state.isOn;
|
||||
if (changed) {
|
||||
this.emit('state:changed', state);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Feature Info
|
||||
// ============================================================================
|
||||
|
||||
public getFeatureInfo(): ISwitchFeatureInfo {
|
||||
return {
|
||||
...this.getBaseFeatureInfo(),
|
||||
type: 'switch',
|
||||
protocol: this.protocol,
|
||||
capabilities: this.capabilities,
|
||||
currentState: {
|
||||
isOn: this._isOn,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,20 @@
|
||||
// Abstract base
|
||||
export { Feature, type TDeviceReference } from './feature.abstract.js';
|
||||
|
||||
// Concrete features
|
||||
// Concrete features - Document/Infrastructure
|
||||
export { ScanFeature, type IScanFeatureOptions } from './feature.scan.js';
|
||||
export { PrintFeature, type IPrintFeatureOptions } from './feature.print.js';
|
||||
export { PlaybackFeature, type IPlaybackFeatureOptions } from './feature.playback.js';
|
||||
export { VolumeFeature, type IVolumeFeatureOptions, type IVolumeController } from './feature.volume.js';
|
||||
export { PowerFeature, type IPowerFeatureOptions } from './feature.power.js';
|
||||
export { SnmpFeature, type ISnmpFeatureOptions } from './feature.snmp.js';
|
||||
|
||||
// Smart Home Features (protocol-agnostic: home-assistant, hue, mqtt, etc.)
|
||||
export { SwitchFeature, type ISwitchFeatureOptions } from './feature.switch.js';
|
||||
export { SensorFeature, type ISensorFeatureOptions } from './feature.sensor.js';
|
||||
export { LightFeature, type ILightFeatureOptions } from './feature.light.js';
|
||||
export { CoverFeature, type ICoverFeatureOptions } from './feature.cover.js';
|
||||
export { LockFeature, type ILockFeatureOptions } from './feature.lock.js';
|
||||
export { FanFeature, type IFanFeatureOptions } from './feature.fan.js';
|
||||
export { ClimateFeature, type IClimateFeatureOptions } from './feature.climate.js';
|
||||
export { CameraFeature, type ICameraFeatureOptions } from './feature.camera.js';
|
||||
|
||||
39
ts/index.ts
39
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
|
||||
// ============================================================================
|
||||
|
||||
@@ -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
|
||||
;
|
||||
|
||||
/**
|
||||
|
||||
666
ts/interfaces/homeassistant.interfaces.ts
Normal file
666
ts/interfaces/homeassistant.interfaces.ts
Normal file
@@ -0,0 +1,666 @@
|
||||
/**
|
||||
* Home Assistant Specific Interfaces
|
||||
* Types for Home Assistant WebSocket API, entities, and configuration
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Configuration
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Configuration for connecting to a Home Assistant instance
|
||||
*/
|
||||
export interface IHomeAssistantInstanceConfig {
|
||||
/** Home Assistant host (IP or hostname) */
|
||||
host: string;
|
||||
/** Port number (default: 8123) */
|
||||
port?: number;
|
||||
/** Long-lived access token from HA */
|
||||
token: string;
|
||||
/** Use secure WebSocket (wss://) */
|
||||
secure?: boolean;
|
||||
/** Friendly name for this instance */
|
||||
friendlyName?: string;
|
||||
/** Auto-reconnect on disconnect (default: true) */
|
||||
autoReconnect?: boolean;
|
||||
/** Reconnect delay in ms (default: 5000) */
|
||||
reconnectDelay?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Home Assistant configuration in DeviceManager options
|
||||
*/
|
||||
export interface IHomeAssistantOptions {
|
||||
/** Enable mDNS auto-discovery of HA instances */
|
||||
autoDiscovery?: boolean;
|
||||
/** Manually configured HA instances */
|
||||
instances?: IHomeAssistantInstanceConfig[];
|
||||
/** Filter: only discover these domains (default: all) */
|
||||
enabledDomains?: THomeAssistantDomain[];
|
||||
/** Auto-reconnect on disconnect (default: true) */
|
||||
autoReconnect?: boolean;
|
||||
/** Reconnect delay in ms (default: 5000) */
|
||||
reconnectDelay?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Entity Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Supported Home Assistant domains that map to features
|
||||
*/
|
||||
export type THomeAssistantDomain =
|
||||
| 'light'
|
||||
| 'switch'
|
||||
| 'sensor'
|
||||
| 'binary_sensor'
|
||||
| 'climate'
|
||||
| 'fan'
|
||||
| 'cover'
|
||||
| 'lock'
|
||||
| 'camera'
|
||||
| 'media_player';
|
||||
|
||||
/**
|
||||
* Home Assistant entity state
|
||||
*/
|
||||
export interface IHomeAssistantEntity {
|
||||
/** Entity ID (e.g., "light.living_room") */
|
||||
entity_id: string;
|
||||
/** Current state value (e.g., "on", "off", "25.5") */
|
||||
state: string;
|
||||
/** Additional attributes */
|
||||
attributes: IHomeAssistantEntityAttributes;
|
||||
/** Last changed timestamp */
|
||||
last_changed: string;
|
||||
/** Last updated timestamp */
|
||||
last_updated: string;
|
||||
/** Context information */
|
||||
context: IHomeAssistantContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Common entity attributes
|
||||
*/
|
||||
export interface IHomeAssistantEntityAttributes {
|
||||
/** Friendly name */
|
||||
friendly_name?: string;
|
||||
/** Device class */
|
||||
device_class?: string;
|
||||
/** Unit of measurement */
|
||||
unit_of_measurement?: string;
|
||||
/** Icon */
|
||||
icon?: string;
|
||||
/** Entity category */
|
||||
entity_category?: string;
|
||||
/** Assumed state (for optimistic updates) */
|
||||
assumed_state?: boolean;
|
||||
/** Supported features bitmask */
|
||||
supported_features?: number;
|
||||
/** Additional dynamic attributes */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Light-specific attributes
|
||||
*/
|
||||
export interface IHomeAssistantLightAttributes extends IHomeAssistantEntityAttributes {
|
||||
brightness?: number; // 0-255
|
||||
color_temp?: number; // Mireds
|
||||
color_temp_kelvin?: number; // Kelvin
|
||||
hs_color?: [number, number]; // [hue 0-360, saturation 0-100]
|
||||
rgb_color?: [number, number, number];
|
||||
xy_color?: [number, number];
|
||||
rgbw_color?: [number, number, number, number];
|
||||
rgbww_color?: [number, number, number, number, number];
|
||||
effect?: string;
|
||||
effect_list?: string[];
|
||||
color_mode?: string;
|
||||
supported_color_modes?: string[];
|
||||
min_mireds?: number;
|
||||
max_mireds?: number;
|
||||
min_color_temp_kelvin?: number;
|
||||
max_color_temp_kelvin?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Climate-specific attributes
|
||||
*/
|
||||
export interface IHomeAssistantClimateAttributes extends IHomeAssistantEntityAttributes {
|
||||
hvac_modes?: string[];
|
||||
hvac_action?: string;
|
||||
current_temperature?: number;
|
||||
target_temp_high?: number;
|
||||
target_temp_low?: number;
|
||||
temperature?: number;
|
||||
preset_mode?: string;
|
||||
preset_modes?: string[];
|
||||
fan_mode?: string;
|
||||
fan_modes?: string[];
|
||||
swing_mode?: string;
|
||||
swing_modes?: string[];
|
||||
aux_heat?: boolean;
|
||||
current_humidity?: number;
|
||||
humidity?: number;
|
||||
min_temp?: number;
|
||||
max_temp?: number;
|
||||
target_temp_step?: number;
|
||||
min_humidity?: number;
|
||||
max_humidity?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sensor-specific attributes
|
||||
*/
|
||||
export interface IHomeAssistantSensorAttributes extends IHomeAssistantEntityAttributes {
|
||||
state_class?: 'measurement' | 'total' | 'total_increasing';
|
||||
native_unit_of_measurement?: string;
|
||||
native_value?: string | number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cover-specific attributes
|
||||
*/
|
||||
export interface IHomeAssistantCoverAttributes extends IHomeAssistantEntityAttributes {
|
||||
current_position?: number; // 0-100
|
||||
current_tilt_position?: number; // 0-100
|
||||
}
|
||||
|
||||
/**
|
||||
* Fan-specific attributes
|
||||
*/
|
||||
export interface IHomeAssistantFanAttributes extends IHomeAssistantEntityAttributes {
|
||||
percentage?: number; // 0-100
|
||||
percentage_step?: number;
|
||||
preset_mode?: string;
|
||||
preset_modes?: string[];
|
||||
oscillating?: boolean;
|
||||
direction?: 'forward' | 'reverse';
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock-specific attributes
|
||||
*/
|
||||
export interface IHomeAssistantLockAttributes extends IHomeAssistantEntityAttributes {
|
||||
is_locked?: boolean;
|
||||
is_locking?: boolean;
|
||||
is_unlocking?: boolean;
|
||||
is_jammed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Camera-specific attributes
|
||||
*/
|
||||
export interface IHomeAssistantCameraAttributes extends IHomeAssistantEntityAttributes {
|
||||
access_token?: string;
|
||||
entity_picture?: string;
|
||||
frontend_stream_type?: 'hls' | 'web_rtc';
|
||||
is_streaming?: boolean;
|
||||
motion_detection?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Media player-specific attributes
|
||||
*/
|
||||
export interface IHomeAssistantMediaPlayerAttributes extends IHomeAssistantEntityAttributes {
|
||||
volume_level?: number; // 0-1
|
||||
is_volume_muted?: boolean;
|
||||
media_content_id?: string;
|
||||
media_content_type?: string;
|
||||
media_duration?: number;
|
||||
media_position?: number;
|
||||
media_position_updated_at?: string;
|
||||
media_title?: string;
|
||||
media_artist?: string;
|
||||
media_album_name?: string;
|
||||
media_album_artist?: string;
|
||||
media_track?: number;
|
||||
media_series_title?: string;
|
||||
media_season?: number;
|
||||
media_episode?: number;
|
||||
app_id?: string;
|
||||
app_name?: string;
|
||||
source?: string;
|
||||
source_list?: string[];
|
||||
sound_mode?: string;
|
||||
sound_mode_list?: string[];
|
||||
shuffle?: boolean;
|
||||
repeat?: 'off' | 'all' | 'one';
|
||||
entity_picture_local?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context for entity state changes
|
||||
*/
|
||||
export interface IHomeAssistantContext {
|
||||
id: string;
|
||||
parent_id?: string;
|
||||
user_id?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WebSocket Message Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Base message structure
|
||||
*/
|
||||
export interface IHomeAssistantMessage {
|
||||
id?: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication required message
|
||||
*/
|
||||
export interface IHomeAssistantAuthRequired extends IHomeAssistantMessage {
|
||||
type: 'auth_required';
|
||||
ha_version: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication message to send
|
||||
*/
|
||||
export interface IHomeAssistantAuth extends IHomeAssistantMessage {
|
||||
type: 'auth';
|
||||
access_token: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication success
|
||||
*/
|
||||
export interface IHomeAssistantAuthOk extends IHomeAssistantMessage {
|
||||
type: 'auth_ok';
|
||||
ha_version: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication invalid
|
||||
*/
|
||||
export interface IHomeAssistantAuthInvalid extends IHomeAssistantMessage {
|
||||
type: 'auth_invalid';
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result message
|
||||
*/
|
||||
export interface IHomeAssistantResult extends IHomeAssistantMessage {
|
||||
id: number;
|
||||
type: 'result';
|
||||
success: boolean;
|
||||
result?: unknown;
|
||||
error?: {
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Event message
|
||||
*/
|
||||
export interface IHomeAssistantEvent extends IHomeAssistantMessage {
|
||||
id: number;
|
||||
type: 'event';
|
||||
event: {
|
||||
event_type: string;
|
||||
data: unknown;
|
||||
origin: string;
|
||||
time_fired: string;
|
||||
context: IHomeAssistantContext;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* State changed event data
|
||||
*/
|
||||
export interface IHomeAssistantStateChangedEvent {
|
||||
entity_id: string;
|
||||
old_state: IHomeAssistantEntity | null;
|
||||
new_state: IHomeAssistantEntity | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe events request
|
||||
*/
|
||||
export interface IHomeAssistantSubscribeEvents extends IHomeAssistantMessage {
|
||||
id: number;
|
||||
type: 'subscribe_events';
|
||||
event_type?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get states request
|
||||
*/
|
||||
export interface IHomeAssistantGetStates extends IHomeAssistantMessage {
|
||||
id: number;
|
||||
type: 'get_states';
|
||||
}
|
||||
|
||||
/**
|
||||
* Call service request
|
||||
*/
|
||||
export interface IHomeAssistantCallService extends IHomeAssistantMessage {
|
||||
id: number;
|
||||
type: 'call_service';
|
||||
domain: string;
|
||||
service: string;
|
||||
target?: {
|
||||
entity_id?: string | string[];
|
||||
device_id?: string | string[];
|
||||
area_id?: string | string[];
|
||||
};
|
||||
service_data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get services request
|
||||
*/
|
||||
export interface IHomeAssistantGetServices extends IHomeAssistantMessage {
|
||||
id: number;
|
||||
type: 'get_services';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get config request
|
||||
*/
|
||||
export interface IHomeAssistantGetConfig extends IHomeAssistantMessage {
|
||||
id: number;
|
||||
type: 'get_config';
|
||||
}
|
||||
|
||||
/**
|
||||
* Home Assistant config response
|
||||
*/
|
||||
export interface IHomeAssistantConfig {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
elevation: number;
|
||||
unit_system: {
|
||||
length: string;
|
||||
mass: string;
|
||||
pressure: string;
|
||||
temperature: string;
|
||||
volume: string;
|
||||
};
|
||||
location_name: string;
|
||||
time_zone: string;
|
||||
components: string[];
|
||||
config_dir: string;
|
||||
allowlist_external_dirs: string[];
|
||||
allowlist_external_urls: string[];
|
||||
version: string;
|
||||
config_source: string;
|
||||
safe_mode: boolean;
|
||||
state: 'NOT_RUNNING' | 'STARTING' | 'RUNNING' | 'STOPPING' | 'FINAL_WRITE';
|
||||
external_url: string | null;
|
||||
internal_url: string | null;
|
||||
currency: string;
|
||||
country: string;
|
||||
language: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Service Definitions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Light service data
|
||||
*/
|
||||
export interface IHomeAssistantLightServiceData {
|
||||
brightness?: number; // 0-255
|
||||
brightness_pct?: number; // 0-100
|
||||
brightness_step?: number; // Step to increase/decrease
|
||||
brightness_step_pct?: number; // Step percentage
|
||||
color_temp?: number; // Mireds
|
||||
color_temp_kelvin?: number; // Kelvin
|
||||
hs_color?: [number, number]; // [hue, saturation]
|
||||
rgb_color?: [number, number, number];
|
||||
xy_color?: [number, number];
|
||||
rgbw_color?: [number, number, number, number];
|
||||
rgbww_color?: [number, number, number, number, number];
|
||||
color_name?: string;
|
||||
kelvin?: number;
|
||||
effect?: string;
|
||||
transition?: number; // Seconds
|
||||
flash?: 'short' | 'long';
|
||||
profile?: string;
|
||||
[key: string]: unknown; // Index signature for Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Climate service data
|
||||
*/
|
||||
export interface IHomeAssistantClimateServiceData {
|
||||
hvac_mode?: string;
|
||||
temperature?: number;
|
||||
target_temp_high?: number;
|
||||
target_temp_low?: number;
|
||||
humidity?: number;
|
||||
fan_mode?: string;
|
||||
swing_mode?: string;
|
||||
preset_mode?: string;
|
||||
aux_heat?: boolean;
|
||||
[key: string]: unknown; // Index signature for Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Cover service data
|
||||
*/
|
||||
export interface IHomeAssistantCoverServiceData {
|
||||
position?: number; // 0-100
|
||||
tilt_position?: number; // 0-100
|
||||
}
|
||||
|
||||
/**
|
||||
* Fan service data
|
||||
*/
|
||||
export interface IHomeAssistantFanServiceData {
|
||||
percentage?: number; // 0-100
|
||||
percentage_step?: number;
|
||||
preset_mode?: string;
|
||||
direction?: 'forward' | 'reverse';
|
||||
oscillating?: boolean;
|
||||
[key: string]: unknown; // Index signature for Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Media player service data
|
||||
*/
|
||||
export interface IHomeAssistantMediaPlayerServiceData {
|
||||
volume_level?: number; // 0-1
|
||||
is_volume_muted?: boolean;
|
||||
media_content_id?: string;
|
||||
media_content_type?: string;
|
||||
enqueue?: 'play' | 'next' | 'add' | 'replace';
|
||||
seek_position?: number;
|
||||
source?: string;
|
||||
sound_mode?: string;
|
||||
shuffle?: boolean;
|
||||
repeat?: 'off' | 'all' | 'one';
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Discovery Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Discovered Home Assistant instance via mDNS
|
||||
*/
|
||||
export interface IHomeAssistantDiscoveredInstance {
|
||||
/** Instance ID (derived from host) */
|
||||
id: string;
|
||||
/** Host address */
|
||||
host: string;
|
||||
/** Port number */
|
||||
port: number;
|
||||
/** Base URL */
|
||||
base_url: string;
|
||||
/** mDNS TXT records */
|
||||
txtRecords: Record<string, string>;
|
||||
/** Whether connection requires token */
|
||||
requires_api_password: boolean;
|
||||
/** Friendly name from mDNS */
|
||||
friendlyName?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Protocol Events
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Events emitted by HomeAssistantProtocol
|
||||
*/
|
||||
export type THomeAssistantProtocolEvents = {
|
||||
'connected': () => void;
|
||||
'disconnected': () => void;
|
||||
'reconnecting': (attempt: number) => void;
|
||||
'authenticated': (config: IHomeAssistantConfig) => void;
|
||||
'auth:failed': (message: string) => void;
|
||||
'state:changed': (event: IHomeAssistantStateChangedEvent) => void;
|
||||
'states:loaded': (entities: IHomeAssistantEntity[]) => void;
|
||||
'error': (error: Error) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Events emitted by HomeAssistantDiscovery
|
||||
*/
|
||||
export type THomeAssistantDiscoveryEvents = {
|
||||
'instance:found': (instance: IHomeAssistantDiscoveredInstance) => void;
|
||||
'instance:lost': (instanceId: string) => void;
|
||||
'entity:found': (entity: IHomeAssistantEntity) => void;
|
||||
'entity:updated': (entity: IHomeAssistantEntity) => void;
|
||||
'entity:removed': (entityId: string) => void;
|
||||
'error': (error: Error) => void;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Helper Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Extract domain from entity_id
|
||||
*/
|
||||
export function getEntityDomain(entityId: string): THomeAssistantDomain | null {
|
||||
const domain = entityId.split('.')[0];
|
||||
const validDomains: THomeAssistantDomain[] = [
|
||||
'light', 'switch', 'sensor', 'binary_sensor', 'climate',
|
||||
'fan', 'cover', 'lock', 'camera', 'media_player'
|
||||
];
|
||||
return validDomains.includes(domain as THomeAssistantDomain)
|
||||
? domain as THomeAssistantDomain
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map HA domain to feature type
|
||||
*/
|
||||
export function domainToFeatureType(domain: THomeAssistantDomain): string {
|
||||
const mapping: Record<THomeAssistantDomain, string> = {
|
||||
'light': 'light',
|
||||
'switch': 'switch',
|
||||
'sensor': 'sensor',
|
||||
'binary_sensor': 'sensor',
|
||||
'climate': 'climate',
|
||||
'fan': 'fan',
|
||||
'cover': 'cover',
|
||||
'lock': 'lock',
|
||||
'camera': 'camera',
|
||||
'media_player': 'playback',
|
||||
};
|
||||
return mapping[domain];
|
||||
}
|
||||
|
||||
/**
|
||||
* Supported light color modes in HA
|
||||
*/
|
||||
export type THomeAssistantColorMode =
|
||||
| 'unknown'
|
||||
| 'onoff'
|
||||
| 'brightness'
|
||||
| 'color_temp'
|
||||
| 'hs'
|
||||
| 'xy'
|
||||
| 'rgb'
|
||||
| 'rgbw'
|
||||
| 'rgbww'
|
||||
| 'white';
|
||||
|
||||
/**
|
||||
* Light supported features bitmask
|
||||
*/
|
||||
export const LIGHT_SUPPORT = {
|
||||
EFFECT: 4,
|
||||
FLASH: 8,
|
||||
TRANSITION: 32,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Climate supported features bitmask
|
||||
*/
|
||||
export const CLIMATE_SUPPORT = {
|
||||
TARGET_TEMPERATURE: 1,
|
||||
TARGET_TEMPERATURE_RANGE: 2,
|
||||
TARGET_HUMIDITY: 4,
|
||||
FAN_MODE: 8,
|
||||
PRESET_MODE: 16,
|
||||
SWING_MODE: 32,
|
||||
AUX_HEAT: 64,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Cover supported features bitmask
|
||||
*/
|
||||
export const COVER_SUPPORT = {
|
||||
OPEN: 1,
|
||||
CLOSE: 2,
|
||||
SET_POSITION: 4,
|
||||
STOP: 8,
|
||||
OPEN_TILT: 16,
|
||||
CLOSE_TILT: 32,
|
||||
STOP_TILT: 64,
|
||||
SET_TILT_POSITION: 128,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Fan supported features bitmask
|
||||
*/
|
||||
export const FAN_SUPPORT = {
|
||||
SET_SPEED: 1,
|
||||
OSCILLATE: 2,
|
||||
DIRECTION: 4,
|
||||
PRESET_MODE: 8,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Lock supported features bitmask
|
||||
*/
|
||||
export const LOCK_SUPPORT = {
|
||||
OPEN: 1,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Media player supported features bitmask
|
||||
*/
|
||||
export const MEDIA_PLAYER_SUPPORT = {
|
||||
PAUSE: 1,
|
||||
SEEK: 2,
|
||||
VOLUME_SET: 4,
|
||||
VOLUME_MUTE: 8,
|
||||
PREVIOUS_TRACK: 16,
|
||||
NEXT_TRACK: 32,
|
||||
TURN_ON: 128,
|
||||
TURN_OFF: 256,
|
||||
PLAY_MEDIA: 512,
|
||||
VOLUME_STEP: 1024,
|
||||
SELECT_SOURCE: 2048,
|
||||
STOP: 4096,
|
||||
CLEAR_PLAYLIST: 8192,
|
||||
PLAY: 16384,
|
||||
SHUFFLE_SET: 32768,
|
||||
SELECT_SOUND_MODE: 65536,
|
||||
BROWSE_MEDIA: 131072,
|
||||
REPEAT_SET: 262144,
|
||||
GROUPING: 524288,
|
||||
} as const;
|
||||
@@ -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';
|
||||
|
||||
421
ts/interfaces/smarthome.interfaces.ts
Normal file
421
ts/interfaces/smarthome.interfaces.ts
Normal file
@@ -0,0 +1,421 @@
|
||||
/**
|
||||
* Smart Home Device Interfaces
|
||||
* Generic types for smart home features (lights, climate, sensors, etc.)
|
||||
* Protocol-agnostic - can be implemented by Home Assistant, Hue, MQTT, etc.
|
||||
*/
|
||||
|
||||
import type { TFeatureState, IFeatureInfo } from './feature.interfaces.js';
|
||||
|
||||
// ============================================================================
|
||||
// Light Feature Types
|
||||
// ============================================================================
|
||||
|
||||
export type TLightProtocol = 'home-assistant' | 'hue' | 'mqtt' | 'zigbee';
|
||||
|
||||
export interface ILightCapabilities {
|
||||
supportsBrightness: boolean;
|
||||
supportsColorTemp: boolean;
|
||||
supportsRgb: boolean;
|
||||
supportsHs: boolean; // Hue/Saturation
|
||||
supportsXy: boolean; // CIE xy color
|
||||
supportsEffects: boolean;
|
||||
supportsTransition: boolean;
|
||||
effects?: string[];
|
||||
minMireds?: number;
|
||||
maxMireds?: number;
|
||||
minColorTempKelvin?: number;
|
||||
maxColorTempKelvin?: number;
|
||||
}
|
||||
|
||||
export interface ILightState {
|
||||
isOn: boolean;
|
||||
brightness?: number; // 0-255
|
||||
colorTemp?: number; // Kelvin
|
||||
colorTempMireds?: number; // Mireds (1000000/Kelvin)
|
||||
rgbColor?: [number, number, number];
|
||||
hsColor?: [number, number]; // [hue 0-360, saturation 0-100]
|
||||
xyColor?: [number, number]; // CIE xy
|
||||
effect?: string;
|
||||
}
|
||||
|
||||
export interface ILightFeatureInfo extends IFeatureInfo {
|
||||
type: 'light';
|
||||
protocol: TLightProtocol;
|
||||
capabilities: ILightCapabilities;
|
||||
currentState: ILightState;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Climate/Thermostat Feature Types
|
||||
// ============================================================================
|
||||
|
||||
export type TClimateProtocol = 'home-assistant' | 'nest' | 'ecobee' | 'mqtt';
|
||||
|
||||
export type THvacMode =
|
||||
| 'off'
|
||||
| 'heat'
|
||||
| 'cool'
|
||||
| 'heat_cool' // Auto dual setpoint
|
||||
| 'auto'
|
||||
| 'dry'
|
||||
| 'fan_only';
|
||||
|
||||
export type THvacAction =
|
||||
| 'off'
|
||||
| 'heating'
|
||||
| 'cooling'
|
||||
| 'drying'
|
||||
| 'idle'
|
||||
| 'fan';
|
||||
|
||||
export interface IClimateCapabilities {
|
||||
hvacModes: THvacMode[];
|
||||
presetModes?: string[]; // 'away', 'eco', 'boost', 'sleep'
|
||||
fanModes?: string[]; // 'auto', 'low', 'medium', 'high'
|
||||
swingModes?: string[]; // 'off', 'vertical', 'horizontal', 'both'
|
||||
supportsTargetTemp: boolean;
|
||||
supportsTargetTempRange: boolean; // For heat_cool mode
|
||||
supportsHumidity: boolean;
|
||||
supportsAuxHeat: boolean;
|
||||
minTemp: number;
|
||||
maxTemp: number;
|
||||
tempStep: number; // Temperature increment (e.g., 0.5, 1)
|
||||
minHumidity?: number;
|
||||
maxHumidity?: number;
|
||||
}
|
||||
|
||||
export interface IClimateState {
|
||||
currentTemp?: number;
|
||||
targetTemp?: number;
|
||||
targetTempHigh?: number; // For heat_cool mode
|
||||
targetTempLow?: number; // For heat_cool mode
|
||||
hvacMode: THvacMode;
|
||||
hvacAction?: THvacAction;
|
||||
presetMode?: string;
|
||||
fanMode?: string;
|
||||
swingMode?: string;
|
||||
humidity?: number;
|
||||
targetHumidity?: number;
|
||||
auxHeat?: boolean;
|
||||
}
|
||||
|
||||
export interface IClimateFeatureInfo extends IFeatureInfo {
|
||||
type: 'climate';
|
||||
protocol: TClimateProtocol;
|
||||
capabilities: IClimateCapabilities;
|
||||
currentState: IClimateState;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Sensor Feature Types
|
||||
// ============================================================================
|
||||
|
||||
export type TSensorProtocol = 'home-assistant' | 'mqtt' | 'snmp';
|
||||
|
||||
export type TSensorDeviceClass =
|
||||
| 'temperature'
|
||||
| 'humidity'
|
||||
| 'pressure'
|
||||
| 'illuminance'
|
||||
| 'battery'
|
||||
| 'power'
|
||||
| 'energy'
|
||||
| 'voltage'
|
||||
| 'current'
|
||||
| 'frequency'
|
||||
| 'gas'
|
||||
| 'co2'
|
||||
| 'pm25'
|
||||
| 'pm10'
|
||||
| 'signal_strength'
|
||||
| 'timestamp'
|
||||
| 'duration'
|
||||
| 'distance'
|
||||
| 'speed'
|
||||
| 'weight'
|
||||
| 'monetary'
|
||||
| 'data_size'
|
||||
| 'data_rate'
|
||||
| 'water'
|
||||
| 'irradiance'
|
||||
| 'precipitation'
|
||||
| 'precipitation_intensity'
|
||||
| 'wind_speed';
|
||||
|
||||
export type TSensorStateClass =
|
||||
| 'measurement' // Instantaneous reading
|
||||
| 'total' // Cumulative total
|
||||
| 'total_increasing'; // Monotonically increasing total
|
||||
|
||||
export interface ISensorCapabilities {
|
||||
deviceClass?: TSensorDeviceClass;
|
||||
stateClass?: TSensorStateClass;
|
||||
unit?: string;
|
||||
nativeUnit?: string;
|
||||
precision?: number; // Decimal places
|
||||
}
|
||||
|
||||
export interface ISensorState {
|
||||
value: string | number | boolean;
|
||||
numericValue?: number;
|
||||
unit?: string;
|
||||
lastUpdated: Date;
|
||||
}
|
||||
|
||||
export interface ISensorFeatureInfo extends IFeatureInfo {
|
||||
type: 'sensor';
|
||||
protocol: TSensorProtocol;
|
||||
capabilities: ISensorCapabilities;
|
||||
currentState: ISensorState;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Camera Feature Types
|
||||
// ============================================================================
|
||||
|
||||
export type TCameraProtocol = 'home-assistant' | 'onvif' | 'rtsp';
|
||||
|
||||
export interface ICameraCapabilities {
|
||||
supportsStream: boolean;
|
||||
supportsPtz: boolean; // Pan-tilt-zoom
|
||||
supportsSnapshot: boolean;
|
||||
supportsMotionDetection: boolean;
|
||||
frontendStreamType?: 'hls' | 'web_rtc';
|
||||
streamUrl?: string;
|
||||
}
|
||||
|
||||
export interface ICameraState {
|
||||
isRecording: boolean;
|
||||
isStreaming: boolean;
|
||||
motionDetected: boolean;
|
||||
}
|
||||
|
||||
export interface ICameraFeatureInfo extends IFeatureInfo {
|
||||
type: 'camera';
|
||||
protocol: TCameraProtocol;
|
||||
capabilities: ICameraCapabilities;
|
||||
currentState: ICameraState;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Cover/Blind Feature Types
|
||||
// ============================================================================
|
||||
|
||||
export type TCoverProtocol = 'home-assistant' | 'mqtt' | 'somfy';
|
||||
|
||||
export type TCoverDeviceClass =
|
||||
| 'awning'
|
||||
| 'blind'
|
||||
| 'curtain'
|
||||
| 'damper'
|
||||
| 'door'
|
||||
| 'garage'
|
||||
| 'gate'
|
||||
| 'shade'
|
||||
| 'shutter'
|
||||
| 'window';
|
||||
|
||||
export type TCoverState = 'open' | 'opening' | 'closed' | 'closing' | 'stopped' | 'unknown';
|
||||
|
||||
export interface ICoverCapabilities {
|
||||
deviceClass?: TCoverDeviceClass;
|
||||
supportsOpen: boolean;
|
||||
supportsClose: boolean;
|
||||
supportsStop: boolean;
|
||||
supportsPosition: boolean; // set_cover_position
|
||||
supportsTilt: boolean; // set_cover_tilt_position
|
||||
}
|
||||
|
||||
export interface ICoverStateInfo {
|
||||
state: TCoverState;
|
||||
position?: number; // 0-100, 0 = closed, 100 = fully open
|
||||
tiltPosition?: number; // 0-100
|
||||
}
|
||||
|
||||
export interface ICoverFeatureInfo extends IFeatureInfo {
|
||||
type: 'cover';
|
||||
protocol: TCoverProtocol;
|
||||
capabilities: ICoverCapabilities;
|
||||
currentState: ICoverStateInfo;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Switch Feature Types
|
||||
// ============================================================================
|
||||
|
||||
export type TSwitchProtocol = 'home-assistant' | 'mqtt' | 'tasmota' | 'tuya';
|
||||
|
||||
export type TSwitchDeviceClass = 'outlet' | 'switch';
|
||||
|
||||
export interface ISwitchCapabilities {
|
||||
deviceClass?: TSwitchDeviceClass;
|
||||
}
|
||||
|
||||
export interface ISwitchState {
|
||||
isOn: boolean;
|
||||
}
|
||||
|
||||
export interface ISwitchFeatureInfo extends IFeatureInfo {
|
||||
type: 'switch';
|
||||
protocol: TSwitchProtocol;
|
||||
capabilities: ISwitchCapabilities;
|
||||
currentState: ISwitchState;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Lock Feature Types
|
||||
// ============================================================================
|
||||
|
||||
export type TLockProtocol = 'home-assistant' | 'mqtt' | 'august' | 'yale';
|
||||
|
||||
export type TLockState =
|
||||
| 'locked'
|
||||
| 'unlocked'
|
||||
| 'locking'
|
||||
| 'unlocking'
|
||||
| 'jammed'
|
||||
| 'unknown';
|
||||
|
||||
export interface ILockCapabilities {
|
||||
supportsOpen: boolean; // Physical open (some locks can open the door)
|
||||
}
|
||||
|
||||
export interface ILockStateInfo {
|
||||
state: TLockState;
|
||||
isLocked: boolean;
|
||||
}
|
||||
|
||||
export interface ILockFeatureInfo extends IFeatureInfo {
|
||||
type: 'lock';
|
||||
protocol: TLockProtocol;
|
||||
capabilities: ILockCapabilities;
|
||||
currentState: ILockStateInfo;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Fan Feature Types
|
||||
// ============================================================================
|
||||
|
||||
export type TFanProtocol = 'home-assistant' | 'mqtt' | 'bond';
|
||||
|
||||
export type TFanDirection = 'forward' | 'reverse';
|
||||
|
||||
export interface IFanCapabilities {
|
||||
supportsSpeed: boolean;
|
||||
supportsOscillate: boolean;
|
||||
supportsDirection: boolean;
|
||||
supportsPresetModes: boolean;
|
||||
presetModes?: string[];
|
||||
speedCount?: number; // Number of discrete speed levels
|
||||
}
|
||||
|
||||
export interface IFanState {
|
||||
isOn: boolean;
|
||||
percentage?: number; // 0-100 speed
|
||||
presetMode?: string;
|
||||
oscillating?: boolean;
|
||||
direction?: TFanDirection;
|
||||
}
|
||||
|
||||
export interface IFanFeatureInfo extends IFeatureInfo {
|
||||
type: 'fan';
|
||||
protocol: TFanProtocol;
|
||||
capabilities: IFanCapabilities;
|
||||
currentState: IFanState;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Protocol Client Interfaces (for dependency injection)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Light protocol client interface
|
||||
* Implemented by HomeAssistantProtocol, HueProtocol, etc.
|
||||
*/
|
||||
export interface ILightProtocolClient {
|
||||
turnOn(entityId: string, options?: { brightness?: number; colorTemp?: number; rgb?: [number, number, number]; transition?: number }): Promise<void>;
|
||||
turnOff(entityId: string, options?: { transition?: number }): Promise<void>;
|
||||
toggle(entityId: string): Promise<void>;
|
||||
setBrightness(entityId: string, brightness: number, transition?: number): Promise<void>;
|
||||
setColorTemp(entityId: string, kelvin: number, transition?: number): Promise<void>;
|
||||
setRgbColor(entityId: string, r: number, g: number, b: number, transition?: number): Promise<void>;
|
||||
setEffect(entityId: string, effect: string): Promise<void>;
|
||||
getState(entityId: string): Promise<ILightState>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Climate protocol client interface
|
||||
*/
|
||||
export interface IClimateProtocolClient {
|
||||
setHvacMode(entityId: string, mode: THvacMode): Promise<void>;
|
||||
setTargetTemp(entityId: string, temp: number): Promise<void>;
|
||||
setTargetTempRange(entityId: string, low: number, high: number): Promise<void>;
|
||||
setPresetMode(entityId: string, preset: string): Promise<void>;
|
||||
setFanMode(entityId: string, mode: string): Promise<void>;
|
||||
setSwingMode(entityId: string, mode: string): Promise<void>;
|
||||
setAuxHeat(entityId: string, enabled: boolean): Promise<void>;
|
||||
getState(entityId: string): Promise<IClimateState>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sensor protocol client interface (read-only)
|
||||
*/
|
||||
export interface ISensorProtocolClient {
|
||||
getState(entityId: string): Promise<ISensorState>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Camera protocol client interface
|
||||
*/
|
||||
export interface ICameraProtocolClient {
|
||||
getSnapshot(entityId: string): Promise<Buffer>;
|
||||
getSnapshotUrl(entityId: string): Promise<string>;
|
||||
getStreamUrl(entityId: string): Promise<string>;
|
||||
getState(entityId: string): Promise<ICameraState>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cover protocol client interface
|
||||
*/
|
||||
export interface ICoverProtocolClient {
|
||||
open(entityId: string): Promise<void>;
|
||||
close(entityId: string): Promise<void>;
|
||||
stop(entityId: string): Promise<void>;
|
||||
setPosition(entityId: string, position: number): Promise<void>;
|
||||
setTiltPosition(entityId: string, position: number): Promise<void>;
|
||||
getState(entityId: string): Promise<ICoverStateInfo>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch protocol client interface
|
||||
*/
|
||||
export interface ISwitchProtocolClient {
|
||||
turnOn(entityId: string): Promise<void>;
|
||||
turnOff(entityId: string): Promise<void>;
|
||||
toggle(entityId: string): Promise<void>;
|
||||
getState(entityId: string): Promise<ISwitchState>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock protocol client interface
|
||||
*/
|
||||
export interface ILockProtocolClient {
|
||||
lock(entityId: string): Promise<void>;
|
||||
unlock(entityId: string): Promise<void>;
|
||||
open(entityId: string): Promise<void>; // Physical open if supported
|
||||
getState(entityId: string): Promise<ILockStateInfo>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fan protocol client interface
|
||||
*/
|
||||
export interface IFanProtocolClient {
|
||||
turnOn(entityId: string, percentage?: number): Promise<void>;
|
||||
turnOff(entityId: string): Promise<void>;
|
||||
toggle(entityId: string): Promise<void>;
|
||||
setPercentage(entityId: string, percentage: number): Promise<void>;
|
||||
setPresetMode(entityId: string, mode: string): Promise<void>;
|
||||
setOscillating(entityId: string, oscillating: boolean): Promise<void>;
|
||||
setDirection(entityId: string, direction: TFanDirection): Promise<void>;
|
||||
getState(entityId: string): Promise<IFanState>;
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -54,3 +54,6 @@ export {
|
||||
type TUpsTestResult,
|
||||
type IUpsSnmpStatus,
|
||||
} from './protocol.upssnmp.js';
|
||||
|
||||
// Home Assistant WebSocket protocol
|
||||
export { HomeAssistantProtocol } from './protocol.homeassistant.js';
|
||||
|
||||
737
ts/protocols/protocol.homeassistant.ts
Normal file
737
ts/protocols/protocol.homeassistant.ts
Normal file
@@ -0,0 +1,737 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type {
|
||||
IHomeAssistantInstanceConfig,
|
||||
IHomeAssistantEntity,
|
||||
IHomeAssistantConfig,
|
||||
IHomeAssistantStateChangedEvent,
|
||||
IHomeAssistantMessage,
|
||||
IHomeAssistantAuthRequired,
|
||||
IHomeAssistantAuthOk,
|
||||
IHomeAssistantAuthInvalid,
|
||||
IHomeAssistantResult,
|
||||
IHomeAssistantEvent,
|
||||
THomeAssistantProtocolEvents,
|
||||
IHomeAssistantLightServiceData,
|
||||
IHomeAssistantClimateServiceData,
|
||||
IHomeAssistantCoverServiceData,
|
||||
IHomeAssistantFanServiceData,
|
||||
IHomeAssistantMediaPlayerServiceData,
|
||||
} from '../interfaces/homeassistant.interfaces.js';
|
||||
|
||||
/**
|
||||
* Home Assistant WebSocket Protocol Handler
|
||||
* Connects to HA via WebSocket, handles authentication, state subscriptions, and service calls
|
||||
*/
|
||||
export class HomeAssistantProtocol extends plugins.events.EventEmitter {
|
||||
private ws: InstanceType<typeof plugins.WebSocket> | null = null;
|
||||
private config: IHomeAssistantInstanceConfig;
|
||||
private messageId: number = 1;
|
||||
private pendingRequests: Map<number, {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
}> = new Map();
|
||||
private isAuthenticated: boolean = false;
|
||||
private haConfig: IHomeAssistantConfig | null = null;
|
||||
private reconnectAttempt: number = 0;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private stateSubscriptionId: number | null = null;
|
||||
private entityStates: Map<string, IHomeAssistantEntity> = new Map();
|
||||
private intentionalDisconnect: boolean = false;
|
||||
|
||||
constructor(config: IHomeAssistantInstanceConfig) {
|
||||
super();
|
||||
this.config = {
|
||||
port: 8123,
|
||||
secure: false,
|
||||
autoReconnect: true,
|
||||
reconnectDelay: 5000,
|
||||
...config,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the WebSocket URL for this HA instance
|
||||
*/
|
||||
private get wsUrl(): string {
|
||||
const protocol = this.config.secure ? 'wss' : 'ws';
|
||||
return `${protocol}://${this.config.host}:${this.config.port}/api/websocket`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection state
|
||||
*/
|
||||
public get isConnected(): boolean {
|
||||
return this.ws !== null && this.ws.readyState === plugins.WebSocket.OPEN && this.isAuthenticated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get HA config if authenticated
|
||||
*/
|
||||
public get homeAssistantConfig(): IHomeAssistantConfig | null {
|
||||
return this.haConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all cached entity states
|
||||
*/
|
||||
public get entities(): Map<string, IHomeAssistantEntity> {
|
||||
return this.entityStates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to Home Assistant
|
||||
*/
|
||||
public async connect(): Promise<void> {
|
||||
if (this.ws && this.ws.readyState === plugins.WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.intentionalDisconnect = false;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
this.ws = new plugins.WebSocket(this.wsUrl);
|
||||
|
||||
const connectionTimeout = setTimeout(() => {
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
reject(new Error(`Connection timeout to ${this.wsUrl}`));
|
||||
}, 10000);
|
||||
|
||||
this.ws.on('open', () => {
|
||||
// Connection established, waiting for auth_required
|
||||
});
|
||||
|
||||
this.ws.on('message', async (data: Buffer | string) => {
|
||||
const message = JSON.parse(data.toString()) as IHomeAssistantMessage;
|
||||
|
||||
if (message.type === 'auth_required') {
|
||||
// Send authentication
|
||||
await this.sendAuth();
|
||||
} else if (message.type === 'auth_ok') {
|
||||
clearTimeout(connectionTimeout);
|
||||
this.isAuthenticated = true;
|
||||
this.reconnectAttempt = 0;
|
||||
|
||||
// Get HA config
|
||||
try {
|
||||
this.haConfig = await this.getConfig();
|
||||
this.emit('authenticated', this.haConfig);
|
||||
} catch (err) {
|
||||
// Non-fatal, continue anyway
|
||||
}
|
||||
|
||||
this.emit('connected');
|
||||
resolve();
|
||||
} else if (message.type === 'auth_invalid') {
|
||||
clearTimeout(connectionTimeout);
|
||||
const authInvalid = message as IHomeAssistantAuthInvalid;
|
||||
this.emit('auth:failed', authInvalid.message);
|
||||
reject(new Error(`Authentication failed: ${authInvalid.message}`));
|
||||
} else {
|
||||
// Handle other messages
|
||||
this.handleMessage(message);
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.on('error', (error: Error) => {
|
||||
this.emit('error', error);
|
||||
});
|
||||
|
||||
this.ws.on('close', () => {
|
||||
this.isAuthenticated = false;
|
||||
this.stateSubscriptionId = null;
|
||||
|
||||
// Reject all pending requests
|
||||
for (const [id, request] of this.pendingRequests) {
|
||||
clearTimeout(request.timeout);
|
||||
request.reject(new Error('Connection closed'));
|
||||
this.pendingRequests.delete(id);
|
||||
}
|
||||
|
||||
this.emit('disconnected');
|
||||
|
||||
// Auto-reconnect if not intentional
|
||||
if (this.config.autoReconnect && !this.intentionalDisconnect) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from Home Assistant
|
||||
*/
|
||||
public async disconnect(): Promise<void> {
|
||||
this.intentionalDisconnect = true;
|
||||
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
|
||||
if (this.ws) {
|
||||
// Clear all pending requests
|
||||
for (const [id, request] of this.pendingRequests) {
|
||||
clearTimeout(request.timeout);
|
||||
request.reject(new Error('Disconnecting'));
|
||||
this.pendingRequests.delete(id);
|
||||
}
|
||||
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
this.isAuthenticated = false;
|
||||
this.stateSubscriptionId = null;
|
||||
this.entityStates.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send authentication message
|
||||
*/
|
||||
private async sendAuth(): Promise<void> {
|
||||
if (!this.ws) return;
|
||||
|
||||
const authMessage = {
|
||||
type: 'auth',
|
||||
access_token: this.config.token,
|
||||
};
|
||||
|
||||
this.ws.send(JSON.stringify(authMessage));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming messages
|
||||
*/
|
||||
private handleMessage(message: IHomeAssistantMessage): void {
|
||||
if (message.type === 'result') {
|
||||
const result = message as IHomeAssistantResult;
|
||||
const pending = this.pendingRequests.get(result.id);
|
||||
|
||||
if (pending) {
|
||||
clearTimeout(pending.timeout);
|
||||
this.pendingRequests.delete(result.id);
|
||||
|
||||
if (result.success) {
|
||||
pending.resolve(result.result);
|
||||
} else {
|
||||
pending.reject(new Error(result.error?.message || 'Unknown error'));
|
||||
}
|
||||
}
|
||||
} else if (message.type === 'event') {
|
||||
const event = message as IHomeAssistantEvent;
|
||||
|
||||
if (event.event.event_type === 'state_changed') {
|
||||
const stateChanged = event.event.data as IHomeAssistantStateChangedEvent;
|
||||
|
||||
// Update cached state
|
||||
if (stateChanged.new_state) {
|
||||
this.entityStates.set(stateChanged.entity_id, stateChanged.new_state);
|
||||
} else {
|
||||
this.entityStates.delete(stateChanged.entity_id);
|
||||
}
|
||||
|
||||
this.emit('state:changed', stateChanged);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request and wait for response
|
||||
*/
|
||||
private async sendRequest<T>(type: string, data: Record<string, unknown> = {}): Promise<T> {
|
||||
if (!this.ws || !this.isAuthenticated) {
|
||||
throw new Error('Not connected to Home Assistant');
|
||||
}
|
||||
|
||||
const id = this.messageId++;
|
||||
const message = { id, type, ...data };
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.pendingRequests.delete(id);
|
||||
reject(new Error(`Request timeout: ${type}`));
|
||||
}, 30000);
|
||||
|
||||
this.pendingRequests.set(id, {
|
||||
resolve: resolve as (value: unknown) => void,
|
||||
reject,
|
||||
timeout,
|
||||
});
|
||||
|
||||
this.ws!.send(JSON.stringify(message));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule reconnection attempt
|
||||
*/
|
||||
private scheduleReconnect(): void {
|
||||
if (this.reconnectTimer) return;
|
||||
|
||||
this.reconnectAttempt++;
|
||||
const delay = Math.min(
|
||||
this.config.reconnectDelay! * Math.pow(1.5, this.reconnectAttempt - 1),
|
||||
60000 // Max 60 seconds
|
||||
);
|
||||
|
||||
this.emit('reconnecting', this.reconnectAttempt);
|
||||
|
||||
this.reconnectTimer = setTimeout(async () => {
|
||||
this.reconnectTimer = null;
|
||||
|
||||
try {
|
||||
await this.connect();
|
||||
|
||||
// Re-subscribe to state changes
|
||||
if (this.isConnected) {
|
||||
await this.subscribeToStateChanges();
|
||||
}
|
||||
} catch {
|
||||
// connect() will schedule another reconnect on failure
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Public API - State
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get HA config
|
||||
*/
|
||||
public async getConfig(): Promise<IHomeAssistantConfig> {
|
||||
return this.sendRequest<IHomeAssistantConfig>('get_config');
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to state change events
|
||||
*/
|
||||
public async subscribeToStateChanges(): Promise<number> {
|
||||
const result = await this.sendRequest<{ context: { id: string } }>('subscribe_events', {
|
||||
event_type: 'state_changed',
|
||||
});
|
||||
|
||||
// Get all current states after subscribing
|
||||
const states = await this.getStates();
|
||||
for (const entity of states) {
|
||||
this.entityStates.set(entity.entity_id, entity);
|
||||
}
|
||||
this.emit('states:loaded', states);
|
||||
|
||||
this.stateSubscriptionId = this.messageId - 1;
|
||||
return this.stateSubscriptionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all entity states
|
||||
*/
|
||||
public async getStates(): Promise<IHomeAssistantEntity[]> {
|
||||
return this.sendRequest<IHomeAssistantEntity[]>('get_states');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific entity state
|
||||
*/
|
||||
public async getState(entityId: string): Promise<IHomeAssistantEntity | null> {
|
||||
// First check cache
|
||||
const cached = this.entityStates.get(entityId);
|
||||
if (cached) return cached;
|
||||
|
||||
// Otherwise fetch all states and find it
|
||||
const states = await this.getStates();
|
||||
return states.find((s) => s.entity_id === entityId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get entities by domain
|
||||
*/
|
||||
public async getEntitiesByDomain(domain: string): Promise<IHomeAssistantEntity[]> {
|
||||
const states = await this.getStates();
|
||||
return states.filter((s) => s.entity_id.startsWith(`${domain}.`));
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Public API - Service Calls
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Call a Home Assistant service
|
||||
*/
|
||||
public async callService(
|
||||
domain: string,
|
||||
service: string,
|
||||
target?: { entity_id?: string | string[]; device_id?: string | string[]; area_id?: string | string[] },
|
||||
serviceData?: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
await this.sendRequest('call_service', {
|
||||
domain,
|
||||
service,
|
||||
target,
|
||||
service_data: serviceData,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn on an entity
|
||||
*/
|
||||
public async turnOn(entityId: string, data?: Record<string, unknown>): Promise<void> {
|
||||
const domain = entityId.split('.')[0];
|
||||
await this.callService(domain, 'turn_on', { entity_id: entityId }, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn off an entity
|
||||
*/
|
||||
public async turnOff(entityId: string): Promise<void> {
|
||||
const domain = entityId.split('.')[0];
|
||||
await this.callService(domain, 'turn_off', { entity_id: entityId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle an entity
|
||||
*/
|
||||
public async toggle(entityId: string): Promise<void> {
|
||||
const domain = entityId.split('.')[0];
|
||||
await this.callService(domain, 'toggle', { entity_id: entityId });
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Light Services
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Control a light
|
||||
*/
|
||||
public async lightTurnOn(entityId: string, options?: IHomeAssistantLightServiceData): Promise<void> {
|
||||
await this.callService('light', 'turn_on', { entity_id: entityId }, options);
|
||||
}
|
||||
|
||||
public async lightTurnOff(entityId: string, options?: { transition?: number }): Promise<void> {
|
||||
await this.callService('light', 'turn_off', { entity_id: entityId }, options);
|
||||
}
|
||||
|
||||
public async lightToggle(entityId: string, options?: IHomeAssistantLightServiceData): Promise<void> {
|
||||
await this.callService('light', 'toggle', { entity_id: entityId }, options);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Climate Services
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Set HVAC mode
|
||||
*/
|
||||
public async climateSetHvacMode(entityId: string, hvacMode: string): Promise<void> {
|
||||
await this.callService('climate', 'set_hvac_mode', { entity_id: entityId }, { hvac_mode: hvacMode });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set target temperature
|
||||
*/
|
||||
public async climateSetTemperature(entityId: string, options: IHomeAssistantClimateServiceData): Promise<void> {
|
||||
await this.callService('climate', 'set_temperature', { entity_id: entityId }, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set fan mode
|
||||
*/
|
||||
public async climateSetFanMode(entityId: string, fanMode: string): Promise<void> {
|
||||
await this.callService('climate', 'set_fan_mode', { entity_id: entityId }, { fan_mode: fanMode });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set preset mode
|
||||
*/
|
||||
public async climateSetPresetMode(entityId: string, presetMode: string): Promise<void> {
|
||||
await this.callService('climate', 'set_preset_mode', { entity_id: entityId }, { preset_mode: presetMode });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set swing mode
|
||||
*/
|
||||
public async climateSetSwingMode(entityId: string, swingMode: string): Promise<void> {
|
||||
await this.callService('climate', 'set_swing_mode', { entity_id: entityId }, { swing_mode: swingMode });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set aux heat
|
||||
*/
|
||||
public async climateSetAuxHeat(entityId: string, auxHeat: boolean): Promise<void> {
|
||||
await this.callService('climate', 'set_aux_heat', { entity_id: entityId }, { aux_heat: auxHeat });
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Cover Services
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Open cover
|
||||
*/
|
||||
public async coverOpen(entityId: string): Promise<void> {
|
||||
await this.callService('cover', 'open_cover', { entity_id: entityId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Close cover
|
||||
*/
|
||||
public async coverClose(entityId: string): Promise<void> {
|
||||
await this.callService('cover', 'close_cover', { entity_id: entityId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop cover
|
||||
*/
|
||||
public async coverStop(entityId: string): Promise<void> {
|
||||
await this.callService('cover', 'stop_cover', { entity_id: entityId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set cover position
|
||||
*/
|
||||
public async coverSetPosition(entityId: string, position: number): Promise<void> {
|
||||
await this.callService('cover', 'set_cover_position', { entity_id: entityId }, { position });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set cover tilt position
|
||||
*/
|
||||
public async coverSetTiltPosition(entityId: string, tiltPosition: number): Promise<void> {
|
||||
await this.callService('cover', 'set_cover_tilt_position', { entity_id: entityId }, { tilt_position: tiltPosition });
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Fan Services
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Turn on fan
|
||||
*/
|
||||
public async fanTurnOn(entityId: string, options?: IHomeAssistantFanServiceData): Promise<void> {
|
||||
await this.callService('fan', 'turn_on', { entity_id: entityId }, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn off fan
|
||||
*/
|
||||
public async fanTurnOff(entityId: string): Promise<void> {
|
||||
await this.callService('fan', 'turn_off', { entity_id: entityId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set fan percentage
|
||||
*/
|
||||
public async fanSetPercentage(entityId: string, percentage: number): Promise<void> {
|
||||
await this.callService('fan', 'set_percentage', { entity_id: entityId }, { percentage });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set fan preset mode
|
||||
*/
|
||||
public async fanSetPresetMode(entityId: string, presetMode: string): Promise<void> {
|
||||
await this.callService('fan', 'set_preset_mode', { entity_id: entityId }, { preset_mode: presetMode });
|
||||
}
|
||||
|
||||
/**
|
||||
* Oscillate fan
|
||||
*/
|
||||
public async fanOscillate(entityId: string, oscillating: boolean): Promise<void> {
|
||||
await this.callService('fan', 'oscillate', { entity_id: entityId }, { oscillating });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set fan direction
|
||||
*/
|
||||
public async fanSetDirection(entityId: string, direction: 'forward' | 'reverse'): Promise<void> {
|
||||
await this.callService('fan', 'set_direction', { entity_id: entityId }, { direction });
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Lock Services
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Lock
|
||||
*/
|
||||
public async lockLock(entityId: string): Promise<void> {
|
||||
await this.callService('lock', 'lock', { entity_id: entityId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlock
|
||||
*/
|
||||
public async lockUnlock(entityId: string): Promise<void> {
|
||||
await this.callService('lock', 'unlock', { entity_id: entityId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Open (if supported)
|
||||
*/
|
||||
public async lockOpen(entityId: string): Promise<void> {
|
||||
await this.callService('lock', 'open', { entity_id: entityId });
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Switch Services
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Turn on switch
|
||||
*/
|
||||
public async switchTurnOn(entityId: string): Promise<void> {
|
||||
await this.callService('switch', 'turn_on', { entity_id: entityId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn off switch
|
||||
*/
|
||||
public async switchTurnOff(entityId: string): Promise<void> {
|
||||
await this.callService('switch', 'turn_off', { entity_id: entityId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle switch
|
||||
*/
|
||||
public async switchToggle(entityId: string): Promise<void> {
|
||||
await this.callService('switch', 'toggle', { entity_id: entityId });
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Media Player Services
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Play media
|
||||
*/
|
||||
public async mediaPlayerPlay(entityId: string): Promise<void> {
|
||||
await this.callService('media_player', 'media_play', { entity_id: entityId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause media
|
||||
*/
|
||||
public async mediaPlayerPause(entityId: string): Promise<void> {
|
||||
await this.callService('media_player', 'media_pause', { entity_id: entityId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop media
|
||||
*/
|
||||
public async mediaPlayerStop(entityId: string): Promise<void> {
|
||||
await this.callService('media_player', 'media_stop', { entity_id: entityId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Next track
|
||||
*/
|
||||
public async mediaPlayerNext(entityId: string): Promise<void> {
|
||||
await this.callService('media_player', 'media_next_track', { entity_id: entityId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Previous track
|
||||
*/
|
||||
public async mediaPlayerPrevious(entityId: string): Promise<void> {
|
||||
await this.callService('media_player', 'media_previous_track', { entity_id: entityId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set volume
|
||||
*/
|
||||
public async mediaPlayerSetVolume(entityId: string, volumeLevel: number): Promise<void> {
|
||||
await this.callService('media_player', 'volume_set', { entity_id: entityId }, { volume_level: volumeLevel });
|
||||
}
|
||||
|
||||
/**
|
||||
* Mute/unmute
|
||||
*/
|
||||
public async mediaPlayerMute(entityId: string, isMuted: boolean): Promise<void> {
|
||||
await this.callService('media_player', 'volume_mute', { entity_id: entityId }, { is_volume_muted: isMuted });
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek to position
|
||||
*/
|
||||
public async mediaPlayerSeek(entityId: string, position: number): Promise<void> {
|
||||
await this.callService('media_player', 'media_seek', { entity_id: entityId }, { seek_position: position });
|
||||
}
|
||||
|
||||
/**
|
||||
* Select source
|
||||
*/
|
||||
public async mediaPlayerSelectSource(entityId: string, source: string): Promise<void> {
|
||||
await this.callService('media_player', 'select_source', { entity_id: entityId }, { source });
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Camera Services
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get camera snapshot URL
|
||||
*/
|
||||
public getCameraSnapshotUrl(entityId: string): string {
|
||||
const protocol = this.config.secure ? 'https' : 'http';
|
||||
const entity = this.entityStates.get(entityId);
|
||||
const accessToken = (entity?.attributes as { access_token?: string })?.access_token || '';
|
||||
return `${protocol}://${this.config.host}:${this.config.port}/api/camera_proxy/${entityId}?token=${accessToken}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get camera stream URL
|
||||
*/
|
||||
public getCameraStreamUrl(entityId: string): string {
|
||||
const protocol = this.config.secure ? 'https' : 'http';
|
||||
return `${protocol}://${this.config.host}:${this.config.port}/api/camera_proxy_stream/${entityId}`;
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Static Helpers
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Probe if a Home Assistant instance is reachable
|
||||
*/
|
||||
public static async probe(host: string, port: number = 8123, secure: boolean = false, timeout: number = 5000): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const protocol = secure ? 'wss' : 'ws';
|
||||
const url = `${protocol}://${host}:${port}/api/websocket`;
|
||||
|
||||
try {
|
||||
const ws = new plugins.WebSocket(url);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
ws.close();
|
||||
resolve(false);
|
||||
}, timeout);
|
||||
|
||||
ws.on('open', () => {
|
||||
// Wait for auth_required message
|
||||
});
|
||||
|
||||
ws.on('message', (data: Buffer | string) => {
|
||||
const message = JSON.parse(data.toString()) as IHomeAssistantMessage;
|
||||
if (message.type === 'auth_required') {
|
||||
clearTimeout(timer);
|
||||
ws.close();
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', () => {
|
||||
clearTimeout(timer);
|
||||
resolve(false);
|
||||
});
|
||||
} catch {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user