Files
devicemanager/ts/device/device.classes.device.ts

455 lines
12 KiB
TypeScript
Raw Permalink Normal View History

/**
* 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);
}