455 lines
12 KiB
TypeScript
455 lines
12 KiB
TypeScript
/**
|
|
* 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<ScanFeature>('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<TFeatureType, Feature> = 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<boolean> {
|
|
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<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
|
|
*/
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<T extends Feature>(
|
|
device: UniversalDevice,
|
|
type: TFeatureType
|
|
): device is UniversalDevice & { getFeature(type: TFeatureType): T } {
|
|
return device.hasFeature(type);
|
|
}
|