/** * Universal Device class * A device is a network endpoint that can have multiple features (capabilities) */ import * as plugins from '../plugins.js'; import type { TFeatureType, IFeatureInfo, IDiscoveredFeature, } from '../interfaces/feature.interfaces.js'; import type { TDeviceStatus, IRetryOptions } from '../interfaces/index.js'; import { Feature, type TDeviceReference } from '../features/feature.abstract.js'; // ============================================================================ // Device Info Interface // ============================================================================ /** * Serializable device information */ export interface IUniversalDeviceInfo { id: string; name: string; address: string; port: number; status: TDeviceStatus; manufacturer?: string; model?: string; serialNumber?: string; firmwareVersion?: string; features: IFeatureInfo[]; featureTypes: TFeatureType[]; } /** * Options for creating a device */ export interface IDeviceCreateOptions { name?: string; manufacturer?: string; model?: string; serialNumber?: string; firmwareVersion?: string; retryOptions?: IRetryOptions; } // ============================================================================ // Universal Device Class // ============================================================================ /** * Universal Device - represents any network device with composable features * * Instead of having separate Scanner, Printer, Speaker classes, a Device can have * any combination of features (scan, print, playback, volume, etc.) * * @example * ```typescript * // A multifunction printer/scanner * const mfp = new UniversalDevice('192.168.1.100', 80); * mfp.addFeature(new ScanFeature(mfp, 80)); * mfp.addFeature(new PrintFeature(mfp, 631)); * * // Check capabilities * if (mfp.hasFeature('scan') && mfp.hasFeature('print')) { * const scanFeature = mfp.getFeature('scan'); * await scanFeature.scan({ format: 'pdf' }); * } * ``` */ export class UniversalDevice extends plugins.events.EventEmitter { // ============================================================================ // Properties // ============================================================================ /** * Unique device identifier * Format: {address}:{port} or custom ID from discovery */ public readonly id: string; /** * Human-readable device name */ public name: string; /** * Device network address (IP or hostname) */ public readonly address: string; /** * Primary device port */ public readonly port: number; /** * Device manufacturer */ public manufacturer?: string; /** * Device model */ public model?: string; /** * Device serial number */ public serialNumber?: string; /** * Device firmware version */ public firmwareVersion?: string; /** * Current device status */ protected _status: TDeviceStatus = 'unknown'; /** * Map of features by type */ protected _features: Map = new Map(); /** * Retry options for features */ protected _retryOptions: IRetryOptions; // ============================================================================ // Constructor // ============================================================================ constructor( address: string, port: number, options?: IDeviceCreateOptions ) { super(); this.address = address; this.port = port; this.id = `${address}:${port}`; this.name = options?.name ?? `Device at ${address}`; this.manufacturer = options?.manufacturer; this.model = options?.model; this.serialNumber = options?.serialNumber; this.firmwareVersion = options?.firmwareVersion; this._retryOptions = options?.retryOptions ?? {}; } // ============================================================================ // Status Management // ============================================================================ /** * Get current device status */ public get status(): TDeviceStatus { return this._status; } /** * Set device status */ public set status(value: TDeviceStatus) { if (this._status !== value) { const oldStatus = this._status; this._status = value; this.emit('status:changed', { oldStatus, newStatus: value }); } } /** * Check if device is online */ public get isOnline(): boolean { return this._status === 'online'; } // ============================================================================ // Feature Management // ============================================================================ /** * Add a feature to the device */ public addFeature(feature: Feature): void { if (this._features.has(feature.type)) { throw new Error(`Feature '${feature.type}' already exists on device ${this.id}`); } this._features.set(feature.type, feature); // Forward feature events feature.on('connected', () => this.emit('feature:connected', feature.type)); feature.on('disconnected', () => this.emit('feature:disconnected', feature.type)); feature.on('error', (error) => this.emit('feature:error', { type: feature.type, error })); feature.on('state:changed', (states) => this.emit('feature:state:changed', { type: feature.type, ...states })); this.emit('feature:added', feature); } /** * Remove a feature from the device */ public async removeFeature(type: TFeatureType): Promise { const feature = this._features.get(type); if (!feature) { return false; } // Disconnect the feature first try { await feature.disconnect(); } catch { // Ignore disconnect errors } this._features.delete(type); this.emit('feature:removed', type); return true; } /** * Check if device has a specific feature */ public hasFeature(type: TFeatureType): boolean { return this._features.has(type); } /** * Check if device has all specified features */ public hasFeatures(types: TFeatureType[]): boolean { return types.every((type) => this._features.has(type)); } /** * Check if device has any of the specified features */ public hasAnyFeature(types: TFeatureType[]): boolean { return types.some((type) => this._features.has(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 */ public getFeatures(): Feature[] { return Array.from(this._features.values()); } /** * Get all feature types */ public getFeatureTypes(): TFeatureType[] { return Array.from(this._features.keys()); } /** * Get feature count */ public get featureCount(): number { return this._features.size; } // ============================================================================ // Connection Management // ============================================================================ /** * Connect all features */ public async connect(): Promise { const features = this.getFeatures(); const results = await Promise.allSettled( features.map((f) => f.connect()) ); // Check if any connected successfully const anyConnected = results.some((r) => r.status === 'fulfilled'); if (anyConnected) { this.status = 'online'; } else if (results.length > 0) { // All failed this.status = 'error'; const errors = results .filter((r): r is PromiseRejectedResult => r.status === 'rejected') .map((r) => r.reason); throw new AggregateError(errors, 'Failed to connect any features'); } } /** * Connect a specific feature */ public async connectFeature(type: TFeatureType): Promise { const feature = this._features.get(type); if (!feature) { throw new Error(`Feature '${type}' not found on device ${this.id}`); } await feature.connect(); // Update device status if this is the first connected feature if (this._status !== 'online') { this.status = 'online'; } } /** * Disconnect all features */ public async disconnect(): Promise { const features = this.getFeatures(); await Promise.allSettled( features.map((f) => f.disconnect()) ); this.status = 'offline'; } /** * Disconnect a specific feature */ public async disconnectFeature(type: TFeatureType): Promise { const feature = this._features.get(type); if (feature) { await feature.disconnect(); } // Update device status if all features are disconnected const anyConnected = this.getFeatures().some((f) => f.isConnected); if (!anyConnected) { this.status = 'offline'; } } // ============================================================================ // Serialization // ============================================================================ /** * Get device reference for features */ public getDeviceReference(): TDeviceReference { return { id: this.id, name: this.name, address: this.address, port: this.port, }; } /** * Get serializable device info */ public getDeviceInfo(): IUniversalDeviceInfo { return { id: this.id, name: this.name, address: this.address, port: this.port, status: this._status, manufacturer: this.manufacturer, model: this.model, serialNumber: this.serialNumber, firmwareVersion: this.firmwareVersion, features: this.getFeatures().map((f) => f.getFeatureInfo()), featureTypes: this.getFeatureTypes(), }; } /** * Get retry options for features */ public get retryOptions(): IRetryOptions { return this._retryOptions; } // ============================================================================ // Static Factory Methods // ============================================================================ /** * Create a device from discovered features */ public static fromDiscovery( id: string, name: string, address: string, port: number, discoveredFeatures: IDiscoveredFeature[], options?: IDeviceCreateOptions ): UniversalDevice { const device = new UniversalDevice(address, port, { ...options, name, }); // Override the generated ID with the discovery ID (device as { id: string }).id = id; // Features will be added by the DeviceManager after creation // This is because feature instances need protocol-specific initialization return device; } } // ============================================================================ // Type Guards // ============================================================================ /** * Check if a device has a specific feature (type guard) */ export function deviceHasFeature( device: UniversalDevice, type: TFeatureType ): device is UniversalDevice & { getFeature(type: TFeatureType): T } { return device.hasFeature(type); }