Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 716347bac1 | |||
| a6ee36f187 | |||
| be993bf667 | |||
| 1cc8c48315 | |||
| 3377053ef4 | |||
| d7240696a8 | |||
| 2e82ec1884 | |||
| 79b05d47aa | |||
| e424085000 | |||
| 82a99cdfb8 |
43
changelog.md
43
changelog.md
@@ -1,5 +1,48 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-01-12 - 3.0.2 - fix(devicemanager)
|
||||
no changes detected - nothing to commit
|
||||
|
||||
- git diff indicates no modifications, additions, or deletions
|
||||
- no files were changed in the provided diff
|
||||
|
||||
## 2026-01-12 - 3.0.1 - fix(release)
|
||||
add npm registries to release config and expand documentation for UniversalDevice architecture and smart-home features
|
||||
|
||||
- npmextra.json: add "registries" to release configuration to publish to both Verdaccio (https://verdaccio.lossless.digital) and the npm registry
|
||||
- readme.hints.md: rewritten/expanded implementation notes to describe the UniversalDevice architecture, composable features (including smart-home types like light, switch, sensor, climate, cover, lock, fan, camera), protocols, discovery, factories, interfaces, and testing guidance
|
||||
- readme.md: add factory usage examples including smart-home factory functions, update txtRecords usage (rp) in examples, and small copy/emoji edits
|
||||
|
||||
## 2026-01-10 - 3.0.0 - BREAKING CHANGE(devicemanager)
|
||||
migrate tests to new UniversalDevice/feature-based API, add device factories, SNMP protocol/feature and IP helper utilities
|
||||
|
||||
- Replace protocol-specific device classes (Scanner, Printer) with UniversalDevice and feature objects (ScanFeature, PrintFeature, PlaybackFeature, VolumeFeature, PowerFeature, SnmpFeature)
|
||||
- Add device factory functions: createScanner, createPrinter, createSpeaker, createUpsDevice
|
||||
- Add DeviceManager.getDevices selector and updated selectDevice behavior (throws when no match)
|
||||
- Expose SnmpProtocol and other protocol implementations
|
||||
- Introduce IP helper utilities: isValidIp, cidrToIps, getLocalSubnet
|
||||
- Update tests and logging to use feature-based APIs and factories (selectFeature/getFeature, hasFeature, featureCount)
|
||||
|
||||
## 2026-01-09 - 2.3.1 - fix(readme)
|
||||
update README to comprehensive, TypeScript-first documentation covering installation, quick start, examples, API usage, events, error handling, requirements, credits, and legal/company information
|
||||
|
||||
- Rewrote readme.md with ~590 additional lines to provide a full usage guide and examples
|
||||
- Added installation instructions for pnpm, npm, and yarn and badges for npm and license
|
||||
- Documented OOP usage pattern (Discovery → Selection → Feature → Operation), event handling, and error handling examples
|
||||
- Clarified requirements (Node.js 18+, TypeScript 5.0+), credits, license, trademark and company contact information
|
||||
- Docs-only change — no code or API modifications
|
||||
|
||||
## 2026-01-09 - 2.3.0 - feat(devicemanager)
|
||||
add selector-based device APIs, selectFeature helper, convenience discovery methods, and ESCL scan completion fallback
|
||||
|
||||
- Introduce IDeviceSelector and add selector support to getDevices(selector) to filter devices by id,address,name,model,manufacturer and feature capabilities.
|
||||
- Add selectDevice(selector) which returns exactly one device (throws if none) and logs a warning if multiple matches are returned without a unique identifier.
|
||||
- Deprecate getDevice(id) and getDeviceByAddress(address) in favor of selector-based retrieval methods.
|
||||
- Add private matchesSelector(...) implementing exact identity checks (id,address), case-insensitive partial attribute matching (name, model, manufacturer), and feature availability checks (hasFeature, hasFeatures, hasAnyFeature).
|
||||
- Add selectFeature(type) on devices to provide fail-fast access to a required feature (throws if missing).
|
||||
- Add discoverScanners(subnet, options) and discoverPrinters(subnet, options) convenience methods that run targeted network scans and return discovered scanners or printers respectively.
|
||||
- Improve ESCL protocol waitForScanComplete to attempt a direct download first (which triggers/blocks on many scanners) and fall back to polling if direct download fails or returns empty data.
|
||||
|
||||
## 2026-01-09 - 2.2.0 - feat(smarthome)
|
||||
add smart home features and Home Assistant integration (WebSocket protocol, discovery, factories, interfaces)
|
||||
|
||||
|
||||
@@ -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": "2.2.0",
|
||||
"version": "3.0.2",
|
||||
"private": false,
|
||||
"description": "a device manager for talking to devices on network and over usb",
|
||||
"main": "dist_ts/index.js",
|
||||
|
||||
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: '2.2.0',
|
||||
version: '3.0.2',
|
||||
description: 'a device manager for talking to devices on network and over usb'
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -38,6 +38,7 @@ import type {
|
||||
INetworkScanOptions,
|
||||
INetworkScanResult,
|
||||
TFeatureType,
|
||||
IDeviceSelector,
|
||||
} from './interfaces/index.js';
|
||||
|
||||
/**
|
||||
@@ -567,14 +568,48 @@ export class DeviceManager extends plugins.events.EventEmitter {
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get all devices
|
||||
* Get devices matching the selector criteria.
|
||||
* Returns all devices if no selector provided, or devices matching all criteria.
|
||||
* @param selector Optional criteria to filter devices
|
||||
* @returns Array of matching devices (empty if no matches)
|
||||
*/
|
||||
public getDevices(): UniversalDevice[] {
|
||||
return Array.from(this.devicesByIp.values());
|
||||
public getDevices(selector?: IDeviceSelector): UniversalDevice[] {
|
||||
const devices = Array.from(this.devicesByIp.values());
|
||||
|
||||
if (!selector) {
|
||||
return devices;
|
||||
}
|
||||
|
||||
return devices.filter((device) => this.matchesSelector(device, selector));
|
||||
}
|
||||
|
||||
/**
|
||||
* Select exactly ONE device matching the criteria.
|
||||
* Use this when you expect a specific device and want fail-fast behavior.
|
||||
* @param selector Criteria to match the device
|
||||
* @returns The matching device
|
||||
* @throws Error if no device matches the selector
|
||||
*/
|
||||
public selectDevice(selector: IDeviceSelector): UniversalDevice {
|
||||
const matches = this.getDevices(selector);
|
||||
|
||||
if (matches.length === 0) {
|
||||
throw new Error(`No device found matching: ${JSON.stringify(selector)}`);
|
||||
}
|
||||
|
||||
if (matches.length > 1 && !selector.address && !selector.id) {
|
||||
// Multiple matches without unique identifier - log warning
|
||||
console.warn(
|
||||
`Multiple devices (${matches.length}) match selector, using first: ${matches[0].name}`
|
||||
);
|
||||
}
|
||||
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get device by ID
|
||||
* @deprecated Use getDevices({ id }) or selectDevice({ id }) instead
|
||||
*/
|
||||
public getDevice(id: string): UniversalDevice | undefined {
|
||||
for (const device of this.devicesByIp.values()) {
|
||||
@@ -587,11 +622,52 @@ export class DeviceManager extends plugins.events.EventEmitter {
|
||||
|
||||
/**
|
||||
* Get device by address
|
||||
* @deprecated Use getDevices({ address }) or selectDevice({ address }) instead
|
||||
*/
|
||||
public getDeviceByAddress(address: string): UniversalDevice | undefined {
|
||||
return this.devicesByIp.get(address);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a device matches the selector criteria
|
||||
*/
|
||||
private matchesSelector(device: UniversalDevice, selector: IDeviceSelector): boolean {
|
||||
// Identity checks (exact match)
|
||||
if (selector.id && device.id !== selector.id) {
|
||||
return false;
|
||||
}
|
||||
if (selector.address && device.address !== selector.address) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Attribute checks (partial match, case-insensitive)
|
||||
if (selector.name && !device.name.toLowerCase().includes(selector.name.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
if (selector.model && !device.model?.toLowerCase().includes(selector.model.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
selector.manufacturer &&
|
||||
!device.manufacturer?.toLowerCase().includes(selector.manufacturer.toLowerCase())
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Capability checks
|
||||
if (selector.hasFeature && !device.hasFeature(selector.hasFeature)) {
|
||||
return false;
|
||||
}
|
||||
if (selector.hasFeatures && !device.hasFeatures(selector.hasFeatures)) {
|
||||
return false;
|
||||
}
|
||||
if (selector.hasAnyFeature && !device.hasAnyFeature(selector.hasAnyFeature)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Device Access - By Feature Type
|
||||
// ============================================================================
|
||||
@@ -982,6 +1058,68 @@ export class DeviceManager extends plugins.events.EventEmitter {
|
||||
return foundDevices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover scanners in a subnet and add them to the manager.
|
||||
* Convenience method that focuses discovery on scanner protocols only (eSCL, SANE).
|
||||
*
|
||||
* @param subnet CIDR notation subnet (e.g., '192.168.1.0/24')
|
||||
* @param options Optional discovery settings
|
||||
* @returns Array of discovered scanner devices
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const scanners = await manager.discoverScanners('192.168.190.0/24');
|
||||
* for (const scanner of scanners) {
|
||||
* console.log(`Found: ${scanner.name} at ${scanner.address}`);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
public async discoverScanners(
|
||||
subnet: string,
|
||||
options?: { timeout?: number; concurrency?: number }
|
||||
): Promise<UniversalDevice[]> {
|
||||
await this.scanNetwork({
|
||||
ipRange: subnet,
|
||||
probeEscl: true,
|
||||
probeSane: true,
|
||||
probeIpp: false,
|
||||
probeAirplay: false,
|
||||
probeSonos: false,
|
||||
probeChromecast: false,
|
||||
concurrency: options?.concurrency ?? 50,
|
||||
timeout: options?.timeout ?? 3000,
|
||||
});
|
||||
|
||||
return this.getScanners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover printers in a subnet and add them to the manager.
|
||||
* Convenience method that focuses discovery on printer protocols only (IPP).
|
||||
*
|
||||
* @param subnet CIDR notation subnet (e.g., '192.168.1.0/24')
|
||||
* @param options Optional discovery settings
|
||||
* @returns Array of discovered printer devices
|
||||
*/
|
||||
public async discoverPrinters(
|
||||
subnet: string,
|
||||
options?: { timeout?: number; concurrency?: number }
|
||||
): Promise<UniversalDevice[]> {
|
||||
await this.scanNetwork({
|
||||
ipRange: subnet,
|
||||
probeEscl: false,
|
||||
probeSane: false,
|
||||
probeIpp: true,
|
||||
probeAirplay: false,
|
||||
probeSonos: false,
|
||||
probeChromecast: false,
|
||||
concurrency: options?.concurrency ?? 50,
|
||||
timeout: options?.timeout ?? 3000,
|
||||
});
|
||||
|
||||
return this.getPrinters();
|
||||
}
|
||||
|
||||
public async cancelNetworkScan(): Promise<void> {
|
||||
if (this._networkScanner) {
|
||||
await this._networkScanner.cancel();
|
||||
|
||||
@@ -371,6 +371,37 @@ 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)
|
||||
// ============================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user