feat(devicemanager): Introduce a UniversalDevice architecture with composable Feature system; add extensive new device/protocol support and discovery/refactors
This commit is contained in:
431
ts/device/device.classes.device.ts
Normal file
431
ts/device/device.classes.device.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
public getFeature<T extends Feature>(type: TFeatureType): T | undefined {
|
||||
return this._features.get(type) as T | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
Reference in New Issue
Block a user