14 Commits

Author SHA1 Message Date
d9029ec02b v3.1.0
Some checks failed
Default (tags) / security (push) Successful in 33s
Default (tags) / test (push) Failing after 33s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-13 21:14:30 +00:00
b89e8cbc3c feat(print): use IPP smartPrint and normalize IPP capabilities and job mapping 2026-01-13 21:14:30 +00:00
716347bac1 v3.0.2
Some checks failed
Default (tags) / security (push) Successful in 30s
Default (tags) / test (push) Failing after 32s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-12 11:00:15 +00:00
a6ee36f187 fix(devicemanager): no changes detected - nothing to commit 2026-01-12 11:00:15 +00:00
be993bf667 v3.0.1
Some checks failed
Default (tags) / security (push) Successful in 32s
Default (tags) / test (push) Failing after 32s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-12 09:13:22 +00:00
1cc8c48315 fix(release): add npm registries to release config and expand documentation for UniversalDevice architecture and smart-home features 2026-01-12 09:13:22 +00:00
3377053ef4 v3.0.0
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 1m44s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-10 09:23:36 +00:00
d7240696a8 BREAKING CHANGE(devicemanager): migrate tests to new UniversalDevice/feature-based API, add device factories, SNMP protocol/feature and IP helper utilities 2026-01-10 09:23:36 +00:00
2e82ec1884 v2.3.1
Some checks failed
Default (tags) / security (push) Successful in 24s
Default (tags) / test (push) Failing after 33s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-09 17:21:26 +00:00
79b05d47aa fix(readme): update README to comprehensive, TypeScript-first documentation covering installation, quick start, examples, API usage, events, error handling, requirements, credits, and legal/company information 2026-01-09 17:21:26 +00:00
e424085000 v2.3.0
Some checks failed
Default (tags) / security (push) Successful in 31s
Default (tags) / test (push) Failing after 33s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-09 17:18:48 +00:00
82a99cdfb8 feat(devicemanager): add selector-based device APIs, selectFeature helper, convenience discovery methods, and ESCL scan completion fallback 2026-01-09 17:18:48 +00:00
d72ea96ec5 v2.2.0
Some checks failed
Default (tags) / security (push) Successful in 1m38s
Default (tags) / test (push) Failing after 32s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-09 16:20:54 +00:00
38a6e5c250 feat(smarthome): add smart home features and Home Assistant integration (WebSocket protocol, discovery, factories, interfaces) 2026-01-09 16:20:54 +00:00
32 changed files with 7130 additions and 409 deletions

View File

@@ -1,5 +1,67 @@
# Changelog
## 2026-01-13 - 3.1.0 - feat(print)
use IPP smartPrint and normalize IPP capabilities and job mapping
- Use IppProtocol.smartPrint for automatic format detection/conversion when submitting print jobs.
- Normalize and map IIppJob -> IPrintJob via mapIppJobToInternal, collapsing extended IPP job states into internal states.
- Parse IIppPrinterCapabilities fields (mediaSizeSupported, mediaTypeSupported, sidesSupported, printQualitySupported, copiesSupported) and derive supportsDuplex from sidesSupported and maxCopies from copiesSupported range with a fallback.
- Map numeric IPP printQuality values (3,4,5) to internal quality strings (draft, normal, high).
- Switched calls to getPrinterAttributes/getJobAttributes and adjusted job listing to map returned IIppJob objects.
- Export new IPP types from protocols index: IIppPrinterCapabilities, IIppJob, IIppPrintOptions.
## 2026-01-12 - 3.0.2 - fix(devicemanager)
no changes detected - nothing to commit
- git diff indicates no modifications, additions, or deletions
- no files were changed in the provided diff
## 2026-01-12 - 3.0.1 - fix(release)
add npm registries to release config and expand documentation for UniversalDevice architecture and smart-home features
- npmextra.json: add "registries" to release configuration to publish to both Verdaccio (https://verdaccio.lossless.digital) and the npm registry
- readme.hints.md: rewritten/expanded implementation notes to describe the UniversalDevice architecture, composable features (including smart-home types like light, switch, sensor, climate, cover, lock, fan, camera), protocols, discovery, factories, interfaces, and testing guidance
- readme.md: add factory usage examples including smart-home factory functions, update txtRecords usage (rp) in examples, and small copy/emoji edits
## 2026-01-10 - 3.0.0 - BREAKING CHANGE(devicemanager)
migrate tests to new UniversalDevice/feature-based API, add device factories, SNMP protocol/feature and IP helper utilities
- Replace protocol-specific device classes (Scanner, Printer) with UniversalDevice and feature objects (ScanFeature, PrintFeature, PlaybackFeature, VolumeFeature, PowerFeature, SnmpFeature)
- Add device factory functions: createScanner, createPrinter, createSpeaker, createUpsDevice
- Add DeviceManager.getDevices selector and updated selectDevice behavior (throws when no match)
- Expose SnmpProtocol and other protocol implementations
- Introduce IP helper utilities: isValidIp, cidrToIps, getLocalSubnet
- Update tests and logging to use feature-based APIs and factories (selectFeature/getFeature, hasFeature, featureCount)
## 2026-01-09 - 2.3.1 - fix(readme)
update README to comprehensive, TypeScript-first documentation covering installation, quick start, examples, API usage, events, error handling, requirements, credits, and legal/company information
- Rewrote readme.md with ~590 additional lines to provide a full usage guide and examples
- Added installation instructions for pnpm, npm, and yarn and badges for npm and license
- Documented OOP usage pattern (Discovery → Selection → Feature → Operation), event handling, and error handling examples
- Clarified requirements (Node.js 18+, TypeScript 5.0+), credits, license, trademark and company contact information
- Docs-only change — no code or API modifications
## 2026-01-09 - 2.3.0 - feat(devicemanager)
add selector-based device APIs, selectFeature helper, convenience discovery methods, and ESCL scan completion fallback
- Introduce IDeviceSelector and add selector support to getDevices(selector) to filter devices by id,address,name,model,manufacturer and feature capabilities.
- Add selectDevice(selector) which returns exactly one device (throws if none) and logs a warning if multiple matches are returned without a unique identifier.
- Deprecate getDevice(id) and getDeviceByAddress(address) in favor of selector-based retrieval methods.
- Add private matchesSelector(...) implementing exact identity checks (id,address), case-insensitive partial attribute matching (name, model, manufacturer), and feature availability checks (hasFeature, hasFeatures, hasAnyFeature).
- Add selectFeature(type) on devices to provide fail-fast access to a required feature (throws if missing).
- Add discoverScanners(subnet, options) and discoverPrinters(subnet, options) convenience methods that run targeted network scans and return discovered scanners or printers respectively.
- Improve ESCL protocol waitForScanComplete to attempt a direct download first (which triggers/blocks on many scanners) and fall back to polling if direct download fails or returns empty data.
## 2026-01-09 - 2.2.0 - feat(smarthome)
add smart home features and Home Assistant integration (WebSocket protocol, discovery, factories, interfaces)
- Add concrete smart home feature implementations: light, climate, sensor, switch, cover, lock, fan, camera.
- Introduce Home Assistant WebSocket protocol handler (protocol.homeassistant) and Home Assistant discovery via mDNS (discovery.classes.homeassistant).
- Add generic smart home interfaces and Home Assistant-specific interfaces (smarthome.interfaces, homeassistant.interfaces) and export them.
- Add smart home factories to create devices for discovered/declared smart home entities and export factory helpers.
- Update plugins to include WebSocket (ws) and add ws dependency and @types/ws in package.json.
## 2026-01-09 - 2.1.0 - feat(devicemanager)
prefer higher-priority discovery source when resolving device names and track per-device name source

View File

@@ -11,10 +11,14 @@
"projectDomain": "ecobridge.xyz"
},
"release": {
"accessLevel": "public"
"accessLevel": "public",
"registries": [
"https://verdaccio.lossless.digital",
"https://registry.npmjs.org"
]
}
},
"@ship.zone/szci": {
"npmGlobalTools": []
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@ecobridge.xyz/devicemanager",
"version": "2.1.0",
"version": "3.1.0",
"private": false,
"description": "a device manager for talking to devices on network and over usb",
"main": "dist_ts/index.js",
@@ -17,7 +17,8 @@
"@git.zone/tsbuild": "^4.1.0",
"@git.zone/tsrun": "^2.0.0",
"@git.zone/tstest": "^3.1.3",
"@types/node": "^25.0.3"
"@types/node": "^25.0.3",
"@types/ws": "^8.18.1"
},
"dependencies": {
"@push.rocks/smartdelay": "^3.0.5",
@@ -32,6 +33,7 @@
"ipp": "^2.0.1",
"net-snmp": "^3.26.0",
"node-ssdp": "^4.0.1",
"sonos": "^1.14.2"
"sonos": "^1.14.2",
"ws": "^8.19.0"
}
}

6
pnpm-lock.yaml generated
View File

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

View File

@@ -2,20 +2,20 @@
## Architecture Overview
The device manager supports two architectures:
The device manager uses a **UniversalDevice** architecture with composable features.
### Legacy Architecture (Still Supported)
- Separate device classes: `Scanner`, `Printer`, `Speaker`, `SnmpDevice`, `UpsDevice`, `DlnaRenderer`, `DlnaServer`
- Type-specific collections in DeviceManager
- Type-based queries: `getScanners()`, `getPrinters()`, `getSpeakers()`
### Key Concepts
### New Universal Device Architecture
- Single `UniversalDevice` class with composable features
- Features are capabilities that can be attached to any device
- Supports multifunction devices naturally (e.g., printer+scanner)
- **UniversalDevice**: A single device class that can have multiple features attached
- **Features**: Composable capabilities (scan, print, playback, volume, power, snmp, smart home, etc.)
- **Protocols**: Low-level protocol implementations (eSCL, SANE, IPP, SNMP, NUT, UPnP, Home Assistant)
## Key Files
### Core (`ts/`)
- `devicemanager.classes.devicemanager.ts` - Main DeviceManager class with discovery and device registry
- `device/device.classes.device.ts` - UniversalDevice class with feature management
### Features (`ts/features/`)
- `feature.abstract.ts` - Base Feature class with connection management and retry logic
- `feature.scan.ts` - Scanning via eSCL/SANE protocols
@@ -24,43 +24,92 @@ The device manager supports two architectures:
- `feature.volume.ts` - Volume control (separate from playback)
- `feature.power.ts` - UPS/power monitoring via NUT/SNMP
- `feature.snmp.ts` - SNMP queries
- `feature.light.ts` - Smart light control
- `feature.switch.ts` - Smart switch control
- `feature.sensor.ts` - Smart sensors
- `feature.climate.ts` - Climate/HVAC control
- `feature.cover.ts` - Blinds, garage doors
- `feature.lock.ts` - Smart locks
- `feature.fan.ts` - Fan control
- `feature.camera.ts` - Camera control
### Device (`ts/device/`)
- `device.classes.device.ts` - UniversalDevice class with feature management
### Protocols (`ts/protocols/`)
- `protocol.escl.ts` - eSCL/AirScan scanner protocol
- `protocol.sane.ts` - SANE network scanner protocol
- `protocol.ipp.ts` - IPP printer protocol
- `protocol.snmp.ts` - SNMP queries
- `protocol.nut.ts` - Network UPS Tools protocol
- `protocol.upnp.ts` - UPnP/SOAP client
- `protocol.upssnmp.ts` - UPS-specific SNMP
- `protocol.homeassistant.ts` - Home Assistant WebSocket API
### Discovery (`ts/discovery/`)
- `discovery.classes.mdns.ts` - mDNS discovery (Bonjour)
- `discovery.classes.ssdp.ts` - SSDP/UPnP discovery
- `discovery.classes.networkscanner.ts` - Active network scanning
- `discovery.classes.homeassistant.ts` - Home Assistant instance discovery
### Factories (`ts/factories/`)
- `index.ts` - Device factory functions for creating pre-configured devices:
- `createScanner`, `createPrinter`, `createSpeaker`, `createDlnaRenderer`
- `createSnmpDevice`, `createUpsDevice`
- Smart home: `createSmartLight`, `createSmartSwitch`, `createSmartSensor`, etc.
### Interfaces (`ts/interfaces/`)
- `feature.interfaces.ts` - All feature-related types and interfaces
- `index.ts` - Re-exports feature interfaces
- `smarthome.interfaces.ts` - Smart home specific interfaces
- `homeassistant.interfaces.ts` - Home Assistant API types
- `index.ts` - Re-exports all interfaces
## Feature Types
```typescript
type TFeatureType =
| 'scan' | 'print' | 'fax' | 'copy'
| 'playback' | 'volume' | 'power' | 'snmp'
| 'dlna-render' | 'dlna-serve';
| 'dlna-render' | 'dlna-serve'
| 'light' | 'climate' | 'sensor' | 'camera'
| 'cover' | 'switch' | 'lock' | 'fan';
```
## DeviceManager Feature API
## DeviceManager API
```typescript
// Query by features
dm.getDevicesWithFeature('scan'); // Devices with scan feature
dm.getDevices(); // All devices
dm.getDevices({ hasFeature: 'scan' }); // Devices with scan feature
dm.getDevices({ name: 'Brother' }); // Devices matching name
dm.getDevicesWithFeatures(['scan', 'print']); // Devices with ALL features
dm.getDevicesWithAnyFeature(['playback', 'volume']); // Devices with ANY feature
// Manage universal devices
dm.addUniversalDevice(device);
dm.addFeatureToDevice(deviceId, feature);
dm.removeFeatureFromDevice(deviceId, featureType);
// Select (throws if not found)
dm.selectDevice({ address: '192.168.1.100' });
// Discovery
dm.discoverScanners('192.168.1.0/24');
dm.discoverPrinters('192.168.1.0/24');
dm.scanNetwork({ ipRange: '...', probeEscl: true, ... });
dm.startDiscovery(); // mDNS/SSDP
dm.stopDiscovery();
```
## Protocol Implementations
- `EsclProtocol` - eSCL/AirScan scanner protocol
- `SaneProtocol` - SANE network scanner protocol
- `IppProtocol` - IPP printer protocol
- `SnmpProtocol` - SNMP queries
- `NutProtocol` - Network UPS Tools protocol
## UniversalDevice API
## Type Notes
- `TScanFormat` includes 'tiff' (added for compatibility)
- `IPrinterCapabilities` (from index.ts) has `string[]` for sides/quality
- `IPrintCapabilities` (from feature.interfaces.ts) has typed arrays
```typescript
// Feature access
device.hasFeature('scan');
device.getFeature<ScanFeature>('scan'); // Returns undefined if not found
device.selectFeature<ScanFeature>('scan'); // Throws if not found
device.getFeatureTypes(); // ['scan', 'print', ...]
// Connection
await device.connect(); // Connect all features
await device.disconnect(); // Disconnect all features
```
## Testing Notes
- Test files use `@git.zone/tstest` with tap-based assertions
- Import `expect` from `@git.zone/tstest/tapbundle`
- Tests are in `test/` directory
- Run with `pnpm test` or `tstest test/test.some.ts --verbose`

625
readme.md
View File

@@ -1,5 +1,624 @@
# @ecobridge.xyz/devicemanager
a device manager for talking to devices on network and over usb
## How to create the docs
To create docs run gitzone aidoc.
A comprehensive, TypeScript-first device manager for discovering and communicating with network devices. 🔌
[![npm version](https://img.shields.io/npm/v/@ecobridge.xyz/devicemanager.svg)](https://www.npmjs.com/package/@ecobridge.xyz/devicemanager)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
## 🎯 Overview
`@ecobridge.xyz/devicemanager` provides a unified, object-oriented API for discovering and controlling network devices. Whether you're building a document scanning workflow, managing printers, controlling smart home devices, or monitoring UPS systems — this library has you covered.
**Supported Device Types:**
- 🖨️ **Scanners** — eSCL (AirScan), SANE protocols
- 📄 **Printers** — IPP, JetDirect protocols
- 🔊 **Speakers** — Sonos, AirPlay, Chromecast, DLNA
- 🔋 **UPS Systems** — NUT, SNMP protocols
- 📡 **SNMP Devices** — Generic SNMP v1/v2c/v3 support
- 🏠 **Smart Home** — Home Assistant integration (lights, switches, sensors, climate, locks, fans, cameras, covers)
## Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
## 📦 Installation
```bash
# Using pnpm (recommended)
pnpm add @ecobridge.xyz/devicemanager
# Using npm
npm install @ecobridge.xyz/devicemanager
# Using yarn
yarn add @ecobridge.xyz/devicemanager
```
## 🚀 Quick Start
### The OOP Pattern: Discovery → Selection → Feature → Operation
```typescript
import { DeviceManager, ScanFeature } from '@ecobridge.xyz/devicemanager';
import * as fs from 'fs/promises';
async function scanDocument() {
const manager = new DeviceManager();
// 1⃣ DISCOVERY - Find scanners in your network
await manager.discoverScanners('192.168.1.0/24');
// 2⃣ INSPECTION - See what's available
const scanners = manager.getDevices({ hasFeature: 'scan' });
console.log('Found scanners:', scanners.map(s => `${s.name} at ${s.address}`));
// 3⃣ SELECTION - Choose your device (explicit, no magic!)
const device = manager.selectDevice({ address: '192.168.1.100' });
// 4⃣ FEATURE ACCESS - Get the capability you need
const scanFeature = device.selectFeature<ScanFeature>('scan');
// 5⃣ OPERATION - Do the thing!
await scanFeature.connect();
const result = await scanFeature.scan({
source: 'flatbed',
resolution: 300,
colorMode: 'color',
format: 'jpeg',
});
await fs.writeFile('scan.jpg', result.data);
console.log(`Saved: scan.jpg (${result.data.length} bytes)`);
await manager.shutdown();
}
scanDocument();
```
## 🏗️ Architecture
The library follows a clean, composable architecture:
```
┌─────────────────────────────────────────────────────────────┐
│ DeviceManager │
│ • Discovery (mDNS, SSDP, Network Scanning) │
│ • Device Registry (by IP, deduplication) │
│ • Device Selection (query & assert patterns) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ UniversalDevice │
│ • Represents any network device │
│ • Composable features (scan, print, volume, etc.) │
│ • Connection lifecycle management │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Features │
│ ScanFeature │ PrintFeature │ PlaybackFeature │ VolumeFeature
│ PowerFeature │ SnmpFeature │ LightFeature │ SwitchFeature │
│ SensorFeature │ ClimateFeature │ CameraFeature │ ... │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Protocols │
│ eSCL │ SANE │ IPP │ SNMP │ NUT │ UPnP/SOAP │ Home Assistant│
└─────────────────────────────────────────────────────────────┘
```
## 📖 API Reference
### DeviceManager
The central orchestrator for device discovery and management.
```typescript
const manager = new DeviceManager({
autoDiscovery: true, // Enable mDNS/SSDP auto-discovery
discoveryTimeout: 10000, // Discovery timeout in ms
enableRetry: true, // Enable retry with exponential backoff
maxRetries: 5, // Maximum retry attempts
});
```
#### Discovery Methods
```typescript
// Focused scanner discovery
const scanners = await manager.discoverScanners('192.168.1.0/24', {
timeout: 3000,
concurrency: 50,
});
// Focused printer discovery
const printers = await manager.discoverPrinters('192.168.1.0/24');
// General network scan (all device types)
const devices = await manager.scanNetwork({
ipRange: '192.168.1.0/24',
probeEscl: true,
probeIpp: true,
probeSane: true,
probeAirplay: true,
probeSonos: true,
probeChromecast: true,
});
// mDNS/SSDP continuous discovery
await manager.startDiscovery();
manager.on('device:found', ({ device, featureType }) => {
console.log(`Found: ${device.name}`);
});
await manager.stopDiscovery();
```
#### Device Selection
```typescript
// Query pattern (returns array, may be empty)
const allDevices = manager.getDevices();
const scanners = manager.getDevices({ hasFeature: 'scan' });
const brotherDevices = manager.getDevices({ name: 'Brother' });
// Assert pattern (returns single device, throws if not found)
const device = manager.selectDevice({ address: '192.168.1.100' });
const scanner = manager.selectDevice({ name: 'Brother', hasFeature: 'scan' });
```
#### IDeviceSelector Interface
```typescript
interface IDeviceSelector {
id?: string; // Exact match on device ID
address?: string; // Exact match on IP address
name?: string; // Partial match (case-insensitive)
model?: string; // Partial match (case-insensitive)
manufacturer?: string; // Partial match (case-insensitive)
hasFeature?: TFeatureType; // Must have this feature
hasFeatures?: TFeatureType[]; // Must have ALL features
hasAnyFeature?: TFeatureType[]; // Must have ANY feature
}
```
### UniversalDevice
Represents any network device with composable features.
```typescript
const device = manager.selectDevice({ address: '192.168.1.100' });
// Device properties
console.log(device.name); // "Brother MFC-J5730DW"
console.log(device.address); // "192.168.1.100"
console.log(device.manufacturer); // "Brother"
console.log(device.model); // "MFC-J5730DW"
console.log(device.status); // 'online' | 'offline' | 'busy' | 'error'
// Feature access (safe query - returns undefined)
const maybeScan = device.getFeature<ScanFeature>('scan');
// Feature access (assert - throws if not available)
const scanFeature = device.selectFeature<ScanFeature>('scan');
// Check capabilities
device.hasFeature('scan'); // true/false
device.hasFeatures(['scan', 'print']); // must have ALL
device.hasAnyFeature(['scan', 'print']); // must have ANY
device.getFeatureTypes(); // ['scan', 'print', ...]
```
### Features
#### 🖨️ ScanFeature
```typescript
const scanFeature = device.selectFeature<ScanFeature>('scan');
await scanFeature.connect();
// Get capabilities
const caps = await scanFeature.getCapabilities();
// { resolutions: [100, 200, 300, 600], formats: ['jpeg', 'png', 'pdf'], ... }
// Scan a document
const result = await scanFeature.scan({
source: 'flatbed', // 'flatbed' | 'adf' | 'adf-duplex'
resolution: 300, // DPI
colorMode: 'color', // 'color' | 'grayscale' | 'blackwhite'
format: 'jpeg', // 'jpeg' | 'png' | 'pdf' | 'tiff'
quality: 85, // JPEG quality (1-100)
area: { x: 0, y: 0, width: 210, height: 297 }, // mm
});
// result.data is a Buffer containing the scanned image
await fs.writeFile('scan.jpg', result.data);
```
#### 📄 PrintFeature
```typescript
const printFeature = device.selectFeature<PrintFeature>('print');
await printFeature.connect();
// Get printer capabilities
const caps = await printFeature.getCapabilities();
// Print a document
const job = await printFeature.print(pdfBuffer, {
copies: 2,
mediaSize: 'iso_a4_210x297mm',
sides: 'two-sided-long-edge',
quality: 'high',
colorMode: 'color',
jobName: 'My Document',
});
// Check job status
const status = await printFeature.getJobStatus(job.id);
```
#### 🔊 PlaybackFeature & VolumeFeature
```typescript
const playback = device.selectFeature<PlaybackFeature>('playback');
const volume = device.selectFeature<VolumeFeature>('volume');
await playback.connect();
// Control playback
await playback.play('http://example.com/audio.mp3');
await playback.pause();
await playback.stop();
await playback.seek(120); // seconds
// Get playback status
const status = await playback.getStatus();
// { state: 'playing', position: 45, duration: 180, track: { title: '...' } }
// Control volume
await volume.setVolume(50); // 0-100
await volume.mute();
await volume.unmute();
const level = await volume.getVolume();
```
#### 🔋 PowerFeature (UPS)
```typescript
const power = device.selectFeature<PowerFeature>('power');
await power.connect();
// Get UPS status
const status = await power.getStatus();
// { status: 'online', battery: { charge: 100, runtime: 1800, voltage: 13.8 }, ... }
// Get battery info
const battery = await power.getBatteryInfo();
// { charge: 95, runtime: 1500, health: 'good' }
// Run self-test
await power.runTest();
```
#### 🏠 Smart Home Features
```typescript
// Light control
const light = device.selectFeature<LightFeature>('light');
await light.turnOn();
await light.setBrightness(80);
await light.setColor({ r: 255, g: 100, b: 50 });
await light.setColorTemperature(4000); // Kelvin
// Switch control
const switch_ = device.selectFeature<SwitchFeature>('switch');
await switch_.turnOn();
await switch_.turnOff();
await switch_.toggle();
// Climate control
const climate = device.selectFeature<ClimateFeature>('climate');
await climate.setTargetTemperature(22);
await climate.setMode('heat'); // 'heat' | 'cool' | 'auto' | 'off'
// Sensor reading
const sensor = device.selectFeature<SensorFeature>('sensor');
const reading = await sensor.getState();
// { temperature: 22.5, humidity: 45, battery: 85 }
```
### Protocol Direct Access
For advanced use cases, you can access protocols directly:
```typescript
import { EsclProtocol, IppProtocol, SnmpProtocol } from '@ecobridge.xyz/devicemanager';
// Direct eSCL (AirScan) access
const escl = new EsclProtocol('192.168.1.100', 80, false);
const caps = await escl.getCapabilities();
const result = await escl.scan({ source: 'flatbed', resolution: 300 });
// Direct IPP access
const ipp = new IppProtocol('ipp://192.168.1.100:631/ipp/print');
const printerAttrs = await ipp.getPrinterAttributes();
// Direct SNMP access
const snmp = new SnmpProtocol('192.168.1.100', { community: 'public' });
const sysDescr = await snmp.get('1.3.6.1.2.1.1.1.0');
```
### Home Assistant Integration
```typescript
import { HomeAssistantProtocol, HomeAssistantDiscovery } from '@ecobridge.xyz/devicemanager';
// Connect to Home Assistant
const ha = new HomeAssistantProtocol({
host: 'homeassistant.local',
port: 8123,
accessToken: 'your_long_lived_access_token',
});
await ha.connect();
// Get all entities
const entities = await ha.getStates();
// Control a light
await ha.callService('light', 'turn_on', {
entity_id: 'light.living_room',
brightness: 200,
});
// Subscribe to state changes
ha.on('state_changed', (event) => {
console.log(`${event.entity_id}: ${event.new_state.state}`);
});
// Auto-discover Home Assistant instances via mDNS
const discovery = new HomeAssistantDiscovery();
discovery.on('found', (instance) => {
console.log(`Found HA at ${instance.address}:${instance.port}`);
});
await discovery.start();
```
### Helper Utilities
```typescript
import {
withRetry,
isValidIp,
cidrToIps,
getLocalSubnet,
} from '@ecobridge.xyz/devicemanager';
// Retry with exponential backoff
const result = await withRetry(
() => someFlakeyOperation(),
{ maxRetries: 3, baseDelay: 1000, multiplier: 2 }
);
// IP utilities
isValidIp('192.168.1.1'); // true
cidrToIps('192.168.1.0/30'); // ['192.168.1.0', '192.168.1.1', ...]
getLocalSubnet(); // '192.168.1.0/24'
```
## 🔍 Discovery Methods
The library supports multiple discovery mechanisms:
| Method | Protocol | Use Case |
|--------|----------|----------|
| `discoverScanners()` | eSCL, SANE | Find network scanners |
| `discoverPrinters()` | IPP | Find network printers |
| `scanNetwork()` | All | Comprehensive subnet scan |
| `startDiscovery()` | mDNS, SSDP | Continuous auto-discovery |
### mDNS Service Types
```typescript
import { SERVICE_TYPES } from '@ecobridge.xyz/devicemanager';
// Available: escl, ipp, ipp-tls, airplay, raop,
// googlecast, sonos, sane, http, https, printer
```
### SSDP Service Types
```typescript
import { SSDP_SERVICE_TYPES } from '@ecobridge.xyz/devicemanager';
// Available: all, rootdevice, mediaRenderer, mediaServer,
// contentDirectory, avtransport, renderingControl,
// connectionManager, zonePlayer
```
## 🎯 Feature Types
```typescript
type TFeatureType =
| 'scan' // Document scanning
| 'print' // Document printing
| 'fax' // Fax send/receive
| 'copy' // Copy (scan + print)
| 'playback' // Media playback
| 'volume' // Volume control
| 'power' // Power/UPS status
| 'snmp' // SNMP queries
| 'dlna-render'// DLNA renderer
| 'dlna-serve' // DLNA server
| 'light' // Smart lights
| 'climate' // HVAC/thermostats
| 'sensor' // Sensors
| 'camera' // Cameras
| 'cover' // Blinds, garage doors
| 'switch' // Smart switches
| 'lock' // Smart locks
| 'fan' // Fans
;
```
## 🔧 Advanced Usage
### Custom Device Creation
Use the factory functions for creating devices with specific features:
```typescript
import { createScanner, createPrinter, createSpeaker } from '@ecobridge.xyz/devicemanager';
// Create a scanner device manually
const scanner = createScanner({
id: 'my-scanner',
name: 'Office Scanner',
address: '192.168.1.50',
port: 80,
protocol: 'escl',
txtRecords: {},
});
// Create a printer device
const printer = createPrinter({
id: 'my-printer',
name: 'Office Printer',
address: '192.168.1.51',
port: 631,
txtRecords: { rp: '/ipp/print' },
});
// Create a Sonos speaker
const speaker = createSpeaker({
id: 'living-room-sonos',
name: 'Living Room',
address: '192.168.1.52',
port: 1400,
protocol: 'sonos',
});
```
### Smart Home Factory Functions
```typescript
import {
createSmartLight,
createSmartSwitch,
createSmartSensor,
createSmartClimate,
createSmartCover,
createSmartLock,
createSmartFan,
createSmartCamera,
} from '@ecobridge.xyz/devicemanager';
// Create devices with Home Assistant integration
const light = createSmartLight({
id: 'living-room-light',
name: 'Living Room Light',
address: 'homeassistant.local',
port: 8123,
entityId: 'light.living_room',
protocol: 'home-assistant',
protocolClient: haClient, // Your HomeAssistantProtocol instance
capabilities: {
supportsBrightness: true,
supportsColorTemp: true,
supportsRgb: true,
},
});
```
### Event Handling
```typescript
const manager = new DeviceManager();
// Discovery events
manager.on('device:found', ({ device, featureType }) => {
console.log(`Found ${device.name} with ${featureType} capability`);
});
manager.on('device:lost', (address) => {
console.log(`Device at ${address} went offline`);
});
// Network scan progress
manager.on('network:progress', (progress) => {
console.log(`Scanning: ${progress.percentage}% - Found ${progress.devicesFound} devices`);
});
// Device events
const device = manager.selectDevice({ address: '192.168.1.100' });
device.on('status:changed', ({ oldStatus, newStatus }) => {
console.log(`Status: ${oldStatus}${newStatus}`);
});
device.on('feature:connected', (featureType) => {
console.log(`Feature ${featureType} connected`);
});
```
### Error Handling
The library uses a fail-fast approach with clear error messages:
```typescript
try {
// Throws if no device matches
const device = manager.selectDevice({ address: '192.168.1.999' });
} catch (err) {
// "No device found matching: {\"address\":\"192.168.1.999\"}"
}
try {
// Throws if device doesn't have the feature
const printFeature = device.selectFeature<PrintFeature>('print');
} catch (err) {
// "Device 'Brother Scanner' does not have feature 'print'"
}
// Safe alternatives that don't throw
const devices = manager.getDevices({ address: '192.168.1.999' }); // []
const maybePrint = device.getFeature<PrintFeature>('print'); // undefined
```
## 📋 Requirements
- **Node.js** 18+ (native `fetch` support required)
- **TypeScript** 5.0+ (recommended)
- **Network access** to target devices
## 🙏 Credits
Built with ❤️ using:
- [bonjour-service](https://github.com/onlxltd/bonjour-service) - mDNS discovery
- [node-ssdp](https://github.com/diversario/node-ssdp) - SSDP/UPnP discovery
- [net-snmp](https://github.com/markabrahams/node-net-snmp) - SNMP protocol
- [ipp](https://github.com/niclaslindstedt/ipp) - IPP printing protocol
- [sonos](https://github.com/bencevans/node-sonos) - Sonos control
- [castv2-client](https://github.com/thibauts/node-castv2-client) - Chromecast protocol
## License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
### Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
### Company Information
Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.

View File

@@ -1,26 +1,40 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as devicemanager from '../ts/index.js';
// Test imports
// Test core exports
tap.test('should export DeviceManager', async () => {
expect(devicemanager.DeviceManager).toBeDefined();
expect(typeof devicemanager.DeviceManager).toEqual('function');
});
tap.test('should export Scanner', async () => {
expect(devicemanager.Scanner).toBeDefined();
expect(typeof devicemanager.Scanner).toEqual('function');
tap.test('should export UniversalDevice', async () => {
expect(devicemanager.UniversalDevice).toBeDefined();
expect(typeof devicemanager.UniversalDevice).toEqual('function');
});
tap.test('should export Printer', async () => {
expect(devicemanager.Printer).toBeDefined();
expect(typeof devicemanager.Printer).toEqual('function');
tap.test('should export Features', async () => {
expect(devicemanager.ScanFeature).toBeDefined();
expect(devicemanager.PrintFeature).toBeDefined();
expect(devicemanager.PlaybackFeature).toBeDefined();
expect(devicemanager.VolumeFeature).toBeDefined();
expect(devicemanager.PowerFeature).toBeDefined();
expect(devicemanager.SnmpFeature).toBeDefined();
});
tap.test('should export device factories', async () => {
expect(devicemanager.createScanner).toBeDefined();
expect(devicemanager.createPrinter).toBeDefined();
expect(devicemanager.createSpeaker).toBeDefined();
expect(devicemanager.createUpsDevice).toBeDefined();
expect(typeof devicemanager.createScanner).toEqual('function');
expect(typeof devicemanager.createPrinter).toEqual('function');
});
tap.test('should export protocol implementations', async () => {
expect(devicemanager.EsclProtocol).toBeDefined();
expect(devicemanager.SaneProtocol).toBeDefined();
expect(devicemanager.IppProtocol).toBeDefined();
expect(devicemanager.SnmpProtocol).toBeDefined();
});
tap.test('should export retry helpers', async () => {
@@ -39,6 +53,28 @@ tap.test('should create DeviceManager instance', async () => {
expect(dm.isDiscovering).toEqual(false);
expect(dm.getScanners()).toEqual([]);
expect(dm.getPrinters()).toEqual([]);
expect(dm.getDevices()).toEqual([]);
});
// Test device selector
tap.test('should support device selection with IDeviceSelector', async () => {
const dm = new devicemanager.DeviceManager({
autoDiscovery: false,
});
// Empty manager should return empty array
expect(dm.getDevices({ hasFeature: 'scan' })).toEqual([]);
expect(dm.getDevices({ name: 'Test' })).toEqual([]);
// selectDevice should throw when no devices match
let error: Error | null = null;
try {
dm.selectDevice({ address: '192.168.1.100' });
} catch (e) {
error = e as Error;
}
expect(error).not.toBeNull();
expect(error?.message).toContain('No device found');
});
// Test retry helper
@@ -114,17 +150,18 @@ tap.test('should start and stop discovery', async () => {
// Wait a bit for potential device discovery
await new Promise((resolve) => setTimeout(resolve, 2000));
// Log discovered devices
const scanners = dm.getScanners();
const printers = dm.getPrinters();
// Log discovered devices using new API
const scanners = dm.getDevices({ hasFeature: 'scan' });
const printers = dm.getDevices({ hasFeature: 'print' });
console.log(`Discovered ${scanners.length} scanner(s) and ${printers.length} printer(s)`);
for (const scanner of scanners) {
console.log(` Scanner: ${scanner.name} (${scanner.address}:${scanner.port}) - ${scanner.protocol}`);
const scanFeature = scanner.getFeature<devicemanager.ScanFeature>('scan');
console.log(` Scanner: ${scanner.name} (${scanner.address}) - ${scanFeature?.protocol || 'unknown'}`);
}
for (const printer of printers) {
console.log(` Printer: ${printer.name} (${printer.address}:${printer.port})`);
console.log(` Printer: ${printer.name} (${printer.address})`);
}
await dm.stopDiscovery();
@@ -134,9 +171,9 @@ tap.test('should start and stop discovery', async () => {
await dm.shutdown();
});
// Test Scanner creation from discovery info
tap.test('should create Scanner from discovery info', async () => {
const scanner = devicemanager.Scanner.fromDiscovery({
// Test Scanner creation using factory
tap.test('should create Scanner device using factory', async () => {
const scanner = devicemanager.createScanner({
id: 'test:scanner:1',
name: 'Test Scanner',
address: '192.168.1.100',
@@ -150,40 +187,75 @@ tap.test('should create Scanner from discovery info', async () => {
},
});
expect(scanner).toBeInstanceOf(devicemanager.UniversalDevice);
expect(scanner.name).toEqual('Test Scanner');
expect(scanner.address).toEqual('192.168.1.100');
expect(scanner.port).toEqual(443);
expect(scanner.protocol).toEqual('escl');
expect(scanner.supportedFormats).toContain('jpeg');
expect(scanner.supportedFormats).toContain('pdf');
expect(scanner.supportedColorModes).toContain('color');
expect(scanner.supportedColorModes).toContain('grayscale');
expect(scanner.supportedSources).toContain('flatbed');
expect(scanner.supportedSources).toContain('adf');
expect(scanner.hasAdf).toEqual(true);
expect(scanner.hasFeature('scan')).toEqual(true);
const scanFeature = scanner.selectFeature<devicemanager.ScanFeature>('scan');
expect(scanFeature).toBeInstanceOf(devicemanager.ScanFeature);
expect(scanFeature.protocol).toEqual('escl');
});
// Test Printer creation from discovery info
tap.test('should create Printer from discovery info', async () => {
const printer = devicemanager.Printer.fromDiscovery({
// Test Printer creation using factory
tap.test('should create Printer device using factory', async () => {
const printer = devicemanager.createPrinter({
id: 'test:printer:1',
name: 'Test Printer',
address: '192.168.1.101',
port: 631,
ippPath: '/ipp/print',
txtRecords: {
'ty': 'Brother HL-L2350DW',
'rp': 'ipp/print',
'Color': 'T',
'Duplex': 'T',
},
});
expect(printer).toBeInstanceOf(devicemanager.UniversalDevice);
expect(printer.name).toEqual('Test Printer');
expect(printer.address).toEqual('192.168.1.101');
expect(printer.port).toEqual(631);
expect(printer.supportsColor).toEqual(true);
expect(printer.supportsDuplex).toEqual(true);
expect(printer.uri).toContain('ipp://');
expect(printer.hasFeature('print')).toEqual(true);
const printFeature = printer.selectFeature<devicemanager.PrintFeature>('print');
expect(printFeature).toBeInstanceOf(devicemanager.PrintFeature);
});
// Test UniversalDevice feature management
tap.test('should manage features on UniversalDevice', async () => {
const device = new devicemanager.UniversalDevice('192.168.1.50', 80, {
name: 'Test Device',
});
expect(device.name).toEqual('Test Device');
expect(device.address).toEqual('192.168.1.50');
expect(device.featureCount).toEqual(0);
expect(device.hasFeature('scan')).toEqual(false);
// selectFeature should throw when feature doesn't exist
let error: Error | null = null;
try {
device.selectFeature('scan');
} catch (e) {
error = e as Error;
}
expect(error).not.toBeNull();
expect(error?.message).toContain("does not have feature 'scan'");
// getFeature should return undefined
expect(device.getFeature('scan')).toBeUndefined();
});
// Test IP helpers
tap.test('should export IP helper utilities', async () => {
expect(devicemanager.isValidIp).toBeDefined();
expect(devicemanager.cidrToIps).toBeDefined();
expect(devicemanager.getLocalSubnet).toBeDefined();
// Test isValidIp
expect(devicemanager.isValidIp('192.168.1.1')).toEqual(true);
expect(devicemanager.isValidIp('invalid')).toEqual(false);
expect(devicemanager.isValidIp('256.1.1.1')).toEqual(false);
});
export default tap.start();

View File

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

View File

@@ -246,12 +246,35 @@ export class UniversalDevice extends plugins.events.EventEmitter {
}
/**
* Get a feature by type
* Get a feature by type (returns undefined if not available)
*/
public getFeature<T extends Feature>(type: TFeatureType): T | undefined {
return this._features.get(type) as T | undefined;
}
/**
* Select a feature by type (throws if not available).
* Use this when you expect the device to have this feature and want fail-fast behavior.
*
* @param type The feature type to select
* @returns The feature instance
* @throws Error if the device does not have this feature
*
* @example
* ```typescript
* const scanFeature = device.selectFeature<ScanFeature>('scan');
* await scanFeature.connect();
* const result = await scanFeature.scan({ source: 'flatbed' });
* ```
*/
public selectFeature<T extends Feature>(type: TFeatureType): T {
const feature = this._features.get(type) as T | undefined;
if (!feature) {
throw new Error(`Device '${this.name}' does not have feature '${type}'`);
}
return feature;
}
/**
* Get all features
*/

View File

@@ -38,6 +38,7 @@ import type {
INetworkScanOptions,
INetworkScanResult,
TFeatureType,
IDeviceSelector,
} from './interfaces/index.js';
/**
@@ -567,14 +568,48 @@ export class DeviceManager extends plugins.events.EventEmitter {
// ============================================================================
/**
* Get all devices
* Get devices matching the selector criteria.
* Returns all devices if no selector provided, or devices matching all criteria.
* @param selector Optional criteria to filter devices
* @returns Array of matching devices (empty if no matches)
*/
public getDevices(): UniversalDevice[] {
return Array.from(this.devicesByIp.values());
public getDevices(selector?: IDeviceSelector): UniversalDevice[] {
const devices = Array.from(this.devicesByIp.values());
if (!selector) {
return devices;
}
return devices.filter((device) => this.matchesSelector(device, selector));
}
/**
* Select exactly ONE device matching the criteria.
* Use this when you expect a specific device and want fail-fast behavior.
* @param selector Criteria to match the device
* @returns The matching device
* @throws Error if no device matches the selector
*/
public selectDevice(selector: IDeviceSelector): UniversalDevice {
const matches = this.getDevices(selector);
if (matches.length === 0) {
throw new Error(`No device found matching: ${JSON.stringify(selector)}`);
}
if (matches.length > 1 && !selector.address && !selector.id) {
// Multiple matches without unique identifier - log warning
console.warn(
`Multiple devices (${matches.length}) match selector, using first: ${matches[0].name}`
);
}
return matches[0];
}
/**
* Get device by ID
* @deprecated Use getDevices({ id }) or selectDevice({ id }) instead
*/
public getDevice(id: string): UniversalDevice | undefined {
for (const device of this.devicesByIp.values()) {
@@ -587,11 +622,52 @@ export class DeviceManager extends plugins.events.EventEmitter {
/**
* Get device by address
* @deprecated Use getDevices({ address }) or selectDevice({ address }) instead
*/
public getDeviceByAddress(address: string): UniversalDevice | undefined {
return this.devicesByIp.get(address);
}
/**
* Check if a device matches the selector criteria
*/
private matchesSelector(device: UniversalDevice, selector: IDeviceSelector): boolean {
// Identity checks (exact match)
if (selector.id && device.id !== selector.id) {
return false;
}
if (selector.address && device.address !== selector.address) {
return false;
}
// Attribute checks (partial match, case-insensitive)
if (selector.name && !device.name.toLowerCase().includes(selector.name.toLowerCase())) {
return false;
}
if (selector.model && !device.model?.toLowerCase().includes(selector.model.toLowerCase())) {
return false;
}
if (
selector.manufacturer &&
!device.manufacturer?.toLowerCase().includes(selector.manufacturer.toLowerCase())
) {
return false;
}
// Capability checks
if (selector.hasFeature && !device.hasFeature(selector.hasFeature)) {
return false;
}
if (selector.hasFeatures && !device.hasFeatures(selector.hasFeatures)) {
return false;
}
if (selector.hasAnyFeature && !device.hasAnyFeature(selector.hasAnyFeature)) {
return false;
}
return true;
}
// ============================================================================
// Device Access - By Feature Type
// ============================================================================
@@ -982,6 +1058,68 @@ export class DeviceManager extends plugins.events.EventEmitter {
return foundDevices;
}
/**
* Discover scanners in a subnet and add them to the manager.
* Convenience method that focuses discovery on scanner protocols only (eSCL, SANE).
*
* @param subnet CIDR notation subnet (e.g., '192.168.1.0/24')
* @param options Optional discovery settings
* @returns Array of discovered scanner devices
*
* @example
* ```typescript
* const scanners = await manager.discoverScanners('192.168.190.0/24');
* for (const scanner of scanners) {
* console.log(`Found: ${scanner.name} at ${scanner.address}`);
* }
* ```
*/
public async discoverScanners(
subnet: string,
options?: { timeout?: number; concurrency?: number }
): Promise<UniversalDevice[]> {
await this.scanNetwork({
ipRange: subnet,
probeEscl: true,
probeSane: true,
probeIpp: false,
probeAirplay: false,
probeSonos: false,
probeChromecast: false,
concurrency: options?.concurrency ?? 50,
timeout: options?.timeout ?? 3000,
});
return this.getScanners();
}
/**
* Discover printers in a subnet and add them to the manager.
* Convenience method that focuses discovery on printer protocols only (IPP).
*
* @param subnet CIDR notation subnet (e.g., '192.168.1.0/24')
* @param options Optional discovery settings
* @returns Array of discovered printer devices
*/
public async discoverPrinters(
subnet: string,
options?: { timeout?: number; concurrency?: number }
): Promise<UniversalDevice[]> {
await this.scanNetwork({
ipRange: subnet,
probeEscl: false,
probeSane: false,
probeIpp: true,
probeAirplay: false,
probeSonos: false,
probeChromecast: false,
concurrency: options?.concurrency ?? 50,
timeout: options?.timeout ?? 3000,
});
return this.getPrinters();
}
public async cancelNetworkScan(): Promise<void> {
if (this._networkScanner) {
await this._networkScanner.cancel();

View File

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

View File

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

View File

@@ -0,0 +1,214 @@
/**
* Camera Feature
* Provides control for smart cameras (snapshots, streams)
*/
import { Feature, type TDeviceReference } from './feature.abstract.js';
import type { IFeatureOptions } from '../interfaces/feature.interfaces.js';
import type {
TCameraProtocol,
ICameraCapabilities,
ICameraState,
ICameraFeatureInfo,
ICameraProtocolClient,
} from '../interfaces/smarthome.interfaces.js';
/**
* Options for creating a CameraFeature
*/
export interface ICameraFeatureOptions extends IFeatureOptions {
/** Protocol type */
protocol: TCameraProtocol;
/** Entity ID (for Home Assistant) */
entityId: string;
/** Protocol client for the camera */
protocolClient: ICameraProtocolClient;
/** Camera capabilities */
capabilities?: Partial<ICameraCapabilities>;
}
/**
* Camera Feature - snapshot and stream access
*
* Protocol-agnostic: works with Home Assistant, ONVIF, RTSP, etc.
*
* @example
* ```typescript
* const camera = device.getFeature<CameraFeature>('camera');
* if (camera) {
* const snapshot = await camera.getSnapshot();
* const streamUrl = await camera.getStreamUrl();
* }
* ```
*/
export class CameraFeature extends Feature {
public readonly type = 'camera' as const;
public readonly protocol: TCameraProtocol;
/** Entity ID (e.g., "camera.front_door") */
public readonly entityId: string;
/** Capabilities */
public readonly capabilities: ICameraCapabilities;
/** Current state */
protected _isRecording: boolean = false;
protected _isStreaming: boolean = false;
protected _motionDetected: boolean = false;
/** Protocol client for the camera */
private protocolClient: ICameraProtocolClient;
constructor(
device: TDeviceReference,
port: number,
options: ICameraFeatureOptions
) {
super(device, port, options);
this.protocol = options.protocol;
this.entityId = options.entityId;
this.protocolClient = options.protocolClient;
this.capabilities = {
supportsStream: options.capabilities?.supportsStream ?? true,
supportsPtz: options.capabilities?.supportsPtz ?? false,
supportsSnapshot: options.capabilities?.supportsSnapshot ?? true,
supportsMotionDetection: options.capabilities?.supportsMotionDetection ?? false,
frontendStreamType: options.capabilities?.frontendStreamType,
streamUrl: options.capabilities?.streamUrl,
};
}
// ============================================================================
// Properties
// ============================================================================
/**
* Check if recording (cached)
*/
public get isRecording(): boolean {
return this._isRecording;
}
/**
* Check if streaming (cached)
*/
public get isStreaming(): boolean {
return this._isStreaming;
}
/**
* Check if motion detected (cached)
*/
public get motionDetected(): boolean {
return this._motionDetected;
}
// ============================================================================
// Connection
// ============================================================================
protected async doConnect(): Promise<void> {
try {
const state = await this.protocolClient.getState(this.entityId);
this.updateStateInternal(state);
} catch {
// Ignore errors fetching initial state
}
}
protected async doDisconnect(): Promise<void> {
// Nothing to disconnect
}
// ============================================================================
// Camera Access
// ============================================================================
/**
* Get a snapshot image from the camera
* @returns Buffer containing image data
*/
public async getSnapshot(): Promise<Buffer> {
if (!this.capabilities.supportsSnapshot) {
throw new Error('Camera does not support snapshots');
}
return this.protocolClient.getSnapshot(this.entityId);
}
/**
* Get snapshot URL
* @returns URL for the snapshot image
*/
public async getSnapshotUrl(): Promise<string> {
if (!this.capabilities.supportsSnapshot) {
throw new Error('Camera does not support snapshots');
}
return this.protocolClient.getSnapshotUrl(this.entityId);
}
/**
* Get stream URL
* @returns URL for the video stream
*/
public async getStreamUrl(): Promise<string> {
if (!this.capabilities.supportsStream) {
throw new Error('Camera does not support streaming');
}
return this.protocolClient.getStreamUrl(this.entityId);
}
/**
* Get current state as object
*/
public getState(): ICameraState {
return {
isRecording: this._isRecording,
isStreaming: this._isStreaming,
motionDetected: this._motionDetected,
};
}
/**
* Refresh state from the device
*/
public async refreshState(): Promise<ICameraState> {
const state = await this.protocolClient.getState(this.entityId);
this.updateStateInternal(state);
return state;
}
/**
* Update state from external source
*/
public updateState(state: ICameraState): void {
this.updateStateInternal(state);
this.emit('state:changed', state);
}
/**
* Internal state update
*/
private updateStateInternal(state: ICameraState): void {
this._isRecording = state.isRecording;
this._isStreaming = state.isStreaming;
this._motionDetected = state.motionDetected;
}
// ============================================================================
// Feature Info
// ============================================================================
public getFeatureInfo(): ICameraFeatureInfo {
return {
...this.getBaseFeatureInfo(),
type: 'camera',
protocol: this.protocol,
capabilities: this.capabilities,
currentState: this.getState(),
};
}
}

View File

@@ -0,0 +1,407 @@
/**
* Climate Feature
* Provides control for thermostats and HVAC systems
*/
import { Feature, type TDeviceReference } from './feature.abstract.js';
import type { IFeatureOptions } from '../interfaces/feature.interfaces.js';
import type {
TClimateProtocol,
THvacMode,
THvacAction,
IClimateCapabilities,
IClimateState,
IClimateFeatureInfo,
IClimateProtocolClient,
} from '../interfaces/smarthome.interfaces.js';
/**
* Options for creating a ClimateFeature
*/
export interface IClimateFeatureOptions extends IFeatureOptions {
/** Protocol type */
protocol: TClimateProtocol;
/** Entity ID (for Home Assistant) */
entityId: string;
/** Protocol client for controlling the climate device */
protocolClient: IClimateProtocolClient;
/** Climate capabilities */
capabilities?: Partial<IClimateCapabilities>;
}
/**
* Climate Feature - thermostat and HVAC control
*
* Protocol-agnostic: works with Home Assistant, Nest, Ecobee, MQTT, etc.
*
* @example
* ```typescript
* const climate = device.getFeature<ClimateFeature>('climate');
* if (climate) {
* await climate.setHvacMode('heat');
* await climate.setTargetTemp(21);
* console.log(`Current: ${climate.currentTemp}°C, Target: ${climate.targetTemp}°C`);
* }
* ```
*/
export class ClimateFeature extends Feature {
public readonly type = 'climate' as const;
public readonly protocol: TClimateProtocol;
/** Entity ID (e.g., "climate.living_room") */
public readonly entityId: string;
/** Capabilities */
public readonly capabilities: IClimateCapabilities;
/** Current state */
protected _currentTemp?: number;
protected _targetTemp?: number;
protected _targetTempHigh?: number;
protected _targetTempLow?: number;
protected _hvacMode: THvacMode = 'off';
protected _hvacAction?: THvacAction;
protected _presetMode?: string;
protected _fanMode?: string;
protected _swingMode?: string;
protected _humidity?: number;
protected _targetHumidity?: number;
protected _auxHeat?: boolean;
/** Protocol client for controlling the climate device */
private protocolClient: IClimateProtocolClient;
constructor(
device: TDeviceReference,
port: number,
options: IClimateFeatureOptions
) {
super(device, port, options);
this.protocol = options.protocol;
this.entityId = options.entityId;
this.protocolClient = options.protocolClient;
this.capabilities = {
hvacModes: options.capabilities?.hvacModes ?? ['off', 'heat', 'cool', 'auto'],
presetModes: options.capabilities?.presetModes,
fanModes: options.capabilities?.fanModes,
swingModes: options.capabilities?.swingModes,
supportsTargetTemp: options.capabilities?.supportsTargetTemp ?? true,
supportsTargetTempRange: options.capabilities?.supportsTargetTempRange ?? false,
supportsHumidity: options.capabilities?.supportsHumidity ?? false,
supportsAuxHeat: options.capabilities?.supportsAuxHeat ?? false,
minTemp: options.capabilities?.minTemp ?? 7,
maxTemp: options.capabilities?.maxTemp ?? 35,
tempStep: options.capabilities?.tempStep ?? 0.5,
minHumidity: options.capabilities?.minHumidity,
maxHumidity: options.capabilities?.maxHumidity,
};
}
// ============================================================================
// Properties
// ============================================================================
/**
* Get current temperature (cached)
*/
public get currentTemp(): number | undefined {
return this._currentTemp;
}
/**
* Get target temperature (cached)
*/
public get targetTemp(): number | undefined {
return this._targetTemp;
}
/**
* Get target temperature high (for heat_cool mode, cached)
*/
public get targetTempHigh(): number | undefined {
return this._targetTempHigh;
}
/**
* Get target temperature low (for heat_cool mode, cached)
*/
public get targetTempLow(): number | undefined {
return this._targetTempLow;
}
/**
* Get current HVAC mode (cached)
*/
public get hvacMode(): THvacMode {
return this._hvacMode;
}
/**
* Get current HVAC action (cached)
*/
public get hvacAction(): THvacAction | undefined {
return this._hvacAction;
}
/**
* Get current preset mode (cached)
*/
public get presetMode(): string | undefined {
return this._presetMode;
}
/**
* Get current fan mode (cached)
*/
public get fanMode(): string | undefined {
return this._fanMode;
}
/**
* Get current swing mode (cached)
*/
public get swingMode(): string | undefined {
return this._swingMode;
}
/**
* Get current humidity (cached)
*/
public get humidity(): number | undefined {
return this._humidity;
}
/**
* Get target humidity (cached)
*/
public get targetHumidity(): number | undefined {
return this._targetHumidity;
}
/**
* Get aux heat state (cached)
*/
public get auxHeat(): boolean | undefined {
return this._auxHeat;
}
/**
* Get available HVAC modes
*/
public get hvacModes(): THvacMode[] {
return this.capabilities.hvacModes;
}
/**
* Get available preset modes
*/
public get presetModes(): string[] | undefined {
return this.capabilities.presetModes;
}
/**
* Get available fan modes
*/
public get fanModes(): string[] | undefined {
return this.capabilities.fanModes;
}
// ============================================================================
// Connection
// ============================================================================
protected async doConnect(): Promise<void> {
try {
const state = await this.protocolClient.getState(this.entityId);
this.updateStateInternal(state);
} catch {
// Ignore errors fetching initial state
}
}
protected async doDisconnect(): Promise<void> {
// Nothing to disconnect
}
// ============================================================================
// Climate Control
// ============================================================================
/**
* Set HVAC mode
* @param mode HVAC mode (off, heat, cool, etc.)
*/
public async setHvacMode(mode: THvacMode): Promise<void> {
if (!this.capabilities.hvacModes.includes(mode)) {
throw new Error(`HVAC mode ${mode} not supported`);
}
await this.protocolClient.setHvacMode(this.entityId, mode);
this._hvacMode = mode;
this.emit('state:changed', this.getState());
}
/**
* Set target temperature
* @param temp Target temperature
*/
public async setTargetTemp(temp: number): Promise<void> {
if (!this.capabilities.supportsTargetTemp) {
throw new Error('Climate device does not support target temperature');
}
const clamped = Math.max(
this.capabilities.minTemp,
Math.min(this.capabilities.maxTemp, temp)
);
await this.protocolClient.setTargetTemp(this.entityId, clamped);
this._targetTemp = clamped;
this.emit('state:changed', this.getState());
}
/**
* Set target temperature range (for heat_cool mode)
* @param low Low temperature
* @param high High temperature
*/
public async setTargetTempRange(low: number, high: number): Promise<void> {
if (!this.capabilities.supportsTargetTempRange) {
throw new Error('Climate device does not support temperature range');
}
const clampedLow = Math.max(this.capabilities.minTemp, Math.min(this.capabilities.maxTemp, low));
const clampedHigh = Math.max(this.capabilities.minTemp, Math.min(this.capabilities.maxTemp, high));
await this.protocolClient.setTargetTempRange(this.entityId, clampedLow, clampedHigh);
this._targetTempLow = clampedLow;
this._targetTempHigh = clampedHigh;
this.emit('state:changed', this.getState());
}
/**
* Set preset mode
* @param preset Preset mode name
*/
public async setPresetMode(preset: string): Promise<void> {
if (!this.capabilities.presetModes?.includes(preset)) {
throw new Error(`Preset mode ${preset} not supported`);
}
await this.protocolClient.setPresetMode(this.entityId, preset);
this._presetMode = preset;
this.emit('state:changed', this.getState());
}
/**
* Set fan mode
* @param mode Fan mode name
*/
public async setFanMode(mode: string): Promise<void> {
if (!this.capabilities.fanModes?.includes(mode)) {
throw new Error(`Fan mode ${mode} not supported`);
}
await this.protocolClient.setFanMode(this.entityId, mode);
this._fanMode = mode;
this.emit('state:changed', this.getState());
}
/**
* Set swing mode
* @param mode Swing mode name
*/
public async setSwingMode(mode: string): Promise<void> {
if (!this.capabilities.swingModes?.includes(mode)) {
throw new Error(`Swing mode ${mode} not supported`);
}
await this.protocolClient.setSwingMode(this.entityId, mode);
this._swingMode = mode;
this.emit('state:changed', this.getState());
}
/**
* Set aux heat
* @param enabled Whether aux heat is enabled
*/
public async setAuxHeat(enabled: boolean): Promise<void> {
if (!this.capabilities.supportsAuxHeat) {
throw new Error('Climate device does not support aux heat');
}
await this.protocolClient.setAuxHeat(this.entityId, enabled);
this._auxHeat = enabled;
this.emit('state:changed', this.getState());
}
/**
* Get current state as object
*/
public getState(): IClimateState {
return {
currentTemp: this._currentTemp,
targetTemp: this._targetTemp,
targetTempHigh: this._targetTempHigh,
targetTempLow: this._targetTempLow,
hvacMode: this._hvacMode,
hvacAction: this._hvacAction,
presetMode: this._presetMode,
fanMode: this._fanMode,
swingMode: this._swingMode,
humidity: this._humidity,
targetHumidity: this._targetHumidity,
auxHeat: this._auxHeat,
};
}
/**
* Refresh state from the device
*/
public async refreshState(): Promise<IClimateState> {
const state = await this.protocolClient.getState(this.entityId);
this.updateStateInternal(state);
return state;
}
/**
* Update state from external source
*/
public updateState(state: IClimateState): void {
this.updateStateInternal(state);
this.emit('state:changed', state);
}
/**
* Internal state update
*/
private updateStateInternal(state: IClimateState): void {
this._currentTemp = state.currentTemp;
this._targetTemp = state.targetTemp;
this._targetTempHigh = state.targetTempHigh;
this._targetTempLow = state.targetTempLow;
this._hvacMode = state.hvacMode;
this._hvacAction = state.hvacAction;
this._presetMode = state.presetMode;
this._fanMode = state.fanMode;
this._swingMode = state.swingMode;
this._humidity = state.humidity;
this._targetHumidity = state.targetHumidity;
this._auxHeat = state.auxHeat;
}
// ============================================================================
// Feature Info
// ============================================================================
public getFeatureInfo(): IClimateFeatureInfo {
return {
...this.getBaseFeatureInfo(),
type: 'climate',
protocol: this.protocol,
capabilities: this.capabilities,
currentState: this.getState(),
};
}
}

View File

@@ -0,0 +1,278 @@
/**
* Cover Feature
* Provides control for covers, blinds, garage doors, etc.
*/
import { Feature, type TDeviceReference } from './feature.abstract.js';
import type { IFeatureOptions } from '../interfaces/feature.interfaces.js';
import type {
TCoverProtocol,
TCoverDeviceClass,
TCoverState,
ICoverCapabilities,
ICoverStateInfo,
ICoverFeatureInfo,
ICoverProtocolClient,
} from '../interfaces/smarthome.interfaces.js';
/**
* Options for creating a CoverFeature
*/
export interface ICoverFeatureOptions extends IFeatureOptions {
/** Protocol type */
protocol: TCoverProtocol;
/** Entity ID (for Home Assistant) */
entityId: string;
/** Protocol client for controlling the cover */
protocolClient: ICoverProtocolClient;
/** Device class */
deviceClass?: TCoverDeviceClass;
/** Cover capabilities */
capabilities?: Partial<ICoverCapabilities>;
}
/**
* Cover Feature - control for blinds, garage doors, etc.
*
* Protocol-agnostic: works with Home Assistant, MQTT, Somfy, etc.
*
* @example
* ```typescript
* const cover = device.getFeature<CoverFeature>('cover');
* if (cover) {
* await cover.open();
* await cover.setPosition(50); // 50% open
* }
* ```
*/
export class CoverFeature extends Feature {
public readonly type = 'cover' as const;
public readonly protocol: TCoverProtocol;
/** Entity ID (e.g., "cover.garage_door") */
public readonly entityId: string;
/** Capabilities */
public readonly capabilities: ICoverCapabilities;
/** Current cover state (not connection state) */
protected _coverState: TCoverState = 'unknown';
protected _position?: number;
protected _tiltPosition?: number;
/** Protocol client for controlling the cover */
private protocolClient: ICoverProtocolClient;
constructor(
device: TDeviceReference,
port: number,
options: ICoverFeatureOptions
) {
super(device, port, options);
this.protocol = options.protocol;
this.entityId = options.entityId;
this.protocolClient = options.protocolClient;
this.capabilities = {
deviceClass: options.deviceClass,
supportsOpen: options.capabilities?.supportsOpen ?? true,
supportsClose: options.capabilities?.supportsClose ?? true,
supportsStop: options.capabilities?.supportsStop ?? true,
supportsPosition: options.capabilities?.supportsPosition ?? false,
supportsTilt: options.capabilities?.supportsTilt ?? false,
};
}
// ============================================================================
// Properties
// ============================================================================
/**
* Get current state (cached)
*/
public get coverState(): TCoverState {
return this._coverState;
}
/**
* Get current position 0-100 (cached)
* 0 = closed, 100 = fully open
*/
public get position(): number | undefined {
return this._position;
}
/**
* Get current tilt position 0-100 (cached)
*/
public get tiltPosition(): number | undefined {
return this._tiltPosition;
}
/**
* Check if cover is open
*/
public get isOpen(): boolean {
return this._coverState === 'open';
}
/**
* Check if cover is closed
*/
public get isClosed(): boolean {
return this._coverState === 'closed';
}
/**
* Check if cover is opening
*/
public get isOpening(): boolean {
return this._coverState === 'opening';
}
/**
* Check if cover is closing
*/
public get isClosing(): boolean {
return this._coverState === 'closing';
}
// ============================================================================
// Connection
// ============================================================================
protected async doConnect(): Promise<void> {
try {
const state = await this.protocolClient.getState(this.entityId);
this.updateStateInternal(state);
} catch {
// Ignore errors fetching initial state
}
}
protected async doDisconnect(): Promise<void> {
// Nothing to disconnect
}
// ============================================================================
// Cover Control
// ============================================================================
/**
* Open the cover
*/
public async open(): Promise<void> {
if (!this.capabilities.supportsOpen) {
throw new Error('Cover does not support open');
}
await this.protocolClient.open(this.entityId);
this._coverState = 'opening';
this.emit('state:changed', this.getState());
}
/**
* Close the cover
*/
public async close(): Promise<void> {
if (!this.capabilities.supportsClose) {
throw new Error('Cover does not support close');
}
await this.protocolClient.close(this.entityId);
this._coverState = 'closing';
this.emit('state:changed', this.getState());
}
/**
* Stop the cover
*/
public async stop(): Promise<void> {
if (!this.capabilities.supportsStop) {
throw new Error('Cover does not support stop');
}
await this.protocolClient.stop(this.entityId);
this._coverState = 'stopped';
this.emit('state:changed', this.getState());
}
/**
* Set cover position
* @param position Position 0-100 (0 = closed, 100 = open)
*/
public async setPosition(position: number): Promise<void> {
if (!this.capabilities.supportsPosition) {
throw new Error('Cover does not support position control');
}
const clamped = Math.max(0, Math.min(100, Math.round(position)));
await this.protocolClient.setPosition(this.entityId, clamped);
this._position = clamped;
this._coverState = clamped === 0 ? 'closed' : clamped === 100 ? 'open' : 'stopped';
this.emit('state:changed', this.getState());
}
/**
* Set tilt position
* @param position Tilt position 0-100
*/
public async setTiltPosition(position: number): Promise<void> {
if (!this.capabilities.supportsTilt) {
throw new Error('Cover does not support tilt control');
}
const clamped = Math.max(0, Math.min(100, Math.round(position)));
await this.protocolClient.setTiltPosition(this.entityId, clamped);
this._tiltPosition = clamped;
this.emit('state:changed', this.getState());
}
/**
* Get current state as object
*/
public getState(): ICoverStateInfo {
return {
state: this._coverState,
position: this._position,
tiltPosition: this._tiltPosition,
};
}
/**
* Refresh state from the device
*/
public async refreshState(): Promise<ICoverStateInfo> {
const state = await this.protocolClient.getState(this.entityId);
this.updateStateInternal(state);
return state;
}
/**
* Update state from external source
*/
public updateState(state: ICoverStateInfo): void {
this.updateStateInternal(state);
this.emit('state:changed', state);
}
/**
* Internal state update
*/
private updateStateInternal(state: ICoverStateInfo): void {
this._coverState = state.state;
this._position = state.position;
this._tiltPosition = state.tiltPosition;
}
// ============================================================================
// Feature Info
// ============================================================================
public getFeatureInfo(): ICoverFeatureInfo {
return {
...this.getBaseFeatureInfo(),
type: 'cover',
protocol: this.protocol,
capabilities: this.capabilities,
currentState: this.getState(),
};
}
}

296
ts/features/feature.fan.ts Normal file
View File

@@ -0,0 +1,296 @@
/**
* Fan Feature
* Provides control for fans (speed, oscillation, direction)
*/
import { Feature, type TDeviceReference } from './feature.abstract.js';
import type { IFeatureOptions } from '../interfaces/feature.interfaces.js';
import type {
TFanProtocol,
TFanDirection,
IFanCapabilities,
IFanState,
IFanFeatureInfo,
IFanProtocolClient,
} from '../interfaces/smarthome.interfaces.js';
/**
* Options for creating a FanFeature
*/
export interface IFanFeatureOptions extends IFeatureOptions {
/** Protocol type */
protocol: TFanProtocol;
/** Entity ID (for Home Assistant) */
entityId: string;
/** Protocol client for controlling the fan */
protocolClient: IFanProtocolClient;
/** Fan capabilities */
capabilities?: Partial<IFanCapabilities>;
}
/**
* Fan Feature - speed, oscillation, and direction control
*
* Protocol-agnostic: works with Home Assistant, MQTT, Bond, etc.
*
* @example
* ```typescript
* const fan = device.getFeature<FanFeature>('fan');
* if (fan) {
* await fan.turnOn(75); // 75% speed
* await fan.setOscillating(true);
* }
* ```
*/
export class FanFeature extends Feature {
public readonly type = 'fan' as const;
public readonly protocol: TFanProtocol;
/** Entity ID (e.g., "fan.bedroom") */
public readonly entityId: string;
/** Capabilities */
public readonly capabilities: IFanCapabilities;
/** Current state */
protected _isOn: boolean = false;
protected _percentage?: number;
protected _presetMode?: string;
protected _oscillating?: boolean;
protected _direction?: TFanDirection;
/** Protocol client for controlling the fan */
private protocolClient: IFanProtocolClient;
constructor(
device: TDeviceReference,
port: number,
options: IFanFeatureOptions
) {
super(device, port, options);
this.protocol = options.protocol;
this.entityId = options.entityId;
this.protocolClient = options.protocolClient;
this.capabilities = {
supportsSpeed: options.capabilities?.supportsSpeed ?? true,
supportsOscillate: options.capabilities?.supportsOscillate ?? false,
supportsDirection: options.capabilities?.supportsDirection ?? false,
supportsPresetModes: options.capabilities?.supportsPresetModes ?? false,
presetModes: options.capabilities?.presetModes,
speedCount: options.capabilities?.speedCount,
};
}
// ============================================================================
// Properties
// ============================================================================
/**
* Get current on/off state (cached)
*/
public get isOn(): boolean {
return this._isOn;
}
/**
* Get current speed percentage 0-100 (cached)
*/
public get percentage(): number | undefined {
return this._percentage;
}
/**
* Get current preset mode (cached)
*/
public get presetMode(): string | undefined {
return this._presetMode;
}
/**
* Get oscillating state (cached)
*/
public get oscillating(): boolean | undefined {
return this._oscillating;
}
/**
* Get direction (cached)
*/
public get direction(): TFanDirection | undefined {
return this._direction;
}
/**
* Get available preset modes
*/
public get presetModes(): string[] | undefined {
return this.capabilities.presetModes;
}
// ============================================================================
// Connection
// ============================================================================
protected async doConnect(): Promise<void> {
try {
const state = await this.protocolClient.getState(this.entityId);
this.updateStateInternal(state);
} catch {
// Ignore errors fetching initial state
}
}
protected async doDisconnect(): Promise<void> {
// Nothing to disconnect
}
// ============================================================================
// Fan Control
// ============================================================================
/**
* Turn on the fan
* @param percentage Optional speed percentage
*/
public async turnOn(percentage?: number): Promise<void> {
await this.protocolClient.turnOn(this.entityId, percentage);
this._isOn = true;
if (percentage !== undefined) {
this._percentage = percentage;
}
this.emit('state:changed', this.getState());
}
/**
* Turn off the fan
*/
public async turnOff(): Promise<void> {
await this.protocolClient.turnOff(this.entityId);
this._isOn = false;
this.emit('state:changed', this.getState());
}
/**
* Toggle the fan
*/
public async toggle(): Promise<void> {
await this.protocolClient.toggle(this.entityId);
this._isOn = !this._isOn;
this.emit('state:changed', this.getState());
}
/**
* Set speed percentage
* @param percentage Speed 0-100
*/
public async setPercentage(percentage: number): Promise<void> {
if (!this.capabilities.supportsSpeed) {
throw new Error('Fan does not support speed control');
}
const clamped = Math.max(0, Math.min(100, Math.round(percentage)));
await this.protocolClient.setPercentage(this.entityId, clamped);
this._percentage = clamped;
this._isOn = clamped > 0;
this.emit('state:changed', this.getState());
}
/**
* Set preset mode
* @param mode Preset mode name
*/
public async setPresetMode(mode: string): Promise<void> {
if (!this.capabilities.supportsPresetModes) {
throw new Error('Fan does not support preset modes');
}
await this.protocolClient.setPresetMode(this.entityId, mode);
this._presetMode = mode;
this._isOn = true;
this.emit('state:changed', this.getState());
}
/**
* Set oscillating state
* @param oscillating Whether to oscillate
*/
public async setOscillating(oscillating: boolean): Promise<void> {
if (!this.capabilities.supportsOscillate) {
throw new Error('Fan does not support oscillation');
}
await this.protocolClient.setOscillating(this.entityId, oscillating);
this._oscillating = oscillating;
this.emit('state:changed', this.getState());
}
/**
* Set direction
* @param direction forward or reverse
*/
public async setDirection(direction: TFanDirection): Promise<void> {
if (!this.capabilities.supportsDirection) {
throw new Error('Fan does not support direction control');
}
await this.protocolClient.setDirection(this.entityId, direction);
this._direction = direction;
this.emit('state:changed', this.getState());
}
/**
* Get current state as object
*/
public getState(): IFanState {
return {
isOn: this._isOn,
percentage: this._percentage,
presetMode: this._presetMode,
oscillating: this._oscillating,
direction: this._direction,
};
}
/**
* Refresh state from the device
*/
public async refreshState(): Promise<IFanState> {
const state = await this.protocolClient.getState(this.entityId);
this.updateStateInternal(state);
return state;
}
/**
* Update state from external source
*/
public updateState(state: IFanState): void {
this.updateStateInternal(state);
this.emit('state:changed', state);
}
/**
* Internal state update
*/
private updateStateInternal(state: IFanState): void {
this._isOn = state.isOn;
this._percentage = state.percentage;
this._presetMode = state.presetMode;
this._oscillating = state.oscillating;
this._direction = state.direction;
}
// ============================================================================
// Feature Info
// ============================================================================
public getFeatureInfo(): IFanFeatureInfo {
return {
...this.getBaseFeatureInfo(),
type: 'fan',
protocol: this.protocol,
capabilities: this.capabilities,
currentState: this.getState(),
};
}
}

View File

@@ -0,0 +1,369 @@
/**
* Light Feature
* Provides control for smart lights (brightness, color, effects)
*/
import { Feature, type TDeviceReference } from './feature.abstract.js';
import type { IFeatureOptions } from '../interfaces/feature.interfaces.js';
import type {
TLightProtocol,
ILightCapabilities,
ILightState,
ILightFeatureInfo,
ILightProtocolClient,
} from '../interfaces/smarthome.interfaces.js';
/**
* Options for creating a LightFeature
*/
export interface ILightFeatureOptions extends IFeatureOptions {
/** Protocol type */
protocol: TLightProtocol;
/** Entity ID (for Home Assistant) */
entityId: string;
/** Protocol client for controlling the light */
protocolClient: ILightProtocolClient;
/** Light capabilities */
capabilities?: Partial<ILightCapabilities>;
}
/**
* Light Feature - brightness, color, and effect control
*
* Protocol-agnostic: works with Home Assistant, Hue, MQTT, Zigbee, etc.
*
* @example
* ```typescript
* const light = device.getFeature<LightFeature>('light');
* if (light) {
* await light.turnOn({ brightness: 200 });
* await light.setColorTemp(4000); // 4000K warm white
* await light.setRgbColor(255, 100, 50);
* }
* ```
*/
export class LightFeature extends Feature {
public readonly type = 'light' as const;
public readonly protocol: TLightProtocol;
/** Entity ID (e.g., "light.living_room") */
public readonly entityId: string;
/** Capabilities */
public readonly capabilities: ILightCapabilities;
/** Current state */
protected _isOn: boolean = false;
protected _brightness?: number;
protected _colorTemp?: number;
protected _colorTempMireds?: number;
protected _rgbColor?: [number, number, number];
protected _hsColor?: [number, number];
protected _xyColor?: [number, number];
protected _effect?: string;
/** Protocol client for controlling the light */
private protocolClient: ILightProtocolClient;
constructor(
device: TDeviceReference,
port: number,
options: ILightFeatureOptions
) {
super(device, port, options);
this.protocol = options.protocol;
this.entityId = options.entityId;
this.protocolClient = options.protocolClient;
// Set capabilities with defaults
this.capabilities = {
supportsBrightness: options.capabilities?.supportsBrightness ?? false,
supportsColorTemp: options.capabilities?.supportsColorTemp ?? false,
supportsRgb: options.capabilities?.supportsRgb ?? false,
supportsHs: options.capabilities?.supportsHs ?? false,
supportsXy: options.capabilities?.supportsXy ?? false,
supportsEffects: options.capabilities?.supportsEffects ?? false,
supportsTransition: options.capabilities?.supportsTransition ?? true,
effects: options.capabilities?.effects,
minMireds: options.capabilities?.minMireds,
maxMireds: options.capabilities?.maxMireds,
minColorTempKelvin: options.capabilities?.minColorTempKelvin,
maxColorTempKelvin: options.capabilities?.maxColorTempKelvin,
};
}
// ============================================================================
// Properties
// ============================================================================
/**
* Get current on/off state (cached)
*/
public get isOn(): boolean {
return this._isOn;
}
/**
* Get current brightness 0-255 (cached)
*/
public get brightness(): number | undefined {
return this._brightness;
}
/**
* Get current color temperature in Kelvin (cached)
*/
public get colorTemp(): number | undefined {
return this._colorTemp;
}
/**
* Get current color temperature in Mireds (cached)
*/
public get colorTempMireds(): number | undefined {
return this._colorTempMireds;
}
/**
* Get current RGB color (cached)
*/
public get rgbColor(): [number, number, number] | undefined {
return this._rgbColor;
}
/**
* Get current HS color [hue 0-360, saturation 0-100] (cached)
*/
public get hsColor(): [number, number] | undefined {
return this._hsColor;
}
/**
* Get current XY color (cached)
*/
public get xyColor(): [number, number] | undefined {
return this._xyColor;
}
/**
* Get current effect (cached)
*/
public get effect(): string | undefined {
return this._effect;
}
/**
* Get available effects
*/
public get effects(): string[] | undefined {
return this.capabilities.effects;
}
// ============================================================================
// Connection
// ============================================================================
protected async doConnect(): Promise<void> {
// Fetch initial state
try {
const state = await this.protocolClient.getState(this.entityId);
this.updateStateInternal(state);
} catch {
// Ignore errors fetching initial state
}
}
protected async doDisconnect(): Promise<void> {
// Nothing to disconnect
}
// ============================================================================
// Light Control
// ============================================================================
/**
* Turn on the light
* @param options Optional settings to apply when turning on
*/
public async turnOn(options?: {
brightness?: number;
colorTemp?: number;
rgb?: [number, number, number];
transition?: number;
}): Promise<void> {
await this.protocolClient.turnOn(this.entityId, options);
this._isOn = true;
if (options?.brightness !== undefined) {
this._brightness = options.brightness;
}
if (options?.colorTemp !== undefined) {
this._colorTemp = options.colorTemp;
}
if (options?.rgb !== undefined) {
this._rgbColor = options.rgb;
}
this.emit('state:changed', this.getState());
}
/**
* Turn off the light
* @param options Optional transition time
*/
public async turnOff(options?: { transition?: number }): Promise<void> {
await this.protocolClient.turnOff(this.entityId, options);
this._isOn = false;
this.emit('state:changed', this.getState());
}
/**
* Toggle the light
*/
public async toggle(): Promise<void> {
await this.protocolClient.toggle(this.entityId);
this._isOn = !this._isOn;
this.emit('state:changed', this.getState());
}
/**
* Set brightness level
* @param brightness Brightness 0-255
* @param transition Optional transition time in seconds
*/
public async setBrightness(brightness: number, transition?: number): Promise<void> {
if (!this.capabilities.supportsBrightness) {
throw new Error('Light does not support brightness control');
}
const clamped = Math.max(0, Math.min(255, Math.round(brightness)));
await this.protocolClient.setBrightness(this.entityId, clamped, transition);
this._brightness = clamped;
if (clamped > 0) {
this._isOn = true;
}
this.emit('state:changed', this.getState());
}
/**
* Set color temperature in Kelvin
* @param kelvin Color temperature in Kelvin (e.g., 2700 warm, 6500 cool)
* @param transition Optional transition time in seconds
*/
public async setColorTemp(kelvin: number, transition?: number): Promise<void> {
if (!this.capabilities.supportsColorTemp) {
throw new Error('Light does not support color temperature');
}
// Clamp to supported range if available
let clamped = kelvin;
if (this.capabilities.minColorTempKelvin && this.capabilities.maxColorTempKelvin) {
clamped = Math.max(
this.capabilities.minColorTempKelvin,
Math.min(this.capabilities.maxColorTempKelvin, kelvin)
);
}
await this.protocolClient.setColorTemp(this.entityId, clamped, transition);
this._colorTemp = clamped;
this._colorTempMireds = Math.round(1000000 / clamped);
this._isOn = true;
this.emit('state:changed', this.getState());
}
/**
* Set RGB color
* @param r Red 0-255
* @param g Green 0-255
* @param b Blue 0-255
* @param transition Optional transition time in seconds
*/
public async setRgbColor(r: number, g: number, b: number, transition?: number): Promise<void> {
if (!this.capabilities.supportsRgb) {
throw new Error('Light does not support RGB color');
}
const clampedR = Math.max(0, Math.min(255, Math.round(r)));
const clampedG = Math.max(0, Math.min(255, Math.round(g)));
const clampedB = Math.max(0, Math.min(255, Math.round(b)));
await this.protocolClient.setRgbColor(this.entityId, clampedR, clampedG, clampedB, transition);
this._rgbColor = [clampedR, clampedG, clampedB];
this._isOn = true;
this.emit('state:changed', this.getState());
}
/**
* Set light effect
* @param effect Effect name from available effects
*/
public async setEffect(effect: string): Promise<void> {
if (!this.capabilities.supportsEffects) {
throw new Error('Light does not support effects');
}
await this.protocolClient.setEffect(this.entityId, effect);
this._effect = effect;
this._isOn = true;
this.emit('state:changed', this.getState());
}
/**
* Get current state as object
*/
public getState(): ILightState {
return {
isOn: this._isOn,
brightness: this._brightness,
colorTemp: this._colorTemp,
colorTempMireds: this._colorTempMireds,
rgbColor: this._rgbColor,
hsColor: this._hsColor,
xyColor: this._xyColor,
effect: this._effect,
};
}
/**
* Refresh state from the device
*/
public async refreshState(): Promise<ILightState> {
const state = await this.protocolClient.getState(this.entityId);
this.updateStateInternal(state);
return state;
}
/**
* Update state from external source (e.g., state change event)
*/
public updateState(state: ILightState): void {
this.updateStateInternal(state);
this.emit('state:changed', state);
}
/**
* Internal state update
*/
private updateStateInternal(state: ILightState): void {
this._isOn = state.isOn;
this._brightness = state.brightness;
this._colorTemp = state.colorTemp;
this._colorTempMireds = state.colorTempMireds;
this._rgbColor = state.rgbColor;
this._hsColor = state.hsColor;
this._xyColor = state.xyColor;
this._effect = state.effect;
}
// ============================================================================
// Feature Info
// ============================================================================
public getFeatureInfo(): ILightFeatureInfo {
return {
...this.getBaseFeatureInfo(),
type: 'light',
protocol: this.protocol,
capabilities: this.capabilities,
currentState: this.getState(),
};
}
}

223
ts/features/feature.lock.ts Normal file
View File

@@ -0,0 +1,223 @@
/**
* Lock Feature
* Provides control for smart locks
*/
import { Feature, type TDeviceReference } from './feature.abstract.js';
import type { IFeatureOptions } from '../interfaces/feature.interfaces.js';
import type {
TLockProtocol,
TLockState,
ILockCapabilities,
ILockStateInfo,
ILockFeatureInfo,
ILockProtocolClient,
} from '../interfaces/smarthome.interfaces.js';
/**
* Options for creating a LockFeature
*/
export interface ILockFeatureOptions extends IFeatureOptions {
/** Protocol type */
protocol: TLockProtocol;
/** Entity ID (for Home Assistant) */
entityId: string;
/** Protocol client for controlling the lock */
protocolClient: ILockProtocolClient;
/** Whether the lock supports physical open */
supportsOpen?: boolean;
}
/**
* Lock Feature - control for smart locks
*
* Protocol-agnostic: works with Home Assistant, MQTT, August, Yale, etc.
*
* @example
* ```typescript
* const lock = device.getFeature<LockFeature>('lock');
* if (lock) {
* await lock.lock();
* console.log(`Lock is ${lock.isLocked ? 'locked' : 'unlocked'}`);
* }
* ```
*/
export class LockFeature extends Feature {
public readonly type = 'lock' as const;
public readonly protocol: TLockProtocol;
/** Entity ID (e.g., "lock.front_door") */
public readonly entityId: string;
/** Capabilities */
public readonly capabilities: ILockCapabilities;
/** Current state */
protected _lockState: TLockState = 'unknown';
protected _isLocked: boolean = false;
/** Protocol client for controlling the lock */
private protocolClient: ILockProtocolClient;
constructor(
device: TDeviceReference,
port: number,
options: ILockFeatureOptions
) {
super(device, port, options);
this.protocol = options.protocol;
this.entityId = options.entityId;
this.protocolClient = options.protocolClient;
this.capabilities = {
supportsOpen: options.supportsOpen ?? false,
};
}
// ============================================================================
// Properties
// ============================================================================
/**
* Get current lock state (cached)
*/
public get lockState(): TLockState {
return this._lockState;
}
/**
* Check if locked (cached)
*/
public get isLocked(): boolean {
return this._isLocked;
}
/**
* Check if unlocked
*/
public get isUnlocked(): boolean {
return this._lockState === 'unlocked';
}
/**
* Check if currently locking
*/
public get isLocking(): boolean {
return this._lockState === 'locking';
}
/**
* Check if currently unlocking
*/
public get isUnlocking(): boolean {
return this._lockState === 'unlocking';
}
/**
* Check if jammed
*/
public get isJammed(): boolean {
return this._lockState === 'jammed';
}
// ============================================================================
// Connection
// ============================================================================
protected async doConnect(): Promise<void> {
try {
const state = await this.protocolClient.getState(this.entityId);
this.updateStateInternal(state);
} catch {
// Ignore errors fetching initial state
}
}
protected async doDisconnect(): Promise<void> {
// Nothing to disconnect
}
// ============================================================================
// Lock Control
// ============================================================================
/**
* Lock the lock
*/
public async lock(): Promise<void> {
await this.protocolClient.lock(this.entityId);
this._lockState = 'locking';
this.emit('state:changed', this.getState());
}
/**
* Unlock the lock
*/
public async unlock(): Promise<void> {
await this.protocolClient.unlock(this.entityId);
this._lockState = 'unlocking';
this.emit('state:changed', this.getState());
}
/**
* Open the lock (physically open the door if supported)
*/
public async open(): Promise<void> {
if (!this.capabilities.supportsOpen) {
throw new Error('Lock does not support physical open');
}
await this.protocolClient.open(this.entityId);
this._lockState = 'unlocked';
this._isLocked = false;
this.emit('state:changed', this.getState());
}
/**
* Get current state as object
*/
public getState(): ILockStateInfo {
return {
state: this._lockState,
isLocked: this._isLocked,
};
}
/**
* Refresh state from the device
*/
public async refreshState(): Promise<ILockStateInfo> {
const state = await this.protocolClient.getState(this.entityId);
this.updateStateInternal(state);
return state;
}
/**
* Update state from external source
*/
public updateState(state: ILockStateInfo): void {
this.updateStateInternal(state);
this.emit('state:changed', state);
}
/**
* Internal state update
*/
private updateStateInternal(state: ILockStateInfo): void {
this._lockState = state.state;
this._isLocked = state.isLocked;
}
// ============================================================================
// Feature Info
// ============================================================================
public getFeatureInfo(): ILockFeatureInfo {
return {
...this.getBaseFeatureInfo(),
type: 'lock',
protocol: this.protocol,
capabilities: this.capabilities,
currentState: this.getState(),
};
}
}

View File

@@ -4,7 +4,7 @@
*/
import { Feature, type TDeviceReference } from './feature.abstract.js';
import { IppProtocol } from '../protocols/index.js';
import { IppProtocol, type IIppPrinterCapabilities, type IIppJob } from '../protocols/index.js';
import type {
TPrintProtocol,
TPrintSides,
@@ -16,7 +16,6 @@ import type {
IPrintFeatureInfo,
IFeatureOptions,
} from '../interfaces/feature.interfaces.js';
import type { IPrinterCapabilities } from '../interfaces/index.js';
/**
* Options for creating a PrintFeature
@@ -101,7 +100,7 @@ export class PrintFeature extends Feature {
this.ippClient = new IppProtocol(address, port, path);
// Verify connection by getting printer attributes
const attrs = await this.ippClient.getAttributes();
const attrs = await this.ippClient.getPrinterAttributes();
this.updateCapabilitiesFromIpp(attrs);
}
// JetDirect and LPD don't need connection verification
@@ -154,7 +153,8 @@ export class PrintFeature extends Feature {
throw new Error('Print feature not connected');
}
return this.ippClient.getJobs();
const jobs = await this.ippClient.getJobs();
return jobs.map(job => this.mapIppJobToInternal(job));
}
/**
@@ -165,7 +165,8 @@ export class PrintFeature extends Feature {
throw new Error('Print feature not connected');
}
return this.ippClient.getJobInfo(jobId);
const job = await this.ippClient.getJobAttributes(jobId);
return this.mapIppJobToInternal(job);
}
/**
@@ -191,9 +192,17 @@ export class PrintFeature extends Feature {
this.emit('print:started', options);
// IppProtocol.print() accepts IPrintOptions and returns IPrintJob
const job = await this.ippClient.print(data, options);
// Use smartPrint for auto format detection and conversion
const ippJob = await this.ippClient.smartPrint(data, {
jobName: options?.jobName,
copies: options?.copies,
media: options?.mediaSize,
sides: options?.sides,
printQuality: options?.quality,
colorMode: options?.colorMode,
});
const job = this.mapIppJobToInternal(ippJob);
this.emit('print:submitted', job);
return job;
}
@@ -202,58 +211,57 @@ export class PrintFeature extends Feature {
// Helper Methods
// ============================================================================
private updateCapabilitiesFromIpp(caps: IPrinterCapabilities): void {
private updateCapabilitiesFromIpp(caps: IIppPrinterCapabilities): void {
this.supportsColor = caps.colorSupported;
this.supportsDuplex = caps.duplexSupported;
this.maxCopies = caps.maxCopies;
// Derive duplexSupported from sidesSupported
this.supportsDuplex = caps.sidesSupported?.some(s =>
s.includes('two-sided')
) ?? false;
// Get max copies from range
this.maxCopies = caps.copiesSupported?.upper ?? 99;
if (caps.mediaSizes && caps.mediaSizes.length > 0) {
this.supportedMediaSizes = caps.mediaSizes;
if (caps.mediaSizeSupported && caps.mediaSizeSupported.length > 0) {
this.supportedMediaSizes = caps.mediaSizeSupported;
}
if (caps.mediaTypes && caps.mediaTypes.length > 0) {
this.supportedMediaTypes = caps.mediaTypes;
if (caps.mediaTypeSupported && caps.mediaTypeSupported.length > 0) {
this.supportedMediaTypes = caps.mediaTypeSupported;
}
if (caps.sidesSupported && caps.sidesSupported.length > 0) {
this.supportedSides = caps.sidesSupported.filter((s): s is TPrintSides =>
['one-sided', 'two-sided-long-edge', 'two-sided-short-edge'].includes(s)
);
}
if (caps.qualitySupported && caps.qualitySupported.length > 0) {
this.supportedQualities = caps.qualitySupported.filter((q): q is TPrintQuality =>
['draft', 'normal', 'high'].includes(q)
);
// Map IPP quality values (3=draft, 4=normal, 5=high) to strings
if (caps.printQualitySupported && caps.printQualitySupported.length > 0) {
const qualityMap: Record<number, TPrintQuality> = { 3: 'draft', 4: 'normal', 5: 'high' };
this.supportedQualities = caps.printQualitySupported
.map(q => qualityMap[q])
.filter((q): q is TPrintQuality => q !== undefined);
}
}
private qualityToIpp(quality: TPrintQuality): number {
switch (quality) {
case 'draft': return 3;
case 'normal': return 4;
case 'high': return 5;
default: return 4;
}
}
private mapIppJob(job: Record<string, unknown>): IPrintJob {
const stateMap: Record<number, IPrintJob['state']> = {
3: 'pending',
4: 'pending',
5: 'processing',
6: 'processing',
7: 'canceled',
8: 'aborted',
9: 'completed',
/**
* Map IIppJob to IPrintJob, normalizing extended states
*/
private mapIppJobToInternal(job: IIppJob): IPrintJob {
// Map extended IPP states to simpler internal states
const stateMap: Record<IIppJob['state'], IPrintJob['state']> = {
'pending': 'pending',
'pending-held': 'pending',
'processing': 'processing',
'processing-stopped': 'processing',
'canceled': 'canceled',
'aborted': 'aborted',
'completed': 'completed',
};
return {
id: job['job-id'] as number,
name: job['job-name'] as string ?? 'Unknown',
state: stateMap[(job['job-state'] as number) ?? 3] ?? 'pending',
stateReason: (job['job-state-reasons'] as string[])?.[0],
createdAt: new Date((job['time-at-creation'] as number) * 1000),
completedAt: job['time-at-completed']
? new Date((job['time-at-completed'] as number) * 1000)
: undefined,
id: job.id,
name: job.name,
state: stateMap[job.state],
stateReason: job.stateReasons?.[0],
createdAt: job.createdAt,
completedAt: job.completedAt,
};
}

View File

@@ -0,0 +1,202 @@
/**
* Sensor Feature
* Provides read-only state for sensors (temperature, humidity, power, etc.)
*/
import { Feature, type TDeviceReference } from './feature.abstract.js';
import type { IFeatureOptions } from '../interfaces/feature.interfaces.js';
import type {
TSensorProtocol,
TSensorDeviceClass,
TSensorStateClass,
ISensorCapabilities,
ISensorState,
ISensorFeatureInfo,
ISensorProtocolClient,
} from '../interfaces/smarthome.interfaces.js';
/**
* Options for creating a SensorFeature
*/
export interface ISensorFeatureOptions extends IFeatureOptions {
/** Protocol type */
protocol: TSensorProtocol;
/** Entity ID (for Home Assistant) */
entityId: string;
/** Protocol client for reading sensor state */
protocolClient: ISensorProtocolClient;
/** Device class (temperature, humidity, etc.) */
deviceClass?: TSensorDeviceClass;
/** State class (measurement, total, etc.) */
stateClass?: TSensorStateClass;
/** Unit of measurement */
unit?: string;
/** Precision (decimal places) */
precision?: number;
}
/**
* Sensor Feature - read-only state values
*
* Protocol-agnostic: works with Home Assistant, MQTT, SNMP, etc.
*
* @example
* ```typescript
* const sensor = device.getFeature<SensorFeature>('sensor');
* if (sensor) {
* await sensor.refreshState();
* console.log(`Temperature: ${sensor.value} ${sensor.unit}`);
* }
* ```
*/
export class SensorFeature extends Feature {
public readonly type = 'sensor' as const;
public readonly protocol: TSensorProtocol;
/** Entity ID (e.g., "sensor.living_room_temperature") */
public readonly entityId: string;
/** Capabilities */
public readonly capabilities: ISensorCapabilities;
/** Current state */
protected _value: string | number | boolean = '';
protected _numericValue?: number;
protected _unit?: string;
protected _lastUpdated: Date = new Date();
/** Protocol client for reading sensor state */
private protocolClient: ISensorProtocolClient;
constructor(
device: TDeviceReference,
port: number,
options: ISensorFeatureOptions
) {
super(device, port, options);
this.protocol = options.protocol;
this.entityId = options.entityId;
this.protocolClient = options.protocolClient;
this.capabilities = {
deviceClass: options.deviceClass,
stateClass: options.stateClass,
unit: options.unit,
precision: options.precision,
};
this._unit = options.unit;
}
// ============================================================================
// Properties
// ============================================================================
/**
* Get current value (cached)
*/
public get value(): string | number | boolean {
return this._value;
}
/**
* Get numeric value if available (cached)
*/
public get numericValue(): number | undefined {
return this._numericValue;
}
/**
* Get unit of measurement
*/
public get unit(): string | undefined {
return this._unit;
}
/**
* Get device class
*/
public get deviceClass(): TSensorDeviceClass | undefined {
return this.capabilities.deviceClass;
}
/**
* Get state class
*/
public get stateClass(): TSensorStateClass | undefined {
return this.capabilities.stateClass;
}
/**
* Get last updated timestamp
*/
public get lastUpdated(): Date {
return this._lastUpdated;
}
// ============================================================================
// Connection
// ============================================================================
protected async doConnect(): Promise<void> {
// Fetch initial state
try {
const state = await this.protocolClient.getState(this.entityId);
this.updateStateInternal(state);
} catch {
// Ignore errors fetching initial state
}
}
protected async doDisconnect(): Promise<void> {
// Nothing to disconnect
}
// ============================================================================
// Sensor Reading
// ============================================================================
/**
* Refresh state from the device
*/
public async refreshState(): Promise<ISensorState> {
const state = await this.protocolClient.getState(this.entityId);
this.updateStateInternal(state);
return state;
}
/**
* Update state from external source (e.g., state change event)
*/
public updateState(state: ISensorState): void {
this.updateStateInternal(state);
this.emit('state:changed', state);
}
/**
* Internal state update
*/
private updateStateInternal(state: ISensorState): void {
this._value = state.value;
this._numericValue = state.numericValue;
this._unit = state.unit || this._unit;
this._lastUpdated = state.lastUpdated;
}
// ============================================================================
// Feature Info
// ============================================================================
public getFeatureInfo(): ISensorFeatureInfo {
return {
...this.getBaseFeatureInfo(),
type: 'sensor',
protocol: this.protocol,
capabilities: this.capabilities,
currentState: {
value: this._value,
numericValue: this._numericValue,
unit: this._unit,
lastUpdated: this._lastUpdated,
},
};
}
}

View File

@@ -0,0 +1,170 @@
/**
* Switch Feature
* Provides binary on/off control for smart switches, outlets, etc.
*/
import { Feature, type TDeviceReference } from './feature.abstract.js';
import type { IFeatureOptions } from '../interfaces/feature.interfaces.js';
import type {
TSwitchProtocol,
ISwitchCapabilities,
ISwitchState,
ISwitchFeatureInfo,
ISwitchProtocolClient,
} from '../interfaces/smarthome.interfaces.js';
/**
* Options for creating a SwitchFeature
*/
export interface ISwitchFeatureOptions extends IFeatureOptions {
/** Protocol type */
protocol: TSwitchProtocol;
/** Entity ID (for Home Assistant) */
entityId: string;
/** Protocol client for controlling the switch */
protocolClient: ISwitchProtocolClient;
/** Device class */
deviceClass?: 'outlet' | 'switch';
}
/**
* Switch Feature - binary on/off control
*
* Protocol-agnostic: works with Home Assistant, MQTT, Tasmota, Tuya, etc.
*
* @example
* ```typescript
* const sw = device.getFeature<SwitchFeature>('switch');
* if (sw) {
* await sw.turnOn();
* await sw.toggle();
* console.log(`Switch is ${sw.isOn ? 'on' : 'off'}`);
* }
* ```
*/
export class SwitchFeature extends Feature {
public readonly type = 'switch' as const;
public readonly protocol: TSwitchProtocol;
/** Entity ID (e.g., "switch.living_room") */
public readonly entityId: string;
/** Capabilities */
public readonly capabilities: ISwitchCapabilities;
/** Current state */
protected _isOn: boolean = false;
/** Protocol client for controlling the switch */
private protocolClient: ISwitchProtocolClient;
constructor(
device: TDeviceReference,
port: number,
options: ISwitchFeatureOptions
) {
super(device, port, options);
this.protocol = options.protocol;
this.entityId = options.entityId;
this.protocolClient = options.protocolClient;
this.capabilities = {
deviceClass: options.deviceClass,
};
}
// ============================================================================
// Properties
// ============================================================================
/**
* Get current on/off state (cached)
*/
public get isOn(): boolean {
return this._isOn;
}
// ============================================================================
// Connection
// ============================================================================
protected async doConnect(): Promise<void> {
// Fetch initial state
try {
const state = await this.protocolClient.getState(this.entityId);
this._isOn = state.isOn;
} catch {
// Ignore errors fetching initial state
}
}
protected async doDisconnect(): Promise<void> {
// Nothing to disconnect
}
// ============================================================================
// Switch Control
// ============================================================================
/**
* Turn on the switch
*/
public async turnOn(): Promise<void> {
await this.protocolClient.turnOn(this.entityId);
this._isOn = true;
this.emit('state:changed', { isOn: true });
}
/**
* Turn off the switch
*/
public async turnOff(): Promise<void> {
await this.protocolClient.turnOff(this.entityId);
this._isOn = false;
this.emit('state:changed', { isOn: false });
}
/**
* Toggle the switch
*/
public async toggle(): Promise<void> {
await this.protocolClient.toggle(this.entityId);
this._isOn = !this._isOn;
this.emit('state:changed', { isOn: this._isOn });
}
/**
* Refresh state from the device
*/
public async refreshState(): Promise<ISwitchState> {
const state = await this.protocolClient.getState(this.entityId);
this._isOn = state.isOn;
return state;
}
/**
* Update state from external source (e.g., state change event)
*/
public updateState(state: ISwitchState): void {
const changed = this._isOn !== state.isOn;
this._isOn = state.isOn;
if (changed) {
this.emit('state:changed', state);
}
}
// ============================================================================
// Feature Info
// ============================================================================
public getFeatureInfo(): ISwitchFeatureInfo {
return {
...this.getBaseFeatureInfo(),
type: 'switch',
protocol: this.protocol,
capabilities: this.capabilities,
currentState: {
isOn: this._isOn,
},
};
}
}

View File

@@ -6,10 +6,20 @@
// Abstract base
export { Feature, type TDeviceReference } from './feature.abstract.js';
// Concrete features
// Concrete features - Document/Infrastructure
export { ScanFeature, type IScanFeatureOptions } from './feature.scan.js';
export { PrintFeature, type IPrintFeatureOptions } from './feature.print.js';
export { PlaybackFeature, type IPlaybackFeatureOptions } from './feature.playback.js';
export { VolumeFeature, type IVolumeFeatureOptions, type IVolumeController } from './feature.volume.js';
export { PowerFeature, type IPowerFeatureOptions } from './feature.power.js';
export { SnmpFeature, type ISnmpFeatureOptions } from './feature.snmp.js';
// Smart Home Features (protocol-agnostic: home-assistant, hue, mqtt, etc.)
export { SwitchFeature, type ISwitchFeatureOptions } from './feature.switch.js';
export { SensorFeature, type ISensorFeatureOptions } from './feature.sensor.js';
export { LightFeature, type ILightFeatureOptions } from './feature.light.js';
export { CoverFeature, type ICoverFeatureOptions } from './feature.cover.js';
export { LockFeature, type ILockFeatureOptions } from './feature.lock.js';
export { FanFeature, type IFanFeatureOptions } from './feature.fan.js';
export { ClimateFeature, type IClimateFeatureOptions } from './feature.climate.js';
export { CameraFeature, type ICameraFeatureOptions } from './feature.camera.js';

View File

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

View File

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

View File

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

View File

@@ -371,8 +371,51 @@ export type TNetworkScannerEvents = {
'cancelled': () => void;
};
// ============================================================================
// Device Selector Interface
// ============================================================================
import type { TFeatureType } from './feature.interfaces.js';
/**
* Criteria for selecting devices from the manager.
* Identity selectors (id, address) match exactly.
* Attribute selectors (name, model, manufacturer) match partially (case-insensitive).
* Capability selectors filter by feature availability.
*/
export interface IDeviceSelector {
/** Exact match on device ID */
id?: string;
/** Exact match on IP address */
address?: string;
/** Partial match on device name (case-insensitive) */
name?: string;
/** Partial match on device model (case-insensitive) */
model?: string;
/** Partial match on manufacturer name (case-insensitive) */
manufacturer?: string;
/** Device must have this feature */
hasFeature?: TFeatureType;
/** Device must have ALL of these features */
hasFeatures?: TFeatureType[];
/** Device must have ANY of these features */
hasAnyFeature?: TFeatureType[];
}
// ============================================================================
// Feature Types (Universal Device Architecture)
// ============================================================================
export * from './feature.interfaces.js';
// ============================================================================
// Smart Home Types (Generic, Protocol-agnostic)
// ============================================================================
export * from './smarthome.interfaces.js';
// ============================================================================
// Home Assistant Specific Types
// ============================================================================
export * from './homeassistant.interfaces.js';

View File

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

View File

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

View File

@@ -10,7 +10,12 @@ export { EsclProtocol } from './protocol.escl.js';
export { SaneProtocol } from './protocol.sane.js';
// IPP printer protocol
export { IppProtocol } from './protocol.ipp.js';
export {
IppProtocol,
type IIppPrinterCapabilities,
type IIppJob,
type IIppPrintOptions,
} from './protocol.ipp.js';
// SNMP query protocol
export {
@@ -54,3 +59,6 @@ export {
type TUpsTestResult,
type IUpsSnmpStatus,
} from './protocol.upssnmp.js';
// Home Assistant WebSocket protocol
export { HomeAssistantProtocol } from './protocol.homeassistant.js';

View File

@@ -142,13 +142,28 @@ export class EsclProtocol {
/**
* Wait for scan job to complete and download the result
*
* Note: Many scanners (including Brother) don't start scanning until
* NextDocument is requested. The request triggers the scan and blocks
* until complete. We try direct download first, then fall back to polling.
*/
public async waitForScanComplete(
jobUri: string,
options: IScanOptions,
pollInterval: number = 500
): Promise<IScanResult> {
// Poll until job is complete
// Try direct download first - this triggers the scan on many scanners
// (including Brother) and blocks until the scan completes
try {
const result = await this.downloadScan(jobUri, options);
if (result.data.length > 0) {
return result;
}
} catch (err) {
// Direct download failed, fall back to polling
}
// Fall back to polling for scanners that need it
let attempts = 0;
const maxAttempts = 120; // 60 seconds max

View File

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

File diff suppressed because it is too large Load Diff