Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e424085000 | |||
| 82a99cdfb8 |
11
changelog.md
11
changelog.md
@@ -1,5 +1,16 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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)
|
## 2026-01-09 - 2.2.0 - feat(smarthome)
|
||||||
add smart home features and Home Assistant integration (WebSocket protocol, discovery, factories, interfaces)
|
add smart home features and Home Assistant integration (WebSocket protocol, discovery, factories, interfaces)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ecobridge.xyz/devicemanager",
|
"name": "@ecobridge.xyz/devicemanager",
|
||||||
"version": "2.2.0",
|
"version": "2.3.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "a device manager for talking to devices on network and over usb",
|
"description": "a device manager for talking to devices on network and over usb",
|
||||||
"main": "dist_ts/index.js",
|
"main": "dist_ts/index.js",
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@ecobridge.xyz/devicemanager',
|
name: '@ecobridge.xyz/devicemanager',
|
||||||
version: '2.2.0',
|
version: '2.3.0',
|
||||||
description: 'a device manager for talking to devices on network and over usb'
|
description: 'a device manager for talking to devices on network and over usb'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
public getFeature<T extends Feature>(type: TFeatureType): T | undefined {
|
||||||
return this._features.get(type) as 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
|
* Get all features
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import type {
|
|||||||
INetworkScanOptions,
|
INetworkScanOptions,
|
||||||
INetworkScanResult,
|
INetworkScanResult,
|
||||||
TFeatureType,
|
TFeatureType,
|
||||||
|
IDeviceSelector,
|
||||||
} from './interfaces/index.js';
|
} 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[] {
|
public getDevices(selector?: IDeviceSelector): UniversalDevice[] {
|
||||||
return Array.from(this.devicesByIp.values());
|
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
|
* Get device by ID
|
||||||
|
* @deprecated Use getDevices({ id }) or selectDevice({ id }) instead
|
||||||
*/
|
*/
|
||||||
public getDevice(id: string): UniversalDevice | undefined {
|
public getDevice(id: string): UniversalDevice | undefined {
|
||||||
for (const device of this.devicesByIp.values()) {
|
for (const device of this.devicesByIp.values()) {
|
||||||
@@ -587,11 +622,52 @@ export class DeviceManager extends plugins.events.EventEmitter {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get device by address
|
* Get device by address
|
||||||
|
* @deprecated Use getDevices({ address }) or selectDevice({ address }) instead
|
||||||
*/
|
*/
|
||||||
public getDeviceByAddress(address: string): UniversalDevice | undefined {
|
public getDeviceByAddress(address: string): UniversalDevice | undefined {
|
||||||
return this.devicesByIp.get(address);
|
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
|
// Device Access - By Feature Type
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -982,6 +1058,68 @@ export class DeviceManager extends plugins.events.EventEmitter {
|
|||||||
return foundDevices;
|
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> {
|
public async cancelNetworkScan(): Promise<void> {
|
||||||
if (this._networkScanner) {
|
if (this._networkScanner) {
|
||||||
await this._networkScanner.cancel();
|
await this._networkScanner.cancel();
|
||||||
|
|||||||
@@ -371,6 +371,37 @@ export type TNetworkScannerEvents = {
|
|||||||
'cancelled': () => void;
|
'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)
|
// Feature Types (Universal Device Architecture)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -142,13 +142,28 @@ export class EsclProtocol {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Wait for scan job to complete and download the result
|
* 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(
|
public async waitForScanComplete(
|
||||||
jobUri: string,
|
jobUri: string,
|
||||||
options: IScanOptions,
|
options: IScanOptions,
|
||||||
pollInterval: number = 500
|
pollInterval: number = 500
|
||||||
): Promise<IScanResult> {
|
): 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;
|
let attempts = 0;
|
||||||
const maxAttempts = 120; // 60 seconds max
|
const maxAttempts = 120; // 60 seconds max
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user