Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| be993bf667 | |||
| 1cc8c48315 | |||
| 3377053ef4 | |||
| d7240696a8 | |||
| 2e82ec1884 | |||
| 79b05d47aa | |||
| e424085000 | |||
| 82a99cdfb8 | |||
| d72ea96ec5 | |||
| 38a6e5c250 | |||
| 7bcec69658 | |||
| 4b61ed31bc | |||
| de34e83b3d | |||
| 181c4f5d5d |
67
changelog.md
67
changelog.md
@@ -1,5 +1,72 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
|
||||
- Add TNameSource type and NAME_SOURCE_PRIORITY to rank name sources (generic, manual, airplay, chromecast, mdns, dlna, sonos).
|
||||
- Replace chooseBestName with shouldUpdateName that validates 'real' names and uses source priority when deciding to update a device name.
|
||||
- Add nameSourceByIp map to track which discovery source provided the current name and persist updates during registration.
|
||||
- Register devices with an explicit nameSource (e.g. 'mdns', 'dlna', 'sonos', 'manual') and map speaker protocols to appropriate name sources.
|
||||
- Ensure manual additions use 'manual' source and non-real names default to 'generic'.
|
||||
- Clear nameSourceByIp entries when devices are removed/disconnected and on shutdown.
|
||||
|
||||
## 2026-01-09 - 2.0.0 - BREAKING CHANGE(core)
|
||||
rework core device architecture: consolidate protocols into a protocols/ module, introduce UniversalDevice + factories, and remove many legacy device-specific classes (breaking API changes)
|
||||
|
||||
- Consolidated protocol implementations into ts/protocols and added protocols/index.ts for unified exports.
|
||||
- Added device factory layer at ts/factories/index.ts to create UniversalDevice instances with appropriate features.
|
||||
- Introduced protocols/protocol.upssnmp.ts (UPS SNMP handler) and other protocol reorganizations.
|
||||
- Removed legacy concrete device classes and related files (Device abstract, Scanner, Printer, SnmpDevice, UpsDevice, DlnaRenderer/Server, Speaker and Sonos/AirPlay/Chromecast implementations).
|
||||
- Updated top-level ts/index.ts exports to prefer UniversalDevice, factories and the new protocols module.
|
||||
- Updated feature and discovery modules to import protocols from the new protocols index (import path changes).
|
||||
- BREAKING: Consumers must update imports and device creation flows to use the new factories/UniversalDevice and protocols exports instead of the removed legacy classes.
|
||||
|
||||
## 2026-01-09 - 1.1.0 - feat(devicemanager)
|
||||
Introduce a UniversalDevice architecture with composable Feature system; add extensive new device/protocol support and discovery/refactors
|
||||
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ecobridge.xyz/devicemanager",
|
||||
"version": "1.1.0",
|
||||
"version": "3.0.1",
|
||||
"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
6
pnpm-lock.yaml
generated
@@ -47,6 +47,9 @@ importers:
|
||||
sonos:
|
||||
specifier: ^1.14.2
|
||||
version: 1.14.2
|
||||
ws:
|
||||
specifier: ^8.19.0
|
||||
version: 8.19.0
|
||||
devDependencies:
|
||||
'@git.zone/tsbuild':
|
||||
specifier: ^4.1.0
|
||||
@@ -60,6 +63,9 @@ importers:
|
||||
'@types/node':
|
||||
specifier: ^25.0.3
|
||||
version: 25.0.3
|
||||
'@types/ws':
|
||||
specifier: ^8.18.1
|
||||
version: 8.18.1
|
||||
|
||||
packages:
|
||||
|
||||
|
||||
107
readme.hints.md
107
readme.hints.md
@@ -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
625
readme.md
@@ -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. 🔌
|
||||
|
||||
[](https://www.npmjs.com/package/@ecobridge.xyz/devicemanager)
|
||||
[](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.
|
||||
|
||||
136
test/test.ts
136
test/test.ts
@@ -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();
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@ecobridge.xyz/devicemanager',
|
||||
version: '1.1.0',
|
||||
version: '3.0.1',
|
||||
description: 'a device manager for talking to devices on network and over usb'
|
||||
}
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type {
|
||||
IDeviceInfo,
|
||||
TDeviceType,
|
||||
TDeviceStatus,
|
||||
TConnectionState,
|
||||
IRetryOptions,
|
||||
} from '../interfaces/index.js';
|
||||
import { withRetry } from '../helpers/helpers.retry.js';
|
||||
|
||||
/**
|
||||
* Abstract base class for all devices (scanners, printers)
|
||||
*/
|
||||
export abstract class Device extends plugins.events.EventEmitter {
|
||||
public readonly id: string;
|
||||
public readonly name: string;
|
||||
public readonly type: TDeviceType;
|
||||
public readonly address: string;
|
||||
public readonly port: number;
|
||||
|
||||
protected _status: TDeviceStatus = 'unknown';
|
||||
protected _connectionState: TConnectionState = 'disconnected';
|
||||
protected _lastError: Error | null = null;
|
||||
|
||||
public manufacturer?: string;
|
||||
public model?: string;
|
||||
public serialNumber?: string;
|
||||
public firmwareVersion?: string;
|
||||
|
||||
protected retryOptions: IRetryOptions;
|
||||
|
||||
constructor(info: IDeviceInfo, retryOptions?: IRetryOptions) {
|
||||
super();
|
||||
this.id = info.id;
|
||||
this.name = info.name;
|
||||
this.type = info.type;
|
||||
this.address = info.address;
|
||||
this.port = info.port;
|
||||
this._status = info.status;
|
||||
this.manufacturer = info.manufacturer;
|
||||
this.model = info.model;
|
||||
this.serialNumber = info.serialNumber;
|
||||
this.firmwareVersion = info.firmwareVersion;
|
||||
|
||||
this.retryOptions = retryOptions ?? {
|
||||
maxRetries: 5,
|
||||
baseDelay: 1000,
|
||||
maxDelay: 16000,
|
||||
multiplier: 2,
|
||||
jitter: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current device status
|
||||
*/
|
||||
public get status(): TDeviceStatus {
|
||||
return this._status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current connection state
|
||||
*/
|
||||
public get connectionState(): TConnectionState {
|
||||
return this._connectionState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last error if any
|
||||
*/
|
||||
public get lastError(): Error | null {
|
||||
return this._lastError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if device is connected
|
||||
*/
|
||||
public get isConnected(): boolean {
|
||||
return this._connectionState === 'connected';
|
||||
}
|
||||
|
||||
/**
|
||||
* Update device status
|
||||
*/
|
||||
protected setStatus(status: TDeviceStatus): void {
|
||||
if (this._status !== status) {
|
||||
const oldStatus = this._status;
|
||||
this._status = status;
|
||||
this.emit('status:changed', { oldStatus, newStatus: status });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update connection state
|
||||
*/
|
||||
protected setConnectionState(state: TConnectionState): void {
|
||||
if (this._connectionState !== state) {
|
||||
const oldState = this._connectionState;
|
||||
this._connectionState = state;
|
||||
this.emit('connection:changed', { oldState, newState: state });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set error state
|
||||
*/
|
||||
protected setError(error: Error): void {
|
||||
this._lastError = error;
|
||||
this.setStatus('error');
|
||||
this.emit('error', error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear error state
|
||||
*/
|
||||
protected clearError(): void {
|
||||
this._lastError = null;
|
||||
if (this._status === 'error') {
|
||||
this.setStatus('online');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an operation with retry logic
|
||||
*/
|
||||
protected async withRetry<T>(fn: () => Promise<T>): Promise<T> {
|
||||
return withRetry(fn, this.retryOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the device
|
||||
*/
|
||||
public async connect(): Promise<void> {
|
||||
if (this.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setConnectionState('connecting');
|
||||
this.clearError();
|
||||
|
||||
try {
|
||||
await this.withRetry(() => this.doConnect());
|
||||
this.setConnectionState('connected');
|
||||
this.setStatus('online');
|
||||
} catch (error) {
|
||||
this.setConnectionState('error');
|
||||
this.setError(error instanceof Error ? error : new Error(String(error)));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from the device
|
||||
*/
|
||||
public async disconnect(): Promise<void> {
|
||||
if (this._connectionState === 'disconnected') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.doDisconnect();
|
||||
} finally {
|
||||
this.setConnectionState('disconnected');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get device info as plain object
|
||||
*/
|
||||
public getInfo(): IDeviceInfo {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
type: this.type,
|
||||
address: this.address,
|
||||
port: this.port,
|
||||
status: this._status,
|
||||
manufacturer: this.manufacturer,
|
||||
model: this.model,
|
||||
serialNumber: this.serialNumber,
|
||||
firmwareVersion: this.firmwareVersion,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation-specific connect logic
|
||||
* Override in subclasses
|
||||
*/
|
||||
protected abstract doConnect(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Implementation-specific disconnect logic
|
||||
* Override in subclasses
|
||||
*/
|
||||
protected abstract doDisconnect(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Refresh device status
|
||||
* Override in subclasses
|
||||
*/
|
||||
public abstract refreshStatus(): Promise<void>;
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
376
ts/discovery/discovery.classes.homeassistant.ts
Normal file
376
ts/discovery/discovery.classes.homeassistant.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type {
|
||||
IHomeAssistantInstanceConfig,
|
||||
IHomeAssistantEntity,
|
||||
IHomeAssistantDiscoveredInstance,
|
||||
THomeAssistantDomain,
|
||||
THomeAssistantDiscoveryEvents,
|
||||
} from '../interfaces/homeassistant.interfaces.js';
|
||||
import { HomeAssistantProtocol } from '../protocols/protocol.homeassistant.js';
|
||||
|
||||
/**
|
||||
* mDNS service type for Home Assistant discovery
|
||||
*/
|
||||
const HA_SERVICE_TYPE = '_home-assistant._tcp';
|
||||
|
||||
/**
|
||||
* Default domains to discover
|
||||
*/
|
||||
const DEFAULT_DOMAINS: THomeAssistantDomain[] = [
|
||||
'light',
|
||||
'switch',
|
||||
'sensor',
|
||||
'binary_sensor',
|
||||
'climate',
|
||||
'fan',
|
||||
'cover',
|
||||
'lock',
|
||||
'camera',
|
||||
'media_player',
|
||||
];
|
||||
|
||||
/**
|
||||
* Home Assistant Discovery
|
||||
* Discovers HA instances via mDNS and/or manual configuration,
|
||||
* connects to them, and enumerates all entities
|
||||
*/
|
||||
export class HomeAssistantDiscovery extends plugins.events.EventEmitter {
|
||||
private bonjour: plugins.bonjourService.Bonjour | null = null;
|
||||
private browser: plugins.bonjourService.Browser | null = null;
|
||||
private discoveredInstances: Map<string, IHomeAssistantDiscoveredInstance> = new Map();
|
||||
private connectedProtocols: Map<string, HomeAssistantProtocol> = new Map();
|
||||
private entityCache: Map<string, IHomeAssistantEntity> = new Map();
|
||||
private enabledDomains: THomeAssistantDomain[];
|
||||
private isRunning: boolean = false;
|
||||
|
||||
constructor(options?: { enabledDomains?: THomeAssistantDomain[] }) {
|
||||
super();
|
||||
this.enabledDomains = options?.enabledDomains || DEFAULT_DOMAINS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if discovery is running
|
||||
*/
|
||||
public get running(): boolean {
|
||||
return this.isRunning;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all discovered HA instances
|
||||
*/
|
||||
public getInstances(): IHomeAssistantDiscoveredInstance[] {
|
||||
return Array.from(this.discoveredInstances.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connected protocol for an instance
|
||||
*/
|
||||
public getProtocol(instanceId: string): HomeAssistantProtocol | undefined {
|
||||
return this.connectedProtocols.get(instanceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all connected protocols
|
||||
*/
|
||||
public getProtocols(): Map<string, HomeAssistantProtocol> {
|
||||
return this.connectedProtocols;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all cached entities
|
||||
*/
|
||||
public getEntities(): IHomeAssistantEntity[] {
|
||||
return Array.from(this.entityCache.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get entities by domain
|
||||
*/
|
||||
public getEntitiesByDomain(domain: THomeAssistantDomain): IHomeAssistantEntity[] {
|
||||
return this.getEntities().filter((e) => e.entity_id.startsWith(`${domain}.`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get entities for a specific instance
|
||||
*/
|
||||
public getEntitiesForInstance(instanceId: string): IHomeAssistantEntity[] {
|
||||
const protocol = this.connectedProtocols.get(instanceId);
|
||||
if (!protocol) return [];
|
||||
return Array.from(protocol.entities.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Start mDNS discovery for Home Assistant instances
|
||||
*/
|
||||
public async startMdnsDiscovery(): Promise<void> {
|
||||
if (this.isRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.bonjour = new plugins.bonjourService.Bonjour();
|
||||
this.isRunning = true;
|
||||
|
||||
this.browser = this.bonjour.find({ type: HA_SERVICE_TYPE }, (service) => {
|
||||
this.handleInstanceFound(service);
|
||||
});
|
||||
|
||||
this.browser.on('down', (service) => {
|
||||
this.handleInstanceLost(service);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop mDNS discovery
|
||||
*/
|
||||
public async stopMdnsDiscovery(): Promise<void> {
|
||||
if (!this.isRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.browser) {
|
||||
this.browser.stop();
|
||||
this.browser = null;
|
||||
}
|
||||
|
||||
if (this.bonjour) {
|
||||
this.bonjour.destroy();
|
||||
this.bonjour = null;
|
||||
}
|
||||
|
||||
this.isRunning = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a manually configured HA instance
|
||||
*/
|
||||
public async addInstance(config: IHomeAssistantInstanceConfig): Promise<HomeAssistantProtocol> {
|
||||
const instanceId = this.generateInstanceId(config.host, config.port || 8123);
|
||||
|
||||
// Check if already connected
|
||||
if (this.connectedProtocols.has(instanceId)) {
|
||||
return this.connectedProtocols.get(instanceId)!;
|
||||
}
|
||||
|
||||
// Create protocol and connect
|
||||
const protocol = new HomeAssistantProtocol(config);
|
||||
|
||||
// Set up event handlers
|
||||
this.setupProtocolHandlers(protocol, instanceId);
|
||||
|
||||
// Connect
|
||||
await protocol.connect();
|
||||
|
||||
// Subscribe to state changes
|
||||
await protocol.subscribeToStateChanges();
|
||||
|
||||
// Cache entities
|
||||
const entities = await protocol.getStates();
|
||||
for (const entity of entities) {
|
||||
if (this.isEnabledDomain(entity.entity_id)) {
|
||||
const cacheKey = `${instanceId}:${entity.entity_id}`;
|
||||
this.entityCache.set(cacheKey, entity);
|
||||
this.emit('entity:found', entity);
|
||||
}
|
||||
}
|
||||
|
||||
// Store protocol
|
||||
this.connectedProtocols.set(instanceId, protocol);
|
||||
|
||||
// Also store as discovered instance
|
||||
this.discoveredInstances.set(instanceId, {
|
||||
id: instanceId,
|
||||
host: config.host,
|
||||
port: config.port || 8123,
|
||||
base_url: `http://${config.host}:${config.port || 8123}`,
|
||||
txtRecords: {},
|
||||
requires_api_password: true,
|
||||
friendlyName: config.friendlyName,
|
||||
});
|
||||
|
||||
return protocol;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an HA instance
|
||||
*/
|
||||
public async removeInstance(instanceId: string): Promise<void> {
|
||||
const protocol = this.connectedProtocols.get(instanceId);
|
||||
if (protocol) {
|
||||
await protocol.disconnect();
|
||||
this.connectedProtocols.delete(instanceId);
|
||||
}
|
||||
|
||||
this.discoveredInstances.delete(instanceId);
|
||||
|
||||
// Remove cached entities for this instance
|
||||
for (const key of this.entityCache.keys()) {
|
||||
if (key.startsWith(`${instanceId}:`)) {
|
||||
this.entityCache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
this.emit('instance:lost', instanceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all and cleanup
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
await this.stopMdnsDiscovery();
|
||||
|
||||
// Disconnect all protocols
|
||||
for (const [instanceId, protocol] of this.connectedProtocols) {
|
||||
await protocol.disconnect();
|
||||
}
|
||||
this.connectedProtocols.clear();
|
||||
this.discoveredInstances.clear();
|
||||
this.entityCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mDNS service found
|
||||
*/
|
||||
private handleInstanceFound(service: plugins.bonjourService.Service): void {
|
||||
const addresses = service.addresses ?? [];
|
||||
const address = addresses.find((a) => !a.includes(':')) ?? addresses[0] ?? service.host;
|
||||
|
||||
if (!address) {
|
||||
return;
|
||||
}
|
||||
|
||||
const instanceId = this.generateInstanceId(address, service.port);
|
||||
const txtRecords = this.parseTxtRecords(service.txt);
|
||||
|
||||
const instance: IHomeAssistantDiscoveredInstance = {
|
||||
id: instanceId,
|
||||
host: address,
|
||||
port: service.port,
|
||||
base_url: txtRecords['base_url'] || `http://${address}:${service.port}`,
|
||||
txtRecords,
|
||||
requires_api_password: txtRecords['requires_api_password'] === 'true',
|
||||
friendlyName: service.name,
|
||||
};
|
||||
|
||||
// Check if this is a new instance
|
||||
const existing = this.discoveredInstances.get(instanceId);
|
||||
if (!existing) {
|
||||
this.discoveredInstances.set(instanceId, instance);
|
||||
this.emit('instance:found', instance);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mDNS service lost
|
||||
*/
|
||||
private handleInstanceLost(service: plugins.bonjourService.Service): void {
|
||||
const addresses = service.addresses ?? [];
|
||||
const address = addresses.find((a) => !a.includes(':')) ?? addresses[0] ?? service.host;
|
||||
|
||||
if (!address) {
|
||||
return;
|
||||
}
|
||||
|
||||
const instanceId = this.generateInstanceId(address, service.port);
|
||||
|
||||
if (this.discoveredInstances.has(instanceId)) {
|
||||
// Don't remove if we have an active connection (manually added)
|
||||
if (!this.connectedProtocols.has(instanceId)) {
|
||||
this.discoveredInstances.delete(instanceId);
|
||||
}
|
||||
this.emit('instance:lost', instanceId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up event handlers for a protocol
|
||||
*/
|
||||
private setupProtocolHandlers(protocol: HomeAssistantProtocol, instanceId: string): void {
|
||||
protocol.on('state:changed', (event) => {
|
||||
const cacheKey = `${instanceId}:${event.entity_id}`;
|
||||
|
||||
if (event.new_state) {
|
||||
if (this.isEnabledDomain(event.entity_id)) {
|
||||
const existing = this.entityCache.has(cacheKey);
|
||||
this.entityCache.set(cacheKey, event.new_state);
|
||||
|
||||
if (existing) {
|
||||
this.emit('entity:updated', event.new_state);
|
||||
} else {
|
||||
this.emit('entity:found', event.new_state);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Entity removed
|
||||
if (this.entityCache.has(cacheKey)) {
|
||||
this.entityCache.delete(cacheKey);
|
||||
this.emit('entity:removed', event.entity_id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
protocol.on('disconnected', () => {
|
||||
// Clear cached entities for this instance on disconnect
|
||||
for (const key of this.entityCache.keys()) {
|
||||
if (key.startsWith(`${instanceId}:`)) {
|
||||
this.entityCache.delete(key);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
protocol.on('error', (error) => {
|
||||
this.emit('error', error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if entity domain is enabled
|
||||
*/
|
||||
private isEnabledDomain(entityId: string): boolean {
|
||||
const domain = entityId.split('.')[0] as THomeAssistantDomain;
|
||||
return this.enabledDomains.includes(domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique instance ID
|
||||
*/
|
||||
private generateInstanceId(host: string, port: number): string {
|
||||
return `ha:${host}:${port}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse TXT records from mDNS service
|
||||
*/
|
||||
private parseTxtRecords(txt: Record<string, unknown> | undefined): Record<string, string> {
|
||||
const records: Record<string, string> = {};
|
||||
|
||||
if (!txt) {
|
||||
return records;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(txt)) {
|
||||
if (typeof value === 'string') {
|
||||
records[key] = value;
|
||||
} else if (Buffer.isBuffer(value)) {
|
||||
records[key] = value.toString('utf-8');
|
||||
} else if (value !== undefined && value !== null) {
|
||||
records[key] = String(value);
|
||||
}
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe if a host has Home Assistant running
|
||||
*/
|
||||
public static async probe(
|
||||
host: string,
|
||||
port: number = 8123,
|
||||
secure: boolean = false,
|
||||
timeout: number = 5000
|
||||
): Promise<boolean> {
|
||||
return HomeAssistantProtocol.probe(host, port, secure, timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export { HA_SERVICE_TYPE };
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { EsclProtocol } from '../scanner/scanner.classes.esclprotocol.js';
|
||||
import { EsclProtocol } from '../protocols/index.js';
|
||||
import {
|
||||
cidrToIps,
|
||||
ipRangeToIps,
|
||||
|
||||
@@ -1,527 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { Device } from '../abstract/device.abstract.js';
|
||||
import {
|
||||
UpnpSoapClient,
|
||||
UPNP_SERVICE_TYPES,
|
||||
type TDlnaTransportState,
|
||||
type IDlnaTransportInfo,
|
||||
type IDlnaPositionInfo,
|
||||
type IDlnaMediaInfo,
|
||||
} from './dlna.classes.upnp.js';
|
||||
import type { IDeviceInfo, IRetryOptions } from '../interfaces/index.js';
|
||||
import type { ISsdpDevice, ISsdpService } from '../discovery/discovery.classes.ssdp.js';
|
||||
|
||||
/**
|
||||
* DLNA Renderer device info
|
||||
*/
|
||||
export interface IDlnaRendererInfo extends IDeviceInfo {
|
||||
type: 'dlna-renderer';
|
||||
friendlyName: string;
|
||||
modelName: string;
|
||||
modelNumber?: string;
|
||||
manufacturer: string;
|
||||
udn: string;
|
||||
iconUrl?: string;
|
||||
supportsVolume: boolean;
|
||||
supportsSeek: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Playback state
|
||||
*/
|
||||
export interface IDlnaPlaybackState {
|
||||
state: TDlnaTransportState;
|
||||
volume: number;
|
||||
muted: boolean;
|
||||
currentUri: string;
|
||||
currentTrack: {
|
||||
title: string;
|
||||
artist?: string;
|
||||
album?: string;
|
||||
duration: number;
|
||||
position: number;
|
||||
albumArtUri?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* DLNA Media Renderer device
|
||||
* Represents a device that can play media (TV, speaker, etc.)
|
||||
*/
|
||||
export class DlnaRenderer extends Device {
|
||||
private soapClient: UpnpSoapClient | null = null;
|
||||
private avTransportUrl: string = '';
|
||||
private renderingControlUrl: string = '';
|
||||
private baseUrl: string = '';
|
||||
|
||||
private _friendlyName: string;
|
||||
private _modelName: string = '';
|
||||
private _modelNumber?: string;
|
||||
private _udn: string = '';
|
||||
private _iconUrl?: string;
|
||||
private _supportsVolume: boolean = true;
|
||||
private _supportsSeek: boolean = true;
|
||||
|
||||
private _currentState: TDlnaTransportState = 'STOPPED';
|
||||
private _currentVolume: number = 0;
|
||||
private _currentMuted: boolean = false;
|
||||
|
||||
constructor(
|
||||
info: IDeviceInfo,
|
||||
options: {
|
||||
friendlyName: string;
|
||||
baseUrl: string;
|
||||
avTransportUrl?: string;
|
||||
renderingControlUrl?: string;
|
||||
modelName?: string;
|
||||
modelNumber?: string;
|
||||
udn?: string;
|
||||
iconUrl?: string;
|
||||
},
|
||||
retryOptions?: IRetryOptions
|
||||
) {
|
||||
super(info, retryOptions);
|
||||
this._friendlyName = options.friendlyName;
|
||||
this.baseUrl = options.baseUrl;
|
||||
this.avTransportUrl = options.avTransportUrl || '/AVTransport/control';
|
||||
this.renderingControlUrl = options.renderingControlUrl || '/RenderingControl/control';
|
||||
this._modelName = options.modelName || '';
|
||||
this._modelNumber = options.modelNumber;
|
||||
this._udn = options.udn || '';
|
||||
this._iconUrl = options.iconUrl;
|
||||
}
|
||||
|
||||
// Getters
|
||||
public get friendlyName(): string {
|
||||
return this._friendlyName;
|
||||
}
|
||||
|
||||
public get modelName(): string {
|
||||
return this._modelName;
|
||||
}
|
||||
|
||||
public get modelNumber(): string | undefined {
|
||||
return this._modelNumber;
|
||||
}
|
||||
|
||||
public get udn(): string {
|
||||
return this._udn;
|
||||
}
|
||||
|
||||
public get iconUrl(): string | undefined {
|
||||
return this._iconUrl;
|
||||
}
|
||||
|
||||
public get supportsVolume(): boolean {
|
||||
return this._supportsVolume;
|
||||
}
|
||||
|
||||
public get supportsSeek(): boolean {
|
||||
return this._supportsSeek;
|
||||
}
|
||||
|
||||
public get currentState(): TDlnaTransportState {
|
||||
return this._currentState;
|
||||
}
|
||||
|
||||
public get currentVolume(): number {
|
||||
return this._currentVolume;
|
||||
}
|
||||
|
||||
public get currentMuted(): boolean {
|
||||
return this._currentMuted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to renderer
|
||||
*/
|
||||
protected async doConnect(): Promise<void> {
|
||||
this.soapClient = new UpnpSoapClient(this.baseUrl);
|
||||
|
||||
// Test connection by getting transport info
|
||||
try {
|
||||
await this.getTransportInfo();
|
||||
} catch (error) {
|
||||
this.soapClient = null;
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Try to get volume (may not be supported)
|
||||
try {
|
||||
this._currentVolume = await this.getVolume();
|
||||
this._supportsVolume = true;
|
||||
} catch {
|
||||
this._supportsVolume = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect
|
||||
*/
|
||||
protected async doDisconnect(): Promise<void> {
|
||||
this.soapClient = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh status
|
||||
*/
|
||||
public async refreshStatus(): Promise<void> {
|
||||
if (!this.soapClient) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
const [transport, volume, muted] = await Promise.all([
|
||||
this.getTransportInfo(),
|
||||
this._supportsVolume ? this.getVolume() : Promise.resolve(0),
|
||||
this._supportsVolume ? this.getMute() : Promise.resolve(false),
|
||||
]);
|
||||
|
||||
this._currentState = transport.state;
|
||||
this._currentVolume = volume;
|
||||
this._currentMuted = muted;
|
||||
|
||||
this.emit('status:updated', this.getDeviceInfo());
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Playback Control
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Set media URI to play
|
||||
*/
|
||||
public async setAVTransportURI(uri: string, metadata?: string): Promise<void> {
|
||||
if (!this.soapClient) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
const meta = metadata || this.soapClient.generateDidlMetadata('Media', uri);
|
||||
await this.soapClient.setAVTransportURI(this.avTransportUrl, uri, meta);
|
||||
this.emit('media:loaded', { uri });
|
||||
}
|
||||
|
||||
/**
|
||||
* Play current media
|
||||
*/
|
||||
public async play(uri?: string, metadata?: string): Promise<void> {
|
||||
if (!this.soapClient) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
if (uri) {
|
||||
await this.setAVTransportURI(uri, metadata);
|
||||
}
|
||||
|
||||
await this.soapClient.play(this.avTransportUrl);
|
||||
this._currentState = 'PLAYING';
|
||||
this.emit('playback:started');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause playback
|
||||
*/
|
||||
public async pause(): Promise<void> {
|
||||
if (!this.soapClient) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
await this.soapClient.pause(this.avTransportUrl);
|
||||
this._currentState = 'PAUSED_PLAYBACK';
|
||||
this.emit('playback:paused');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop playback
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
if (!this.soapClient) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
await this.soapClient.stop(this.avTransportUrl);
|
||||
this._currentState = 'STOPPED';
|
||||
this.emit('playback:stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek to position
|
||||
*/
|
||||
public async seek(seconds: number): Promise<void> {
|
||||
if (!this.soapClient) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
const target = this.soapClient.secondsToDuration(seconds);
|
||||
await this.soapClient.seek(this.avTransportUrl, target, 'REL_TIME');
|
||||
this.emit('playback:seeked', { position: seconds });
|
||||
}
|
||||
|
||||
/**
|
||||
* Next track
|
||||
*/
|
||||
public async next(): Promise<void> {
|
||||
if (!this.soapClient) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
await this.soapClient.next(this.avTransportUrl);
|
||||
this.emit('playback:next');
|
||||
}
|
||||
|
||||
/**
|
||||
* Previous track
|
||||
*/
|
||||
public async previous(): Promise<void> {
|
||||
if (!this.soapClient) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
await this.soapClient.previous(this.avTransportUrl);
|
||||
this.emit('playback:previous');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Volume Control
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get volume level
|
||||
*/
|
||||
public async getVolume(): Promise<number> {
|
||||
if (!this.soapClient) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
return this.soapClient.getVolume(this.renderingControlUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set volume level
|
||||
*/
|
||||
public async setVolume(level: number): Promise<void> {
|
||||
if (!this.soapClient) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
await this.soapClient.setVolume(this.renderingControlUrl, level);
|
||||
this._currentVolume = level;
|
||||
this.emit('volume:changed', { volume: level });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mute state
|
||||
*/
|
||||
public async getMute(): Promise<boolean> {
|
||||
if (!this.soapClient) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
return this.soapClient.getMute(this.renderingControlUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set mute state
|
||||
*/
|
||||
public async setMute(muted: boolean): Promise<void> {
|
||||
if (!this.soapClient) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
await this.soapClient.setMute(this.renderingControlUrl, muted);
|
||||
this._currentMuted = muted;
|
||||
this.emit('mute:changed', { muted });
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle mute
|
||||
*/
|
||||
public async toggleMute(): Promise<boolean> {
|
||||
const newMuted = !this._currentMuted;
|
||||
await this.setMute(newMuted);
|
||||
return newMuted;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Status Information
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get transport info
|
||||
*/
|
||||
public async getTransportInfo(): Promise<IDlnaTransportInfo> {
|
||||
if (!this.soapClient) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
return this.soapClient.getTransportInfo(this.avTransportUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get position info
|
||||
*/
|
||||
public async getPositionInfo(): Promise<IDlnaPositionInfo> {
|
||||
if (!this.soapClient) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
return this.soapClient.getPositionInfo(this.avTransportUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get media info
|
||||
*/
|
||||
public async getMediaInfo(): Promise<IDlnaMediaInfo> {
|
||||
if (!this.soapClient) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
return this.soapClient.getMediaInfo(this.avTransportUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full playback state
|
||||
*/
|
||||
public async getPlaybackState(): Promise<IDlnaPlaybackState> {
|
||||
if (!this.soapClient) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
const [transport, position, media, volume, muted] = await Promise.all([
|
||||
this.getTransportInfo(),
|
||||
this.getPositionInfo(),
|
||||
this.getMediaInfo(),
|
||||
this._supportsVolume ? this.getVolume() : Promise.resolve(0),
|
||||
this._supportsVolume ? this.getMute() : Promise.resolve(false),
|
||||
]);
|
||||
|
||||
// Parse metadata for track info
|
||||
const trackMeta = this.parseTrackMetadata(position.trackMetadata);
|
||||
|
||||
return {
|
||||
state: transport.state,
|
||||
volume,
|
||||
muted,
|
||||
currentUri: media.currentUri,
|
||||
currentTrack: {
|
||||
title: trackMeta.title || 'Unknown',
|
||||
artist: trackMeta.artist,
|
||||
album: trackMeta.album,
|
||||
duration: this.soapClient.durationToSeconds(position.trackDuration),
|
||||
position: this.soapClient.durationToSeconds(position.relativeTime),
|
||||
albumArtUri: trackMeta.albumArtUri,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse track metadata from DIDL-Lite
|
||||
*/
|
||||
private parseTrackMetadata(metadata: string): {
|
||||
title?: string;
|
||||
artist?: string;
|
||||
album?: string;
|
||||
albumArtUri?: string;
|
||||
} {
|
||||
if (!metadata) return {};
|
||||
|
||||
const extractTag = (xml: string, tag: string): string | undefined => {
|
||||
const regex = new RegExp(`<(?:[^:]*:)?${tag}[^>]*>([^<]*)<\/(?:[^:]*:)?${tag}>`, 'i');
|
||||
const match = xml.match(regex);
|
||||
return match ? match[1].trim() : undefined;
|
||||
};
|
||||
|
||||
return {
|
||||
title: extractTag(metadata, 'title'),
|
||||
artist: extractTag(metadata, 'creator') || extractTag(metadata, 'artist'),
|
||||
album: extractTag(metadata, 'album'),
|
||||
albumArtUri: extractTag(metadata, 'albumArtURI'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get device info
|
||||
*/
|
||||
public getDeviceInfo(): IDlnaRendererInfo {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
type: 'dlna-renderer',
|
||||
address: this.address,
|
||||
port: this.port,
|
||||
status: this.status,
|
||||
friendlyName: this._friendlyName,
|
||||
modelName: this._modelName,
|
||||
modelNumber: this._modelNumber,
|
||||
manufacturer: this.manufacturer || '',
|
||||
udn: this._udn,
|
||||
iconUrl: this._iconUrl,
|
||||
supportsVolume: this._supportsVolume,
|
||||
supportsSeek: this._supportsSeek,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from SSDP discovery
|
||||
*/
|
||||
public static fromSsdpDevice(
|
||||
ssdpDevice: ISsdpDevice,
|
||||
retryOptions?: IRetryOptions
|
||||
): DlnaRenderer | null {
|
||||
if (!ssdpDevice.description) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const desc = ssdpDevice.description;
|
||||
|
||||
// Find AVTransport and RenderingControl URLs
|
||||
const avTransport = desc.services.find((s) =>
|
||||
s.serviceType.includes('AVTransport')
|
||||
);
|
||||
const renderingControl = desc.services.find((s) =>
|
||||
s.serviceType.includes('RenderingControl')
|
||||
);
|
||||
|
||||
if (!avTransport) {
|
||||
return null; // Not a media renderer
|
||||
}
|
||||
|
||||
// Build base URL
|
||||
const baseUrl = new URL(ssdpDevice.location);
|
||||
const baseUrlStr = `${baseUrl.protocol}//${baseUrl.host}`;
|
||||
|
||||
// Get icon URL
|
||||
let iconUrl: string | undefined;
|
||||
if (desc.icons && desc.icons.length > 0) {
|
||||
const bestIcon = desc.icons.sort((a, b) => b.width - a.width)[0];
|
||||
iconUrl = bestIcon.url.startsWith('http')
|
||||
? bestIcon.url
|
||||
: `${baseUrlStr}${bestIcon.url}`;
|
||||
}
|
||||
|
||||
const info: IDeviceInfo = {
|
||||
id: `dlna-renderer:${desc.UDN}`,
|
||||
name: desc.friendlyName,
|
||||
type: 'dlna-renderer',
|
||||
address: ssdpDevice.address,
|
||||
port: ssdpDevice.port,
|
||||
status: 'unknown',
|
||||
manufacturer: desc.manufacturer,
|
||||
model: desc.modelName,
|
||||
};
|
||||
|
||||
return new DlnaRenderer(
|
||||
info,
|
||||
{
|
||||
friendlyName: desc.friendlyName,
|
||||
baseUrl: baseUrlStr,
|
||||
avTransportUrl: avTransport.controlURL,
|
||||
renderingControlUrl: renderingControl?.controlURL,
|
||||
modelName: desc.modelName,
|
||||
modelNumber: desc.modelNumber,
|
||||
udn: desc.UDN,
|
||||
iconUrl,
|
||||
},
|
||||
retryOptions
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,468 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { Device } from '../abstract/device.abstract.js';
|
||||
import {
|
||||
UpnpSoapClient,
|
||||
UPNP_SERVICE_TYPES,
|
||||
type IDlnaContentItem,
|
||||
type IDlnaBrowseResult,
|
||||
} from './dlna.classes.upnp.js';
|
||||
import type { IDeviceInfo, IRetryOptions } from '../interfaces/index.js';
|
||||
import type { ISsdpDevice, ISsdpService } from '../discovery/discovery.classes.ssdp.js';
|
||||
|
||||
/**
|
||||
* DLNA Server device info
|
||||
*/
|
||||
export interface IDlnaServerInfo extends IDeviceInfo {
|
||||
type: 'dlna-server';
|
||||
friendlyName: string;
|
||||
modelName: string;
|
||||
modelNumber?: string;
|
||||
manufacturer: string;
|
||||
udn: string;
|
||||
iconUrl?: string;
|
||||
contentCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content directory statistics
|
||||
*/
|
||||
export interface IDlnaServerStats {
|
||||
totalItems: number;
|
||||
audioItems: number;
|
||||
videoItems: number;
|
||||
imageItems: number;
|
||||
containers: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* DLNA Media Server device
|
||||
* Represents a device that serves media content (NAS, media library, etc.)
|
||||
*/
|
||||
export class DlnaServer extends Device {
|
||||
private soapClient: UpnpSoapClient | null = null;
|
||||
private contentDirectoryUrl: string = '';
|
||||
private baseUrl: string = '';
|
||||
|
||||
private _friendlyName: string;
|
||||
private _modelName: string = '';
|
||||
private _modelNumber?: string;
|
||||
private _udn: string = '';
|
||||
private _iconUrl?: string;
|
||||
private _contentCount?: number;
|
||||
|
||||
constructor(
|
||||
info: IDeviceInfo,
|
||||
options: {
|
||||
friendlyName: string;
|
||||
baseUrl: string;
|
||||
contentDirectoryUrl?: string;
|
||||
modelName?: string;
|
||||
modelNumber?: string;
|
||||
udn?: string;
|
||||
iconUrl?: string;
|
||||
},
|
||||
retryOptions?: IRetryOptions
|
||||
) {
|
||||
super(info, retryOptions);
|
||||
this._friendlyName = options.friendlyName;
|
||||
this.baseUrl = options.baseUrl;
|
||||
this.contentDirectoryUrl = options.contentDirectoryUrl || '/ContentDirectory/control';
|
||||
this._modelName = options.modelName || '';
|
||||
this._modelNumber = options.modelNumber;
|
||||
this._udn = options.udn || '';
|
||||
this._iconUrl = options.iconUrl;
|
||||
}
|
||||
|
||||
// Getters
|
||||
public get friendlyName(): string {
|
||||
return this._friendlyName;
|
||||
}
|
||||
|
||||
public get modelName(): string {
|
||||
return this._modelName;
|
||||
}
|
||||
|
||||
public get modelNumber(): string | undefined {
|
||||
return this._modelNumber;
|
||||
}
|
||||
|
||||
public get udn(): string {
|
||||
return this._udn;
|
||||
}
|
||||
|
||||
public get iconUrl(): string | undefined {
|
||||
return this._iconUrl;
|
||||
}
|
||||
|
||||
public get contentCount(): number | undefined {
|
||||
return this._contentCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to server
|
||||
*/
|
||||
protected async doConnect(): Promise<void> {
|
||||
this.soapClient = new UpnpSoapClient(this.baseUrl);
|
||||
|
||||
// Test connection by browsing root
|
||||
try {
|
||||
const root = await this.browse('0', 0, 1);
|
||||
this._contentCount = root.totalMatches;
|
||||
} catch (error) {
|
||||
this.soapClient = null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect
|
||||
*/
|
||||
protected async doDisconnect(): Promise<void> {
|
||||
this.soapClient = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh status
|
||||
*/
|
||||
public async refreshStatus(): Promise<void> {
|
||||
if (!this.soapClient) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
const root = await this.browse('0', 0, 1);
|
||||
this._contentCount = root.totalMatches;
|
||||
|
||||
this.emit('status:updated', this.getDeviceInfo());
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Content Directory Browsing
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Browse content directory
|
||||
*/
|
||||
public async browse(
|
||||
objectId: string = '0',
|
||||
startIndex: number = 0,
|
||||
requestCount: number = 100
|
||||
): Promise<IDlnaBrowseResult> {
|
||||
if (!this.soapClient) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
return this.soapClient.browse(
|
||||
this.contentDirectoryUrl,
|
||||
objectId,
|
||||
'BrowseDirectChildren',
|
||||
'*',
|
||||
startIndex,
|
||||
requestCount
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metadata for a specific item
|
||||
*/
|
||||
public async getMetadata(objectId: string): Promise<IDlnaContentItem | null> {
|
||||
if (!this.soapClient) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
const result = await this.soapClient.browse(
|
||||
this.contentDirectoryUrl,
|
||||
objectId,
|
||||
'BrowseMetadata',
|
||||
'*',
|
||||
0,
|
||||
1
|
||||
);
|
||||
|
||||
return result.items[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search content directory
|
||||
*/
|
||||
public async search(
|
||||
containerId: string,
|
||||
searchCriteria: string,
|
||||
startIndex: number = 0,
|
||||
requestCount: number = 100
|
||||
): Promise<IDlnaBrowseResult> {
|
||||
if (!this.soapClient) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
return this.soapClient.search(
|
||||
this.contentDirectoryUrl,
|
||||
containerId,
|
||||
searchCriteria,
|
||||
'*',
|
||||
startIndex,
|
||||
requestCount
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse all items recursively (up to limit)
|
||||
*/
|
||||
public async browseAll(
|
||||
objectId: string = '0',
|
||||
limit: number = 1000
|
||||
): Promise<IDlnaContentItem[]> {
|
||||
const allItems: IDlnaContentItem[] = [];
|
||||
let startIndex = 0;
|
||||
const batchSize = 100;
|
||||
|
||||
while (allItems.length < limit) {
|
||||
const result = await this.browse(objectId, startIndex, batchSize);
|
||||
allItems.push(...result.items);
|
||||
|
||||
if (result.items.length < batchSize || allItems.length >= result.totalMatches) {
|
||||
break;
|
||||
}
|
||||
|
||||
startIndex += result.items.length;
|
||||
}
|
||||
|
||||
return allItems.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get content statistics
|
||||
*/
|
||||
public async getStats(): Promise<IDlnaServerStats> {
|
||||
const stats: IDlnaServerStats = {
|
||||
totalItems: 0,
|
||||
audioItems: 0,
|
||||
videoItems: 0,
|
||||
imageItems: 0,
|
||||
containers: 0,
|
||||
};
|
||||
|
||||
// Browse root to get counts
|
||||
const root = await this.browseAll('0', 500);
|
||||
|
||||
for (const item of root) {
|
||||
stats.totalItems++;
|
||||
|
||||
if (item.class.includes('container')) {
|
||||
stats.containers++;
|
||||
} else if (item.class.includes('audioItem')) {
|
||||
stats.audioItems++;
|
||||
} else if (item.class.includes('videoItem')) {
|
||||
stats.videoItems++;
|
||||
} else if (item.class.includes('imageItem')) {
|
||||
stats.imageItems++;
|
||||
}
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Content Access
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get stream URL for content item
|
||||
*/
|
||||
public getStreamUrl(item: IDlnaContentItem): string | null {
|
||||
if (!item.res || item.res.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Return first resource URL
|
||||
return item.res[0].url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get best quality stream URL
|
||||
*/
|
||||
public getBestStreamUrl(item: IDlnaContentItem, preferredType?: string): string | null {
|
||||
if (!item.res || item.res.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sort by bitrate (highest first)
|
||||
const sorted = [...item.res].sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0));
|
||||
|
||||
// If preferred type specified, try to find matching
|
||||
if (preferredType) {
|
||||
const preferred = sorted.find((r) =>
|
||||
r.protocolInfo.toLowerCase().includes(preferredType.toLowerCase())
|
||||
);
|
||||
if (preferred) return preferred.url;
|
||||
}
|
||||
|
||||
return sorted[0].url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get album art URL for item
|
||||
*/
|
||||
public getAlbumArtUrl(item: IDlnaContentItem): string | null {
|
||||
if (item.albumArtUri) {
|
||||
// Resolve relative URLs
|
||||
if (!item.albumArtUri.startsWith('http')) {
|
||||
return `${this.baseUrl}${item.albumArtUri}`;
|
||||
}
|
||||
return item.albumArtUri;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Search Helpers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Search for audio items by title
|
||||
*/
|
||||
public async searchAudio(
|
||||
title: string,
|
||||
startIndex: number = 0,
|
||||
requestCount: number = 100
|
||||
): Promise<IDlnaBrowseResult> {
|
||||
const criteria = `dc:title contains "${title}" and upnp:class derivedfrom "object.item.audioItem"`;
|
||||
return this.search('0', criteria, startIndex, requestCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for video items by title
|
||||
*/
|
||||
public async searchVideo(
|
||||
title: string,
|
||||
startIndex: number = 0,
|
||||
requestCount: number = 100
|
||||
): Promise<IDlnaBrowseResult> {
|
||||
const criteria = `dc:title contains "${title}" and upnp:class derivedfrom "object.item.videoItem"`;
|
||||
return this.search('0', criteria, startIndex, requestCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search by artist
|
||||
*/
|
||||
public async searchByArtist(
|
||||
artist: string,
|
||||
startIndex: number = 0,
|
||||
requestCount: number = 100
|
||||
): Promise<IDlnaBrowseResult> {
|
||||
const criteria = `dc:creator contains "${artist}" or upnp:artist contains "${artist}"`;
|
||||
return this.search('0', criteria, startIndex, requestCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search by album
|
||||
*/
|
||||
public async searchByAlbum(
|
||||
album: string,
|
||||
startIndex: number = 0,
|
||||
requestCount: number = 100
|
||||
): Promise<IDlnaBrowseResult> {
|
||||
const criteria = `upnp:album contains "${album}"`;
|
||||
return this.search('0', criteria, startIndex, requestCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search by genre
|
||||
*/
|
||||
public async searchByGenre(
|
||||
genre: string,
|
||||
startIndex: number = 0,
|
||||
requestCount: number = 100
|
||||
): Promise<IDlnaBrowseResult> {
|
||||
const criteria = `upnp:genre contains "${genre}"`;
|
||||
return this.search('0', criteria, startIndex, requestCount);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Device Info
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get device info
|
||||
*/
|
||||
public getDeviceInfo(): IDlnaServerInfo {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
type: 'dlna-server',
|
||||
address: this.address,
|
||||
port: this.port,
|
||||
status: this.status,
|
||||
friendlyName: this._friendlyName,
|
||||
modelName: this._modelName,
|
||||
modelNumber: this._modelNumber,
|
||||
manufacturer: this.manufacturer || '',
|
||||
udn: this._udn,
|
||||
iconUrl: this._iconUrl,
|
||||
contentCount: this._contentCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from SSDP discovery
|
||||
*/
|
||||
public static fromSsdpDevice(
|
||||
ssdpDevice: ISsdpDevice,
|
||||
retryOptions?: IRetryOptions
|
||||
): DlnaServer | null {
|
||||
if (!ssdpDevice.description) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const desc = ssdpDevice.description;
|
||||
|
||||
// Find ContentDirectory URL
|
||||
const contentDirectory = desc.services.find((s) =>
|
||||
s.serviceType.includes('ContentDirectory')
|
||||
);
|
||||
|
||||
if (!contentDirectory) {
|
||||
return null; // Not a media server
|
||||
}
|
||||
|
||||
// Build base URL
|
||||
const baseUrl = new URL(ssdpDevice.location);
|
||||
const baseUrlStr = `${baseUrl.protocol}//${baseUrl.host}`;
|
||||
|
||||
// Get icon URL
|
||||
let iconUrl: string | undefined;
|
||||
if (desc.icons && desc.icons.length > 0) {
|
||||
const bestIcon = desc.icons.sort((a, b) => b.width - a.width)[0];
|
||||
iconUrl = bestIcon.url.startsWith('http')
|
||||
? bestIcon.url
|
||||
: `${baseUrlStr}${bestIcon.url}`;
|
||||
}
|
||||
|
||||
const info: IDeviceInfo = {
|
||||
id: `dlna-server:${desc.UDN}`,
|
||||
name: desc.friendlyName,
|
||||
type: 'dlna-server',
|
||||
address: ssdpDevice.address,
|
||||
port: ssdpDevice.port,
|
||||
status: 'unknown',
|
||||
manufacturer: desc.manufacturer,
|
||||
model: desc.modelName,
|
||||
};
|
||||
|
||||
return new DlnaServer(
|
||||
info,
|
||||
{
|
||||
friendlyName: desc.friendlyName,
|
||||
baseUrl: baseUrlStr,
|
||||
contentDirectoryUrl: contentDirectory.controlURL,
|
||||
modelName: desc.modelName,
|
||||
modelNumber: desc.modelNumber,
|
||||
udn: desc.UDN,
|
||||
iconUrl,
|
||||
},
|
||||
retryOptions
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export content types
|
||||
export type { IDlnaContentItem, IDlnaBrowseResult } from './dlna.classes.upnp.js';
|
||||
696
ts/factories/index.ts
Normal file
696
ts/factories/index.ts
Normal file
@@ -0,0 +1,696 @@
|
||||
/**
|
||||
* Device Factory Functions
|
||||
* Create UniversalDevice instances with appropriate features
|
||||
*/
|
||||
|
||||
import { UniversalDevice, type IDeviceCreateOptions } from '../device/device.classes.device.js';
|
||||
import { ScanFeature, type IScanFeatureOptions } from '../features/feature.scan.js';
|
||||
import { PrintFeature, type IPrintFeatureOptions } from '../features/feature.print.js';
|
||||
import { PlaybackFeature, type IPlaybackFeatureOptions } from '../features/feature.playback.js';
|
||||
import { VolumeFeature, type IVolumeFeatureOptions } from '../features/feature.volume.js';
|
||||
import { PowerFeature, type IPowerFeatureOptions } from '../features/feature.power.js';
|
||||
import { SnmpFeature, type ISnmpFeatureOptions } from '../features/feature.snmp.js';
|
||||
import type {
|
||||
TScannerProtocol,
|
||||
TScanFormat,
|
||||
TColorMode,
|
||||
TScanSource,
|
||||
IRetryOptions,
|
||||
} from '../interfaces/index.js';
|
||||
import type { TPrintProtocol } from '../interfaces/feature.interfaces.js';
|
||||
|
||||
// ============================================================================
|
||||
// Scanner Factory
|
||||
// ============================================================================
|
||||
|
||||
export interface IScannerDiscoveryInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
port: number;
|
||||
protocol: TScannerProtocol | 'ipp';
|
||||
txtRecords: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a scanner device (UniversalDevice with ScanFeature)
|
||||
*/
|
||||
export function createScanner(
|
||||
info: IScannerDiscoveryInfo,
|
||||
retryOptions?: IRetryOptions
|
||||
): UniversalDevice {
|
||||
const protocol = info.protocol === 'ipp' ? 'escl' : info.protocol;
|
||||
const isSecure = info.txtRecords['TLS'] === '1' || (protocol === 'escl' && info.port === 443);
|
||||
|
||||
// Parse capabilities from TXT records
|
||||
const formats = parseScanFormats(info.txtRecords);
|
||||
const resolutions = parseScanResolutions(info.txtRecords);
|
||||
const colorModes = parseScanColorModes(info.txtRecords);
|
||||
const sources = parseScanSources(info.txtRecords);
|
||||
|
||||
const device = new UniversalDevice(info.address, info.port, {
|
||||
name: info.name,
|
||||
manufacturer: info.txtRecords['usb_MFG'] || info.txtRecords['mfg'],
|
||||
model: info.txtRecords['usb_MDL'] || info.txtRecords['mdl'] || info.txtRecords['ty'],
|
||||
retryOptions,
|
||||
});
|
||||
|
||||
// Override the generated ID with discovery ID
|
||||
(device as { id: string }).id = info.id;
|
||||
|
||||
// Add scan feature
|
||||
const scanFeature = new ScanFeature(device.getDeviceReference(), info.port, {
|
||||
protocol: protocol as 'escl' | 'sane',
|
||||
secure: isSecure,
|
||||
supportedFormats: formats,
|
||||
supportedResolutions: resolutions,
|
||||
supportedColorModes: colorModes,
|
||||
supportedSources: sources,
|
||||
hasAdf: sources.includes('adf') || sources.includes('adf-duplex'),
|
||||
hasDuplex: sources.includes('adf-duplex'),
|
||||
});
|
||||
|
||||
device.addFeature(scanFeature);
|
||||
return device;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Printer Factory
|
||||
// ============================================================================
|
||||
|
||||
export interface IPrinterDiscoveryInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
port: number;
|
||||
txtRecords: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a printer device (UniversalDevice with PrintFeature)
|
||||
*/
|
||||
export function createPrinter(
|
||||
info: IPrinterDiscoveryInfo,
|
||||
retryOptions?: IRetryOptions
|
||||
): UniversalDevice {
|
||||
const ippPath = info.txtRecords['rp'] || info.txtRecords['rfo'] || '/ipp/print';
|
||||
const uri = `ipp://${info.address}:${info.port}${ippPath.startsWith('/') ? '' : '/'}${ippPath}`;
|
||||
|
||||
const device = new UniversalDevice(info.address, info.port, {
|
||||
name: info.name,
|
||||
manufacturer: info.txtRecords['usb_MFG'] || info.txtRecords['mfg'],
|
||||
model: info.txtRecords['usb_MDL'] || info.txtRecords['mdl'] || info.txtRecords['ty'],
|
||||
retryOptions,
|
||||
});
|
||||
|
||||
// Override the generated ID with discovery ID
|
||||
(device as { id: string }).id = info.id;
|
||||
|
||||
// Add print feature
|
||||
const printFeature = new PrintFeature(device.getDeviceReference(), info.port, {
|
||||
protocol: 'ipp',
|
||||
uri,
|
||||
supportsColor: info.txtRecords['Color'] === 'T' || info.txtRecords['color'] === 'true',
|
||||
supportsDuplex: info.txtRecords['Duplex'] === 'T' || info.txtRecords['duplex'] === 'true',
|
||||
});
|
||||
|
||||
device.addFeature(printFeature);
|
||||
return device;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SNMP Device Factory
|
||||
// ============================================================================
|
||||
|
||||
export interface ISnmpDiscoveryInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
port: number;
|
||||
community?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an SNMP device (UniversalDevice with SnmpFeature)
|
||||
*/
|
||||
export function createSnmpDevice(
|
||||
info: ISnmpDiscoveryInfo,
|
||||
retryOptions?: IRetryOptions
|
||||
): UniversalDevice {
|
||||
const device = new UniversalDevice(info.address, info.port, {
|
||||
name: info.name,
|
||||
retryOptions,
|
||||
});
|
||||
|
||||
// Override the generated ID with discovery ID
|
||||
(device as { id: string }).id = info.id;
|
||||
|
||||
// Add SNMP feature
|
||||
const snmpFeature = new SnmpFeature(device.getDeviceReference(), info.port, {
|
||||
community: info.community ?? 'public',
|
||||
});
|
||||
|
||||
device.addFeature(snmpFeature);
|
||||
return device;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// UPS Device Factory
|
||||
// ============================================================================
|
||||
|
||||
export interface IUpsDiscoveryInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
port: number;
|
||||
protocol: 'nut' | 'snmp';
|
||||
upsName?: string;
|
||||
community?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a UPS device (UniversalDevice with PowerFeature)
|
||||
*/
|
||||
export function createUpsDevice(
|
||||
info: IUpsDiscoveryInfo,
|
||||
retryOptions?: IRetryOptions
|
||||
): UniversalDevice {
|
||||
const device = new UniversalDevice(info.address, info.port, {
|
||||
name: info.name,
|
||||
retryOptions,
|
||||
});
|
||||
|
||||
// Override the generated ID with discovery ID
|
||||
(device as { id: string }).id = info.id;
|
||||
|
||||
// Add power feature
|
||||
const powerFeature = new PowerFeature(device.getDeviceReference(), info.port, {
|
||||
protocol: info.protocol,
|
||||
upsName: info.upsName,
|
||||
community: info.community,
|
||||
});
|
||||
|
||||
device.addFeature(powerFeature);
|
||||
return device;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Speaker Factory
|
||||
// ============================================================================
|
||||
|
||||
export interface ISpeakerDiscoveryInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
port: number;
|
||||
protocol: 'sonos' | 'airplay' | 'chromecast' | 'dlna';
|
||||
roomName?: string;
|
||||
modelName?: string;
|
||||
features?: number; // AirPlay feature flags
|
||||
deviceId?: string;
|
||||
friendlyName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a speaker device (UniversalDevice with PlaybackFeature and VolumeFeature)
|
||||
*/
|
||||
export function createSpeaker(
|
||||
info: ISpeakerDiscoveryInfo,
|
||||
retryOptions?: IRetryOptions
|
||||
): UniversalDevice {
|
||||
const device = new UniversalDevice(info.address, info.port, {
|
||||
name: info.name,
|
||||
model: info.modelName,
|
||||
retryOptions,
|
||||
});
|
||||
|
||||
// Override the generated ID with discovery ID
|
||||
(device as { id: string }).id = info.id;
|
||||
|
||||
// Add playback feature
|
||||
const playbackFeature = new PlaybackFeature(device.getDeviceReference(), info.port, {
|
||||
protocol: info.protocol,
|
||||
supportsQueue: info.protocol === 'sonos',
|
||||
supportsSeek: info.protocol !== 'airplay',
|
||||
});
|
||||
|
||||
device.addFeature(playbackFeature);
|
||||
|
||||
// Add volume feature
|
||||
const volumeFeature = new VolumeFeature(device.getDeviceReference(), info.port, {
|
||||
volumeProtocol: info.protocol,
|
||||
minVolume: 0,
|
||||
maxVolume: 100,
|
||||
supportsMute: true,
|
||||
});
|
||||
|
||||
device.addFeature(volumeFeature);
|
||||
|
||||
return device;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DLNA Factory
|
||||
// ============================================================================
|
||||
|
||||
export interface IDlnaRendererDiscoveryInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
port: number;
|
||||
controlUrl: string;
|
||||
friendlyName: string;
|
||||
modelName?: string;
|
||||
manufacturer?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a DLNA renderer device (UniversalDevice with PlaybackFeature)
|
||||
*/
|
||||
export function createDlnaRenderer(
|
||||
info: IDlnaRendererDiscoveryInfo,
|
||||
retryOptions?: IRetryOptions
|
||||
): UniversalDevice {
|
||||
const device = new UniversalDevice(info.address, info.port, {
|
||||
name: info.friendlyName || info.name,
|
||||
manufacturer: info.manufacturer,
|
||||
model: info.modelName,
|
||||
retryOptions,
|
||||
});
|
||||
|
||||
// Override the generated ID with discovery ID
|
||||
(device as { id: string }).id = info.id;
|
||||
|
||||
// Add playback feature for DLNA
|
||||
const playbackFeature = new PlaybackFeature(device.getDeviceReference(), info.port, {
|
||||
protocol: 'dlna',
|
||||
supportsQueue: false,
|
||||
supportsSeek: true,
|
||||
});
|
||||
|
||||
device.addFeature(playbackFeature);
|
||||
|
||||
// Add volume feature
|
||||
const volumeFeature = new VolumeFeature(device.getDeviceReference(), info.port, {
|
||||
volumeProtocol: 'dlna',
|
||||
minVolume: 0,
|
||||
maxVolume: 100,
|
||||
supportsMute: true,
|
||||
});
|
||||
|
||||
device.addFeature(volumeFeature);
|
||||
|
||||
return device;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Parsing Helpers
|
||||
// ============================================================================
|
||||
|
||||
function parseScanFormats(txtRecords: Record<string, string>): TScanFormat[] {
|
||||
const formats: TScanFormat[] = [];
|
||||
const pdl = txtRecords['pdl'] || txtRecords['DocumentFormat'] || '';
|
||||
|
||||
if (pdl.includes('jpeg') || pdl.includes('jpg')) formats.push('jpeg');
|
||||
if (pdl.includes('png')) formats.push('png');
|
||||
if (pdl.includes('pdf')) formats.push('pdf');
|
||||
if (pdl.includes('tiff')) formats.push('tiff');
|
||||
|
||||
return formats.length > 0 ? formats : ['jpeg', 'png'];
|
||||
}
|
||||
|
||||
function parseScanResolutions(txtRecords: Record<string, string>): number[] {
|
||||
const rs = txtRecords['rs'] || '';
|
||||
const parts = rs.split(',').map((s) => parseInt(s.trim())).filter((n) => !isNaN(n) && n > 0);
|
||||
return parts.length > 0 ? parts : [75, 150, 300, 600];
|
||||
}
|
||||
|
||||
function parseScanColorModes(txtRecords: Record<string, string>): TColorMode[] {
|
||||
const cs = txtRecords['cs'] || txtRecords['ColorSpace'] || '';
|
||||
const modes: TColorMode[] = [];
|
||||
|
||||
if (cs.includes('color') || cs.includes('RGB')) modes.push('color');
|
||||
if (cs.includes('gray') || cs.includes('grayscale')) modes.push('grayscale');
|
||||
if (cs.includes('binary') || cs.includes('bw')) modes.push('blackwhite');
|
||||
|
||||
return modes.length > 0 ? modes : ['color', 'grayscale'];
|
||||
}
|
||||
|
||||
function parseScanSources(txtRecords: Record<string, string>): TScanSource[] {
|
||||
const is = txtRecords['is'] || txtRecords['InputSource'] || '';
|
||||
const sources: TScanSource[] = [];
|
||||
|
||||
if (is.includes('platen') || is.includes('flatbed') || is === '') {
|
||||
sources.push('flatbed');
|
||||
}
|
||||
if (is.includes('adf') || is.includes('feeder')) {
|
||||
sources.push('adf');
|
||||
}
|
||||
if (is.includes('duplex')) {
|
||||
sources.push('adf-duplex');
|
||||
}
|
||||
|
||||
return sources.length > 0 ? sources : ['flatbed'];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Smart Home Factories
|
||||
// ============================================================================
|
||||
|
||||
import { SwitchFeature, type ISwitchFeatureOptions } from '../features/feature.switch.js';
|
||||
import { SensorFeature, type ISensorFeatureOptions } from '../features/feature.sensor.js';
|
||||
import { LightFeature, type ILightFeatureOptions } from '../features/feature.light.js';
|
||||
import { CoverFeature, type ICoverFeatureOptions } from '../features/feature.cover.js';
|
||||
import { LockFeature, type ILockFeatureOptions } from '../features/feature.lock.js';
|
||||
import { FanFeature, type IFanFeatureOptions } from '../features/feature.fan.js';
|
||||
import { ClimateFeature, type IClimateFeatureOptions } from '../features/feature.climate.js';
|
||||
import { CameraFeature, type ICameraFeatureOptions } from '../features/feature.camera.js';
|
||||
|
||||
import type {
|
||||
TSwitchProtocol,
|
||||
TSensorProtocol,
|
||||
TLightProtocol,
|
||||
TCoverProtocol,
|
||||
TLockProtocol,
|
||||
TFanProtocol,
|
||||
TClimateProtocol,
|
||||
TCameraProtocol,
|
||||
ISwitchProtocolClient,
|
||||
ISensorProtocolClient,
|
||||
ILightProtocolClient,
|
||||
ICoverProtocolClient,
|
||||
ILockProtocolClient,
|
||||
IFanProtocolClient,
|
||||
IClimateProtocolClient,
|
||||
ICameraProtocolClient,
|
||||
ILightCapabilities,
|
||||
ICoverCapabilities,
|
||||
IFanCapabilities,
|
||||
IClimateCapabilities,
|
||||
ICameraCapabilities,
|
||||
TSensorDeviceClass,
|
||||
TSensorStateClass,
|
||||
TCoverDeviceClass,
|
||||
} from '../interfaces/smarthome.interfaces.js';
|
||||
|
||||
// Smart Switch Factory
|
||||
export interface ISmartSwitchDiscoveryInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
port: number;
|
||||
entityId: string;
|
||||
protocol: TSwitchProtocol;
|
||||
protocolClient: ISwitchProtocolClient;
|
||||
deviceClass?: 'outlet' | 'switch';
|
||||
}
|
||||
|
||||
export function createSmartSwitch(
|
||||
info: ISmartSwitchDiscoveryInfo,
|
||||
retryOptions?: IRetryOptions
|
||||
): UniversalDevice {
|
||||
const device = new UniversalDevice(info.address, info.port, {
|
||||
name: info.name,
|
||||
retryOptions,
|
||||
});
|
||||
|
||||
(device as { id: string }).id = info.id;
|
||||
|
||||
const switchFeature = new SwitchFeature(device.getDeviceReference(), info.port, {
|
||||
protocol: info.protocol,
|
||||
entityId: info.entityId,
|
||||
protocolClient: info.protocolClient,
|
||||
deviceClass: info.deviceClass,
|
||||
});
|
||||
|
||||
device.addFeature(switchFeature);
|
||||
return device;
|
||||
}
|
||||
|
||||
// Smart Sensor Factory
|
||||
export interface ISmartSensorDiscoveryInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
port: number;
|
||||
entityId: string;
|
||||
protocol: TSensorProtocol;
|
||||
protocolClient: ISensorProtocolClient;
|
||||
deviceClass?: TSensorDeviceClass;
|
||||
stateClass?: TSensorStateClass;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export function createSmartSensor(
|
||||
info: ISmartSensorDiscoveryInfo,
|
||||
retryOptions?: IRetryOptions
|
||||
): UniversalDevice {
|
||||
const device = new UniversalDevice(info.address, info.port, {
|
||||
name: info.name,
|
||||
retryOptions,
|
||||
});
|
||||
|
||||
(device as { id: string }).id = info.id;
|
||||
|
||||
const sensorFeature = new SensorFeature(device.getDeviceReference(), info.port, {
|
||||
protocol: info.protocol,
|
||||
entityId: info.entityId,
|
||||
protocolClient: info.protocolClient,
|
||||
deviceClass: info.deviceClass,
|
||||
stateClass: info.stateClass,
|
||||
unit: info.unit,
|
||||
});
|
||||
|
||||
device.addFeature(sensorFeature);
|
||||
return device;
|
||||
}
|
||||
|
||||
// Smart Light Factory
|
||||
export interface ISmartLightDiscoveryInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
port: number;
|
||||
entityId: string;
|
||||
protocol: TLightProtocol;
|
||||
protocolClient: ILightProtocolClient;
|
||||
capabilities?: Partial<ILightCapabilities>;
|
||||
}
|
||||
|
||||
export function createSmartLight(
|
||||
info: ISmartLightDiscoveryInfo,
|
||||
retryOptions?: IRetryOptions
|
||||
): UniversalDevice {
|
||||
const device = new UniversalDevice(info.address, info.port, {
|
||||
name: info.name,
|
||||
retryOptions,
|
||||
});
|
||||
|
||||
(device as { id: string }).id = info.id;
|
||||
|
||||
const lightFeature = new LightFeature(device.getDeviceReference(), info.port, {
|
||||
protocol: info.protocol,
|
||||
entityId: info.entityId,
|
||||
protocolClient: info.protocolClient,
|
||||
capabilities: info.capabilities,
|
||||
});
|
||||
|
||||
device.addFeature(lightFeature);
|
||||
return device;
|
||||
}
|
||||
|
||||
// Smart Cover Factory
|
||||
export interface ISmartCoverDiscoveryInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
port: number;
|
||||
entityId: string;
|
||||
protocol: TCoverProtocol;
|
||||
protocolClient: ICoverProtocolClient;
|
||||
deviceClass?: TCoverDeviceClass;
|
||||
capabilities?: Partial<ICoverCapabilities>;
|
||||
}
|
||||
|
||||
export function createSmartCover(
|
||||
info: ISmartCoverDiscoveryInfo,
|
||||
retryOptions?: IRetryOptions
|
||||
): UniversalDevice {
|
||||
const device = new UniversalDevice(info.address, info.port, {
|
||||
name: info.name,
|
||||
retryOptions,
|
||||
});
|
||||
|
||||
(device as { id: string }).id = info.id;
|
||||
|
||||
const coverFeature = new CoverFeature(device.getDeviceReference(), info.port, {
|
||||
protocol: info.protocol,
|
||||
entityId: info.entityId,
|
||||
protocolClient: info.protocolClient,
|
||||
deviceClass: info.deviceClass,
|
||||
capabilities: info.capabilities,
|
||||
});
|
||||
|
||||
device.addFeature(coverFeature);
|
||||
return device;
|
||||
}
|
||||
|
||||
// Smart Lock Factory
|
||||
export interface ISmartLockDiscoveryInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
port: number;
|
||||
entityId: string;
|
||||
protocol: TLockProtocol;
|
||||
protocolClient: ILockProtocolClient;
|
||||
supportsOpen?: boolean;
|
||||
}
|
||||
|
||||
export function createSmartLock(
|
||||
info: ISmartLockDiscoveryInfo,
|
||||
retryOptions?: IRetryOptions
|
||||
): UniversalDevice {
|
||||
const device = new UniversalDevice(info.address, info.port, {
|
||||
name: info.name,
|
||||
retryOptions,
|
||||
});
|
||||
|
||||
(device as { id: string }).id = info.id;
|
||||
|
||||
const lockFeature = new LockFeature(device.getDeviceReference(), info.port, {
|
||||
protocol: info.protocol,
|
||||
entityId: info.entityId,
|
||||
protocolClient: info.protocolClient,
|
||||
supportsOpen: info.supportsOpen,
|
||||
});
|
||||
|
||||
device.addFeature(lockFeature);
|
||||
return device;
|
||||
}
|
||||
|
||||
// Smart Fan Factory
|
||||
export interface ISmartFanDiscoveryInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
port: number;
|
||||
entityId: string;
|
||||
protocol: TFanProtocol;
|
||||
protocolClient: IFanProtocolClient;
|
||||
capabilities?: Partial<IFanCapabilities>;
|
||||
}
|
||||
|
||||
export function createSmartFan(
|
||||
info: ISmartFanDiscoveryInfo,
|
||||
retryOptions?: IRetryOptions
|
||||
): UniversalDevice {
|
||||
const device = new UniversalDevice(info.address, info.port, {
|
||||
name: info.name,
|
||||
retryOptions,
|
||||
});
|
||||
|
||||
(device as { id: string }).id = info.id;
|
||||
|
||||
const fanFeature = new FanFeature(device.getDeviceReference(), info.port, {
|
||||
protocol: info.protocol,
|
||||
entityId: info.entityId,
|
||||
protocolClient: info.protocolClient,
|
||||
capabilities: info.capabilities,
|
||||
});
|
||||
|
||||
device.addFeature(fanFeature);
|
||||
return device;
|
||||
}
|
||||
|
||||
// Smart Climate Factory
|
||||
export interface ISmartClimateDiscoveryInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
port: number;
|
||||
entityId: string;
|
||||
protocol: TClimateProtocol;
|
||||
protocolClient: IClimateProtocolClient;
|
||||
capabilities?: Partial<IClimateCapabilities>;
|
||||
}
|
||||
|
||||
export function createSmartClimate(
|
||||
info: ISmartClimateDiscoveryInfo,
|
||||
retryOptions?: IRetryOptions
|
||||
): UniversalDevice {
|
||||
const device = new UniversalDevice(info.address, info.port, {
|
||||
name: info.name,
|
||||
retryOptions,
|
||||
});
|
||||
|
||||
(device as { id: string }).id = info.id;
|
||||
|
||||
const climateFeature = new ClimateFeature(device.getDeviceReference(), info.port, {
|
||||
protocol: info.protocol,
|
||||
entityId: info.entityId,
|
||||
protocolClient: info.protocolClient,
|
||||
capabilities: info.capabilities,
|
||||
});
|
||||
|
||||
device.addFeature(climateFeature);
|
||||
return device;
|
||||
}
|
||||
|
||||
// Smart Camera Factory
|
||||
export interface ISmartCameraDiscoveryInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
port: number;
|
||||
entityId: string;
|
||||
protocol: TCameraProtocol;
|
||||
protocolClient: ICameraProtocolClient;
|
||||
capabilities?: Partial<ICameraCapabilities>;
|
||||
}
|
||||
|
||||
export function createSmartCamera(
|
||||
info: ISmartCameraDiscoveryInfo,
|
||||
retryOptions?: IRetryOptions
|
||||
): UniversalDevice {
|
||||
const device = new UniversalDevice(info.address, info.port, {
|
||||
name: info.name,
|
||||
retryOptions,
|
||||
});
|
||||
|
||||
(device as { id: string }).id = info.id;
|
||||
|
||||
const cameraFeature = new CameraFeature(device.getDeviceReference(), info.port, {
|
||||
protocol: info.protocol,
|
||||
entityId: info.entityId,
|
||||
protocolClient: info.protocolClient,
|
||||
capabilities: info.capabilities,
|
||||
});
|
||||
|
||||
device.addFeature(cameraFeature);
|
||||
return device;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Exports
|
||||
// ============================================================================
|
||||
|
||||
export {
|
||||
// Re-export device and feature types for convenience
|
||||
UniversalDevice,
|
||||
ScanFeature,
|
||||
PrintFeature,
|
||||
PlaybackFeature,
|
||||
VolumeFeature,
|
||||
PowerFeature,
|
||||
SnmpFeature,
|
||||
// Smart home features
|
||||
SwitchFeature,
|
||||
SensorFeature,
|
||||
LightFeature,
|
||||
CoverFeature,
|
||||
LockFeature,
|
||||
FanFeature,
|
||||
ClimateFeature,
|
||||
CameraFeature,
|
||||
};
|
||||
214
ts/features/feature.camera.ts
Normal file
214
ts/features/feature.camera.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Camera Feature
|
||||
* Provides control for smart cameras (snapshots, streams)
|
||||
*/
|
||||
|
||||
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
||||
import type { IFeatureOptions } from '../interfaces/feature.interfaces.js';
|
||||
import type {
|
||||
TCameraProtocol,
|
||||
ICameraCapabilities,
|
||||
ICameraState,
|
||||
ICameraFeatureInfo,
|
||||
ICameraProtocolClient,
|
||||
} from '../interfaces/smarthome.interfaces.js';
|
||||
|
||||
/**
|
||||
* Options for creating a CameraFeature
|
||||
*/
|
||||
export interface ICameraFeatureOptions extends IFeatureOptions {
|
||||
/** Protocol type */
|
||||
protocol: TCameraProtocol;
|
||||
/** Entity ID (for Home Assistant) */
|
||||
entityId: string;
|
||||
/** Protocol client for the camera */
|
||||
protocolClient: ICameraProtocolClient;
|
||||
/** Camera capabilities */
|
||||
capabilities?: Partial<ICameraCapabilities>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Camera Feature - snapshot and stream access
|
||||
*
|
||||
* Protocol-agnostic: works with Home Assistant, ONVIF, RTSP, etc.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const camera = device.getFeature<CameraFeature>('camera');
|
||||
* if (camera) {
|
||||
* const snapshot = await camera.getSnapshot();
|
||||
* const streamUrl = await camera.getStreamUrl();
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class CameraFeature extends Feature {
|
||||
public readonly type = 'camera' as const;
|
||||
public readonly protocol: TCameraProtocol;
|
||||
|
||||
/** Entity ID (e.g., "camera.front_door") */
|
||||
public readonly entityId: string;
|
||||
|
||||
/** Capabilities */
|
||||
public readonly capabilities: ICameraCapabilities;
|
||||
|
||||
/** Current state */
|
||||
protected _isRecording: boolean = false;
|
||||
protected _isStreaming: boolean = false;
|
||||
protected _motionDetected: boolean = false;
|
||||
|
||||
/** Protocol client for the camera */
|
||||
private protocolClient: ICameraProtocolClient;
|
||||
|
||||
constructor(
|
||||
device: TDeviceReference,
|
||||
port: number,
|
||||
options: ICameraFeatureOptions
|
||||
) {
|
||||
super(device, port, options);
|
||||
this.protocol = options.protocol;
|
||||
this.entityId = options.entityId;
|
||||
this.protocolClient = options.protocolClient;
|
||||
|
||||
this.capabilities = {
|
||||
supportsStream: options.capabilities?.supportsStream ?? true,
|
||||
supportsPtz: options.capabilities?.supportsPtz ?? false,
|
||||
supportsSnapshot: options.capabilities?.supportsSnapshot ?? true,
|
||||
supportsMotionDetection: options.capabilities?.supportsMotionDetection ?? false,
|
||||
frontendStreamType: options.capabilities?.frontendStreamType,
|
||||
streamUrl: options.capabilities?.streamUrl,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Properties
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Check if recording (cached)
|
||||
*/
|
||||
public get isRecording(): boolean {
|
||||
return this._isRecording;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if streaming (cached)
|
||||
*/
|
||||
public get isStreaming(): boolean {
|
||||
return this._isStreaming;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if motion detected (cached)
|
||||
*/
|
||||
public get motionDetected(): boolean {
|
||||
return this._motionDetected;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Connection
|
||||
// ============================================================================
|
||||
|
||||
protected async doConnect(): Promise<void> {
|
||||
try {
|
||||
const state = await this.protocolClient.getState(this.entityId);
|
||||
this.updateStateInternal(state);
|
||||
} catch {
|
||||
// Ignore errors fetching initial state
|
||||
}
|
||||
}
|
||||
|
||||
protected async doDisconnect(): Promise<void> {
|
||||
// Nothing to disconnect
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Camera Access
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get a snapshot image from the camera
|
||||
* @returns Buffer containing image data
|
||||
*/
|
||||
public async getSnapshot(): Promise<Buffer> {
|
||||
if (!this.capabilities.supportsSnapshot) {
|
||||
throw new Error('Camera does not support snapshots');
|
||||
}
|
||||
|
||||
return this.protocolClient.getSnapshot(this.entityId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get snapshot URL
|
||||
* @returns URL for the snapshot image
|
||||
*/
|
||||
public async getSnapshotUrl(): Promise<string> {
|
||||
if (!this.capabilities.supportsSnapshot) {
|
||||
throw new Error('Camera does not support snapshots');
|
||||
}
|
||||
|
||||
return this.protocolClient.getSnapshotUrl(this.entityId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stream URL
|
||||
* @returns URL for the video stream
|
||||
*/
|
||||
public async getStreamUrl(): Promise<string> {
|
||||
if (!this.capabilities.supportsStream) {
|
||||
throw new Error('Camera does not support streaming');
|
||||
}
|
||||
|
||||
return this.protocolClient.getStreamUrl(this.entityId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current state as object
|
||||
*/
|
||||
public getState(): ICameraState {
|
||||
return {
|
||||
isRecording: this._isRecording,
|
||||
isStreaming: this._isStreaming,
|
||||
motionDetected: this._motionDetected,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh state from the device
|
||||
*/
|
||||
public async refreshState(): Promise<ICameraState> {
|
||||
const state = await this.protocolClient.getState(this.entityId);
|
||||
this.updateStateInternal(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update state from external source
|
||||
*/
|
||||
public updateState(state: ICameraState): void {
|
||||
this.updateStateInternal(state);
|
||||
this.emit('state:changed', state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal state update
|
||||
*/
|
||||
private updateStateInternal(state: ICameraState): void {
|
||||
this._isRecording = state.isRecording;
|
||||
this._isStreaming = state.isStreaming;
|
||||
this._motionDetected = state.motionDetected;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Feature Info
|
||||
// ============================================================================
|
||||
|
||||
public getFeatureInfo(): ICameraFeatureInfo {
|
||||
return {
|
||||
...this.getBaseFeatureInfo(),
|
||||
type: 'camera',
|
||||
protocol: this.protocol,
|
||||
capabilities: this.capabilities,
|
||||
currentState: this.getState(),
|
||||
};
|
||||
}
|
||||
}
|
||||
407
ts/features/feature.climate.ts
Normal file
407
ts/features/feature.climate.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
/**
|
||||
* Climate Feature
|
||||
* Provides control for thermostats and HVAC systems
|
||||
*/
|
||||
|
||||
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
||||
import type { IFeatureOptions } from '../interfaces/feature.interfaces.js';
|
||||
import type {
|
||||
TClimateProtocol,
|
||||
THvacMode,
|
||||
THvacAction,
|
||||
IClimateCapabilities,
|
||||
IClimateState,
|
||||
IClimateFeatureInfo,
|
||||
IClimateProtocolClient,
|
||||
} from '../interfaces/smarthome.interfaces.js';
|
||||
|
||||
/**
|
||||
* Options for creating a ClimateFeature
|
||||
*/
|
||||
export interface IClimateFeatureOptions extends IFeatureOptions {
|
||||
/** Protocol type */
|
||||
protocol: TClimateProtocol;
|
||||
/** Entity ID (for Home Assistant) */
|
||||
entityId: string;
|
||||
/** Protocol client for controlling the climate device */
|
||||
protocolClient: IClimateProtocolClient;
|
||||
/** Climate capabilities */
|
||||
capabilities?: Partial<IClimateCapabilities>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Climate Feature - thermostat and HVAC control
|
||||
*
|
||||
* Protocol-agnostic: works with Home Assistant, Nest, Ecobee, MQTT, etc.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const climate = device.getFeature<ClimateFeature>('climate');
|
||||
* if (climate) {
|
||||
* await climate.setHvacMode('heat');
|
||||
* await climate.setTargetTemp(21);
|
||||
* console.log(`Current: ${climate.currentTemp}°C, Target: ${climate.targetTemp}°C`);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class ClimateFeature extends Feature {
|
||||
public readonly type = 'climate' as const;
|
||||
public readonly protocol: TClimateProtocol;
|
||||
|
||||
/** Entity ID (e.g., "climate.living_room") */
|
||||
public readonly entityId: string;
|
||||
|
||||
/** Capabilities */
|
||||
public readonly capabilities: IClimateCapabilities;
|
||||
|
||||
/** Current state */
|
||||
protected _currentTemp?: number;
|
||||
protected _targetTemp?: number;
|
||||
protected _targetTempHigh?: number;
|
||||
protected _targetTempLow?: number;
|
||||
protected _hvacMode: THvacMode = 'off';
|
||||
protected _hvacAction?: THvacAction;
|
||||
protected _presetMode?: string;
|
||||
protected _fanMode?: string;
|
||||
protected _swingMode?: string;
|
||||
protected _humidity?: number;
|
||||
protected _targetHumidity?: number;
|
||||
protected _auxHeat?: boolean;
|
||||
|
||||
/** Protocol client for controlling the climate device */
|
||||
private protocolClient: IClimateProtocolClient;
|
||||
|
||||
constructor(
|
||||
device: TDeviceReference,
|
||||
port: number,
|
||||
options: IClimateFeatureOptions
|
||||
) {
|
||||
super(device, port, options);
|
||||
this.protocol = options.protocol;
|
||||
this.entityId = options.entityId;
|
||||
this.protocolClient = options.protocolClient;
|
||||
|
||||
this.capabilities = {
|
||||
hvacModes: options.capabilities?.hvacModes ?? ['off', 'heat', 'cool', 'auto'],
|
||||
presetModes: options.capabilities?.presetModes,
|
||||
fanModes: options.capabilities?.fanModes,
|
||||
swingModes: options.capabilities?.swingModes,
|
||||
supportsTargetTemp: options.capabilities?.supportsTargetTemp ?? true,
|
||||
supportsTargetTempRange: options.capabilities?.supportsTargetTempRange ?? false,
|
||||
supportsHumidity: options.capabilities?.supportsHumidity ?? false,
|
||||
supportsAuxHeat: options.capabilities?.supportsAuxHeat ?? false,
|
||||
minTemp: options.capabilities?.minTemp ?? 7,
|
||||
maxTemp: options.capabilities?.maxTemp ?? 35,
|
||||
tempStep: options.capabilities?.tempStep ?? 0.5,
|
||||
minHumidity: options.capabilities?.minHumidity,
|
||||
maxHumidity: options.capabilities?.maxHumidity,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Properties
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get current temperature (cached)
|
||||
*/
|
||||
public get currentTemp(): number | undefined {
|
||||
return this._currentTemp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get target temperature (cached)
|
||||
*/
|
||||
public get targetTemp(): number | undefined {
|
||||
return this._targetTemp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get target temperature high (for heat_cool mode, cached)
|
||||
*/
|
||||
public get targetTempHigh(): number | undefined {
|
||||
return this._targetTempHigh;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get target temperature low (for heat_cool mode, cached)
|
||||
*/
|
||||
public get targetTempLow(): number | undefined {
|
||||
return this._targetTempLow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current HVAC mode (cached)
|
||||
*/
|
||||
public get hvacMode(): THvacMode {
|
||||
return this._hvacMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current HVAC action (cached)
|
||||
*/
|
||||
public get hvacAction(): THvacAction | undefined {
|
||||
return this._hvacAction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current preset mode (cached)
|
||||
*/
|
||||
public get presetMode(): string | undefined {
|
||||
return this._presetMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current fan mode (cached)
|
||||
*/
|
||||
public get fanMode(): string | undefined {
|
||||
return this._fanMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current swing mode (cached)
|
||||
*/
|
||||
public get swingMode(): string | undefined {
|
||||
return this._swingMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current humidity (cached)
|
||||
*/
|
||||
public get humidity(): number | undefined {
|
||||
return this._humidity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get target humidity (cached)
|
||||
*/
|
||||
public get targetHumidity(): number | undefined {
|
||||
return this._targetHumidity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get aux heat state (cached)
|
||||
*/
|
||||
public get auxHeat(): boolean | undefined {
|
||||
return this._auxHeat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available HVAC modes
|
||||
*/
|
||||
public get hvacModes(): THvacMode[] {
|
||||
return this.capabilities.hvacModes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available preset modes
|
||||
*/
|
||||
public get presetModes(): string[] | undefined {
|
||||
return this.capabilities.presetModes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available fan modes
|
||||
*/
|
||||
public get fanModes(): string[] | undefined {
|
||||
return this.capabilities.fanModes;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Connection
|
||||
// ============================================================================
|
||||
|
||||
protected async doConnect(): Promise<void> {
|
||||
try {
|
||||
const state = await this.protocolClient.getState(this.entityId);
|
||||
this.updateStateInternal(state);
|
||||
} catch {
|
||||
// Ignore errors fetching initial state
|
||||
}
|
||||
}
|
||||
|
||||
protected async doDisconnect(): Promise<void> {
|
||||
// Nothing to disconnect
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Climate Control
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Set HVAC mode
|
||||
* @param mode HVAC mode (off, heat, cool, etc.)
|
||||
*/
|
||||
public async setHvacMode(mode: THvacMode): Promise<void> {
|
||||
if (!this.capabilities.hvacModes.includes(mode)) {
|
||||
throw new Error(`HVAC mode ${mode} not supported`);
|
||||
}
|
||||
|
||||
await this.protocolClient.setHvacMode(this.entityId, mode);
|
||||
this._hvacMode = mode;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set target temperature
|
||||
* @param temp Target temperature
|
||||
*/
|
||||
public async setTargetTemp(temp: number): Promise<void> {
|
||||
if (!this.capabilities.supportsTargetTemp) {
|
||||
throw new Error('Climate device does not support target temperature');
|
||||
}
|
||||
|
||||
const clamped = Math.max(
|
||||
this.capabilities.minTemp,
|
||||
Math.min(this.capabilities.maxTemp, temp)
|
||||
);
|
||||
|
||||
await this.protocolClient.setTargetTemp(this.entityId, clamped);
|
||||
this._targetTemp = clamped;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set target temperature range (for heat_cool mode)
|
||||
* @param low Low temperature
|
||||
* @param high High temperature
|
||||
*/
|
||||
public async setTargetTempRange(low: number, high: number): Promise<void> {
|
||||
if (!this.capabilities.supportsTargetTempRange) {
|
||||
throw new Error('Climate device does not support temperature range');
|
||||
}
|
||||
|
||||
const clampedLow = Math.max(this.capabilities.minTemp, Math.min(this.capabilities.maxTemp, low));
|
||||
const clampedHigh = Math.max(this.capabilities.minTemp, Math.min(this.capabilities.maxTemp, high));
|
||||
|
||||
await this.protocolClient.setTargetTempRange(this.entityId, clampedLow, clampedHigh);
|
||||
this._targetTempLow = clampedLow;
|
||||
this._targetTempHigh = clampedHigh;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set preset mode
|
||||
* @param preset Preset mode name
|
||||
*/
|
||||
public async setPresetMode(preset: string): Promise<void> {
|
||||
if (!this.capabilities.presetModes?.includes(preset)) {
|
||||
throw new Error(`Preset mode ${preset} not supported`);
|
||||
}
|
||||
|
||||
await this.protocolClient.setPresetMode(this.entityId, preset);
|
||||
this._presetMode = preset;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set fan mode
|
||||
* @param mode Fan mode name
|
||||
*/
|
||||
public async setFanMode(mode: string): Promise<void> {
|
||||
if (!this.capabilities.fanModes?.includes(mode)) {
|
||||
throw new Error(`Fan mode ${mode} not supported`);
|
||||
}
|
||||
|
||||
await this.protocolClient.setFanMode(this.entityId, mode);
|
||||
this._fanMode = mode;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set swing mode
|
||||
* @param mode Swing mode name
|
||||
*/
|
||||
public async setSwingMode(mode: string): Promise<void> {
|
||||
if (!this.capabilities.swingModes?.includes(mode)) {
|
||||
throw new Error(`Swing mode ${mode} not supported`);
|
||||
}
|
||||
|
||||
await this.protocolClient.setSwingMode(this.entityId, mode);
|
||||
this._swingMode = mode;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set aux heat
|
||||
* @param enabled Whether aux heat is enabled
|
||||
*/
|
||||
public async setAuxHeat(enabled: boolean): Promise<void> {
|
||||
if (!this.capabilities.supportsAuxHeat) {
|
||||
throw new Error('Climate device does not support aux heat');
|
||||
}
|
||||
|
||||
await this.protocolClient.setAuxHeat(this.entityId, enabled);
|
||||
this._auxHeat = enabled;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current state as object
|
||||
*/
|
||||
public getState(): IClimateState {
|
||||
return {
|
||||
currentTemp: this._currentTemp,
|
||||
targetTemp: this._targetTemp,
|
||||
targetTempHigh: this._targetTempHigh,
|
||||
targetTempLow: this._targetTempLow,
|
||||
hvacMode: this._hvacMode,
|
||||
hvacAction: this._hvacAction,
|
||||
presetMode: this._presetMode,
|
||||
fanMode: this._fanMode,
|
||||
swingMode: this._swingMode,
|
||||
humidity: this._humidity,
|
||||
targetHumidity: this._targetHumidity,
|
||||
auxHeat: this._auxHeat,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh state from the device
|
||||
*/
|
||||
public async refreshState(): Promise<IClimateState> {
|
||||
const state = await this.protocolClient.getState(this.entityId);
|
||||
this.updateStateInternal(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update state from external source
|
||||
*/
|
||||
public updateState(state: IClimateState): void {
|
||||
this.updateStateInternal(state);
|
||||
this.emit('state:changed', state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal state update
|
||||
*/
|
||||
private updateStateInternal(state: IClimateState): void {
|
||||
this._currentTemp = state.currentTemp;
|
||||
this._targetTemp = state.targetTemp;
|
||||
this._targetTempHigh = state.targetTempHigh;
|
||||
this._targetTempLow = state.targetTempLow;
|
||||
this._hvacMode = state.hvacMode;
|
||||
this._hvacAction = state.hvacAction;
|
||||
this._presetMode = state.presetMode;
|
||||
this._fanMode = state.fanMode;
|
||||
this._swingMode = state.swingMode;
|
||||
this._humidity = state.humidity;
|
||||
this._targetHumidity = state.targetHumidity;
|
||||
this._auxHeat = state.auxHeat;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Feature Info
|
||||
// ============================================================================
|
||||
|
||||
public getFeatureInfo(): IClimateFeatureInfo {
|
||||
return {
|
||||
...this.getBaseFeatureInfo(),
|
||||
type: 'climate',
|
||||
protocol: this.protocol,
|
||||
capabilities: this.capabilities,
|
||||
currentState: this.getState(),
|
||||
};
|
||||
}
|
||||
}
|
||||
278
ts/features/feature.cover.ts
Normal file
278
ts/features/feature.cover.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* Cover Feature
|
||||
* Provides control for covers, blinds, garage doors, etc.
|
||||
*/
|
||||
|
||||
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
||||
import type { IFeatureOptions } from '../interfaces/feature.interfaces.js';
|
||||
import type {
|
||||
TCoverProtocol,
|
||||
TCoverDeviceClass,
|
||||
TCoverState,
|
||||
ICoverCapabilities,
|
||||
ICoverStateInfo,
|
||||
ICoverFeatureInfo,
|
||||
ICoverProtocolClient,
|
||||
} from '../interfaces/smarthome.interfaces.js';
|
||||
|
||||
/**
|
||||
* Options for creating a CoverFeature
|
||||
*/
|
||||
export interface ICoverFeatureOptions extends IFeatureOptions {
|
||||
/** Protocol type */
|
||||
protocol: TCoverProtocol;
|
||||
/** Entity ID (for Home Assistant) */
|
||||
entityId: string;
|
||||
/** Protocol client for controlling the cover */
|
||||
protocolClient: ICoverProtocolClient;
|
||||
/** Device class */
|
||||
deviceClass?: TCoverDeviceClass;
|
||||
/** Cover capabilities */
|
||||
capabilities?: Partial<ICoverCapabilities>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cover Feature - control for blinds, garage doors, etc.
|
||||
*
|
||||
* Protocol-agnostic: works with Home Assistant, MQTT, Somfy, etc.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const cover = device.getFeature<CoverFeature>('cover');
|
||||
* if (cover) {
|
||||
* await cover.open();
|
||||
* await cover.setPosition(50); // 50% open
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class CoverFeature extends Feature {
|
||||
public readonly type = 'cover' as const;
|
||||
public readonly protocol: TCoverProtocol;
|
||||
|
||||
/** Entity ID (e.g., "cover.garage_door") */
|
||||
public readonly entityId: string;
|
||||
|
||||
/** Capabilities */
|
||||
public readonly capabilities: ICoverCapabilities;
|
||||
|
||||
/** Current cover state (not connection state) */
|
||||
protected _coverState: TCoverState = 'unknown';
|
||||
protected _position?: number;
|
||||
protected _tiltPosition?: number;
|
||||
|
||||
/** Protocol client for controlling the cover */
|
||||
private protocolClient: ICoverProtocolClient;
|
||||
|
||||
constructor(
|
||||
device: TDeviceReference,
|
||||
port: number,
|
||||
options: ICoverFeatureOptions
|
||||
) {
|
||||
super(device, port, options);
|
||||
this.protocol = options.protocol;
|
||||
this.entityId = options.entityId;
|
||||
this.protocolClient = options.protocolClient;
|
||||
|
||||
this.capabilities = {
|
||||
deviceClass: options.deviceClass,
|
||||
supportsOpen: options.capabilities?.supportsOpen ?? true,
|
||||
supportsClose: options.capabilities?.supportsClose ?? true,
|
||||
supportsStop: options.capabilities?.supportsStop ?? true,
|
||||
supportsPosition: options.capabilities?.supportsPosition ?? false,
|
||||
supportsTilt: options.capabilities?.supportsTilt ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Properties
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get current state (cached)
|
||||
*/
|
||||
public get coverState(): TCoverState {
|
||||
return this._coverState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current position 0-100 (cached)
|
||||
* 0 = closed, 100 = fully open
|
||||
*/
|
||||
public get position(): number | undefined {
|
||||
return this._position;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current tilt position 0-100 (cached)
|
||||
*/
|
||||
public get tiltPosition(): number | undefined {
|
||||
return this._tiltPosition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cover is open
|
||||
*/
|
||||
public get isOpen(): boolean {
|
||||
return this._coverState === 'open';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cover is closed
|
||||
*/
|
||||
public get isClosed(): boolean {
|
||||
return this._coverState === 'closed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cover is opening
|
||||
*/
|
||||
public get isOpening(): boolean {
|
||||
return this._coverState === 'opening';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cover is closing
|
||||
*/
|
||||
public get isClosing(): boolean {
|
||||
return this._coverState === 'closing';
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Connection
|
||||
// ============================================================================
|
||||
|
||||
protected async doConnect(): Promise<void> {
|
||||
try {
|
||||
const state = await this.protocolClient.getState(this.entityId);
|
||||
this.updateStateInternal(state);
|
||||
} catch {
|
||||
// Ignore errors fetching initial state
|
||||
}
|
||||
}
|
||||
|
||||
protected async doDisconnect(): Promise<void> {
|
||||
// Nothing to disconnect
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Cover Control
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Open the cover
|
||||
*/
|
||||
public async open(): Promise<void> {
|
||||
if (!this.capabilities.supportsOpen) {
|
||||
throw new Error('Cover does not support open');
|
||||
}
|
||||
await this.protocolClient.open(this.entityId);
|
||||
this._coverState = 'opening';
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the cover
|
||||
*/
|
||||
public async close(): Promise<void> {
|
||||
if (!this.capabilities.supportsClose) {
|
||||
throw new Error('Cover does not support close');
|
||||
}
|
||||
await this.protocolClient.close(this.entityId);
|
||||
this._coverState = 'closing';
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the cover
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
if (!this.capabilities.supportsStop) {
|
||||
throw new Error('Cover does not support stop');
|
||||
}
|
||||
await this.protocolClient.stop(this.entityId);
|
||||
this._coverState = 'stopped';
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set cover position
|
||||
* @param position Position 0-100 (0 = closed, 100 = open)
|
||||
*/
|
||||
public async setPosition(position: number): Promise<void> {
|
||||
if (!this.capabilities.supportsPosition) {
|
||||
throw new Error('Cover does not support position control');
|
||||
}
|
||||
|
||||
const clamped = Math.max(0, Math.min(100, Math.round(position)));
|
||||
await this.protocolClient.setPosition(this.entityId, clamped);
|
||||
this._position = clamped;
|
||||
this._coverState = clamped === 0 ? 'closed' : clamped === 100 ? 'open' : 'stopped';
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set tilt position
|
||||
* @param position Tilt position 0-100
|
||||
*/
|
||||
public async setTiltPosition(position: number): Promise<void> {
|
||||
if (!this.capabilities.supportsTilt) {
|
||||
throw new Error('Cover does not support tilt control');
|
||||
}
|
||||
|
||||
const clamped = Math.max(0, Math.min(100, Math.round(position)));
|
||||
await this.protocolClient.setTiltPosition(this.entityId, clamped);
|
||||
this._tiltPosition = clamped;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current state as object
|
||||
*/
|
||||
public getState(): ICoverStateInfo {
|
||||
return {
|
||||
state: this._coverState,
|
||||
position: this._position,
|
||||
tiltPosition: this._tiltPosition,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh state from the device
|
||||
*/
|
||||
public async refreshState(): Promise<ICoverStateInfo> {
|
||||
const state = await this.protocolClient.getState(this.entityId);
|
||||
this.updateStateInternal(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update state from external source
|
||||
*/
|
||||
public updateState(state: ICoverStateInfo): void {
|
||||
this.updateStateInternal(state);
|
||||
this.emit('state:changed', state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal state update
|
||||
*/
|
||||
private updateStateInternal(state: ICoverStateInfo): void {
|
||||
this._coverState = state.state;
|
||||
this._position = state.position;
|
||||
this._tiltPosition = state.tiltPosition;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Feature Info
|
||||
// ============================================================================
|
||||
|
||||
public getFeatureInfo(): ICoverFeatureInfo {
|
||||
return {
|
||||
...this.getBaseFeatureInfo(),
|
||||
type: 'cover',
|
||||
protocol: this.protocol,
|
||||
capabilities: this.capabilities,
|
||||
currentState: this.getState(),
|
||||
};
|
||||
}
|
||||
}
|
||||
296
ts/features/feature.fan.ts
Normal file
296
ts/features/feature.fan.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* Fan Feature
|
||||
* Provides control for fans (speed, oscillation, direction)
|
||||
*/
|
||||
|
||||
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
||||
import type { IFeatureOptions } from '../interfaces/feature.interfaces.js';
|
||||
import type {
|
||||
TFanProtocol,
|
||||
TFanDirection,
|
||||
IFanCapabilities,
|
||||
IFanState,
|
||||
IFanFeatureInfo,
|
||||
IFanProtocolClient,
|
||||
} from '../interfaces/smarthome.interfaces.js';
|
||||
|
||||
/**
|
||||
* Options for creating a FanFeature
|
||||
*/
|
||||
export interface IFanFeatureOptions extends IFeatureOptions {
|
||||
/** Protocol type */
|
||||
protocol: TFanProtocol;
|
||||
/** Entity ID (for Home Assistant) */
|
||||
entityId: string;
|
||||
/** Protocol client for controlling the fan */
|
||||
protocolClient: IFanProtocolClient;
|
||||
/** Fan capabilities */
|
||||
capabilities?: Partial<IFanCapabilities>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fan Feature - speed, oscillation, and direction control
|
||||
*
|
||||
* Protocol-agnostic: works with Home Assistant, MQTT, Bond, etc.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const fan = device.getFeature<FanFeature>('fan');
|
||||
* if (fan) {
|
||||
* await fan.turnOn(75); // 75% speed
|
||||
* await fan.setOscillating(true);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class FanFeature extends Feature {
|
||||
public readonly type = 'fan' as const;
|
||||
public readonly protocol: TFanProtocol;
|
||||
|
||||
/** Entity ID (e.g., "fan.bedroom") */
|
||||
public readonly entityId: string;
|
||||
|
||||
/** Capabilities */
|
||||
public readonly capabilities: IFanCapabilities;
|
||||
|
||||
/** Current state */
|
||||
protected _isOn: boolean = false;
|
||||
protected _percentage?: number;
|
||||
protected _presetMode?: string;
|
||||
protected _oscillating?: boolean;
|
||||
protected _direction?: TFanDirection;
|
||||
|
||||
/** Protocol client for controlling the fan */
|
||||
private protocolClient: IFanProtocolClient;
|
||||
|
||||
constructor(
|
||||
device: TDeviceReference,
|
||||
port: number,
|
||||
options: IFanFeatureOptions
|
||||
) {
|
||||
super(device, port, options);
|
||||
this.protocol = options.protocol;
|
||||
this.entityId = options.entityId;
|
||||
this.protocolClient = options.protocolClient;
|
||||
|
||||
this.capabilities = {
|
||||
supportsSpeed: options.capabilities?.supportsSpeed ?? true,
|
||||
supportsOscillate: options.capabilities?.supportsOscillate ?? false,
|
||||
supportsDirection: options.capabilities?.supportsDirection ?? false,
|
||||
supportsPresetModes: options.capabilities?.supportsPresetModes ?? false,
|
||||
presetModes: options.capabilities?.presetModes,
|
||||
speedCount: options.capabilities?.speedCount,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Properties
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get current on/off state (cached)
|
||||
*/
|
||||
public get isOn(): boolean {
|
||||
return this._isOn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current speed percentage 0-100 (cached)
|
||||
*/
|
||||
public get percentage(): number | undefined {
|
||||
return this._percentage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current preset mode (cached)
|
||||
*/
|
||||
public get presetMode(): string | undefined {
|
||||
return this._presetMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get oscillating state (cached)
|
||||
*/
|
||||
public get oscillating(): boolean | undefined {
|
||||
return this._oscillating;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get direction (cached)
|
||||
*/
|
||||
public get direction(): TFanDirection | undefined {
|
||||
return this._direction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available preset modes
|
||||
*/
|
||||
public get presetModes(): string[] | undefined {
|
||||
return this.capabilities.presetModes;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Connection
|
||||
// ============================================================================
|
||||
|
||||
protected async doConnect(): Promise<void> {
|
||||
try {
|
||||
const state = await this.protocolClient.getState(this.entityId);
|
||||
this.updateStateInternal(state);
|
||||
} catch {
|
||||
// Ignore errors fetching initial state
|
||||
}
|
||||
}
|
||||
|
||||
protected async doDisconnect(): Promise<void> {
|
||||
// Nothing to disconnect
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Fan Control
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Turn on the fan
|
||||
* @param percentage Optional speed percentage
|
||||
*/
|
||||
public async turnOn(percentage?: number): Promise<void> {
|
||||
await this.protocolClient.turnOn(this.entityId, percentage);
|
||||
this._isOn = true;
|
||||
if (percentage !== undefined) {
|
||||
this._percentage = percentage;
|
||||
}
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn off the fan
|
||||
*/
|
||||
public async turnOff(): Promise<void> {
|
||||
await this.protocolClient.turnOff(this.entityId);
|
||||
this._isOn = false;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the fan
|
||||
*/
|
||||
public async toggle(): Promise<void> {
|
||||
await this.protocolClient.toggle(this.entityId);
|
||||
this._isOn = !this._isOn;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set speed percentage
|
||||
* @param percentage Speed 0-100
|
||||
*/
|
||||
public async setPercentage(percentage: number): Promise<void> {
|
||||
if (!this.capabilities.supportsSpeed) {
|
||||
throw new Error('Fan does not support speed control');
|
||||
}
|
||||
|
||||
const clamped = Math.max(0, Math.min(100, Math.round(percentage)));
|
||||
await this.protocolClient.setPercentage(this.entityId, clamped);
|
||||
this._percentage = clamped;
|
||||
this._isOn = clamped > 0;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set preset mode
|
||||
* @param mode Preset mode name
|
||||
*/
|
||||
public async setPresetMode(mode: string): Promise<void> {
|
||||
if (!this.capabilities.supportsPresetModes) {
|
||||
throw new Error('Fan does not support preset modes');
|
||||
}
|
||||
|
||||
await this.protocolClient.setPresetMode(this.entityId, mode);
|
||||
this._presetMode = mode;
|
||||
this._isOn = true;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set oscillating state
|
||||
* @param oscillating Whether to oscillate
|
||||
*/
|
||||
public async setOscillating(oscillating: boolean): Promise<void> {
|
||||
if (!this.capabilities.supportsOscillate) {
|
||||
throw new Error('Fan does not support oscillation');
|
||||
}
|
||||
|
||||
await this.protocolClient.setOscillating(this.entityId, oscillating);
|
||||
this._oscillating = oscillating;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set direction
|
||||
* @param direction forward or reverse
|
||||
*/
|
||||
public async setDirection(direction: TFanDirection): Promise<void> {
|
||||
if (!this.capabilities.supportsDirection) {
|
||||
throw new Error('Fan does not support direction control');
|
||||
}
|
||||
|
||||
await this.protocolClient.setDirection(this.entityId, direction);
|
||||
this._direction = direction;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current state as object
|
||||
*/
|
||||
public getState(): IFanState {
|
||||
return {
|
||||
isOn: this._isOn,
|
||||
percentage: this._percentage,
|
||||
presetMode: this._presetMode,
|
||||
oscillating: this._oscillating,
|
||||
direction: this._direction,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh state from the device
|
||||
*/
|
||||
public async refreshState(): Promise<IFanState> {
|
||||
const state = await this.protocolClient.getState(this.entityId);
|
||||
this.updateStateInternal(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update state from external source
|
||||
*/
|
||||
public updateState(state: IFanState): void {
|
||||
this.updateStateInternal(state);
|
||||
this.emit('state:changed', state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal state update
|
||||
*/
|
||||
private updateStateInternal(state: IFanState): void {
|
||||
this._isOn = state.isOn;
|
||||
this._percentage = state.percentage;
|
||||
this._presetMode = state.presetMode;
|
||||
this._oscillating = state.oscillating;
|
||||
this._direction = state.direction;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Feature Info
|
||||
// ============================================================================
|
||||
|
||||
public getFeatureInfo(): IFanFeatureInfo {
|
||||
return {
|
||||
...this.getBaseFeatureInfo(),
|
||||
type: 'fan',
|
||||
protocol: this.protocol,
|
||||
capabilities: this.capabilities,
|
||||
currentState: this.getState(),
|
||||
};
|
||||
}
|
||||
}
|
||||
369
ts/features/feature.light.ts
Normal file
369
ts/features/feature.light.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* Light Feature
|
||||
* Provides control for smart lights (brightness, color, effects)
|
||||
*/
|
||||
|
||||
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
||||
import type { IFeatureOptions } from '../interfaces/feature.interfaces.js';
|
||||
import type {
|
||||
TLightProtocol,
|
||||
ILightCapabilities,
|
||||
ILightState,
|
||||
ILightFeatureInfo,
|
||||
ILightProtocolClient,
|
||||
} from '../interfaces/smarthome.interfaces.js';
|
||||
|
||||
/**
|
||||
* Options for creating a LightFeature
|
||||
*/
|
||||
export interface ILightFeatureOptions extends IFeatureOptions {
|
||||
/** Protocol type */
|
||||
protocol: TLightProtocol;
|
||||
/** Entity ID (for Home Assistant) */
|
||||
entityId: string;
|
||||
/** Protocol client for controlling the light */
|
||||
protocolClient: ILightProtocolClient;
|
||||
/** Light capabilities */
|
||||
capabilities?: Partial<ILightCapabilities>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Light Feature - brightness, color, and effect control
|
||||
*
|
||||
* Protocol-agnostic: works with Home Assistant, Hue, MQTT, Zigbee, etc.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const light = device.getFeature<LightFeature>('light');
|
||||
* if (light) {
|
||||
* await light.turnOn({ brightness: 200 });
|
||||
* await light.setColorTemp(4000); // 4000K warm white
|
||||
* await light.setRgbColor(255, 100, 50);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class LightFeature extends Feature {
|
||||
public readonly type = 'light' as const;
|
||||
public readonly protocol: TLightProtocol;
|
||||
|
||||
/** Entity ID (e.g., "light.living_room") */
|
||||
public readonly entityId: string;
|
||||
|
||||
/** Capabilities */
|
||||
public readonly capabilities: ILightCapabilities;
|
||||
|
||||
/** Current state */
|
||||
protected _isOn: boolean = false;
|
||||
protected _brightness?: number;
|
||||
protected _colorTemp?: number;
|
||||
protected _colorTempMireds?: number;
|
||||
protected _rgbColor?: [number, number, number];
|
||||
protected _hsColor?: [number, number];
|
||||
protected _xyColor?: [number, number];
|
||||
protected _effect?: string;
|
||||
|
||||
/** Protocol client for controlling the light */
|
||||
private protocolClient: ILightProtocolClient;
|
||||
|
||||
constructor(
|
||||
device: TDeviceReference,
|
||||
port: number,
|
||||
options: ILightFeatureOptions
|
||||
) {
|
||||
super(device, port, options);
|
||||
this.protocol = options.protocol;
|
||||
this.entityId = options.entityId;
|
||||
this.protocolClient = options.protocolClient;
|
||||
|
||||
// Set capabilities with defaults
|
||||
this.capabilities = {
|
||||
supportsBrightness: options.capabilities?.supportsBrightness ?? false,
|
||||
supportsColorTemp: options.capabilities?.supportsColorTemp ?? false,
|
||||
supportsRgb: options.capabilities?.supportsRgb ?? false,
|
||||
supportsHs: options.capabilities?.supportsHs ?? false,
|
||||
supportsXy: options.capabilities?.supportsXy ?? false,
|
||||
supportsEffects: options.capabilities?.supportsEffects ?? false,
|
||||
supportsTransition: options.capabilities?.supportsTransition ?? true,
|
||||
effects: options.capabilities?.effects,
|
||||
minMireds: options.capabilities?.minMireds,
|
||||
maxMireds: options.capabilities?.maxMireds,
|
||||
minColorTempKelvin: options.capabilities?.minColorTempKelvin,
|
||||
maxColorTempKelvin: options.capabilities?.maxColorTempKelvin,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Properties
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get current on/off state (cached)
|
||||
*/
|
||||
public get isOn(): boolean {
|
||||
return this._isOn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current brightness 0-255 (cached)
|
||||
*/
|
||||
public get brightness(): number | undefined {
|
||||
return this._brightness;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current color temperature in Kelvin (cached)
|
||||
*/
|
||||
public get colorTemp(): number | undefined {
|
||||
return this._colorTemp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current color temperature in Mireds (cached)
|
||||
*/
|
||||
public get colorTempMireds(): number | undefined {
|
||||
return this._colorTempMireds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current RGB color (cached)
|
||||
*/
|
||||
public get rgbColor(): [number, number, number] | undefined {
|
||||
return this._rgbColor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current HS color [hue 0-360, saturation 0-100] (cached)
|
||||
*/
|
||||
public get hsColor(): [number, number] | undefined {
|
||||
return this._hsColor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current XY color (cached)
|
||||
*/
|
||||
public get xyColor(): [number, number] | undefined {
|
||||
return this._xyColor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current effect (cached)
|
||||
*/
|
||||
public get effect(): string | undefined {
|
||||
return this._effect;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available effects
|
||||
*/
|
||||
public get effects(): string[] | undefined {
|
||||
return this.capabilities.effects;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Connection
|
||||
// ============================================================================
|
||||
|
||||
protected async doConnect(): Promise<void> {
|
||||
// Fetch initial state
|
||||
try {
|
||||
const state = await this.protocolClient.getState(this.entityId);
|
||||
this.updateStateInternal(state);
|
||||
} catch {
|
||||
// Ignore errors fetching initial state
|
||||
}
|
||||
}
|
||||
|
||||
protected async doDisconnect(): Promise<void> {
|
||||
// Nothing to disconnect
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Light Control
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Turn on the light
|
||||
* @param options Optional settings to apply when turning on
|
||||
*/
|
||||
public async turnOn(options?: {
|
||||
brightness?: number;
|
||||
colorTemp?: number;
|
||||
rgb?: [number, number, number];
|
||||
transition?: number;
|
||||
}): Promise<void> {
|
||||
await this.protocolClient.turnOn(this.entityId, options);
|
||||
this._isOn = true;
|
||||
if (options?.brightness !== undefined) {
|
||||
this._brightness = options.brightness;
|
||||
}
|
||||
if (options?.colorTemp !== undefined) {
|
||||
this._colorTemp = options.colorTemp;
|
||||
}
|
||||
if (options?.rgb !== undefined) {
|
||||
this._rgbColor = options.rgb;
|
||||
}
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn off the light
|
||||
* @param options Optional transition time
|
||||
*/
|
||||
public async turnOff(options?: { transition?: number }): Promise<void> {
|
||||
await this.protocolClient.turnOff(this.entityId, options);
|
||||
this._isOn = false;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the light
|
||||
*/
|
||||
public async toggle(): Promise<void> {
|
||||
await this.protocolClient.toggle(this.entityId);
|
||||
this._isOn = !this._isOn;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set brightness level
|
||||
* @param brightness Brightness 0-255
|
||||
* @param transition Optional transition time in seconds
|
||||
*/
|
||||
public async setBrightness(brightness: number, transition?: number): Promise<void> {
|
||||
if (!this.capabilities.supportsBrightness) {
|
||||
throw new Error('Light does not support brightness control');
|
||||
}
|
||||
|
||||
const clamped = Math.max(0, Math.min(255, Math.round(brightness)));
|
||||
await this.protocolClient.setBrightness(this.entityId, clamped, transition);
|
||||
this._brightness = clamped;
|
||||
if (clamped > 0) {
|
||||
this._isOn = true;
|
||||
}
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set color temperature in Kelvin
|
||||
* @param kelvin Color temperature in Kelvin (e.g., 2700 warm, 6500 cool)
|
||||
* @param transition Optional transition time in seconds
|
||||
*/
|
||||
public async setColorTemp(kelvin: number, transition?: number): Promise<void> {
|
||||
if (!this.capabilities.supportsColorTemp) {
|
||||
throw new Error('Light does not support color temperature');
|
||||
}
|
||||
|
||||
// Clamp to supported range if available
|
||||
let clamped = kelvin;
|
||||
if (this.capabilities.minColorTempKelvin && this.capabilities.maxColorTempKelvin) {
|
||||
clamped = Math.max(
|
||||
this.capabilities.minColorTempKelvin,
|
||||
Math.min(this.capabilities.maxColorTempKelvin, kelvin)
|
||||
);
|
||||
}
|
||||
|
||||
await this.protocolClient.setColorTemp(this.entityId, clamped, transition);
|
||||
this._colorTemp = clamped;
|
||||
this._colorTempMireds = Math.round(1000000 / clamped);
|
||||
this._isOn = true;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set RGB color
|
||||
* @param r Red 0-255
|
||||
* @param g Green 0-255
|
||||
* @param b Blue 0-255
|
||||
* @param transition Optional transition time in seconds
|
||||
*/
|
||||
public async setRgbColor(r: number, g: number, b: number, transition?: number): Promise<void> {
|
||||
if (!this.capabilities.supportsRgb) {
|
||||
throw new Error('Light does not support RGB color');
|
||||
}
|
||||
|
||||
const clampedR = Math.max(0, Math.min(255, Math.round(r)));
|
||||
const clampedG = Math.max(0, Math.min(255, Math.round(g)));
|
||||
const clampedB = Math.max(0, Math.min(255, Math.round(b)));
|
||||
|
||||
await this.protocolClient.setRgbColor(this.entityId, clampedR, clampedG, clampedB, transition);
|
||||
this._rgbColor = [clampedR, clampedG, clampedB];
|
||||
this._isOn = true;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set light effect
|
||||
* @param effect Effect name from available effects
|
||||
*/
|
||||
public async setEffect(effect: string): Promise<void> {
|
||||
if (!this.capabilities.supportsEffects) {
|
||||
throw new Error('Light does not support effects');
|
||||
}
|
||||
|
||||
await this.protocolClient.setEffect(this.entityId, effect);
|
||||
this._effect = effect;
|
||||
this._isOn = true;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current state as object
|
||||
*/
|
||||
public getState(): ILightState {
|
||||
return {
|
||||
isOn: this._isOn,
|
||||
brightness: this._brightness,
|
||||
colorTemp: this._colorTemp,
|
||||
colorTempMireds: this._colorTempMireds,
|
||||
rgbColor: this._rgbColor,
|
||||
hsColor: this._hsColor,
|
||||
xyColor: this._xyColor,
|
||||
effect: this._effect,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh state from the device
|
||||
*/
|
||||
public async refreshState(): Promise<ILightState> {
|
||||
const state = await this.protocolClient.getState(this.entityId);
|
||||
this.updateStateInternal(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update state from external source (e.g., state change event)
|
||||
*/
|
||||
public updateState(state: ILightState): void {
|
||||
this.updateStateInternal(state);
|
||||
this.emit('state:changed', state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal state update
|
||||
*/
|
||||
private updateStateInternal(state: ILightState): void {
|
||||
this._isOn = state.isOn;
|
||||
this._brightness = state.brightness;
|
||||
this._colorTemp = state.colorTemp;
|
||||
this._colorTempMireds = state.colorTempMireds;
|
||||
this._rgbColor = state.rgbColor;
|
||||
this._hsColor = state.hsColor;
|
||||
this._xyColor = state.xyColor;
|
||||
this._effect = state.effect;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Feature Info
|
||||
// ============================================================================
|
||||
|
||||
public getFeatureInfo(): ILightFeatureInfo {
|
||||
return {
|
||||
...this.getBaseFeatureInfo(),
|
||||
type: 'light',
|
||||
protocol: this.protocol,
|
||||
capabilities: this.capabilities,
|
||||
currentState: this.getState(),
|
||||
};
|
||||
}
|
||||
}
|
||||
223
ts/features/feature.lock.ts
Normal file
223
ts/features/feature.lock.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Lock Feature
|
||||
* Provides control for smart locks
|
||||
*/
|
||||
|
||||
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
||||
import type { IFeatureOptions } from '../interfaces/feature.interfaces.js';
|
||||
import type {
|
||||
TLockProtocol,
|
||||
TLockState,
|
||||
ILockCapabilities,
|
||||
ILockStateInfo,
|
||||
ILockFeatureInfo,
|
||||
ILockProtocolClient,
|
||||
} from '../interfaces/smarthome.interfaces.js';
|
||||
|
||||
/**
|
||||
* Options for creating a LockFeature
|
||||
*/
|
||||
export interface ILockFeatureOptions extends IFeatureOptions {
|
||||
/** Protocol type */
|
||||
protocol: TLockProtocol;
|
||||
/** Entity ID (for Home Assistant) */
|
||||
entityId: string;
|
||||
/** Protocol client for controlling the lock */
|
||||
protocolClient: ILockProtocolClient;
|
||||
/** Whether the lock supports physical open */
|
||||
supportsOpen?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock Feature - control for smart locks
|
||||
*
|
||||
* Protocol-agnostic: works with Home Assistant, MQTT, August, Yale, etc.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const lock = device.getFeature<LockFeature>('lock');
|
||||
* if (lock) {
|
||||
* await lock.lock();
|
||||
* console.log(`Lock is ${lock.isLocked ? 'locked' : 'unlocked'}`);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class LockFeature extends Feature {
|
||||
public readonly type = 'lock' as const;
|
||||
public readonly protocol: TLockProtocol;
|
||||
|
||||
/** Entity ID (e.g., "lock.front_door") */
|
||||
public readonly entityId: string;
|
||||
|
||||
/** Capabilities */
|
||||
public readonly capabilities: ILockCapabilities;
|
||||
|
||||
/** Current state */
|
||||
protected _lockState: TLockState = 'unknown';
|
||||
protected _isLocked: boolean = false;
|
||||
|
||||
/** Protocol client for controlling the lock */
|
||||
private protocolClient: ILockProtocolClient;
|
||||
|
||||
constructor(
|
||||
device: TDeviceReference,
|
||||
port: number,
|
||||
options: ILockFeatureOptions
|
||||
) {
|
||||
super(device, port, options);
|
||||
this.protocol = options.protocol;
|
||||
this.entityId = options.entityId;
|
||||
this.protocolClient = options.protocolClient;
|
||||
|
||||
this.capabilities = {
|
||||
supportsOpen: options.supportsOpen ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Properties
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get current lock state (cached)
|
||||
*/
|
||||
public get lockState(): TLockState {
|
||||
return this._lockState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if locked (cached)
|
||||
*/
|
||||
public get isLocked(): boolean {
|
||||
return this._isLocked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if unlocked
|
||||
*/
|
||||
public get isUnlocked(): boolean {
|
||||
return this._lockState === 'unlocked';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if currently locking
|
||||
*/
|
||||
public get isLocking(): boolean {
|
||||
return this._lockState === 'locking';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if currently unlocking
|
||||
*/
|
||||
public get isUnlocking(): boolean {
|
||||
return this._lockState === 'unlocking';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if jammed
|
||||
*/
|
||||
public get isJammed(): boolean {
|
||||
return this._lockState === 'jammed';
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Connection
|
||||
// ============================================================================
|
||||
|
||||
protected async doConnect(): Promise<void> {
|
||||
try {
|
||||
const state = await this.protocolClient.getState(this.entityId);
|
||||
this.updateStateInternal(state);
|
||||
} catch {
|
||||
// Ignore errors fetching initial state
|
||||
}
|
||||
}
|
||||
|
||||
protected async doDisconnect(): Promise<void> {
|
||||
// Nothing to disconnect
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Lock Control
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Lock the lock
|
||||
*/
|
||||
public async lock(): Promise<void> {
|
||||
await this.protocolClient.lock(this.entityId);
|
||||
this._lockState = 'locking';
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlock the lock
|
||||
*/
|
||||
public async unlock(): Promise<void> {
|
||||
await this.protocolClient.unlock(this.entityId);
|
||||
this._lockState = 'unlocking';
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the lock (physically open the door if supported)
|
||||
*/
|
||||
public async open(): Promise<void> {
|
||||
if (!this.capabilities.supportsOpen) {
|
||||
throw new Error('Lock does not support physical open');
|
||||
}
|
||||
await this.protocolClient.open(this.entityId);
|
||||
this._lockState = 'unlocked';
|
||||
this._isLocked = false;
|
||||
this.emit('state:changed', this.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current state as object
|
||||
*/
|
||||
public getState(): ILockStateInfo {
|
||||
return {
|
||||
state: this._lockState,
|
||||
isLocked: this._isLocked,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh state from the device
|
||||
*/
|
||||
public async refreshState(): Promise<ILockStateInfo> {
|
||||
const state = await this.protocolClient.getState(this.entityId);
|
||||
this.updateStateInternal(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update state from external source
|
||||
*/
|
||||
public updateState(state: ILockStateInfo): void {
|
||||
this.updateStateInternal(state);
|
||||
this.emit('state:changed', state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal state update
|
||||
*/
|
||||
private updateStateInternal(state: ILockStateInfo): void {
|
||||
this._lockState = state.state;
|
||||
this._isLocked = state.isLocked;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Feature Info
|
||||
// ============================================================================
|
||||
|
||||
public getFeatureInfo(): ILockFeatureInfo {
|
||||
return {
|
||||
...this.getBaseFeatureInfo(),
|
||||
type: 'lock',
|
||||
protocol: this.protocol,
|
||||
capabilities: this.capabilities,
|
||||
currentState: this.getState(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
||||
import { IppProtocol } from '../printer/printer.classes.ippprotocol.js';
|
||||
import { IppProtocol } from '../protocols/index.js';
|
||||
import type {
|
||||
TPrintProtocol,
|
||||
TPrintSides,
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
*/
|
||||
|
||||
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
||||
import { EsclProtocol } from '../scanner/scanner.classes.esclprotocol.js';
|
||||
import { SaneProtocol } from '../scanner/scanner.classes.saneprotocol.js';
|
||||
import { EsclProtocol, SaneProtocol } from '../protocols/index.js';
|
||||
import type {
|
||||
TScanProtocol,
|
||||
TScanFormat,
|
||||
|
||||
202
ts/features/feature.sensor.ts
Normal file
202
ts/features/feature.sensor.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Sensor Feature
|
||||
* Provides read-only state for sensors (temperature, humidity, power, etc.)
|
||||
*/
|
||||
|
||||
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
||||
import type { IFeatureOptions } from '../interfaces/feature.interfaces.js';
|
||||
import type {
|
||||
TSensorProtocol,
|
||||
TSensorDeviceClass,
|
||||
TSensorStateClass,
|
||||
ISensorCapabilities,
|
||||
ISensorState,
|
||||
ISensorFeatureInfo,
|
||||
ISensorProtocolClient,
|
||||
} from '../interfaces/smarthome.interfaces.js';
|
||||
|
||||
/**
|
||||
* Options for creating a SensorFeature
|
||||
*/
|
||||
export interface ISensorFeatureOptions extends IFeatureOptions {
|
||||
/** Protocol type */
|
||||
protocol: TSensorProtocol;
|
||||
/** Entity ID (for Home Assistant) */
|
||||
entityId: string;
|
||||
/** Protocol client for reading sensor state */
|
||||
protocolClient: ISensorProtocolClient;
|
||||
/** Device class (temperature, humidity, etc.) */
|
||||
deviceClass?: TSensorDeviceClass;
|
||||
/** State class (measurement, total, etc.) */
|
||||
stateClass?: TSensorStateClass;
|
||||
/** Unit of measurement */
|
||||
unit?: string;
|
||||
/** Precision (decimal places) */
|
||||
precision?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sensor Feature - read-only state values
|
||||
*
|
||||
* Protocol-agnostic: works with Home Assistant, MQTT, SNMP, etc.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const sensor = device.getFeature<SensorFeature>('sensor');
|
||||
* if (sensor) {
|
||||
* await sensor.refreshState();
|
||||
* console.log(`Temperature: ${sensor.value} ${sensor.unit}`);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class SensorFeature extends Feature {
|
||||
public readonly type = 'sensor' as const;
|
||||
public readonly protocol: TSensorProtocol;
|
||||
|
||||
/** Entity ID (e.g., "sensor.living_room_temperature") */
|
||||
public readonly entityId: string;
|
||||
|
||||
/** Capabilities */
|
||||
public readonly capabilities: ISensorCapabilities;
|
||||
|
||||
/** Current state */
|
||||
protected _value: string | number | boolean = '';
|
||||
protected _numericValue?: number;
|
||||
protected _unit?: string;
|
||||
protected _lastUpdated: Date = new Date();
|
||||
|
||||
/** Protocol client for reading sensor state */
|
||||
private protocolClient: ISensorProtocolClient;
|
||||
|
||||
constructor(
|
||||
device: TDeviceReference,
|
||||
port: number,
|
||||
options: ISensorFeatureOptions
|
||||
) {
|
||||
super(device, port, options);
|
||||
this.protocol = options.protocol;
|
||||
this.entityId = options.entityId;
|
||||
this.protocolClient = options.protocolClient;
|
||||
this.capabilities = {
|
||||
deviceClass: options.deviceClass,
|
||||
stateClass: options.stateClass,
|
||||
unit: options.unit,
|
||||
precision: options.precision,
|
||||
};
|
||||
this._unit = options.unit;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Properties
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get current value (cached)
|
||||
*/
|
||||
public get value(): string | number | boolean {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get numeric value if available (cached)
|
||||
*/
|
||||
public get numericValue(): number | undefined {
|
||||
return this._numericValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unit of measurement
|
||||
*/
|
||||
public get unit(): string | undefined {
|
||||
return this._unit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get device class
|
||||
*/
|
||||
public get deviceClass(): TSensorDeviceClass | undefined {
|
||||
return this.capabilities.deviceClass;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get state class
|
||||
*/
|
||||
public get stateClass(): TSensorStateClass | undefined {
|
||||
return this.capabilities.stateClass;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last updated timestamp
|
||||
*/
|
||||
public get lastUpdated(): Date {
|
||||
return this._lastUpdated;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Connection
|
||||
// ============================================================================
|
||||
|
||||
protected async doConnect(): Promise<void> {
|
||||
// Fetch initial state
|
||||
try {
|
||||
const state = await this.protocolClient.getState(this.entityId);
|
||||
this.updateStateInternal(state);
|
||||
} catch {
|
||||
// Ignore errors fetching initial state
|
||||
}
|
||||
}
|
||||
|
||||
protected async doDisconnect(): Promise<void> {
|
||||
// Nothing to disconnect
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Sensor Reading
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Refresh state from the device
|
||||
*/
|
||||
public async refreshState(): Promise<ISensorState> {
|
||||
const state = await this.protocolClient.getState(this.entityId);
|
||||
this.updateStateInternal(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update state from external source (e.g., state change event)
|
||||
*/
|
||||
public updateState(state: ISensorState): void {
|
||||
this.updateStateInternal(state);
|
||||
this.emit('state:changed', state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal state update
|
||||
*/
|
||||
private updateStateInternal(state: ISensorState): void {
|
||||
this._value = state.value;
|
||||
this._numericValue = state.numericValue;
|
||||
this._unit = state.unit || this._unit;
|
||||
this._lastUpdated = state.lastUpdated;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Feature Info
|
||||
// ============================================================================
|
||||
|
||||
public getFeatureInfo(): ISensorFeatureInfo {
|
||||
return {
|
||||
...this.getBaseFeatureInfo(),
|
||||
type: 'sensor',
|
||||
protocol: this.protocol,
|
||||
capabilities: this.capabilities,
|
||||
currentState: {
|
||||
value: this._value,
|
||||
numericValue: this._numericValue,
|
||||
unit: this._unit,
|
||||
lastUpdated: this._lastUpdated,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
170
ts/features/feature.switch.ts
Normal file
170
ts/features/feature.switch.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Switch Feature
|
||||
* Provides binary on/off control for smart switches, outlets, etc.
|
||||
*/
|
||||
|
||||
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
||||
import type { IFeatureOptions } from '../interfaces/feature.interfaces.js';
|
||||
import type {
|
||||
TSwitchProtocol,
|
||||
ISwitchCapabilities,
|
||||
ISwitchState,
|
||||
ISwitchFeatureInfo,
|
||||
ISwitchProtocolClient,
|
||||
} from '../interfaces/smarthome.interfaces.js';
|
||||
|
||||
/**
|
||||
* Options for creating a SwitchFeature
|
||||
*/
|
||||
export interface ISwitchFeatureOptions extends IFeatureOptions {
|
||||
/** Protocol type */
|
||||
protocol: TSwitchProtocol;
|
||||
/** Entity ID (for Home Assistant) */
|
||||
entityId: string;
|
||||
/** Protocol client for controlling the switch */
|
||||
protocolClient: ISwitchProtocolClient;
|
||||
/** Device class */
|
||||
deviceClass?: 'outlet' | 'switch';
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch Feature - binary on/off control
|
||||
*
|
||||
* Protocol-agnostic: works with Home Assistant, MQTT, Tasmota, Tuya, etc.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const sw = device.getFeature<SwitchFeature>('switch');
|
||||
* if (sw) {
|
||||
* await sw.turnOn();
|
||||
* await sw.toggle();
|
||||
* console.log(`Switch is ${sw.isOn ? 'on' : 'off'}`);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class SwitchFeature extends Feature {
|
||||
public readonly type = 'switch' as const;
|
||||
public readonly protocol: TSwitchProtocol;
|
||||
|
||||
/** Entity ID (e.g., "switch.living_room") */
|
||||
public readonly entityId: string;
|
||||
|
||||
/** Capabilities */
|
||||
public readonly capabilities: ISwitchCapabilities;
|
||||
|
||||
/** Current state */
|
||||
protected _isOn: boolean = false;
|
||||
|
||||
/** Protocol client for controlling the switch */
|
||||
private protocolClient: ISwitchProtocolClient;
|
||||
|
||||
constructor(
|
||||
device: TDeviceReference,
|
||||
port: number,
|
||||
options: ISwitchFeatureOptions
|
||||
) {
|
||||
super(device, port, options);
|
||||
this.protocol = options.protocol;
|
||||
this.entityId = options.entityId;
|
||||
this.protocolClient = options.protocolClient;
|
||||
this.capabilities = {
|
||||
deviceClass: options.deviceClass,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Properties
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get current on/off state (cached)
|
||||
*/
|
||||
public get isOn(): boolean {
|
||||
return this._isOn;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Connection
|
||||
// ============================================================================
|
||||
|
||||
protected async doConnect(): Promise<void> {
|
||||
// Fetch initial state
|
||||
try {
|
||||
const state = await this.protocolClient.getState(this.entityId);
|
||||
this._isOn = state.isOn;
|
||||
} catch {
|
||||
// Ignore errors fetching initial state
|
||||
}
|
||||
}
|
||||
|
||||
protected async doDisconnect(): Promise<void> {
|
||||
// Nothing to disconnect
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Switch Control
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Turn on the switch
|
||||
*/
|
||||
public async turnOn(): Promise<void> {
|
||||
await this.protocolClient.turnOn(this.entityId);
|
||||
this._isOn = true;
|
||||
this.emit('state:changed', { isOn: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn off the switch
|
||||
*/
|
||||
public async turnOff(): Promise<void> {
|
||||
await this.protocolClient.turnOff(this.entityId);
|
||||
this._isOn = false;
|
||||
this.emit('state:changed', { isOn: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the switch
|
||||
*/
|
||||
public async toggle(): Promise<void> {
|
||||
await this.protocolClient.toggle(this.entityId);
|
||||
this._isOn = !this._isOn;
|
||||
this.emit('state:changed', { isOn: this._isOn });
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh state from the device
|
||||
*/
|
||||
public async refreshState(): Promise<ISwitchState> {
|
||||
const state = await this.protocolClient.getState(this.entityId);
|
||||
this._isOn = state.isOn;
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update state from external source (e.g., state change event)
|
||||
*/
|
||||
public updateState(state: ISwitchState): void {
|
||||
const changed = this._isOn !== state.isOn;
|
||||
this._isOn = state.isOn;
|
||||
if (changed) {
|
||||
this.emit('state:changed', state);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Feature Info
|
||||
// ============================================================================
|
||||
|
||||
public getFeatureInfo(): ISwitchFeatureInfo {
|
||||
return {
|
||||
...this.getBaseFeatureInfo(),
|
||||
type: 'switch',
|
||||
protocol: this.protocol,
|
||||
capabilities: this.capabilities,
|
||||
currentState: {
|
||||
isOn: this._isOn,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,20 @@
|
||||
// Abstract base
|
||||
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';
|
||||
|
||||
209
ts/index.ts
209
ts/index.ts
@@ -4,31 +4,24 @@
|
||||
* Supports: Scanners, Printers, SNMP devices, UPS, DLNA, Sonos, AirPlay, Chromecast
|
||||
*/
|
||||
|
||||
// Main exports from DeviceManager
|
||||
// ============================================================================
|
||||
// Core Device Manager
|
||||
// ============================================================================
|
||||
|
||||
export {
|
||||
DeviceManager,
|
||||
MdnsDiscovery,
|
||||
NetworkScanner,
|
||||
SsdpDiscovery,
|
||||
Scanner,
|
||||
Printer,
|
||||
SnmpDevice,
|
||||
UpsDevice,
|
||||
DlnaRenderer,
|
||||
DlnaServer,
|
||||
Speaker,
|
||||
SonosSpeaker,
|
||||
AirPlaySpeaker,
|
||||
ChromecastSpeaker,
|
||||
SERVICE_TYPES,
|
||||
SSDP_SERVICE_TYPES,
|
||||
} from './devicemanager.classes.devicemanager.js';
|
||||
|
||||
// Abstract/base classes
|
||||
export { Device } from './abstract/device.abstract.js';
|
||||
// ============================================================================
|
||||
// Universal Device & Features
|
||||
// ============================================================================
|
||||
|
||||
// Universal Device & Features (new architecture)
|
||||
export { UniversalDevice } from './device/device.classes.device.js';
|
||||
export { UniversalDevice, type IUniversalDeviceInfo, type IDeviceCreateOptions } from './device/device.classes.device.js';
|
||||
export {
|
||||
Feature,
|
||||
ScanFeature,
|
||||
@@ -37,6 +30,15 @@ export {
|
||||
VolumeFeature,
|
||||
PowerFeature,
|
||||
SnmpFeature,
|
||||
// Smart home features
|
||||
SwitchFeature,
|
||||
SensorFeature,
|
||||
LightFeature,
|
||||
CoverFeature,
|
||||
LockFeature,
|
||||
FanFeature,
|
||||
ClimateFeature,
|
||||
CameraFeature,
|
||||
type TDeviceReference,
|
||||
type IScanFeatureOptions,
|
||||
type IPrintFeatureOptions,
|
||||
@@ -45,36 +47,98 @@ 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';
|
||||
|
||||
// Scanner protocol implementations
|
||||
export { EsclProtocol } from './scanner/scanner.classes.esclprotocol.js';
|
||||
export { SaneProtocol } from './scanner/scanner.classes.saneprotocol.js';
|
||||
// ============================================================================
|
||||
// Device Factories
|
||||
// ============================================================================
|
||||
|
||||
// Printer protocol
|
||||
export { IppProtocol } from './printer/printer.classes.ippprotocol.js';
|
||||
|
||||
// SNMP protocol
|
||||
export { SnmpProtocol, SNMP_OIDS } from './snmp/snmp.classes.snmpprotocol.js';
|
||||
|
||||
// UPS protocols
|
||||
export { NutProtocol, NUT_COMMANDS, NUT_VARIABLES } from './ups/ups.classes.nutprotocol.js';
|
||||
export { UpsSnmpHandler, UPS_SNMP_OIDS } from './ups/ups.classes.upssnmp.js';
|
||||
|
||||
// DLNA/UPnP protocol
|
||||
export {
|
||||
createScanner,
|
||||
createPrinter,
|
||||
createSnmpDevice,
|
||||
createUpsDevice,
|
||||
createSpeaker,
|
||||
createDlnaRenderer,
|
||||
// Smart home factories
|
||||
createSmartSwitch,
|
||||
createSmartSensor,
|
||||
createSmartLight,
|
||||
createSmartCover,
|
||||
createSmartLock,
|
||||
createSmartFan,
|
||||
createSmartClimate,
|
||||
createSmartCamera,
|
||||
type IScannerDiscoveryInfo,
|
||||
type IPrinterDiscoveryInfo,
|
||||
type ISnmpDiscoveryInfo,
|
||||
type IUpsDiscoveryInfo,
|
||||
type ISpeakerDiscoveryInfo,
|
||||
type IDlnaRendererDiscoveryInfo,
|
||||
type ISmartSwitchDiscoveryInfo,
|
||||
type ISmartSensorDiscoveryInfo,
|
||||
type ISmartLightDiscoveryInfo,
|
||||
type ISmartCoverDiscoveryInfo,
|
||||
type ISmartLockDiscoveryInfo,
|
||||
type ISmartFanDiscoveryInfo,
|
||||
type ISmartClimateDiscoveryInfo,
|
||||
type ISmartCameraDiscoveryInfo,
|
||||
} from './factories/index.js';
|
||||
|
||||
// ============================================================================
|
||||
// Protocol Implementations
|
||||
// ============================================================================
|
||||
|
||||
export {
|
||||
EsclProtocol,
|
||||
SaneProtocol,
|
||||
IppProtocol,
|
||||
SnmpProtocol,
|
||||
SNMP_OIDS,
|
||||
NutProtocol,
|
||||
NUT_COMMANDS,
|
||||
NUT_VARIABLES,
|
||||
UpnpSoapClient,
|
||||
UPNP_SERVICE_TYPES,
|
||||
UPNP_DEVICE_TYPES,
|
||||
} from './dlna/dlna.classes.upnp.js';
|
||||
UpsSnmpHandler,
|
||||
UPS_SNMP_OIDS,
|
||||
// Home Assistant protocol
|
||||
HomeAssistantProtocol,
|
||||
type ISnmpOptions,
|
||||
type ISnmpVarbind,
|
||||
type TSnmpValueType,
|
||||
type TNutStatusFlag,
|
||||
type INutUpsInfo,
|
||||
type INutVariable,
|
||||
type TDlnaTransportState,
|
||||
type TDlnaTransportStatus,
|
||||
type IDlnaPositionInfo,
|
||||
type IDlnaTransportInfo,
|
||||
type IDlnaMediaInfo,
|
||||
type IDlnaContentItem,
|
||||
type IDlnaBrowseResult,
|
||||
type TUpsBatteryStatus,
|
||||
type TUpsOutputSource,
|
||||
type TUpsTestResult,
|
||||
type IUpsSnmpStatus,
|
||||
} from './protocols/index.js';
|
||||
|
||||
// Chromecast app IDs
|
||||
export { CHROMECAST_APPS } from './speaker/speaker.classes.chromecast.js';
|
||||
|
||||
// AirPlay features
|
||||
export { AIRPLAY_FEATURES } from './speaker/speaker.classes.airplay.js';
|
||||
// Home Assistant Discovery
|
||||
export { HomeAssistantDiscovery, HA_SERVICE_TYPE } from './discovery/discovery.classes.homeassistant.js';
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
export { withRetry, createRetryable, defaultRetryOptions } from './helpers/helpers.retry.js';
|
||||
export {
|
||||
isValidIp,
|
||||
@@ -86,56 +150,12 @@ export {
|
||||
countIpsInCidr,
|
||||
} from './helpers/helpers.iprange.js';
|
||||
|
||||
// All interfaces and types
|
||||
// ============================================================================
|
||||
// All Interfaces and Types
|
||||
// ============================================================================
|
||||
|
||||
export * from './interfaces/index.js';
|
||||
|
||||
// SNMP types
|
||||
export type {
|
||||
ISnmpOptions,
|
||||
ISnmpVarbind,
|
||||
TSnmpValueType,
|
||||
} from './snmp/snmp.classes.snmpprotocol.js';
|
||||
export type { ISnmpDeviceInfo } from './snmp/snmp.classes.snmpdevice.js';
|
||||
|
||||
// UPS types
|
||||
export type {
|
||||
TNutStatusFlag,
|
||||
INutUpsInfo,
|
||||
INutVariable,
|
||||
} from './ups/ups.classes.nutprotocol.js';
|
||||
export type {
|
||||
TUpsBatteryStatus,
|
||||
TUpsOutputSource,
|
||||
IUpsSnmpStatus,
|
||||
} from './ups/ups.classes.upssnmp.js';
|
||||
export type {
|
||||
TUpsStatus,
|
||||
TUpsProtocol,
|
||||
IUpsDeviceInfo,
|
||||
IUpsBatteryInfo,
|
||||
IUpsPowerInfo,
|
||||
IUpsFullStatus,
|
||||
} from './ups/ups.classes.upsdevice.js';
|
||||
|
||||
// DLNA types
|
||||
export type {
|
||||
TDlnaTransportState,
|
||||
TDlnaTransportStatus,
|
||||
IDlnaPositionInfo,
|
||||
IDlnaTransportInfo,
|
||||
IDlnaMediaInfo,
|
||||
IDlnaContentItem,
|
||||
IDlnaBrowseResult,
|
||||
} from './dlna/dlna.classes.upnp.js';
|
||||
export type {
|
||||
IDlnaRendererInfo,
|
||||
IDlnaPlaybackState,
|
||||
} from './dlna/dlna.classes.renderer.js';
|
||||
export type {
|
||||
IDlnaServerInfo,
|
||||
IDlnaServerStats,
|
||||
} from './dlna/dlna.classes.server.js';
|
||||
|
||||
// SSDP types
|
||||
export type {
|
||||
ISsdpDevice,
|
||||
@@ -143,26 +163,3 @@ export type {
|
||||
ISsdpService,
|
||||
ISsdpIcon,
|
||||
} from './discovery/discovery.classes.ssdp.js';
|
||||
|
||||
// Speaker types
|
||||
export type {
|
||||
TSpeakerProtocol,
|
||||
TPlaybackState,
|
||||
ITrackInfo,
|
||||
IPlaybackStatus,
|
||||
ISpeakerInfo,
|
||||
} from './speaker/speaker.classes.speaker.js';
|
||||
export type {
|
||||
ISonosZoneInfo,
|
||||
ISonosSpeakerInfo,
|
||||
} from './speaker/speaker.classes.sonos.js';
|
||||
export type {
|
||||
IAirPlaySpeakerInfo,
|
||||
IAirPlayPlaybackInfo,
|
||||
} from './speaker/speaker.classes.airplay.js';
|
||||
export type {
|
||||
TChromecastType,
|
||||
IChromecastSpeakerInfo,
|
||||
IChromecastMediaMetadata,
|
||||
IChromecastMediaStatus,
|
||||
} from './speaker/speaker.classes.chromecast.js';
|
||||
|
||||
@@ -13,16 +13,29 @@ import type { IRetryOptions } from './index.js';
|
||||
* All supported feature types
|
||||
*/
|
||||
export type TFeatureType =
|
||||
// Document handling
|
||||
| 'scan' // Can scan documents (eSCL, SANE)
|
||||
| 'print' // Can print documents (IPP, JetDirect)
|
||||
| 'fax' // Can send/receive fax
|
||||
| 'copy' // Can copy (scan + print combined)
|
||||
// Media playback
|
||||
| 'playback' // Can play media (audio/video)
|
||||
| 'volume' // Has volume control
|
||||
// Infrastructure
|
||||
| 'power' // Has power status (UPS, smart plug)
|
||||
| 'snmp' // SNMP queryable
|
||||
// DLNA
|
||||
| 'dlna-render' // DLNA renderer
|
||||
| 'dlna-serve' // DLNA server (content provider)
|
||||
// Smart home (protocol-agnostic: home-assistant, hue, mqtt, etc.)
|
||||
| 'light' // Brightness, color, effects
|
||||
| 'climate' // Temperature, HVAC modes
|
||||
| 'sensor' // Read-only state values
|
||||
| 'camera' // Snapshots, streams
|
||||
| 'cover' // Blinds, garage doors
|
||||
| 'switch' // Binary on/off
|
||||
| 'lock' // Lock/unlock
|
||||
| 'fan' // Speed, oscillation
|
||||
;
|
||||
|
||||
/**
|
||||
|
||||
666
ts/interfaces/homeassistant.interfaces.ts
Normal file
666
ts/interfaces/homeassistant.interfaces.ts
Normal file
@@ -0,0 +1,666 @@
|
||||
/**
|
||||
* Home Assistant Specific Interfaces
|
||||
* Types for Home Assistant WebSocket API, entities, and configuration
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Configuration
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Configuration for connecting to a Home Assistant instance
|
||||
*/
|
||||
export interface IHomeAssistantInstanceConfig {
|
||||
/** Home Assistant host (IP or hostname) */
|
||||
host: string;
|
||||
/** Port number (default: 8123) */
|
||||
port?: number;
|
||||
/** Long-lived access token from HA */
|
||||
token: string;
|
||||
/** Use secure WebSocket (wss://) */
|
||||
secure?: boolean;
|
||||
/** Friendly name for this instance */
|
||||
friendlyName?: string;
|
||||
/** Auto-reconnect on disconnect (default: true) */
|
||||
autoReconnect?: boolean;
|
||||
/** Reconnect delay in ms (default: 5000) */
|
||||
reconnectDelay?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Home Assistant configuration in DeviceManager options
|
||||
*/
|
||||
export interface IHomeAssistantOptions {
|
||||
/** Enable mDNS auto-discovery of HA instances */
|
||||
autoDiscovery?: boolean;
|
||||
/** Manually configured HA instances */
|
||||
instances?: IHomeAssistantInstanceConfig[];
|
||||
/** Filter: only discover these domains (default: all) */
|
||||
enabledDomains?: THomeAssistantDomain[];
|
||||
/** Auto-reconnect on disconnect (default: true) */
|
||||
autoReconnect?: boolean;
|
||||
/** Reconnect delay in ms (default: 5000) */
|
||||
reconnectDelay?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Entity Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Supported Home Assistant domains that map to features
|
||||
*/
|
||||
export type THomeAssistantDomain =
|
||||
| 'light'
|
||||
| 'switch'
|
||||
| 'sensor'
|
||||
| 'binary_sensor'
|
||||
| 'climate'
|
||||
| 'fan'
|
||||
| 'cover'
|
||||
| 'lock'
|
||||
| 'camera'
|
||||
| 'media_player';
|
||||
|
||||
/**
|
||||
* Home Assistant entity state
|
||||
*/
|
||||
export interface IHomeAssistantEntity {
|
||||
/** Entity ID (e.g., "light.living_room") */
|
||||
entity_id: string;
|
||||
/** Current state value (e.g., "on", "off", "25.5") */
|
||||
state: string;
|
||||
/** Additional attributes */
|
||||
attributes: IHomeAssistantEntityAttributes;
|
||||
/** Last changed timestamp */
|
||||
last_changed: string;
|
||||
/** Last updated timestamp */
|
||||
last_updated: string;
|
||||
/** Context information */
|
||||
context: IHomeAssistantContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Common entity attributes
|
||||
*/
|
||||
export interface IHomeAssistantEntityAttributes {
|
||||
/** Friendly name */
|
||||
friendly_name?: string;
|
||||
/** Device class */
|
||||
device_class?: string;
|
||||
/** Unit of measurement */
|
||||
unit_of_measurement?: string;
|
||||
/** Icon */
|
||||
icon?: string;
|
||||
/** Entity category */
|
||||
entity_category?: string;
|
||||
/** Assumed state (for optimistic updates) */
|
||||
assumed_state?: boolean;
|
||||
/** Supported features bitmask */
|
||||
supported_features?: number;
|
||||
/** Additional dynamic attributes */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Light-specific attributes
|
||||
*/
|
||||
export interface IHomeAssistantLightAttributes extends IHomeAssistantEntityAttributes {
|
||||
brightness?: number; // 0-255
|
||||
color_temp?: number; // Mireds
|
||||
color_temp_kelvin?: number; // Kelvin
|
||||
hs_color?: [number, number]; // [hue 0-360, saturation 0-100]
|
||||
rgb_color?: [number, number, number];
|
||||
xy_color?: [number, number];
|
||||
rgbw_color?: [number, number, number, number];
|
||||
rgbww_color?: [number, number, number, number, number];
|
||||
effect?: string;
|
||||
effect_list?: string[];
|
||||
color_mode?: string;
|
||||
supported_color_modes?: string[];
|
||||
min_mireds?: number;
|
||||
max_mireds?: number;
|
||||
min_color_temp_kelvin?: number;
|
||||
max_color_temp_kelvin?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Climate-specific attributes
|
||||
*/
|
||||
export interface IHomeAssistantClimateAttributes extends IHomeAssistantEntityAttributes {
|
||||
hvac_modes?: string[];
|
||||
hvac_action?: string;
|
||||
current_temperature?: number;
|
||||
target_temp_high?: number;
|
||||
target_temp_low?: number;
|
||||
temperature?: number;
|
||||
preset_mode?: string;
|
||||
preset_modes?: string[];
|
||||
fan_mode?: string;
|
||||
fan_modes?: string[];
|
||||
swing_mode?: string;
|
||||
swing_modes?: string[];
|
||||
aux_heat?: boolean;
|
||||
current_humidity?: number;
|
||||
humidity?: number;
|
||||
min_temp?: number;
|
||||
max_temp?: number;
|
||||
target_temp_step?: number;
|
||||
min_humidity?: number;
|
||||
max_humidity?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sensor-specific attributes
|
||||
*/
|
||||
export interface IHomeAssistantSensorAttributes extends IHomeAssistantEntityAttributes {
|
||||
state_class?: 'measurement' | 'total' | 'total_increasing';
|
||||
native_unit_of_measurement?: string;
|
||||
native_value?: string | number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cover-specific attributes
|
||||
*/
|
||||
export interface IHomeAssistantCoverAttributes extends IHomeAssistantEntityAttributes {
|
||||
current_position?: number; // 0-100
|
||||
current_tilt_position?: number; // 0-100
|
||||
}
|
||||
|
||||
/**
|
||||
* Fan-specific attributes
|
||||
*/
|
||||
export interface IHomeAssistantFanAttributes extends IHomeAssistantEntityAttributes {
|
||||
percentage?: number; // 0-100
|
||||
percentage_step?: number;
|
||||
preset_mode?: string;
|
||||
preset_modes?: string[];
|
||||
oscillating?: boolean;
|
||||
direction?: 'forward' | 'reverse';
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock-specific attributes
|
||||
*/
|
||||
export interface IHomeAssistantLockAttributes extends IHomeAssistantEntityAttributes {
|
||||
is_locked?: boolean;
|
||||
is_locking?: boolean;
|
||||
is_unlocking?: boolean;
|
||||
is_jammed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Camera-specific attributes
|
||||
*/
|
||||
export interface IHomeAssistantCameraAttributes extends IHomeAssistantEntityAttributes {
|
||||
access_token?: string;
|
||||
entity_picture?: string;
|
||||
frontend_stream_type?: 'hls' | 'web_rtc';
|
||||
is_streaming?: boolean;
|
||||
motion_detection?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Media player-specific attributes
|
||||
*/
|
||||
export interface IHomeAssistantMediaPlayerAttributes extends IHomeAssistantEntityAttributes {
|
||||
volume_level?: number; // 0-1
|
||||
is_volume_muted?: boolean;
|
||||
media_content_id?: string;
|
||||
media_content_type?: string;
|
||||
media_duration?: number;
|
||||
media_position?: number;
|
||||
media_position_updated_at?: string;
|
||||
media_title?: string;
|
||||
media_artist?: string;
|
||||
media_album_name?: string;
|
||||
media_album_artist?: string;
|
||||
media_track?: number;
|
||||
media_series_title?: string;
|
||||
media_season?: number;
|
||||
media_episode?: number;
|
||||
app_id?: string;
|
||||
app_name?: string;
|
||||
source?: string;
|
||||
source_list?: string[];
|
||||
sound_mode?: string;
|
||||
sound_mode_list?: string[];
|
||||
shuffle?: boolean;
|
||||
repeat?: 'off' | 'all' | 'one';
|
||||
entity_picture_local?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context for entity state changes
|
||||
*/
|
||||
export interface IHomeAssistantContext {
|
||||
id: string;
|
||||
parent_id?: string;
|
||||
user_id?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WebSocket Message Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Base message structure
|
||||
*/
|
||||
export interface IHomeAssistantMessage {
|
||||
id?: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication required message
|
||||
*/
|
||||
export interface IHomeAssistantAuthRequired extends IHomeAssistantMessage {
|
||||
type: 'auth_required';
|
||||
ha_version: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication message to send
|
||||
*/
|
||||
export interface IHomeAssistantAuth extends IHomeAssistantMessage {
|
||||
type: 'auth';
|
||||
access_token: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication success
|
||||
*/
|
||||
export interface IHomeAssistantAuthOk extends IHomeAssistantMessage {
|
||||
type: 'auth_ok';
|
||||
ha_version: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication invalid
|
||||
*/
|
||||
export interface IHomeAssistantAuthInvalid extends IHomeAssistantMessage {
|
||||
type: 'auth_invalid';
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result message
|
||||
*/
|
||||
export interface IHomeAssistantResult extends IHomeAssistantMessage {
|
||||
id: number;
|
||||
type: 'result';
|
||||
success: boolean;
|
||||
result?: unknown;
|
||||
error?: {
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Event message
|
||||
*/
|
||||
export interface IHomeAssistantEvent extends IHomeAssistantMessage {
|
||||
id: number;
|
||||
type: 'event';
|
||||
event: {
|
||||
event_type: string;
|
||||
data: unknown;
|
||||
origin: string;
|
||||
time_fired: string;
|
||||
context: IHomeAssistantContext;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* State changed event data
|
||||
*/
|
||||
export interface IHomeAssistantStateChangedEvent {
|
||||
entity_id: string;
|
||||
old_state: IHomeAssistantEntity | null;
|
||||
new_state: IHomeAssistantEntity | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe events request
|
||||
*/
|
||||
export interface IHomeAssistantSubscribeEvents extends IHomeAssistantMessage {
|
||||
id: number;
|
||||
type: 'subscribe_events';
|
||||
event_type?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get states request
|
||||
*/
|
||||
export interface IHomeAssistantGetStates extends IHomeAssistantMessage {
|
||||
id: number;
|
||||
type: 'get_states';
|
||||
}
|
||||
|
||||
/**
|
||||
* Call service request
|
||||
*/
|
||||
export interface IHomeAssistantCallService extends IHomeAssistantMessage {
|
||||
id: number;
|
||||
type: 'call_service';
|
||||
domain: string;
|
||||
service: string;
|
||||
target?: {
|
||||
entity_id?: string | string[];
|
||||
device_id?: string | string[];
|
||||
area_id?: string | string[];
|
||||
};
|
||||
service_data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get services request
|
||||
*/
|
||||
export interface IHomeAssistantGetServices extends IHomeAssistantMessage {
|
||||
id: number;
|
||||
type: 'get_services';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get config request
|
||||
*/
|
||||
export interface IHomeAssistantGetConfig extends IHomeAssistantMessage {
|
||||
id: number;
|
||||
type: 'get_config';
|
||||
}
|
||||
|
||||
/**
|
||||
* Home Assistant config response
|
||||
*/
|
||||
export interface IHomeAssistantConfig {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
elevation: number;
|
||||
unit_system: {
|
||||
length: string;
|
||||
mass: string;
|
||||
pressure: string;
|
||||
temperature: string;
|
||||
volume: string;
|
||||
};
|
||||
location_name: string;
|
||||
time_zone: string;
|
||||
components: string[];
|
||||
config_dir: string;
|
||||
allowlist_external_dirs: string[];
|
||||
allowlist_external_urls: string[];
|
||||
version: string;
|
||||
config_source: string;
|
||||
safe_mode: boolean;
|
||||
state: 'NOT_RUNNING' | 'STARTING' | 'RUNNING' | 'STOPPING' | 'FINAL_WRITE';
|
||||
external_url: string | null;
|
||||
internal_url: string | null;
|
||||
currency: string;
|
||||
country: string;
|
||||
language: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Service Definitions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Light service data
|
||||
*/
|
||||
export interface IHomeAssistantLightServiceData {
|
||||
brightness?: number; // 0-255
|
||||
brightness_pct?: number; // 0-100
|
||||
brightness_step?: number; // Step to increase/decrease
|
||||
brightness_step_pct?: number; // Step percentage
|
||||
color_temp?: number; // Mireds
|
||||
color_temp_kelvin?: number; // Kelvin
|
||||
hs_color?: [number, number]; // [hue, saturation]
|
||||
rgb_color?: [number, number, number];
|
||||
xy_color?: [number, number];
|
||||
rgbw_color?: [number, number, number, number];
|
||||
rgbww_color?: [number, number, number, number, number];
|
||||
color_name?: string;
|
||||
kelvin?: number;
|
||||
effect?: string;
|
||||
transition?: number; // Seconds
|
||||
flash?: 'short' | 'long';
|
||||
profile?: string;
|
||||
[key: string]: unknown; // Index signature for Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Climate service data
|
||||
*/
|
||||
export interface IHomeAssistantClimateServiceData {
|
||||
hvac_mode?: string;
|
||||
temperature?: number;
|
||||
target_temp_high?: number;
|
||||
target_temp_low?: number;
|
||||
humidity?: number;
|
||||
fan_mode?: string;
|
||||
swing_mode?: string;
|
||||
preset_mode?: string;
|
||||
aux_heat?: boolean;
|
||||
[key: string]: unknown; // Index signature for Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Cover service data
|
||||
*/
|
||||
export interface IHomeAssistantCoverServiceData {
|
||||
position?: number; // 0-100
|
||||
tilt_position?: number; // 0-100
|
||||
}
|
||||
|
||||
/**
|
||||
* Fan service data
|
||||
*/
|
||||
export interface IHomeAssistantFanServiceData {
|
||||
percentage?: number; // 0-100
|
||||
percentage_step?: number;
|
||||
preset_mode?: string;
|
||||
direction?: 'forward' | 'reverse';
|
||||
oscillating?: boolean;
|
||||
[key: string]: unknown; // Index signature for Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Media player service data
|
||||
*/
|
||||
export interface IHomeAssistantMediaPlayerServiceData {
|
||||
volume_level?: number; // 0-1
|
||||
is_volume_muted?: boolean;
|
||||
media_content_id?: string;
|
||||
media_content_type?: string;
|
||||
enqueue?: 'play' | 'next' | 'add' | 'replace';
|
||||
seek_position?: number;
|
||||
source?: string;
|
||||
sound_mode?: string;
|
||||
shuffle?: boolean;
|
||||
repeat?: 'off' | 'all' | 'one';
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Discovery Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Discovered Home Assistant instance via mDNS
|
||||
*/
|
||||
export interface IHomeAssistantDiscoveredInstance {
|
||||
/** Instance ID (derived from host) */
|
||||
id: string;
|
||||
/** Host address */
|
||||
host: string;
|
||||
/** Port number */
|
||||
port: number;
|
||||
/** Base URL */
|
||||
base_url: string;
|
||||
/** mDNS TXT records */
|
||||
txtRecords: Record<string, string>;
|
||||
/** Whether connection requires token */
|
||||
requires_api_password: boolean;
|
||||
/** Friendly name from mDNS */
|
||||
friendlyName?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Protocol Events
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Events emitted by HomeAssistantProtocol
|
||||
*/
|
||||
export type THomeAssistantProtocolEvents = {
|
||||
'connected': () => void;
|
||||
'disconnected': () => void;
|
||||
'reconnecting': (attempt: number) => void;
|
||||
'authenticated': (config: IHomeAssistantConfig) => void;
|
||||
'auth:failed': (message: string) => void;
|
||||
'state:changed': (event: IHomeAssistantStateChangedEvent) => void;
|
||||
'states:loaded': (entities: IHomeAssistantEntity[]) => void;
|
||||
'error': (error: Error) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Events emitted by HomeAssistantDiscovery
|
||||
*/
|
||||
export type THomeAssistantDiscoveryEvents = {
|
||||
'instance:found': (instance: IHomeAssistantDiscoveredInstance) => void;
|
||||
'instance:lost': (instanceId: string) => void;
|
||||
'entity:found': (entity: IHomeAssistantEntity) => void;
|
||||
'entity:updated': (entity: IHomeAssistantEntity) => void;
|
||||
'entity:removed': (entityId: string) => void;
|
||||
'error': (error: Error) => void;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Helper Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Extract domain from entity_id
|
||||
*/
|
||||
export function getEntityDomain(entityId: string): THomeAssistantDomain | null {
|
||||
const domain = entityId.split('.')[0];
|
||||
const validDomains: THomeAssistantDomain[] = [
|
||||
'light', 'switch', 'sensor', 'binary_sensor', 'climate',
|
||||
'fan', 'cover', 'lock', 'camera', 'media_player'
|
||||
];
|
||||
return validDomains.includes(domain as THomeAssistantDomain)
|
||||
? domain as THomeAssistantDomain
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map HA domain to feature type
|
||||
*/
|
||||
export function domainToFeatureType(domain: THomeAssistantDomain): string {
|
||||
const mapping: Record<THomeAssistantDomain, string> = {
|
||||
'light': 'light',
|
||||
'switch': 'switch',
|
||||
'sensor': 'sensor',
|
||||
'binary_sensor': 'sensor',
|
||||
'climate': 'climate',
|
||||
'fan': 'fan',
|
||||
'cover': 'cover',
|
||||
'lock': 'lock',
|
||||
'camera': 'camera',
|
||||
'media_player': 'playback',
|
||||
};
|
||||
return mapping[domain];
|
||||
}
|
||||
|
||||
/**
|
||||
* Supported light color modes in HA
|
||||
*/
|
||||
export type THomeAssistantColorMode =
|
||||
| 'unknown'
|
||||
| 'onoff'
|
||||
| 'brightness'
|
||||
| 'color_temp'
|
||||
| 'hs'
|
||||
| 'xy'
|
||||
| 'rgb'
|
||||
| 'rgbw'
|
||||
| 'rgbww'
|
||||
| 'white';
|
||||
|
||||
/**
|
||||
* Light supported features bitmask
|
||||
*/
|
||||
export const LIGHT_SUPPORT = {
|
||||
EFFECT: 4,
|
||||
FLASH: 8,
|
||||
TRANSITION: 32,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Climate supported features bitmask
|
||||
*/
|
||||
export const CLIMATE_SUPPORT = {
|
||||
TARGET_TEMPERATURE: 1,
|
||||
TARGET_TEMPERATURE_RANGE: 2,
|
||||
TARGET_HUMIDITY: 4,
|
||||
FAN_MODE: 8,
|
||||
PRESET_MODE: 16,
|
||||
SWING_MODE: 32,
|
||||
AUX_HEAT: 64,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Cover supported features bitmask
|
||||
*/
|
||||
export const COVER_SUPPORT = {
|
||||
OPEN: 1,
|
||||
CLOSE: 2,
|
||||
SET_POSITION: 4,
|
||||
STOP: 8,
|
||||
OPEN_TILT: 16,
|
||||
CLOSE_TILT: 32,
|
||||
STOP_TILT: 64,
|
||||
SET_TILT_POSITION: 128,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Fan supported features bitmask
|
||||
*/
|
||||
export const FAN_SUPPORT = {
|
||||
SET_SPEED: 1,
|
||||
OSCILLATE: 2,
|
||||
DIRECTION: 4,
|
||||
PRESET_MODE: 8,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Lock supported features bitmask
|
||||
*/
|
||||
export const LOCK_SUPPORT = {
|
||||
OPEN: 1,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Media player supported features bitmask
|
||||
*/
|
||||
export const MEDIA_PLAYER_SUPPORT = {
|
||||
PAUSE: 1,
|
||||
SEEK: 2,
|
||||
VOLUME_SET: 4,
|
||||
VOLUME_MUTE: 8,
|
||||
PREVIOUS_TRACK: 16,
|
||||
NEXT_TRACK: 32,
|
||||
TURN_ON: 128,
|
||||
TURN_OFF: 256,
|
||||
PLAY_MEDIA: 512,
|
||||
VOLUME_STEP: 1024,
|
||||
SELECT_SOURCE: 2048,
|
||||
STOP: 4096,
|
||||
CLEAR_PLAYLIST: 8192,
|
||||
PLAY: 16384,
|
||||
SHUFFLE_SET: 32768,
|
||||
SELECT_SOUND_MODE: 65536,
|
||||
BROWSE_MEDIA: 131072,
|
||||
REPEAT_SET: 262144,
|
||||
GROUPING: 524288,
|
||||
} as const;
|
||||
@@ -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';
|
||||
|
||||
421
ts/interfaces/smarthome.interfaces.ts
Normal file
421
ts/interfaces/smarthome.interfaces.ts
Normal file
@@ -0,0 +1,421 @@
|
||||
/**
|
||||
* Smart Home Device Interfaces
|
||||
* Generic types for smart home features (lights, climate, sensors, etc.)
|
||||
* Protocol-agnostic - can be implemented by Home Assistant, Hue, MQTT, etc.
|
||||
*/
|
||||
|
||||
import type { TFeatureState, IFeatureInfo } from './feature.interfaces.js';
|
||||
|
||||
// ============================================================================
|
||||
// Light Feature Types
|
||||
// ============================================================================
|
||||
|
||||
export type TLightProtocol = 'home-assistant' | 'hue' | 'mqtt' | 'zigbee';
|
||||
|
||||
export interface ILightCapabilities {
|
||||
supportsBrightness: boolean;
|
||||
supportsColorTemp: boolean;
|
||||
supportsRgb: boolean;
|
||||
supportsHs: boolean; // Hue/Saturation
|
||||
supportsXy: boolean; // CIE xy color
|
||||
supportsEffects: boolean;
|
||||
supportsTransition: boolean;
|
||||
effects?: string[];
|
||||
minMireds?: number;
|
||||
maxMireds?: number;
|
||||
minColorTempKelvin?: number;
|
||||
maxColorTempKelvin?: number;
|
||||
}
|
||||
|
||||
export interface ILightState {
|
||||
isOn: boolean;
|
||||
brightness?: number; // 0-255
|
||||
colorTemp?: number; // Kelvin
|
||||
colorTempMireds?: number; // Mireds (1000000/Kelvin)
|
||||
rgbColor?: [number, number, number];
|
||||
hsColor?: [number, number]; // [hue 0-360, saturation 0-100]
|
||||
xyColor?: [number, number]; // CIE xy
|
||||
effect?: string;
|
||||
}
|
||||
|
||||
export interface ILightFeatureInfo extends IFeatureInfo {
|
||||
type: 'light';
|
||||
protocol: TLightProtocol;
|
||||
capabilities: ILightCapabilities;
|
||||
currentState: ILightState;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Climate/Thermostat Feature Types
|
||||
// ============================================================================
|
||||
|
||||
export type TClimateProtocol = 'home-assistant' | 'nest' | 'ecobee' | 'mqtt';
|
||||
|
||||
export type THvacMode =
|
||||
| 'off'
|
||||
| 'heat'
|
||||
| 'cool'
|
||||
| 'heat_cool' // Auto dual setpoint
|
||||
| 'auto'
|
||||
| 'dry'
|
||||
| 'fan_only';
|
||||
|
||||
export type THvacAction =
|
||||
| 'off'
|
||||
| 'heating'
|
||||
| 'cooling'
|
||||
| 'drying'
|
||||
| 'idle'
|
||||
| 'fan';
|
||||
|
||||
export interface IClimateCapabilities {
|
||||
hvacModes: THvacMode[];
|
||||
presetModes?: string[]; // 'away', 'eco', 'boost', 'sleep'
|
||||
fanModes?: string[]; // 'auto', 'low', 'medium', 'high'
|
||||
swingModes?: string[]; // 'off', 'vertical', 'horizontal', 'both'
|
||||
supportsTargetTemp: boolean;
|
||||
supportsTargetTempRange: boolean; // For heat_cool mode
|
||||
supportsHumidity: boolean;
|
||||
supportsAuxHeat: boolean;
|
||||
minTemp: number;
|
||||
maxTemp: number;
|
||||
tempStep: number; // Temperature increment (e.g., 0.5, 1)
|
||||
minHumidity?: number;
|
||||
maxHumidity?: number;
|
||||
}
|
||||
|
||||
export interface IClimateState {
|
||||
currentTemp?: number;
|
||||
targetTemp?: number;
|
||||
targetTempHigh?: number; // For heat_cool mode
|
||||
targetTempLow?: number; // For heat_cool mode
|
||||
hvacMode: THvacMode;
|
||||
hvacAction?: THvacAction;
|
||||
presetMode?: string;
|
||||
fanMode?: string;
|
||||
swingMode?: string;
|
||||
humidity?: number;
|
||||
targetHumidity?: number;
|
||||
auxHeat?: boolean;
|
||||
}
|
||||
|
||||
export interface IClimateFeatureInfo extends IFeatureInfo {
|
||||
type: 'climate';
|
||||
protocol: TClimateProtocol;
|
||||
capabilities: IClimateCapabilities;
|
||||
currentState: IClimateState;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Sensor Feature Types
|
||||
// ============================================================================
|
||||
|
||||
export type TSensorProtocol = 'home-assistant' | 'mqtt' | 'snmp';
|
||||
|
||||
export type TSensorDeviceClass =
|
||||
| 'temperature'
|
||||
| 'humidity'
|
||||
| 'pressure'
|
||||
| 'illuminance'
|
||||
| 'battery'
|
||||
| 'power'
|
||||
| 'energy'
|
||||
| 'voltage'
|
||||
| 'current'
|
||||
| 'frequency'
|
||||
| 'gas'
|
||||
| 'co2'
|
||||
| 'pm25'
|
||||
| 'pm10'
|
||||
| 'signal_strength'
|
||||
| 'timestamp'
|
||||
| 'duration'
|
||||
| 'distance'
|
||||
| 'speed'
|
||||
| 'weight'
|
||||
| 'monetary'
|
||||
| 'data_size'
|
||||
| 'data_rate'
|
||||
| 'water'
|
||||
| 'irradiance'
|
||||
| 'precipitation'
|
||||
| 'precipitation_intensity'
|
||||
| 'wind_speed';
|
||||
|
||||
export type TSensorStateClass =
|
||||
| 'measurement' // Instantaneous reading
|
||||
| 'total' // Cumulative total
|
||||
| 'total_increasing'; // Monotonically increasing total
|
||||
|
||||
export interface ISensorCapabilities {
|
||||
deviceClass?: TSensorDeviceClass;
|
||||
stateClass?: TSensorStateClass;
|
||||
unit?: string;
|
||||
nativeUnit?: string;
|
||||
precision?: number; // Decimal places
|
||||
}
|
||||
|
||||
export interface ISensorState {
|
||||
value: string | number | boolean;
|
||||
numericValue?: number;
|
||||
unit?: string;
|
||||
lastUpdated: Date;
|
||||
}
|
||||
|
||||
export interface ISensorFeatureInfo extends IFeatureInfo {
|
||||
type: 'sensor';
|
||||
protocol: TSensorProtocol;
|
||||
capabilities: ISensorCapabilities;
|
||||
currentState: ISensorState;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Camera Feature Types
|
||||
// ============================================================================
|
||||
|
||||
export type TCameraProtocol = 'home-assistant' | 'onvif' | 'rtsp';
|
||||
|
||||
export interface ICameraCapabilities {
|
||||
supportsStream: boolean;
|
||||
supportsPtz: boolean; // Pan-tilt-zoom
|
||||
supportsSnapshot: boolean;
|
||||
supportsMotionDetection: boolean;
|
||||
frontendStreamType?: 'hls' | 'web_rtc';
|
||||
streamUrl?: string;
|
||||
}
|
||||
|
||||
export interface ICameraState {
|
||||
isRecording: boolean;
|
||||
isStreaming: boolean;
|
||||
motionDetected: boolean;
|
||||
}
|
||||
|
||||
export interface ICameraFeatureInfo extends IFeatureInfo {
|
||||
type: 'camera';
|
||||
protocol: TCameraProtocol;
|
||||
capabilities: ICameraCapabilities;
|
||||
currentState: ICameraState;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Cover/Blind Feature Types
|
||||
// ============================================================================
|
||||
|
||||
export type TCoverProtocol = 'home-assistant' | 'mqtt' | 'somfy';
|
||||
|
||||
export type TCoverDeviceClass =
|
||||
| 'awning'
|
||||
| 'blind'
|
||||
| 'curtain'
|
||||
| 'damper'
|
||||
| 'door'
|
||||
| 'garage'
|
||||
| 'gate'
|
||||
| 'shade'
|
||||
| 'shutter'
|
||||
| 'window';
|
||||
|
||||
export type TCoverState = 'open' | 'opening' | 'closed' | 'closing' | 'stopped' | 'unknown';
|
||||
|
||||
export interface ICoverCapabilities {
|
||||
deviceClass?: TCoverDeviceClass;
|
||||
supportsOpen: boolean;
|
||||
supportsClose: boolean;
|
||||
supportsStop: boolean;
|
||||
supportsPosition: boolean; // set_cover_position
|
||||
supportsTilt: boolean; // set_cover_tilt_position
|
||||
}
|
||||
|
||||
export interface ICoverStateInfo {
|
||||
state: TCoverState;
|
||||
position?: number; // 0-100, 0 = closed, 100 = fully open
|
||||
tiltPosition?: number; // 0-100
|
||||
}
|
||||
|
||||
export interface ICoverFeatureInfo extends IFeatureInfo {
|
||||
type: 'cover';
|
||||
protocol: TCoverProtocol;
|
||||
capabilities: ICoverCapabilities;
|
||||
currentState: ICoverStateInfo;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Switch Feature Types
|
||||
// ============================================================================
|
||||
|
||||
export type TSwitchProtocol = 'home-assistant' | 'mqtt' | 'tasmota' | 'tuya';
|
||||
|
||||
export type TSwitchDeviceClass = 'outlet' | 'switch';
|
||||
|
||||
export interface ISwitchCapabilities {
|
||||
deviceClass?: TSwitchDeviceClass;
|
||||
}
|
||||
|
||||
export interface ISwitchState {
|
||||
isOn: boolean;
|
||||
}
|
||||
|
||||
export interface ISwitchFeatureInfo extends IFeatureInfo {
|
||||
type: 'switch';
|
||||
protocol: TSwitchProtocol;
|
||||
capabilities: ISwitchCapabilities;
|
||||
currentState: ISwitchState;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Lock Feature Types
|
||||
// ============================================================================
|
||||
|
||||
export type TLockProtocol = 'home-assistant' | 'mqtt' | 'august' | 'yale';
|
||||
|
||||
export type TLockState =
|
||||
| 'locked'
|
||||
| 'unlocked'
|
||||
| 'locking'
|
||||
| 'unlocking'
|
||||
| 'jammed'
|
||||
| 'unknown';
|
||||
|
||||
export interface ILockCapabilities {
|
||||
supportsOpen: boolean; // Physical open (some locks can open the door)
|
||||
}
|
||||
|
||||
export interface ILockStateInfo {
|
||||
state: TLockState;
|
||||
isLocked: boolean;
|
||||
}
|
||||
|
||||
export interface ILockFeatureInfo extends IFeatureInfo {
|
||||
type: 'lock';
|
||||
protocol: TLockProtocol;
|
||||
capabilities: ILockCapabilities;
|
||||
currentState: ILockStateInfo;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Fan Feature Types
|
||||
// ============================================================================
|
||||
|
||||
export type TFanProtocol = 'home-assistant' | 'mqtt' | 'bond';
|
||||
|
||||
export type TFanDirection = 'forward' | 'reverse';
|
||||
|
||||
export interface IFanCapabilities {
|
||||
supportsSpeed: boolean;
|
||||
supportsOscillate: boolean;
|
||||
supportsDirection: boolean;
|
||||
supportsPresetModes: boolean;
|
||||
presetModes?: string[];
|
||||
speedCount?: number; // Number of discrete speed levels
|
||||
}
|
||||
|
||||
export interface IFanState {
|
||||
isOn: boolean;
|
||||
percentage?: number; // 0-100 speed
|
||||
presetMode?: string;
|
||||
oscillating?: boolean;
|
||||
direction?: TFanDirection;
|
||||
}
|
||||
|
||||
export interface IFanFeatureInfo extends IFeatureInfo {
|
||||
type: 'fan';
|
||||
protocol: TFanProtocol;
|
||||
capabilities: IFanCapabilities;
|
||||
currentState: IFanState;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Protocol Client Interfaces (for dependency injection)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Light protocol client interface
|
||||
* Implemented by HomeAssistantProtocol, HueProtocol, etc.
|
||||
*/
|
||||
export interface ILightProtocolClient {
|
||||
turnOn(entityId: string, options?: { brightness?: number; colorTemp?: number; rgb?: [number, number, number]; transition?: number }): Promise<void>;
|
||||
turnOff(entityId: string, options?: { transition?: number }): Promise<void>;
|
||||
toggle(entityId: string): Promise<void>;
|
||||
setBrightness(entityId: string, brightness: number, transition?: number): Promise<void>;
|
||||
setColorTemp(entityId: string, kelvin: number, transition?: number): Promise<void>;
|
||||
setRgbColor(entityId: string, r: number, g: number, b: number, transition?: number): Promise<void>;
|
||||
setEffect(entityId: string, effect: string): Promise<void>;
|
||||
getState(entityId: string): Promise<ILightState>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Climate protocol client interface
|
||||
*/
|
||||
export interface IClimateProtocolClient {
|
||||
setHvacMode(entityId: string, mode: THvacMode): Promise<void>;
|
||||
setTargetTemp(entityId: string, temp: number): Promise<void>;
|
||||
setTargetTempRange(entityId: string, low: number, high: number): Promise<void>;
|
||||
setPresetMode(entityId: string, preset: string): Promise<void>;
|
||||
setFanMode(entityId: string, mode: string): Promise<void>;
|
||||
setSwingMode(entityId: string, mode: string): Promise<void>;
|
||||
setAuxHeat(entityId: string, enabled: boolean): Promise<void>;
|
||||
getState(entityId: string): Promise<IClimateState>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sensor protocol client interface (read-only)
|
||||
*/
|
||||
export interface ISensorProtocolClient {
|
||||
getState(entityId: string): Promise<ISensorState>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Camera protocol client interface
|
||||
*/
|
||||
export interface ICameraProtocolClient {
|
||||
getSnapshot(entityId: string): Promise<Buffer>;
|
||||
getSnapshotUrl(entityId: string): Promise<string>;
|
||||
getStreamUrl(entityId: string): Promise<string>;
|
||||
getState(entityId: string): Promise<ICameraState>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cover protocol client interface
|
||||
*/
|
||||
export interface ICoverProtocolClient {
|
||||
open(entityId: string): Promise<void>;
|
||||
close(entityId: string): Promise<void>;
|
||||
stop(entityId: string): Promise<void>;
|
||||
setPosition(entityId: string, position: number): Promise<void>;
|
||||
setTiltPosition(entityId: string, position: number): Promise<void>;
|
||||
getState(entityId: string): Promise<ICoverStateInfo>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch protocol client interface
|
||||
*/
|
||||
export interface ISwitchProtocolClient {
|
||||
turnOn(entityId: string): Promise<void>;
|
||||
turnOff(entityId: string): Promise<void>;
|
||||
toggle(entityId: string): Promise<void>;
|
||||
getState(entityId: string): Promise<ISwitchState>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock protocol client interface
|
||||
*/
|
||||
export interface ILockProtocolClient {
|
||||
lock(entityId: string): Promise<void>;
|
||||
unlock(entityId: string): Promise<void>;
|
||||
open(entityId: string): Promise<void>; // Physical open if supported
|
||||
getState(entityId: string): Promise<ILockStateInfo>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fan protocol client interface
|
||||
*/
|
||||
export interface IFanProtocolClient {
|
||||
turnOn(entityId: string, percentage?: number): Promise<void>;
|
||||
turnOff(entityId: string): Promise<void>;
|
||||
toggle(entityId: string): Promise<void>;
|
||||
setPercentage(entityId: string, percentage: number): Promise<void>;
|
||||
setPresetMode(entityId: string, mode: string): Promise<void>;
|
||||
setOscillating(entityId: string, oscillating: boolean): Promise<void>;
|
||||
setDirection(entityId: string, direction: TFanDirection): Promise<void>;
|
||||
getState(entityId: string): Promise<IFanState>;
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import nodeSsdpModule from 'node-ssdp';
|
||||
import * as netSnmp from 'net-snmp';
|
||||
import * as sonos from 'sonos';
|
||||
import * as castv2Client from 'castv2-client';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
// node-ssdp exports Client/Server under default in ESM
|
||||
const nodeSsdp = {
|
||||
@@ -37,4 +38,4 @@ const nodeSsdp = {
|
||||
Server: nodeSsdpModule.Server,
|
||||
};
|
||||
|
||||
export { bonjourService, ipp, nodeSsdp, netSnmp, sonos, castv2Client };
|
||||
export { bonjourService, ipp, nodeSsdp, netSnmp, sonos, castv2Client, WebSocket };
|
||||
|
||||
@@ -1,255 +0,0 @@
|
||||
import { Device } from '../abstract/device.abstract.js';
|
||||
import { IppProtocol } from './printer.classes.ippprotocol.js';
|
||||
import type {
|
||||
IPrinterInfo,
|
||||
IPrinterCapabilities,
|
||||
IPrintOptions,
|
||||
IPrintJob,
|
||||
IRetryOptions,
|
||||
} from '../interfaces/index.js';
|
||||
|
||||
/**
|
||||
* Printer class for IPP network printers
|
||||
*/
|
||||
export class Printer extends Device {
|
||||
public readonly uri: string;
|
||||
public supportsColor: boolean = false;
|
||||
public supportsDuplex: boolean = false;
|
||||
public supportedMediaTypes: string[] = [];
|
||||
public supportedMediaSizes: string[] = [];
|
||||
public maxCopies: number = 99;
|
||||
|
||||
private ippClient: IppProtocol | null = null;
|
||||
private ippPath: string;
|
||||
|
||||
constructor(
|
||||
info: IPrinterInfo,
|
||||
options?: {
|
||||
ippPath?: string;
|
||||
retryOptions?: IRetryOptions;
|
||||
}
|
||||
) {
|
||||
super(info, options?.retryOptions);
|
||||
this.uri = info.uri;
|
||||
this.supportsColor = info.supportsColor;
|
||||
this.supportsDuplex = info.supportsDuplex;
|
||||
this.supportedMediaTypes = info.supportedMediaTypes;
|
||||
this.supportedMediaSizes = info.supportedMediaSizes;
|
||||
this.maxCopies = info.maxCopies;
|
||||
this.ippPath = options?.ippPath ?? '/ipp/print';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Printer from discovery info
|
||||
*/
|
||||
public static fromDiscovery(
|
||||
discoveredDevice: {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
port: number;
|
||||
txtRecords: Record<string, string>;
|
||||
},
|
||||
retryOptions?: IRetryOptions
|
||||
): Printer {
|
||||
// Parse capabilities from TXT records
|
||||
const txtRecords = discoveredDevice.txtRecords;
|
||||
|
||||
// Get IPP path from TXT records
|
||||
const rp = txtRecords['rp'] || 'ipp/print';
|
||||
const ippPath = rp.startsWith('/') ? rp : `/${rp}`;
|
||||
|
||||
// Parse color support
|
||||
const colorSupported =
|
||||
txtRecords['Color'] === 'T' ||
|
||||
txtRecords['color'] === 'true' ||
|
||||
txtRecords['URF']?.includes('W8') ||
|
||||
false;
|
||||
|
||||
// Parse duplex support
|
||||
const duplexSupported =
|
||||
txtRecords['Duplex'] === 'T' ||
|
||||
txtRecords['duplex'] === 'true' ||
|
||||
txtRecords['URF']?.includes('DM') ||
|
||||
false;
|
||||
|
||||
// Build printer URI
|
||||
const isSecure = txtRecords['TLS'] === '1' || discoveredDevice.port === 443;
|
||||
const protocol = isSecure ? 'ipps' : 'ipp';
|
||||
const uri = `${protocol}://${discoveredDevice.address}:${discoveredDevice.port}${ippPath}`;
|
||||
|
||||
const info: IPrinterInfo = {
|
||||
id: discoveredDevice.id,
|
||||
name: discoveredDevice.name,
|
||||
type: 'printer',
|
||||
address: discoveredDevice.address,
|
||||
port: discoveredDevice.port,
|
||||
status: 'online',
|
||||
uri: uri,
|
||||
supportsColor: colorSupported,
|
||||
supportsDuplex: duplexSupported,
|
||||
supportedMediaTypes: [],
|
||||
supportedMediaSizes: [],
|
||||
maxCopies: 99,
|
||||
manufacturer: txtRecords['usb_MFG'] || txtRecords['mfg'],
|
||||
model: txtRecords['usb_MDL'] || txtRecords['mdl'] || txtRecords['ty'],
|
||||
};
|
||||
|
||||
return new Printer(info, { ippPath, retryOptions });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get printer info
|
||||
*/
|
||||
public getPrinterInfo(): IPrinterInfo {
|
||||
return {
|
||||
...this.getInfo(),
|
||||
type: 'printer',
|
||||
uri: this.uri,
|
||||
supportsColor: this.supportsColor,
|
||||
supportsDuplex: this.supportsDuplex,
|
||||
supportedMediaTypes: this.supportedMediaTypes,
|
||||
supportedMediaSizes: this.supportedMediaSizes,
|
||||
maxCopies: this.maxCopies,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get printer capabilities
|
||||
*/
|
||||
public async getCapabilities(): Promise<IPrinterCapabilities> {
|
||||
if (!this.isConnected) {
|
||||
await this.connect();
|
||||
}
|
||||
|
||||
if (!this.ippClient) {
|
||||
throw new Error('IPP client not initialized');
|
||||
}
|
||||
|
||||
const caps = await this.withRetry(() => this.ippClient!.getAttributes());
|
||||
|
||||
// Update local properties
|
||||
this.supportsColor = caps.colorSupported;
|
||||
this.supportsDuplex = caps.duplexSupported;
|
||||
this.supportedMediaSizes = caps.mediaSizes;
|
||||
this.supportedMediaTypes = caps.mediaTypes;
|
||||
this.maxCopies = caps.maxCopies;
|
||||
|
||||
return caps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Print a document
|
||||
*/
|
||||
public async print(data: Buffer, options?: IPrintOptions): Promise<IPrintJob> {
|
||||
if (!this.isConnected) {
|
||||
await this.connect();
|
||||
}
|
||||
|
||||
if (!this.ippClient) {
|
||||
throw new Error('IPP client not initialized');
|
||||
}
|
||||
|
||||
this.setStatus('busy');
|
||||
this.emit('print:started', options);
|
||||
|
||||
try {
|
||||
const job = await this.withRetry(() => this.ippClient!.print(data, options));
|
||||
this.setStatus('online');
|
||||
this.emit('print:submitted', job);
|
||||
return job;
|
||||
} catch (error) {
|
||||
this.setStatus('online');
|
||||
this.emit('print:error', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all print jobs
|
||||
*/
|
||||
public async getJobs(): Promise<IPrintJob[]> {
|
||||
if (!this.isConnected) {
|
||||
await this.connect();
|
||||
}
|
||||
|
||||
if (!this.ippClient) {
|
||||
throw new Error('IPP client not initialized');
|
||||
}
|
||||
|
||||
return this.withRetry(() => this.ippClient!.getJobs());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get specific job info
|
||||
*/
|
||||
public async getJobInfo(jobId: number): Promise<IPrintJob> {
|
||||
if (!this.isConnected) {
|
||||
await this.connect();
|
||||
}
|
||||
|
||||
if (!this.ippClient) {
|
||||
throw new Error('IPP client not initialized');
|
||||
}
|
||||
|
||||
return this.withRetry(() => this.ippClient!.getJobInfo(jobId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a print job
|
||||
*/
|
||||
public async cancelJob(jobId: number): Promise<void> {
|
||||
if (!this.isConnected) {
|
||||
await this.connect();
|
||||
}
|
||||
|
||||
if (!this.ippClient) {
|
||||
throw new Error('IPP client not initialized');
|
||||
}
|
||||
|
||||
await this.withRetry(() => this.ippClient!.cancelJob(jobId));
|
||||
this.emit('print:canceled', jobId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the printer
|
||||
*/
|
||||
protected async doConnect(): Promise<void> {
|
||||
this.ippClient = new IppProtocol(this.address, this.port, this.ippPath);
|
||||
|
||||
// Test connection by checking availability
|
||||
const available = await this.ippClient.checkAvailability();
|
||||
if (!available) {
|
||||
throw new Error('Printer not available');
|
||||
}
|
||||
|
||||
// Fetch capabilities to populate local properties
|
||||
await this.getCapabilities();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from the printer
|
||||
*/
|
||||
protected async doDisconnect(): Promise<void> {
|
||||
this.ippClient = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh printer status
|
||||
*/
|
||||
public async refreshStatus(): Promise<void> {
|
||||
try {
|
||||
if (this.ippClient) {
|
||||
const available = await this.ippClient.checkAvailability();
|
||||
this.setStatus(available ? 'online' : 'offline');
|
||||
} else {
|
||||
this.setStatus('offline');
|
||||
}
|
||||
} catch (error) {
|
||||
this.setStatus('error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { IppProtocol };
|
||||
59
ts/protocols/index.ts
Normal file
59
ts/protocols/index.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Protocol implementations
|
||||
* All network communication protocols for device interaction
|
||||
*/
|
||||
|
||||
// eSCL/AirScan scanner protocol
|
||||
export { EsclProtocol } from './protocol.escl.js';
|
||||
|
||||
// SANE network scanner protocol
|
||||
export { SaneProtocol } from './protocol.sane.js';
|
||||
|
||||
// IPP printer protocol
|
||||
export { IppProtocol } from './protocol.ipp.js';
|
||||
|
||||
// SNMP query protocol
|
||||
export {
|
||||
SnmpProtocol,
|
||||
SNMP_OIDS,
|
||||
type TSnmpValueType,
|
||||
type ISnmpVarbind,
|
||||
type ISnmpOptions,
|
||||
} from './protocol.snmp.js';
|
||||
|
||||
// Network UPS Tools protocol
|
||||
export {
|
||||
NutProtocol,
|
||||
NUT_VARIABLES,
|
||||
NUT_COMMANDS,
|
||||
type TNutStatusFlag,
|
||||
type INutUpsInfo,
|
||||
type INutVariable,
|
||||
} from './protocol.nut.js';
|
||||
|
||||
// UPnP/DLNA SOAP protocol
|
||||
export {
|
||||
UpnpSoapClient,
|
||||
UPNP_SERVICE_TYPES,
|
||||
UPNP_DEVICE_TYPES,
|
||||
type TDlnaTransportState,
|
||||
type TDlnaTransportStatus,
|
||||
type IDlnaPositionInfo,
|
||||
type IDlnaTransportInfo,
|
||||
type IDlnaMediaInfo,
|
||||
type IDlnaContentItem,
|
||||
type IDlnaBrowseResult,
|
||||
} from './protocol.upnp.js';
|
||||
|
||||
// UPS SNMP (UPS-MIB RFC 1628)
|
||||
export {
|
||||
UpsSnmpHandler,
|
||||
UPS_SNMP_OIDS,
|
||||
type TUpsBatteryStatus,
|
||||
type TUpsOutputSource,
|
||||
type TUpsTestResult,
|
||||
type IUpsSnmpStatus,
|
||||
} from './protocol.upssnmp.js';
|
||||
|
||||
// Home Assistant WebSocket protocol
|
||||
export { HomeAssistantProtocol } from './protocol.homeassistant.js';
|
||||
@@ -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
|
||||
|
||||
|
||||
737
ts/protocols/protocol.homeassistant.ts
Normal file
737
ts/protocols/protocol.homeassistant.ts
Normal file
@@ -0,0 +1,737 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type {
|
||||
IHomeAssistantInstanceConfig,
|
||||
IHomeAssistantEntity,
|
||||
IHomeAssistantConfig,
|
||||
IHomeAssistantStateChangedEvent,
|
||||
IHomeAssistantMessage,
|
||||
IHomeAssistantAuthRequired,
|
||||
IHomeAssistantAuthOk,
|
||||
IHomeAssistantAuthInvalid,
|
||||
IHomeAssistantResult,
|
||||
IHomeAssistantEvent,
|
||||
THomeAssistantProtocolEvents,
|
||||
IHomeAssistantLightServiceData,
|
||||
IHomeAssistantClimateServiceData,
|
||||
IHomeAssistantCoverServiceData,
|
||||
IHomeAssistantFanServiceData,
|
||||
IHomeAssistantMediaPlayerServiceData,
|
||||
} from '../interfaces/homeassistant.interfaces.js';
|
||||
|
||||
/**
|
||||
* Home Assistant WebSocket Protocol Handler
|
||||
* Connects to HA via WebSocket, handles authentication, state subscriptions, and service calls
|
||||
*/
|
||||
export class HomeAssistantProtocol extends plugins.events.EventEmitter {
|
||||
private ws: InstanceType<typeof plugins.WebSocket> | null = null;
|
||||
private config: IHomeAssistantInstanceConfig;
|
||||
private messageId: number = 1;
|
||||
private pendingRequests: Map<number, {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
}> = new Map();
|
||||
private isAuthenticated: boolean = false;
|
||||
private haConfig: IHomeAssistantConfig | null = null;
|
||||
private reconnectAttempt: number = 0;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private stateSubscriptionId: number | null = null;
|
||||
private entityStates: Map<string, IHomeAssistantEntity> = new Map();
|
||||
private intentionalDisconnect: boolean = false;
|
||||
|
||||
constructor(config: IHomeAssistantInstanceConfig) {
|
||||
super();
|
||||
this.config = {
|
||||
port: 8123,
|
||||
secure: false,
|
||||
autoReconnect: true,
|
||||
reconnectDelay: 5000,
|
||||
...config,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the WebSocket URL for this HA instance
|
||||
*/
|
||||
private get wsUrl(): string {
|
||||
const protocol = this.config.secure ? 'wss' : 'ws';
|
||||
return `${protocol}://${this.config.host}:${this.config.port}/api/websocket`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection state
|
||||
*/
|
||||
public get isConnected(): boolean {
|
||||
return this.ws !== null && this.ws.readyState === plugins.WebSocket.OPEN && this.isAuthenticated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get HA config if authenticated
|
||||
*/
|
||||
public get homeAssistantConfig(): IHomeAssistantConfig | null {
|
||||
return this.haConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all cached entity states
|
||||
*/
|
||||
public get entities(): Map<string, IHomeAssistantEntity> {
|
||||
return this.entityStates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to Home Assistant
|
||||
*/
|
||||
public async connect(): Promise<void> {
|
||||
if (this.ws && this.ws.readyState === plugins.WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.intentionalDisconnect = false;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
this.ws = new plugins.WebSocket(this.wsUrl);
|
||||
|
||||
const connectionTimeout = setTimeout(() => {
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
reject(new Error(`Connection timeout to ${this.wsUrl}`));
|
||||
}, 10000);
|
||||
|
||||
this.ws.on('open', () => {
|
||||
// Connection established, waiting for auth_required
|
||||
});
|
||||
|
||||
this.ws.on('message', async (data: Buffer | string) => {
|
||||
const message = JSON.parse(data.toString()) as IHomeAssistantMessage;
|
||||
|
||||
if (message.type === 'auth_required') {
|
||||
// Send authentication
|
||||
await this.sendAuth();
|
||||
} else if (message.type === 'auth_ok') {
|
||||
clearTimeout(connectionTimeout);
|
||||
this.isAuthenticated = true;
|
||||
this.reconnectAttempt = 0;
|
||||
|
||||
// Get HA config
|
||||
try {
|
||||
this.haConfig = await this.getConfig();
|
||||
this.emit('authenticated', this.haConfig);
|
||||
} catch (err) {
|
||||
// Non-fatal, continue anyway
|
||||
}
|
||||
|
||||
this.emit('connected');
|
||||
resolve();
|
||||
} else if (message.type === 'auth_invalid') {
|
||||
clearTimeout(connectionTimeout);
|
||||
const authInvalid = message as IHomeAssistantAuthInvalid;
|
||||
this.emit('auth:failed', authInvalid.message);
|
||||
reject(new Error(`Authentication failed: ${authInvalid.message}`));
|
||||
} else {
|
||||
// Handle other messages
|
||||
this.handleMessage(message);
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.on('error', (error: Error) => {
|
||||
this.emit('error', error);
|
||||
});
|
||||
|
||||
this.ws.on('close', () => {
|
||||
this.isAuthenticated = false;
|
||||
this.stateSubscriptionId = null;
|
||||
|
||||
// Reject all pending requests
|
||||
for (const [id, request] of this.pendingRequests) {
|
||||
clearTimeout(request.timeout);
|
||||
request.reject(new Error('Connection closed'));
|
||||
this.pendingRequests.delete(id);
|
||||
}
|
||||
|
||||
this.emit('disconnected');
|
||||
|
||||
// Auto-reconnect if not intentional
|
||||
if (this.config.autoReconnect && !this.intentionalDisconnect) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from Home Assistant
|
||||
*/
|
||||
public async disconnect(): Promise<void> {
|
||||
this.intentionalDisconnect = true;
|
||||
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
|
||||
if (this.ws) {
|
||||
// Clear all pending requests
|
||||
for (const [id, request] of this.pendingRequests) {
|
||||
clearTimeout(request.timeout);
|
||||
request.reject(new Error('Disconnecting'));
|
||||
this.pendingRequests.delete(id);
|
||||
}
|
||||
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
this.isAuthenticated = false;
|
||||
this.stateSubscriptionId = null;
|
||||
this.entityStates.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send authentication message
|
||||
*/
|
||||
private async sendAuth(): Promise<void> {
|
||||
if (!this.ws) return;
|
||||
|
||||
const authMessage = {
|
||||
type: 'auth',
|
||||
access_token: this.config.token,
|
||||
};
|
||||
|
||||
this.ws.send(JSON.stringify(authMessage));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming messages
|
||||
*/
|
||||
private handleMessage(message: IHomeAssistantMessage): void {
|
||||
if (message.type === 'result') {
|
||||
const result = message as IHomeAssistantResult;
|
||||
const pending = this.pendingRequests.get(result.id);
|
||||
|
||||
if (pending) {
|
||||
clearTimeout(pending.timeout);
|
||||
this.pendingRequests.delete(result.id);
|
||||
|
||||
if (result.success) {
|
||||
pending.resolve(result.result);
|
||||
} else {
|
||||
pending.reject(new Error(result.error?.message || 'Unknown error'));
|
||||
}
|
||||
}
|
||||
} else if (message.type === 'event') {
|
||||
const event = message as IHomeAssistantEvent;
|
||||
|
||||
if (event.event.event_type === 'state_changed') {
|
||||
const stateChanged = event.event.data as IHomeAssistantStateChangedEvent;
|
||||
|
||||
// Update cached state
|
||||
if (stateChanged.new_state) {
|
||||
this.entityStates.set(stateChanged.entity_id, stateChanged.new_state);
|
||||
} else {
|
||||
this.entityStates.delete(stateChanged.entity_id);
|
||||
}
|
||||
|
||||
this.emit('state:changed', stateChanged);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request and wait for response
|
||||
*/
|
||||
private async sendRequest<T>(type: string, data: Record<string, unknown> = {}): Promise<T> {
|
||||
if (!this.ws || !this.isAuthenticated) {
|
||||
throw new Error('Not connected to Home Assistant');
|
||||
}
|
||||
|
||||
const id = this.messageId++;
|
||||
const message = { id, type, ...data };
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.pendingRequests.delete(id);
|
||||
reject(new Error(`Request timeout: ${type}`));
|
||||
}, 30000);
|
||||
|
||||
this.pendingRequests.set(id, {
|
||||
resolve: resolve as (value: unknown) => void,
|
||||
reject,
|
||||
timeout,
|
||||
});
|
||||
|
||||
this.ws!.send(JSON.stringify(message));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule reconnection attempt
|
||||
*/
|
||||
private scheduleReconnect(): void {
|
||||
if (this.reconnectTimer) return;
|
||||
|
||||
this.reconnectAttempt++;
|
||||
const delay = Math.min(
|
||||
this.config.reconnectDelay! * Math.pow(1.5, this.reconnectAttempt - 1),
|
||||
60000 // Max 60 seconds
|
||||
);
|
||||
|
||||
this.emit('reconnecting', this.reconnectAttempt);
|
||||
|
||||
this.reconnectTimer = setTimeout(async () => {
|
||||
this.reconnectTimer = null;
|
||||
|
||||
try {
|
||||
await this.connect();
|
||||
|
||||
// Re-subscribe to state changes
|
||||
if (this.isConnected) {
|
||||
await this.subscribeToStateChanges();
|
||||
}
|
||||
} catch {
|
||||
// connect() will schedule another reconnect on failure
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Public API - State
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get HA config
|
||||
*/
|
||||
public async getConfig(): Promise<IHomeAssistantConfig> {
|
||||
return this.sendRequest<IHomeAssistantConfig>('get_config');
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to state change events
|
||||
*/
|
||||
public async subscribeToStateChanges(): Promise<number> {
|
||||
const result = await this.sendRequest<{ context: { id: string } }>('subscribe_events', {
|
||||
event_type: 'state_changed',
|
||||
});
|
||||
|
||||
// Get all current states after subscribing
|
||||
const states = await this.getStates();
|
||||
for (const entity of states) {
|
||||
this.entityStates.set(entity.entity_id, entity);
|
||||
}
|
||||
this.emit('states:loaded', states);
|
||||
|
||||
this.stateSubscriptionId = this.messageId - 1;
|
||||
return this.stateSubscriptionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all entity states
|
||||
*/
|
||||
public async getStates(): Promise<IHomeAssistantEntity[]> {
|
||||
return this.sendRequest<IHomeAssistantEntity[]>('get_states');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific entity state
|
||||
*/
|
||||
public async getState(entityId: string): Promise<IHomeAssistantEntity | null> {
|
||||
// First check cache
|
||||
const cached = this.entityStates.get(entityId);
|
||||
if (cached) return cached;
|
||||
|
||||
// Otherwise fetch all states and find it
|
||||
const states = await this.getStates();
|
||||
return states.find((s) => s.entity_id === entityId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get entities by domain
|
||||
*/
|
||||
public async getEntitiesByDomain(domain: string): Promise<IHomeAssistantEntity[]> {
|
||||
const states = await this.getStates();
|
||||
return states.filter((s) => s.entity_id.startsWith(`${domain}.`));
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Public API - Service Calls
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Call a Home Assistant service
|
||||
*/
|
||||
public async callService(
|
||||
domain: string,
|
||||
service: string,
|
||||
target?: { entity_id?: string | string[]; device_id?: string | string[]; area_id?: string | string[] },
|
||||
serviceData?: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
await this.sendRequest('call_service', {
|
||||
domain,
|
||||
service,
|
||||
target,
|
||||
service_data: serviceData,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn on an entity
|
||||
*/
|
||||
public async turnOn(entityId: string, data?: Record<string, unknown>): Promise<void> {
|
||||
const domain = entityId.split('.')[0];
|
||||
await this.callService(domain, 'turn_on', { entity_id: entityId }, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn off an entity
|
||||
*/
|
||||
public async turnOff(entityId: string): Promise<void> {
|
||||
const domain = entityId.split('.')[0];
|
||||
await this.callService(domain, 'turn_off', { entity_id: entityId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle an entity
|
||||
*/
|
||||
public async toggle(entityId: string): Promise<void> {
|
||||
const domain = entityId.split('.')[0];
|
||||
await this.callService(domain, 'toggle', { entity_id: entityId });
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Light Services
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Control a light
|
||||
*/
|
||||
public async lightTurnOn(entityId: string, options?: IHomeAssistantLightServiceData): Promise<void> {
|
||||
await this.callService('light', 'turn_on', { entity_id: entityId }, options);
|
||||
}
|
||||
|
||||
public async lightTurnOff(entityId: string, options?: { transition?: number }): Promise<void> {
|
||||
await this.callService('light', 'turn_off', { entity_id: entityId }, options);
|
||||
}
|
||||
|
||||
public async lightToggle(entityId: string, options?: IHomeAssistantLightServiceData): Promise<void> {
|
||||
await this.callService('light', 'toggle', { entity_id: entityId }, options);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Climate Services
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Set HVAC mode
|
||||
*/
|
||||
public async climateSetHvacMode(entityId: string, hvacMode: string): Promise<void> {
|
||||
await this.callService('climate', 'set_hvac_mode', { entity_id: entityId }, { hvac_mode: hvacMode });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set target temperature
|
||||
*/
|
||||
public async climateSetTemperature(entityId: string, options: IHomeAssistantClimateServiceData): Promise<void> {
|
||||
await this.callService('climate', 'set_temperature', { entity_id: entityId }, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set fan mode
|
||||
*/
|
||||
public async climateSetFanMode(entityId: string, fanMode: string): Promise<void> {
|
||||
await this.callService('climate', 'set_fan_mode', { entity_id: entityId }, { fan_mode: fanMode });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set preset mode
|
||||
*/
|
||||
public async climateSetPresetMode(entityId: string, presetMode: string): Promise<void> {
|
||||
await this.callService('climate', 'set_preset_mode', { entity_id: entityId }, { preset_mode: presetMode });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set swing mode
|
||||
*/
|
||||
public async climateSetSwingMode(entityId: string, swingMode: string): Promise<void> {
|
||||
await this.callService('climate', 'set_swing_mode', { entity_id: entityId }, { swing_mode: swingMode });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set aux heat
|
||||
*/
|
||||
public async climateSetAuxHeat(entityId: string, auxHeat: boolean): Promise<void> {
|
||||
await this.callService('climate', 'set_aux_heat', { entity_id: entityId }, { aux_heat: auxHeat });
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Cover Services
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Open cover
|
||||
*/
|
||||
public async coverOpen(entityId: string): Promise<void> {
|
||||
await this.callService('cover', 'open_cover', { entity_id: entityId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Close cover
|
||||
*/
|
||||
public async coverClose(entityId: string): Promise<void> {
|
||||
await this.callService('cover', 'close_cover', { entity_id: entityId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop cover
|
||||
*/
|
||||
public async coverStop(entityId: string): Promise<void> {
|
||||
await this.callService('cover', 'stop_cover', { entity_id: entityId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set cover position
|
||||
*/
|
||||
public async coverSetPosition(entityId: string, position: number): Promise<void> {
|
||||
await this.callService('cover', 'set_cover_position', { entity_id: entityId }, { position });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set cover tilt position
|
||||
*/
|
||||
public async coverSetTiltPosition(entityId: string, tiltPosition: number): Promise<void> {
|
||||
await this.callService('cover', 'set_cover_tilt_position', { entity_id: entityId }, { tilt_position: tiltPosition });
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Fan Services
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Turn on fan
|
||||
*/
|
||||
public async fanTurnOn(entityId: string, options?: IHomeAssistantFanServiceData): Promise<void> {
|
||||
await this.callService('fan', 'turn_on', { entity_id: entityId }, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn off fan
|
||||
*/
|
||||
public async fanTurnOff(entityId: string): Promise<void> {
|
||||
await this.callService('fan', 'turn_off', { entity_id: entityId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set fan percentage
|
||||
*/
|
||||
public async fanSetPercentage(entityId: string, percentage: number): Promise<void> {
|
||||
await this.callService('fan', 'set_percentage', { entity_id: entityId }, { percentage });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set fan preset mode
|
||||
*/
|
||||
public async fanSetPresetMode(entityId: string, presetMode: string): Promise<void> {
|
||||
await this.callService('fan', 'set_preset_mode', { entity_id: entityId }, { preset_mode: presetMode });
|
||||
}
|
||||
|
||||
/**
|
||||
* Oscillate fan
|
||||
*/
|
||||
public async fanOscillate(entityId: string, oscillating: boolean): Promise<void> {
|
||||
await this.callService('fan', 'oscillate', { entity_id: entityId }, { oscillating });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set fan direction
|
||||
*/
|
||||
public async fanSetDirection(entityId: string, direction: 'forward' | 'reverse'): Promise<void> {
|
||||
await this.callService('fan', 'set_direction', { entity_id: entityId }, { direction });
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Lock Services
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Lock
|
||||
*/
|
||||
public async lockLock(entityId: string): Promise<void> {
|
||||
await this.callService('lock', 'lock', { entity_id: entityId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlock
|
||||
*/
|
||||
public async lockUnlock(entityId: string): Promise<void> {
|
||||
await this.callService('lock', 'unlock', { entity_id: entityId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Open (if supported)
|
||||
*/
|
||||
public async lockOpen(entityId: string): Promise<void> {
|
||||
await this.callService('lock', 'open', { entity_id: entityId });
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Switch Services
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Turn on switch
|
||||
*/
|
||||
public async switchTurnOn(entityId: string): Promise<void> {
|
||||
await this.callService('switch', 'turn_on', { entity_id: entityId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn off switch
|
||||
*/
|
||||
public async switchTurnOff(entityId: string): Promise<void> {
|
||||
await this.callService('switch', 'turn_off', { entity_id: entityId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle switch
|
||||
*/
|
||||
public async switchToggle(entityId: string): Promise<void> {
|
||||
await this.callService('switch', 'toggle', { entity_id: entityId });
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Media Player Services
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Play media
|
||||
*/
|
||||
public async mediaPlayerPlay(entityId: string): Promise<void> {
|
||||
await this.callService('media_player', 'media_play', { entity_id: entityId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause media
|
||||
*/
|
||||
public async mediaPlayerPause(entityId: string): Promise<void> {
|
||||
await this.callService('media_player', 'media_pause', { entity_id: entityId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop media
|
||||
*/
|
||||
public async mediaPlayerStop(entityId: string): Promise<void> {
|
||||
await this.callService('media_player', 'media_stop', { entity_id: entityId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Next track
|
||||
*/
|
||||
public async mediaPlayerNext(entityId: string): Promise<void> {
|
||||
await this.callService('media_player', 'media_next_track', { entity_id: entityId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Previous track
|
||||
*/
|
||||
public async mediaPlayerPrevious(entityId: string): Promise<void> {
|
||||
await this.callService('media_player', 'media_previous_track', { entity_id: entityId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set volume
|
||||
*/
|
||||
public async mediaPlayerSetVolume(entityId: string, volumeLevel: number): Promise<void> {
|
||||
await this.callService('media_player', 'volume_set', { entity_id: entityId }, { volume_level: volumeLevel });
|
||||
}
|
||||
|
||||
/**
|
||||
* Mute/unmute
|
||||
*/
|
||||
public async mediaPlayerMute(entityId: string, isMuted: boolean): Promise<void> {
|
||||
await this.callService('media_player', 'volume_mute', { entity_id: entityId }, { is_volume_muted: isMuted });
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek to position
|
||||
*/
|
||||
public async mediaPlayerSeek(entityId: string, position: number): Promise<void> {
|
||||
await this.callService('media_player', 'media_seek', { entity_id: entityId }, { seek_position: position });
|
||||
}
|
||||
|
||||
/**
|
||||
* Select source
|
||||
*/
|
||||
public async mediaPlayerSelectSource(entityId: string, source: string): Promise<void> {
|
||||
await this.callService('media_player', 'select_source', { entity_id: entityId }, { source });
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Camera Services
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get camera snapshot URL
|
||||
*/
|
||||
public getCameraSnapshotUrl(entityId: string): string {
|
||||
const protocol = this.config.secure ? 'https' : 'http';
|
||||
const entity = this.entityStates.get(entityId);
|
||||
const accessToken = (entity?.attributes as { access_token?: string })?.access_token || '';
|
||||
return `${protocol}://${this.config.host}:${this.config.port}/api/camera_proxy/${entityId}?token=${accessToken}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get camera stream URL
|
||||
*/
|
||||
public getCameraStreamUrl(entityId: string): string {
|
||||
const protocol = this.config.secure ? 'https' : 'http';
|
||||
return `${protocol}://${this.config.host}:${this.config.port}/api/camera_proxy_stream/${entityId}`;
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Static Helpers
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Probe if a Home Assistant instance is reachable
|
||||
*/
|
||||
public static async probe(host: string, port: number = 8123, secure: boolean = false, timeout: number = 5000): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const protocol = secure ? 'wss' : 'ws';
|
||||
const url = `${protocol}://${host}:${port}/api/websocket`;
|
||||
|
||||
try {
|
||||
const ws = new plugins.WebSocket(url);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
ws.close();
|
||||
resolve(false);
|
||||
}, timeout);
|
||||
|
||||
ws.on('open', () => {
|
||||
// Wait for auth_required message
|
||||
});
|
||||
|
||||
ws.on('message', (data: Buffer | string) => {
|
||||
const message = JSON.parse(data.toString()) as IHomeAssistantMessage;
|
||||
if (message.type === 'auth_required') {
|
||||
clearTimeout(timer);
|
||||
ws.close();
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', () => {
|
||||
clearTimeout(timer);
|
||||
resolve(false);
|
||||
});
|
||||
} catch {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SnmpProtocol, SNMP_OIDS, type ISnmpOptions } from '../snmp/snmp.classes.snmpprotocol.js';
|
||||
import { SnmpProtocol, SNMP_OIDS, type ISnmpOptions } from '../protocols/index.js';
|
||||
|
||||
/**
|
||||
* Extended UPS-MIB OIDs (RFC 1628)
|
||||
@@ -1,370 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { Device } from '../abstract/device.abstract.js';
|
||||
import { EsclProtocol } from './scanner.classes.esclprotocol.js';
|
||||
import { SaneProtocol } from './scanner.classes.saneprotocol.js';
|
||||
import type {
|
||||
IScannerInfo,
|
||||
IScannerCapabilities,
|
||||
IScanOptions,
|
||||
IScanResult,
|
||||
TScannerProtocol,
|
||||
TScanFormat,
|
||||
TColorMode,
|
||||
TScanSource,
|
||||
IRetryOptions,
|
||||
} from '../interfaces/index.js';
|
||||
|
||||
/**
|
||||
* Unified Scanner class that abstracts over eSCL and SANE protocols
|
||||
*/
|
||||
export class Scanner extends Device {
|
||||
public readonly protocol: TScannerProtocol;
|
||||
public supportedFormats: TScanFormat[] = ['jpeg', 'png', 'pdf'];
|
||||
public supportedResolutions: number[] = [75, 150, 300, 600];
|
||||
public supportedColorModes: TColorMode[] = ['color', 'grayscale', 'blackwhite'];
|
||||
public supportedSources: TScanSource[] = ['flatbed'];
|
||||
public hasAdf: boolean = false;
|
||||
public hasDuplex: boolean = false;
|
||||
public maxWidth: number = 215.9; // A4 width in mm
|
||||
public maxHeight: number = 297; // A4 height in mm
|
||||
|
||||
private esclClient: EsclProtocol | null = null;
|
||||
private saneClient: SaneProtocol | null = null;
|
||||
private deviceName: string = '';
|
||||
private isSecure: boolean = false;
|
||||
|
||||
constructor(
|
||||
info: IScannerInfo,
|
||||
options?: {
|
||||
deviceName?: string;
|
||||
secure?: boolean;
|
||||
retryOptions?: IRetryOptions;
|
||||
}
|
||||
) {
|
||||
super(info, options?.retryOptions);
|
||||
this.protocol = info.protocol;
|
||||
this.supportedFormats = info.supportedFormats;
|
||||
this.supportedResolutions = info.supportedResolutions;
|
||||
this.supportedColorModes = info.supportedColorModes;
|
||||
this.supportedSources = info.supportedSources;
|
||||
this.hasAdf = info.hasAdf;
|
||||
this.hasDuplex = info.hasDuplex;
|
||||
this.maxWidth = info.maxWidth ?? this.maxWidth;
|
||||
this.maxHeight = info.maxHeight ?? this.maxHeight;
|
||||
this.deviceName = options?.deviceName ?? '';
|
||||
this.isSecure = options?.secure ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Scanner from discovery info
|
||||
*/
|
||||
public static fromDiscovery(
|
||||
discoveredDevice: {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
port: number;
|
||||
protocol: TScannerProtocol | 'ipp';
|
||||
txtRecords: Record<string, string>;
|
||||
},
|
||||
retryOptions?: IRetryOptions
|
||||
): Scanner {
|
||||
const protocol = discoveredDevice.protocol === 'ipp' ? 'escl' : discoveredDevice.protocol;
|
||||
|
||||
// Parse capabilities from TXT records
|
||||
const formats = Scanner.parseFormats(discoveredDevice.txtRecords);
|
||||
const resolutions = Scanner.parseResolutions(discoveredDevice.txtRecords);
|
||||
const colorModes = Scanner.parseColorModes(discoveredDevice.txtRecords);
|
||||
const sources = Scanner.parseSources(discoveredDevice.txtRecords);
|
||||
|
||||
const info: IScannerInfo = {
|
||||
id: discoveredDevice.id,
|
||||
name: discoveredDevice.name,
|
||||
type: 'scanner',
|
||||
address: discoveredDevice.address,
|
||||
port: discoveredDevice.port,
|
||||
status: 'online',
|
||||
protocol: protocol,
|
||||
supportedFormats: formats,
|
||||
supportedResolutions: resolutions,
|
||||
supportedColorModes: colorModes,
|
||||
supportedSources: sources,
|
||||
hasAdf: sources.includes('adf') || sources.includes('adf-duplex'),
|
||||
hasDuplex: sources.includes('adf-duplex'),
|
||||
manufacturer: discoveredDevice.txtRecords['usb_MFG'] || discoveredDevice.txtRecords['mfg'],
|
||||
model: discoveredDevice.txtRecords['usb_MDL'] || discoveredDevice.txtRecords['mdl'] || discoveredDevice.txtRecords['ty'],
|
||||
};
|
||||
|
||||
const isSecure = discoveredDevice.txtRecords['TLS'] === '1' ||
|
||||
discoveredDevice.protocol === 'escl' && discoveredDevice.port === 443;
|
||||
|
||||
return new Scanner(info, {
|
||||
secure: isSecure,
|
||||
retryOptions,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse supported formats from TXT records
|
||||
*/
|
||||
private static parseFormats(txtRecords: Record<string, string>): TScanFormat[] {
|
||||
const formats: TScanFormat[] = [];
|
||||
const pdl = txtRecords['pdl'] || txtRecords['DocumentFormat'] || '';
|
||||
|
||||
if (pdl.includes('jpeg') || pdl.includes('jpg')) formats.push('jpeg');
|
||||
if (pdl.includes('png')) formats.push('png');
|
||||
if (pdl.includes('pdf')) formats.push('pdf');
|
||||
|
||||
// Default to jpeg if nothing found
|
||||
if (formats.length === 0) {
|
||||
formats.push('jpeg', 'png');
|
||||
}
|
||||
|
||||
return formats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse supported resolutions from TXT records
|
||||
*/
|
||||
private static parseResolutions(txtRecords: Record<string, string>): number[] {
|
||||
const rs = txtRecords['rs'] || '';
|
||||
const resolutions: number[] = [];
|
||||
|
||||
// Try to parse comma-separated resolutions
|
||||
const parts = rs.split(',').map((s) => parseInt(s.trim())).filter((n) => !isNaN(n) && n > 0);
|
||||
if (parts.length > 0) {
|
||||
return parts;
|
||||
}
|
||||
|
||||
// Default common resolutions
|
||||
return [75, 150, 300, 600];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse color modes from TXT records
|
||||
*/
|
||||
private static parseColorModes(txtRecords: Record<string, string>): TColorMode[] {
|
||||
const cs = txtRecords['cs'] || txtRecords['ColorSpace'] || '';
|
||||
const modes: TColorMode[] = [];
|
||||
|
||||
if (cs.includes('color') || cs.includes('RGB')) modes.push('color');
|
||||
if (cs.includes('gray') || cs.includes('grayscale')) modes.push('grayscale');
|
||||
if (cs.includes('binary') || cs.includes('bw')) modes.push('blackwhite');
|
||||
|
||||
// Default to color and grayscale
|
||||
if (modes.length === 0) {
|
||||
modes.push('color', 'grayscale');
|
||||
}
|
||||
|
||||
return modes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse input sources from TXT records
|
||||
*/
|
||||
private static parseSources(txtRecords: Record<string, string>): TScanSource[] {
|
||||
const is = txtRecords['is'] || txtRecords['InputSource'] || '';
|
||||
const sources: TScanSource[] = [];
|
||||
|
||||
if (is.includes('platen') || is.includes('flatbed') || is === '') {
|
||||
sources.push('flatbed');
|
||||
}
|
||||
if (is.includes('adf') || is.includes('feeder')) {
|
||||
sources.push('adf');
|
||||
}
|
||||
if (is.includes('duplex')) {
|
||||
sources.push('adf-duplex');
|
||||
}
|
||||
|
||||
// Default to flatbed
|
||||
if (sources.length === 0) {
|
||||
sources.push('flatbed');
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scanner info
|
||||
*/
|
||||
public getScannerInfo(): IScannerInfo {
|
||||
return {
|
||||
...this.getInfo(),
|
||||
type: 'scanner',
|
||||
protocol: this.protocol,
|
||||
supportedFormats: this.supportedFormats,
|
||||
supportedResolutions: this.supportedResolutions,
|
||||
supportedColorModes: this.supportedColorModes,
|
||||
supportedSources: this.supportedSources,
|
||||
hasAdf: this.hasAdf,
|
||||
hasDuplex: this.hasDuplex,
|
||||
maxWidth: this.maxWidth,
|
||||
maxHeight: this.maxHeight,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scanner capabilities
|
||||
*/
|
||||
public async getCapabilities(): Promise<IScannerCapabilities> {
|
||||
if (!this.isConnected) {
|
||||
await this.connect();
|
||||
}
|
||||
|
||||
if (this.protocol === 'escl' && this.esclClient) {
|
||||
const caps = await this.esclClient.getCapabilities();
|
||||
|
||||
const platen = caps.platen;
|
||||
return {
|
||||
resolutions: platen?.supportedResolutions ?? this.supportedResolutions,
|
||||
formats: this.supportedFormats,
|
||||
colorModes: this.supportedColorModes,
|
||||
sources: this.supportedSources,
|
||||
maxWidth: platen ? platen.maxWidth / 300 * 25.4 : this.maxWidth,
|
||||
maxHeight: platen ? platen.maxHeight / 300 * 25.4 : this.maxHeight,
|
||||
minWidth: platen ? platen.minWidth / 300 * 25.4 : 0,
|
||||
minHeight: platen ? platen.minHeight / 300 * 25.4 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Return defaults for SANE (would need to query options)
|
||||
return {
|
||||
resolutions: this.supportedResolutions,
|
||||
formats: this.supportedFormats,
|
||||
colorModes: this.supportedColorModes,
|
||||
sources: this.supportedSources,
|
||||
maxWidth: this.maxWidth,
|
||||
maxHeight: this.maxHeight,
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a scan
|
||||
*/
|
||||
public async scan(options?: IScanOptions): Promise<IScanResult> {
|
||||
if (!this.isConnected) {
|
||||
await this.connect();
|
||||
}
|
||||
|
||||
const scanOptions: IScanOptions = {
|
||||
resolution: options?.resolution ?? 300,
|
||||
format: options?.format ?? 'jpeg',
|
||||
colorMode: options?.colorMode ?? 'color',
|
||||
source: options?.source ?? 'flatbed',
|
||||
area: options?.area,
|
||||
intent: options?.intent ?? 'document',
|
||||
quality: options?.quality ?? 85,
|
||||
};
|
||||
|
||||
this.setStatus('busy');
|
||||
this.emit('scan:started', scanOptions);
|
||||
|
||||
try {
|
||||
let result: IScanResult;
|
||||
|
||||
if (this.protocol === 'escl' && this.esclClient) {
|
||||
result = await this.withRetry(() => this.esclClient!.scan(scanOptions));
|
||||
} else if (this.protocol === 'sane' && this.saneClient) {
|
||||
result = await this.withRetry(() => this.saneClient!.scan(scanOptions));
|
||||
} else {
|
||||
throw new Error(`No protocol client available for ${this.protocol}`);
|
||||
}
|
||||
|
||||
this.setStatus('online');
|
||||
this.emit('scan:completed', result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.setStatus('online');
|
||||
this.emit('scan:error', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel an ongoing scan
|
||||
*/
|
||||
public async cancelScan(): Promise<void> {
|
||||
if (this.protocol === 'sane' && this.saneClient) {
|
||||
await this.saneClient.cancel();
|
||||
}
|
||||
// eSCL cancellation is handled via job deletion in the protocol
|
||||
this.emit('scan:canceled');
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the scanner
|
||||
*/
|
||||
protected async doConnect(): Promise<void> {
|
||||
if (this.protocol === 'escl') {
|
||||
this.esclClient = new EsclProtocol(this.address, this.port, this.isSecure);
|
||||
// Test connection by getting capabilities
|
||||
await this.esclClient.getCapabilities();
|
||||
} else if (this.protocol === 'sane') {
|
||||
this.saneClient = new SaneProtocol(this.address, this.port);
|
||||
await this.saneClient.connect();
|
||||
|
||||
// Get available devices
|
||||
const devices = await this.saneClient.getDevices();
|
||||
if (devices.length === 0) {
|
||||
throw new Error('No SANE devices available');
|
||||
}
|
||||
|
||||
// Open the first device or the specified one
|
||||
const deviceToOpen = this.deviceName || devices[0].name;
|
||||
await this.saneClient.open(deviceToOpen);
|
||||
} else {
|
||||
throw new Error(`Unsupported protocol: ${this.protocol}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from the scanner
|
||||
*/
|
||||
protected async doDisconnect(): Promise<void> {
|
||||
if (this.esclClient) {
|
||||
this.esclClient = null;
|
||||
}
|
||||
|
||||
if (this.saneClient) {
|
||||
await this.saneClient.disconnect();
|
||||
this.saneClient = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh scanner status
|
||||
*/
|
||||
public async refreshStatus(): Promise<void> {
|
||||
try {
|
||||
if (this.protocol === 'escl' && this.esclClient) {
|
||||
const status = await this.esclClient.getStatus();
|
||||
switch (status.state) {
|
||||
case 'Idle':
|
||||
this.setStatus('online');
|
||||
break;
|
||||
case 'Processing':
|
||||
this.setStatus('busy');
|
||||
break;
|
||||
case 'Stopped':
|
||||
case 'Testing':
|
||||
this.setStatus('offline');
|
||||
break;
|
||||
}
|
||||
} else if (this.protocol === 'sane') {
|
||||
// SANE doesn't have a direct status query
|
||||
// Just check if we can still communicate
|
||||
if (this.saneClient) {
|
||||
await this.saneClient.getParameters();
|
||||
this.setStatus('online');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.setStatus('error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { EsclProtocol, SaneProtocol };
|
||||
@@ -1,271 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { Device } from '../abstract/device.abstract.js';
|
||||
import { SnmpProtocol, SNMP_OIDS, type ISnmpOptions, type ISnmpVarbind } from './snmp.classes.snmpprotocol.js';
|
||||
import type { IDeviceInfo, IRetryOptions, TDeviceStatus } from '../interfaces/index.js';
|
||||
|
||||
/**
|
||||
* SNMP device information
|
||||
*/
|
||||
export interface ISnmpDeviceInfo extends IDeviceInfo {
|
||||
type: 'snmp';
|
||||
sysDescr: string;
|
||||
sysObjectID: string;
|
||||
sysUpTime: number;
|
||||
sysContact?: string;
|
||||
sysName?: string;
|
||||
sysLocation?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* SNMP Device class for generic SNMP-enabled devices
|
||||
*/
|
||||
export class SnmpDevice extends Device {
|
||||
private protocol: SnmpProtocol | null = null;
|
||||
private snmpOptions: ISnmpOptions;
|
||||
|
||||
private _sysDescr: string = '';
|
||||
private _sysObjectID: string = '';
|
||||
private _sysUpTime: number = 0;
|
||||
private _sysContact?: string;
|
||||
private _sysName?: string;
|
||||
private _sysLocation?: string;
|
||||
|
||||
constructor(
|
||||
info: IDeviceInfo,
|
||||
snmpOptions?: ISnmpOptions,
|
||||
retryOptions?: IRetryOptions
|
||||
) {
|
||||
super(info, retryOptions);
|
||||
this.snmpOptions = { port: info.port, ...snmpOptions };
|
||||
}
|
||||
|
||||
// Getters for SNMP properties
|
||||
public get sysDescr(): string {
|
||||
return this._sysDescr;
|
||||
}
|
||||
|
||||
public get sysObjectID(): string {
|
||||
return this._sysObjectID;
|
||||
}
|
||||
|
||||
public get sysUpTime(): number {
|
||||
return this._sysUpTime;
|
||||
}
|
||||
|
||||
public get sysContact(): string | undefined {
|
||||
return this._sysContact;
|
||||
}
|
||||
|
||||
public get sysName(): string | undefined {
|
||||
return this._sysName;
|
||||
}
|
||||
|
||||
public get sysLocation(): string | undefined {
|
||||
return this._sysLocation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the SNMP device
|
||||
*/
|
||||
protected async doConnect(): Promise<void> {
|
||||
this.protocol = new SnmpProtocol(this.address, this.snmpOptions);
|
||||
|
||||
// Verify connection by fetching system info
|
||||
const sysInfo = await this.protocol.getSystemInfo();
|
||||
|
||||
this._sysDescr = sysInfo.sysDescr;
|
||||
this._sysObjectID = sysInfo.sysObjectID;
|
||||
this._sysUpTime = sysInfo.sysUpTime;
|
||||
this._sysContact = sysInfo.sysContact || undefined;
|
||||
this._sysName = sysInfo.sysName || undefined;
|
||||
this._sysLocation = sysInfo.sysLocation || undefined;
|
||||
|
||||
// Update device name if sysName is available
|
||||
if (sysInfo.sysName && !this.name.includes('SNMP Device')) {
|
||||
// Keep custom name
|
||||
} else if (sysInfo.sysName) {
|
||||
(this as { name: string }).name = sysInfo.sysName;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from the SNMP device
|
||||
*/
|
||||
protected async doDisconnect(): Promise<void> {
|
||||
if (this.protocol) {
|
||||
this.protocol.close();
|
||||
this.protocol = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh device status
|
||||
*/
|
||||
public async refreshStatus(): Promise<void> {
|
||||
if (!this.protocol) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
const sysInfo = await this.protocol.getSystemInfo();
|
||||
this._sysUpTime = sysInfo.sysUpTime;
|
||||
this.emit('status:updated', this.getDeviceInfo());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single OID value
|
||||
*/
|
||||
public async get(oid: string): Promise<ISnmpVarbind> {
|
||||
if (!this.protocol) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
return this.protocol.get(oid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get multiple OID values
|
||||
*/
|
||||
public async getMultiple(oids: string[]): Promise<ISnmpVarbind[]> {
|
||||
if (!this.protocol) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
return this.protocol.getMultiple(oids);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next OID in the MIB tree
|
||||
*/
|
||||
public async getNext(oid: string): Promise<ISnmpVarbind> {
|
||||
if (!this.protocol) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
return this.protocol.getNext(oid);
|
||||
}
|
||||
|
||||
/**
|
||||
* GETBULK operation for efficient table retrieval
|
||||
*/
|
||||
public async getBulk(
|
||||
oids: string[],
|
||||
nonRepeaters?: number,
|
||||
maxRepetitions?: number
|
||||
): Promise<ISnmpVarbind[]> {
|
||||
if (!this.protocol) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
return this.protocol.getBulk(oids, nonRepeaters, maxRepetitions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a MIB tree
|
||||
*/
|
||||
public async walk(baseOid: string): Promise<ISnmpVarbind[]> {
|
||||
if (!this.protocol) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
return this.protocol.walk(baseOid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set an OID value
|
||||
*/
|
||||
public async set(
|
||||
oid: string,
|
||||
type: 'Integer' | 'OctetString' | 'ObjectIdentifier' | 'IpAddress',
|
||||
value: unknown
|
||||
): Promise<ISnmpVarbind> {
|
||||
if (!this.protocol) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
return this.protocol.set(oid, type, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get device information
|
||||
*/
|
||||
public getDeviceInfo(): ISnmpDeviceInfo {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
type: 'snmp',
|
||||
address: this.address,
|
||||
port: this.port,
|
||||
status: this.status,
|
||||
sysDescr: this._sysDescr,
|
||||
sysObjectID: this._sysObjectID,
|
||||
sysUpTime: this._sysUpTime,
|
||||
sysContact: this._sysContact,
|
||||
sysName: this._sysName,
|
||||
sysLocation: this._sysLocation,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create SnmpDevice from discovery data
|
||||
*/
|
||||
public static fromDiscovery(
|
||||
data: {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
port?: number;
|
||||
community?: string;
|
||||
},
|
||||
retryOptions?: IRetryOptions
|
||||
): SnmpDevice {
|
||||
const info: IDeviceInfo = {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
type: 'snmp',
|
||||
address: data.address,
|
||||
port: data.port ?? 161,
|
||||
status: 'unknown',
|
||||
};
|
||||
return new SnmpDevice(
|
||||
info,
|
||||
{ community: data.community ?? 'public' },
|
||||
retryOptions
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe an IP address for SNMP device
|
||||
*/
|
||||
public static async probe(
|
||||
address: string,
|
||||
port: number = 161,
|
||||
community: string = 'public',
|
||||
timeout: number = 5000
|
||||
): Promise<ISnmpDeviceInfo | null> {
|
||||
const protocol = new SnmpProtocol(address, {
|
||||
community,
|
||||
port,
|
||||
timeout,
|
||||
retries: 0,
|
||||
});
|
||||
|
||||
try {
|
||||
const sysInfo = await protocol.getSystemInfo();
|
||||
|
||||
return {
|
||||
id: `snmp:${address}:${port}`,
|
||||
name: sysInfo.sysName || `SNMP Device at ${address}`,
|
||||
type: 'snmp',
|
||||
address,
|
||||
port,
|
||||
status: 'online',
|
||||
sysDescr: sysInfo.sysDescr,
|
||||
sysObjectID: sysInfo.sysObjectID,
|
||||
sysUpTime: sysInfo.sysUpTime,
|
||||
sysContact: sysInfo.sysContact || undefined,
|
||||
sysName: sysInfo.sysName || undefined,
|
||||
sysLocation: sysInfo.sysLocation || undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
protocol.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { SNMP_OIDS };
|
||||
@@ -1,548 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { Speaker, type ITrackInfo, type IPlaybackStatus, type TPlaybackState, type ISpeakerInfo } from './speaker.classes.speaker.js';
|
||||
import type { IDeviceInfo, IRetryOptions } from '../interfaces/index.js';
|
||||
|
||||
/**
|
||||
* AirPlay features bitmask
|
||||
*/
|
||||
export const AIRPLAY_FEATURES = {
|
||||
Video: 1 << 0,
|
||||
Photo: 1 << 1,
|
||||
VideoFairPlay: 1 << 2,
|
||||
VideoVolumeControl: 1 << 3,
|
||||
VideoHTTPLiveStreams: 1 << 4,
|
||||
Slideshow: 1 << 5,
|
||||
Screen: 1 << 7,
|
||||
ScreenRotate: 1 << 8,
|
||||
Audio: 1 << 9,
|
||||
AudioRedundant: 1 << 11,
|
||||
FPSAPv2pt5_AES_GCM: 1 << 12,
|
||||
PhotoCaching: 1 << 13,
|
||||
Authentication4: 1 << 14,
|
||||
MetadataFeatures: 1 << 15,
|
||||
AudioFormats: 1 << 16,
|
||||
Authentication1: 1 << 17,
|
||||
};
|
||||
|
||||
/**
|
||||
* AirPlay device info
|
||||
*/
|
||||
export interface IAirPlaySpeakerInfo extends ISpeakerInfo {
|
||||
protocol: 'airplay';
|
||||
features: number;
|
||||
supportsVideo: boolean;
|
||||
supportsAudio: boolean;
|
||||
supportsScreen: boolean;
|
||||
deviceId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* AirPlay playback info
|
||||
*/
|
||||
export interface IAirPlayPlaybackInfo {
|
||||
duration: number;
|
||||
position: number;
|
||||
rate: number;
|
||||
readyToPlay: boolean;
|
||||
playbackBufferEmpty: boolean;
|
||||
playbackBufferFull: boolean;
|
||||
playbackLikelyToKeepUp: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* AirPlay Speaker device
|
||||
* Basic implementation for AirPlay-compatible devices
|
||||
*/
|
||||
export class AirPlaySpeaker extends Speaker {
|
||||
private _features: number = 0;
|
||||
private _deviceId?: string;
|
||||
private _supportsVideo: boolean = false;
|
||||
private _supportsAudio: boolean = true;
|
||||
private _supportsScreen: boolean = false;
|
||||
private _currentUri?: string;
|
||||
private _currentPosition: number = 0;
|
||||
private _currentDuration: number = 0;
|
||||
private _isPlaying: boolean = false;
|
||||
|
||||
constructor(
|
||||
info: IDeviceInfo,
|
||||
options?: {
|
||||
roomName?: string;
|
||||
modelName?: string;
|
||||
features?: number;
|
||||
deviceId?: string;
|
||||
},
|
||||
retryOptions?: IRetryOptions
|
||||
) {
|
||||
super(info, 'airplay', options, retryOptions);
|
||||
this._features = options?.features || 0;
|
||||
this._deviceId = options?.deviceId;
|
||||
|
||||
// Parse features
|
||||
if (this._features) {
|
||||
this._supportsVideo = !!(this._features & AIRPLAY_FEATURES.Video);
|
||||
this._supportsAudio = !!(this._features & AIRPLAY_FEATURES.Audio);
|
||||
this._supportsScreen = !!(this._features & AIRPLAY_FEATURES.Screen);
|
||||
}
|
||||
}
|
||||
|
||||
// Getters
|
||||
public get features(): number {
|
||||
return this._features;
|
||||
}
|
||||
|
||||
public get deviceId(): string | undefined {
|
||||
return this._deviceId;
|
||||
}
|
||||
|
||||
public get supportsVideo(): boolean {
|
||||
return this._supportsVideo;
|
||||
}
|
||||
|
||||
public get supportsAudio(): boolean {
|
||||
return this._supportsAudio;
|
||||
}
|
||||
|
||||
public get supportsScreen(): boolean {
|
||||
return this._supportsScreen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to AirPlay device
|
||||
* AirPlay 2 devices (HomePods) may not respond to /server-info,
|
||||
* so we consider them connected even if we can't get device info.
|
||||
*/
|
||||
protected async doConnect(): Promise<void> {
|
||||
// Try /server-info endpoint (works for older AirPlay devices)
|
||||
const url = `http://${this.address}:${this.port}/server-info`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Parse server info (plist format)
|
||||
const text = await response.text();
|
||||
|
||||
// Extract features if available
|
||||
const featuresMatch = text.match(/<key>features<\/key>\s*<integer>(\d+)<\/integer>/);
|
||||
if (featuresMatch) {
|
||||
this._features = parseInt(featuresMatch[1]);
|
||||
this._supportsVideo = !!(this._features & AIRPLAY_FEATURES.Video);
|
||||
this._supportsAudio = !!(this._features & AIRPLAY_FEATURES.Audio);
|
||||
this._supportsScreen = !!(this._features & AIRPLAY_FEATURES.Screen);
|
||||
}
|
||||
|
||||
// Extract device ID
|
||||
const deviceIdMatch = text.match(/<key>deviceid<\/key>\s*<string>([^<]+)<\/string>/);
|
||||
if (deviceIdMatch) {
|
||||
this._deviceId = deviceIdMatch[1];
|
||||
}
|
||||
|
||||
// Extract model
|
||||
const modelMatch = text.match(/<key>model<\/key>\s*<string>([^<]+)<\/string>/);
|
||||
if (modelMatch) {
|
||||
this._modelName = modelMatch[1];
|
||||
this.model = modelMatch[1];
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Non-OK response - might be AirPlay 2, continue below
|
||||
} catch {
|
||||
// /server-info failed, might be AirPlay 2 device
|
||||
}
|
||||
|
||||
// For AirPlay 2 devices (HomePods), /server-info doesn't work
|
||||
// Try a simple port check - if the port responds, consider it connected
|
||||
// HomePods will respond to proper AirPlay 2 protocol even if HTTP endpoints fail
|
||||
// We'll assume it's an AirPlay 2 audio device
|
||||
this._supportsAudio = true;
|
||||
this._supportsVideo = false;
|
||||
this._supportsScreen = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect
|
||||
*/
|
||||
protected async doDisconnect(): Promise<void> {
|
||||
try {
|
||||
await this.stop();
|
||||
} catch {
|
||||
// Ignore stop errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh status
|
||||
*/
|
||||
public async refreshStatus(): Promise<void> {
|
||||
try {
|
||||
const info = await this.getAirPlayPlaybackInfo();
|
||||
this._isPlaying = info.rate > 0;
|
||||
this._currentPosition = info.position;
|
||||
this._currentDuration = info.duration;
|
||||
this._playbackState = this._isPlaying ? 'playing' : 'paused';
|
||||
} catch {
|
||||
this._playbackState = 'stopped';
|
||||
}
|
||||
|
||||
this.emit('status:updated', this.getSpeakerInfo());
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Playback Control
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Play media URL
|
||||
*/
|
||||
public async play(uri?: string): Promise<void> {
|
||||
if (uri) {
|
||||
this._currentUri = uri;
|
||||
|
||||
const body = `Content-Location: ${uri}\nStart-Position: 0\n`;
|
||||
|
||||
const response = await fetch(`http://${this.address}:${this.port}/play`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/parameters',
|
||||
},
|
||||
body,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Play failed: ${response.status}`);
|
||||
}
|
||||
} else {
|
||||
// Resume playback
|
||||
await this.setRate(1);
|
||||
}
|
||||
|
||||
this._isPlaying = true;
|
||||
this._playbackState = 'playing';
|
||||
this.emit('playback:started');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause playback
|
||||
*/
|
||||
public async pause(): Promise<void> {
|
||||
await this.setRate(0);
|
||||
this._isPlaying = false;
|
||||
this._playbackState = 'paused';
|
||||
this.emit('playback:paused');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop playback
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
const response = await fetch(`http://${this.address}:${this.port}/stop`, {
|
||||
method: 'POST',
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Stop failed: ${response.status}`);
|
||||
}
|
||||
|
||||
this._isPlaying = false;
|
||||
this._playbackState = 'stopped';
|
||||
this._currentUri = undefined;
|
||||
this.emit('playback:stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Next track (not supported on basic AirPlay)
|
||||
*/
|
||||
public async next(): Promise<void> {
|
||||
throw new Error('Next track not supported on AirPlay');
|
||||
}
|
||||
|
||||
/**
|
||||
* Previous track (not supported on basic AirPlay)
|
||||
*/
|
||||
public async previous(): Promise<void> {
|
||||
throw new Error('Previous track not supported on AirPlay');
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek to position
|
||||
*/
|
||||
public async seek(seconds: number): Promise<void> {
|
||||
const body = `position: ${seconds}\n`;
|
||||
|
||||
const response = await fetch(`http://${this.address}:${this.port}/scrub`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/parameters',
|
||||
},
|
||||
body,
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Seek failed: ${response.status}`);
|
||||
}
|
||||
|
||||
this._currentPosition = seconds;
|
||||
this.emit('playback:seeked', { position: seconds });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Volume Control (limited support)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get volume (not always supported)
|
||||
*/
|
||||
public async getVolume(): Promise<number> {
|
||||
// AirPlay volume control varies by device
|
||||
return this._volume;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set volume (not always supported)
|
||||
*/
|
||||
public async setVolume(level: number): Promise<void> {
|
||||
const clamped = Math.max(0, Math.min(100, level));
|
||||
|
||||
try {
|
||||
const body = `volume: ${clamped / 100}\n`;
|
||||
|
||||
const response = await fetch(`http://${this.address}:${this.port}/volume`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/parameters',
|
||||
},
|
||||
body,
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
this._volume = clamped;
|
||||
this.emit('volume:changed', { volume: clamped });
|
||||
}
|
||||
} catch {
|
||||
// Volume control may not be supported
|
||||
throw new Error('Volume control not supported on this device');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mute state (not always supported)
|
||||
*/
|
||||
public async getMute(): Promise<boolean> {
|
||||
return this._muted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set mute state (not always supported)
|
||||
*/
|
||||
public async setMute(muted: boolean): Promise<void> {
|
||||
// Mute by setting volume to 0
|
||||
if (muted) {
|
||||
await this.setVolume(0);
|
||||
} else {
|
||||
await this.setVolume(this._volume || 50);
|
||||
}
|
||||
this._muted = muted;
|
||||
this.emit('mute:changed', { muted });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Track Information
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get current track
|
||||
*/
|
||||
public async getCurrentTrack(): Promise<ITrackInfo | null> {
|
||||
if (!this._currentUri) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
title: this._currentUri.split('/').pop() || 'Unknown',
|
||||
duration: this._currentDuration,
|
||||
position: this._currentPosition,
|
||||
uri: this._currentUri,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get playback status
|
||||
*/
|
||||
public async getPlaybackStatus(): Promise<IPlaybackStatus> {
|
||||
await this.refreshStatus();
|
||||
|
||||
return {
|
||||
state: this._playbackState,
|
||||
volume: this._volume,
|
||||
muted: this._muted,
|
||||
track: await this.getCurrentTrack() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AirPlay-specific Methods
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Set playback rate
|
||||
*/
|
||||
private async setRate(rate: number): Promise<void> {
|
||||
const body = `value: ${rate}\n`;
|
||||
|
||||
const response = await fetch(`http://${this.address}:${this.port}/rate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/parameters',
|
||||
},
|
||||
body,
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Set rate failed: ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get AirPlay playback info
|
||||
*/
|
||||
public async getAirPlayPlaybackInfo(): Promise<IAirPlayPlaybackInfo> {
|
||||
const response = await fetch(`http://${this.address}:${this.port}/playback-info`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Get playback info failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
|
||||
// Parse plist response
|
||||
const extractReal = (key: string): number => {
|
||||
const match = text.match(new RegExp(`<key>${key}</key>\\s*<real>([\\d.]+)</real>`));
|
||||
return match ? parseFloat(match[1]) : 0;
|
||||
};
|
||||
|
||||
const extractBool = (key: string): boolean => {
|
||||
const match = text.match(new RegExp(`<key>${key}</key>\\s*<(true|false)/>`));
|
||||
return match?.[1] === 'true';
|
||||
};
|
||||
|
||||
return {
|
||||
duration: extractReal('duration'),
|
||||
position: extractReal('position'),
|
||||
rate: extractReal('rate'),
|
||||
readyToPlay: extractBool('readyToPlay'),
|
||||
playbackBufferEmpty: extractBool('playbackBufferEmpty'),
|
||||
playbackBufferFull: extractBool('playbackBufferFull'),
|
||||
playbackLikelyToKeepUp: extractBool('playbackLikelyToKeepUp'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scrub position
|
||||
*/
|
||||
public async getScrubPosition(): Promise<{ position: number; duration: number }> {
|
||||
const response = await fetch(`http://${this.address}:${this.port}/scrub`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Get scrub position failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
|
||||
const durationMatch = text.match(/duration:\s*([\d.]+)/);
|
||||
const positionMatch = text.match(/position:\s*([\d.]+)/);
|
||||
|
||||
return {
|
||||
duration: durationMatch ? parseFloat(durationMatch[1]) : 0,
|
||||
position: positionMatch ? parseFloat(positionMatch[1]) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Device Info
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get speaker info
|
||||
*/
|
||||
public getSpeakerInfo(): IAirPlaySpeakerInfo {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
type: 'speaker',
|
||||
address: this.address,
|
||||
port: this.port,
|
||||
status: this.status,
|
||||
protocol: 'airplay',
|
||||
roomName: this._roomName,
|
||||
modelName: this._modelName,
|
||||
features: this._features,
|
||||
supportsVideo: this._supportsVideo,
|
||||
supportsAudio: this._supportsAudio,
|
||||
supportsScreen: this._supportsScreen,
|
||||
deviceId: this._deviceId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from mDNS discovery
|
||||
*/
|
||||
public static fromDiscovery(
|
||||
data: {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
port?: number;
|
||||
roomName?: string;
|
||||
modelName?: string;
|
||||
features?: number;
|
||||
deviceId?: string;
|
||||
},
|
||||
retryOptions?: IRetryOptions
|
||||
): AirPlaySpeaker {
|
||||
const info: IDeviceInfo = {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
type: 'speaker',
|
||||
address: data.address,
|
||||
port: data.port ?? 7000,
|
||||
status: 'unknown',
|
||||
};
|
||||
|
||||
return new AirPlaySpeaker(
|
||||
info,
|
||||
{
|
||||
roomName: data.roomName,
|
||||
modelName: data.modelName,
|
||||
features: data.features,
|
||||
deviceId: data.deviceId,
|
||||
},
|
||||
retryOptions
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe for AirPlay device
|
||||
*/
|
||||
public static async probe(address: string, port: number = 7000, timeout: number = 3000): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`http://${address}:${port}/server-info`, {
|
||||
signal: AbortSignal.timeout(timeout),
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,725 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { Speaker, type ITrackInfo, type IPlaybackStatus, type TPlaybackState, type ISpeakerInfo } from './speaker.classes.speaker.js';
|
||||
import type { IDeviceInfo, IRetryOptions } from '../interfaces/index.js';
|
||||
|
||||
/**
|
||||
* Chromecast device types
|
||||
*/
|
||||
export type TChromecastType = 'audio' | 'video' | 'group';
|
||||
|
||||
/**
|
||||
* Chromecast application IDs
|
||||
*/
|
||||
export const CHROMECAST_APPS = {
|
||||
DEFAULT_MEDIA_RECEIVER: 'CC1AD845',
|
||||
BACKDROP: 'E8C28D3C',
|
||||
YOUTUBE: '233637DE',
|
||||
NETFLIX: 'CA5E8412',
|
||||
PLEX: '9AC194DC',
|
||||
};
|
||||
|
||||
/**
|
||||
* Chromecast device info
|
||||
*/
|
||||
export interface IChromecastSpeakerInfo extends ISpeakerInfo {
|
||||
protocol: 'chromecast';
|
||||
friendlyName: string;
|
||||
deviceType: TChromecastType;
|
||||
capabilities: string[];
|
||||
currentAppId?: string;
|
||||
currentAppName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chromecast media metadata
|
||||
*/
|
||||
export interface IChromecastMediaMetadata {
|
||||
metadataType?: number;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
artist?: string;
|
||||
albumName?: string;
|
||||
albumArtist?: string;
|
||||
trackNumber?: number;
|
||||
discNumber?: number;
|
||||
images?: { url: string; width?: number; height?: number }[];
|
||||
releaseDate?: string;
|
||||
studio?: string;
|
||||
seriesTitle?: string;
|
||||
season?: number;
|
||||
episode?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chromecast media status
|
||||
*/
|
||||
export interface IChromecastMediaStatus {
|
||||
mediaSessionId: number;
|
||||
playbackRate: number;
|
||||
playerState: 'IDLE' | 'PLAYING' | 'PAUSED' | 'BUFFERING';
|
||||
currentTime: number;
|
||||
idleReason?: 'CANCELLED' | 'INTERRUPTED' | 'FINISHED' | 'ERROR';
|
||||
media?: {
|
||||
contentId: string;
|
||||
contentType: string;
|
||||
duration: number;
|
||||
metadata?: IChromecastMediaMetadata;
|
||||
};
|
||||
volume: {
|
||||
level: number;
|
||||
muted: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Chromecast Speaker device
|
||||
*/
|
||||
export class ChromecastSpeaker extends Speaker {
|
||||
private client: InstanceType<typeof plugins.castv2Client.Client> | null = null;
|
||||
private player: unknown = null;
|
||||
|
||||
private _friendlyName: string = '';
|
||||
private _deviceType: TChromecastType = 'audio';
|
||||
private _capabilities: string[] = [];
|
||||
private _currentAppId?: string;
|
||||
private _currentAppName?: string;
|
||||
private _mediaSessionId?: number;
|
||||
|
||||
constructor(
|
||||
info: IDeviceInfo,
|
||||
options?: {
|
||||
roomName?: string;
|
||||
modelName?: string;
|
||||
friendlyName?: string;
|
||||
deviceType?: TChromecastType;
|
||||
capabilities?: string[];
|
||||
},
|
||||
retryOptions?: IRetryOptions
|
||||
) {
|
||||
super(info, 'chromecast', options, retryOptions);
|
||||
this._friendlyName = options?.friendlyName || info.name;
|
||||
this._deviceType = options?.deviceType || 'audio';
|
||||
this._capabilities = options?.capabilities || [];
|
||||
}
|
||||
|
||||
// Getters
|
||||
public get friendlyName(): string {
|
||||
return this._friendlyName;
|
||||
}
|
||||
|
||||
public get deviceType(): TChromecastType {
|
||||
return this._deviceType;
|
||||
}
|
||||
|
||||
public get capabilities(): string[] {
|
||||
return this._capabilities;
|
||||
}
|
||||
|
||||
public get currentAppId(): string | undefined {
|
||||
return this._currentAppId;
|
||||
}
|
||||
|
||||
public get currentAppName(): string | undefined {
|
||||
return this._currentAppName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to Chromecast
|
||||
*/
|
||||
protected async doConnect(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.client = new plugins.castv2Client.Client();
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (this.client) {
|
||||
this.client.close();
|
||||
this.client = null;
|
||||
}
|
||||
reject(new Error('Connection timeout'));
|
||||
}, 10000);
|
||||
|
||||
this.client.on('error', (err: Error) => {
|
||||
clearTimeout(timeout);
|
||||
if (this.client) {
|
||||
this.client.close();
|
||||
this.client = null;
|
||||
}
|
||||
reject(err);
|
||||
});
|
||||
|
||||
this.client.connect(this.address, () => {
|
||||
clearTimeout(timeout);
|
||||
|
||||
// Get receiver status
|
||||
this.client!.getStatus((err: Error | null, status: { applications?: Array<{ appId: string; displayName: string }> }) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (status && status.applications && status.applications.length > 0) {
|
||||
const app = status.applications[0];
|
||||
this._currentAppId = app.appId;
|
||||
this._currentAppName = app.displayName;
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect
|
||||
*/
|
||||
protected async doDisconnect(): Promise<void> {
|
||||
if (this.client) {
|
||||
this.client.close();
|
||||
this.client = null;
|
||||
}
|
||||
this.player = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh status
|
||||
*/
|
||||
public async refreshStatus(): Promise<void> {
|
||||
if (!this.client) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.client!.getStatus((err: Error | null, status: {
|
||||
applications?: Array<{ appId: string; displayName: string }>;
|
||||
volume?: { level: number; muted: boolean };
|
||||
}) => {
|
||||
if (!err && status) {
|
||||
if (status.applications && status.applications.length > 0) {
|
||||
const app = status.applications[0];
|
||||
this._currentAppId = app.appId;
|
||||
this._currentAppName = app.displayName;
|
||||
}
|
||||
|
||||
if (status.volume) {
|
||||
this._volume = Math.round(status.volume.level * 100);
|
||||
this._muted = status.volume.muted;
|
||||
}
|
||||
}
|
||||
|
||||
this.emit('status:updated', this.getSpeakerInfo());
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch media receiver and get player
|
||||
*/
|
||||
private async getMediaPlayer(): Promise<InstanceType<typeof plugins.castv2Client.DefaultMediaReceiver>> {
|
||||
if (!this.client) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.client!.launch(plugins.castv2Client.DefaultMediaReceiver, (err: Error | null, player: InstanceType<typeof plugins.castv2Client.DefaultMediaReceiver>) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
this.player = player;
|
||||
|
||||
player.on('status', (status: IChromecastMediaStatus) => {
|
||||
this.handleMediaStatus(status);
|
||||
});
|
||||
|
||||
resolve(player);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle media status update
|
||||
*/
|
||||
private handleMediaStatus(status: IChromecastMediaStatus): void {
|
||||
if (!status) return;
|
||||
|
||||
this._mediaSessionId = status.mediaSessionId;
|
||||
|
||||
// Update playback state
|
||||
switch (status.playerState) {
|
||||
case 'PLAYING':
|
||||
this._playbackState = 'playing';
|
||||
break;
|
||||
case 'PAUSED':
|
||||
this._playbackState = 'paused';
|
||||
break;
|
||||
case 'BUFFERING':
|
||||
this._playbackState = 'transitioning';
|
||||
break;
|
||||
case 'IDLE':
|
||||
default:
|
||||
this._playbackState = 'stopped';
|
||||
break;
|
||||
}
|
||||
|
||||
// Update volume
|
||||
if (status.volume) {
|
||||
this._volume = Math.round(status.volume.level * 100);
|
||||
this._muted = status.volume.muted;
|
||||
}
|
||||
|
||||
this.emit('playback:status', status);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Playback Control
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Play media URL
|
||||
*/
|
||||
public async play(uri?: string): Promise<void> {
|
||||
if (!this.client) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
const player = await this.getMediaPlayer() as InstanceType<typeof plugins.castv2Client.DefaultMediaReceiver>;
|
||||
|
||||
if (uri) {
|
||||
// Determine content type
|
||||
const contentType = this.guessContentType(uri);
|
||||
|
||||
const media = {
|
||||
contentId: uri,
|
||||
contentType,
|
||||
streamType: 'BUFFERED' as const,
|
||||
metadata: {
|
||||
type: 0,
|
||||
metadataType: 0,
|
||||
title: uri.split('/').pop() || 'Media',
|
||||
},
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
player.load(media, { autoplay: true }, (err: Error | null) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
this._playbackState = 'playing';
|
||||
this.emit('playback:started');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// Resume playback
|
||||
return new Promise((resolve, reject) => {
|
||||
player.play((err: Error | null) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
this._playbackState = 'playing';
|
||||
this.emit('playback:started');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause playback
|
||||
*/
|
||||
public async pause(): Promise<void> {
|
||||
if (!this.player) {
|
||||
throw new Error('No active media session');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
(this.player as InstanceType<typeof plugins.castv2Client.DefaultMediaReceiver>).pause((err: Error | null) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
this._playbackState = 'paused';
|
||||
this.emit('playback:paused');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop playback
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
if (!this.player) {
|
||||
throw new Error('No active media session');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
(this.player as InstanceType<typeof plugins.castv2Client.DefaultMediaReceiver>).stop((err: Error | null) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
this._playbackState = 'stopped';
|
||||
this.emit('playback:stopped');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Next track (not supported)
|
||||
*/
|
||||
public async next(): Promise<void> {
|
||||
throw new Error('Next track not supported on basic Chromecast');
|
||||
}
|
||||
|
||||
/**
|
||||
* Previous track (not supported)
|
||||
*/
|
||||
public async previous(): Promise<void> {
|
||||
throw new Error('Previous track not supported on basic Chromecast');
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek to position
|
||||
*/
|
||||
public async seek(seconds: number): Promise<void> {
|
||||
if (!this.player) {
|
||||
throw new Error('No active media session');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
(this.player as InstanceType<typeof plugins.castv2Client.DefaultMediaReceiver>).seek(seconds, (err: Error | null) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
this.emit('playback:seeked', { position: seconds });
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Volume Control
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get volume
|
||||
*/
|
||||
public async getVolume(): Promise<number> {
|
||||
await this.refreshStatus();
|
||||
return this._volume;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set volume
|
||||
*/
|
||||
public async setVolume(level: number): Promise<void> {
|
||||
if (!this.client) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
const clamped = Math.max(0, Math.min(100, level));
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.client!.setVolume({ level: clamped / 100 }, (err: Error | null) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
this._volume = clamped;
|
||||
this.emit('volume:changed', { volume: clamped });
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mute state
|
||||
*/
|
||||
public async getMute(): Promise<boolean> {
|
||||
await this.refreshStatus();
|
||||
return this._muted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set mute state
|
||||
*/
|
||||
public async setMute(muted: boolean): Promise<void> {
|
||||
if (!this.client) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.client!.setVolume({ muted }, (err: Error | null) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
this._muted = muted;
|
||||
this.emit('mute:changed', { muted });
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Track Information
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get current track
|
||||
*/
|
||||
public async getCurrentTrack(): Promise<ITrackInfo | null> {
|
||||
if (!this.player) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
(this.player as InstanceType<typeof plugins.castv2Client.DefaultMediaReceiver>).getStatus((err: Error | null, status: IChromecastMediaStatus) => {
|
||||
if (err || !status || !status.media) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const media = status.media;
|
||||
const metadata = media.metadata;
|
||||
|
||||
resolve({
|
||||
title: metadata?.title || 'Unknown',
|
||||
artist: metadata?.artist,
|
||||
album: metadata?.albumName,
|
||||
duration: media.duration || 0,
|
||||
position: status.currentTime || 0,
|
||||
albumArtUri: metadata?.images?.[0]?.url,
|
||||
uri: media.contentId,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get playback status
|
||||
*/
|
||||
public async getPlaybackStatus(): Promise<IPlaybackStatus> {
|
||||
await this.refreshStatus();
|
||||
|
||||
return {
|
||||
state: this._playbackState,
|
||||
volume: this._volume,
|
||||
muted: this._muted,
|
||||
track: await this.getCurrentTrack() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Chromecast-specific Methods
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Launch an application
|
||||
*/
|
||||
public async launchApp(appId: string): Promise<void> {
|
||||
if (!this.client) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.client!.launch({ id: appId } as Parameters<typeof plugins.castv2Client.Client.prototype.launch>[0], (err: Error | null) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
this._currentAppId = appId;
|
||||
this.emit('app:launched', { appId });
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop current application
|
||||
*/
|
||||
public async stopApp(): Promise<void> {
|
||||
if (!this.client) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.client!.stop(this.player as InstanceType<typeof plugins.castv2Client.DefaultMediaReceiver>, (err: Error | null) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
this._currentAppId = undefined;
|
||||
this._currentAppName = undefined;
|
||||
this.player = null;
|
||||
this.emit('app:stopped');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get receiver status
|
||||
*/
|
||||
public async getReceiverStatus(): Promise<{
|
||||
applications?: Array<{ appId: string; displayName: string }>;
|
||||
volume: { level: number; muted: boolean };
|
||||
}> {
|
||||
if (!this.client) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.client!.getStatus((err: Error | null, status: {
|
||||
applications?: Array<{ appId: string; displayName: string }>;
|
||||
volume: { level: number; muted: boolean };
|
||||
}) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(status);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Guess content type from URL
|
||||
*/
|
||||
private guessContentType(url: string): string {
|
||||
const ext = url.split('.').pop()?.toLowerCase();
|
||||
|
||||
switch (ext) {
|
||||
case 'mp3':
|
||||
return 'audio/mpeg';
|
||||
case 'mp4':
|
||||
case 'm4v':
|
||||
return 'video/mp4';
|
||||
case 'webm':
|
||||
return 'video/webm';
|
||||
case 'mkv':
|
||||
return 'video/x-matroska';
|
||||
case 'ogg':
|
||||
return 'audio/ogg';
|
||||
case 'flac':
|
||||
return 'audio/flac';
|
||||
case 'wav':
|
||||
return 'audio/wav';
|
||||
case 'm3u8':
|
||||
return 'application/x-mpegURL';
|
||||
case 'mpd':
|
||||
return 'application/dash+xml';
|
||||
default:
|
||||
return 'video/mp4';
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Device Info
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get speaker info
|
||||
*/
|
||||
public getSpeakerInfo(): IChromecastSpeakerInfo {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
type: 'speaker',
|
||||
address: this.address,
|
||||
port: this.port,
|
||||
status: this.status,
|
||||
protocol: 'chromecast',
|
||||
roomName: this._roomName,
|
||||
modelName: this._modelName,
|
||||
friendlyName: this._friendlyName,
|
||||
deviceType: this._deviceType,
|
||||
capabilities: this._capabilities,
|
||||
currentAppId: this._currentAppId,
|
||||
currentAppName: this._currentAppName,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from mDNS discovery
|
||||
*/
|
||||
public static fromDiscovery(
|
||||
data: {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
port?: number;
|
||||
roomName?: string;
|
||||
modelName?: string;
|
||||
friendlyName?: string;
|
||||
deviceType?: TChromecastType;
|
||||
capabilities?: string[];
|
||||
},
|
||||
retryOptions?: IRetryOptions
|
||||
): ChromecastSpeaker {
|
||||
const info: IDeviceInfo = {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
type: 'speaker',
|
||||
address: data.address,
|
||||
port: data.port ?? 8009,
|
||||
status: 'unknown',
|
||||
};
|
||||
|
||||
return new ChromecastSpeaker(
|
||||
info,
|
||||
{
|
||||
roomName: data.roomName,
|
||||
modelName: data.modelName,
|
||||
friendlyName: data.friendlyName,
|
||||
deviceType: data.deviceType,
|
||||
capabilities: data.capabilities,
|
||||
},
|
||||
retryOptions
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe for Chromecast device
|
||||
*/
|
||||
public static async probe(address: string, port: number = 8009, timeout: number = 5000): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const client = new plugins.castv2Client.Client();
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
client.close();
|
||||
resolve(false);
|
||||
}, timeout);
|
||||
|
||||
client.on('error', () => {
|
||||
clearTimeout(timer);
|
||||
client.close();
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
client.connect(address, () => {
|
||||
clearTimeout(timer);
|
||||
client.close();
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,654 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { Speaker, type ITrackInfo, type IPlaybackStatus, type TPlaybackState, type ISpeakerInfo } from './speaker.classes.speaker.js';
|
||||
import type { IDeviceInfo, IRetryOptions } from '../interfaces/index.js';
|
||||
|
||||
/**
|
||||
* Sonos zone (room) information
|
||||
*/
|
||||
export interface ISonosZoneInfo {
|
||||
name: string;
|
||||
uuid: string;
|
||||
coordinator: boolean;
|
||||
groupId: string;
|
||||
members: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sonos speaker device info
|
||||
*/
|
||||
export interface ISonosSpeakerInfo extends ISpeakerInfo {
|
||||
protocol: 'sonos';
|
||||
zoneName: string;
|
||||
zoneUuid: string;
|
||||
isCoordinator: boolean;
|
||||
groupId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sonos Speaker device
|
||||
*/
|
||||
export class SonosSpeaker extends Speaker {
|
||||
private device: InstanceType<typeof plugins.sonos.Sonos> | null = null;
|
||||
|
||||
private _zoneName: string = '';
|
||||
private _zoneUuid: string = '';
|
||||
private _isCoordinator: boolean = false;
|
||||
private _groupId?: string;
|
||||
|
||||
constructor(
|
||||
info: IDeviceInfo,
|
||||
options?: {
|
||||
roomName?: string;
|
||||
modelName?: string;
|
||||
},
|
||||
retryOptions?: IRetryOptions
|
||||
) {
|
||||
super(info, 'sonos', options, retryOptions);
|
||||
}
|
||||
|
||||
// Getters
|
||||
public get zoneName(): string {
|
||||
return this._zoneName;
|
||||
}
|
||||
|
||||
public get zoneUuid(): string {
|
||||
return this._zoneUuid;
|
||||
}
|
||||
|
||||
public get isCoordinator(): boolean {
|
||||
return this._isCoordinator;
|
||||
}
|
||||
|
||||
public get groupId(): string | undefined {
|
||||
return this._groupId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to Sonos device
|
||||
*/
|
||||
protected async doConnect(): Promise<void> {
|
||||
this.device = new plugins.sonos.Sonos(this.address, this.port);
|
||||
|
||||
// Get device info
|
||||
try {
|
||||
const zoneInfo = await this.device.getZoneInfo();
|
||||
this._zoneName = zoneInfo.ZoneName || '';
|
||||
this._roomName = this._zoneName;
|
||||
|
||||
const attrs = await this.device.getZoneAttrs();
|
||||
this._zoneUuid = attrs.CurrentZoneName || '';
|
||||
} catch (error) {
|
||||
// Some info may not be available
|
||||
}
|
||||
|
||||
// Get device description
|
||||
try {
|
||||
const desc = await this.device.deviceDescription();
|
||||
this._modelName = desc.modelName;
|
||||
this.model = desc.modelName;
|
||||
this.manufacturer = desc.manufacturer;
|
||||
this.serialNumber = desc.serialNum;
|
||||
} catch {
|
||||
// Optional info
|
||||
}
|
||||
|
||||
// Get current state
|
||||
await this.refreshStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect
|
||||
*/
|
||||
protected async doDisconnect(): Promise<void> {
|
||||
this.device = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh status
|
||||
*/
|
||||
public async refreshStatus(): Promise<void> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
try {
|
||||
const [volume, muted, state] = await Promise.all([
|
||||
this.device.getVolume(),
|
||||
this.device.getMuted(),
|
||||
this.device.getCurrentState(),
|
||||
]);
|
||||
|
||||
this._volume = volume;
|
||||
this._muted = muted;
|
||||
this._playbackState = this.mapSonosState(state);
|
||||
} catch {
|
||||
// Status refresh failed
|
||||
}
|
||||
|
||||
this.emit('status:updated', this.getSpeakerInfo());
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Sonos state to our state
|
||||
*/
|
||||
private mapSonosState(state: string): TPlaybackState {
|
||||
switch (state.toLowerCase()) {
|
||||
case 'playing':
|
||||
return 'playing';
|
||||
case 'paused':
|
||||
case 'paused_playback':
|
||||
return 'paused';
|
||||
case 'stopped':
|
||||
return 'stopped';
|
||||
case 'transitioning':
|
||||
return 'transitioning';
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Playback Control
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Play
|
||||
*/
|
||||
public async play(uri?: string): Promise<void> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
if (uri) {
|
||||
await this.device.play(uri);
|
||||
} else {
|
||||
await this.device.play();
|
||||
}
|
||||
|
||||
this._playbackState = 'playing';
|
||||
this.emit('playback:started');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause
|
||||
*/
|
||||
public async pause(): Promise<void> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
await this.device.pause();
|
||||
this._playbackState = 'paused';
|
||||
this.emit('playback:paused');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
await this.device.stop();
|
||||
this._playbackState = 'stopped';
|
||||
this.emit('playback:stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Next track
|
||||
*/
|
||||
public async next(): Promise<void> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
await this.device.next();
|
||||
this.emit('playback:next');
|
||||
}
|
||||
|
||||
/**
|
||||
* Previous track
|
||||
*/
|
||||
public async previous(): Promise<void> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
await this.device.previous();
|
||||
this.emit('playback:previous');
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek to position
|
||||
*/
|
||||
public async seek(seconds: number): Promise<void> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
await this.device.seek(seconds);
|
||||
this.emit('playback:seeked', { position: seconds });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Volume Control
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get volume
|
||||
*/
|
||||
public async getVolume(): Promise<number> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
const volume = await this.device.getVolume();
|
||||
this._volume = volume;
|
||||
return volume;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set volume
|
||||
*/
|
||||
public async setVolume(level: number): Promise<void> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
const clamped = Math.max(0, Math.min(100, level));
|
||||
await this.device.setVolume(clamped);
|
||||
this._volume = clamped;
|
||||
this.emit('volume:changed', { volume: clamped });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mute state
|
||||
*/
|
||||
public async getMute(): Promise<boolean> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
const muted = await this.device.getMuted();
|
||||
this._muted = muted;
|
||||
return muted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set mute state
|
||||
*/
|
||||
public async setMute(muted: boolean): Promise<void> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
await this.device.setMuted(muted);
|
||||
this._muted = muted;
|
||||
this.emit('mute:changed', { muted });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Track Information
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get current track
|
||||
*/
|
||||
public async getCurrentTrack(): Promise<ITrackInfo | null> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
try {
|
||||
const track = await this.device.currentTrack();
|
||||
|
||||
if (!track) return null;
|
||||
|
||||
return {
|
||||
title: track.title || 'Unknown',
|
||||
artist: track.artist,
|
||||
album: track.album,
|
||||
duration: track.duration || 0,
|
||||
position: track.position || 0,
|
||||
albumArtUri: track.albumArtURI || track.albumArtURL,
|
||||
uri: track.uri,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get playback status
|
||||
*/
|
||||
public async getPlaybackStatus(): Promise<IPlaybackStatus> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
const [state, volume, muted, track] = await Promise.all([
|
||||
this.device.getCurrentState(),
|
||||
this.device.getVolume(),
|
||||
this.device.getMuted(),
|
||||
this.getCurrentTrack(),
|
||||
]);
|
||||
|
||||
return {
|
||||
state: this.mapSonosState(state),
|
||||
volume,
|
||||
muted,
|
||||
track: track || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Sonos-specific Features
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Play from queue
|
||||
*/
|
||||
public async playFromQueue(index: number): Promise<void> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
await this.device.selectQueue();
|
||||
await this.device.selectTrack(index);
|
||||
await this.device.play();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add URI to queue
|
||||
*/
|
||||
public async addToQueue(uri: string, positionInQueue?: number): Promise<void> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
await this.device.queue(uri, positionInQueue);
|
||||
this.emit('queue:added', { uri, position: positionInQueue });
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear queue
|
||||
*/
|
||||
public async clearQueue(): Promise<void> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
await this.device.flush();
|
||||
this.emit('queue:cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue contents
|
||||
*/
|
||||
public async getQueue(): Promise<ITrackInfo[]> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
const queue = await this.device.getQueue();
|
||||
|
||||
if (!queue || !queue.items) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return queue.items.map((item: { title?: string; artist?: string; album?: string; albumArtURI?: string; uri?: string }) => ({
|
||||
title: item.title || 'Unknown',
|
||||
artist: item.artist,
|
||||
album: item.album,
|
||||
duration: 0,
|
||||
position: 0,
|
||||
albumArtUri: item.albumArtURI,
|
||||
uri: item.uri,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a Sonos playlist
|
||||
*/
|
||||
public async playPlaylist(playlistName: string): Promise<void> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
const playlists = await this.device.getMusicLibrary('sonos_playlists');
|
||||
const playlist = playlists.items?.find((p: { title?: string }) =>
|
||||
p.title?.toLowerCase().includes(playlistName.toLowerCase())
|
||||
);
|
||||
|
||||
if (playlist && playlist.uri) {
|
||||
await this.device.play(playlist.uri);
|
||||
} else {
|
||||
throw new Error(`Playlist "${playlistName}" not found`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play favorite by name
|
||||
*/
|
||||
public async playFavorite(favoriteName: string): Promise<void> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
const favorites = await this.device.getFavorites();
|
||||
const favorite = favorites.items?.find((f: { title?: string }) =>
|
||||
f.title?.toLowerCase().includes(favoriteName.toLowerCase())
|
||||
);
|
||||
|
||||
if (favorite && favorite.uri) {
|
||||
await this.device.play(favorite.uri);
|
||||
} else {
|
||||
throw new Error(`Favorite "${favoriteName}" not found`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get favorites
|
||||
*/
|
||||
public async getFavorites(): Promise<{ title: string; uri: string; albumArtUri?: string }[]> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
const favorites = await this.device.getFavorites();
|
||||
|
||||
if (!favorites.items) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return favorites.items.map((f: { title?: string; uri?: string; albumArtURI?: string }) => ({
|
||||
title: f.title || 'Unknown',
|
||||
uri: f.uri || '',
|
||||
albumArtUri: f.albumArtURI,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Play TuneIn radio station by ID
|
||||
*/
|
||||
public async playTuneInRadio(stationId: string): Promise<void> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
await this.device.playTuneinRadio(stationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Play Spotify URI
|
||||
*/
|
||||
public async playSpotify(spotifyUri: string): Promise<void> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
await this.device.play(spotifyUri);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Grouping
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Join another speaker's group
|
||||
*/
|
||||
public async joinGroup(coordinatorAddress: string): Promise<void> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
const coordinator = new plugins.sonos.Sonos(coordinatorAddress);
|
||||
await this.device.joinGroup(await coordinator.getName());
|
||||
this.emit('group:joined', { coordinator: coordinatorAddress });
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave current group
|
||||
*/
|
||||
public async leaveGroup(): Promise<void> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
await this.device.leaveGroup();
|
||||
this.emit('group:left');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get group information
|
||||
*/
|
||||
public async getGroupInfo(): Promise<ISonosZoneInfo | null> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
try {
|
||||
const groups = await this.device.getAllGroups();
|
||||
|
||||
// Find our group
|
||||
for (const group of groups) {
|
||||
const members = group.ZoneGroupMember || [];
|
||||
const memberArray = Array.isArray(members) ? members : [members];
|
||||
|
||||
for (const member of memberArray) {
|
||||
if (member.Location?.includes(this.address)) {
|
||||
const coordinator = memberArray.find((m: { UUID?: string }) => m.UUID === group.Coordinator);
|
||||
|
||||
return {
|
||||
name: group.Name || 'Group',
|
||||
uuid: group.Coordinator || '',
|
||||
coordinator: member.UUID === group.Coordinator,
|
||||
groupId: group.ID || '',
|
||||
members: memberArray.map((m: { ZoneName?: string }) => m.ZoneName || ''),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Device Info
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get speaker info
|
||||
*/
|
||||
public getSpeakerInfo(): ISonosSpeakerInfo {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
type: 'speaker',
|
||||
address: this.address,
|
||||
port: this.port,
|
||||
status: this.status,
|
||||
protocol: 'sonos',
|
||||
roomName: this._roomName,
|
||||
modelName: this._modelName,
|
||||
zoneName: this._zoneName,
|
||||
zoneUuid: this._zoneUuid,
|
||||
isCoordinator: this._isCoordinator,
|
||||
groupId: this._groupId,
|
||||
supportsGrouping: true,
|
||||
isGroupCoordinator: this._isCoordinator,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from discovery
|
||||
*/
|
||||
public static fromDiscovery(
|
||||
data: {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
port?: number;
|
||||
roomName?: string;
|
||||
modelName?: string;
|
||||
},
|
||||
retryOptions?: IRetryOptions
|
||||
): SonosSpeaker {
|
||||
const info: IDeviceInfo = {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
type: 'speaker',
|
||||
address: data.address,
|
||||
port: data.port ?? 1400,
|
||||
status: 'unknown',
|
||||
};
|
||||
|
||||
return new SonosSpeaker(
|
||||
info,
|
||||
{
|
||||
roomName: data.roomName,
|
||||
modelName: data.modelName,
|
||||
},
|
||||
retryOptions
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover Sonos devices on the network
|
||||
*/
|
||||
public static async discover(timeout: number = 5000): Promise<SonosSpeaker[]> {
|
||||
return new Promise((resolve) => {
|
||||
const speakers: SonosSpeaker[] = [];
|
||||
const discovery = new plugins.sonos.AsyncDeviceDiscovery();
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
resolve(speakers);
|
||||
}, timeout);
|
||||
|
||||
discovery.discover().then((device: { host: string; port: number }) => {
|
||||
clearTimeout(timer);
|
||||
|
||||
const speaker = new SonosSpeaker(
|
||||
{
|
||||
id: `sonos:${device.host}`,
|
||||
name: `Sonos ${device.host}`,
|
||||
type: 'speaker',
|
||||
address: device.host,
|
||||
port: device.port || 1400,
|
||||
status: 'unknown',
|
||||
}
|
||||
);
|
||||
|
||||
speakers.push(speaker);
|
||||
resolve(speakers);
|
||||
}).catch(() => {
|
||||
clearTimeout(timer);
|
||||
resolve(speakers);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { Device } from '../abstract/device.abstract.js';
|
||||
import type { IDeviceInfo, IRetryOptions } from '../interfaces/index.js';
|
||||
|
||||
/**
|
||||
* Speaker protocol types
|
||||
*/
|
||||
export type TSpeakerProtocol = 'sonos' | 'airplay' | 'chromecast' | 'dlna';
|
||||
|
||||
/**
|
||||
* Playback state
|
||||
*/
|
||||
export type TPlaybackState = 'playing' | 'paused' | 'stopped' | 'transitioning' | 'unknown';
|
||||
|
||||
/**
|
||||
* Track information
|
||||
*/
|
||||
export interface ITrackInfo {
|
||||
title: string;
|
||||
artist?: string;
|
||||
album?: string;
|
||||
duration: number; // seconds
|
||||
position: number; // seconds
|
||||
albumArtUri?: string;
|
||||
uri?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Speaker playback status
|
||||
*/
|
||||
export interface IPlaybackStatus {
|
||||
state: TPlaybackState;
|
||||
volume: number; // 0-100
|
||||
muted: boolean;
|
||||
track?: ITrackInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Speaker device info
|
||||
*/
|
||||
export interface ISpeakerInfo extends IDeviceInfo {
|
||||
type: 'speaker';
|
||||
protocol: TSpeakerProtocol;
|
||||
roomName?: string;
|
||||
modelName?: string;
|
||||
supportsGrouping?: boolean;
|
||||
groupId?: string;
|
||||
isGroupCoordinator?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract Speaker base class
|
||||
* Common interface for all speaker types (Sonos, AirPlay, Chromecast)
|
||||
*/
|
||||
export abstract class Speaker extends Device {
|
||||
protected _protocol: TSpeakerProtocol;
|
||||
protected _roomName?: string;
|
||||
protected _modelName?: string;
|
||||
protected _volume: number = 0;
|
||||
protected _muted: boolean = false;
|
||||
protected _playbackState: TPlaybackState = 'unknown';
|
||||
|
||||
constructor(
|
||||
info: IDeviceInfo,
|
||||
protocol: TSpeakerProtocol,
|
||||
options?: {
|
||||
roomName?: string;
|
||||
modelName?: string;
|
||||
},
|
||||
retryOptions?: IRetryOptions
|
||||
) {
|
||||
super(info, retryOptions);
|
||||
this._protocol = protocol;
|
||||
this._roomName = options?.roomName;
|
||||
this._modelName = options?.modelName;
|
||||
}
|
||||
|
||||
// Getters
|
||||
public get protocol(): TSpeakerProtocol {
|
||||
return this._protocol;
|
||||
}
|
||||
|
||||
public get roomName(): string | undefined {
|
||||
return this._roomName;
|
||||
}
|
||||
|
||||
public get speakerModelName(): string | undefined {
|
||||
return this._modelName;
|
||||
}
|
||||
|
||||
public get volume(): number {
|
||||
return this._volume;
|
||||
}
|
||||
|
||||
public get muted(): boolean {
|
||||
return this._muted;
|
||||
}
|
||||
|
||||
public get playbackState(): TPlaybackState {
|
||||
return this._playbackState;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Abstract Methods - Must be implemented by subclasses
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Play media from URI
|
||||
*/
|
||||
public abstract play(uri?: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Pause playback
|
||||
*/
|
||||
public abstract pause(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Stop playback
|
||||
*/
|
||||
public abstract stop(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Next track
|
||||
*/
|
||||
public abstract next(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Previous track
|
||||
*/
|
||||
public abstract previous(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Seek to position
|
||||
*/
|
||||
public abstract seek(seconds: number): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get volume level (0-100)
|
||||
*/
|
||||
public abstract getVolume(): Promise<number>;
|
||||
|
||||
/**
|
||||
* Set volume level (0-100)
|
||||
*/
|
||||
public abstract setVolume(level: number): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get mute state
|
||||
*/
|
||||
public abstract getMute(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Set mute state
|
||||
*/
|
||||
public abstract setMute(muted: boolean): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get current track info
|
||||
*/
|
||||
public abstract getCurrentTrack(): Promise<ITrackInfo | null>;
|
||||
|
||||
/**
|
||||
* Get playback status
|
||||
*/
|
||||
public abstract getPlaybackStatus(): Promise<IPlaybackStatus>;
|
||||
|
||||
// ============================================================================
|
||||
// Common Methods
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Toggle mute
|
||||
*/
|
||||
public async toggleMute(): Promise<boolean> {
|
||||
const currentMute = await this.getMute();
|
||||
await this.setMute(!currentMute);
|
||||
return !currentMute;
|
||||
}
|
||||
|
||||
/**
|
||||
* Volume up
|
||||
*/
|
||||
public async volumeUp(step: number = 5): Promise<number> {
|
||||
const current = await this.getVolume();
|
||||
const newVolume = Math.min(100, current + step);
|
||||
await this.setVolume(newVolume);
|
||||
return newVolume;
|
||||
}
|
||||
|
||||
/**
|
||||
* Volume down
|
||||
*/
|
||||
public async volumeDown(step: number = 5): Promise<number> {
|
||||
const current = await this.getVolume();
|
||||
const newVolume = Math.max(0, current - step);
|
||||
await this.setVolume(newVolume);
|
||||
return newVolume;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get speaker info
|
||||
*/
|
||||
public getSpeakerInfo(): ISpeakerInfo {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
type: 'speaker',
|
||||
address: this.address,
|
||||
port: this.port,
|
||||
status: this.status,
|
||||
protocol: this._protocol,
|
||||
roomName: this._roomName,
|
||||
modelName: this._modelName,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,548 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { Device } from '../abstract/device.abstract.js';
|
||||
import { NutProtocol, NUT_COMMANDS, NUT_VARIABLES, type TNutStatusFlag } from './ups.classes.nutprotocol.js';
|
||||
import { UpsSnmpHandler, type TUpsBatteryStatus, type TUpsOutputSource } from './ups.classes.upssnmp.js';
|
||||
import type { IDeviceInfo, IRetryOptions } from '../interfaces/index.js';
|
||||
|
||||
/**
|
||||
* UPS status enumeration
|
||||
*/
|
||||
export type TUpsStatus = 'online' | 'onbattery' | 'lowbattery' | 'charging' | 'discharging' | 'bypass' | 'offline' | 'error' | 'unknown';
|
||||
|
||||
/**
|
||||
* UPS protocol type
|
||||
*/
|
||||
export type TUpsProtocol = 'nut' | 'snmp';
|
||||
|
||||
/**
|
||||
* UPS device information
|
||||
*/
|
||||
export interface IUpsDeviceInfo extends IDeviceInfo {
|
||||
type: 'ups';
|
||||
protocol: TUpsProtocol;
|
||||
upsName?: string; // NUT ups name
|
||||
manufacturer: string;
|
||||
model: string;
|
||||
serialNumber?: string;
|
||||
firmwareVersion?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* UPS battery information
|
||||
*/
|
||||
export interface IUpsBatteryInfo {
|
||||
charge: number; // 0-100%
|
||||
runtime: number; // seconds remaining
|
||||
voltage: number; // volts
|
||||
temperature?: number; // celsius
|
||||
status: 'normal' | 'low' | 'depleted' | 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* UPS input/output power info
|
||||
*/
|
||||
export interface IUpsPowerInfo {
|
||||
inputVoltage: number;
|
||||
inputFrequency?: number;
|
||||
outputVoltage: number;
|
||||
outputFrequency?: number;
|
||||
outputCurrent?: number;
|
||||
outputPower?: number;
|
||||
load: number; // 0-100%
|
||||
}
|
||||
|
||||
/**
|
||||
* Full UPS status
|
||||
*/
|
||||
export interface IUpsFullStatus {
|
||||
status: TUpsStatus;
|
||||
battery: IUpsBatteryInfo;
|
||||
power: IUpsPowerInfo;
|
||||
alarms: string[];
|
||||
secondsOnBattery: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* UPS Device class supporting both NUT and SNMP protocols
|
||||
*/
|
||||
export class UpsDevice extends Device {
|
||||
private nutProtocol: NutProtocol | null = null;
|
||||
private snmpHandler: UpsSnmpHandler | null = null;
|
||||
private upsProtocol: TUpsProtocol;
|
||||
private upsName: string;
|
||||
private snmpCommunity: string;
|
||||
|
||||
private _upsStatus: TUpsStatus = 'unknown';
|
||||
private _manufacturer: string = '';
|
||||
private _model: string = '';
|
||||
private _batteryCharge: number = 0;
|
||||
private _batteryRuntime: number = 0;
|
||||
private _inputVoltage: number = 0;
|
||||
private _outputVoltage: number = 0;
|
||||
private _load: number = 0;
|
||||
|
||||
constructor(
|
||||
info: IDeviceInfo,
|
||||
options: {
|
||||
protocol: TUpsProtocol;
|
||||
upsName?: string; // Required for NUT
|
||||
snmpCommunity?: string; // For SNMP
|
||||
},
|
||||
retryOptions?: IRetryOptions
|
||||
) {
|
||||
super(info, retryOptions);
|
||||
this.upsProtocol = options.protocol;
|
||||
this.upsName = options.upsName || 'ups';
|
||||
this.snmpCommunity = options.snmpCommunity || 'public';
|
||||
}
|
||||
|
||||
// Getters for UPS properties
|
||||
public get upsStatus(): TUpsStatus {
|
||||
return this._upsStatus;
|
||||
}
|
||||
|
||||
public get upsManufacturer(): string {
|
||||
return this._manufacturer;
|
||||
}
|
||||
|
||||
public get upsModel(): string {
|
||||
return this._model;
|
||||
}
|
||||
|
||||
public get batteryCharge(): number {
|
||||
return this._batteryCharge;
|
||||
}
|
||||
|
||||
public get batteryRuntime(): number {
|
||||
return this._batteryRuntime;
|
||||
}
|
||||
|
||||
public get inputVoltage(): number {
|
||||
return this._inputVoltage;
|
||||
}
|
||||
|
||||
public get outputVoltage(): number {
|
||||
return this._outputVoltage;
|
||||
}
|
||||
|
||||
public get load(): number {
|
||||
return this._load;
|
||||
}
|
||||
|
||||
public get protocol(): TUpsProtocol {
|
||||
return this.upsProtocol;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to UPS
|
||||
*/
|
||||
protected async doConnect(): Promise<void> {
|
||||
if (this.upsProtocol === 'nut') {
|
||||
await this.connectNut();
|
||||
} else {
|
||||
await this.connectSnmp();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect via NUT protocol
|
||||
*/
|
||||
private async connectNut(): Promise<void> {
|
||||
this.nutProtocol = new NutProtocol(this.address, this.port);
|
||||
await this.nutProtocol.connect();
|
||||
|
||||
// Get device info
|
||||
const deviceInfo = await this.nutProtocol.getDeviceInfo(this.upsName);
|
||||
this._manufacturer = deviceInfo.manufacturer;
|
||||
this._model = deviceInfo.model;
|
||||
this.manufacturer = deviceInfo.manufacturer;
|
||||
this.model = deviceInfo.model;
|
||||
this.serialNumber = deviceInfo.serial;
|
||||
|
||||
// Get initial status
|
||||
await this.refreshStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect via SNMP protocol
|
||||
*/
|
||||
private async connectSnmp(): Promise<void> {
|
||||
this.snmpHandler = new UpsSnmpHandler(this.address, {
|
||||
community: this.snmpCommunity,
|
||||
port: this.port,
|
||||
});
|
||||
|
||||
// Verify it's a UPS
|
||||
const isUps = await this.snmpHandler.isUpsDevice();
|
||||
if (!isUps) {
|
||||
this.snmpHandler.close();
|
||||
this.snmpHandler = null;
|
||||
throw new Error('Device does not support UPS-MIB');
|
||||
}
|
||||
|
||||
// Get identity
|
||||
const identity = await this.snmpHandler.getIdentity();
|
||||
this._manufacturer = identity.manufacturer;
|
||||
this._model = identity.model;
|
||||
this.manufacturer = identity.manufacturer;
|
||||
this.model = identity.model;
|
||||
this.firmwareVersion = identity.softwareVersion;
|
||||
|
||||
// Get initial status
|
||||
await this.refreshStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from UPS
|
||||
*/
|
||||
protected async doDisconnect(): Promise<void> {
|
||||
if (this.nutProtocol) {
|
||||
await this.nutProtocol.disconnect();
|
||||
this.nutProtocol = null;
|
||||
}
|
||||
if (this.snmpHandler) {
|
||||
this.snmpHandler.close();
|
||||
this.snmpHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh UPS status
|
||||
*/
|
||||
public async refreshStatus(): Promise<void> {
|
||||
if (this.upsProtocol === 'nut' && this.nutProtocol) {
|
||||
await this.refreshNutStatus();
|
||||
} else if (this.snmpHandler) {
|
||||
await this.refreshSnmpStatus();
|
||||
} else {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
this.emit('status:updated', this.getDeviceInfo());
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh status via NUT
|
||||
*/
|
||||
private async refreshNutStatus(): Promise<void> {
|
||||
if (!this.nutProtocol) return;
|
||||
|
||||
const status = await this.nutProtocol.getUpsStatus(this.upsName);
|
||||
|
||||
this._batteryCharge = status.batteryCharge;
|
||||
this._batteryRuntime = status.batteryRuntime;
|
||||
this._inputVoltage = status.inputVoltage;
|
||||
this._outputVoltage = status.outputVoltage;
|
||||
this._load = status.load;
|
||||
|
||||
// Convert NUT status flags to our status
|
||||
this._upsStatus = this.nutStatusToUpsStatus(status.status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh status via SNMP
|
||||
*/
|
||||
private async refreshSnmpStatus(): Promise<void> {
|
||||
if (!this.snmpHandler) return;
|
||||
|
||||
const status = await this.snmpHandler.getFullStatus();
|
||||
|
||||
this._batteryCharge = status.estimatedChargeRemaining;
|
||||
this._batteryRuntime = status.estimatedMinutesRemaining * 60; // Convert to seconds
|
||||
this._inputVoltage = status.inputVoltage;
|
||||
this._outputVoltage = status.outputVoltage;
|
||||
this._load = status.outputPercentLoad;
|
||||
|
||||
// Convert SNMP status to our status
|
||||
this._upsStatus = this.snmpStatusToUpsStatus(status.outputSource, status.batteryStatus);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert NUT status flags to TUpsStatus
|
||||
*/
|
||||
private nutStatusToUpsStatus(flags: TNutStatusFlag[]): TUpsStatus {
|
||||
if (flags.includes('OFF')) return 'offline';
|
||||
if (flags.includes('LB')) return 'lowbattery';
|
||||
if (flags.includes('OB')) return 'onbattery';
|
||||
if (flags.includes('BYPASS')) return 'bypass';
|
||||
if (flags.includes('CHRG')) return 'charging';
|
||||
if (flags.includes('DISCHRG')) return 'discharging';
|
||||
if (flags.includes('OL')) return 'online';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert SNMP status to TUpsStatus
|
||||
*/
|
||||
private snmpStatusToUpsStatus(source: TUpsOutputSource, battery: TUpsBatteryStatus): TUpsStatus {
|
||||
if (source === 'none') return 'offline';
|
||||
if (source === 'battery') {
|
||||
if (battery === 'batteryLow') return 'lowbattery';
|
||||
if (battery === 'batteryDepleted') return 'lowbattery';
|
||||
return 'onbattery';
|
||||
}
|
||||
if (source === 'bypass') return 'bypass';
|
||||
if (source === 'normal') return 'online';
|
||||
if (source === 'booster' || source === 'reducer') return 'online';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get battery information
|
||||
*/
|
||||
public async getBatteryInfo(): Promise<IUpsBatteryInfo> {
|
||||
if (this.upsProtocol === 'nut' && this.nutProtocol) {
|
||||
const vars = await this.nutProtocol.getVariables(this.upsName, [
|
||||
NUT_VARIABLES.batteryCharge,
|
||||
NUT_VARIABLES.batteryRuntime,
|
||||
NUT_VARIABLES.batteryVoltage,
|
||||
NUT_VARIABLES.batteryTemperature,
|
||||
]);
|
||||
|
||||
return {
|
||||
charge: parseFloat(vars.get(NUT_VARIABLES.batteryCharge) || '0'),
|
||||
runtime: parseFloat(vars.get(NUT_VARIABLES.batteryRuntime) || '0'),
|
||||
voltage: parseFloat(vars.get(NUT_VARIABLES.batteryVoltage) || '0'),
|
||||
temperature: vars.has(NUT_VARIABLES.batteryTemperature)
|
||||
? parseFloat(vars.get(NUT_VARIABLES.batteryTemperature)!)
|
||||
: undefined,
|
||||
status: 'normal',
|
||||
};
|
||||
} else if (this.snmpHandler) {
|
||||
const battery = await this.snmpHandler.getBatteryStatus();
|
||||
|
||||
const statusMap: Record<TUpsBatteryStatus, IUpsBatteryInfo['status']> = {
|
||||
unknown: 'unknown',
|
||||
batteryNormal: 'normal',
|
||||
batteryLow: 'low',
|
||||
batteryDepleted: 'depleted',
|
||||
};
|
||||
|
||||
return {
|
||||
charge: battery.estimatedChargeRemaining,
|
||||
runtime: battery.estimatedMinutesRemaining * 60,
|
||||
voltage: battery.voltage,
|
||||
temperature: battery.temperature || undefined,
|
||||
status: statusMap[battery.status],
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get power information
|
||||
*/
|
||||
public async getPowerInfo(): Promise<IUpsPowerInfo> {
|
||||
if (this.upsProtocol === 'nut' && this.nutProtocol) {
|
||||
const vars = await this.nutProtocol.getVariables(this.upsName, [
|
||||
NUT_VARIABLES.inputVoltage,
|
||||
NUT_VARIABLES.inputFrequency,
|
||||
NUT_VARIABLES.outputVoltage,
|
||||
NUT_VARIABLES.outputCurrent,
|
||||
NUT_VARIABLES.upsLoad,
|
||||
]);
|
||||
|
||||
return {
|
||||
inputVoltage: parseFloat(vars.get(NUT_VARIABLES.inputVoltage) || '0'),
|
||||
inputFrequency: vars.has(NUT_VARIABLES.inputFrequency)
|
||||
? parseFloat(vars.get(NUT_VARIABLES.inputFrequency)!)
|
||||
: undefined,
|
||||
outputVoltage: parseFloat(vars.get(NUT_VARIABLES.outputVoltage) || '0'),
|
||||
outputCurrent: vars.has(NUT_VARIABLES.outputCurrent)
|
||||
? parseFloat(vars.get(NUT_VARIABLES.outputCurrent)!)
|
||||
: undefined,
|
||||
load: parseFloat(vars.get(NUT_VARIABLES.upsLoad) || '0'),
|
||||
};
|
||||
} else if (this.snmpHandler) {
|
||||
const [input, output] = await Promise.all([
|
||||
this.snmpHandler.getInputStatus(),
|
||||
this.snmpHandler.getOutputStatus(),
|
||||
]);
|
||||
|
||||
return {
|
||||
inputVoltage: input.voltage,
|
||||
inputFrequency: input.frequency,
|
||||
outputVoltage: output.voltage,
|
||||
outputFrequency: output.frequency,
|
||||
outputCurrent: output.current,
|
||||
outputPower: output.power,
|
||||
load: output.percentLoad,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full status
|
||||
*/
|
||||
public async getFullStatus(): Promise<IUpsFullStatus> {
|
||||
const [battery, power] = await Promise.all([
|
||||
this.getBatteryInfo(),
|
||||
this.getPowerInfo(),
|
||||
]);
|
||||
|
||||
let secondsOnBattery = 0;
|
||||
const alarms: string[] = [];
|
||||
|
||||
if (this.upsProtocol === 'nut' && this.nutProtocol) {
|
||||
const vars = await this.nutProtocol.getVariables(this.upsName, [
|
||||
NUT_VARIABLES.upsStatus,
|
||||
NUT_VARIABLES.upsAlarm,
|
||||
]);
|
||||
const alarm = vars.get(NUT_VARIABLES.upsAlarm);
|
||||
if (alarm) {
|
||||
alarms.push(alarm);
|
||||
}
|
||||
} else if (this.snmpHandler) {
|
||||
const snmpStatus = await this.snmpHandler.getFullStatus();
|
||||
secondsOnBattery = snmpStatus.secondsOnBattery;
|
||||
if (snmpStatus.alarmsPresent > 0) {
|
||||
alarms.push(`${snmpStatus.alarmsPresent} alarm(s) present`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: this._upsStatus,
|
||||
battery,
|
||||
power,
|
||||
alarms,
|
||||
secondsOnBattery,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a UPS command (NUT only)
|
||||
*/
|
||||
public async runCommand(command: string): Promise<boolean> {
|
||||
if (this.upsProtocol !== 'nut' || !this.nutProtocol) {
|
||||
throw new Error('Commands only supported via NUT protocol');
|
||||
}
|
||||
|
||||
const result = await this.nutProtocol.runCommand(this.upsName, command);
|
||||
this.emit('command:executed', { command, success: result });
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start battery test
|
||||
*/
|
||||
public async startBatteryTest(type: 'quick' | 'deep' = 'quick'): Promise<boolean> {
|
||||
const command = type === 'deep'
|
||||
? NUT_COMMANDS.testBatteryStartDeep
|
||||
: NUT_COMMANDS.testBatteryStartQuick;
|
||||
return this.runCommand(command);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop battery test
|
||||
*/
|
||||
public async stopBatteryTest(): Promise<boolean> {
|
||||
return this.runCommand(NUT_COMMANDS.testBatteryStop);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle beeper
|
||||
*/
|
||||
public async toggleBeeper(): Promise<boolean> {
|
||||
return this.runCommand(NUT_COMMANDS.beeperToggle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get device info
|
||||
*/
|
||||
public getDeviceInfo(): IUpsDeviceInfo {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
type: 'ups',
|
||||
address: this.address,
|
||||
port: this.port,
|
||||
status: this.status,
|
||||
protocol: this.upsProtocol,
|
||||
upsName: this.upsName,
|
||||
manufacturer: this._manufacturer,
|
||||
model: this._model,
|
||||
serialNumber: this.serialNumber,
|
||||
firmwareVersion: this.firmwareVersion,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create UPS device from discovery
|
||||
*/
|
||||
public static fromDiscovery(
|
||||
data: {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
port?: number;
|
||||
protocol: TUpsProtocol;
|
||||
upsName?: string;
|
||||
community?: string;
|
||||
},
|
||||
retryOptions?: IRetryOptions
|
||||
): UpsDevice {
|
||||
const info: IDeviceInfo = {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
type: 'ups',
|
||||
address: data.address,
|
||||
port: data.port ?? (data.protocol === 'nut' ? 3493 : 161),
|
||||
status: 'unknown',
|
||||
};
|
||||
|
||||
return new UpsDevice(
|
||||
info,
|
||||
{
|
||||
protocol: data.protocol,
|
||||
upsName: data.upsName,
|
||||
snmpCommunity: data.community,
|
||||
},
|
||||
retryOptions
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe for UPS device (NUT or SNMP)
|
||||
*/
|
||||
public static async probe(
|
||||
address: string,
|
||||
options?: {
|
||||
nutPort?: number;
|
||||
snmpPort?: number;
|
||||
snmpCommunity?: string;
|
||||
timeout?: number;
|
||||
}
|
||||
): Promise<{ protocol: TUpsProtocol; port: number } | null> {
|
||||
const nutPort = options?.nutPort ?? 3493;
|
||||
const snmpPort = options?.snmpPort ?? 161;
|
||||
const community = options?.snmpCommunity ?? 'public';
|
||||
|
||||
// Try NUT first
|
||||
const nutAvailable = await NutProtocol.probe(address, nutPort, options?.timeout);
|
||||
if (nutAvailable) {
|
||||
return { protocol: 'nut', port: nutPort };
|
||||
}
|
||||
|
||||
// Try SNMP UPS-MIB
|
||||
try {
|
||||
const handler = new UpsSnmpHandler(address, { community, port: snmpPort, timeout: options?.timeout ?? 3000 });
|
||||
const isUps = await handler.isUpsDevice();
|
||||
handler.close();
|
||||
|
||||
if (isUps) {
|
||||
return { protocol: 'snmp', port: snmpPort };
|
||||
}
|
||||
} catch {
|
||||
// Ignore SNMP errors
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export types
|
||||
export { NUT_COMMANDS, NUT_VARIABLES, type TNutStatusFlag } from './ups.classes.nutprotocol.js';
|
||||
export { UPS_SNMP_OIDS, type TUpsBatteryStatus, type TUpsOutputSource } from './ups.classes.upssnmp.js';
|
||||
Reference in New Issue
Block a user