feat(devicemanager): add selector-based device APIs, selectFeature helper, convenience discovery methods, and ESCL scan completion fallback

This commit is contained in:
2026-01-09 17:18:48 +00:00
parent d72ea96ec5
commit 82a99cdfb8
6 changed files with 224 additions and 6 deletions

View File

@@ -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'
}

View File

@@ -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
*/

View File

@@ -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();

View File

@@ -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)
// ============================================================================

View File

@@ -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