Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| be993bf667 | |||
| 1cc8c48315 | |||
| 3377053ef4 | |||
| d7240696a8 |
17
changelog.md
17
changelog.md
@@ -1,5 +1,22 @@
|
||||
# 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
|
||||
|
||||
|
||||
@@ -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.3.1",
|
||||
"version": "3.0.1",
|
||||
"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`
|
||||
|
||||
39
readme.md
39
readme.md
@@ -468,6 +468,8 @@ type TFeatureType =
|
||||
|
||||
### Custom Device Creation
|
||||
|
||||
Use the factory functions for creating devices with specific features:
|
||||
|
||||
```typescript
|
||||
import { createScanner, createPrinter, createSpeaker } from '@ecobridge.xyz/devicemanager';
|
||||
|
||||
@@ -487,8 +489,7 @@ const printer = createPrinter({
|
||||
name: 'Office Printer',
|
||||
address: '192.168.1.51',
|
||||
port: 631,
|
||||
ippPath: '/ipp/print',
|
||||
txtRecords: {},
|
||||
txtRecords: { rp: '/ipp/print' },
|
||||
});
|
||||
|
||||
// Create a Sonos speaker
|
||||
@@ -498,7 +499,37 @@ const speaker = createSpeaker({
|
||||
address: '192.168.1.52',
|
||||
port: 1400,
|
||||
protocol: 'sonos',
|
||||
txtRecords: {},
|
||||
});
|
||||
```
|
||||
|
||||
### 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,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
@@ -563,7 +594,7 @@ const maybePrint = device.getFeature<PrintFeature>('print'); // undefined
|
||||
|
||||
## 🙏 Credits
|
||||
|
||||
Built with love using:
|
||||
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
|
||||
|
||||
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.3.1',
|
||||
version: '3.0.1',
|
||||
description: 'a device manager for talking to devices on network and over usb'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user