diff --git a/changelog.md b/changelog.md index dfb4c33..5378bf1 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,16 @@ # 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) add smart home features and Home Assistant integration (WebSocket protocol, discovery, factories, interfaces) diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 56186d7..31ab3ee 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { 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' } diff --git a/ts/device/device.classes.device.ts b/ts/device/device.classes.device.ts index edd6b45..2c242f8 100644 --- a/ts/device/device.classes.device.ts +++ b/ts/device/device.classes.device.ts @@ -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(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('scan'); + * await scanFeature.connect(); + * const result = await scanFeature.scan({ source: 'flatbed' }); + * ``` + */ + public selectFeature(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 */ diff --git a/ts/devicemanager.classes.devicemanager.ts b/ts/devicemanager.classes.devicemanager.ts index ee53f57..04e2f48 100644 --- a/ts/devicemanager.classes.devicemanager.ts +++ b/ts/devicemanager.classes.devicemanager.ts @@ -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 { + 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 { + 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 { if (this._networkScanner) { await this._networkScanner.cancel(); diff --git a/ts/interfaces/index.ts b/ts/interfaces/index.ts index 93f8946..1702120 100644 --- a/ts/interfaces/index.ts +++ b/ts/interfaces/index.ts @@ -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) // ============================================================================ diff --git a/ts/protocols/protocol.escl.ts b/ts/protocols/protocol.escl.ts index 6e7d2b5..348a9eb 100644 --- a/ts/protocols/protocol.escl.ts +++ b/ts/protocols/protocol.escl.ts @@ -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 { - // 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