feat(devicemanager): Introduce a UniversalDevice architecture with composable Feature system; add extensive new device/protocol support and discovery/refactors
This commit is contained in:
251
ts/features/feature.abstract.ts
Normal file
251
ts/features/feature.abstract.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Abstract Feature base class
|
||||
* Features are composable capabilities that can be attached to devices
|
||||
*/
|
||||
|
||||
import * as plugins from '../plugins.js';
|
||||
import type {
|
||||
TFeatureType,
|
||||
TFeatureState,
|
||||
IFeature,
|
||||
IFeatureInfo,
|
||||
IFeatureOptions,
|
||||
} from '../interfaces/feature.interfaces.js';
|
||||
import type { IRetryOptions } from '../interfaces/index.js';
|
||||
import { withRetry } from '../helpers/helpers.retry.js';
|
||||
|
||||
/**
|
||||
* Forward reference to Device to avoid circular dependency
|
||||
* The actual Device class will set this reference
|
||||
*/
|
||||
export type TDeviceReference = {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
port: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Abstract base class for all features
|
||||
* Provides common functionality for connection management, state tracking, and retry logic
|
||||
*/
|
||||
export abstract class Feature extends plugins.events.EventEmitter implements IFeature {
|
||||
/**
|
||||
* The feature type identifier
|
||||
*/
|
||||
public abstract readonly type: TFeatureType;
|
||||
|
||||
/**
|
||||
* The protocol used by this feature
|
||||
*/
|
||||
public abstract readonly protocol: string;
|
||||
|
||||
/**
|
||||
* The port used by this feature
|
||||
*/
|
||||
protected _port: number;
|
||||
|
||||
/**
|
||||
* Current feature state
|
||||
*/
|
||||
protected _state: TFeatureState = 'disconnected';
|
||||
|
||||
/**
|
||||
* Last error encountered
|
||||
*/
|
||||
protected _lastError?: Error;
|
||||
|
||||
/**
|
||||
* Reference to the parent device
|
||||
*/
|
||||
protected _device: TDeviceReference;
|
||||
|
||||
/**
|
||||
* Retry configuration
|
||||
*/
|
||||
protected _retryConfig: Required<IRetryOptions>;
|
||||
|
||||
/**
|
||||
* Additional metadata from discovery
|
||||
*/
|
||||
protected _metadata: Record<string, unknown>;
|
||||
|
||||
constructor(
|
||||
device: TDeviceReference,
|
||||
port: number,
|
||||
options?: IFeatureOptions
|
||||
) {
|
||||
super();
|
||||
this._device = device;
|
||||
this._port = port;
|
||||
this._metadata = options?.metadata ?? {};
|
||||
|
||||
// Setup retry config
|
||||
const retryOpts = options?.retryOptions ?? {};
|
||||
this._retryConfig = {
|
||||
maxRetries: retryOpts.maxRetries ?? 3,
|
||||
baseDelay: retryOpts.baseDelay ?? 1000,
|
||||
maxDelay: retryOpts.maxDelay ?? 30000,
|
||||
multiplier: retryOpts.multiplier ?? 2,
|
||||
jitter: retryOpts.jitter ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Public Properties
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get the feature port
|
||||
*/
|
||||
public get port(): number {
|
||||
return this._port;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current feature state
|
||||
*/
|
||||
public get state(): TFeatureState {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the feature is connected
|
||||
*/
|
||||
public get isConnected(): boolean {
|
||||
return this._state === 'connected';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last error
|
||||
*/
|
||||
public get lastError(): Error | undefined {
|
||||
return this._lastError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the device address
|
||||
*/
|
||||
public get address(): string {
|
||||
return this._device.address;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the device ID
|
||||
*/
|
||||
public get deviceId(): string {
|
||||
return this._device.id;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Connection Management
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Connect to the feature endpoint
|
||||
*/
|
||||
public async connect(): Promise<void> {
|
||||
if (this._state === 'connected') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._state === 'connecting') {
|
||||
throw new Error(`Feature ${this.type} is already connecting`);
|
||||
}
|
||||
|
||||
this.setState('connecting');
|
||||
this._lastError = undefined;
|
||||
|
||||
try {
|
||||
await this.withRetry(() => this.doConnect());
|
||||
this.setState('connected');
|
||||
this.emit('connected');
|
||||
} catch (error) {
|
||||
this._lastError = error instanceof Error ? error : new Error(String(error));
|
||||
this.setState('error');
|
||||
this.emit('error', this._lastError);
|
||||
throw this._lastError;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from the feature endpoint
|
||||
*/
|
||||
public async disconnect(): Promise<void> {
|
||||
if (this._state === 'disconnected') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.doDisconnect();
|
||||
} finally {
|
||||
this.setState('disconnected');
|
||||
this.emit('disconnected');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Abstract Methods (implemented by subclasses)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Perform the actual connection logic
|
||||
* Implemented by each feature subclass
|
||||
*/
|
||||
protected abstract doConnect(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Perform the actual disconnection logic
|
||||
* Implemented by each feature subclass
|
||||
*/
|
||||
protected abstract doDisconnect(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get feature-specific info for serialization
|
||||
*/
|
||||
public abstract getFeatureInfo(): IFeatureInfo;
|
||||
|
||||
// ============================================================================
|
||||
// Helper Methods
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Set the feature state and emit change event
|
||||
*/
|
||||
protected setState(state: TFeatureState): void {
|
||||
if (this._state !== state) {
|
||||
const oldState = this._state;
|
||||
this._state = state;
|
||||
this.emit('state:changed', { oldState, newState: state });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an operation with retry logic
|
||||
*/
|
||||
protected async withRetry<T>(operation: () => Promise<T>): Promise<T> {
|
||||
return withRetry(operation, this._retryConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get base feature info
|
||||
*/
|
||||
protected getBaseFeatureInfo(): IFeatureInfo {
|
||||
return {
|
||||
type: this.type,
|
||||
protocol: this.protocol,
|
||||
port: this._port,
|
||||
state: this._state,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear error state
|
||||
*/
|
||||
public clearError(): void {
|
||||
this._lastError = undefined;
|
||||
if (this._state === 'error') {
|
||||
this.setState('disconnected');
|
||||
}
|
||||
}
|
||||
}
|
||||
246
ts/features/feature.playback.ts
Normal file
246
ts/features/feature.playback.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Playback Feature
|
||||
* Provides media playback control capability
|
||||
*/
|
||||
|
||||
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
||||
import type {
|
||||
TPlaybackProtocol,
|
||||
TPlaybackState,
|
||||
ITrackInfo,
|
||||
IPlaybackStatus,
|
||||
IPlaybackFeatureInfo,
|
||||
IFeatureOptions,
|
||||
} from '../interfaces/feature.interfaces.js';
|
||||
|
||||
/**
|
||||
* Options for creating a PlaybackFeature
|
||||
*/
|
||||
export interface IPlaybackFeatureOptions extends IFeatureOptions {
|
||||
protocol: TPlaybackProtocol;
|
||||
supportsQueue?: boolean;
|
||||
supportsSeek?: boolean;
|
||||
/** Protocol-specific client instance (Sonos, AirPlay, etc.) */
|
||||
protocolClient?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Playback Feature - provides media playback control
|
||||
*
|
||||
* Abstract feature that can be backed by different protocols (Sonos, AirPlay, Chromecast, DLNA).
|
||||
* Concrete implementations should extend this class for protocol-specific behavior.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const playback = device.getFeature<PlaybackFeature>('playback');
|
||||
* if (playback) {
|
||||
* await playback.play('spotify:track:123');
|
||||
* const status = await playback.getPlaybackStatus();
|
||||
* console.log(`Playing: ${status.track?.title}`);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class PlaybackFeature extends Feature {
|
||||
public readonly type = 'playback' as const;
|
||||
public readonly protocol: TPlaybackProtocol;
|
||||
|
||||
// Capabilities
|
||||
public supportsQueue: boolean = true;
|
||||
public supportsSeek: boolean = true;
|
||||
|
||||
// Current state
|
||||
protected _playbackState: TPlaybackState = 'stopped';
|
||||
protected _currentTrack: ITrackInfo | null = null;
|
||||
protected _position: number = 0;
|
||||
protected _duration: number = 0;
|
||||
|
||||
// Protocol client (set by subclass or passed in options)
|
||||
protected protocolClient: unknown = null;
|
||||
|
||||
constructor(
|
||||
device: TDeviceReference,
|
||||
port: number,
|
||||
options: IPlaybackFeatureOptions
|
||||
) {
|
||||
super(device, port, options);
|
||||
this.protocol = options.protocol;
|
||||
if (options.supportsQueue !== undefined) this.supportsQueue = options.supportsQueue;
|
||||
if (options.supportsSeek !== undefined) this.supportsSeek = options.supportsSeek;
|
||||
if (options.protocolClient) this.protocolClient = options.protocolClient;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Properties
|
||||
// ============================================================================
|
||||
|
||||
public get playbackState(): TPlaybackState {
|
||||
return this._playbackState;
|
||||
}
|
||||
|
||||
public get currentTrack(): ITrackInfo | null {
|
||||
return this._currentTrack;
|
||||
}
|
||||
|
||||
public get position(): number {
|
||||
return this._position;
|
||||
}
|
||||
|
||||
public get duration(): number {
|
||||
return this._duration;
|
||||
}
|
||||
|
||||
public get isPlaying(): boolean {
|
||||
return this._playbackState === 'playing';
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Connection (to be overridden by protocol-specific subclasses)
|
||||
// ============================================================================
|
||||
|
||||
protected async doConnect(): Promise<void> {
|
||||
// Base implementation - protocol-specific subclasses should override
|
||||
// and establish their protocol connection
|
||||
}
|
||||
|
||||
protected async doDisconnect(): Promise<void> {
|
||||
// Base implementation - protocol-specific subclasses should override
|
||||
this.protocolClient = null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Playback Control
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Start playback
|
||||
* @param uri Optional URI to play. If not provided, resumes current playback.
|
||||
*/
|
||||
public async play(uri?: string): Promise<void> {
|
||||
this.emit('playback:play', { uri });
|
||||
// Protocol-specific implementation should be provided by subclass
|
||||
this._playbackState = 'playing';
|
||||
this.emit('playback:state:changed', this._playbackState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause playback
|
||||
*/
|
||||
public async pause(): Promise<void> {
|
||||
this.emit('playback:pause');
|
||||
this._playbackState = 'paused';
|
||||
this.emit('playback:state:changed', this._playbackState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop playback
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
this.emit('playback:stop');
|
||||
this._playbackState = 'stopped';
|
||||
this._position = 0;
|
||||
this.emit('playback:state:changed', this._playbackState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip to next track
|
||||
*/
|
||||
public async next(): Promise<void> {
|
||||
this.emit('playback:next');
|
||||
}
|
||||
|
||||
/**
|
||||
* Go to previous track
|
||||
*/
|
||||
public async previous(): Promise<void> {
|
||||
this.emit('playback:previous');
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek to position
|
||||
* @param seconds Position in seconds
|
||||
*/
|
||||
public async seek(seconds: number): Promise<void> {
|
||||
if (!this.supportsSeek) {
|
||||
throw new Error('Seek not supported');
|
||||
}
|
||||
this._position = seconds;
|
||||
this.emit('playback:seek', seconds);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Status
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get current track info
|
||||
*/
|
||||
public async getCurrentTrack(): Promise<ITrackInfo | null> {
|
||||
return this._currentTrack;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get playback status
|
||||
*/
|
||||
public async getPlaybackStatus(): Promise<IPlaybackStatus> {
|
||||
return {
|
||||
state: this._playbackState,
|
||||
position: this._position,
|
||||
duration: this._duration,
|
||||
track: this._currentTrack ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update track info (called by protocol-specific implementations)
|
||||
*/
|
||||
protected updateTrack(track: ITrackInfo | null): void {
|
||||
const oldTrack = this._currentTrack;
|
||||
this._currentTrack = track;
|
||||
if (track?.title !== oldTrack?.title || track?.uri !== oldTrack?.uri) {
|
||||
this.emit('playback:track:changed', track);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update position (called by protocol-specific implementations)
|
||||
*/
|
||||
protected updatePosition(position: number, duration: number): void {
|
||||
this._position = position;
|
||||
this._duration = duration;
|
||||
this.emit('playback:position:changed', { position, duration });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Serialization
|
||||
// ============================================================================
|
||||
|
||||
public getFeatureInfo(): IPlaybackFeatureInfo {
|
||||
return {
|
||||
...this.getBaseFeatureInfo(),
|
||||
type: 'playback',
|
||||
protocol: this.protocol,
|
||||
supportsQueue: this.supportsQueue,
|
||||
supportsSeek: this.supportsSeek,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Static Factory
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create from discovery metadata
|
||||
*/
|
||||
public static fromDiscovery(
|
||||
device: TDeviceReference,
|
||||
port: number,
|
||||
protocol: TPlaybackProtocol,
|
||||
metadata: Record<string, unknown>
|
||||
): PlaybackFeature {
|
||||
return new PlaybackFeature(device, port, {
|
||||
protocol,
|
||||
supportsQueue: metadata.supportsQueue as boolean ?? true,
|
||||
supportsSeek: metadata.supportsSeek as boolean ?? true,
|
||||
});
|
||||
}
|
||||
}
|
||||
231
ts/features/feature.power.ts
Normal file
231
ts/features/feature.power.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* Power Feature
|
||||
* Provides UPS/power monitoring and control capability
|
||||
*/
|
||||
|
||||
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
||||
import type {
|
||||
TPowerProtocol,
|
||||
TPowerStatus,
|
||||
IBatteryInfo,
|
||||
IPowerInfo,
|
||||
IPowerFeatureInfo,
|
||||
IFeatureOptions,
|
||||
} from '../interfaces/feature.interfaces.js';
|
||||
|
||||
/**
|
||||
* Options for creating a PowerFeature
|
||||
*/
|
||||
export interface IPowerFeatureOptions extends IFeatureOptions {
|
||||
protocol: TPowerProtocol;
|
||||
hasBattery?: boolean;
|
||||
supportsShutdown?: boolean;
|
||||
supportsTest?: boolean;
|
||||
/** For NUT protocol: UPS name */
|
||||
upsName?: string;
|
||||
/** For SNMP: community string */
|
||||
community?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Power Feature - provides UPS/power monitoring and control
|
||||
*
|
||||
* Supports NUT (Network UPS Tools) and SNMP protocols for monitoring
|
||||
* UPS devices and smart power equipment.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const power = device.getFeature<PowerFeature>('power');
|
||||
* if (power) {
|
||||
* const status = await power.getStatus();
|
||||
* const battery = await power.getBatteryInfo();
|
||||
* console.log(`Status: ${status}, Battery: ${battery.charge}%`);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class PowerFeature extends Feature {
|
||||
public readonly type = 'power' as const;
|
||||
public readonly protocol: TPowerProtocol;
|
||||
|
||||
// Configuration
|
||||
public readonly upsName: string;
|
||||
public readonly community: string;
|
||||
|
||||
// Capabilities
|
||||
public readonly hasBattery: boolean;
|
||||
public readonly supportsShutdown: boolean;
|
||||
public readonly supportsTest: boolean;
|
||||
|
||||
// Current state
|
||||
protected _status: TPowerStatus = 'unknown';
|
||||
protected _batteryCharge: number = 0;
|
||||
protected _batteryRuntime: number = 0;
|
||||
protected _load: number = 0;
|
||||
|
||||
constructor(
|
||||
device: TDeviceReference,
|
||||
port: number,
|
||||
options: IPowerFeatureOptions
|
||||
) {
|
||||
super(device, port, options);
|
||||
this.protocol = options.protocol;
|
||||
this.upsName = options.upsName ?? 'ups';
|
||||
this.community = options.community ?? 'public';
|
||||
this.hasBattery = options.hasBattery ?? true;
|
||||
this.supportsShutdown = options.supportsShutdown ?? false;
|
||||
this.supportsTest = options.supportsTest ?? false;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Properties
|
||||
// ============================================================================
|
||||
|
||||
public get powerStatus(): TPowerStatus {
|
||||
return this._status;
|
||||
}
|
||||
|
||||
public get batteryCharge(): number {
|
||||
return this._batteryCharge;
|
||||
}
|
||||
|
||||
public get batteryRuntime(): number {
|
||||
return this._batteryRuntime;
|
||||
}
|
||||
|
||||
public get load(): number {
|
||||
return this._load;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Connection
|
||||
// ============================================================================
|
||||
|
||||
protected async doConnect(): Promise<void> {
|
||||
// Protocol-specific connection would be implemented here
|
||||
// For now, just verify the port is reachable
|
||||
}
|
||||
|
||||
protected async doDisconnect(): Promise<void> {
|
||||
// Protocol-specific disconnection
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Status Monitoring
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get current power status
|
||||
*/
|
||||
public async getStatus(): Promise<TPowerStatus> {
|
||||
// Protocol-specific implementation would fetch status
|
||||
return this._status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get battery information
|
||||
*/
|
||||
public async getBatteryInfo(): Promise<IBatteryInfo> {
|
||||
return {
|
||||
charge: this._batteryCharge,
|
||||
runtime: this._batteryRuntime,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get power information
|
||||
*/
|
||||
public async getPowerInfo(): Promise<IPowerInfo> {
|
||||
return {
|
||||
load: this._load,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Control Commands
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Initiate shutdown
|
||||
*/
|
||||
public async shutdown(delay?: number): Promise<void> {
|
||||
if (!this.supportsShutdown) {
|
||||
throw new Error('Shutdown not supported');
|
||||
}
|
||||
this.emit('power:shutdown', { delay });
|
||||
}
|
||||
|
||||
/**
|
||||
* Start battery test
|
||||
*/
|
||||
public async testBattery(): Promise<void> {
|
||||
if (!this.supportsTest) {
|
||||
throw new Error('Battery test not supported');
|
||||
}
|
||||
this.emit('power:test:started');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// State Updates
|
||||
// ============================================================================
|
||||
|
||||
protected updateStatus(status: TPowerStatus): void {
|
||||
const oldStatus = this._status;
|
||||
this._status = status;
|
||||
if (oldStatus !== status) {
|
||||
this.emit('power:status:changed', { oldStatus, newStatus: status });
|
||||
|
||||
// Emit specific events
|
||||
if (status === 'onbattery') {
|
||||
this.emit('power:onbattery');
|
||||
} else if (status === 'online' && oldStatus === 'onbattery') {
|
||||
this.emit('power:restored');
|
||||
} else if (status === 'lowbattery') {
|
||||
this.emit('power:lowbattery');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected updateBattery(charge: number, runtime: number): void {
|
||||
const oldCharge = this._batteryCharge;
|
||||
this._batteryCharge = charge;
|
||||
this._batteryRuntime = runtime;
|
||||
|
||||
if (oldCharge !== charge) {
|
||||
this.emit('battery:changed', { charge, runtime });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Serialization
|
||||
// ============================================================================
|
||||
|
||||
public getFeatureInfo(): IPowerFeatureInfo {
|
||||
return {
|
||||
...this.getBaseFeatureInfo(),
|
||||
type: 'power',
|
||||
protocol: this.protocol,
|
||||
hasBattery: this.hasBattery,
|
||||
supportsShutdown: this.supportsShutdown,
|
||||
supportsTest: this.supportsTest,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Static Factory
|
||||
// ============================================================================
|
||||
|
||||
public static fromDiscovery(
|
||||
device: TDeviceReference,
|
||||
port: number,
|
||||
protocol: TPowerProtocol,
|
||||
metadata: Record<string, unknown>
|
||||
): PowerFeature {
|
||||
return new PowerFeature(device, port, {
|
||||
protocol,
|
||||
upsName: metadata.upsName as string,
|
||||
hasBattery: metadata.hasBattery as boolean ?? true,
|
||||
supportsShutdown: metadata.supportsShutdown as boolean ?? false,
|
||||
supportsTest: metadata.supportsTest as boolean ?? false,
|
||||
});
|
||||
}
|
||||
}
|
||||
297
ts/features/feature.print.ts
Normal file
297
ts/features/feature.print.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* Print Feature
|
||||
* Provides document printing capability using IPP protocol
|
||||
*/
|
||||
|
||||
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
||||
import { IppProtocol } from '../printer/printer.classes.ippprotocol.js';
|
||||
import type {
|
||||
TPrintProtocol,
|
||||
TPrintSides,
|
||||
TPrintQuality,
|
||||
TPrintColorMode,
|
||||
IPrintCapabilities,
|
||||
IPrintOptions,
|
||||
IPrintJob,
|
||||
IPrintFeatureInfo,
|
||||
IFeatureOptions,
|
||||
} from '../interfaces/feature.interfaces.js';
|
||||
import type { IPrinterCapabilities } from '../interfaces/index.js';
|
||||
|
||||
/**
|
||||
* Options for creating a PrintFeature
|
||||
*/
|
||||
export interface IPrintFeatureOptions extends IFeatureOptions {
|
||||
protocol?: TPrintProtocol;
|
||||
uri?: string;
|
||||
supportsColor?: boolean;
|
||||
supportsDuplex?: boolean;
|
||||
supportedMediaSizes?: string[];
|
||||
supportedMediaTypes?: string[];
|
||||
maxCopies?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Print Feature - provides document printing capability
|
||||
*
|
||||
* Wraps the IPP protocol to provide a unified printing interface.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const printFeature = device.getFeature<PrintFeature>('print');
|
||||
* if (printFeature) {
|
||||
* await printFeature.connect();
|
||||
* const job = await printFeature.print(pdfBuffer, { copies: 2 });
|
||||
* console.log(`Print job ${job.id} created`);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class PrintFeature extends Feature {
|
||||
public readonly type = 'print' as const;
|
||||
public readonly protocol: TPrintProtocol;
|
||||
|
||||
// Protocol client
|
||||
private ippClient: IppProtocol | null = null;
|
||||
|
||||
// Configuration
|
||||
public readonly uri: string;
|
||||
|
||||
// Capabilities
|
||||
public supportsColor: boolean = true;
|
||||
public supportsDuplex: boolean = false;
|
||||
public supportedMediaSizes: string[] = ['iso_a4_210x297mm', 'na_letter_8.5x11in'];
|
||||
public supportedMediaTypes: string[] = ['stationery'];
|
||||
public maxCopies: number = 99;
|
||||
public supportedSides: TPrintSides[] = ['one-sided'];
|
||||
public supportedQualities: TPrintQuality[] = ['normal'];
|
||||
|
||||
constructor(
|
||||
device: TDeviceReference,
|
||||
port: number,
|
||||
options?: IPrintFeatureOptions
|
||||
) {
|
||||
super(device, port, options);
|
||||
this.protocol = options?.protocol ?? 'ipp';
|
||||
this.uri = options?.uri ?? `ipp://${device.address}:${port}/ipp/print`;
|
||||
|
||||
// Set capabilities from options if provided
|
||||
if (options?.supportsColor !== undefined) this.supportsColor = options.supportsColor;
|
||||
if (options?.supportsDuplex !== undefined) {
|
||||
this.supportsDuplex = options.supportsDuplex;
|
||||
if (options.supportsDuplex) {
|
||||
this.supportedSides = ['one-sided', 'two-sided-long-edge', 'two-sided-short-edge'];
|
||||
}
|
||||
}
|
||||
if (options?.supportedMediaSizes) this.supportedMediaSizes = options.supportedMediaSizes;
|
||||
if (options?.supportedMediaTypes) this.supportedMediaTypes = options.supportedMediaTypes;
|
||||
if (options?.maxCopies) this.maxCopies = options.maxCopies;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Connection
|
||||
// ============================================================================
|
||||
|
||||
protected async doConnect(): Promise<void> {
|
||||
if (this.protocol === 'ipp') {
|
||||
// Parse URI to get address, port, and path
|
||||
const url = new URL(this.uri.replace('ipp://', 'http://').replace('ipps://', 'https://'));
|
||||
const address = url.hostname;
|
||||
const port = parseInt(url.port) || this._port;
|
||||
const path = url.pathname || '/ipp/print';
|
||||
|
||||
this.ippClient = new IppProtocol(address, port, path);
|
||||
// Verify connection by getting printer attributes
|
||||
const attrs = await this.ippClient.getAttributes();
|
||||
this.updateCapabilitiesFromIpp(attrs);
|
||||
}
|
||||
// JetDirect and LPD don't need connection verification
|
||||
}
|
||||
|
||||
protected async doDisconnect(): Promise<void> {
|
||||
this.ippClient = null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Printing Operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get printer capabilities
|
||||
*/
|
||||
public async getCapabilities(): Promise<IPrintCapabilities> {
|
||||
return {
|
||||
colorSupported: this.supportsColor,
|
||||
duplexSupported: this.supportsDuplex,
|
||||
mediaSizes: this.supportedMediaSizes,
|
||||
mediaTypes: this.supportedMediaTypes,
|
||||
resolutions: [300, 600],
|
||||
maxCopies: this.maxCopies,
|
||||
sidesSupported: this.supportedSides,
|
||||
qualitySupported: this.supportedQualities,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Print a document
|
||||
*/
|
||||
public async print(data: Buffer, options?: IPrintOptions): Promise<IPrintJob> {
|
||||
if (!this.isConnected) {
|
||||
throw new Error('Print feature not connected');
|
||||
}
|
||||
|
||||
if (this.protocol === 'ipp' && this.ippClient) {
|
||||
return this.printWithIpp(data, options);
|
||||
}
|
||||
|
||||
throw new Error(`Protocol ${this.protocol} not supported yet`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active print jobs
|
||||
*/
|
||||
public async getJobs(): Promise<IPrintJob[]> {
|
||||
if (!this.isConnected || !this.ippClient) {
|
||||
throw new Error('Print feature not connected');
|
||||
}
|
||||
|
||||
return this.ippClient.getJobs();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get info about a specific job
|
||||
*/
|
||||
public async getJobInfo(jobId: number): Promise<IPrintJob> {
|
||||
if (!this.isConnected || !this.ippClient) {
|
||||
throw new Error('Print feature not connected');
|
||||
}
|
||||
|
||||
return this.ippClient.getJobInfo(jobId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a print job
|
||||
*/
|
||||
public async cancelJob(jobId: number): Promise<void> {
|
||||
if (!this.isConnected || !this.ippClient) {
|
||||
throw new Error('Print feature not connected');
|
||||
}
|
||||
|
||||
await this.ippClient.cancelJob(jobId);
|
||||
this.emit('job:cancelled', jobId);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Protocol-Specific Printing
|
||||
// ============================================================================
|
||||
|
||||
private async printWithIpp(data: Buffer, options?: IPrintOptions): Promise<IPrintJob> {
|
||||
if (!this.ippClient) {
|
||||
throw new Error('IPP client not initialized');
|
||||
}
|
||||
|
||||
this.emit('print:started', options);
|
||||
|
||||
// IppProtocol.print() accepts IPrintOptions and returns IPrintJob
|
||||
const job = await this.ippClient.print(data, options);
|
||||
|
||||
this.emit('print:submitted', job);
|
||||
return job;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Methods
|
||||
// ============================================================================
|
||||
|
||||
private updateCapabilitiesFromIpp(caps: IPrinterCapabilities): void {
|
||||
this.supportsColor = caps.colorSupported;
|
||||
this.supportsDuplex = caps.duplexSupported;
|
||||
this.maxCopies = caps.maxCopies;
|
||||
|
||||
if (caps.mediaSizes && caps.mediaSizes.length > 0) {
|
||||
this.supportedMediaSizes = caps.mediaSizes;
|
||||
}
|
||||
if (caps.mediaTypes && caps.mediaTypes.length > 0) {
|
||||
this.supportedMediaTypes = caps.mediaTypes;
|
||||
}
|
||||
if (caps.sidesSupported && caps.sidesSupported.length > 0) {
|
||||
this.supportedSides = caps.sidesSupported.filter((s): s is TPrintSides =>
|
||||
['one-sided', 'two-sided-long-edge', 'two-sided-short-edge'].includes(s)
|
||||
);
|
||||
}
|
||||
if (caps.qualitySupported && caps.qualitySupported.length > 0) {
|
||||
this.supportedQualities = caps.qualitySupported.filter((q): q is TPrintQuality =>
|
||||
['draft', 'normal', 'high'].includes(q)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private qualityToIpp(quality: TPrintQuality): number {
|
||||
switch (quality) {
|
||||
case 'draft': return 3;
|
||||
case 'normal': return 4;
|
||||
case 'high': return 5;
|
||||
default: return 4;
|
||||
}
|
||||
}
|
||||
|
||||
private mapIppJob(job: Record<string, unknown>): IPrintJob {
|
||||
const stateMap: Record<number, IPrintJob['state']> = {
|
||||
3: 'pending',
|
||||
4: 'pending',
|
||||
5: 'processing',
|
||||
6: 'processing',
|
||||
7: 'canceled',
|
||||
8: 'aborted',
|
||||
9: 'completed',
|
||||
};
|
||||
|
||||
return {
|
||||
id: job['job-id'] as number,
|
||||
name: job['job-name'] as string ?? 'Unknown',
|
||||
state: stateMap[(job['job-state'] as number) ?? 3] ?? 'pending',
|
||||
stateReason: (job['job-state-reasons'] as string[])?.[0],
|
||||
createdAt: new Date((job['time-at-creation'] as number) * 1000),
|
||||
completedAt: job['time-at-completed']
|
||||
? new Date((job['time-at-completed'] as number) * 1000)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Serialization
|
||||
// ============================================================================
|
||||
|
||||
public getFeatureInfo(): IPrintFeatureInfo {
|
||||
return {
|
||||
...this.getBaseFeatureInfo(),
|
||||
type: 'print',
|
||||
protocol: this.protocol,
|
||||
supportsColor: this.supportsColor,
|
||||
supportsDuplex: this.supportsDuplex,
|
||||
supportedMediaSizes: this.supportedMediaSizes,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Static Factory
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create from discovery metadata
|
||||
*/
|
||||
public static fromDiscovery(
|
||||
device: TDeviceReference,
|
||||
port: number,
|
||||
protocol: TPrintProtocol,
|
||||
metadata: Record<string, unknown>
|
||||
): PrintFeature {
|
||||
const txtRecords = metadata.txtRecords as Record<string, string> ?? {};
|
||||
|
||||
return new PrintFeature(device, port, {
|
||||
protocol,
|
||||
uri: metadata.uri as string,
|
||||
supportsColor: txtRecords['Color'] === 'T' || txtRecords['color'] === 'true',
|
||||
supportsDuplex: txtRecords['Duplex'] === 'T' || txtRecords['duplex'] === 'true',
|
||||
});
|
||||
}
|
||||
}
|
||||
371
ts/features/feature.scan.ts
Normal file
371
ts/features/feature.scan.ts
Normal file
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* Scan Feature
|
||||
* Provides document scanning capability using eSCL or SANE protocols
|
||||
*/
|
||||
|
||||
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
||||
import { EsclProtocol } from '../scanner/scanner.classes.esclprotocol.js';
|
||||
import { SaneProtocol } from '../scanner/scanner.classes.saneprotocol.js';
|
||||
import type {
|
||||
TScanProtocol,
|
||||
TScanFormat,
|
||||
TColorMode,
|
||||
TScanSource,
|
||||
IScanCapabilities,
|
||||
IScanOptions,
|
||||
IScanResult,
|
||||
IScanFeatureInfo,
|
||||
IFeatureOptions,
|
||||
} from '../interfaces/feature.interfaces.js';
|
||||
|
||||
/**
|
||||
* Options for creating a ScanFeature
|
||||
*/
|
||||
export interface IScanFeatureOptions extends IFeatureOptions {
|
||||
protocol: TScanProtocol;
|
||||
secure?: boolean;
|
||||
deviceName?: string; // For SANE
|
||||
supportedFormats?: TScanFormat[];
|
||||
supportedResolutions?: number[];
|
||||
supportedColorModes?: TColorMode[];
|
||||
supportedSources?: TScanSource[];
|
||||
hasAdf?: boolean;
|
||||
hasDuplex?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan Feature - provides document scanning capability
|
||||
*
|
||||
* Wraps eSCL (AirScan) or SANE protocols to provide a unified scanning interface.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const scanFeature = device.getFeature<ScanFeature>('scan');
|
||||
* if (scanFeature) {
|
||||
* await scanFeature.connect();
|
||||
* const result = await scanFeature.scan({ format: 'pdf', resolution: 300 });
|
||||
* console.log(`Scanned ${result.width}x${result.height} pixels`);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class ScanFeature extends Feature {
|
||||
public readonly type = 'scan' as const;
|
||||
public readonly protocol: TScanProtocol;
|
||||
|
||||
// Protocol clients
|
||||
private esclClient: EsclProtocol | null = null;
|
||||
private saneClient: SaneProtocol | null = null;
|
||||
|
||||
// Configuration
|
||||
private readonly isSecure: boolean;
|
||||
private readonly deviceName: string;
|
||||
|
||||
// Capabilities
|
||||
public supportedFormats: TScanFormat[] = ['jpeg', 'png', 'pdf'];
|
||||
public supportedResolutions: number[] = [75, 150, 300, 600];
|
||||
public supportedColorModes: TColorMode[] = ['color', 'grayscale', 'blackwhite'];
|
||||
public supportedSources: TScanSource[] = ['flatbed'];
|
||||
public hasAdf: boolean = false;
|
||||
public hasDuplex: boolean = false;
|
||||
public maxWidth: number = 215.9; // A4 width in mm
|
||||
public maxHeight: number = 297; // A4 height in mm
|
||||
public minWidth: number = 10;
|
||||
public minHeight: number = 10;
|
||||
|
||||
constructor(
|
||||
device: TDeviceReference,
|
||||
port: number,
|
||||
options: IScanFeatureOptions
|
||||
) {
|
||||
super(device, port, options);
|
||||
this.protocol = options.protocol;
|
||||
this.isSecure = options.secure ?? false;
|
||||
this.deviceName = options.deviceName ?? '';
|
||||
|
||||
// Set capabilities from options if provided
|
||||
if (options.supportedFormats) this.supportedFormats = options.supportedFormats;
|
||||
if (options.supportedResolutions) this.supportedResolutions = options.supportedResolutions;
|
||||
if (options.supportedColorModes) this.supportedColorModes = options.supportedColorModes;
|
||||
if (options.supportedSources) this.supportedSources = options.supportedSources;
|
||||
if (options.hasAdf !== undefined) this.hasAdf = options.hasAdf;
|
||||
if (options.hasDuplex !== undefined) this.hasDuplex = options.hasDuplex;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Connection
|
||||
// ============================================================================
|
||||
|
||||
protected async doConnect(): Promise<void> {
|
||||
if (this.protocol === 'escl') {
|
||||
this.esclClient = new EsclProtocol(this.address, this._port, this.isSecure);
|
||||
// Fetch capabilities to verify connection
|
||||
const caps = await this.esclClient.getCapabilities();
|
||||
this.updateCapabilitiesFromEscl(caps);
|
||||
} else if (this.protocol === 'sane') {
|
||||
this.saneClient = new SaneProtocol(this.address, this._port);
|
||||
await this.saneClient.connect();
|
||||
// Open the device if we have a name
|
||||
if (this.deviceName) {
|
||||
await this.saneClient.open(this.deviceName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected async doDisconnect(): Promise<void> {
|
||||
if (this.saneClient) {
|
||||
try {
|
||||
await this.saneClient.close();
|
||||
await this.saneClient.disconnect();
|
||||
} catch {
|
||||
// Ignore disconnect errors
|
||||
}
|
||||
this.saneClient = null;
|
||||
}
|
||||
this.esclClient = null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Scanning Operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get scanner capabilities
|
||||
*/
|
||||
public async getCapabilities(): Promise<IScanCapabilities> {
|
||||
return {
|
||||
resolutions: this.supportedResolutions,
|
||||
formats: this.supportedFormats,
|
||||
colorModes: this.supportedColorModes,
|
||||
sources: this.supportedSources,
|
||||
maxWidth: this.maxWidth,
|
||||
maxHeight: this.maxHeight,
|
||||
minWidth: this.minWidth,
|
||||
minHeight: this.minHeight,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a scan
|
||||
*/
|
||||
public async scan(options?: IScanOptions): Promise<IScanResult> {
|
||||
if (!this.isConnected) {
|
||||
throw new Error('Scan feature not connected');
|
||||
}
|
||||
|
||||
const opts = this.resolveOptions(options);
|
||||
|
||||
if (this.protocol === 'escl' && this.esclClient) {
|
||||
return this.scanWithEscl(opts);
|
||||
} else if (this.protocol === 'sane' && this.saneClient) {
|
||||
return this.scanWithSane(opts);
|
||||
}
|
||||
|
||||
throw new Error('No scanner protocol available');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel an ongoing scan
|
||||
*/
|
||||
public async cancelScan(): Promise<void> {
|
||||
if (this.protocol === 'sane' && this.saneClient) {
|
||||
await this.saneClient.cancel();
|
||||
}
|
||||
// eSCL cancellation is handled by deleting the job
|
||||
this.emit('scan:cancelled');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Protocol-Specific Scanning
|
||||
// ============================================================================
|
||||
|
||||
private async scanWithEscl(options: Required<IScanOptions>): Promise<IScanResult> {
|
||||
if (!this.esclClient) {
|
||||
throw new Error('eSCL client not initialized');
|
||||
}
|
||||
|
||||
this.emit('scan:started', options);
|
||||
|
||||
// Use the protocol's scan method which handles job submission,
|
||||
// waiting for completion, and downloading in one operation
|
||||
const result = await this.esclClient.scan({
|
||||
format: options.format,
|
||||
resolution: options.resolution,
|
||||
colorMode: options.colorMode,
|
||||
source: options.source,
|
||||
area: options.area,
|
||||
intent: options.intent,
|
||||
});
|
||||
|
||||
this.emit('scan:completed', result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private async scanWithSane(options: Required<IScanOptions>): Promise<IScanResult> {
|
||||
if (!this.saneClient) {
|
||||
throw new Error('SANE client not initialized');
|
||||
}
|
||||
|
||||
this.emit('scan:started', options);
|
||||
|
||||
// Use the protocol's scan method which handles option configuration,
|
||||
// parameter retrieval, and image reading in one operation
|
||||
const result = await this.saneClient.scan({
|
||||
format: options.format,
|
||||
resolution: options.resolution,
|
||||
colorMode: options.colorMode,
|
||||
source: options.source,
|
||||
area: options.area,
|
||||
});
|
||||
|
||||
this.emit('scan:completed', result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Methods
|
||||
// ============================================================================
|
||||
|
||||
private resolveOptions(options?: IScanOptions): Required<IScanOptions> {
|
||||
return {
|
||||
resolution: options?.resolution ?? 300,
|
||||
format: options?.format ?? 'jpeg',
|
||||
colorMode: options?.colorMode ?? 'color',
|
||||
source: options?.source ?? 'flatbed',
|
||||
area: options?.area ?? {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: this.maxWidth,
|
||||
height: this.maxHeight,
|
||||
},
|
||||
intent: options?.intent ?? 'document',
|
||||
quality: options?.quality ?? 85,
|
||||
};
|
||||
}
|
||||
|
||||
private updateCapabilitiesFromEscl(caps: any): void {
|
||||
if (caps.platen) {
|
||||
this.supportedResolutions = caps.platen.supportedResolutions || this.supportedResolutions;
|
||||
this.maxWidth = caps.platen.maxWidth || this.maxWidth;
|
||||
this.maxHeight = caps.platen.maxHeight || this.maxHeight;
|
||||
this.minWidth = caps.platen.minWidth || this.minWidth;
|
||||
this.minHeight = caps.platen.minHeight || this.minHeight;
|
||||
}
|
||||
if (caps.adf) {
|
||||
this.hasAdf = true;
|
||||
if (!this.supportedSources.includes('adf')) {
|
||||
this.supportedSources.push('adf');
|
||||
}
|
||||
}
|
||||
if (caps.adfDuplex) {
|
||||
this.hasDuplex = true;
|
||||
if (!this.supportedSources.includes('adf-duplex')) {
|
||||
this.supportedSources.push('adf-duplex');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private colorModeToSane(mode: TColorMode): string {
|
||||
switch (mode) {
|
||||
case 'color': return 'Color';
|
||||
case 'grayscale': return 'Gray';
|
||||
case 'blackwhite': return 'Lineart';
|
||||
default: return 'Color';
|
||||
}
|
||||
}
|
||||
|
||||
private getMimeType(format: TScanFormat): string {
|
||||
switch (format) {
|
||||
case 'jpeg': return 'image/jpeg';
|
||||
case 'png': return 'image/png';
|
||||
case 'pdf': return 'application/pdf';
|
||||
case 'tiff': return 'image/tiff';
|
||||
default: return 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Serialization
|
||||
// ============================================================================
|
||||
|
||||
public getFeatureInfo(): IScanFeatureInfo {
|
||||
return {
|
||||
...this.getBaseFeatureInfo(),
|
||||
type: 'scan',
|
||||
protocol: this.protocol,
|
||||
supportedFormats: this.supportedFormats,
|
||||
supportedResolutions: this.supportedResolutions,
|
||||
supportedColorModes: this.supportedColorModes,
|
||||
supportedSources: this.supportedSources,
|
||||
hasAdf: this.hasAdf,
|
||||
hasDuplex: this.hasDuplex,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Static Factory
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create from discovery metadata
|
||||
*/
|
||||
public static fromDiscovery(
|
||||
device: TDeviceReference,
|
||||
port: number,
|
||||
protocol: TScanProtocol,
|
||||
metadata: Record<string, unknown>
|
||||
): ScanFeature {
|
||||
const txtRecords = metadata.txtRecords as Record<string, string> ?? {};
|
||||
|
||||
return new ScanFeature(device, port, {
|
||||
protocol,
|
||||
secure: metadata.secure as boolean ?? port === 443,
|
||||
deviceName: metadata.deviceName as string,
|
||||
supportedFormats: parseFormats(txtRecords),
|
||||
supportedResolutions: parseResolutions(txtRecords),
|
||||
supportedColorModes: parseColorModes(txtRecords),
|
||||
supportedSources: parseSources(txtRecords),
|
||||
hasAdf: Boolean(metadata.hasAdf),
|
||||
hasDuplex: Boolean(metadata.hasDuplex),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Parsing Helpers
|
||||
// ============================================================================
|
||||
|
||||
function parseFormats(txt: Record<string, string>): TScanFormat[] {
|
||||
const pdl = txt['pdl'] || '';
|
||||
const formats: TScanFormat[] = [];
|
||||
if (pdl.includes('image/jpeg')) formats.push('jpeg');
|
||||
if (pdl.includes('image/png')) formats.push('png');
|
||||
if (pdl.includes('application/pdf')) formats.push('pdf');
|
||||
if (pdl.includes('image/tiff')) formats.push('tiff');
|
||||
return formats.length > 0 ? formats : ['jpeg', 'png', 'pdf'];
|
||||
}
|
||||
|
||||
function parseResolutions(txt: Record<string, string>): number[] {
|
||||
const rs = txt['rs'] || '';
|
||||
if (!rs) return [75, 150, 300, 600];
|
||||
return rs.split(',')
|
||||
.map((r) => parseInt(r.trim(), 10))
|
||||
.filter((r) => !isNaN(r) && r > 0);
|
||||
}
|
||||
|
||||
function parseColorModes(txt: Record<string, string>): TColorMode[] {
|
||||
const cs = txt['cs'] || '';
|
||||
const modes: TColorMode[] = [];
|
||||
if (cs.includes('color') || cs.includes('RGB')) modes.push('color');
|
||||
if (cs.includes('grayscale') || cs.includes('gray')) modes.push('grayscale');
|
||||
if (cs.includes('binary') || cs.includes('lineart')) modes.push('blackwhite');
|
||||
return modes.length > 0 ? modes : ['color', 'grayscale', 'blackwhite'];
|
||||
}
|
||||
|
||||
function parseSources(txt: Record<string, string>): TScanSource[] {
|
||||
const is = txt['is'] || '';
|
||||
const sources: TScanSource[] = [];
|
||||
if (is.includes('platen') || is.includes('flatbed') || !is) sources.push('flatbed');
|
||||
if (is.includes('adf') && is.includes('duplex')) sources.push('adf-duplex');
|
||||
else if (is.includes('adf')) sources.push('adf');
|
||||
return sources.length > 0 ? sources : ['flatbed'];
|
||||
}
|
||||
235
ts/features/feature.snmp.ts
Normal file
235
ts/features/feature.snmp.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* SNMP Feature
|
||||
* Provides SNMP query capability for network management
|
||||
*/
|
||||
|
||||
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
||||
import type {
|
||||
TSnmpVersion,
|
||||
ISnmpVarbind,
|
||||
ISnmpFeatureInfo,
|
||||
IFeatureOptions,
|
||||
} from '../interfaces/feature.interfaces.js';
|
||||
|
||||
/**
|
||||
* Options for creating an SnmpFeature
|
||||
*/
|
||||
export interface ISnmpFeatureOptions extends IFeatureOptions {
|
||||
version?: TSnmpVersion;
|
||||
community?: string;
|
||||
// SNMPv3 options
|
||||
username?: string;
|
||||
authProtocol?: 'md5' | 'sha';
|
||||
authKey?: string;
|
||||
privProtocol?: 'des' | 'aes';
|
||||
privKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* SNMP Feature - provides SNMP query capability
|
||||
*
|
||||
* Allows querying devices via SNMP for monitoring and management.
|
||||
* Many network devices (printers, switches, UPS) support SNMP.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const snmp = device.getFeature<SnmpFeature>('snmp');
|
||||
* if (snmp) {
|
||||
* const sysDescr = await snmp.get('1.3.6.1.2.1.1.1.0');
|
||||
* console.log(`System: ${sysDescr.value}`);
|
||||
*
|
||||
* const interfaces = await snmp.walk('1.3.6.1.2.1.2.2.1');
|
||||
* console.log(`Found ${interfaces.length} interface entries`);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class SnmpFeature extends Feature {
|
||||
public readonly type = 'snmp' as const;
|
||||
public readonly protocol = 'snmp';
|
||||
|
||||
// Configuration
|
||||
public readonly version: TSnmpVersion;
|
||||
public readonly community: string;
|
||||
|
||||
// System info (populated after connect)
|
||||
public sysDescr?: string;
|
||||
public sysObjectId?: string;
|
||||
public sysName?: string;
|
||||
public sysLocation?: string;
|
||||
public sysContact?: string;
|
||||
public sysUpTime?: number;
|
||||
|
||||
constructor(
|
||||
device: TDeviceReference,
|
||||
port: number,
|
||||
options?: ISnmpFeatureOptions
|
||||
) {
|
||||
super(device, port ?? 161, options);
|
||||
this.version = options?.version ?? 'v2c';
|
||||
this.community = options?.community ?? 'public';
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Connection
|
||||
// ============================================================================
|
||||
|
||||
protected async doConnect(): Promise<void> {
|
||||
// Test connection by getting system description
|
||||
try {
|
||||
const result = await this.get('1.3.6.1.2.1.1.1.0'); // sysDescr
|
||||
if (result) {
|
||||
this.sysDescr = String(result.value);
|
||||
}
|
||||
} catch {
|
||||
// Connection test failed
|
||||
throw new Error('SNMP connection failed');
|
||||
}
|
||||
}
|
||||
|
||||
protected async doDisconnect(): Promise<void> {
|
||||
// SNMP is connectionless, nothing to disconnect
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SNMP Operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get a single OID value
|
||||
*/
|
||||
public async get(oid: string): Promise<ISnmpVarbind> {
|
||||
// This would use the SNMP library to perform the query
|
||||
// Placeholder implementation
|
||||
return {
|
||||
oid,
|
||||
type: 0,
|
||||
value: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get multiple OID values
|
||||
*/
|
||||
public async getMultiple(oids: string[]): Promise<ISnmpVarbind[]> {
|
||||
const results: ISnmpVarbind[] = [];
|
||||
for (const oid of oids) {
|
||||
results.push(await this.get(oid));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next OID in MIB tree
|
||||
*/
|
||||
public async getNext(oid: string): Promise<ISnmpVarbind> {
|
||||
// Placeholder
|
||||
return {
|
||||
oid,
|
||||
type: 0,
|
||||
value: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a subtree of the MIB
|
||||
*/
|
||||
public async walk(baseOid: string): Promise<ISnmpVarbind[]> {
|
||||
const results: ISnmpVarbind[] = [];
|
||||
// This would iterate through the MIB tree using getNext
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk get (SNMPv2c/v3)
|
||||
*/
|
||||
public async getBulk(
|
||||
oids: string[],
|
||||
nonRepeaters: number = 0,
|
||||
maxRepetitions: number = 10
|
||||
): Promise<ISnmpVarbind[]> {
|
||||
if (this.version === 'v1') {
|
||||
throw new Error('getBulk not supported in SNMPv1');
|
||||
}
|
||||
// Placeholder
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set an OID value
|
||||
*/
|
||||
public async set(oid: string, type: number, value: unknown): Promise<ISnmpVarbind> {
|
||||
// Placeholder
|
||||
return {
|
||||
oid,
|
||||
type,
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// System Info
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get all system info OIDs
|
||||
*/
|
||||
public async getSystemInfo(): Promise<{
|
||||
sysDescr?: string;
|
||||
sysObjectId?: string;
|
||||
sysName?: string;
|
||||
sysLocation?: string;
|
||||
sysContact?: string;
|
||||
sysUpTime?: number;
|
||||
}> {
|
||||
const oids = [
|
||||
'1.3.6.1.2.1.1.1.0', // sysDescr
|
||||
'1.3.6.1.2.1.1.2.0', // sysObjectID
|
||||
'1.3.6.1.2.1.1.3.0', // sysUpTime
|
||||
'1.3.6.1.2.1.1.4.0', // sysContact
|
||||
'1.3.6.1.2.1.1.5.0', // sysName
|
||||
'1.3.6.1.2.1.1.6.0', // sysLocation
|
||||
];
|
||||
|
||||
const results = await this.getMultiple(oids);
|
||||
|
||||
return {
|
||||
sysDescr: results[0]?.value as string,
|
||||
sysObjectId: results[1]?.value as string,
|
||||
sysUpTime: results[2]?.value as number,
|
||||
sysContact: results[3]?.value as string,
|
||||
sysName: results[4]?.value as string,
|
||||
sysLocation: results[5]?.value as string,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Serialization
|
||||
// ============================================================================
|
||||
|
||||
public getFeatureInfo(): ISnmpFeatureInfo {
|
||||
return {
|
||||
...this.getBaseFeatureInfo(),
|
||||
type: 'snmp',
|
||||
version: this.version,
|
||||
community: this.version !== 'v3' ? this.community : undefined,
|
||||
sysDescr: this.sysDescr,
|
||||
sysName: this.sysName,
|
||||
sysLocation: this.sysLocation,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Static Factory
|
||||
// ============================================================================
|
||||
|
||||
public static fromDiscovery(
|
||||
device: TDeviceReference,
|
||||
port: number,
|
||||
metadata: Record<string, unknown>
|
||||
): SnmpFeature {
|
||||
return new SnmpFeature(device, port ?? 161, {
|
||||
version: metadata.version as TSnmpVersion ?? 'v2c',
|
||||
community: metadata.community as string ?? 'public',
|
||||
});
|
||||
}
|
||||
}
|
||||
256
ts/features/feature.volume.ts
Normal file
256
ts/features/feature.volume.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Volume Feature
|
||||
* Provides volume control capability
|
||||
*/
|
||||
|
||||
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
||||
import type {
|
||||
IVolumeFeatureInfo,
|
||||
IFeatureOptions,
|
||||
} from '../interfaces/feature.interfaces.js';
|
||||
|
||||
/**
|
||||
* Options for creating a VolumeFeature
|
||||
*/
|
||||
export interface IVolumeFeatureOptions extends IFeatureOptions {
|
||||
/** Volume control protocol (usually same as device protocol) */
|
||||
volumeProtocol?: string;
|
||||
/** Minimum volume level */
|
||||
minVolume?: number;
|
||||
/** Maximum volume level */
|
||||
maxVolume?: number;
|
||||
/** Volume step increment */
|
||||
volumeStep?: number;
|
||||
/** Whether mute is supported */
|
||||
supportsMute?: boolean;
|
||||
/** Protocol-specific volume controller */
|
||||
volumeController?: IVolumeController;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for protocol-specific volume control
|
||||
*/
|
||||
export interface IVolumeController {
|
||||
getVolume(): Promise<number>;
|
||||
setVolume(level: number): Promise<void>;
|
||||
getMute(): Promise<boolean>;
|
||||
setMute(muted: boolean): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Volume Feature - provides volume control capability
|
||||
*
|
||||
* Separated from PlaybackFeature because some devices have volume control
|
||||
* without playback capability (e.g., amplifiers, HDMI matrix switches).
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const volume = device.getFeature<VolumeFeature>('volume');
|
||||
* if (volume) {
|
||||
* const current = await volume.getVolume();
|
||||
* await volume.setVolume(current + 10);
|
||||
* await volume.toggleMute();
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class VolumeFeature extends Feature {
|
||||
public readonly type = 'volume' as const;
|
||||
public readonly protocol: string;
|
||||
|
||||
// Capabilities
|
||||
public readonly minVolume: number;
|
||||
public readonly maxVolume: number;
|
||||
public readonly volumeStep: number;
|
||||
public readonly supportsMute: boolean;
|
||||
|
||||
// Current state
|
||||
protected _volume: number = 0;
|
||||
protected _muted: boolean = false;
|
||||
|
||||
// Volume controller (protocol-specific)
|
||||
protected volumeController: IVolumeController | null = null;
|
||||
|
||||
constructor(
|
||||
device: TDeviceReference,
|
||||
port: number,
|
||||
options?: IVolumeFeatureOptions
|
||||
) {
|
||||
super(device, port, options);
|
||||
this.protocol = options?.volumeProtocol ?? 'generic';
|
||||
this.minVolume = options?.minVolume ?? 0;
|
||||
this.maxVolume = options?.maxVolume ?? 100;
|
||||
this.volumeStep = options?.volumeStep ?? 5;
|
||||
this.supportsMute = options?.supportsMute ?? true;
|
||||
|
||||
if (options?.volumeController) {
|
||||
this.volumeController = options.volumeController;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Properties
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get current volume level (cached)
|
||||
*/
|
||||
public get volume(): number {
|
||||
return this._volume;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mute state (cached)
|
||||
*/
|
||||
public get muted(): boolean {
|
||||
return this._muted;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Connection
|
||||
// ============================================================================
|
||||
|
||||
protected async doConnect(): Promise<void> {
|
||||
// Fetch initial state if we have a controller
|
||||
if (this.volumeController) {
|
||||
try {
|
||||
this._volume = await this.volumeController.getVolume();
|
||||
this._muted = await this.volumeController.getMute();
|
||||
} catch {
|
||||
// Ignore errors fetching initial state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected async doDisconnect(): Promise<void> {
|
||||
// Nothing to disconnect for volume control
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Volume Control
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get current volume level
|
||||
*/
|
||||
public async getVolume(): Promise<number> {
|
||||
if (this.volumeController) {
|
||||
this._volume = await this.volumeController.getVolume();
|
||||
}
|
||||
return this._volume;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set volume level
|
||||
* @param level Volume level (clamped to min/max)
|
||||
*/
|
||||
public async setVolume(level: number): Promise<void> {
|
||||
const clampedLevel = Math.max(this.minVolume, Math.min(this.maxVolume, level));
|
||||
|
||||
if (this.volumeController) {
|
||||
await this.volumeController.setVolume(clampedLevel);
|
||||
}
|
||||
|
||||
const oldVolume = this._volume;
|
||||
this._volume = clampedLevel;
|
||||
|
||||
if (oldVolume !== clampedLevel) {
|
||||
this.emit('volume:changed', { oldVolume, newVolume: clampedLevel });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Increase volume by step
|
||||
*/
|
||||
public async volumeUp(step?: number): Promise<number> {
|
||||
const increment = step ?? this.volumeStep;
|
||||
const newVolume = Math.min(this.maxVolume, this._volume + increment);
|
||||
await this.setVolume(newVolume);
|
||||
return this._volume;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrease volume by step
|
||||
*/
|
||||
public async volumeDown(step?: number): Promise<number> {
|
||||
const decrement = step ?? this.volumeStep;
|
||||
const newVolume = Math.max(this.minVolume, this._volume - decrement);
|
||||
await this.setVolume(newVolume);
|
||||
return this._volume;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mute state
|
||||
*/
|
||||
public async getMute(): Promise<boolean> {
|
||||
if (this.volumeController && this.supportsMute) {
|
||||
this._muted = await this.volumeController.getMute();
|
||||
}
|
||||
return this._muted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set mute state
|
||||
*/
|
||||
public async setMute(muted: boolean): Promise<void> {
|
||||
if (!this.supportsMute) {
|
||||
throw new Error('Mute not supported');
|
||||
}
|
||||
|
||||
if (this.volumeController) {
|
||||
await this.volumeController.setMute(muted);
|
||||
}
|
||||
|
||||
const oldMuted = this._muted;
|
||||
this._muted = muted;
|
||||
|
||||
if (oldMuted !== muted) {
|
||||
this.emit('mute:changed', { oldMuted, newMuted: muted });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle mute state
|
||||
*/
|
||||
public async toggleMute(): Promise<boolean> {
|
||||
const currentMuted = await this.getMute();
|
||||
await this.setMute(!currentMuted);
|
||||
return this._muted;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Serialization
|
||||
// ============================================================================
|
||||
|
||||
public getFeatureInfo(): IVolumeFeatureInfo {
|
||||
return {
|
||||
...this.getBaseFeatureInfo(),
|
||||
type: 'volume',
|
||||
minVolume: this.minVolume,
|
||||
maxVolume: this.maxVolume,
|
||||
volumeStep: this.volumeStep,
|
||||
supportsMute: this.supportsMute,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Static Factory
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create from discovery metadata
|
||||
*/
|
||||
public static fromDiscovery(
|
||||
device: TDeviceReference,
|
||||
port: number,
|
||||
protocol: string,
|
||||
metadata: Record<string, unknown>
|
||||
): VolumeFeature {
|
||||
return new VolumeFeature(device, port, {
|
||||
volumeProtocol: protocol,
|
||||
minVolume: metadata.minVolume as number ?? 0,
|
||||
maxVolume: metadata.maxVolume as number ?? 100,
|
||||
volumeStep: metadata.volumeStep as number ?? 5,
|
||||
supportsMute: metadata.supportsMute as boolean ?? true,
|
||||
});
|
||||
}
|
||||
}
|
||||
15
ts/features/index.ts
Normal file
15
ts/features/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Features Module
|
||||
* Exports all feature classes and types
|
||||
*/
|
||||
|
||||
// Abstract base
|
||||
export { Feature, type TDeviceReference } from './feature.abstract.js';
|
||||
|
||||
// Concrete features
|
||||
export { ScanFeature, type IScanFeatureOptions } from './feature.scan.js';
|
||||
export { PrintFeature, type IPrintFeatureOptions } from './feature.print.js';
|
||||
export { PlaybackFeature, type IPlaybackFeatureOptions } from './feature.playback.js';
|
||||
export { VolumeFeature, type IVolumeFeatureOptions, type IVolumeController } from './feature.volume.js';
|
||||
export { PowerFeature, type IPowerFeatureOptions } from './feature.power.js';
|
||||
export { SnmpFeature, type ISnmpFeatureOptions } from './feature.snmp.js';
|
||||
Reference in New Issue
Block a user