Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d72ea96ec5 | |||
| 38a6e5c250 | |||
| 7bcec69658 | |||
| 4b61ed31bc | |||
| de34e83b3d | |||
| 181c4f5d5d |
30
changelog.md
30
changelog.md
@@ -1,5 +1,35 @@
|
|||||||
# Changelog
|
# 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
|
||||||
|
|
||||||
|
- Add TNameSource type and NAME_SOURCE_PRIORITY to rank name sources (generic, manual, airplay, chromecast, mdns, dlna, sonos).
|
||||||
|
- Replace chooseBestName with shouldUpdateName that validates 'real' names and uses source priority when deciding to update a device name.
|
||||||
|
- Add nameSourceByIp map to track which discovery source provided the current name and persist updates during registration.
|
||||||
|
- Register devices with an explicit nameSource (e.g. 'mdns', 'dlna', 'sonos', 'manual') and map speaker protocols to appropriate name sources.
|
||||||
|
- Ensure manual additions use 'manual' source and non-real names default to 'generic'.
|
||||||
|
- Clear nameSourceByIp entries when devices are removed/disconnected and on shutdown.
|
||||||
|
|
||||||
|
## 2026-01-09 - 2.0.0 - BREAKING CHANGE(core)
|
||||||
|
rework core device architecture: consolidate protocols into a protocols/ module, introduce UniversalDevice + factories, and remove many legacy device-specific classes (breaking API changes)
|
||||||
|
|
||||||
|
- Consolidated protocol implementations into ts/protocols and added protocols/index.ts for unified exports.
|
||||||
|
- Added device factory layer at ts/factories/index.ts to create UniversalDevice instances with appropriate features.
|
||||||
|
- Introduced protocols/protocol.upssnmp.ts (UPS SNMP handler) and other protocol reorganizations.
|
||||||
|
- Removed legacy concrete device classes and related files (Device abstract, Scanner, Printer, SnmpDevice, UpsDevice, DlnaRenderer/Server, Speaker and Sonos/AirPlay/Chromecast implementations).
|
||||||
|
- Updated top-level ts/index.ts exports to prefer UniversalDevice, factories and the new protocols module.
|
||||||
|
- Updated feature and discovery modules to import protocols from the new protocols index (import path changes).
|
||||||
|
- BREAKING: Consumers must update imports and device creation flows to use the new factories/UniversalDevice and protocols exports instead of the removed legacy classes.
|
||||||
|
|
||||||
## 2026-01-09 - 1.1.0 - feat(devicemanager)
|
## 2026-01-09 - 1.1.0 - feat(devicemanager)
|
||||||
Introduce a UniversalDevice architecture with composable Feature system; add extensive new device/protocol support and discovery/refactors
|
Introduce a UniversalDevice architecture with composable Feature system; add extensive new device/protocol support and discovery/refactors
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ecobridge.xyz/devicemanager",
|
"name": "@ecobridge.xyz/devicemanager",
|
||||||
"version": "1.1.0",
|
"version": "2.2.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "a device manager for talking to devices on network and over usb",
|
"description": "a device manager for talking to devices on network and over usb",
|
||||||
"main": "dist_ts/index.js",
|
"main": "dist_ts/index.js",
|
||||||
@@ -17,7 +17,8 @@
|
|||||||
"@git.zone/tsbuild": "^4.1.0",
|
"@git.zone/tsbuild": "^4.1.0",
|
||||||
"@git.zone/tsrun": "^2.0.0",
|
"@git.zone/tsrun": "^2.0.0",
|
||||||
"@git.zone/tstest": "^3.1.3",
|
"@git.zone/tstest": "^3.1.3",
|
||||||
"@types/node": "^25.0.3"
|
"@types/node": "^25.0.3",
|
||||||
|
"@types/ws": "^8.18.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@push.rocks/smartdelay": "^3.0.5",
|
"@push.rocks/smartdelay": "^3.0.5",
|
||||||
@@ -32,6 +33,7 @@
|
|||||||
"ipp": "^2.0.1",
|
"ipp": "^2.0.1",
|
||||||
"net-snmp": "^3.26.0",
|
"net-snmp": "^3.26.0",
|
||||||
"node-ssdp": "^4.0.1",
|
"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:
|
sonos:
|
||||||
specifier: ^1.14.2
|
specifier: ^1.14.2
|
||||||
version: 1.14.2
|
version: 1.14.2
|
||||||
|
ws:
|
||||||
|
specifier: ^8.19.0
|
||||||
|
version: 8.19.0
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@git.zone/tsbuild':
|
'@git.zone/tsbuild':
|
||||||
specifier: ^4.1.0
|
specifier: ^4.1.0
|
||||||
@@ -60,6 +63,9 @@ importers:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^25.0.3
|
specifier: ^25.0.3
|
||||||
version: 25.0.3
|
version: 25.0.3
|
||||||
|
'@types/ws':
|
||||||
|
specifier: ^8.18.1
|
||||||
|
version: 8.18.1
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@ecobridge.xyz/devicemanager',
|
name: '@ecobridge.xyz/devicemanager',
|
||||||
version: '1.1.0',
|
version: '2.2.0',
|
||||||
description: 'a device manager for talking to devices on network and over usb'
|
description: 'a device manager for talking to devices on network and over usb'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,202 +0,0 @@
|
|||||||
import * as plugins from '../plugins.js';
|
|
||||||
import type {
|
|
||||||
IDeviceInfo,
|
|
||||||
TDeviceType,
|
|
||||||
TDeviceStatus,
|
|
||||||
TConnectionState,
|
|
||||||
IRetryOptions,
|
|
||||||
} from '../interfaces/index.js';
|
|
||||||
import { withRetry } from '../helpers/helpers.retry.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Abstract base class for all devices (scanners, printers)
|
|
||||||
*/
|
|
||||||
export abstract class Device extends plugins.events.EventEmitter {
|
|
||||||
public readonly id: string;
|
|
||||||
public readonly name: string;
|
|
||||||
public readonly type: TDeviceType;
|
|
||||||
public readonly address: string;
|
|
||||||
public readonly port: number;
|
|
||||||
|
|
||||||
protected _status: TDeviceStatus = 'unknown';
|
|
||||||
protected _connectionState: TConnectionState = 'disconnected';
|
|
||||||
protected _lastError: Error | null = null;
|
|
||||||
|
|
||||||
public manufacturer?: string;
|
|
||||||
public model?: string;
|
|
||||||
public serialNumber?: string;
|
|
||||||
public firmwareVersion?: string;
|
|
||||||
|
|
||||||
protected retryOptions: IRetryOptions;
|
|
||||||
|
|
||||||
constructor(info: IDeviceInfo, retryOptions?: IRetryOptions) {
|
|
||||||
super();
|
|
||||||
this.id = info.id;
|
|
||||||
this.name = info.name;
|
|
||||||
this.type = info.type;
|
|
||||||
this.address = info.address;
|
|
||||||
this.port = info.port;
|
|
||||||
this._status = info.status;
|
|
||||||
this.manufacturer = info.manufacturer;
|
|
||||||
this.model = info.model;
|
|
||||||
this.serialNumber = info.serialNumber;
|
|
||||||
this.firmwareVersion = info.firmwareVersion;
|
|
||||||
|
|
||||||
this.retryOptions = retryOptions ?? {
|
|
||||||
maxRetries: 5,
|
|
||||||
baseDelay: 1000,
|
|
||||||
maxDelay: 16000,
|
|
||||||
multiplier: 2,
|
|
||||||
jitter: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current device status
|
|
||||||
*/
|
|
||||||
public get status(): TDeviceStatus {
|
|
||||||
return this._status;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current connection state
|
|
||||||
*/
|
|
||||||
public get connectionState(): TConnectionState {
|
|
||||||
return this._connectionState;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get last error if any
|
|
||||||
*/
|
|
||||||
public get lastError(): Error | null {
|
|
||||||
return this._lastError;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if device is connected
|
|
||||||
*/
|
|
||||||
public get isConnected(): boolean {
|
|
||||||
return this._connectionState === 'connected';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update device status
|
|
||||||
*/
|
|
||||||
protected setStatus(status: TDeviceStatus): void {
|
|
||||||
if (this._status !== status) {
|
|
||||||
const oldStatus = this._status;
|
|
||||||
this._status = status;
|
|
||||||
this.emit('status:changed', { oldStatus, newStatus: status });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update connection state
|
|
||||||
*/
|
|
||||||
protected setConnectionState(state: TConnectionState): void {
|
|
||||||
if (this._connectionState !== state) {
|
|
||||||
const oldState = this._connectionState;
|
|
||||||
this._connectionState = state;
|
|
||||||
this.emit('connection:changed', { oldState, newState: state });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set error state
|
|
||||||
*/
|
|
||||||
protected setError(error: Error): void {
|
|
||||||
this._lastError = error;
|
|
||||||
this.setStatus('error');
|
|
||||||
this.emit('error', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear error state
|
|
||||||
*/
|
|
||||||
protected clearError(): void {
|
|
||||||
this._lastError = null;
|
|
||||||
if (this._status === 'error') {
|
|
||||||
this.setStatus('online');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute an operation with retry logic
|
|
||||||
*/
|
|
||||||
protected async withRetry<T>(fn: () => Promise<T>): Promise<T> {
|
|
||||||
return withRetry(fn, this.retryOptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Connect to the device
|
|
||||||
*/
|
|
||||||
public async connect(): Promise<void> {
|
|
||||||
if (this.isConnected) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setConnectionState('connecting');
|
|
||||||
this.clearError();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.withRetry(() => this.doConnect());
|
|
||||||
this.setConnectionState('connected');
|
|
||||||
this.setStatus('online');
|
|
||||||
} catch (error) {
|
|
||||||
this.setConnectionState('error');
|
|
||||||
this.setError(error instanceof Error ? error : new Error(String(error)));
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disconnect from the device
|
|
||||||
*/
|
|
||||||
public async disconnect(): Promise<void> {
|
|
||||||
if (this._connectionState === 'disconnected') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.doDisconnect();
|
|
||||||
} finally {
|
|
||||||
this.setConnectionState('disconnected');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get device info as plain object
|
|
||||||
*/
|
|
||||||
public getInfo(): IDeviceInfo {
|
|
||||||
return {
|
|
||||||
id: this.id,
|
|
||||||
name: this.name,
|
|
||||||
type: this.type,
|
|
||||||
address: this.address,
|
|
||||||
port: this.port,
|
|
||||||
status: this._status,
|
|
||||||
manufacturer: this.manufacturer,
|
|
||||||
model: this.model,
|
|
||||||
serialNumber: this.serialNumber,
|
|
||||||
firmwareVersion: this.firmwareVersion,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Implementation-specific connect logic
|
|
||||||
* Override in subclasses
|
|
||||||
*/
|
|
||||||
protected abstract doConnect(): Promise<void>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Implementation-specific disconnect logic
|
|
||||||
* Override in subclasses
|
|
||||||
*/
|
|
||||||
protected abstract doDisconnect(): Promise<void>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh device status
|
|
||||||
* Override in subclasses
|
|
||||||
*/
|
|
||||||
public abstract refreshStatus(): Promise<void>;
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
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 };
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import { EsclProtocol } from '../scanner/scanner.classes.esclprotocol.js';
|
import { EsclProtocol } from '../protocols/index.js';
|
||||||
import {
|
import {
|
||||||
cidrToIps,
|
cidrToIps,
|
||||||
ipRangeToIps,
|
ipRangeToIps,
|
||||||
|
|||||||
@@ -1,527 +0,0 @@
|
|||||||
import * as plugins from '../plugins.js';
|
|
||||||
import { Device } from '../abstract/device.abstract.js';
|
|
||||||
import {
|
|
||||||
UpnpSoapClient,
|
|
||||||
UPNP_SERVICE_TYPES,
|
|
||||||
type TDlnaTransportState,
|
|
||||||
type IDlnaTransportInfo,
|
|
||||||
type IDlnaPositionInfo,
|
|
||||||
type IDlnaMediaInfo,
|
|
||||||
} from './dlna.classes.upnp.js';
|
|
||||||
import type { IDeviceInfo, IRetryOptions } from '../interfaces/index.js';
|
|
||||||
import type { ISsdpDevice, ISsdpService } from '../discovery/discovery.classes.ssdp.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DLNA Renderer device info
|
|
||||||
*/
|
|
||||||
export interface IDlnaRendererInfo extends IDeviceInfo {
|
|
||||||
type: 'dlna-renderer';
|
|
||||||
friendlyName: string;
|
|
||||||
modelName: string;
|
|
||||||
modelNumber?: string;
|
|
||||||
manufacturer: string;
|
|
||||||
udn: string;
|
|
||||||
iconUrl?: string;
|
|
||||||
supportsVolume: boolean;
|
|
||||||
supportsSeek: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Playback state
|
|
||||||
*/
|
|
||||||
export interface IDlnaPlaybackState {
|
|
||||||
state: TDlnaTransportState;
|
|
||||||
volume: number;
|
|
||||||
muted: boolean;
|
|
||||||
currentUri: string;
|
|
||||||
currentTrack: {
|
|
||||||
title: string;
|
|
||||||
artist?: string;
|
|
||||||
album?: string;
|
|
||||||
duration: number;
|
|
||||||
position: number;
|
|
||||||
albumArtUri?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DLNA Media Renderer device
|
|
||||||
* Represents a device that can play media (TV, speaker, etc.)
|
|
||||||
*/
|
|
||||||
export class DlnaRenderer extends Device {
|
|
||||||
private soapClient: UpnpSoapClient | null = null;
|
|
||||||
private avTransportUrl: string = '';
|
|
||||||
private renderingControlUrl: string = '';
|
|
||||||
private baseUrl: string = '';
|
|
||||||
|
|
||||||
private _friendlyName: string;
|
|
||||||
private _modelName: string = '';
|
|
||||||
private _modelNumber?: string;
|
|
||||||
private _udn: string = '';
|
|
||||||
private _iconUrl?: string;
|
|
||||||
private _supportsVolume: boolean = true;
|
|
||||||
private _supportsSeek: boolean = true;
|
|
||||||
|
|
||||||
private _currentState: TDlnaTransportState = 'STOPPED';
|
|
||||||
private _currentVolume: number = 0;
|
|
||||||
private _currentMuted: boolean = false;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
info: IDeviceInfo,
|
|
||||||
options: {
|
|
||||||
friendlyName: string;
|
|
||||||
baseUrl: string;
|
|
||||||
avTransportUrl?: string;
|
|
||||||
renderingControlUrl?: string;
|
|
||||||
modelName?: string;
|
|
||||||
modelNumber?: string;
|
|
||||||
udn?: string;
|
|
||||||
iconUrl?: string;
|
|
||||||
},
|
|
||||||
retryOptions?: IRetryOptions
|
|
||||||
) {
|
|
||||||
super(info, retryOptions);
|
|
||||||
this._friendlyName = options.friendlyName;
|
|
||||||
this.baseUrl = options.baseUrl;
|
|
||||||
this.avTransportUrl = options.avTransportUrl || '/AVTransport/control';
|
|
||||||
this.renderingControlUrl = options.renderingControlUrl || '/RenderingControl/control';
|
|
||||||
this._modelName = options.modelName || '';
|
|
||||||
this._modelNumber = options.modelNumber;
|
|
||||||
this._udn = options.udn || '';
|
|
||||||
this._iconUrl = options.iconUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Getters
|
|
||||||
public get friendlyName(): string {
|
|
||||||
return this._friendlyName;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get modelName(): string {
|
|
||||||
return this._modelName;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get modelNumber(): string | undefined {
|
|
||||||
return this._modelNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get udn(): string {
|
|
||||||
return this._udn;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get iconUrl(): string | undefined {
|
|
||||||
return this._iconUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get supportsVolume(): boolean {
|
|
||||||
return this._supportsVolume;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get supportsSeek(): boolean {
|
|
||||||
return this._supportsSeek;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get currentState(): TDlnaTransportState {
|
|
||||||
return this._currentState;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get currentVolume(): number {
|
|
||||||
return this._currentVolume;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get currentMuted(): boolean {
|
|
||||||
return this._currentMuted;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Connect to renderer
|
|
||||||
*/
|
|
||||||
protected async doConnect(): Promise<void> {
|
|
||||||
this.soapClient = new UpnpSoapClient(this.baseUrl);
|
|
||||||
|
|
||||||
// Test connection by getting transport info
|
|
||||||
try {
|
|
||||||
await this.getTransportInfo();
|
|
||||||
} catch (error) {
|
|
||||||
this.soapClient = null;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to get volume (may not be supported)
|
|
||||||
try {
|
|
||||||
this._currentVolume = await this.getVolume();
|
|
||||||
this._supportsVolume = true;
|
|
||||||
} catch {
|
|
||||||
this._supportsVolume = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disconnect
|
|
||||||
*/
|
|
||||||
protected async doDisconnect(): Promise<void> {
|
|
||||||
this.soapClient = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh status
|
|
||||||
*/
|
|
||||||
public async refreshStatus(): Promise<void> {
|
|
||||||
if (!this.soapClient) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
const [transport, volume, muted] = await Promise.all([
|
|
||||||
this.getTransportInfo(),
|
|
||||||
this._supportsVolume ? this.getVolume() : Promise.resolve(0),
|
|
||||||
this._supportsVolume ? this.getMute() : Promise.resolve(false),
|
|
||||||
]);
|
|
||||||
|
|
||||||
this._currentState = transport.state;
|
|
||||||
this._currentVolume = volume;
|
|
||||||
this._currentMuted = muted;
|
|
||||||
|
|
||||||
this.emit('status:updated', this.getDeviceInfo());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Playback Control
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set media URI to play
|
|
||||||
*/
|
|
||||||
public async setAVTransportURI(uri: string, metadata?: string): Promise<void> {
|
|
||||||
if (!this.soapClient) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
const meta = metadata || this.soapClient.generateDidlMetadata('Media', uri);
|
|
||||||
await this.soapClient.setAVTransportURI(this.avTransportUrl, uri, meta);
|
|
||||||
this.emit('media:loaded', { uri });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Play current media
|
|
||||||
*/
|
|
||||||
public async play(uri?: string, metadata?: string): Promise<void> {
|
|
||||||
if (!this.soapClient) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (uri) {
|
|
||||||
await this.setAVTransportURI(uri, metadata);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.soapClient.play(this.avTransportUrl);
|
|
||||||
this._currentState = 'PLAYING';
|
|
||||||
this.emit('playback:started');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pause playback
|
|
||||||
*/
|
|
||||||
public async pause(): Promise<void> {
|
|
||||||
if (!this.soapClient) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.soapClient.pause(this.avTransportUrl);
|
|
||||||
this._currentState = 'PAUSED_PLAYBACK';
|
|
||||||
this.emit('playback:paused');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop playback
|
|
||||||
*/
|
|
||||||
public async stop(): Promise<void> {
|
|
||||||
if (!this.soapClient) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.soapClient.stop(this.avTransportUrl);
|
|
||||||
this._currentState = 'STOPPED';
|
|
||||||
this.emit('playback:stopped');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Seek to position
|
|
||||||
*/
|
|
||||||
public async seek(seconds: number): Promise<void> {
|
|
||||||
if (!this.soapClient) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = this.soapClient.secondsToDuration(seconds);
|
|
||||||
await this.soapClient.seek(this.avTransportUrl, target, 'REL_TIME');
|
|
||||||
this.emit('playback:seeked', { position: seconds });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Next track
|
|
||||||
*/
|
|
||||||
public async next(): Promise<void> {
|
|
||||||
if (!this.soapClient) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.soapClient.next(this.avTransportUrl);
|
|
||||||
this.emit('playback:next');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Previous track
|
|
||||||
*/
|
|
||||||
public async previous(): Promise<void> {
|
|
||||||
if (!this.soapClient) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.soapClient.previous(this.avTransportUrl);
|
|
||||||
this.emit('playback:previous');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Volume Control
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get volume level
|
|
||||||
*/
|
|
||||||
public async getVolume(): Promise<number> {
|
|
||||||
if (!this.soapClient) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.soapClient.getVolume(this.renderingControlUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set volume level
|
|
||||||
*/
|
|
||||||
public async setVolume(level: number): Promise<void> {
|
|
||||||
if (!this.soapClient) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.soapClient.setVolume(this.renderingControlUrl, level);
|
|
||||||
this._currentVolume = level;
|
|
||||||
this.emit('volume:changed', { volume: level });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get mute state
|
|
||||||
*/
|
|
||||||
public async getMute(): Promise<boolean> {
|
|
||||||
if (!this.soapClient) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.soapClient.getMute(this.renderingControlUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set mute state
|
|
||||||
*/
|
|
||||||
public async setMute(muted: boolean): Promise<void> {
|
|
||||||
if (!this.soapClient) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.soapClient.setMute(this.renderingControlUrl, muted);
|
|
||||||
this._currentMuted = muted;
|
|
||||||
this.emit('mute:changed', { muted });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle mute
|
|
||||||
*/
|
|
||||||
public async toggleMute(): Promise<boolean> {
|
|
||||||
const newMuted = !this._currentMuted;
|
|
||||||
await this.setMute(newMuted);
|
|
||||||
return newMuted;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Status Information
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get transport info
|
|
||||||
*/
|
|
||||||
public async getTransportInfo(): Promise<IDlnaTransportInfo> {
|
|
||||||
if (!this.soapClient) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.soapClient.getTransportInfo(this.avTransportUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get position info
|
|
||||||
*/
|
|
||||||
public async getPositionInfo(): Promise<IDlnaPositionInfo> {
|
|
||||||
if (!this.soapClient) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.soapClient.getPositionInfo(this.avTransportUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get media info
|
|
||||||
*/
|
|
||||||
public async getMediaInfo(): Promise<IDlnaMediaInfo> {
|
|
||||||
if (!this.soapClient) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.soapClient.getMediaInfo(this.avTransportUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get full playback state
|
|
||||||
*/
|
|
||||||
public async getPlaybackState(): Promise<IDlnaPlaybackState> {
|
|
||||||
if (!this.soapClient) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
const [transport, position, media, volume, muted] = await Promise.all([
|
|
||||||
this.getTransportInfo(),
|
|
||||||
this.getPositionInfo(),
|
|
||||||
this.getMediaInfo(),
|
|
||||||
this._supportsVolume ? this.getVolume() : Promise.resolve(0),
|
|
||||||
this._supportsVolume ? this.getMute() : Promise.resolve(false),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Parse metadata for track info
|
|
||||||
const trackMeta = this.parseTrackMetadata(position.trackMetadata);
|
|
||||||
|
|
||||||
return {
|
|
||||||
state: transport.state,
|
|
||||||
volume,
|
|
||||||
muted,
|
|
||||||
currentUri: media.currentUri,
|
|
||||||
currentTrack: {
|
|
||||||
title: trackMeta.title || 'Unknown',
|
|
||||||
artist: trackMeta.artist,
|
|
||||||
album: trackMeta.album,
|
|
||||||
duration: this.soapClient.durationToSeconds(position.trackDuration),
|
|
||||||
position: this.soapClient.durationToSeconds(position.relativeTime),
|
|
||||||
albumArtUri: trackMeta.albumArtUri,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse track metadata from DIDL-Lite
|
|
||||||
*/
|
|
||||||
private parseTrackMetadata(metadata: string): {
|
|
||||||
title?: string;
|
|
||||||
artist?: string;
|
|
||||||
album?: string;
|
|
||||||
albumArtUri?: string;
|
|
||||||
} {
|
|
||||||
if (!metadata) return {};
|
|
||||||
|
|
||||||
const extractTag = (xml: string, tag: string): string | undefined => {
|
|
||||||
const regex = new RegExp(`<(?:[^:]*:)?${tag}[^>]*>([^<]*)<\/(?:[^:]*:)?${tag}>`, 'i');
|
|
||||||
const match = xml.match(regex);
|
|
||||||
return match ? match[1].trim() : undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: extractTag(metadata, 'title'),
|
|
||||||
artist: extractTag(metadata, 'creator') || extractTag(metadata, 'artist'),
|
|
||||||
album: extractTag(metadata, 'album'),
|
|
||||||
albumArtUri: extractTag(metadata, 'albumArtURI'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get device info
|
|
||||||
*/
|
|
||||||
public getDeviceInfo(): IDlnaRendererInfo {
|
|
||||||
return {
|
|
||||||
id: this.id,
|
|
||||||
name: this.name,
|
|
||||||
type: 'dlna-renderer',
|
|
||||||
address: this.address,
|
|
||||||
port: this.port,
|
|
||||||
status: this.status,
|
|
||||||
friendlyName: this._friendlyName,
|
|
||||||
modelName: this._modelName,
|
|
||||||
modelNumber: this._modelNumber,
|
|
||||||
manufacturer: this.manufacturer || '',
|
|
||||||
udn: this._udn,
|
|
||||||
iconUrl: this._iconUrl,
|
|
||||||
supportsVolume: this._supportsVolume,
|
|
||||||
supportsSeek: this._supportsSeek,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create from SSDP discovery
|
|
||||||
*/
|
|
||||||
public static fromSsdpDevice(
|
|
||||||
ssdpDevice: ISsdpDevice,
|
|
||||||
retryOptions?: IRetryOptions
|
|
||||||
): DlnaRenderer | null {
|
|
||||||
if (!ssdpDevice.description) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const desc = ssdpDevice.description;
|
|
||||||
|
|
||||||
// Find AVTransport and RenderingControl URLs
|
|
||||||
const avTransport = desc.services.find((s) =>
|
|
||||||
s.serviceType.includes('AVTransport')
|
|
||||||
);
|
|
||||||
const renderingControl = desc.services.find((s) =>
|
|
||||||
s.serviceType.includes('RenderingControl')
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!avTransport) {
|
|
||||||
return null; // Not a media renderer
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build base URL
|
|
||||||
const baseUrl = new URL(ssdpDevice.location);
|
|
||||||
const baseUrlStr = `${baseUrl.protocol}//${baseUrl.host}`;
|
|
||||||
|
|
||||||
// Get icon URL
|
|
||||||
let iconUrl: string | undefined;
|
|
||||||
if (desc.icons && desc.icons.length > 0) {
|
|
||||||
const bestIcon = desc.icons.sort((a, b) => b.width - a.width)[0];
|
|
||||||
iconUrl = bestIcon.url.startsWith('http')
|
|
||||||
? bestIcon.url
|
|
||||||
: `${baseUrlStr}${bestIcon.url}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const info: IDeviceInfo = {
|
|
||||||
id: `dlna-renderer:${desc.UDN}`,
|
|
||||||
name: desc.friendlyName,
|
|
||||||
type: 'dlna-renderer',
|
|
||||||
address: ssdpDevice.address,
|
|
||||||
port: ssdpDevice.port,
|
|
||||||
status: 'unknown',
|
|
||||||
manufacturer: desc.manufacturer,
|
|
||||||
model: desc.modelName,
|
|
||||||
};
|
|
||||||
|
|
||||||
return new DlnaRenderer(
|
|
||||||
info,
|
|
||||||
{
|
|
||||||
friendlyName: desc.friendlyName,
|
|
||||||
baseUrl: baseUrlStr,
|
|
||||||
avTransportUrl: avTransport.controlURL,
|
|
||||||
renderingControlUrl: renderingControl?.controlURL,
|
|
||||||
modelName: desc.modelName,
|
|
||||||
modelNumber: desc.modelNumber,
|
|
||||||
udn: desc.UDN,
|
|
||||||
iconUrl,
|
|
||||||
},
|
|
||||||
retryOptions
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,468 +0,0 @@
|
|||||||
import * as plugins from '../plugins.js';
|
|
||||||
import { Device } from '../abstract/device.abstract.js';
|
|
||||||
import {
|
|
||||||
UpnpSoapClient,
|
|
||||||
UPNP_SERVICE_TYPES,
|
|
||||||
type IDlnaContentItem,
|
|
||||||
type IDlnaBrowseResult,
|
|
||||||
} from './dlna.classes.upnp.js';
|
|
||||||
import type { IDeviceInfo, IRetryOptions } from '../interfaces/index.js';
|
|
||||||
import type { ISsdpDevice, ISsdpService } from '../discovery/discovery.classes.ssdp.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DLNA Server device info
|
|
||||||
*/
|
|
||||||
export interface IDlnaServerInfo extends IDeviceInfo {
|
|
||||||
type: 'dlna-server';
|
|
||||||
friendlyName: string;
|
|
||||||
modelName: string;
|
|
||||||
modelNumber?: string;
|
|
||||||
manufacturer: string;
|
|
||||||
udn: string;
|
|
||||||
iconUrl?: string;
|
|
||||||
contentCount?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Content directory statistics
|
|
||||||
*/
|
|
||||||
export interface IDlnaServerStats {
|
|
||||||
totalItems: number;
|
|
||||||
audioItems: number;
|
|
||||||
videoItems: number;
|
|
||||||
imageItems: number;
|
|
||||||
containers: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DLNA Media Server device
|
|
||||||
* Represents a device that serves media content (NAS, media library, etc.)
|
|
||||||
*/
|
|
||||||
export class DlnaServer extends Device {
|
|
||||||
private soapClient: UpnpSoapClient | null = null;
|
|
||||||
private contentDirectoryUrl: string = '';
|
|
||||||
private baseUrl: string = '';
|
|
||||||
|
|
||||||
private _friendlyName: string;
|
|
||||||
private _modelName: string = '';
|
|
||||||
private _modelNumber?: string;
|
|
||||||
private _udn: string = '';
|
|
||||||
private _iconUrl?: string;
|
|
||||||
private _contentCount?: number;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
info: IDeviceInfo,
|
|
||||||
options: {
|
|
||||||
friendlyName: string;
|
|
||||||
baseUrl: string;
|
|
||||||
contentDirectoryUrl?: string;
|
|
||||||
modelName?: string;
|
|
||||||
modelNumber?: string;
|
|
||||||
udn?: string;
|
|
||||||
iconUrl?: string;
|
|
||||||
},
|
|
||||||
retryOptions?: IRetryOptions
|
|
||||||
) {
|
|
||||||
super(info, retryOptions);
|
|
||||||
this._friendlyName = options.friendlyName;
|
|
||||||
this.baseUrl = options.baseUrl;
|
|
||||||
this.contentDirectoryUrl = options.contentDirectoryUrl || '/ContentDirectory/control';
|
|
||||||
this._modelName = options.modelName || '';
|
|
||||||
this._modelNumber = options.modelNumber;
|
|
||||||
this._udn = options.udn || '';
|
|
||||||
this._iconUrl = options.iconUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Getters
|
|
||||||
public get friendlyName(): string {
|
|
||||||
return this._friendlyName;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get modelName(): string {
|
|
||||||
return this._modelName;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get modelNumber(): string | undefined {
|
|
||||||
return this._modelNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get udn(): string {
|
|
||||||
return this._udn;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get iconUrl(): string | undefined {
|
|
||||||
return this._iconUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get contentCount(): number | undefined {
|
|
||||||
return this._contentCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Connect to server
|
|
||||||
*/
|
|
||||||
protected async doConnect(): Promise<void> {
|
|
||||||
this.soapClient = new UpnpSoapClient(this.baseUrl);
|
|
||||||
|
|
||||||
// Test connection by browsing root
|
|
||||||
try {
|
|
||||||
const root = await this.browse('0', 0, 1);
|
|
||||||
this._contentCount = root.totalMatches;
|
|
||||||
} catch (error) {
|
|
||||||
this.soapClient = null;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disconnect
|
|
||||||
*/
|
|
||||||
protected async doDisconnect(): Promise<void> {
|
|
||||||
this.soapClient = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh status
|
|
||||||
*/
|
|
||||||
public async refreshStatus(): Promise<void> {
|
|
||||||
if (!this.soapClient) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
const root = await this.browse('0', 0, 1);
|
|
||||||
this._contentCount = root.totalMatches;
|
|
||||||
|
|
||||||
this.emit('status:updated', this.getDeviceInfo());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Content Directory Browsing
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Browse content directory
|
|
||||||
*/
|
|
||||||
public async browse(
|
|
||||||
objectId: string = '0',
|
|
||||||
startIndex: number = 0,
|
|
||||||
requestCount: number = 100
|
|
||||||
): Promise<IDlnaBrowseResult> {
|
|
||||||
if (!this.soapClient) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.soapClient.browse(
|
|
||||||
this.contentDirectoryUrl,
|
|
||||||
objectId,
|
|
||||||
'BrowseDirectChildren',
|
|
||||||
'*',
|
|
||||||
startIndex,
|
|
||||||
requestCount
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get metadata for a specific item
|
|
||||||
*/
|
|
||||||
public async getMetadata(objectId: string): Promise<IDlnaContentItem | null> {
|
|
||||||
if (!this.soapClient) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await this.soapClient.browse(
|
|
||||||
this.contentDirectoryUrl,
|
|
||||||
objectId,
|
|
||||||
'BrowseMetadata',
|
|
||||||
'*',
|
|
||||||
0,
|
|
||||||
1
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.items[0] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search content directory
|
|
||||||
*/
|
|
||||||
public async search(
|
|
||||||
containerId: string,
|
|
||||||
searchCriteria: string,
|
|
||||||
startIndex: number = 0,
|
|
||||||
requestCount: number = 100
|
|
||||||
): Promise<IDlnaBrowseResult> {
|
|
||||||
if (!this.soapClient) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.soapClient.search(
|
|
||||||
this.contentDirectoryUrl,
|
|
||||||
containerId,
|
|
||||||
searchCriteria,
|
|
||||||
'*',
|
|
||||||
startIndex,
|
|
||||||
requestCount
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Browse all items recursively (up to limit)
|
|
||||||
*/
|
|
||||||
public async browseAll(
|
|
||||||
objectId: string = '0',
|
|
||||||
limit: number = 1000
|
|
||||||
): Promise<IDlnaContentItem[]> {
|
|
||||||
const allItems: IDlnaContentItem[] = [];
|
|
||||||
let startIndex = 0;
|
|
||||||
const batchSize = 100;
|
|
||||||
|
|
||||||
while (allItems.length < limit) {
|
|
||||||
const result = await this.browse(objectId, startIndex, batchSize);
|
|
||||||
allItems.push(...result.items);
|
|
||||||
|
|
||||||
if (result.items.length < batchSize || allItems.length >= result.totalMatches) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
startIndex += result.items.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
return allItems.slice(0, limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get content statistics
|
|
||||||
*/
|
|
||||||
public async getStats(): Promise<IDlnaServerStats> {
|
|
||||||
const stats: IDlnaServerStats = {
|
|
||||||
totalItems: 0,
|
|
||||||
audioItems: 0,
|
|
||||||
videoItems: 0,
|
|
||||||
imageItems: 0,
|
|
||||||
containers: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Browse root to get counts
|
|
||||||
const root = await this.browseAll('0', 500);
|
|
||||||
|
|
||||||
for (const item of root) {
|
|
||||||
stats.totalItems++;
|
|
||||||
|
|
||||||
if (item.class.includes('container')) {
|
|
||||||
stats.containers++;
|
|
||||||
} else if (item.class.includes('audioItem')) {
|
|
||||||
stats.audioItems++;
|
|
||||||
} else if (item.class.includes('videoItem')) {
|
|
||||||
stats.videoItems++;
|
|
||||||
} else if (item.class.includes('imageItem')) {
|
|
||||||
stats.imageItems++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return stats;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Content Access
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get stream URL for content item
|
|
||||||
*/
|
|
||||||
public getStreamUrl(item: IDlnaContentItem): string | null {
|
|
||||||
if (!item.res || item.res.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return first resource URL
|
|
||||||
return item.res[0].url;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get best quality stream URL
|
|
||||||
*/
|
|
||||||
public getBestStreamUrl(item: IDlnaContentItem, preferredType?: string): string | null {
|
|
||||||
if (!item.res || item.res.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by bitrate (highest first)
|
|
||||||
const sorted = [...item.res].sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0));
|
|
||||||
|
|
||||||
// If preferred type specified, try to find matching
|
|
||||||
if (preferredType) {
|
|
||||||
const preferred = sorted.find((r) =>
|
|
||||||
r.protocolInfo.toLowerCase().includes(preferredType.toLowerCase())
|
|
||||||
);
|
|
||||||
if (preferred) return preferred.url;
|
|
||||||
}
|
|
||||||
|
|
||||||
return sorted[0].url;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get album art URL for item
|
|
||||||
*/
|
|
||||||
public getAlbumArtUrl(item: IDlnaContentItem): string | null {
|
|
||||||
if (item.albumArtUri) {
|
|
||||||
// Resolve relative URLs
|
|
||||||
if (!item.albumArtUri.startsWith('http')) {
|
|
||||||
return `${this.baseUrl}${item.albumArtUri}`;
|
|
||||||
}
|
|
||||||
return item.albumArtUri;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Search Helpers
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search for audio items by title
|
|
||||||
*/
|
|
||||||
public async searchAudio(
|
|
||||||
title: string,
|
|
||||||
startIndex: number = 0,
|
|
||||||
requestCount: number = 100
|
|
||||||
): Promise<IDlnaBrowseResult> {
|
|
||||||
const criteria = `dc:title contains "${title}" and upnp:class derivedfrom "object.item.audioItem"`;
|
|
||||||
return this.search('0', criteria, startIndex, requestCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search for video items by title
|
|
||||||
*/
|
|
||||||
public async searchVideo(
|
|
||||||
title: string,
|
|
||||||
startIndex: number = 0,
|
|
||||||
requestCount: number = 100
|
|
||||||
): Promise<IDlnaBrowseResult> {
|
|
||||||
const criteria = `dc:title contains "${title}" and upnp:class derivedfrom "object.item.videoItem"`;
|
|
||||||
return this.search('0', criteria, startIndex, requestCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search by artist
|
|
||||||
*/
|
|
||||||
public async searchByArtist(
|
|
||||||
artist: string,
|
|
||||||
startIndex: number = 0,
|
|
||||||
requestCount: number = 100
|
|
||||||
): Promise<IDlnaBrowseResult> {
|
|
||||||
const criteria = `dc:creator contains "${artist}" or upnp:artist contains "${artist}"`;
|
|
||||||
return this.search('0', criteria, startIndex, requestCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search by album
|
|
||||||
*/
|
|
||||||
public async searchByAlbum(
|
|
||||||
album: string,
|
|
||||||
startIndex: number = 0,
|
|
||||||
requestCount: number = 100
|
|
||||||
): Promise<IDlnaBrowseResult> {
|
|
||||||
const criteria = `upnp:album contains "${album}"`;
|
|
||||||
return this.search('0', criteria, startIndex, requestCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search by genre
|
|
||||||
*/
|
|
||||||
public async searchByGenre(
|
|
||||||
genre: string,
|
|
||||||
startIndex: number = 0,
|
|
||||||
requestCount: number = 100
|
|
||||||
): Promise<IDlnaBrowseResult> {
|
|
||||||
const criteria = `upnp:genre contains "${genre}"`;
|
|
||||||
return this.search('0', criteria, startIndex, requestCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Device Info
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get device info
|
|
||||||
*/
|
|
||||||
public getDeviceInfo(): IDlnaServerInfo {
|
|
||||||
return {
|
|
||||||
id: this.id,
|
|
||||||
name: this.name,
|
|
||||||
type: 'dlna-server',
|
|
||||||
address: this.address,
|
|
||||||
port: this.port,
|
|
||||||
status: this.status,
|
|
||||||
friendlyName: this._friendlyName,
|
|
||||||
modelName: this._modelName,
|
|
||||||
modelNumber: this._modelNumber,
|
|
||||||
manufacturer: this.manufacturer || '',
|
|
||||||
udn: this._udn,
|
|
||||||
iconUrl: this._iconUrl,
|
|
||||||
contentCount: this._contentCount,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create from SSDP discovery
|
|
||||||
*/
|
|
||||||
public static fromSsdpDevice(
|
|
||||||
ssdpDevice: ISsdpDevice,
|
|
||||||
retryOptions?: IRetryOptions
|
|
||||||
): DlnaServer | null {
|
|
||||||
if (!ssdpDevice.description) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const desc = ssdpDevice.description;
|
|
||||||
|
|
||||||
// Find ContentDirectory URL
|
|
||||||
const contentDirectory = desc.services.find((s) =>
|
|
||||||
s.serviceType.includes('ContentDirectory')
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!contentDirectory) {
|
|
||||||
return null; // Not a media server
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build base URL
|
|
||||||
const baseUrl = new URL(ssdpDevice.location);
|
|
||||||
const baseUrlStr = `${baseUrl.protocol}//${baseUrl.host}`;
|
|
||||||
|
|
||||||
// Get icon URL
|
|
||||||
let iconUrl: string | undefined;
|
|
||||||
if (desc.icons && desc.icons.length > 0) {
|
|
||||||
const bestIcon = desc.icons.sort((a, b) => b.width - a.width)[0];
|
|
||||||
iconUrl = bestIcon.url.startsWith('http')
|
|
||||||
? bestIcon.url
|
|
||||||
: `${baseUrlStr}${bestIcon.url}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const info: IDeviceInfo = {
|
|
||||||
id: `dlna-server:${desc.UDN}`,
|
|
||||||
name: desc.friendlyName,
|
|
||||||
type: 'dlna-server',
|
|
||||||
address: ssdpDevice.address,
|
|
||||||
port: ssdpDevice.port,
|
|
||||||
status: 'unknown',
|
|
||||||
manufacturer: desc.manufacturer,
|
|
||||||
model: desc.modelName,
|
|
||||||
};
|
|
||||||
|
|
||||||
return new DlnaServer(
|
|
||||||
info,
|
|
||||||
{
|
|
||||||
friendlyName: desc.friendlyName,
|
|
||||||
baseUrl: baseUrlStr,
|
|
||||||
contentDirectoryUrl: contentDirectory.controlURL,
|
|
||||||
modelName: desc.modelName,
|
|
||||||
modelNumber: desc.modelNumber,
|
|
||||||
udn: desc.UDN,
|
|
||||||
iconUrl,
|
|
||||||
},
|
|
||||||
retryOptions
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-export content types
|
|
||||||
export type { IDlnaContentItem, IDlnaBrowseResult } from './dlna.classes.upnp.js';
|
|
||||||
696
ts/factories/index.ts
Normal file
696
ts/factories/index.ts
Normal file
@@ -0,0 +1,696 @@
|
|||||||
|
/**
|
||||||
|
* Device Factory Functions
|
||||||
|
* Create UniversalDevice instances with appropriate features
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { UniversalDevice, type IDeviceCreateOptions } from '../device/device.classes.device.js';
|
||||||
|
import { ScanFeature, type IScanFeatureOptions } from '../features/feature.scan.js';
|
||||||
|
import { PrintFeature, type IPrintFeatureOptions } from '../features/feature.print.js';
|
||||||
|
import { PlaybackFeature, type IPlaybackFeatureOptions } from '../features/feature.playback.js';
|
||||||
|
import { VolumeFeature, type IVolumeFeatureOptions } from '../features/feature.volume.js';
|
||||||
|
import { PowerFeature, type IPowerFeatureOptions } from '../features/feature.power.js';
|
||||||
|
import { SnmpFeature, type ISnmpFeatureOptions } from '../features/feature.snmp.js';
|
||||||
|
import type {
|
||||||
|
TScannerProtocol,
|
||||||
|
TScanFormat,
|
||||||
|
TColorMode,
|
||||||
|
TScanSource,
|
||||||
|
IRetryOptions,
|
||||||
|
} from '../interfaces/index.js';
|
||||||
|
import type { TPrintProtocol } from '../interfaces/feature.interfaces.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Scanner Factory
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface IScannerDiscoveryInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
port: number;
|
||||||
|
protocol: TScannerProtocol | 'ipp';
|
||||||
|
txtRecords: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a scanner device (UniversalDevice with ScanFeature)
|
||||||
|
*/
|
||||||
|
export function createScanner(
|
||||||
|
info: IScannerDiscoveryInfo,
|
||||||
|
retryOptions?: IRetryOptions
|
||||||
|
): UniversalDevice {
|
||||||
|
const protocol = info.protocol === 'ipp' ? 'escl' : info.protocol;
|
||||||
|
const isSecure = info.txtRecords['TLS'] === '1' || (protocol === 'escl' && info.port === 443);
|
||||||
|
|
||||||
|
// Parse capabilities from TXT records
|
||||||
|
const formats = parseScanFormats(info.txtRecords);
|
||||||
|
const resolutions = parseScanResolutions(info.txtRecords);
|
||||||
|
const colorModes = parseScanColorModes(info.txtRecords);
|
||||||
|
const sources = parseScanSources(info.txtRecords);
|
||||||
|
|
||||||
|
const device = new UniversalDevice(info.address, info.port, {
|
||||||
|
name: info.name,
|
||||||
|
manufacturer: info.txtRecords['usb_MFG'] || info.txtRecords['mfg'],
|
||||||
|
model: info.txtRecords['usb_MDL'] || info.txtRecords['mdl'] || info.txtRecords['ty'],
|
||||||
|
retryOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Override the generated ID with discovery ID
|
||||||
|
(device as { id: string }).id = info.id;
|
||||||
|
|
||||||
|
// Add scan feature
|
||||||
|
const scanFeature = new ScanFeature(device.getDeviceReference(), info.port, {
|
||||||
|
protocol: protocol as 'escl' | 'sane',
|
||||||
|
secure: isSecure,
|
||||||
|
supportedFormats: formats,
|
||||||
|
supportedResolutions: resolutions,
|
||||||
|
supportedColorModes: colorModes,
|
||||||
|
supportedSources: sources,
|
||||||
|
hasAdf: sources.includes('adf') || sources.includes('adf-duplex'),
|
||||||
|
hasDuplex: sources.includes('adf-duplex'),
|
||||||
|
});
|
||||||
|
|
||||||
|
device.addFeature(scanFeature);
|
||||||
|
return device;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Printer Factory
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface IPrinterDiscoveryInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
port: number;
|
||||||
|
txtRecords: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a printer device (UniversalDevice with PrintFeature)
|
||||||
|
*/
|
||||||
|
export function createPrinter(
|
||||||
|
info: IPrinterDiscoveryInfo,
|
||||||
|
retryOptions?: IRetryOptions
|
||||||
|
): UniversalDevice {
|
||||||
|
const ippPath = info.txtRecords['rp'] || info.txtRecords['rfo'] || '/ipp/print';
|
||||||
|
const uri = `ipp://${info.address}:${info.port}${ippPath.startsWith('/') ? '' : '/'}${ippPath}`;
|
||||||
|
|
||||||
|
const device = new UniversalDevice(info.address, info.port, {
|
||||||
|
name: info.name,
|
||||||
|
manufacturer: info.txtRecords['usb_MFG'] || info.txtRecords['mfg'],
|
||||||
|
model: info.txtRecords['usb_MDL'] || info.txtRecords['mdl'] || info.txtRecords['ty'],
|
||||||
|
retryOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Override the generated ID with discovery ID
|
||||||
|
(device as { id: string }).id = info.id;
|
||||||
|
|
||||||
|
// Add print feature
|
||||||
|
const printFeature = new PrintFeature(device.getDeviceReference(), info.port, {
|
||||||
|
protocol: 'ipp',
|
||||||
|
uri,
|
||||||
|
supportsColor: info.txtRecords['Color'] === 'T' || info.txtRecords['color'] === 'true',
|
||||||
|
supportsDuplex: info.txtRecords['Duplex'] === 'T' || info.txtRecords['duplex'] === 'true',
|
||||||
|
});
|
||||||
|
|
||||||
|
device.addFeature(printFeature);
|
||||||
|
return device;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SNMP Device Factory
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface ISnmpDiscoveryInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
port: number;
|
||||||
|
community?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an SNMP device (UniversalDevice with SnmpFeature)
|
||||||
|
*/
|
||||||
|
export function createSnmpDevice(
|
||||||
|
info: ISnmpDiscoveryInfo,
|
||||||
|
retryOptions?: IRetryOptions
|
||||||
|
): UniversalDevice {
|
||||||
|
const device = new UniversalDevice(info.address, info.port, {
|
||||||
|
name: info.name,
|
||||||
|
retryOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Override the generated ID with discovery ID
|
||||||
|
(device as { id: string }).id = info.id;
|
||||||
|
|
||||||
|
// Add SNMP feature
|
||||||
|
const snmpFeature = new SnmpFeature(device.getDeviceReference(), info.port, {
|
||||||
|
community: info.community ?? 'public',
|
||||||
|
});
|
||||||
|
|
||||||
|
device.addFeature(snmpFeature);
|
||||||
|
return device;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// UPS Device Factory
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface IUpsDiscoveryInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
port: number;
|
||||||
|
protocol: 'nut' | 'snmp';
|
||||||
|
upsName?: string;
|
||||||
|
community?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a UPS device (UniversalDevice with PowerFeature)
|
||||||
|
*/
|
||||||
|
export function createUpsDevice(
|
||||||
|
info: IUpsDiscoveryInfo,
|
||||||
|
retryOptions?: IRetryOptions
|
||||||
|
): UniversalDevice {
|
||||||
|
const device = new UniversalDevice(info.address, info.port, {
|
||||||
|
name: info.name,
|
||||||
|
retryOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Override the generated ID with discovery ID
|
||||||
|
(device as { id: string }).id = info.id;
|
||||||
|
|
||||||
|
// Add power feature
|
||||||
|
const powerFeature = new PowerFeature(device.getDeviceReference(), info.port, {
|
||||||
|
protocol: info.protocol,
|
||||||
|
upsName: info.upsName,
|
||||||
|
community: info.community,
|
||||||
|
});
|
||||||
|
|
||||||
|
device.addFeature(powerFeature);
|
||||||
|
return device;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Speaker Factory
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface ISpeakerDiscoveryInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
port: number;
|
||||||
|
protocol: 'sonos' | 'airplay' | 'chromecast' | 'dlna';
|
||||||
|
roomName?: string;
|
||||||
|
modelName?: string;
|
||||||
|
features?: number; // AirPlay feature flags
|
||||||
|
deviceId?: string;
|
||||||
|
friendlyName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a speaker device (UniversalDevice with PlaybackFeature and VolumeFeature)
|
||||||
|
*/
|
||||||
|
export function createSpeaker(
|
||||||
|
info: ISpeakerDiscoveryInfo,
|
||||||
|
retryOptions?: IRetryOptions
|
||||||
|
): UniversalDevice {
|
||||||
|
const device = new UniversalDevice(info.address, info.port, {
|
||||||
|
name: info.name,
|
||||||
|
model: info.modelName,
|
||||||
|
retryOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Override the generated ID with discovery ID
|
||||||
|
(device as { id: string }).id = info.id;
|
||||||
|
|
||||||
|
// Add playback feature
|
||||||
|
const playbackFeature = new PlaybackFeature(device.getDeviceReference(), info.port, {
|
||||||
|
protocol: info.protocol,
|
||||||
|
supportsQueue: info.protocol === 'sonos',
|
||||||
|
supportsSeek: info.protocol !== 'airplay',
|
||||||
|
});
|
||||||
|
|
||||||
|
device.addFeature(playbackFeature);
|
||||||
|
|
||||||
|
// Add volume feature
|
||||||
|
const volumeFeature = new VolumeFeature(device.getDeviceReference(), info.port, {
|
||||||
|
volumeProtocol: info.protocol,
|
||||||
|
minVolume: 0,
|
||||||
|
maxVolume: 100,
|
||||||
|
supportsMute: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
device.addFeature(volumeFeature);
|
||||||
|
|
||||||
|
return device;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// DLNA Factory
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface IDlnaRendererDiscoveryInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
port: number;
|
||||||
|
controlUrl: string;
|
||||||
|
friendlyName: string;
|
||||||
|
modelName?: string;
|
||||||
|
manufacturer?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a DLNA renderer device (UniversalDevice with PlaybackFeature)
|
||||||
|
*/
|
||||||
|
export function createDlnaRenderer(
|
||||||
|
info: IDlnaRendererDiscoveryInfo,
|
||||||
|
retryOptions?: IRetryOptions
|
||||||
|
): UniversalDevice {
|
||||||
|
const device = new UniversalDevice(info.address, info.port, {
|
||||||
|
name: info.friendlyName || info.name,
|
||||||
|
manufacturer: info.manufacturer,
|
||||||
|
model: info.modelName,
|
||||||
|
retryOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Override the generated ID with discovery ID
|
||||||
|
(device as { id: string }).id = info.id;
|
||||||
|
|
||||||
|
// Add playback feature for DLNA
|
||||||
|
const playbackFeature = new PlaybackFeature(device.getDeviceReference(), info.port, {
|
||||||
|
protocol: 'dlna',
|
||||||
|
supportsQueue: false,
|
||||||
|
supportsSeek: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
device.addFeature(playbackFeature);
|
||||||
|
|
||||||
|
// Add volume feature
|
||||||
|
const volumeFeature = new VolumeFeature(device.getDeviceReference(), info.port, {
|
||||||
|
volumeProtocol: 'dlna',
|
||||||
|
minVolume: 0,
|
||||||
|
maxVolume: 100,
|
||||||
|
supportsMute: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
device.addFeature(volumeFeature);
|
||||||
|
|
||||||
|
return device;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Parsing Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function parseScanFormats(txtRecords: Record<string, string>): TScanFormat[] {
|
||||||
|
const formats: TScanFormat[] = [];
|
||||||
|
const pdl = txtRecords['pdl'] || txtRecords['DocumentFormat'] || '';
|
||||||
|
|
||||||
|
if (pdl.includes('jpeg') || pdl.includes('jpg')) formats.push('jpeg');
|
||||||
|
if (pdl.includes('png')) formats.push('png');
|
||||||
|
if (pdl.includes('pdf')) formats.push('pdf');
|
||||||
|
if (pdl.includes('tiff')) formats.push('tiff');
|
||||||
|
|
||||||
|
return formats.length > 0 ? formats : ['jpeg', 'png'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseScanResolutions(txtRecords: Record<string, string>): number[] {
|
||||||
|
const rs = txtRecords['rs'] || '';
|
||||||
|
const parts = rs.split(',').map((s) => parseInt(s.trim())).filter((n) => !isNaN(n) && n > 0);
|
||||||
|
return parts.length > 0 ? parts : [75, 150, 300, 600];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseScanColorModes(txtRecords: Record<string, string>): TColorMode[] {
|
||||||
|
const cs = txtRecords['cs'] || txtRecords['ColorSpace'] || '';
|
||||||
|
const modes: TColorMode[] = [];
|
||||||
|
|
||||||
|
if (cs.includes('color') || cs.includes('RGB')) modes.push('color');
|
||||||
|
if (cs.includes('gray') || cs.includes('grayscale')) modes.push('grayscale');
|
||||||
|
if (cs.includes('binary') || cs.includes('bw')) modes.push('blackwhite');
|
||||||
|
|
||||||
|
return modes.length > 0 ? modes : ['color', 'grayscale'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseScanSources(txtRecords: Record<string, string>): TScanSource[] {
|
||||||
|
const is = txtRecords['is'] || txtRecords['InputSource'] || '';
|
||||||
|
const sources: TScanSource[] = [];
|
||||||
|
|
||||||
|
if (is.includes('platen') || is.includes('flatbed') || is === '') {
|
||||||
|
sources.push('flatbed');
|
||||||
|
}
|
||||||
|
if (is.includes('adf') || is.includes('feeder')) {
|
||||||
|
sources.push('adf');
|
||||||
|
}
|
||||||
|
if (is.includes('duplex')) {
|
||||||
|
sources.push('adf-duplex');
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export {
|
||||||
|
// Re-export device and feature types for convenience
|
||||||
|
UniversalDevice,
|
||||||
|
ScanFeature,
|
||||||
|
PrintFeature,
|
||||||
|
PlaybackFeature,
|
||||||
|
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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
||||||
import { IppProtocol } from '../printer/printer.classes.ippprotocol.js';
|
import { IppProtocol } from '../protocols/index.js';
|
||||||
import type {
|
import type {
|
||||||
TPrintProtocol,
|
TPrintProtocol,
|
||||||
TPrintSides,
|
TPrintSides,
|
||||||
|
|||||||
@@ -4,8 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
||||||
import { EsclProtocol } from '../scanner/scanner.classes.esclprotocol.js';
|
import { EsclProtocol, SaneProtocol } from '../protocols/index.js';
|
||||||
import { SaneProtocol } from '../scanner/scanner.classes.saneprotocol.js';
|
|
||||||
import type {
|
import type {
|
||||||
TScanProtocol,
|
TScanProtocol,
|
||||||
TScanFormat,
|
TScanFormat,
|
||||||
|
|||||||
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
|
// Abstract base
|
||||||
export { Feature, type TDeviceReference } from './feature.abstract.js';
|
export { Feature, type TDeviceReference } from './feature.abstract.js';
|
||||||
|
|
||||||
// Concrete features
|
// Concrete features - Document/Infrastructure
|
||||||
export { ScanFeature, type IScanFeatureOptions } from './feature.scan.js';
|
export { ScanFeature, type IScanFeatureOptions } from './feature.scan.js';
|
||||||
export { PrintFeature, type IPrintFeatureOptions } from './feature.print.js';
|
export { PrintFeature, type IPrintFeatureOptions } from './feature.print.js';
|
||||||
export { PlaybackFeature, type IPlaybackFeatureOptions } from './feature.playback.js';
|
export { PlaybackFeature, type IPlaybackFeatureOptions } from './feature.playback.js';
|
||||||
export { VolumeFeature, type IVolumeFeatureOptions, type IVolumeController } from './feature.volume.js';
|
export { VolumeFeature, type IVolumeFeatureOptions, type IVolumeController } from './feature.volume.js';
|
||||||
export { PowerFeature, type IPowerFeatureOptions } from './feature.power.js';
|
export { PowerFeature, type IPowerFeatureOptions } from './feature.power.js';
|
||||||
export { SnmpFeature, type ISnmpFeatureOptions } from './feature.snmp.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';
|
||||||
|
|||||||
209
ts/index.ts
209
ts/index.ts
@@ -4,31 +4,24 @@
|
|||||||
* Supports: Scanners, Printers, SNMP devices, UPS, DLNA, Sonos, AirPlay, Chromecast
|
* Supports: Scanners, Printers, SNMP devices, UPS, DLNA, Sonos, AirPlay, Chromecast
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Main exports from DeviceManager
|
// ============================================================================
|
||||||
|
// Core Device Manager
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
export {
|
export {
|
||||||
DeviceManager,
|
DeviceManager,
|
||||||
MdnsDiscovery,
|
MdnsDiscovery,
|
||||||
NetworkScanner,
|
NetworkScanner,
|
||||||
SsdpDiscovery,
|
SsdpDiscovery,
|
||||||
Scanner,
|
|
||||||
Printer,
|
|
||||||
SnmpDevice,
|
|
||||||
UpsDevice,
|
|
||||||
DlnaRenderer,
|
|
||||||
DlnaServer,
|
|
||||||
Speaker,
|
|
||||||
SonosSpeaker,
|
|
||||||
AirPlaySpeaker,
|
|
||||||
ChromecastSpeaker,
|
|
||||||
SERVICE_TYPES,
|
SERVICE_TYPES,
|
||||||
SSDP_SERVICE_TYPES,
|
SSDP_SERVICE_TYPES,
|
||||||
} from './devicemanager.classes.devicemanager.js';
|
} from './devicemanager.classes.devicemanager.js';
|
||||||
|
|
||||||
// Abstract/base classes
|
// ============================================================================
|
||||||
export { Device } from './abstract/device.abstract.js';
|
// Universal Device & Features
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
// Universal Device & Features (new architecture)
|
export { UniversalDevice, type IUniversalDeviceInfo, type IDeviceCreateOptions } from './device/device.classes.device.js';
|
||||||
export { UniversalDevice } from './device/device.classes.device.js';
|
|
||||||
export {
|
export {
|
||||||
Feature,
|
Feature,
|
||||||
ScanFeature,
|
ScanFeature,
|
||||||
@@ -37,6 +30,15 @@ export {
|
|||||||
VolumeFeature,
|
VolumeFeature,
|
||||||
PowerFeature,
|
PowerFeature,
|
||||||
SnmpFeature,
|
SnmpFeature,
|
||||||
|
// Smart home features
|
||||||
|
SwitchFeature,
|
||||||
|
SensorFeature,
|
||||||
|
LightFeature,
|
||||||
|
CoverFeature,
|
||||||
|
LockFeature,
|
||||||
|
FanFeature,
|
||||||
|
ClimateFeature,
|
||||||
|
CameraFeature,
|
||||||
type TDeviceReference,
|
type TDeviceReference,
|
||||||
type IScanFeatureOptions,
|
type IScanFeatureOptions,
|
||||||
type IPrintFeatureOptions,
|
type IPrintFeatureOptions,
|
||||||
@@ -45,36 +47,98 @@ export {
|
|||||||
type IVolumeController,
|
type IVolumeController,
|
||||||
type IPowerFeatureOptions,
|
type IPowerFeatureOptions,
|
||||||
type ISnmpFeatureOptions,
|
type ISnmpFeatureOptions,
|
||||||
|
type ISwitchFeatureOptions,
|
||||||
|
type ISensorFeatureOptions,
|
||||||
|
type ILightFeatureOptions,
|
||||||
|
type ICoverFeatureOptions,
|
||||||
|
type ILockFeatureOptions,
|
||||||
|
type IFanFeatureOptions,
|
||||||
|
type IClimateFeatureOptions,
|
||||||
|
type ICameraFeatureOptions,
|
||||||
} from './features/index.js';
|
} from './features/index.js';
|
||||||
|
|
||||||
// Scanner protocol implementations
|
// ============================================================================
|
||||||
export { EsclProtocol } from './scanner/scanner.classes.esclprotocol.js';
|
// Device Factories
|
||||||
export { SaneProtocol } from './scanner/scanner.classes.saneprotocol.js';
|
// ============================================================================
|
||||||
|
|
||||||
// Printer protocol
|
|
||||||
export { IppProtocol } from './printer/printer.classes.ippprotocol.js';
|
|
||||||
|
|
||||||
// SNMP protocol
|
|
||||||
export { SnmpProtocol, SNMP_OIDS } from './snmp/snmp.classes.snmpprotocol.js';
|
|
||||||
|
|
||||||
// UPS protocols
|
|
||||||
export { NutProtocol, NUT_COMMANDS, NUT_VARIABLES } from './ups/ups.classes.nutprotocol.js';
|
|
||||||
export { UpsSnmpHandler, UPS_SNMP_OIDS } from './ups/ups.classes.upssnmp.js';
|
|
||||||
|
|
||||||
// DLNA/UPnP protocol
|
|
||||||
export {
|
export {
|
||||||
|
createScanner,
|
||||||
|
createPrinter,
|
||||||
|
createSnmpDevice,
|
||||||
|
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';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Protocol Implementations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export {
|
||||||
|
EsclProtocol,
|
||||||
|
SaneProtocol,
|
||||||
|
IppProtocol,
|
||||||
|
SnmpProtocol,
|
||||||
|
SNMP_OIDS,
|
||||||
|
NutProtocol,
|
||||||
|
NUT_COMMANDS,
|
||||||
|
NUT_VARIABLES,
|
||||||
UpnpSoapClient,
|
UpnpSoapClient,
|
||||||
UPNP_SERVICE_TYPES,
|
UPNP_SERVICE_TYPES,
|
||||||
UPNP_DEVICE_TYPES,
|
UPNP_DEVICE_TYPES,
|
||||||
} from './dlna/dlna.classes.upnp.js';
|
UpsSnmpHandler,
|
||||||
|
UPS_SNMP_OIDS,
|
||||||
|
// Home Assistant protocol
|
||||||
|
HomeAssistantProtocol,
|
||||||
|
type ISnmpOptions,
|
||||||
|
type ISnmpVarbind,
|
||||||
|
type TSnmpValueType,
|
||||||
|
type TNutStatusFlag,
|
||||||
|
type INutUpsInfo,
|
||||||
|
type INutVariable,
|
||||||
|
type TDlnaTransportState,
|
||||||
|
type TDlnaTransportStatus,
|
||||||
|
type IDlnaPositionInfo,
|
||||||
|
type IDlnaTransportInfo,
|
||||||
|
type IDlnaMediaInfo,
|
||||||
|
type IDlnaContentItem,
|
||||||
|
type IDlnaBrowseResult,
|
||||||
|
type TUpsBatteryStatus,
|
||||||
|
type TUpsOutputSource,
|
||||||
|
type TUpsTestResult,
|
||||||
|
type IUpsSnmpStatus,
|
||||||
|
} from './protocols/index.js';
|
||||||
|
|
||||||
// Chromecast app IDs
|
// Home Assistant Discovery
|
||||||
export { CHROMECAST_APPS } from './speaker/speaker.classes.chromecast.js';
|
export { HomeAssistantDiscovery, HA_SERVICE_TYPE } from './discovery/discovery.classes.homeassistant.js';
|
||||||
|
|
||||||
// AirPlay features
|
|
||||||
export { AIRPLAY_FEATURES } from './speaker/speaker.classes.airplay.js';
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
// Helpers
|
// Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
export { withRetry, createRetryable, defaultRetryOptions } from './helpers/helpers.retry.js';
|
export { withRetry, createRetryable, defaultRetryOptions } from './helpers/helpers.retry.js';
|
||||||
export {
|
export {
|
||||||
isValidIp,
|
isValidIp,
|
||||||
@@ -86,56 +150,12 @@ export {
|
|||||||
countIpsInCidr,
|
countIpsInCidr,
|
||||||
} from './helpers/helpers.iprange.js';
|
} from './helpers/helpers.iprange.js';
|
||||||
|
|
||||||
// All interfaces and types
|
// ============================================================================
|
||||||
|
// All Interfaces and Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
export * from './interfaces/index.js';
|
export * from './interfaces/index.js';
|
||||||
|
|
||||||
// SNMP types
|
|
||||||
export type {
|
|
||||||
ISnmpOptions,
|
|
||||||
ISnmpVarbind,
|
|
||||||
TSnmpValueType,
|
|
||||||
} from './snmp/snmp.classes.snmpprotocol.js';
|
|
||||||
export type { ISnmpDeviceInfo } from './snmp/snmp.classes.snmpdevice.js';
|
|
||||||
|
|
||||||
// UPS types
|
|
||||||
export type {
|
|
||||||
TNutStatusFlag,
|
|
||||||
INutUpsInfo,
|
|
||||||
INutVariable,
|
|
||||||
} from './ups/ups.classes.nutprotocol.js';
|
|
||||||
export type {
|
|
||||||
TUpsBatteryStatus,
|
|
||||||
TUpsOutputSource,
|
|
||||||
IUpsSnmpStatus,
|
|
||||||
} from './ups/ups.classes.upssnmp.js';
|
|
||||||
export type {
|
|
||||||
TUpsStatus,
|
|
||||||
TUpsProtocol,
|
|
||||||
IUpsDeviceInfo,
|
|
||||||
IUpsBatteryInfo,
|
|
||||||
IUpsPowerInfo,
|
|
||||||
IUpsFullStatus,
|
|
||||||
} from './ups/ups.classes.upsdevice.js';
|
|
||||||
|
|
||||||
// DLNA types
|
|
||||||
export type {
|
|
||||||
TDlnaTransportState,
|
|
||||||
TDlnaTransportStatus,
|
|
||||||
IDlnaPositionInfo,
|
|
||||||
IDlnaTransportInfo,
|
|
||||||
IDlnaMediaInfo,
|
|
||||||
IDlnaContentItem,
|
|
||||||
IDlnaBrowseResult,
|
|
||||||
} from './dlna/dlna.classes.upnp.js';
|
|
||||||
export type {
|
|
||||||
IDlnaRendererInfo,
|
|
||||||
IDlnaPlaybackState,
|
|
||||||
} from './dlna/dlna.classes.renderer.js';
|
|
||||||
export type {
|
|
||||||
IDlnaServerInfo,
|
|
||||||
IDlnaServerStats,
|
|
||||||
} from './dlna/dlna.classes.server.js';
|
|
||||||
|
|
||||||
// SSDP types
|
// SSDP types
|
||||||
export type {
|
export type {
|
||||||
ISsdpDevice,
|
ISsdpDevice,
|
||||||
@@ -143,26 +163,3 @@ export type {
|
|||||||
ISsdpService,
|
ISsdpService,
|
||||||
ISsdpIcon,
|
ISsdpIcon,
|
||||||
} from './discovery/discovery.classes.ssdp.js';
|
} from './discovery/discovery.classes.ssdp.js';
|
||||||
|
|
||||||
// Speaker types
|
|
||||||
export type {
|
|
||||||
TSpeakerProtocol,
|
|
||||||
TPlaybackState,
|
|
||||||
ITrackInfo,
|
|
||||||
IPlaybackStatus,
|
|
||||||
ISpeakerInfo,
|
|
||||||
} from './speaker/speaker.classes.speaker.js';
|
|
||||||
export type {
|
|
||||||
ISonosZoneInfo,
|
|
||||||
ISonosSpeakerInfo,
|
|
||||||
} from './speaker/speaker.classes.sonos.js';
|
|
||||||
export type {
|
|
||||||
IAirPlaySpeakerInfo,
|
|
||||||
IAirPlayPlaybackInfo,
|
|
||||||
} from './speaker/speaker.classes.airplay.js';
|
|
||||||
export type {
|
|
||||||
TChromecastType,
|
|
||||||
IChromecastSpeakerInfo,
|
|
||||||
IChromecastMediaMetadata,
|
|
||||||
IChromecastMediaStatus,
|
|
||||||
} from './speaker/speaker.classes.chromecast.js';
|
|
||||||
|
|||||||
@@ -13,16 +13,29 @@ import type { IRetryOptions } from './index.js';
|
|||||||
* All supported feature types
|
* All supported feature types
|
||||||
*/
|
*/
|
||||||
export type TFeatureType =
|
export type TFeatureType =
|
||||||
|
// Document handling
|
||||||
| 'scan' // Can scan documents (eSCL, SANE)
|
| 'scan' // Can scan documents (eSCL, SANE)
|
||||||
| 'print' // Can print documents (IPP, JetDirect)
|
| 'print' // Can print documents (IPP, JetDirect)
|
||||||
| 'fax' // Can send/receive fax
|
| 'fax' // Can send/receive fax
|
||||||
| 'copy' // Can copy (scan + print combined)
|
| 'copy' // Can copy (scan + print combined)
|
||||||
|
// Media playback
|
||||||
| 'playback' // Can play media (audio/video)
|
| 'playback' // Can play media (audio/video)
|
||||||
| 'volume' // Has volume control
|
| 'volume' // Has volume control
|
||||||
|
// Infrastructure
|
||||||
| 'power' // Has power status (UPS, smart plug)
|
| 'power' // Has power status (UPS, smart plug)
|
||||||
| 'snmp' // SNMP queryable
|
| 'snmp' // SNMP queryable
|
||||||
|
// DLNA
|
||||||
| 'dlna-render' // DLNA renderer
|
| 'dlna-render' // DLNA renderer
|
||||||
| 'dlna-serve' // DLNA server (content provider)
|
| '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';
|
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 netSnmp from 'net-snmp';
|
||||||
import * as sonos from 'sonos';
|
import * as sonos from 'sonos';
|
||||||
import * as castv2Client from 'castv2-client';
|
import * as castv2Client from 'castv2-client';
|
||||||
|
import WebSocket from 'ws';
|
||||||
|
|
||||||
// node-ssdp exports Client/Server under default in ESM
|
// node-ssdp exports Client/Server under default in ESM
|
||||||
const nodeSsdp = {
|
const nodeSsdp = {
|
||||||
@@ -37,4 +38,4 @@ const nodeSsdp = {
|
|||||||
Server: nodeSsdpModule.Server,
|
Server: nodeSsdpModule.Server,
|
||||||
};
|
};
|
||||||
|
|
||||||
export { bonjourService, ipp, nodeSsdp, netSnmp, sonos, castv2Client };
|
export { bonjourService, ipp, nodeSsdp, netSnmp, sonos, castv2Client, WebSocket };
|
||||||
|
|||||||
@@ -1,255 +0,0 @@
|
|||||||
import { Device } from '../abstract/device.abstract.js';
|
|
||||||
import { IppProtocol } from './printer.classes.ippprotocol.js';
|
|
||||||
import type {
|
|
||||||
IPrinterInfo,
|
|
||||||
IPrinterCapabilities,
|
|
||||||
IPrintOptions,
|
|
||||||
IPrintJob,
|
|
||||||
IRetryOptions,
|
|
||||||
} from '../interfaces/index.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Printer class for IPP network printers
|
|
||||||
*/
|
|
||||||
export class Printer extends Device {
|
|
||||||
public readonly uri: string;
|
|
||||||
public supportsColor: boolean = false;
|
|
||||||
public supportsDuplex: boolean = false;
|
|
||||||
public supportedMediaTypes: string[] = [];
|
|
||||||
public supportedMediaSizes: string[] = [];
|
|
||||||
public maxCopies: number = 99;
|
|
||||||
|
|
||||||
private ippClient: IppProtocol | null = null;
|
|
||||||
private ippPath: string;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
info: IPrinterInfo,
|
|
||||||
options?: {
|
|
||||||
ippPath?: string;
|
|
||||||
retryOptions?: IRetryOptions;
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
super(info, options?.retryOptions);
|
|
||||||
this.uri = info.uri;
|
|
||||||
this.supportsColor = info.supportsColor;
|
|
||||||
this.supportsDuplex = info.supportsDuplex;
|
|
||||||
this.supportedMediaTypes = info.supportedMediaTypes;
|
|
||||||
this.supportedMediaSizes = info.supportedMediaSizes;
|
|
||||||
this.maxCopies = info.maxCopies;
|
|
||||||
this.ippPath = options?.ippPath ?? '/ipp/print';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a Printer from discovery info
|
|
||||||
*/
|
|
||||||
public static fromDiscovery(
|
|
||||||
discoveredDevice: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
address: string;
|
|
||||||
port: number;
|
|
||||||
txtRecords: Record<string, string>;
|
|
||||||
},
|
|
||||||
retryOptions?: IRetryOptions
|
|
||||||
): Printer {
|
|
||||||
// Parse capabilities from TXT records
|
|
||||||
const txtRecords = discoveredDevice.txtRecords;
|
|
||||||
|
|
||||||
// Get IPP path from TXT records
|
|
||||||
const rp = txtRecords['rp'] || 'ipp/print';
|
|
||||||
const ippPath = rp.startsWith('/') ? rp : `/${rp}`;
|
|
||||||
|
|
||||||
// Parse color support
|
|
||||||
const colorSupported =
|
|
||||||
txtRecords['Color'] === 'T' ||
|
|
||||||
txtRecords['color'] === 'true' ||
|
|
||||||
txtRecords['URF']?.includes('W8') ||
|
|
||||||
false;
|
|
||||||
|
|
||||||
// Parse duplex support
|
|
||||||
const duplexSupported =
|
|
||||||
txtRecords['Duplex'] === 'T' ||
|
|
||||||
txtRecords['duplex'] === 'true' ||
|
|
||||||
txtRecords['URF']?.includes('DM') ||
|
|
||||||
false;
|
|
||||||
|
|
||||||
// Build printer URI
|
|
||||||
const isSecure = txtRecords['TLS'] === '1' || discoveredDevice.port === 443;
|
|
||||||
const protocol = isSecure ? 'ipps' : 'ipp';
|
|
||||||
const uri = `${protocol}://${discoveredDevice.address}:${discoveredDevice.port}${ippPath}`;
|
|
||||||
|
|
||||||
const info: IPrinterInfo = {
|
|
||||||
id: discoveredDevice.id,
|
|
||||||
name: discoveredDevice.name,
|
|
||||||
type: 'printer',
|
|
||||||
address: discoveredDevice.address,
|
|
||||||
port: discoveredDevice.port,
|
|
||||||
status: 'online',
|
|
||||||
uri: uri,
|
|
||||||
supportsColor: colorSupported,
|
|
||||||
supportsDuplex: duplexSupported,
|
|
||||||
supportedMediaTypes: [],
|
|
||||||
supportedMediaSizes: [],
|
|
||||||
maxCopies: 99,
|
|
||||||
manufacturer: txtRecords['usb_MFG'] || txtRecords['mfg'],
|
|
||||||
model: txtRecords['usb_MDL'] || txtRecords['mdl'] || txtRecords['ty'],
|
|
||||||
};
|
|
||||||
|
|
||||||
return new Printer(info, { ippPath, retryOptions });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get printer info
|
|
||||||
*/
|
|
||||||
public getPrinterInfo(): IPrinterInfo {
|
|
||||||
return {
|
|
||||||
...this.getInfo(),
|
|
||||||
type: 'printer',
|
|
||||||
uri: this.uri,
|
|
||||||
supportsColor: this.supportsColor,
|
|
||||||
supportsDuplex: this.supportsDuplex,
|
|
||||||
supportedMediaTypes: this.supportedMediaTypes,
|
|
||||||
supportedMediaSizes: this.supportedMediaSizes,
|
|
||||||
maxCopies: this.maxCopies,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get printer capabilities
|
|
||||||
*/
|
|
||||||
public async getCapabilities(): Promise<IPrinterCapabilities> {
|
|
||||||
if (!this.isConnected) {
|
|
||||||
await this.connect();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.ippClient) {
|
|
||||||
throw new Error('IPP client not initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
const caps = await this.withRetry(() => this.ippClient!.getAttributes());
|
|
||||||
|
|
||||||
// Update local properties
|
|
||||||
this.supportsColor = caps.colorSupported;
|
|
||||||
this.supportsDuplex = caps.duplexSupported;
|
|
||||||
this.supportedMediaSizes = caps.mediaSizes;
|
|
||||||
this.supportedMediaTypes = caps.mediaTypes;
|
|
||||||
this.maxCopies = caps.maxCopies;
|
|
||||||
|
|
||||||
return caps;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Print a document
|
|
||||||
*/
|
|
||||||
public async print(data: Buffer, options?: IPrintOptions): Promise<IPrintJob> {
|
|
||||||
if (!this.isConnected) {
|
|
||||||
await this.connect();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.ippClient) {
|
|
||||||
throw new Error('IPP client not initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setStatus('busy');
|
|
||||||
this.emit('print:started', options);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const job = await this.withRetry(() => this.ippClient!.print(data, options));
|
|
||||||
this.setStatus('online');
|
|
||||||
this.emit('print:submitted', job);
|
|
||||||
return job;
|
|
||||||
} catch (error) {
|
|
||||||
this.setStatus('online');
|
|
||||||
this.emit('print:error', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all print jobs
|
|
||||||
*/
|
|
||||||
public async getJobs(): Promise<IPrintJob[]> {
|
|
||||||
if (!this.isConnected) {
|
|
||||||
await this.connect();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.ippClient) {
|
|
||||||
throw new Error('IPP client not initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.withRetry(() => this.ippClient!.getJobs());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get specific job info
|
|
||||||
*/
|
|
||||||
public async getJobInfo(jobId: number): Promise<IPrintJob> {
|
|
||||||
if (!this.isConnected) {
|
|
||||||
await this.connect();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.ippClient) {
|
|
||||||
throw new Error('IPP client not initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.withRetry(() => this.ippClient!.getJobInfo(jobId));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancel a print job
|
|
||||||
*/
|
|
||||||
public async cancelJob(jobId: number): Promise<void> {
|
|
||||||
if (!this.isConnected) {
|
|
||||||
await this.connect();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.ippClient) {
|
|
||||||
throw new Error('IPP client not initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.withRetry(() => this.ippClient!.cancelJob(jobId));
|
|
||||||
this.emit('print:canceled', jobId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Connect to the printer
|
|
||||||
*/
|
|
||||||
protected async doConnect(): Promise<void> {
|
|
||||||
this.ippClient = new IppProtocol(this.address, this.port, this.ippPath);
|
|
||||||
|
|
||||||
// Test connection by checking availability
|
|
||||||
const available = await this.ippClient.checkAvailability();
|
|
||||||
if (!available) {
|
|
||||||
throw new Error('Printer not available');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch capabilities to populate local properties
|
|
||||||
await this.getCapabilities();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disconnect from the printer
|
|
||||||
*/
|
|
||||||
protected async doDisconnect(): Promise<void> {
|
|
||||||
this.ippClient = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh printer status
|
|
||||||
*/
|
|
||||||
public async refreshStatus(): Promise<void> {
|
|
||||||
try {
|
|
||||||
if (this.ippClient) {
|
|
||||||
const available = await this.ippClient.checkAvailability();
|
|
||||||
this.setStatus(available ? 'online' : 'offline');
|
|
||||||
} else {
|
|
||||||
this.setStatus('offline');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.setStatus('error');
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { IppProtocol };
|
|
||||||
59
ts/protocols/index.ts
Normal file
59
ts/protocols/index.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Protocol implementations
|
||||||
|
* All network communication protocols for device interaction
|
||||||
|
*/
|
||||||
|
|
||||||
|
// eSCL/AirScan scanner protocol
|
||||||
|
export { EsclProtocol } from './protocol.escl.js';
|
||||||
|
|
||||||
|
// SANE network scanner protocol
|
||||||
|
export { SaneProtocol } from './protocol.sane.js';
|
||||||
|
|
||||||
|
// IPP printer protocol
|
||||||
|
export { IppProtocol } from './protocol.ipp.js';
|
||||||
|
|
||||||
|
// SNMP query protocol
|
||||||
|
export {
|
||||||
|
SnmpProtocol,
|
||||||
|
SNMP_OIDS,
|
||||||
|
type TSnmpValueType,
|
||||||
|
type ISnmpVarbind,
|
||||||
|
type ISnmpOptions,
|
||||||
|
} from './protocol.snmp.js';
|
||||||
|
|
||||||
|
// Network UPS Tools protocol
|
||||||
|
export {
|
||||||
|
NutProtocol,
|
||||||
|
NUT_VARIABLES,
|
||||||
|
NUT_COMMANDS,
|
||||||
|
type TNutStatusFlag,
|
||||||
|
type INutUpsInfo,
|
||||||
|
type INutVariable,
|
||||||
|
} from './protocol.nut.js';
|
||||||
|
|
||||||
|
// UPnP/DLNA SOAP protocol
|
||||||
|
export {
|
||||||
|
UpnpSoapClient,
|
||||||
|
UPNP_SERVICE_TYPES,
|
||||||
|
UPNP_DEVICE_TYPES,
|
||||||
|
type TDlnaTransportState,
|
||||||
|
type TDlnaTransportStatus,
|
||||||
|
type IDlnaPositionInfo,
|
||||||
|
type IDlnaTransportInfo,
|
||||||
|
type IDlnaMediaInfo,
|
||||||
|
type IDlnaContentItem,
|
||||||
|
type IDlnaBrowseResult,
|
||||||
|
} from './protocol.upnp.js';
|
||||||
|
|
||||||
|
// UPS SNMP (UPS-MIB RFC 1628)
|
||||||
|
export {
|
||||||
|
UpsSnmpHandler,
|
||||||
|
UPS_SNMP_OIDS,
|
||||||
|
type TUpsBatteryStatus,
|
||||||
|
type TUpsOutputSource,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { SnmpProtocol, SNMP_OIDS, type ISnmpOptions } from '../snmp/snmp.classes.snmpprotocol.js';
|
import { SnmpProtocol, SNMP_OIDS, type ISnmpOptions } from '../protocols/index.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extended UPS-MIB OIDs (RFC 1628)
|
* Extended UPS-MIB OIDs (RFC 1628)
|
||||||
@@ -1,370 +0,0 @@
|
|||||||
import * as plugins from '../plugins.js';
|
|
||||||
import { Device } from '../abstract/device.abstract.js';
|
|
||||||
import { EsclProtocol } from './scanner.classes.esclprotocol.js';
|
|
||||||
import { SaneProtocol } from './scanner.classes.saneprotocol.js';
|
|
||||||
import type {
|
|
||||||
IScannerInfo,
|
|
||||||
IScannerCapabilities,
|
|
||||||
IScanOptions,
|
|
||||||
IScanResult,
|
|
||||||
TScannerProtocol,
|
|
||||||
TScanFormat,
|
|
||||||
TColorMode,
|
|
||||||
TScanSource,
|
|
||||||
IRetryOptions,
|
|
||||||
} from '../interfaces/index.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unified Scanner class that abstracts over eSCL and SANE protocols
|
|
||||||
*/
|
|
||||||
export class Scanner extends Device {
|
|
||||||
public readonly protocol: TScannerProtocol;
|
|
||||||
public supportedFormats: TScanFormat[] = ['jpeg', 'png', 'pdf'];
|
|
||||||
public supportedResolutions: number[] = [75, 150, 300, 600];
|
|
||||||
public supportedColorModes: TColorMode[] = ['color', 'grayscale', 'blackwhite'];
|
|
||||||
public supportedSources: TScanSource[] = ['flatbed'];
|
|
||||||
public hasAdf: boolean = false;
|
|
||||||
public hasDuplex: boolean = false;
|
|
||||||
public maxWidth: number = 215.9; // A4 width in mm
|
|
||||||
public maxHeight: number = 297; // A4 height in mm
|
|
||||||
|
|
||||||
private esclClient: EsclProtocol | null = null;
|
|
||||||
private saneClient: SaneProtocol | null = null;
|
|
||||||
private deviceName: string = '';
|
|
||||||
private isSecure: boolean = false;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
info: IScannerInfo,
|
|
||||||
options?: {
|
|
||||||
deviceName?: string;
|
|
||||||
secure?: boolean;
|
|
||||||
retryOptions?: IRetryOptions;
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
super(info, options?.retryOptions);
|
|
||||||
this.protocol = info.protocol;
|
|
||||||
this.supportedFormats = info.supportedFormats;
|
|
||||||
this.supportedResolutions = info.supportedResolutions;
|
|
||||||
this.supportedColorModes = info.supportedColorModes;
|
|
||||||
this.supportedSources = info.supportedSources;
|
|
||||||
this.hasAdf = info.hasAdf;
|
|
||||||
this.hasDuplex = info.hasDuplex;
|
|
||||||
this.maxWidth = info.maxWidth ?? this.maxWidth;
|
|
||||||
this.maxHeight = info.maxHeight ?? this.maxHeight;
|
|
||||||
this.deviceName = options?.deviceName ?? '';
|
|
||||||
this.isSecure = options?.secure ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a Scanner from discovery info
|
|
||||||
*/
|
|
||||||
public static fromDiscovery(
|
|
||||||
discoveredDevice: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
address: string;
|
|
||||||
port: number;
|
|
||||||
protocol: TScannerProtocol | 'ipp';
|
|
||||||
txtRecords: Record<string, string>;
|
|
||||||
},
|
|
||||||
retryOptions?: IRetryOptions
|
|
||||||
): Scanner {
|
|
||||||
const protocol = discoveredDevice.protocol === 'ipp' ? 'escl' : discoveredDevice.protocol;
|
|
||||||
|
|
||||||
// Parse capabilities from TXT records
|
|
||||||
const formats = Scanner.parseFormats(discoveredDevice.txtRecords);
|
|
||||||
const resolutions = Scanner.parseResolutions(discoveredDevice.txtRecords);
|
|
||||||
const colorModes = Scanner.parseColorModes(discoveredDevice.txtRecords);
|
|
||||||
const sources = Scanner.parseSources(discoveredDevice.txtRecords);
|
|
||||||
|
|
||||||
const info: IScannerInfo = {
|
|
||||||
id: discoveredDevice.id,
|
|
||||||
name: discoveredDevice.name,
|
|
||||||
type: 'scanner',
|
|
||||||
address: discoveredDevice.address,
|
|
||||||
port: discoveredDevice.port,
|
|
||||||
status: 'online',
|
|
||||||
protocol: protocol,
|
|
||||||
supportedFormats: formats,
|
|
||||||
supportedResolutions: resolutions,
|
|
||||||
supportedColorModes: colorModes,
|
|
||||||
supportedSources: sources,
|
|
||||||
hasAdf: sources.includes('adf') || sources.includes('adf-duplex'),
|
|
||||||
hasDuplex: sources.includes('adf-duplex'),
|
|
||||||
manufacturer: discoveredDevice.txtRecords['usb_MFG'] || discoveredDevice.txtRecords['mfg'],
|
|
||||||
model: discoveredDevice.txtRecords['usb_MDL'] || discoveredDevice.txtRecords['mdl'] || discoveredDevice.txtRecords['ty'],
|
|
||||||
};
|
|
||||||
|
|
||||||
const isSecure = discoveredDevice.txtRecords['TLS'] === '1' ||
|
|
||||||
discoveredDevice.protocol === 'escl' && discoveredDevice.port === 443;
|
|
||||||
|
|
||||||
return new Scanner(info, {
|
|
||||||
secure: isSecure,
|
|
||||||
retryOptions,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse supported formats from TXT records
|
|
||||||
*/
|
|
||||||
private static parseFormats(txtRecords: Record<string, string>): TScanFormat[] {
|
|
||||||
const formats: TScanFormat[] = [];
|
|
||||||
const pdl = txtRecords['pdl'] || txtRecords['DocumentFormat'] || '';
|
|
||||||
|
|
||||||
if (pdl.includes('jpeg') || pdl.includes('jpg')) formats.push('jpeg');
|
|
||||||
if (pdl.includes('png')) formats.push('png');
|
|
||||||
if (pdl.includes('pdf')) formats.push('pdf');
|
|
||||||
|
|
||||||
// Default to jpeg if nothing found
|
|
||||||
if (formats.length === 0) {
|
|
||||||
formats.push('jpeg', 'png');
|
|
||||||
}
|
|
||||||
|
|
||||||
return formats;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse supported resolutions from TXT records
|
|
||||||
*/
|
|
||||||
private static parseResolutions(txtRecords: Record<string, string>): number[] {
|
|
||||||
const rs = txtRecords['rs'] || '';
|
|
||||||
const resolutions: number[] = [];
|
|
||||||
|
|
||||||
// Try to parse comma-separated resolutions
|
|
||||||
const parts = rs.split(',').map((s) => parseInt(s.trim())).filter((n) => !isNaN(n) && n > 0);
|
|
||||||
if (parts.length > 0) {
|
|
||||||
return parts;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default common resolutions
|
|
||||||
return [75, 150, 300, 600];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse color modes from TXT records
|
|
||||||
*/
|
|
||||||
private static parseColorModes(txtRecords: Record<string, string>): TColorMode[] {
|
|
||||||
const cs = txtRecords['cs'] || txtRecords['ColorSpace'] || '';
|
|
||||||
const modes: TColorMode[] = [];
|
|
||||||
|
|
||||||
if (cs.includes('color') || cs.includes('RGB')) modes.push('color');
|
|
||||||
if (cs.includes('gray') || cs.includes('grayscale')) modes.push('grayscale');
|
|
||||||
if (cs.includes('binary') || cs.includes('bw')) modes.push('blackwhite');
|
|
||||||
|
|
||||||
// Default to color and grayscale
|
|
||||||
if (modes.length === 0) {
|
|
||||||
modes.push('color', 'grayscale');
|
|
||||||
}
|
|
||||||
|
|
||||||
return modes;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse input sources from TXT records
|
|
||||||
*/
|
|
||||||
private static parseSources(txtRecords: Record<string, string>): TScanSource[] {
|
|
||||||
const is = txtRecords['is'] || txtRecords['InputSource'] || '';
|
|
||||||
const sources: TScanSource[] = [];
|
|
||||||
|
|
||||||
if (is.includes('platen') || is.includes('flatbed') || is === '') {
|
|
||||||
sources.push('flatbed');
|
|
||||||
}
|
|
||||||
if (is.includes('adf') || is.includes('feeder')) {
|
|
||||||
sources.push('adf');
|
|
||||||
}
|
|
||||||
if (is.includes('duplex')) {
|
|
||||||
sources.push('adf-duplex');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default to flatbed
|
|
||||||
if (sources.length === 0) {
|
|
||||||
sources.push('flatbed');
|
|
||||||
}
|
|
||||||
|
|
||||||
return sources;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get scanner info
|
|
||||||
*/
|
|
||||||
public getScannerInfo(): IScannerInfo {
|
|
||||||
return {
|
|
||||||
...this.getInfo(),
|
|
||||||
type: 'scanner',
|
|
||||||
protocol: this.protocol,
|
|
||||||
supportedFormats: this.supportedFormats,
|
|
||||||
supportedResolutions: this.supportedResolutions,
|
|
||||||
supportedColorModes: this.supportedColorModes,
|
|
||||||
supportedSources: this.supportedSources,
|
|
||||||
hasAdf: this.hasAdf,
|
|
||||||
hasDuplex: this.hasDuplex,
|
|
||||||
maxWidth: this.maxWidth,
|
|
||||||
maxHeight: this.maxHeight,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get scanner capabilities
|
|
||||||
*/
|
|
||||||
public async getCapabilities(): Promise<IScannerCapabilities> {
|
|
||||||
if (!this.isConnected) {
|
|
||||||
await this.connect();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.protocol === 'escl' && this.esclClient) {
|
|
||||||
const caps = await this.esclClient.getCapabilities();
|
|
||||||
|
|
||||||
const platen = caps.platen;
|
|
||||||
return {
|
|
||||||
resolutions: platen?.supportedResolutions ?? this.supportedResolutions,
|
|
||||||
formats: this.supportedFormats,
|
|
||||||
colorModes: this.supportedColorModes,
|
|
||||||
sources: this.supportedSources,
|
|
||||||
maxWidth: platen ? platen.maxWidth / 300 * 25.4 : this.maxWidth,
|
|
||||||
maxHeight: platen ? platen.maxHeight / 300 * 25.4 : this.maxHeight,
|
|
||||||
minWidth: platen ? platen.minWidth / 300 * 25.4 : 0,
|
|
||||||
minHeight: platen ? platen.minHeight / 300 * 25.4 : 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return defaults for SANE (would need to query options)
|
|
||||||
return {
|
|
||||||
resolutions: this.supportedResolutions,
|
|
||||||
formats: this.supportedFormats,
|
|
||||||
colorModes: this.supportedColorModes,
|
|
||||||
sources: this.supportedSources,
|
|
||||||
maxWidth: this.maxWidth,
|
|
||||||
maxHeight: this.maxHeight,
|
|
||||||
minWidth: 0,
|
|
||||||
minHeight: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Perform a scan
|
|
||||||
*/
|
|
||||||
public async scan(options?: IScanOptions): Promise<IScanResult> {
|
|
||||||
if (!this.isConnected) {
|
|
||||||
await this.connect();
|
|
||||||
}
|
|
||||||
|
|
||||||
const scanOptions: IScanOptions = {
|
|
||||||
resolution: options?.resolution ?? 300,
|
|
||||||
format: options?.format ?? 'jpeg',
|
|
||||||
colorMode: options?.colorMode ?? 'color',
|
|
||||||
source: options?.source ?? 'flatbed',
|
|
||||||
area: options?.area,
|
|
||||||
intent: options?.intent ?? 'document',
|
|
||||||
quality: options?.quality ?? 85,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.setStatus('busy');
|
|
||||||
this.emit('scan:started', scanOptions);
|
|
||||||
|
|
||||||
try {
|
|
||||||
let result: IScanResult;
|
|
||||||
|
|
||||||
if (this.protocol === 'escl' && this.esclClient) {
|
|
||||||
result = await this.withRetry(() => this.esclClient!.scan(scanOptions));
|
|
||||||
} else if (this.protocol === 'sane' && this.saneClient) {
|
|
||||||
result = await this.withRetry(() => this.saneClient!.scan(scanOptions));
|
|
||||||
} else {
|
|
||||||
throw new Error(`No protocol client available for ${this.protocol}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setStatus('online');
|
|
||||||
this.emit('scan:completed', result);
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
this.setStatus('online');
|
|
||||||
this.emit('scan:error', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancel an ongoing scan
|
|
||||||
*/
|
|
||||||
public async cancelScan(): Promise<void> {
|
|
||||||
if (this.protocol === 'sane' && this.saneClient) {
|
|
||||||
await this.saneClient.cancel();
|
|
||||||
}
|
|
||||||
// eSCL cancellation is handled via job deletion in the protocol
|
|
||||||
this.emit('scan:canceled');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Connect to the scanner
|
|
||||||
*/
|
|
||||||
protected async doConnect(): Promise<void> {
|
|
||||||
if (this.protocol === 'escl') {
|
|
||||||
this.esclClient = new EsclProtocol(this.address, this.port, this.isSecure);
|
|
||||||
// Test connection by getting capabilities
|
|
||||||
await this.esclClient.getCapabilities();
|
|
||||||
} else if (this.protocol === 'sane') {
|
|
||||||
this.saneClient = new SaneProtocol(this.address, this.port);
|
|
||||||
await this.saneClient.connect();
|
|
||||||
|
|
||||||
// Get available devices
|
|
||||||
const devices = await this.saneClient.getDevices();
|
|
||||||
if (devices.length === 0) {
|
|
||||||
throw new Error('No SANE devices available');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open the first device or the specified one
|
|
||||||
const deviceToOpen = this.deviceName || devices[0].name;
|
|
||||||
await this.saneClient.open(deviceToOpen);
|
|
||||||
} else {
|
|
||||||
throw new Error(`Unsupported protocol: ${this.protocol}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disconnect from the scanner
|
|
||||||
*/
|
|
||||||
protected async doDisconnect(): Promise<void> {
|
|
||||||
if (this.esclClient) {
|
|
||||||
this.esclClient = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.saneClient) {
|
|
||||||
await this.saneClient.disconnect();
|
|
||||||
this.saneClient = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh scanner status
|
|
||||||
*/
|
|
||||||
public async refreshStatus(): Promise<void> {
|
|
||||||
try {
|
|
||||||
if (this.protocol === 'escl' && this.esclClient) {
|
|
||||||
const status = await this.esclClient.getStatus();
|
|
||||||
switch (status.state) {
|
|
||||||
case 'Idle':
|
|
||||||
this.setStatus('online');
|
|
||||||
break;
|
|
||||||
case 'Processing':
|
|
||||||
this.setStatus('busy');
|
|
||||||
break;
|
|
||||||
case 'Stopped':
|
|
||||||
case 'Testing':
|
|
||||||
this.setStatus('offline');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} else if (this.protocol === 'sane') {
|
|
||||||
// SANE doesn't have a direct status query
|
|
||||||
// Just check if we can still communicate
|
|
||||||
if (this.saneClient) {
|
|
||||||
await this.saneClient.getParameters();
|
|
||||||
this.setStatus('online');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.setStatus('error');
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { EsclProtocol, SaneProtocol };
|
|
||||||
@@ -1,271 +0,0 @@
|
|||||||
import * as plugins from '../plugins.js';
|
|
||||||
import { Device } from '../abstract/device.abstract.js';
|
|
||||||
import { SnmpProtocol, SNMP_OIDS, type ISnmpOptions, type ISnmpVarbind } from './snmp.classes.snmpprotocol.js';
|
|
||||||
import type { IDeviceInfo, IRetryOptions, TDeviceStatus } from '../interfaces/index.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SNMP device information
|
|
||||||
*/
|
|
||||||
export interface ISnmpDeviceInfo extends IDeviceInfo {
|
|
||||||
type: 'snmp';
|
|
||||||
sysDescr: string;
|
|
||||||
sysObjectID: string;
|
|
||||||
sysUpTime: number;
|
|
||||||
sysContact?: string;
|
|
||||||
sysName?: string;
|
|
||||||
sysLocation?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SNMP Device class for generic SNMP-enabled devices
|
|
||||||
*/
|
|
||||||
export class SnmpDevice extends Device {
|
|
||||||
private protocol: SnmpProtocol | null = null;
|
|
||||||
private snmpOptions: ISnmpOptions;
|
|
||||||
|
|
||||||
private _sysDescr: string = '';
|
|
||||||
private _sysObjectID: string = '';
|
|
||||||
private _sysUpTime: number = 0;
|
|
||||||
private _sysContact?: string;
|
|
||||||
private _sysName?: string;
|
|
||||||
private _sysLocation?: string;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
info: IDeviceInfo,
|
|
||||||
snmpOptions?: ISnmpOptions,
|
|
||||||
retryOptions?: IRetryOptions
|
|
||||||
) {
|
|
||||||
super(info, retryOptions);
|
|
||||||
this.snmpOptions = { port: info.port, ...snmpOptions };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Getters for SNMP properties
|
|
||||||
public get sysDescr(): string {
|
|
||||||
return this._sysDescr;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get sysObjectID(): string {
|
|
||||||
return this._sysObjectID;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get sysUpTime(): number {
|
|
||||||
return this._sysUpTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get sysContact(): string | undefined {
|
|
||||||
return this._sysContact;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get sysName(): string | undefined {
|
|
||||||
return this._sysName;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get sysLocation(): string | undefined {
|
|
||||||
return this._sysLocation;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Connect to the SNMP device
|
|
||||||
*/
|
|
||||||
protected async doConnect(): Promise<void> {
|
|
||||||
this.protocol = new SnmpProtocol(this.address, this.snmpOptions);
|
|
||||||
|
|
||||||
// Verify connection by fetching system info
|
|
||||||
const sysInfo = await this.protocol.getSystemInfo();
|
|
||||||
|
|
||||||
this._sysDescr = sysInfo.sysDescr;
|
|
||||||
this._sysObjectID = sysInfo.sysObjectID;
|
|
||||||
this._sysUpTime = sysInfo.sysUpTime;
|
|
||||||
this._sysContact = sysInfo.sysContact || undefined;
|
|
||||||
this._sysName = sysInfo.sysName || undefined;
|
|
||||||
this._sysLocation = sysInfo.sysLocation || undefined;
|
|
||||||
|
|
||||||
// Update device name if sysName is available
|
|
||||||
if (sysInfo.sysName && !this.name.includes('SNMP Device')) {
|
|
||||||
// Keep custom name
|
|
||||||
} else if (sysInfo.sysName) {
|
|
||||||
(this as { name: string }).name = sysInfo.sysName;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disconnect from the SNMP device
|
|
||||||
*/
|
|
||||||
protected async doDisconnect(): Promise<void> {
|
|
||||||
if (this.protocol) {
|
|
||||||
this.protocol.close();
|
|
||||||
this.protocol = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh device status
|
|
||||||
*/
|
|
||||||
public async refreshStatus(): Promise<void> {
|
|
||||||
if (!this.protocol) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
const sysInfo = await this.protocol.getSystemInfo();
|
|
||||||
this._sysUpTime = sysInfo.sysUpTime;
|
|
||||||
this.emit('status:updated', this.getDeviceInfo());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a single OID value
|
|
||||||
*/
|
|
||||||
public async get(oid: string): Promise<ISnmpVarbind> {
|
|
||||||
if (!this.protocol) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
return this.protocol.get(oid);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get multiple OID values
|
|
||||||
*/
|
|
||||||
public async getMultiple(oids: string[]): Promise<ISnmpVarbind[]> {
|
|
||||||
if (!this.protocol) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
return this.protocol.getMultiple(oids);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get next OID in the MIB tree
|
|
||||||
*/
|
|
||||||
public async getNext(oid: string): Promise<ISnmpVarbind> {
|
|
||||||
if (!this.protocol) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
return this.protocol.getNext(oid);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GETBULK operation for efficient table retrieval
|
|
||||||
*/
|
|
||||||
public async getBulk(
|
|
||||||
oids: string[],
|
|
||||||
nonRepeaters?: number,
|
|
||||||
maxRepetitions?: number
|
|
||||||
): Promise<ISnmpVarbind[]> {
|
|
||||||
if (!this.protocol) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
return this.protocol.getBulk(oids, nonRepeaters, maxRepetitions);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Walk a MIB tree
|
|
||||||
*/
|
|
||||||
public async walk(baseOid: string): Promise<ISnmpVarbind[]> {
|
|
||||||
if (!this.protocol) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
return this.protocol.walk(baseOid);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set an OID value
|
|
||||||
*/
|
|
||||||
public async set(
|
|
||||||
oid: string,
|
|
||||||
type: 'Integer' | 'OctetString' | 'ObjectIdentifier' | 'IpAddress',
|
|
||||||
value: unknown
|
|
||||||
): Promise<ISnmpVarbind> {
|
|
||||||
if (!this.protocol) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
return this.protocol.set(oid, type, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get device information
|
|
||||||
*/
|
|
||||||
public getDeviceInfo(): ISnmpDeviceInfo {
|
|
||||||
return {
|
|
||||||
id: this.id,
|
|
||||||
name: this.name,
|
|
||||||
type: 'snmp',
|
|
||||||
address: this.address,
|
|
||||||
port: this.port,
|
|
||||||
status: this.status,
|
|
||||||
sysDescr: this._sysDescr,
|
|
||||||
sysObjectID: this._sysObjectID,
|
|
||||||
sysUpTime: this._sysUpTime,
|
|
||||||
sysContact: this._sysContact,
|
|
||||||
sysName: this._sysName,
|
|
||||||
sysLocation: this._sysLocation,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create SnmpDevice from discovery data
|
|
||||||
*/
|
|
||||||
public static fromDiscovery(
|
|
||||||
data: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
address: string;
|
|
||||||
port?: number;
|
|
||||||
community?: string;
|
|
||||||
},
|
|
||||||
retryOptions?: IRetryOptions
|
|
||||||
): SnmpDevice {
|
|
||||||
const info: IDeviceInfo = {
|
|
||||||
id: data.id,
|
|
||||||
name: data.name,
|
|
||||||
type: 'snmp',
|
|
||||||
address: data.address,
|
|
||||||
port: data.port ?? 161,
|
|
||||||
status: 'unknown',
|
|
||||||
};
|
|
||||||
return new SnmpDevice(
|
|
||||||
info,
|
|
||||||
{ community: data.community ?? 'public' },
|
|
||||||
retryOptions
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Probe an IP address for SNMP device
|
|
||||||
*/
|
|
||||||
public static async probe(
|
|
||||||
address: string,
|
|
||||||
port: number = 161,
|
|
||||||
community: string = 'public',
|
|
||||||
timeout: number = 5000
|
|
||||||
): Promise<ISnmpDeviceInfo | null> {
|
|
||||||
const protocol = new SnmpProtocol(address, {
|
|
||||||
community,
|
|
||||||
port,
|
|
||||||
timeout,
|
|
||||||
retries: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const sysInfo = await protocol.getSystemInfo();
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: `snmp:${address}:${port}`,
|
|
||||||
name: sysInfo.sysName || `SNMP Device at ${address}`,
|
|
||||||
type: 'snmp',
|
|
||||||
address,
|
|
||||||
port,
|
|
||||||
status: 'online',
|
|
||||||
sysDescr: sysInfo.sysDescr,
|
|
||||||
sysObjectID: sysInfo.sysObjectID,
|
|
||||||
sysUpTime: sysInfo.sysUpTime,
|
|
||||||
sysContact: sysInfo.sysContact || undefined,
|
|
||||||
sysName: sysInfo.sysName || undefined,
|
|
||||||
sysLocation: sysInfo.sysLocation || undefined,
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
protocol.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { SNMP_OIDS };
|
|
||||||
@@ -1,548 +0,0 @@
|
|||||||
import * as plugins from '../plugins.js';
|
|
||||||
import { Speaker, type ITrackInfo, type IPlaybackStatus, type TPlaybackState, type ISpeakerInfo } from './speaker.classes.speaker.js';
|
|
||||||
import type { IDeviceInfo, IRetryOptions } from '../interfaces/index.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* AirPlay features bitmask
|
|
||||||
*/
|
|
||||||
export const AIRPLAY_FEATURES = {
|
|
||||||
Video: 1 << 0,
|
|
||||||
Photo: 1 << 1,
|
|
||||||
VideoFairPlay: 1 << 2,
|
|
||||||
VideoVolumeControl: 1 << 3,
|
|
||||||
VideoHTTPLiveStreams: 1 << 4,
|
|
||||||
Slideshow: 1 << 5,
|
|
||||||
Screen: 1 << 7,
|
|
||||||
ScreenRotate: 1 << 8,
|
|
||||||
Audio: 1 << 9,
|
|
||||||
AudioRedundant: 1 << 11,
|
|
||||||
FPSAPv2pt5_AES_GCM: 1 << 12,
|
|
||||||
PhotoCaching: 1 << 13,
|
|
||||||
Authentication4: 1 << 14,
|
|
||||||
MetadataFeatures: 1 << 15,
|
|
||||||
AudioFormats: 1 << 16,
|
|
||||||
Authentication1: 1 << 17,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* AirPlay device info
|
|
||||||
*/
|
|
||||||
export interface IAirPlaySpeakerInfo extends ISpeakerInfo {
|
|
||||||
protocol: 'airplay';
|
|
||||||
features: number;
|
|
||||||
supportsVideo: boolean;
|
|
||||||
supportsAudio: boolean;
|
|
||||||
supportsScreen: boolean;
|
|
||||||
deviceId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* AirPlay playback info
|
|
||||||
*/
|
|
||||||
export interface IAirPlayPlaybackInfo {
|
|
||||||
duration: number;
|
|
||||||
position: number;
|
|
||||||
rate: number;
|
|
||||||
readyToPlay: boolean;
|
|
||||||
playbackBufferEmpty: boolean;
|
|
||||||
playbackBufferFull: boolean;
|
|
||||||
playbackLikelyToKeepUp: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* AirPlay Speaker device
|
|
||||||
* Basic implementation for AirPlay-compatible devices
|
|
||||||
*/
|
|
||||||
export class AirPlaySpeaker extends Speaker {
|
|
||||||
private _features: number = 0;
|
|
||||||
private _deviceId?: string;
|
|
||||||
private _supportsVideo: boolean = false;
|
|
||||||
private _supportsAudio: boolean = true;
|
|
||||||
private _supportsScreen: boolean = false;
|
|
||||||
private _currentUri?: string;
|
|
||||||
private _currentPosition: number = 0;
|
|
||||||
private _currentDuration: number = 0;
|
|
||||||
private _isPlaying: boolean = false;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
info: IDeviceInfo,
|
|
||||||
options?: {
|
|
||||||
roomName?: string;
|
|
||||||
modelName?: string;
|
|
||||||
features?: number;
|
|
||||||
deviceId?: string;
|
|
||||||
},
|
|
||||||
retryOptions?: IRetryOptions
|
|
||||||
) {
|
|
||||||
super(info, 'airplay', options, retryOptions);
|
|
||||||
this._features = options?.features || 0;
|
|
||||||
this._deviceId = options?.deviceId;
|
|
||||||
|
|
||||||
// Parse features
|
|
||||||
if (this._features) {
|
|
||||||
this._supportsVideo = !!(this._features & AIRPLAY_FEATURES.Video);
|
|
||||||
this._supportsAudio = !!(this._features & AIRPLAY_FEATURES.Audio);
|
|
||||||
this._supportsScreen = !!(this._features & AIRPLAY_FEATURES.Screen);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Getters
|
|
||||||
public get features(): number {
|
|
||||||
return this._features;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get deviceId(): string | undefined {
|
|
||||||
return this._deviceId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get supportsVideo(): boolean {
|
|
||||||
return this._supportsVideo;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get supportsAudio(): boolean {
|
|
||||||
return this._supportsAudio;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get supportsScreen(): boolean {
|
|
||||||
return this._supportsScreen;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Connect to AirPlay device
|
|
||||||
* AirPlay 2 devices (HomePods) may not respond to /server-info,
|
|
||||||
* so we consider them connected even if we can't get device info.
|
|
||||||
*/
|
|
||||||
protected async doConnect(): Promise<void> {
|
|
||||||
// Try /server-info endpoint (works for older AirPlay devices)
|
|
||||||
const url = `http://${this.address}:${this.port}/server-info`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(url, {
|
|
||||||
signal: AbortSignal.timeout(3000),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
// Parse server info (plist format)
|
|
||||||
const text = await response.text();
|
|
||||||
|
|
||||||
// Extract features if available
|
|
||||||
const featuresMatch = text.match(/<key>features<\/key>\s*<integer>(\d+)<\/integer>/);
|
|
||||||
if (featuresMatch) {
|
|
||||||
this._features = parseInt(featuresMatch[1]);
|
|
||||||
this._supportsVideo = !!(this._features & AIRPLAY_FEATURES.Video);
|
|
||||||
this._supportsAudio = !!(this._features & AIRPLAY_FEATURES.Audio);
|
|
||||||
this._supportsScreen = !!(this._features & AIRPLAY_FEATURES.Screen);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract device ID
|
|
||||||
const deviceIdMatch = text.match(/<key>deviceid<\/key>\s*<string>([^<]+)<\/string>/);
|
|
||||||
if (deviceIdMatch) {
|
|
||||||
this._deviceId = deviceIdMatch[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract model
|
|
||||||
const modelMatch = text.match(/<key>model<\/key>\s*<string>([^<]+)<\/string>/);
|
|
||||||
if (modelMatch) {
|
|
||||||
this._modelName = modelMatch[1];
|
|
||||||
this.model = modelMatch[1];
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Non-OK response - might be AirPlay 2, continue below
|
|
||||||
} catch {
|
|
||||||
// /server-info failed, might be AirPlay 2 device
|
|
||||||
}
|
|
||||||
|
|
||||||
// For AirPlay 2 devices (HomePods), /server-info doesn't work
|
|
||||||
// Try a simple port check - if the port responds, consider it connected
|
|
||||||
// HomePods will respond to proper AirPlay 2 protocol even if HTTP endpoints fail
|
|
||||||
// We'll assume it's an AirPlay 2 audio device
|
|
||||||
this._supportsAudio = true;
|
|
||||||
this._supportsVideo = false;
|
|
||||||
this._supportsScreen = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disconnect
|
|
||||||
*/
|
|
||||||
protected async doDisconnect(): Promise<void> {
|
|
||||||
try {
|
|
||||||
await this.stop();
|
|
||||||
} catch {
|
|
||||||
// Ignore stop errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh status
|
|
||||||
*/
|
|
||||||
public async refreshStatus(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const info = await this.getAirPlayPlaybackInfo();
|
|
||||||
this._isPlaying = info.rate > 0;
|
|
||||||
this._currentPosition = info.position;
|
|
||||||
this._currentDuration = info.duration;
|
|
||||||
this._playbackState = this._isPlaying ? 'playing' : 'paused';
|
|
||||||
} catch {
|
|
||||||
this._playbackState = 'stopped';
|
|
||||||
}
|
|
||||||
|
|
||||||
this.emit('status:updated', this.getSpeakerInfo());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Playback Control
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Play media URL
|
|
||||||
*/
|
|
||||||
public async play(uri?: string): Promise<void> {
|
|
||||||
if (uri) {
|
|
||||||
this._currentUri = uri;
|
|
||||||
|
|
||||||
const body = `Content-Location: ${uri}\nStart-Position: 0\n`;
|
|
||||||
|
|
||||||
const response = await fetch(`http://${this.address}:${this.port}/play`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'text/parameters',
|
|
||||||
},
|
|
||||||
body,
|
|
||||||
signal: AbortSignal.timeout(10000),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Play failed: ${response.status}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Resume playback
|
|
||||||
await this.setRate(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
this._isPlaying = true;
|
|
||||||
this._playbackState = 'playing';
|
|
||||||
this.emit('playback:started');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pause playback
|
|
||||||
*/
|
|
||||||
public async pause(): Promise<void> {
|
|
||||||
await this.setRate(0);
|
|
||||||
this._isPlaying = false;
|
|
||||||
this._playbackState = 'paused';
|
|
||||||
this.emit('playback:paused');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop playback
|
|
||||||
*/
|
|
||||||
public async stop(): Promise<void> {
|
|
||||||
const response = await fetch(`http://${this.address}:${this.port}/stop`, {
|
|
||||||
method: 'POST',
|
|
||||||
signal: AbortSignal.timeout(5000),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Stop failed: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
this._isPlaying = false;
|
|
||||||
this._playbackState = 'stopped';
|
|
||||||
this._currentUri = undefined;
|
|
||||||
this.emit('playback:stopped');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Next track (not supported on basic AirPlay)
|
|
||||||
*/
|
|
||||||
public async next(): Promise<void> {
|
|
||||||
throw new Error('Next track not supported on AirPlay');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Previous track (not supported on basic AirPlay)
|
|
||||||
*/
|
|
||||||
public async previous(): Promise<void> {
|
|
||||||
throw new Error('Previous track not supported on AirPlay');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Seek to position
|
|
||||||
*/
|
|
||||||
public async seek(seconds: number): Promise<void> {
|
|
||||||
const body = `position: ${seconds}\n`;
|
|
||||||
|
|
||||||
const response = await fetch(`http://${this.address}:${this.port}/scrub`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'text/parameters',
|
|
||||||
},
|
|
||||||
body,
|
|
||||||
signal: AbortSignal.timeout(5000),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Seek failed: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
this._currentPosition = seconds;
|
|
||||||
this.emit('playback:seeked', { position: seconds });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Volume Control (limited support)
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get volume (not always supported)
|
|
||||||
*/
|
|
||||||
public async getVolume(): Promise<number> {
|
|
||||||
// AirPlay volume control varies by device
|
|
||||||
return this._volume;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set volume (not always supported)
|
|
||||||
*/
|
|
||||||
public async setVolume(level: number): Promise<void> {
|
|
||||||
const clamped = Math.max(0, Math.min(100, level));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const body = `volume: ${clamped / 100}\n`;
|
|
||||||
|
|
||||||
const response = await fetch(`http://${this.address}:${this.port}/volume`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'text/parameters',
|
|
||||||
},
|
|
||||||
body,
|
|
||||||
signal: AbortSignal.timeout(5000),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
this._volume = clamped;
|
|
||||||
this.emit('volume:changed', { volume: clamped });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Volume control may not be supported
|
|
||||||
throw new Error('Volume control not supported on this device');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get mute state (not always supported)
|
|
||||||
*/
|
|
||||||
public async getMute(): Promise<boolean> {
|
|
||||||
return this._muted;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set mute state (not always supported)
|
|
||||||
*/
|
|
||||||
public async setMute(muted: boolean): Promise<void> {
|
|
||||||
// Mute by setting volume to 0
|
|
||||||
if (muted) {
|
|
||||||
await this.setVolume(0);
|
|
||||||
} else {
|
|
||||||
await this.setVolume(this._volume || 50);
|
|
||||||
}
|
|
||||||
this._muted = muted;
|
|
||||||
this.emit('mute:changed', { muted });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Track Information
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current track
|
|
||||||
*/
|
|
||||||
public async getCurrentTrack(): Promise<ITrackInfo | null> {
|
|
||||||
if (!this._currentUri) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: this._currentUri.split('/').pop() || 'Unknown',
|
|
||||||
duration: this._currentDuration,
|
|
||||||
position: this._currentPosition,
|
|
||||||
uri: this._currentUri,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get playback status
|
|
||||||
*/
|
|
||||||
public async getPlaybackStatus(): Promise<IPlaybackStatus> {
|
|
||||||
await this.refreshStatus();
|
|
||||||
|
|
||||||
return {
|
|
||||||
state: this._playbackState,
|
|
||||||
volume: this._volume,
|
|
||||||
muted: this._muted,
|
|
||||||
track: await this.getCurrentTrack() || undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// AirPlay-specific Methods
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set playback rate
|
|
||||||
*/
|
|
||||||
private async setRate(rate: number): Promise<void> {
|
|
||||||
const body = `value: ${rate}\n`;
|
|
||||||
|
|
||||||
const response = await fetch(`http://${this.address}:${this.port}/rate`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'text/parameters',
|
|
||||||
},
|
|
||||||
body,
|
|
||||||
signal: AbortSignal.timeout(5000),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Set rate failed: ${response.status}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get AirPlay playback info
|
|
||||||
*/
|
|
||||||
public async getAirPlayPlaybackInfo(): Promise<IAirPlayPlaybackInfo> {
|
|
||||||
const response = await fetch(`http://${this.address}:${this.port}/playback-info`, {
|
|
||||||
signal: AbortSignal.timeout(5000),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Get playback info failed: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = await response.text();
|
|
||||||
|
|
||||||
// Parse plist response
|
|
||||||
const extractReal = (key: string): number => {
|
|
||||||
const match = text.match(new RegExp(`<key>${key}</key>\\s*<real>([\\d.]+)</real>`));
|
|
||||||
return match ? parseFloat(match[1]) : 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const extractBool = (key: string): boolean => {
|
|
||||||
const match = text.match(new RegExp(`<key>${key}</key>\\s*<(true|false)/>`));
|
|
||||||
return match?.[1] === 'true';
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
duration: extractReal('duration'),
|
|
||||||
position: extractReal('position'),
|
|
||||||
rate: extractReal('rate'),
|
|
||||||
readyToPlay: extractBool('readyToPlay'),
|
|
||||||
playbackBufferEmpty: extractBool('playbackBufferEmpty'),
|
|
||||||
playbackBufferFull: extractBool('playbackBufferFull'),
|
|
||||||
playbackLikelyToKeepUp: extractBool('playbackLikelyToKeepUp'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get scrub position
|
|
||||||
*/
|
|
||||||
public async getScrubPosition(): Promise<{ position: number; duration: number }> {
|
|
||||||
const response = await fetch(`http://${this.address}:${this.port}/scrub`, {
|
|
||||||
signal: AbortSignal.timeout(5000),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Get scrub position failed: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = await response.text();
|
|
||||||
|
|
||||||
const durationMatch = text.match(/duration:\s*([\d.]+)/);
|
|
||||||
const positionMatch = text.match(/position:\s*([\d.]+)/);
|
|
||||||
|
|
||||||
return {
|
|
||||||
duration: durationMatch ? parseFloat(durationMatch[1]) : 0,
|
|
||||||
position: positionMatch ? parseFloat(positionMatch[1]) : 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Device Info
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get speaker info
|
|
||||||
*/
|
|
||||||
public getSpeakerInfo(): IAirPlaySpeakerInfo {
|
|
||||||
return {
|
|
||||||
id: this.id,
|
|
||||||
name: this.name,
|
|
||||||
type: 'speaker',
|
|
||||||
address: this.address,
|
|
||||||
port: this.port,
|
|
||||||
status: this.status,
|
|
||||||
protocol: 'airplay',
|
|
||||||
roomName: this._roomName,
|
|
||||||
modelName: this._modelName,
|
|
||||||
features: this._features,
|
|
||||||
supportsVideo: this._supportsVideo,
|
|
||||||
supportsAudio: this._supportsAudio,
|
|
||||||
supportsScreen: this._supportsScreen,
|
|
||||||
deviceId: this._deviceId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create from mDNS discovery
|
|
||||||
*/
|
|
||||||
public static fromDiscovery(
|
|
||||||
data: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
address: string;
|
|
||||||
port?: number;
|
|
||||||
roomName?: string;
|
|
||||||
modelName?: string;
|
|
||||||
features?: number;
|
|
||||||
deviceId?: string;
|
|
||||||
},
|
|
||||||
retryOptions?: IRetryOptions
|
|
||||||
): AirPlaySpeaker {
|
|
||||||
const info: IDeviceInfo = {
|
|
||||||
id: data.id,
|
|
||||||
name: data.name,
|
|
||||||
type: 'speaker',
|
|
||||||
address: data.address,
|
|
||||||
port: data.port ?? 7000,
|
|
||||||
status: 'unknown',
|
|
||||||
};
|
|
||||||
|
|
||||||
return new AirPlaySpeaker(
|
|
||||||
info,
|
|
||||||
{
|
|
||||||
roomName: data.roomName,
|
|
||||||
modelName: data.modelName,
|
|
||||||
features: data.features,
|
|
||||||
deviceId: data.deviceId,
|
|
||||||
},
|
|
||||||
retryOptions
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Probe for AirPlay device
|
|
||||||
*/
|
|
||||||
public static async probe(address: string, port: number = 7000, timeout: number = 3000): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`http://${address}:${port}/server-info`, {
|
|
||||||
signal: AbortSignal.timeout(timeout),
|
|
||||||
});
|
|
||||||
return response.ok;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,725 +0,0 @@
|
|||||||
import * as plugins from '../plugins.js';
|
|
||||||
import { Speaker, type ITrackInfo, type IPlaybackStatus, type TPlaybackState, type ISpeakerInfo } from './speaker.classes.speaker.js';
|
|
||||||
import type { IDeviceInfo, IRetryOptions } from '../interfaces/index.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Chromecast device types
|
|
||||||
*/
|
|
||||||
export type TChromecastType = 'audio' | 'video' | 'group';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Chromecast application IDs
|
|
||||||
*/
|
|
||||||
export const CHROMECAST_APPS = {
|
|
||||||
DEFAULT_MEDIA_RECEIVER: 'CC1AD845',
|
|
||||||
BACKDROP: 'E8C28D3C',
|
|
||||||
YOUTUBE: '233637DE',
|
|
||||||
NETFLIX: 'CA5E8412',
|
|
||||||
PLEX: '9AC194DC',
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Chromecast device info
|
|
||||||
*/
|
|
||||||
export interface IChromecastSpeakerInfo extends ISpeakerInfo {
|
|
||||||
protocol: 'chromecast';
|
|
||||||
friendlyName: string;
|
|
||||||
deviceType: TChromecastType;
|
|
||||||
capabilities: string[];
|
|
||||||
currentAppId?: string;
|
|
||||||
currentAppName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Chromecast media metadata
|
|
||||||
*/
|
|
||||||
export interface IChromecastMediaMetadata {
|
|
||||||
metadataType?: number;
|
|
||||||
title?: string;
|
|
||||||
subtitle?: string;
|
|
||||||
artist?: string;
|
|
||||||
albumName?: string;
|
|
||||||
albumArtist?: string;
|
|
||||||
trackNumber?: number;
|
|
||||||
discNumber?: number;
|
|
||||||
images?: { url: string; width?: number; height?: number }[];
|
|
||||||
releaseDate?: string;
|
|
||||||
studio?: string;
|
|
||||||
seriesTitle?: string;
|
|
||||||
season?: number;
|
|
||||||
episode?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Chromecast media status
|
|
||||||
*/
|
|
||||||
export interface IChromecastMediaStatus {
|
|
||||||
mediaSessionId: number;
|
|
||||||
playbackRate: number;
|
|
||||||
playerState: 'IDLE' | 'PLAYING' | 'PAUSED' | 'BUFFERING';
|
|
||||||
currentTime: number;
|
|
||||||
idleReason?: 'CANCELLED' | 'INTERRUPTED' | 'FINISHED' | 'ERROR';
|
|
||||||
media?: {
|
|
||||||
contentId: string;
|
|
||||||
contentType: string;
|
|
||||||
duration: number;
|
|
||||||
metadata?: IChromecastMediaMetadata;
|
|
||||||
};
|
|
||||||
volume: {
|
|
||||||
level: number;
|
|
||||||
muted: boolean;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Chromecast Speaker device
|
|
||||||
*/
|
|
||||||
export class ChromecastSpeaker extends Speaker {
|
|
||||||
private client: InstanceType<typeof plugins.castv2Client.Client> | null = null;
|
|
||||||
private player: unknown = null;
|
|
||||||
|
|
||||||
private _friendlyName: string = '';
|
|
||||||
private _deviceType: TChromecastType = 'audio';
|
|
||||||
private _capabilities: string[] = [];
|
|
||||||
private _currentAppId?: string;
|
|
||||||
private _currentAppName?: string;
|
|
||||||
private _mediaSessionId?: number;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
info: IDeviceInfo,
|
|
||||||
options?: {
|
|
||||||
roomName?: string;
|
|
||||||
modelName?: string;
|
|
||||||
friendlyName?: string;
|
|
||||||
deviceType?: TChromecastType;
|
|
||||||
capabilities?: string[];
|
|
||||||
},
|
|
||||||
retryOptions?: IRetryOptions
|
|
||||||
) {
|
|
||||||
super(info, 'chromecast', options, retryOptions);
|
|
||||||
this._friendlyName = options?.friendlyName || info.name;
|
|
||||||
this._deviceType = options?.deviceType || 'audio';
|
|
||||||
this._capabilities = options?.capabilities || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Getters
|
|
||||||
public get friendlyName(): string {
|
|
||||||
return this._friendlyName;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get deviceType(): TChromecastType {
|
|
||||||
return this._deviceType;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get capabilities(): string[] {
|
|
||||||
return this._capabilities;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get currentAppId(): string | undefined {
|
|
||||||
return this._currentAppId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get currentAppName(): string | undefined {
|
|
||||||
return this._currentAppName;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Connect to Chromecast
|
|
||||||
*/
|
|
||||||
protected async doConnect(): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
this.client = new plugins.castv2Client.Client();
|
|
||||||
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
if (this.client) {
|
|
||||||
this.client.close();
|
|
||||||
this.client = null;
|
|
||||||
}
|
|
||||||
reject(new Error('Connection timeout'));
|
|
||||||
}, 10000);
|
|
||||||
|
|
||||||
this.client.on('error', (err: Error) => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
if (this.client) {
|
|
||||||
this.client.close();
|
|
||||||
this.client = null;
|
|
||||||
}
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.client.connect(this.address, () => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
|
|
||||||
// Get receiver status
|
|
||||||
this.client!.getStatus((err: Error | null, status: { applications?: Array<{ appId: string; displayName: string }> }) => {
|
|
||||||
if (err) {
|
|
||||||
reject(err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status && status.applications && status.applications.length > 0) {
|
|
||||||
const app = status.applications[0];
|
|
||||||
this._currentAppId = app.appId;
|
|
||||||
this._currentAppName = app.displayName;
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disconnect
|
|
||||||
*/
|
|
||||||
protected async doDisconnect(): Promise<void> {
|
|
||||||
if (this.client) {
|
|
||||||
this.client.close();
|
|
||||||
this.client = null;
|
|
||||||
}
|
|
||||||
this.player = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh status
|
|
||||||
*/
|
|
||||||
public async refreshStatus(): Promise<void> {
|
|
||||||
if (!this.client) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
this.client!.getStatus((err: Error | null, status: {
|
|
||||||
applications?: Array<{ appId: string; displayName: string }>;
|
|
||||||
volume?: { level: number; muted: boolean };
|
|
||||||
}) => {
|
|
||||||
if (!err && status) {
|
|
||||||
if (status.applications && status.applications.length > 0) {
|
|
||||||
const app = status.applications[0];
|
|
||||||
this._currentAppId = app.appId;
|
|
||||||
this._currentAppName = app.displayName;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status.volume) {
|
|
||||||
this._volume = Math.round(status.volume.level * 100);
|
|
||||||
this._muted = status.volume.muted;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.emit('status:updated', this.getSpeakerInfo());
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Launch media receiver and get player
|
|
||||||
*/
|
|
||||||
private async getMediaPlayer(): Promise<InstanceType<typeof plugins.castv2Client.DefaultMediaReceiver>> {
|
|
||||||
if (!this.client) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
this.client!.launch(plugins.castv2Client.DefaultMediaReceiver, (err: Error | null, player: InstanceType<typeof plugins.castv2Client.DefaultMediaReceiver>) => {
|
|
||||||
if (err) {
|
|
||||||
reject(err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.player = player;
|
|
||||||
|
|
||||||
player.on('status', (status: IChromecastMediaStatus) => {
|
|
||||||
this.handleMediaStatus(status);
|
|
||||||
});
|
|
||||||
|
|
||||||
resolve(player);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle media status update
|
|
||||||
*/
|
|
||||||
private handleMediaStatus(status: IChromecastMediaStatus): void {
|
|
||||||
if (!status) return;
|
|
||||||
|
|
||||||
this._mediaSessionId = status.mediaSessionId;
|
|
||||||
|
|
||||||
// Update playback state
|
|
||||||
switch (status.playerState) {
|
|
||||||
case 'PLAYING':
|
|
||||||
this._playbackState = 'playing';
|
|
||||||
break;
|
|
||||||
case 'PAUSED':
|
|
||||||
this._playbackState = 'paused';
|
|
||||||
break;
|
|
||||||
case 'BUFFERING':
|
|
||||||
this._playbackState = 'transitioning';
|
|
||||||
break;
|
|
||||||
case 'IDLE':
|
|
||||||
default:
|
|
||||||
this._playbackState = 'stopped';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update volume
|
|
||||||
if (status.volume) {
|
|
||||||
this._volume = Math.round(status.volume.level * 100);
|
|
||||||
this._muted = status.volume.muted;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.emit('playback:status', status);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Playback Control
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Play media URL
|
|
||||||
*/
|
|
||||||
public async play(uri?: string): Promise<void> {
|
|
||||||
if (!this.client) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
const player = await this.getMediaPlayer() as InstanceType<typeof plugins.castv2Client.DefaultMediaReceiver>;
|
|
||||||
|
|
||||||
if (uri) {
|
|
||||||
// Determine content type
|
|
||||||
const contentType = this.guessContentType(uri);
|
|
||||||
|
|
||||||
const media = {
|
|
||||||
contentId: uri,
|
|
||||||
contentType,
|
|
||||||
streamType: 'BUFFERED' as const,
|
|
||||||
metadata: {
|
|
||||||
type: 0,
|
|
||||||
metadataType: 0,
|
|
||||||
title: uri.split('/').pop() || 'Media',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
player.load(media, { autoplay: true }, (err: Error | null) => {
|
|
||||||
if (err) {
|
|
||||||
reject(err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._playbackState = 'playing';
|
|
||||||
this.emit('playback:started');
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Resume playback
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
player.play((err: Error | null) => {
|
|
||||||
if (err) {
|
|
||||||
reject(err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._playbackState = 'playing';
|
|
||||||
this.emit('playback:started');
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pause playback
|
|
||||||
*/
|
|
||||||
public async pause(): Promise<void> {
|
|
||||||
if (!this.player) {
|
|
||||||
throw new Error('No active media session');
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
(this.player as InstanceType<typeof plugins.castv2Client.DefaultMediaReceiver>).pause((err: Error | null) => {
|
|
||||||
if (err) {
|
|
||||||
reject(err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._playbackState = 'paused';
|
|
||||||
this.emit('playback:paused');
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop playback
|
|
||||||
*/
|
|
||||||
public async stop(): Promise<void> {
|
|
||||||
if (!this.player) {
|
|
||||||
throw new Error('No active media session');
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
(this.player as InstanceType<typeof plugins.castv2Client.DefaultMediaReceiver>).stop((err: Error | null) => {
|
|
||||||
if (err) {
|
|
||||||
reject(err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._playbackState = 'stopped';
|
|
||||||
this.emit('playback:stopped');
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Next track (not supported)
|
|
||||||
*/
|
|
||||||
public async next(): Promise<void> {
|
|
||||||
throw new Error('Next track not supported on basic Chromecast');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Previous track (not supported)
|
|
||||||
*/
|
|
||||||
public async previous(): Promise<void> {
|
|
||||||
throw new Error('Previous track not supported on basic Chromecast');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Seek to position
|
|
||||||
*/
|
|
||||||
public async seek(seconds: number): Promise<void> {
|
|
||||||
if (!this.player) {
|
|
||||||
throw new Error('No active media session');
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
(this.player as InstanceType<typeof plugins.castv2Client.DefaultMediaReceiver>).seek(seconds, (err: Error | null) => {
|
|
||||||
if (err) {
|
|
||||||
reject(err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.emit('playback:seeked', { position: seconds });
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Volume Control
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get volume
|
|
||||||
*/
|
|
||||||
public async getVolume(): Promise<number> {
|
|
||||||
await this.refreshStatus();
|
|
||||||
return this._volume;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set volume
|
|
||||||
*/
|
|
||||||
public async setVolume(level: number): Promise<void> {
|
|
||||||
if (!this.client) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
const clamped = Math.max(0, Math.min(100, level));
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
this.client!.setVolume({ level: clamped / 100 }, (err: Error | null) => {
|
|
||||||
if (err) {
|
|
||||||
reject(err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._volume = clamped;
|
|
||||||
this.emit('volume:changed', { volume: clamped });
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get mute state
|
|
||||||
*/
|
|
||||||
public async getMute(): Promise<boolean> {
|
|
||||||
await this.refreshStatus();
|
|
||||||
return this._muted;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set mute state
|
|
||||||
*/
|
|
||||||
public async setMute(muted: boolean): Promise<void> {
|
|
||||||
if (!this.client) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
this.client!.setVolume({ muted }, (err: Error | null) => {
|
|
||||||
if (err) {
|
|
||||||
reject(err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._muted = muted;
|
|
||||||
this.emit('mute:changed', { muted });
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Track Information
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current track
|
|
||||||
*/
|
|
||||||
public async getCurrentTrack(): Promise<ITrackInfo | null> {
|
|
||||||
if (!this.player) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
(this.player as InstanceType<typeof plugins.castv2Client.DefaultMediaReceiver>).getStatus((err: Error | null, status: IChromecastMediaStatus) => {
|
|
||||||
if (err || !status || !status.media) {
|
|
||||||
resolve(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const media = status.media;
|
|
||||||
const metadata = media.metadata;
|
|
||||||
|
|
||||||
resolve({
|
|
||||||
title: metadata?.title || 'Unknown',
|
|
||||||
artist: metadata?.artist,
|
|
||||||
album: metadata?.albumName,
|
|
||||||
duration: media.duration || 0,
|
|
||||||
position: status.currentTime || 0,
|
|
||||||
albumArtUri: metadata?.images?.[0]?.url,
|
|
||||||
uri: media.contentId,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get playback status
|
|
||||||
*/
|
|
||||||
public async getPlaybackStatus(): Promise<IPlaybackStatus> {
|
|
||||||
await this.refreshStatus();
|
|
||||||
|
|
||||||
return {
|
|
||||||
state: this._playbackState,
|
|
||||||
volume: this._volume,
|
|
||||||
muted: this._muted,
|
|
||||||
track: await this.getCurrentTrack() || undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Chromecast-specific Methods
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Launch an application
|
|
||||||
*/
|
|
||||||
public async launchApp(appId: string): Promise<void> {
|
|
||||||
if (!this.client) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
this.client!.launch({ id: appId } as Parameters<typeof plugins.castv2Client.Client.prototype.launch>[0], (err: Error | null) => {
|
|
||||||
if (err) {
|
|
||||||
reject(err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._currentAppId = appId;
|
|
||||||
this.emit('app:launched', { appId });
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop current application
|
|
||||||
*/
|
|
||||||
public async stopApp(): Promise<void> {
|
|
||||||
if (!this.client) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
this.client!.stop(this.player as InstanceType<typeof plugins.castv2Client.DefaultMediaReceiver>, (err: Error | null) => {
|
|
||||||
if (err) {
|
|
||||||
reject(err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._currentAppId = undefined;
|
|
||||||
this._currentAppName = undefined;
|
|
||||||
this.player = null;
|
|
||||||
this.emit('app:stopped');
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get receiver status
|
|
||||||
*/
|
|
||||||
public async getReceiverStatus(): Promise<{
|
|
||||||
applications?: Array<{ appId: string; displayName: string }>;
|
|
||||||
volume: { level: number; muted: boolean };
|
|
||||||
}> {
|
|
||||||
if (!this.client) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
this.client!.getStatus((err: Error | null, status: {
|
|
||||||
applications?: Array<{ appId: string; displayName: string }>;
|
|
||||||
volume: { level: number; muted: boolean };
|
|
||||||
}) => {
|
|
||||||
if (err) {
|
|
||||||
reject(err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve(status);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Guess content type from URL
|
|
||||||
*/
|
|
||||||
private guessContentType(url: string): string {
|
|
||||||
const ext = url.split('.').pop()?.toLowerCase();
|
|
||||||
|
|
||||||
switch (ext) {
|
|
||||||
case 'mp3':
|
|
||||||
return 'audio/mpeg';
|
|
||||||
case 'mp4':
|
|
||||||
case 'm4v':
|
|
||||||
return 'video/mp4';
|
|
||||||
case 'webm':
|
|
||||||
return 'video/webm';
|
|
||||||
case 'mkv':
|
|
||||||
return 'video/x-matroska';
|
|
||||||
case 'ogg':
|
|
||||||
return 'audio/ogg';
|
|
||||||
case 'flac':
|
|
||||||
return 'audio/flac';
|
|
||||||
case 'wav':
|
|
||||||
return 'audio/wav';
|
|
||||||
case 'm3u8':
|
|
||||||
return 'application/x-mpegURL';
|
|
||||||
case 'mpd':
|
|
||||||
return 'application/dash+xml';
|
|
||||||
default:
|
|
||||||
return 'video/mp4';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Device Info
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get speaker info
|
|
||||||
*/
|
|
||||||
public getSpeakerInfo(): IChromecastSpeakerInfo {
|
|
||||||
return {
|
|
||||||
id: this.id,
|
|
||||||
name: this.name,
|
|
||||||
type: 'speaker',
|
|
||||||
address: this.address,
|
|
||||||
port: this.port,
|
|
||||||
status: this.status,
|
|
||||||
protocol: 'chromecast',
|
|
||||||
roomName: this._roomName,
|
|
||||||
modelName: this._modelName,
|
|
||||||
friendlyName: this._friendlyName,
|
|
||||||
deviceType: this._deviceType,
|
|
||||||
capabilities: this._capabilities,
|
|
||||||
currentAppId: this._currentAppId,
|
|
||||||
currentAppName: this._currentAppName,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create from mDNS discovery
|
|
||||||
*/
|
|
||||||
public static fromDiscovery(
|
|
||||||
data: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
address: string;
|
|
||||||
port?: number;
|
|
||||||
roomName?: string;
|
|
||||||
modelName?: string;
|
|
||||||
friendlyName?: string;
|
|
||||||
deviceType?: TChromecastType;
|
|
||||||
capabilities?: string[];
|
|
||||||
},
|
|
||||||
retryOptions?: IRetryOptions
|
|
||||||
): ChromecastSpeaker {
|
|
||||||
const info: IDeviceInfo = {
|
|
||||||
id: data.id,
|
|
||||||
name: data.name,
|
|
||||||
type: 'speaker',
|
|
||||||
address: data.address,
|
|
||||||
port: data.port ?? 8009,
|
|
||||||
status: 'unknown',
|
|
||||||
};
|
|
||||||
|
|
||||||
return new ChromecastSpeaker(
|
|
||||||
info,
|
|
||||||
{
|
|
||||||
roomName: data.roomName,
|
|
||||||
modelName: data.modelName,
|
|
||||||
friendlyName: data.friendlyName,
|
|
||||||
deviceType: data.deviceType,
|
|
||||||
capabilities: data.capabilities,
|
|
||||||
},
|
|
||||||
retryOptions
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Probe for Chromecast device
|
|
||||||
*/
|
|
||||||
public static async probe(address: string, port: number = 8009, timeout: number = 5000): Promise<boolean> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const client = new plugins.castv2Client.Client();
|
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
client.close();
|
|
||||||
resolve(false);
|
|
||||||
}, timeout);
|
|
||||||
|
|
||||||
client.on('error', () => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
client.close();
|
|
||||||
resolve(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
client.connect(address, () => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
client.close();
|
|
||||||
resolve(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,654 +0,0 @@
|
|||||||
import * as plugins from '../plugins.js';
|
|
||||||
import { Speaker, type ITrackInfo, type IPlaybackStatus, type TPlaybackState, type ISpeakerInfo } from './speaker.classes.speaker.js';
|
|
||||||
import type { IDeviceInfo, IRetryOptions } from '../interfaces/index.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sonos zone (room) information
|
|
||||||
*/
|
|
||||||
export interface ISonosZoneInfo {
|
|
||||||
name: string;
|
|
||||||
uuid: string;
|
|
||||||
coordinator: boolean;
|
|
||||||
groupId: string;
|
|
||||||
members: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sonos speaker device info
|
|
||||||
*/
|
|
||||||
export interface ISonosSpeakerInfo extends ISpeakerInfo {
|
|
||||||
protocol: 'sonos';
|
|
||||||
zoneName: string;
|
|
||||||
zoneUuid: string;
|
|
||||||
isCoordinator: boolean;
|
|
||||||
groupId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sonos Speaker device
|
|
||||||
*/
|
|
||||||
export class SonosSpeaker extends Speaker {
|
|
||||||
private device: InstanceType<typeof plugins.sonos.Sonos> | null = null;
|
|
||||||
|
|
||||||
private _zoneName: string = '';
|
|
||||||
private _zoneUuid: string = '';
|
|
||||||
private _isCoordinator: boolean = false;
|
|
||||||
private _groupId?: string;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
info: IDeviceInfo,
|
|
||||||
options?: {
|
|
||||||
roomName?: string;
|
|
||||||
modelName?: string;
|
|
||||||
},
|
|
||||||
retryOptions?: IRetryOptions
|
|
||||||
) {
|
|
||||||
super(info, 'sonos', options, retryOptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Getters
|
|
||||||
public get zoneName(): string {
|
|
||||||
return this._zoneName;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get zoneUuid(): string {
|
|
||||||
return this._zoneUuid;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get isCoordinator(): boolean {
|
|
||||||
return this._isCoordinator;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get groupId(): string | undefined {
|
|
||||||
return this._groupId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Connect to Sonos device
|
|
||||||
*/
|
|
||||||
protected async doConnect(): Promise<void> {
|
|
||||||
this.device = new plugins.sonos.Sonos(this.address, this.port);
|
|
||||||
|
|
||||||
// Get device info
|
|
||||||
try {
|
|
||||||
const zoneInfo = await this.device.getZoneInfo();
|
|
||||||
this._zoneName = zoneInfo.ZoneName || '';
|
|
||||||
this._roomName = this._zoneName;
|
|
||||||
|
|
||||||
const attrs = await this.device.getZoneAttrs();
|
|
||||||
this._zoneUuid = attrs.CurrentZoneName || '';
|
|
||||||
} catch (error) {
|
|
||||||
// Some info may not be available
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get device description
|
|
||||||
try {
|
|
||||||
const desc = await this.device.deviceDescription();
|
|
||||||
this._modelName = desc.modelName;
|
|
||||||
this.model = desc.modelName;
|
|
||||||
this.manufacturer = desc.manufacturer;
|
|
||||||
this.serialNumber = desc.serialNum;
|
|
||||||
} catch {
|
|
||||||
// Optional info
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get current state
|
|
||||||
await this.refreshStatus();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disconnect
|
|
||||||
*/
|
|
||||||
protected async doDisconnect(): Promise<void> {
|
|
||||||
this.device = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh status
|
|
||||||
*/
|
|
||||||
public async refreshStatus(): Promise<void> {
|
|
||||||
if (!this.device) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [volume, muted, state] = await Promise.all([
|
|
||||||
this.device.getVolume(),
|
|
||||||
this.device.getMuted(),
|
|
||||||
this.device.getCurrentState(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
this._volume = volume;
|
|
||||||
this._muted = muted;
|
|
||||||
this._playbackState = this.mapSonosState(state);
|
|
||||||
} catch {
|
|
||||||
// Status refresh failed
|
|
||||||
}
|
|
||||||
|
|
||||||
this.emit('status:updated', this.getSpeakerInfo());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map Sonos state to our state
|
|
||||||
*/
|
|
||||||
private mapSonosState(state: string): TPlaybackState {
|
|
||||||
switch (state.toLowerCase()) {
|
|
||||||
case 'playing':
|
|
||||||
return 'playing';
|
|
||||||
case 'paused':
|
|
||||||
case 'paused_playback':
|
|
||||||
return 'paused';
|
|
||||||
case 'stopped':
|
|
||||||
return 'stopped';
|
|
||||||
case 'transitioning':
|
|
||||||
return 'transitioning';
|
|
||||||
default:
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Playback Control
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Play
|
|
||||||
*/
|
|
||||||
public async play(uri?: string): Promise<void> {
|
|
||||||
if (!this.device) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (uri) {
|
|
||||||
await this.device.play(uri);
|
|
||||||
} else {
|
|
||||||
await this.device.play();
|
|
||||||
}
|
|
||||||
|
|
||||||
this._playbackState = 'playing';
|
|
||||||
this.emit('playback:started');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pause
|
|
||||||
*/
|
|
||||||
public async pause(): Promise<void> {
|
|
||||||
if (!this.device) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.device.pause();
|
|
||||||
this._playbackState = 'paused';
|
|
||||||
this.emit('playback:paused');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop
|
|
||||||
*/
|
|
||||||
public async stop(): Promise<void> {
|
|
||||||
if (!this.device) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.device.stop();
|
|
||||||
this._playbackState = 'stopped';
|
|
||||||
this.emit('playback:stopped');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Next track
|
|
||||||
*/
|
|
||||||
public async next(): Promise<void> {
|
|
||||||
if (!this.device) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.device.next();
|
|
||||||
this.emit('playback:next');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Previous track
|
|
||||||
*/
|
|
||||||
public async previous(): Promise<void> {
|
|
||||||
if (!this.device) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.device.previous();
|
|
||||||
this.emit('playback:previous');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Seek to position
|
|
||||||
*/
|
|
||||||
public async seek(seconds: number): Promise<void> {
|
|
||||||
if (!this.device) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.device.seek(seconds);
|
|
||||||
this.emit('playback:seeked', { position: seconds });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Volume Control
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get volume
|
|
||||||
*/
|
|
||||||
public async getVolume(): Promise<number> {
|
|
||||||
if (!this.device) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
const volume = await this.device.getVolume();
|
|
||||||
this._volume = volume;
|
|
||||||
return volume;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set volume
|
|
||||||
*/
|
|
||||||
public async setVolume(level: number): Promise<void> {
|
|
||||||
if (!this.device) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
const clamped = Math.max(0, Math.min(100, level));
|
|
||||||
await this.device.setVolume(clamped);
|
|
||||||
this._volume = clamped;
|
|
||||||
this.emit('volume:changed', { volume: clamped });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get mute state
|
|
||||||
*/
|
|
||||||
public async getMute(): Promise<boolean> {
|
|
||||||
if (!this.device) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
const muted = await this.device.getMuted();
|
|
||||||
this._muted = muted;
|
|
||||||
return muted;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set mute state
|
|
||||||
*/
|
|
||||||
public async setMute(muted: boolean): Promise<void> {
|
|
||||||
if (!this.device) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.device.setMuted(muted);
|
|
||||||
this._muted = muted;
|
|
||||||
this.emit('mute:changed', { muted });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Track Information
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current track
|
|
||||||
*/
|
|
||||||
public async getCurrentTrack(): Promise<ITrackInfo | null> {
|
|
||||||
if (!this.device) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const track = await this.device.currentTrack();
|
|
||||||
|
|
||||||
if (!track) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: track.title || 'Unknown',
|
|
||||||
artist: track.artist,
|
|
||||||
album: track.album,
|
|
||||||
duration: track.duration || 0,
|
|
||||||
position: track.position || 0,
|
|
||||||
albumArtUri: track.albumArtURI || track.albumArtURL,
|
|
||||||
uri: track.uri,
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get playback status
|
|
||||||
*/
|
|
||||||
public async getPlaybackStatus(): Promise<IPlaybackStatus> {
|
|
||||||
if (!this.device) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
const [state, volume, muted, track] = await Promise.all([
|
|
||||||
this.device.getCurrentState(),
|
|
||||||
this.device.getVolume(),
|
|
||||||
this.device.getMuted(),
|
|
||||||
this.getCurrentTrack(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
state: this.mapSonosState(state),
|
|
||||||
volume,
|
|
||||||
muted,
|
|
||||||
track: track || undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Sonos-specific Features
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Play from queue
|
|
||||||
*/
|
|
||||||
public async playFromQueue(index: number): Promise<void> {
|
|
||||||
if (!this.device) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.device.selectQueue();
|
|
||||||
await this.device.selectTrack(index);
|
|
||||||
await this.device.play();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add URI to queue
|
|
||||||
*/
|
|
||||||
public async addToQueue(uri: string, positionInQueue?: number): Promise<void> {
|
|
||||||
if (!this.device) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.device.queue(uri, positionInQueue);
|
|
||||||
this.emit('queue:added', { uri, position: positionInQueue });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear queue
|
|
||||||
*/
|
|
||||||
public async clearQueue(): Promise<void> {
|
|
||||||
if (!this.device) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.device.flush();
|
|
||||||
this.emit('queue:cleared');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get queue contents
|
|
||||||
*/
|
|
||||||
public async getQueue(): Promise<ITrackInfo[]> {
|
|
||||||
if (!this.device) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
const queue = await this.device.getQueue();
|
|
||||||
|
|
||||||
if (!queue || !queue.items) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return queue.items.map((item: { title?: string; artist?: string; album?: string; albumArtURI?: string; uri?: string }) => ({
|
|
||||||
title: item.title || 'Unknown',
|
|
||||||
artist: item.artist,
|
|
||||||
album: item.album,
|
|
||||||
duration: 0,
|
|
||||||
position: 0,
|
|
||||||
albumArtUri: item.albumArtURI,
|
|
||||||
uri: item.uri,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Play a Sonos playlist
|
|
||||||
*/
|
|
||||||
public async playPlaylist(playlistName: string): Promise<void> {
|
|
||||||
if (!this.device) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
const playlists = await this.device.getMusicLibrary('sonos_playlists');
|
|
||||||
const playlist = playlists.items?.find((p: { title?: string }) =>
|
|
||||||
p.title?.toLowerCase().includes(playlistName.toLowerCase())
|
|
||||||
);
|
|
||||||
|
|
||||||
if (playlist && playlist.uri) {
|
|
||||||
await this.device.play(playlist.uri);
|
|
||||||
} else {
|
|
||||||
throw new Error(`Playlist "${playlistName}" not found`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Play favorite by name
|
|
||||||
*/
|
|
||||||
public async playFavorite(favoriteName: string): Promise<void> {
|
|
||||||
if (!this.device) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
const favorites = await this.device.getFavorites();
|
|
||||||
const favorite = favorites.items?.find((f: { title?: string }) =>
|
|
||||||
f.title?.toLowerCase().includes(favoriteName.toLowerCase())
|
|
||||||
);
|
|
||||||
|
|
||||||
if (favorite && favorite.uri) {
|
|
||||||
await this.device.play(favorite.uri);
|
|
||||||
} else {
|
|
||||||
throw new Error(`Favorite "${favoriteName}" not found`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get favorites
|
|
||||||
*/
|
|
||||||
public async getFavorites(): Promise<{ title: string; uri: string; albumArtUri?: string }[]> {
|
|
||||||
if (!this.device) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
const favorites = await this.device.getFavorites();
|
|
||||||
|
|
||||||
if (!favorites.items) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return favorites.items.map((f: { title?: string; uri?: string; albumArtURI?: string }) => ({
|
|
||||||
title: f.title || 'Unknown',
|
|
||||||
uri: f.uri || '',
|
|
||||||
albumArtUri: f.albumArtURI,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Play TuneIn radio station by ID
|
|
||||||
*/
|
|
||||||
public async playTuneInRadio(stationId: string): Promise<void> {
|
|
||||||
if (!this.device) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.device.playTuneinRadio(stationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Play Spotify URI
|
|
||||||
*/
|
|
||||||
public async playSpotify(spotifyUri: string): Promise<void> {
|
|
||||||
if (!this.device) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.device.play(spotifyUri);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Grouping
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Join another speaker's group
|
|
||||||
*/
|
|
||||||
public async joinGroup(coordinatorAddress: string): Promise<void> {
|
|
||||||
if (!this.device) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
const coordinator = new plugins.sonos.Sonos(coordinatorAddress);
|
|
||||||
await this.device.joinGroup(await coordinator.getName());
|
|
||||||
this.emit('group:joined', { coordinator: coordinatorAddress });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Leave current group
|
|
||||||
*/
|
|
||||||
public async leaveGroup(): Promise<void> {
|
|
||||||
if (!this.device) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.device.leaveGroup();
|
|
||||||
this.emit('group:left');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get group information
|
|
||||||
*/
|
|
||||||
public async getGroupInfo(): Promise<ISonosZoneInfo | null> {
|
|
||||||
if (!this.device) {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const groups = await this.device.getAllGroups();
|
|
||||||
|
|
||||||
// Find our group
|
|
||||||
for (const group of groups) {
|
|
||||||
const members = group.ZoneGroupMember || [];
|
|
||||||
const memberArray = Array.isArray(members) ? members : [members];
|
|
||||||
|
|
||||||
for (const member of memberArray) {
|
|
||||||
if (member.Location?.includes(this.address)) {
|
|
||||||
const coordinator = memberArray.find((m: { UUID?: string }) => m.UUID === group.Coordinator);
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: group.Name || 'Group',
|
|
||||||
uuid: group.Coordinator || '',
|
|
||||||
coordinator: member.UUID === group.Coordinator,
|
|
||||||
groupId: group.ID || '',
|
|
||||||
members: memberArray.map((m: { ZoneName?: string }) => m.ZoneName || ''),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Device Info
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get speaker info
|
|
||||||
*/
|
|
||||||
public getSpeakerInfo(): ISonosSpeakerInfo {
|
|
||||||
return {
|
|
||||||
id: this.id,
|
|
||||||
name: this.name,
|
|
||||||
type: 'speaker',
|
|
||||||
address: this.address,
|
|
||||||
port: this.port,
|
|
||||||
status: this.status,
|
|
||||||
protocol: 'sonos',
|
|
||||||
roomName: this._roomName,
|
|
||||||
modelName: this._modelName,
|
|
||||||
zoneName: this._zoneName,
|
|
||||||
zoneUuid: this._zoneUuid,
|
|
||||||
isCoordinator: this._isCoordinator,
|
|
||||||
groupId: this._groupId,
|
|
||||||
supportsGrouping: true,
|
|
||||||
isGroupCoordinator: this._isCoordinator,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create from discovery
|
|
||||||
*/
|
|
||||||
public static fromDiscovery(
|
|
||||||
data: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
address: string;
|
|
||||||
port?: number;
|
|
||||||
roomName?: string;
|
|
||||||
modelName?: string;
|
|
||||||
},
|
|
||||||
retryOptions?: IRetryOptions
|
|
||||||
): SonosSpeaker {
|
|
||||||
const info: IDeviceInfo = {
|
|
||||||
id: data.id,
|
|
||||||
name: data.name,
|
|
||||||
type: 'speaker',
|
|
||||||
address: data.address,
|
|
||||||
port: data.port ?? 1400,
|
|
||||||
status: 'unknown',
|
|
||||||
};
|
|
||||||
|
|
||||||
return new SonosSpeaker(
|
|
||||||
info,
|
|
||||||
{
|
|
||||||
roomName: data.roomName,
|
|
||||||
modelName: data.modelName,
|
|
||||||
},
|
|
||||||
retryOptions
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Discover Sonos devices on the network
|
|
||||||
*/
|
|
||||||
public static async discover(timeout: number = 5000): Promise<SonosSpeaker[]> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const speakers: SonosSpeaker[] = [];
|
|
||||||
const discovery = new plugins.sonos.AsyncDeviceDiscovery();
|
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
resolve(speakers);
|
|
||||||
}, timeout);
|
|
||||||
|
|
||||||
discovery.discover().then((device: { host: string; port: number }) => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
|
|
||||||
const speaker = new SonosSpeaker(
|
|
||||||
{
|
|
||||||
id: `sonos:${device.host}`,
|
|
||||||
name: `Sonos ${device.host}`,
|
|
||||||
type: 'speaker',
|
|
||||||
address: device.host,
|
|
||||||
port: device.port || 1400,
|
|
||||||
status: 'unknown',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
speakers.push(speaker);
|
|
||||||
resolve(speakers);
|
|
||||||
}).catch(() => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
resolve(speakers);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
import * as plugins from '../plugins.js';
|
|
||||||
import { Device } from '../abstract/device.abstract.js';
|
|
||||||
import type { IDeviceInfo, IRetryOptions } from '../interfaces/index.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Speaker protocol types
|
|
||||||
*/
|
|
||||||
export type TSpeakerProtocol = 'sonos' | 'airplay' | 'chromecast' | 'dlna';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Playback state
|
|
||||||
*/
|
|
||||||
export type TPlaybackState = 'playing' | 'paused' | 'stopped' | 'transitioning' | 'unknown';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Track information
|
|
||||||
*/
|
|
||||||
export interface ITrackInfo {
|
|
||||||
title: string;
|
|
||||||
artist?: string;
|
|
||||||
album?: string;
|
|
||||||
duration: number; // seconds
|
|
||||||
position: number; // seconds
|
|
||||||
albumArtUri?: string;
|
|
||||||
uri?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Speaker playback status
|
|
||||||
*/
|
|
||||||
export interface IPlaybackStatus {
|
|
||||||
state: TPlaybackState;
|
|
||||||
volume: number; // 0-100
|
|
||||||
muted: boolean;
|
|
||||||
track?: ITrackInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Speaker device info
|
|
||||||
*/
|
|
||||||
export interface ISpeakerInfo extends IDeviceInfo {
|
|
||||||
type: 'speaker';
|
|
||||||
protocol: TSpeakerProtocol;
|
|
||||||
roomName?: string;
|
|
||||||
modelName?: string;
|
|
||||||
supportsGrouping?: boolean;
|
|
||||||
groupId?: string;
|
|
||||||
isGroupCoordinator?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Abstract Speaker base class
|
|
||||||
* Common interface for all speaker types (Sonos, AirPlay, Chromecast)
|
|
||||||
*/
|
|
||||||
export abstract class Speaker extends Device {
|
|
||||||
protected _protocol: TSpeakerProtocol;
|
|
||||||
protected _roomName?: string;
|
|
||||||
protected _modelName?: string;
|
|
||||||
protected _volume: number = 0;
|
|
||||||
protected _muted: boolean = false;
|
|
||||||
protected _playbackState: TPlaybackState = 'unknown';
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
info: IDeviceInfo,
|
|
||||||
protocol: TSpeakerProtocol,
|
|
||||||
options?: {
|
|
||||||
roomName?: string;
|
|
||||||
modelName?: string;
|
|
||||||
},
|
|
||||||
retryOptions?: IRetryOptions
|
|
||||||
) {
|
|
||||||
super(info, retryOptions);
|
|
||||||
this._protocol = protocol;
|
|
||||||
this._roomName = options?.roomName;
|
|
||||||
this._modelName = options?.modelName;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Getters
|
|
||||||
public get protocol(): TSpeakerProtocol {
|
|
||||||
return this._protocol;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get roomName(): string | undefined {
|
|
||||||
return this._roomName;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get speakerModelName(): string | undefined {
|
|
||||||
return this._modelName;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get volume(): number {
|
|
||||||
return this._volume;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get muted(): boolean {
|
|
||||||
return this._muted;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get playbackState(): TPlaybackState {
|
|
||||||
return this._playbackState;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Abstract Methods - Must be implemented by subclasses
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Play media from URI
|
|
||||||
*/
|
|
||||||
public abstract play(uri?: string): Promise<void>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pause playback
|
|
||||||
*/
|
|
||||||
public abstract pause(): Promise<void>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop playback
|
|
||||||
*/
|
|
||||||
public abstract stop(): Promise<void>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Next track
|
|
||||||
*/
|
|
||||||
public abstract next(): Promise<void>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Previous track
|
|
||||||
*/
|
|
||||||
public abstract previous(): Promise<void>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Seek to position
|
|
||||||
*/
|
|
||||||
public abstract seek(seconds: number): Promise<void>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get volume level (0-100)
|
|
||||||
*/
|
|
||||||
public abstract getVolume(): Promise<number>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set volume level (0-100)
|
|
||||||
*/
|
|
||||||
public abstract setVolume(level: number): Promise<void>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get mute state
|
|
||||||
*/
|
|
||||||
public abstract getMute(): Promise<boolean>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set mute state
|
|
||||||
*/
|
|
||||||
public abstract setMute(muted: boolean): Promise<void>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current track info
|
|
||||||
*/
|
|
||||||
public abstract getCurrentTrack(): Promise<ITrackInfo | null>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get playback status
|
|
||||||
*/
|
|
||||||
public abstract getPlaybackStatus(): Promise<IPlaybackStatus>;
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Common Methods
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle mute
|
|
||||||
*/
|
|
||||||
public async toggleMute(): Promise<boolean> {
|
|
||||||
const currentMute = await this.getMute();
|
|
||||||
await this.setMute(!currentMute);
|
|
||||||
return !currentMute;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Volume up
|
|
||||||
*/
|
|
||||||
public async volumeUp(step: number = 5): Promise<number> {
|
|
||||||
const current = await this.getVolume();
|
|
||||||
const newVolume = Math.min(100, current + step);
|
|
||||||
await this.setVolume(newVolume);
|
|
||||||
return newVolume;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Volume down
|
|
||||||
*/
|
|
||||||
public async volumeDown(step: number = 5): Promise<number> {
|
|
||||||
const current = await this.getVolume();
|
|
||||||
const newVolume = Math.max(0, current - step);
|
|
||||||
await this.setVolume(newVolume);
|
|
||||||
return newVolume;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get speaker info
|
|
||||||
*/
|
|
||||||
public getSpeakerInfo(): ISpeakerInfo {
|
|
||||||
return {
|
|
||||||
id: this.id,
|
|
||||||
name: this.name,
|
|
||||||
type: 'speaker',
|
|
||||||
address: this.address,
|
|
||||||
port: this.port,
|
|
||||||
status: this.status,
|
|
||||||
protocol: this._protocol,
|
|
||||||
roomName: this._roomName,
|
|
||||||
modelName: this._modelName,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,548 +0,0 @@
|
|||||||
import * as plugins from '../plugins.js';
|
|
||||||
import { Device } from '../abstract/device.abstract.js';
|
|
||||||
import { NutProtocol, NUT_COMMANDS, NUT_VARIABLES, type TNutStatusFlag } from './ups.classes.nutprotocol.js';
|
|
||||||
import { UpsSnmpHandler, type TUpsBatteryStatus, type TUpsOutputSource } from './ups.classes.upssnmp.js';
|
|
||||||
import type { IDeviceInfo, IRetryOptions } from '../interfaces/index.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* UPS status enumeration
|
|
||||||
*/
|
|
||||||
export type TUpsStatus = 'online' | 'onbattery' | 'lowbattery' | 'charging' | 'discharging' | 'bypass' | 'offline' | 'error' | 'unknown';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* UPS protocol type
|
|
||||||
*/
|
|
||||||
export type TUpsProtocol = 'nut' | 'snmp';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* UPS device information
|
|
||||||
*/
|
|
||||||
export interface IUpsDeviceInfo extends IDeviceInfo {
|
|
||||||
type: 'ups';
|
|
||||||
protocol: TUpsProtocol;
|
|
||||||
upsName?: string; // NUT ups name
|
|
||||||
manufacturer: string;
|
|
||||||
model: string;
|
|
||||||
serialNumber?: string;
|
|
||||||
firmwareVersion?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* UPS battery information
|
|
||||||
*/
|
|
||||||
export interface IUpsBatteryInfo {
|
|
||||||
charge: number; // 0-100%
|
|
||||||
runtime: number; // seconds remaining
|
|
||||||
voltage: number; // volts
|
|
||||||
temperature?: number; // celsius
|
|
||||||
status: 'normal' | 'low' | 'depleted' | 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* UPS input/output power info
|
|
||||||
*/
|
|
||||||
export interface IUpsPowerInfo {
|
|
||||||
inputVoltage: number;
|
|
||||||
inputFrequency?: number;
|
|
||||||
outputVoltage: number;
|
|
||||||
outputFrequency?: number;
|
|
||||||
outputCurrent?: number;
|
|
||||||
outputPower?: number;
|
|
||||||
load: number; // 0-100%
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Full UPS status
|
|
||||||
*/
|
|
||||||
export interface IUpsFullStatus {
|
|
||||||
status: TUpsStatus;
|
|
||||||
battery: IUpsBatteryInfo;
|
|
||||||
power: IUpsPowerInfo;
|
|
||||||
alarms: string[];
|
|
||||||
secondsOnBattery: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* UPS Device class supporting both NUT and SNMP protocols
|
|
||||||
*/
|
|
||||||
export class UpsDevice extends Device {
|
|
||||||
private nutProtocol: NutProtocol | null = null;
|
|
||||||
private snmpHandler: UpsSnmpHandler | null = null;
|
|
||||||
private upsProtocol: TUpsProtocol;
|
|
||||||
private upsName: string;
|
|
||||||
private snmpCommunity: string;
|
|
||||||
|
|
||||||
private _upsStatus: TUpsStatus = 'unknown';
|
|
||||||
private _manufacturer: string = '';
|
|
||||||
private _model: string = '';
|
|
||||||
private _batteryCharge: number = 0;
|
|
||||||
private _batteryRuntime: number = 0;
|
|
||||||
private _inputVoltage: number = 0;
|
|
||||||
private _outputVoltage: number = 0;
|
|
||||||
private _load: number = 0;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
info: IDeviceInfo,
|
|
||||||
options: {
|
|
||||||
protocol: TUpsProtocol;
|
|
||||||
upsName?: string; // Required for NUT
|
|
||||||
snmpCommunity?: string; // For SNMP
|
|
||||||
},
|
|
||||||
retryOptions?: IRetryOptions
|
|
||||||
) {
|
|
||||||
super(info, retryOptions);
|
|
||||||
this.upsProtocol = options.protocol;
|
|
||||||
this.upsName = options.upsName || 'ups';
|
|
||||||
this.snmpCommunity = options.snmpCommunity || 'public';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Getters for UPS properties
|
|
||||||
public get upsStatus(): TUpsStatus {
|
|
||||||
return this._upsStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get upsManufacturer(): string {
|
|
||||||
return this._manufacturer;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get upsModel(): string {
|
|
||||||
return this._model;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get batteryCharge(): number {
|
|
||||||
return this._batteryCharge;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get batteryRuntime(): number {
|
|
||||||
return this._batteryRuntime;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get inputVoltage(): number {
|
|
||||||
return this._inputVoltage;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get outputVoltage(): number {
|
|
||||||
return this._outputVoltage;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get load(): number {
|
|
||||||
return this._load;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get protocol(): TUpsProtocol {
|
|
||||||
return this.upsProtocol;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Connect to UPS
|
|
||||||
*/
|
|
||||||
protected async doConnect(): Promise<void> {
|
|
||||||
if (this.upsProtocol === 'nut') {
|
|
||||||
await this.connectNut();
|
|
||||||
} else {
|
|
||||||
await this.connectSnmp();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Connect via NUT protocol
|
|
||||||
*/
|
|
||||||
private async connectNut(): Promise<void> {
|
|
||||||
this.nutProtocol = new NutProtocol(this.address, this.port);
|
|
||||||
await this.nutProtocol.connect();
|
|
||||||
|
|
||||||
// Get device info
|
|
||||||
const deviceInfo = await this.nutProtocol.getDeviceInfo(this.upsName);
|
|
||||||
this._manufacturer = deviceInfo.manufacturer;
|
|
||||||
this._model = deviceInfo.model;
|
|
||||||
this.manufacturer = deviceInfo.manufacturer;
|
|
||||||
this.model = deviceInfo.model;
|
|
||||||
this.serialNumber = deviceInfo.serial;
|
|
||||||
|
|
||||||
// Get initial status
|
|
||||||
await this.refreshStatus();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Connect via SNMP protocol
|
|
||||||
*/
|
|
||||||
private async connectSnmp(): Promise<void> {
|
|
||||||
this.snmpHandler = new UpsSnmpHandler(this.address, {
|
|
||||||
community: this.snmpCommunity,
|
|
||||||
port: this.port,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify it's a UPS
|
|
||||||
const isUps = await this.snmpHandler.isUpsDevice();
|
|
||||||
if (!isUps) {
|
|
||||||
this.snmpHandler.close();
|
|
||||||
this.snmpHandler = null;
|
|
||||||
throw new Error('Device does not support UPS-MIB');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get identity
|
|
||||||
const identity = await this.snmpHandler.getIdentity();
|
|
||||||
this._manufacturer = identity.manufacturer;
|
|
||||||
this._model = identity.model;
|
|
||||||
this.manufacturer = identity.manufacturer;
|
|
||||||
this.model = identity.model;
|
|
||||||
this.firmwareVersion = identity.softwareVersion;
|
|
||||||
|
|
||||||
// Get initial status
|
|
||||||
await this.refreshStatus();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disconnect from UPS
|
|
||||||
*/
|
|
||||||
protected async doDisconnect(): Promise<void> {
|
|
||||||
if (this.nutProtocol) {
|
|
||||||
await this.nutProtocol.disconnect();
|
|
||||||
this.nutProtocol = null;
|
|
||||||
}
|
|
||||||
if (this.snmpHandler) {
|
|
||||||
this.snmpHandler.close();
|
|
||||||
this.snmpHandler = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh UPS status
|
|
||||||
*/
|
|
||||||
public async refreshStatus(): Promise<void> {
|
|
||||||
if (this.upsProtocol === 'nut' && this.nutProtocol) {
|
|
||||||
await this.refreshNutStatus();
|
|
||||||
} else if (this.snmpHandler) {
|
|
||||||
await this.refreshSnmpStatus();
|
|
||||||
} else {
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.emit('status:updated', this.getDeviceInfo());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh status via NUT
|
|
||||||
*/
|
|
||||||
private async refreshNutStatus(): Promise<void> {
|
|
||||||
if (!this.nutProtocol) return;
|
|
||||||
|
|
||||||
const status = await this.nutProtocol.getUpsStatus(this.upsName);
|
|
||||||
|
|
||||||
this._batteryCharge = status.batteryCharge;
|
|
||||||
this._batteryRuntime = status.batteryRuntime;
|
|
||||||
this._inputVoltage = status.inputVoltage;
|
|
||||||
this._outputVoltage = status.outputVoltage;
|
|
||||||
this._load = status.load;
|
|
||||||
|
|
||||||
// Convert NUT status flags to our status
|
|
||||||
this._upsStatus = this.nutStatusToUpsStatus(status.status);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh status via SNMP
|
|
||||||
*/
|
|
||||||
private async refreshSnmpStatus(): Promise<void> {
|
|
||||||
if (!this.snmpHandler) return;
|
|
||||||
|
|
||||||
const status = await this.snmpHandler.getFullStatus();
|
|
||||||
|
|
||||||
this._batteryCharge = status.estimatedChargeRemaining;
|
|
||||||
this._batteryRuntime = status.estimatedMinutesRemaining * 60; // Convert to seconds
|
|
||||||
this._inputVoltage = status.inputVoltage;
|
|
||||||
this._outputVoltage = status.outputVoltage;
|
|
||||||
this._load = status.outputPercentLoad;
|
|
||||||
|
|
||||||
// Convert SNMP status to our status
|
|
||||||
this._upsStatus = this.snmpStatusToUpsStatus(status.outputSource, status.batteryStatus);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert NUT status flags to TUpsStatus
|
|
||||||
*/
|
|
||||||
private nutStatusToUpsStatus(flags: TNutStatusFlag[]): TUpsStatus {
|
|
||||||
if (flags.includes('OFF')) return 'offline';
|
|
||||||
if (flags.includes('LB')) return 'lowbattery';
|
|
||||||
if (flags.includes('OB')) return 'onbattery';
|
|
||||||
if (flags.includes('BYPASS')) return 'bypass';
|
|
||||||
if (flags.includes('CHRG')) return 'charging';
|
|
||||||
if (flags.includes('DISCHRG')) return 'discharging';
|
|
||||||
if (flags.includes('OL')) return 'online';
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert SNMP status to TUpsStatus
|
|
||||||
*/
|
|
||||||
private snmpStatusToUpsStatus(source: TUpsOutputSource, battery: TUpsBatteryStatus): TUpsStatus {
|
|
||||||
if (source === 'none') return 'offline';
|
|
||||||
if (source === 'battery') {
|
|
||||||
if (battery === 'batteryLow') return 'lowbattery';
|
|
||||||
if (battery === 'batteryDepleted') return 'lowbattery';
|
|
||||||
return 'onbattery';
|
|
||||||
}
|
|
||||||
if (source === 'bypass') return 'bypass';
|
|
||||||
if (source === 'normal') return 'online';
|
|
||||||
if (source === 'booster' || source === 'reducer') return 'online';
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get battery information
|
|
||||||
*/
|
|
||||||
public async getBatteryInfo(): Promise<IUpsBatteryInfo> {
|
|
||||||
if (this.upsProtocol === 'nut' && this.nutProtocol) {
|
|
||||||
const vars = await this.nutProtocol.getVariables(this.upsName, [
|
|
||||||
NUT_VARIABLES.batteryCharge,
|
|
||||||
NUT_VARIABLES.batteryRuntime,
|
|
||||||
NUT_VARIABLES.batteryVoltage,
|
|
||||||
NUT_VARIABLES.batteryTemperature,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
charge: parseFloat(vars.get(NUT_VARIABLES.batteryCharge) || '0'),
|
|
||||||
runtime: parseFloat(vars.get(NUT_VARIABLES.batteryRuntime) || '0'),
|
|
||||||
voltage: parseFloat(vars.get(NUT_VARIABLES.batteryVoltage) || '0'),
|
|
||||||
temperature: vars.has(NUT_VARIABLES.batteryTemperature)
|
|
||||||
? parseFloat(vars.get(NUT_VARIABLES.batteryTemperature)!)
|
|
||||||
: undefined,
|
|
||||||
status: 'normal',
|
|
||||||
};
|
|
||||||
} else if (this.snmpHandler) {
|
|
||||||
const battery = await this.snmpHandler.getBatteryStatus();
|
|
||||||
|
|
||||||
const statusMap: Record<TUpsBatteryStatus, IUpsBatteryInfo['status']> = {
|
|
||||||
unknown: 'unknown',
|
|
||||||
batteryNormal: 'normal',
|
|
||||||
batteryLow: 'low',
|
|
||||||
batteryDepleted: 'depleted',
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
charge: battery.estimatedChargeRemaining,
|
|
||||||
runtime: battery.estimatedMinutesRemaining * 60,
|
|
||||||
voltage: battery.voltage,
|
|
||||||
temperature: battery.temperature || undefined,
|
|
||||||
status: statusMap[battery.status],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get power information
|
|
||||||
*/
|
|
||||||
public async getPowerInfo(): Promise<IUpsPowerInfo> {
|
|
||||||
if (this.upsProtocol === 'nut' && this.nutProtocol) {
|
|
||||||
const vars = await this.nutProtocol.getVariables(this.upsName, [
|
|
||||||
NUT_VARIABLES.inputVoltage,
|
|
||||||
NUT_VARIABLES.inputFrequency,
|
|
||||||
NUT_VARIABLES.outputVoltage,
|
|
||||||
NUT_VARIABLES.outputCurrent,
|
|
||||||
NUT_VARIABLES.upsLoad,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
inputVoltage: parseFloat(vars.get(NUT_VARIABLES.inputVoltage) || '0'),
|
|
||||||
inputFrequency: vars.has(NUT_VARIABLES.inputFrequency)
|
|
||||||
? parseFloat(vars.get(NUT_VARIABLES.inputFrequency)!)
|
|
||||||
: undefined,
|
|
||||||
outputVoltage: parseFloat(vars.get(NUT_VARIABLES.outputVoltage) || '0'),
|
|
||||||
outputCurrent: vars.has(NUT_VARIABLES.outputCurrent)
|
|
||||||
? parseFloat(vars.get(NUT_VARIABLES.outputCurrent)!)
|
|
||||||
: undefined,
|
|
||||||
load: parseFloat(vars.get(NUT_VARIABLES.upsLoad) || '0'),
|
|
||||||
};
|
|
||||||
} else if (this.snmpHandler) {
|
|
||||||
const [input, output] = await Promise.all([
|
|
||||||
this.snmpHandler.getInputStatus(),
|
|
||||||
this.snmpHandler.getOutputStatus(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
inputVoltage: input.voltage,
|
|
||||||
inputFrequency: input.frequency,
|
|
||||||
outputVoltage: output.voltage,
|
|
||||||
outputFrequency: output.frequency,
|
|
||||||
outputCurrent: output.current,
|
|
||||||
outputPower: output.power,
|
|
||||||
load: output.percentLoad,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get full status
|
|
||||||
*/
|
|
||||||
public async getFullStatus(): Promise<IUpsFullStatus> {
|
|
||||||
const [battery, power] = await Promise.all([
|
|
||||||
this.getBatteryInfo(),
|
|
||||||
this.getPowerInfo(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
let secondsOnBattery = 0;
|
|
||||||
const alarms: string[] = [];
|
|
||||||
|
|
||||||
if (this.upsProtocol === 'nut' && this.nutProtocol) {
|
|
||||||
const vars = await this.nutProtocol.getVariables(this.upsName, [
|
|
||||||
NUT_VARIABLES.upsStatus,
|
|
||||||
NUT_VARIABLES.upsAlarm,
|
|
||||||
]);
|
|
||||||
const alarm = vars.get(NUT_VARIABLES.upsAlarm);
|
|
||||||
if (alarm) {
|
|
||||||
alarms.push(alarm);
|
|
||||||
}
|
|
||||||
} else if (this.snmpHandler) {
|
|
||||||
const snmpStatus = await this.snmpHandler.getFullStatus();
|
|
||||||
secondsOnBattery = snmpStatus.secondsOnBattery;
|
|
||||||
if (snmpStatus.alarmsPresent > 0) {
|
|
||||||
alarms.push(`${snmpStatus.alarmsPresent} alarm(s) present`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: this._upsStatus,
|
|
||||||
battery,
|
|
||||||
power,
|
|
||||||
alarms,
|
|
||||||
secondsOnBattery,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run a UPS command (NUT only)
|
|
||||||
*/
|
|
||||||
public async runCommand(command: string): Promise<boolean> {
|
|
||||||
if (this.upsProtocol !== 'nut' || !this.nutProtocol) {
|
|
||||||
throw new Error('Commands only supported via NUT protocol');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await this.nutProtocol.runCommand(this.upsName, command);
|
|
||||||
this.emit('command:executed', { command, success: result });
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start battery test
|
|
||||||
*/
|
|
||||||
public async startBatteryTest(type: 'quick' | 'deep' = 'quick'): Promise<boolean> {
|
|
||||||
const command = type === 'deep'
|
|
||||||
? NUT_COMMANDS.testBatteryStartDeep
|
|
||||||
: NUT_COMMANDS.testBatteryStartQuick;
|
|
||||||
return this.runCommand(command);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop battery test
|
|
||||||
*/
|
|
||||||
public async stopBatteryTest(): Promise<boolean> {
|
|
||||||
return this.runCommand(NUT_COMMANDS.testBatteryStop);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle beeper
|
|
||||||
*/
|
|
||||||
public async toggleBeeper(): Promise<boolean> {
|
|
||||||
return this.runCommand(NUT_COMMANDS.beeperToggle);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get device info
|
|
||||||
*/
|
|
||||||
public getDeviceInfo(): IUpsDeviceInfo {
|
|
||||||
return {
|
|
||||||
id: this.id,
|
|
||||||
name: this.name,
|
|
||||||
type: 'ups',
|
|
||||||
address: this.address,
|
|
||||||
port: this.port,
|
|
||||||
status: this.status,
|
|
||||||
protocol: this.upsProtocol,
|
|
||||||
upsName: this.upsName,
|
|
||||||
manufacturer: this._manufacturer,
|
|
||||||
model: this._model,
|
|
||||||
serialNumber: this.serialNumber,
|
|
||||||
firmwareVersion: this.firmwareVersion,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create UPS device from discovery
|
|
||||||
*/
|
|
||||||
public static fromDiscovery(
|
|
||||||
data: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
address: string;
|
|
||||||
port?: number;
|
|
||||||
protocol: TUpsProtocol;
|
|
||||||
upsName?: string;
|
|
||||||
community?: string;
|
|
||||||
},
|
|
||||||
retryOptions?: IRetryOptions
|
|
||||||
): UpsDevice {
|
|
||||||
const info: IDeviceInfo = {
|
|
||||||
id: data.id,
|
|
||||||
name: data.name,
|
|
||||||
type: 'ups',
|
|
||||||
address: data.address,
|
|
||||||
port: data.port ?? (data.protocol === 'nut' ? 3493 : 161),
|
|
||||||
status: 'unknown',
|
|
||||||
};
|
|
||||||
|
|
||||||
return new UpsDevice(
|
|
||||||
info,
|
|
||||||
{
|
|
||||||
protocol: data.protocol,
|
|
||||||
upsName: data.upsName,
|
|
||||||
snmpCommunity: data.community,
|
|
||||||
},
|
|
||||||
retryOptions
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Probe for UPS device (NUT or SNMP)
|
|
||||||
*/
|
|
||||||
public static async probe(
|
|
||||||
address: string,
|
|
||||||
options?: {
|
|
||||||
nutPort?: number;
|
|
||||||
snmpPort?: number;
|
|
||||||
snmpCommunity?: string;
|
|
||||||
timeout?: number;
|
|
||||||
}
|
|
||||||
): Promise<{ protocol: TUpsProtocol; port: number } | null> {
|
|
||||||
const nutPort = options?.nutPort ?? 3493;
|
|
||||||
const snmpPort = options?.snmpPort ?? 161;
|
|
||||||
const community = options?.snmpCommunity ?? 'public';
|
|
||||||
|
|
||||||
// Try NUT first
|
|
||||||
const nutAvailable = await NutProtocol.probe(address, nutPort, options?.timeout);
|
|
||||||
if (nutAvailable) {
|
|
||||||
return { protocol: 'nut', port: nutPort };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try SNMP UPS-MIB
|
|
||||||
try {
|
|
||||||
const handler = new UpsSnmpHandler(address, { community, port: snmpPort, timeout: options?.timeout ?? 3000 });
|
|
||||||
const isUps = await handler.isUpsDevice();
|
|
||||||
handler.close();
|
|
||||||
|
|
||||||
if (isUps) {
|
|
||||||
return { protocol: 'snmp', port: snmpPort };
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore SNMP errors
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-export types
|
|
||||||
export { NUT_COMMANDS, NUT_VARIABLES, type TNutStatusFlag } from './ups.classes.nutprotocol.js';
|
|
||||||
export { UPS_SNMP_OIDS, type TUpsBatteryStatus, type TUpsOutputSource } from './ups.classes.upssnmp.js';
|
|
||||||
Reference in New Issue
Block a user