BREAKING CHANGE(core): rework core device architecture: consolidate protocols into a protocols/ module, introduce UniversalDevice + factories, and remove many legacy device-specific classes (breaking API changes)
This commit is contained in:
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@ecobridge.xyz/devicemanager',
|
||||
version: '1.1.0',
|
||||
version: '2.0.0',
|
||||
description: 'a device manager for talking to devices on network and over usb'
|
||||
}
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type {
|
||||
IDeviceInfo,
|
||||
TDeviceType,
|
||||
TDeviceStatus,
|
||||
TConnectionState,
|
||||
IRetryOptions,
|
||||
} from '../interfaces/index.js';
|
||||
import { withRetry } from '../helpers/helpers.retry.js';
|
||||
|
||||
/**
|
||||
* Abstract base class for all devices (scanners, printers)
|
||||
*/
|
||||
export abstract class Device extends plugins.events.EventEmitter {
|
||||
public readonly id: string;
|
||||
public readonly name: string;
|
||||
public readonly type: TDeviceType;
|
||||
public readonly address: string;
|
||||
public readonly port: number;
|
||||
|
||||
protected _status: TDeviceStatus = 'unknown';
|
||||
protected _connectionState: TConnectionState = 'disconnected';
|
||||
protected _lastError: Error | null = null;
|
||||
|
||||
public manufacturer?: string;
|
||||
public model?: string;
|
||||
public serialNumber?: string;
|
||||
public firmwareVersion?: string;
|
||||
|
||||
protected retryOptions: IRetryOptions;
|
||||
|
||||
constructor(info: IDeviceInfo, retryOptions?: IRetryOptions) {
|
||||
super();
|
||||
this.id = info.id;
|
||||
this.name = info.name;
|
||||
this.type = info.type;
|
||||
this.address = info.address;
|
||||
this.port = info.port;
|
||||
this._status = info.status;
|
||||
this.manufacturer = info.manufacturer;
|
||||
this.model = info.model;
|
||||
this.serialNumber = info.serialNumber;
|
||||
this.firmwareVersion = info.firmwareVersion;
|
||||
|
||||
this.retryOptions = retryOptions ?? {
|
||||
maxRetries: 5,
|
||||
baseDelay: 1000,
|
||||
maxDelay: 16000,
|
||||
multiplier: 2,
|
||||
jitter: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current device status
|
||||
*/
|
||||
public get status(): TDeviceStatus {
|
||||
return this._status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current connection state
|
||||
*/
|
||||
public get connectionState(): TConnectionState {
|
||||
return this._connectionState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last error if any
|
||||
*/
|
||||
public get lastError(): Error | null {
|
||||
return this._lastError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if device is connected
|
||||
*/
|
||||
public get isConnected(): boolean {
|
||||
return this._connectionState === 'connected';
|
||||
}
|
||||
|
||||
/**
|
||||
* Update device status
|
||||
*/
|
||||
protected setStatus(status: TDeviceStatus): void {
|
||||
if (this._status !== status) {
|
||||
const oldStatus = this._status;
|
||||
this._status = status;
|
||||
this.emit('status:changed', { oldStatus, newStatus: status });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update connection state
|
||||
*/
|
||||
protected setConnectionState(state: TConnectionState): void {
|
||||
if (this._connectionState !== state) {
|
||||
const oldState = this._connectionState;
|
||||
this._connectionState = state;
|
||||
this.emit('connection:changed', { oldState, newState: state });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set error state
|
||||
*/
|
||||
protected setError(error: Error): void {
|
||||
this._lastError = error;
|
||||
this.setStatus('error');
|
||||
this.emit('error', error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear error state
|
||||
*/
|
||||
protected clearError(): void {
|
||||
this._lastError = null;
|
||||
if (this._status === 'error') {
|
||||
this.setStatus('online');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an operation with retry logic
|
||||
*/
|
||||
protected async withRetry<T>(fn: () => Promise<T>): Promise<T> {
|
||||
return withRetry(fn, this.retryOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the device
|
||||
*/
|
||||
public async connect(): Promise<void> {
|
||||
if (this.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setConnectionState('connecting');
|
||||
this.clearError();
|
||||
|
||||
try {
|
||||
await this.withRetry(() => this.doConnect());
|
||||
this.setConnectionState('connected');
|
||||
this.setStatus('online');
|
||||
} catch (error) {
|
||||
this.setConnectionState('error');
|
||||
this.setError(error instanceof Error ? error : new Error(String(error)));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from the device
|
||||
*/
|
||||
public async disconnect(): Promise<void> {
|
||||
if (this._connectionState === 'disconnected') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.doDisconnect();
|
||||
} finally {
|
||||
this.setConnectionState('disconnected');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get device info as plain object
|
||||
*/
|
||||
public getInfo(): IDeviceInfo {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
type: this.type,
|
||||
address: this.address,
|
||||
port: this.port,
|
||||
status: this._status,
|
||||
manufacturer: this.manufacturer,
|
||||
model: this.model,
|
||||
serialNumber: this.serialNumber,
|
||||
firmwareVersion: this.firmwareVersion,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation-specific connect logic
|
||||
* Override in subclasses
|
||||
*/
|
||||
protected abstract doConnect(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Implementation-specific disconnect logic
|
||||
* Override in subclasses
|
||||
*/
|
||||
protected abstract doDisconnect(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Refresh device status
|
||||
* Override in subclasses
|
||||
*/
|
||||
public abstract refreshStatus(): Promise<void>;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { EsclProtocol } from '../scanner/scanner.classes.esclprotocol.js';
|
||||
import { EsclProtocol } from '../protocols/index.js';
|
||||
import {
|
||||
cidrToIps,
|
||||
ipRangeToIps,
|
||||
|
||||
@@ -1,527 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { Device } from '../abstract/device.abstract.js';
|
||||
import {
|
||||
UpnpSoapClient,
|
||||
UPNP_SERVICE_TYPES,
|
||||
type TDlnaTransportState,
|
||||
type IDlnaTransportInfo,
|
||||
type IDlnaPositionInfo,
|
||||
type IDlnaMediaInfo,
|
||||
} from './dlna.classes.upnp.js';
|
||||
import type { IDeviceInfo, IRetryOptions } from '../interfaces/index.js';
|
||||
import type { ISsdpDevice, ISsdpService } from '../discovery/discovery.classes.ssdp.js';
|
||||
|
||||
/**
|
||||
* DLNA Renderer device info
|
||||
*/
|
||||
export interface IDlnaRendererInfo extends IDeviceInfo {
|
||||
type: 'dlna-renderer';
|
||||
friendlyName: string;
|
||||
modelName: string;
|
||||
modelNumber?: string;
|
||||
manufacturer: string;
|
||||
udn: string;
|
||||
iconUrl?: string;
|
||||
supportsVolume: boolean;
|
||||
supportsSeek: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Playback state
|
||||
*/
|
||||
export interface IDlnaPlaybackState {
|
||||
state: TDlnaTransportState;
|
||||
volume: number;
|
||||
muted: boolean;
|
||||
currentUri: string;
|
||||
currentTrack: {
|
||||
title: string;
|
||||
artist?: string;
|
||||
album?: string;
|
||||
duration: number;
|
||||
position: number;
|
||||
albumArtUri?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* DLNA Media Renderer device
|
||||
* Represents a device that can play media (TV, speaker, etc.)
|
||||
*/
|
||||
export class DlnaRenderer extends Device {
|
||||
private soapClient: UpnpSoapClient | null = null;
|
||||
private avTransportUrl: string = '';
|
||||
private renderingControlUrl: string = '';
|
||||
private baseUrl: string = '';
|
||||
|
||||
private _friendlyName: string;
|
||||
private _modelName: string = '';
|
||||
private _modelNumber?: string;
|
||||
private _udn: string = '';
|
||||
private _iconUrl?: string;
|
||||
private _supportsVolume: boolean = true;
|
||||
private _supportsSeek: boolean = true;
|
||||
|
||||
private _currentState: TDlnaTransportState = 'STOPPED';
|
||||
private _currentVolume: number = 0;
|
||||
private _currentMuted: boolean = false;
|
||||
|
||||
constructor(
|
||||
info: IDeviceInfo,
|
||||
options: {
|
||||
friendlyName: string;
|
||||
baseUrl: string;
|
||||
avTransportUrl?: string;
|
||||
renderingControlUrl?: string;
|
||||
modelName?: string;
|
||||
modelNumber?: string;
|
||||
udn?: string;
|
||||
iconUrl?: string;
|
||||
},
|
||||
retryOptions?: IRetryOptions
|
||||
) {
|
||||
super(info, retryOptions);
|
||||
this._friendlyName = options.friendlyName;
|
||||
this.baseUrl = options.baseUrl;
|
||||
this.avTransportUrl = options.avTransportUrl || '/AVTransport/control';
|
||||
this.renderingControlUrl = options.renderingControlUrl || '/RenderingControl/control';
|
||||
this._modelName = options.modelName || '';
|
||||
this._modelNumber = options.modelNumber;
|
||||
this._udn = options.udn || '';
|
||||
this._iconUrl = options.iconUrl;
|
||||
}
|
||||
|
||||
// Getters
|
||||
public get friendlyName(): string {
|
||||
return this._friendlyName;
|
||||
}
|
||||
|
||||
public get modelName(): string {
|
||||
return this._modelName;
|
||||
}
|
||||
|
||||
public get modelNumber(): string | undefined {
|
||||
return this._modelNumber;
|
||||
}
|
||||
|
||||
public get udn(): string {
|
||||
return this._udn;
|
||||
}
|
||||
|
||||
public get iconUrl(): string | undefined {
|
||||
return this._iconUrl;
|
||||
}
|
||||
|
||||
public get supportsVolume(): boolean {
|
||||
return this._supportsVolume;
|
||||
}
|
||||
|
||||
public get supportsSeek(): boolean {
|
||||
return this._supportsSeek;
|
||||
}
|
||||
|
||||
public get currentState(): TDlnaTransportState {
|
||||
return this._currentState;
|
||||
}
|
||||
|
||||
public get currentVolume(): number {
|
||||
return this._currentVolume;
|
||||
}
|
||||
|
||||
public get currentMuted(): boolean {
|
||||
return this._currentMuted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to renderer
|
||||
*/
|
||||
protected async doConnect(): Promise<void> {
|
||||
this.soapClient = new UpnpSoapClient(this.baseUrl);
|
||||
|
||||
// Test connection by getting transport info
|
||||
try {
|
||||
await this.getTransportInfo();
|
||||
} catch (error) {
|
||||
this.soapClient = null;
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Try to get volume (may not be supported)
|
||||
try {
|
||||
this._currentVolume = await this.getVolume();
|
||||
this._supportsVolume = true;
|
||||
} catch {
|
||||
this._supportsVolume = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect
|
||||
*/
|
||||
protected async doDisconnect(): Promise<void> {
|
||||
this.soapClient = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh status
|
||||
*/
|
||||
public async refreshStatus(): Promise<void> {
|
||||
if (!this.soapClient) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
const [transport, volume, muted] = await Promise.all([
|
||||
this.getTransportInfo(),
|
||||
this._supportsVolume ? this.getVolume() : Promise.resolve(0),
|
||||
this._supportsVolume ? this.getMute() : Promise.resolve(false),
|
||||
]);
|
||||
|
||||
this._currentState = transport.state;
|
||||
this._currentVolume = volume;
|
||||
this._currentMuted = muted;
|
||||
|
||||
this.emit('status:updated', this.getDeviceInfo());
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Playback Control
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Set media URI to play
|
||||
*/
|
||||
public async setAVTransportURI(uri: string, metadata?: string): Promise<void> {
|
||||
if (!this.soapClient) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
const meta = metadata || this.soapClient.generateDidlMetadata('Media', uri);
|
||||
await this.soapClient.setAVTransportURI(this.avTransportUrl, uri, meta);
|
||||
this.emit('media:loaded', { uri });
|
||||
}
|
||||
|
||||
/**
|
||||
* Play current media
|
||||
*/
|
||||
public async play(uri?: string, metadata?: string): Promise<void> {
|
||||
if (!this.soapClient) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
if (uri) {
|
||||
await this.setAVTransportURI(uri, metadata);
|
||||
}
|
||||
|
||||
await this.soapClient.play(this.avTransportUrl);
|
||||
this._currentState = 'PLAYING';
|
||||
this.emit('playback:started');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause playback
|
||||
*/
|
||||
public async pause(): Promise<void> {
|
||||
if (!this.soapClient) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
await this.soapClient.pause(this.avTransportUrl);
|
||||
this._currentState = 'PAUSED_PLAYBACK';
|
||||
this.emit('playback:paused');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop playback
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
if (!this.soapClient) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
await this.soapClient.stop(this.avTransportUrl);
|
||||
this._currentState = 'STOPPED';
|
||||
this.emit('playback:stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek to position
|
||||
*/
|
||||
public async seek(seconds: number): Promise<void> {
|
||||
if (!this.soapClient) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
const target = this.soapClient.secondsToDuration(seconds);
|
||||
await this.soapClient.seek(this.avTransportUrl, target, 'REL_TIME');
|
||||
this.emit('playback:seeked', { position: seconds });
|
||||
}
|
||||
|
||||
/**
|
||||
* Next track
|
||||
*/
|
||||
public async next(): Promise<void> {
|
||||
if (!this.soapClient) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
await this.soapClient.next(this.avTransportUrl);
|
||||
this.emit('playback:next');
|
||||
}
|
||||
|
||||
/**
|
||||
* Previous track
|
||||
*/
|
||||
public async previous(): Promise<void> {
|
||||
if (!this.soapClient) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
await this.soapClient.previous(this.avTransportUrl);
|
||||
this.emit('playback:previous');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Volume Control
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get volume level
|
||||
*/
|
||||
public async getVolume(): Promise<number> {
|
||||
if (!this.soapClient) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
return this.soapClient.getVolume(this.renderingControlUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set volume level
|
||||
*/
|
||||
public async setVolume(level: number): Promise<void> {
|
||||
if (!this.soapClient) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
await this.soapClient.setVolume(this.renderingControlUrl, level);
|
||||
this._currentVolume = level;
|
||||
this.emit('volume:changed', { volume: level });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mute state
|
||||
*/
|
||||
public async getMute(): Promise<boolean> {
|
||||
if (!this.soapClient) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
return this.soapClient.getMute(this.renderingControlUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set mute state
|
||||
*/
|
||||
public async setMute(muted: boolean): Promise<void> {
|
||||
if (!this.soapClient) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
await this.soapClient.setMute(this.renderingControlUrl, muted);
|
||||
this._currentMuted = muted;
|
||||
this.emit('mute:changed', { muted });
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle mute
|
||||
*/
|
||||
public async toggleMute(): Promise<boolean> {
|
||||
const newMuted = !this._currentMuted;
|
||||
await this.setMute(newMuted);
|
||||
return newMuted;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Status Information
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get transport info
|
||||
*/
|
||||
public async getTransportInfo(): Promise<IDlnaTransportInfo> {
|
||||
if (!this.soapClient) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
return this.soapClient.getTransportInfo(this.avTransportUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get position info
|
||||
*/
|
||||
public async getPositionInfo(): Promise<IDlnaPositionInfo> {
|
||||
if (!this.soapClient) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
return this.soapClient.getPositionInfo(this.avTransportUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get media info
|
||||
*/
|
||||
public async getMediaInfo(): Promise<IDlnaMediaInfo> {
|
||||
if (!this.soapClient) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
return this.soapClient.getMediaInfo(this.avTransportUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full playback state
|
||||
*/
|
||||
public async getPlaybackState(): Promise<IDlnaPlaybackState> {
|
||||
if (!this.soapClient) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
const [transport, position, media, volume, muted] = await Promise.all([
|
||||
this.getTransportInfo(),
|
||||
this.getPositionInfo(),
|
||||
this.getMediaInfo(),
|
||||
this._supportsVolume ? this.getVolume() : Promise.resolve(0),
|
||||
this._supportsVolume ? this.getMute() : Promise.resolve(false),
|
||||
]);
|
||||
|
||||
// Parse metadata for track info
|
||||
const trackMeta = this.parseTrackMetadata(position.trackMetadata);
|
||||
|
||||
return {
|
||||
state: transport.state,
|
||||
volume,
|
||||
muted,
|
||||
currentUri: media.currentUri,
|
||||
currentTrack: {
|
||||
title: trackMeta.title || 'Unknown',
|
||||
artist: trackMeta.artist,
|
||||
album: trackMeta.album,
|
||||
duration: this.soapClient.durationToSeconds(position.trackDuration),
|
||||
position: this.soapClient.durationToSeconds(position.relativeTime),
|
||||
albumArtUri: trackMeta.albumArtUri,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse track metadata from DIDL-Lite
|
||||
*/
|
||||
private parseTrackMetadata(metadata: string): {
|
||||
title?: string;
|
||||
artist?: string;
|
||||
album?: string;
|
||||
albumArtUri?: string;
|
||||
} {
|
||||
if (!metadata) return {};
|
||||
|
||||
const extractTag = (xml: string, tag: string): string | undefined => {
|
||||
const regex = new RegExp(`<(?:[^:]*:)?${tag}[^>]*>([^<]*)<\/(?:[^:]*:)?${tag}>`, 'i');
|
||||
const match = xml.match(regex);
|
||||
return match ? match[1].trim() : undefined;
|
||||
};
|
||||
|
||||
return {
|
||||
title: extractTag(metadata, 'title'),
|
||||
artist: extractTag(metadata, 'creator') || extractTag(metadata, 'artist'),
|
||||
album: extractTag(metadata, 'album'),
|
||||
albumArtUri: extractTag(metadata, 'albumArtURI'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get device info
|
||||
*/
|
||||
public getDeviceInfo(): IDlnaRendererInfo {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
type: 'dlna-renderer',
|
||||
address: this.address,
|
||||
port: this.port,
|
||||
status: this.status,
|
||||
friendlyName: this._friendlyName,
|
||||
modelName: this._modelName,
|
||||
modelNumber: this._modelNumber,
|
||||
manufacturer: this.manufacturer || '',
|
||||
udn: this._udn,
|
||||
iconUrl: this._iconUrl,
|
||||
supportsVolume: this._supportsVolume,
|
||||
supportsSeek: this._supportsSeek,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from SSDP discovery
|
||||
*/
|
||||
public static fromSsdpDevice(
|
||||
ssdpDevice: ISsdpDevice,
|
||||
retryOptions?: IRetryOptions
|
||||
): DlnaRenderer | null {
|
||||
if (!ssdpDevice.description) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const desc = ssdpDevice.description;
|
||||
|
||||
// Find AVTransport and RenderingControl URLs
|
||||
const avTransport = desc.services.find((s) =>
|
||||
s.serviceType.includes('AVTransport')
|
||||
);
|
||||
const renderingControl = desc.services.find((s) =>
|
||||
s.serviceType.includes('RenderingControl')
|
||||
);
|
||||
|
||||
if (!avTransport) {
|
||||
return null; // Not a media renderer
|
||||
}
|
||||
|
||||
// Build base URL
|
||||
const baseUrl = new URL(ssdpDevice.location);
|
||||
const baseUrlStr = `${baseUrl.protocol}//${baseUrl.host}`;
|
||||
|
||||
// Get icon URL
|
||||
let iconUrl: string | undefined;
|
||||
if (desc.icons && desc.icons.length > 0) {
|
||||
const bestIcon = desc.icons.sort((a, b) => b.width - a.width)[0];
|
||||
iconUrl = bestIcon.url.startsWith('http')
|
||||
? bestIcon.url
|
||||
: `${baseUrlStr}${bestIcon.url}`;
|
||||
}
|
||||
|
||||
const info: IDeviceInfo = {
|
||||
id: `dlna-renderer:${desc.UDN}`,
|
||||
name: desc.friendlyName,
|
||||
type: 'dlna-renderer',
|
||||
address: ssdpDevice.address,
|
||||
port: ssdpDevice.port,
|
||||
status: 'unknown',
|
||||
manufacturer: desc.manufacturer,
|
||||
model: desc.modelName,
|
||||
};
|
||||
|
||||
return new DlnaRenderer(
|
||||
info,
|
||||
{
|
||||
friendlyName: desc.friendlyName,
|
||||
baseUrl: baseUrlStr,
|
||||
avTransportUrl: avTransport.controlURL,
|
||||
renderingControlUrl: renderingControl?.controlURL,
|
||||
modelName: desc.modelName,
|
||||
modelNumber: desc.modelNumber,
|
||||
udn: desc.UDN,
|
||||
iconUrl,
|
||||
},
|
||||
retryOptions
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,468 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { Device } from '../abstract/device.abstract.js';
|
||||
import {
|
||||
UpnpSoapClient,
|
||||
UPNP_SERVICE_TYPES,
|
||||
type IDlnaContentItem,
|
||||
type IDlnaBrowseResult,
|
||||
} from './dlna.classes.upnp.js';
|
||||
import type { IDeviceInfo, IRetryOptions } from '../interfaces/index.js';
|
||||
import type { ISsdpDevice, ISsdpService } from '../discovery/discovery.classes.ssdp.js';
|
||||
|
||||
/**
|
||||
* DLNA Server device info
|
||||
*/
|
||||
export interface IDlnaServerInfo extends IDeviceInfo {
|
||||
type: 'dlna-server';
|
||||
friendlyName: string;
|
||||
modelName: string;
|
||||
modelNumber?: string;
|
||||
manufacturer: string;
|
||||
udn: string;
|
||||
iconUrl?: string;
|
||||
contentCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content directory statistics
|
||||
*/
|
||||
export interface IDlnaServerStats {
|
||||
totalItems: number;
|
||||
audioItems: number;
|
||||
videoItems: number;
|
||||
imageItems: number;
|
||||
containers: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* DLNA Media Server device
|
||||
* Represents a device that serves media content (NAS, media library, etc.)
|
||||
*/
|
||||
export class DlnaServer extends Device {
|
||||
private soapClient: UpnpSoapClient | null = null;
|
||||
private contentDirectoryUrl: string = '';
|
||||
private baseUrl: string = '';
|
||||
|
||||
private _friendlyName: string;
|
||||
private _modelName: string = '';
|
||||
private _modelNumber?: string;
|
||||
private _udn: string = '';
|
||||
private _iconUrl?: string;
|
||||
private _contentCount?: number;
|
||||
|
||||
constructor(
|
||||
info: IDeviceInfo,
|
||||
options: {
|
||||
friendlyName: string;
|
||||
baseUrl: string;
|
||||
contentDirectoryUrl?: string;
|
||||
modelName?: string;
|
||||
modelNumber?: string;
|
||||
udn?: string;
|
||||
iconUrl?: string;
|
||||
},
|
||||
retryOptions?: IRetryOptions
|
||||
) {
|
||||
super(info, retryOptions);
|
||||
this._friendlyName = options.friendlyName;
|
||||
this.baseUrl = options.baseUrl;
|
||||
this.contentDirectoryUrl = options.contentDirectoryUrl || '/ContentDirectory/control';
|
||||
this._modelName = options.modelName || '';
|
||||
this._modelNumber = options.modelNumber;
|
||||
this._udn = options.udn || '';
|
||||
this._iconUrl = options.iconUrl;
|
||||
}
|
||||
|
||||
// Getters
|
||||
public get friendlyName(): string {
|
||||
return this._friendlyName;
|
||||
}
|
||||
|
||||
public get modelName(): string {
|
||||
return this._modelName;
|
||||
}
|
||||
|
||||
public get modelNumber(): string | undefined {
|
||||
return this._modelNumber;
|
||||
}
|
||||
|
||||
public get udn(): string {
|
||||
return this._udn;
|
||||
}
|
||||
|
||||
public get iconUrl(): string | undefined {
|
||||
return this._iconUrl;
|
||||
}
|
||||
|
||||
public get contentCount(): number | undefined {
|
||||
return this._contentCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to server
|
||||
*/
|
||||
protected async doConnect(): Promise<void> {
|
||||
this.soapClient = new UpnpSoapClient(this.baseUrl);
|
||||
|
||||
// Test connection by browsing root
|
||||
try {
|
||||
const root = await this.browse('0', 0, 1);
|
||||
this._contentCount = root.totalMatches;
|
||||
} catch (error) {
|
||||
this.soapClient = null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect
|
||||
*/
|
||||
protected async doDisconnect(): Promise<void> {
|
||||
this.soapClient = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh status
|
||||
*/
|
||||
public async refreshStatus(): Promise<void> {
|
||||
if (!this.soapClient) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
const root = await this.browse('0', 0, 1);
|
||||
this._contentCount = root.totalMatches;
|
||||
|
||||
this.emit('status:updated', this.getDeviceInfo());
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Content Directory Browsing
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Browse content directory
|
||||
*/
|
||||
public async browse(
|
||||
objectId: string = '0',
|
||||
startIndex: number = 0,
|
||||
requestCount: number = 100
|
||||
): Promise<IDlnaBrowseResult> {
|
||||
if (!this.soapClient) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
return this.soapClient.browse(
|
||||
this.contentDirectoryUrl,
|
||||
objectId,
|
||||
'BrowseDirectChildren',
|
||||
'*',
|
||||
startIndex,
|
||||
requestCount
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metadata for a specific item
|
||||
*/
|
||||
public async getMetadata(objectId: string): Promise<IDlnaContentItem | null> {
|
||||
if (!this.soapClient) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
const result = await this.soapClient.browse(
|
||||
this.contentDirectoryUrl,
|
||||
objectId,
|
||||
'BrowseMetadata',
|
||||
'*',
|
||||
0,
|
||||
1
|
||||
);
|
||||
|
||||
return result.items[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search content directory
|
||||
*/
|
||||
public async search(
|
||||
containerId: string,
|
||||
searchCriteria: string,
|
||||
startIndex: number = 0,
|
||||
requestCount: number = 100
|
||||
): Promise<IDlnaBrowseResult> {
|
||||
if (!this.soapClient) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
return this.soapClient.search(
|
||||
this.contentDirectoryUrl,
|
||||
containerId,
|
||||
searchCriteria,
|
||||
'*',
|
||||
startIndex,
|
||||
requestCount
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse all items recursively (up to limit)
|
||||
*/
|
||||
public async browseAll(
|
||||
objectId: string = '0',
|
||||
limit: number = 1000
|
||||
): Promise<IDlnaContentItem[]> {
|
||||
const allItems: IDlnaContentItem[] = [];
|
||||
let startIndex = 0;
|
||||
const batchSize = 100;
|
||||
|
||||
while (allItems.length < limit) {
|
||||
const result = await this.browse(objectId, startIndex, batchSize);
|
||||
allItems.push(...result.items);
|
||||
|
||||
if (result.items.length < batchSize || allItems.length >= result.totalMatches) {
|
||||
break;
|
||||
}
|
||||
|
||||
startIndex += result.items.length;
|
||||
}
|
||||
|
||||
return allItems.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get content statistics
|
||||
*/
|
||||
public async getStats(): Promise<IDlnaServerStats> {
|
||||
const stats: IDlnaServerStats = {
|
||||
totalItems: 0,
|
||||
audioItems: 0,
|
||||
videoItems: 0,
|
||||
imageItems: 0,
|
||||
containers: 0,
|
||||
};
|
||||
|
||||
// Browse root to get counts
|
||||
const root = await this.browseAll('0', 500);
|
||||
|
||||
for (const item of root) {
|
||||
stats.totalItems++;
|
||||
|
||||
if (item.class.includes('container')) {
|
||||
stats.containers++;
|
||||
} else if (item.class.includes('audioItem')) {
|
||||
stats.audioItems++;
|
||||
} else if (item.class.includes('videoItem')) {
|
||||
stats.videoItems++;
|
||||
} else if (item.class.includes('imageItem')) {
|
||||
stats.imageItems++;
|
||||
}
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Content Access
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get stream URL for content item
|
||||
*/
|
||||
public getStreamUrl(item: IDlnaContentItem): string | null {
|
||||
if (!item.res || item.res.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Return first resource URL
|
||||
return item.res[0].url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get best quality stream URL
|
||||
*/
|
||||
public getBestStreamUrl(item: IDlnaContentItem, preferredType?: string): string | null {
|
||||
if (!item.res || item.res.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sort by bitrate (highest first)
|
||||
const sorted = [...item.res].sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0));
|
||||
|
||||
// If preferred type specified, try to find matching
|
||||
if (preferredType) {
|
||||
const preferred = sorted.find((r) =>
|
||||
r.protocolInfo.toLowerCase().includes(preferredType.toLowerCase())
|
||||
);
|
||||
if (preferred) return preferred.url;
|
||||
}
|
||||
|
||||
return sorted[0].url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get album art URL for item
|
||||
*/
|
||||
public getAlbumArtUrl(item: IDlnaContentItem): string | null {
|
||||
if (item.albumArtUri) {
|
||||
// Resolve relative URLs
|
||||
if (!item.albumArtUri.startsWith('http')) {
|
||||
return `${this.baseUrl}${item.albumArtUri}`;
|
||||
}
|
||||
return item.albumArtUri;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Search Helpers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Search for audio items by title
|
||||
*/
|
||||
public async searchAudio(
|
||||
title: string,
|
||||
startIndex: number = 0,
|
||||
requestCount: number = 100
|
||||
): Promise<IDlnaBrowseResult> {
|
||||
const criteria = `dc:title contains "${title}" and upnp:class derivedfrom "object.item.audioItem"`;
|
||||
return this.search('0', criteria, startIndex, requestCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for video items by title
|
||||
*/
|
||||
public async searchVideo(
|
||||
title: string,
|
||||
startIndex: number = 0,
|
||||
requestCount: number = 100
|
||||
): Promise<IDlnaBrowseResult> {
|
||||
const criteria = `dc:title contains "${title}" and upnp:class derivedfrom "object.item.videoItem"`;
|
||||
return this.search('0', criteria, startIndex, requestCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search by artist
|
||||
*/
|
||||
public async searchByArtist(
|
||||
artist: string,
|
||||
startIndex: number = 0,
|
||||
requestCount: number = 100
|
||||
): Promise<IDlnaBrowseResult> {
|
||||
const criteria = `dc:creator contains "${artist}" or upnp:artist contains "${artist}"`;
|
||||
return this.search('0', criteria, startIndex, requestCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search by album
|
||||
*/
|
||||
public async searchByAlbum(
|
||||
album: string,
|
||||
startIndex: number = 0,
|
||||
requestCount: number = 100
|
||||
): Promise<IDlnaBrowseResult> {
|
||||
const criteria = `upnp:album contains "${album}"`;
|
||||
return this.search('0', criteria, startIndex, requestCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search by genre
|
||||
*/
|
||||
public async searchByGenre(
|
||||
genre: string,
|
||||
startIndex: number = 0,
|
||||
requestCount: number = 100
|
||||
): Promise<IDlnaBrowseResult> {
|
||||
const criteria = `upnp:genre contains "${genre}"`;
|
||||
return this.search('0', criteria, startIndex, requestCount);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Device Info
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get device info
|
||||
*/
|
||||
public getDeviceInfo(): IDlnaServerInfo {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
type: 'dlna-server',
|
||||
address: this.address,
|
||||
port: this.port,
|
||||
status: this.status,
|
||||
friendlyName: this._friendlyName,
|
||||
modelName: this._modelName,
|
||||
modelNumber: this._modelNumber,
|
||||
manufacturer: this.manufacturer || '',
|
||||
udn: this._udn,
|
||||
iconUrl: this._iconUrl,
|
||||
contentCount: this._contentCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from SSDP discovery
|
||||
*/
|
||||
public static fromSsdpDevice(
|
||||
ssdpDevice: ISsdpDevice,
|
||||
retryOptions?: IRetryOptions
|
||||
): DlnaServer | null {
|
||||
if (!ssdpDevice.description) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const desc = ssdpDevice.description;
|
||||
|
||||
// Find ContentDirectory URL
|
||||
const contentDirectory = desc.services.find((s) =>
|
||||
s.serviceType.includes('ContentDirectory')
|
||||
);
|
||||
|
||||
if (!contentDirectory) {
|
||||
return null; // Not a media server
|
||||
}
|
||||
|
||||
// Build base URL
|
||||
const baseUrl = new URL(ssdpDevice.location);
|
||||
const baseUrlStr = `${baseUrl.protocol}//${baseUrl.host}`;
|
||||
|
||||
// Get icon URL
|
||||
let iconUrl: string | undefined;
|
||||
if (desc.icons && desc.icons.length > 0) {
|
||||
const bestIcon = desc.icons.sort((a, b) => b.width - a.width)[0];
|
||||
iconUrl = bestIcon.url.startsWith('http')
|
||||
? bestIcon.url
|
||||
: `${baseUrlStr}${bestIcon.url}`;
|
||||
}
|
||||
|
||||
const info: IDeviceInfo = {
|
||||
id: `dlna-server:${desc.UDN}`,
|
||||
name: desc.friendlyName,
|
||||
type: 'dlna-server',
|
||||
address: ssdpDevice.address,
|
||||
port: ssdpDevice.port,
|
||||
status: 'unknown',
|
||||
manufacturer: desc.manufacturer,
|
||||
model: desc.modelName,
|
||||
};
|
||||
|
||||
return new DlnaServer(
|
||||
info,
|
||||
{
|
||||
friendlyName: desc.friendlyName,
|
||||
baseUrl: baseUrlStr,
|
||||
contentDirectoryUrl: contentDirectory.controlURL,
|
||||
modelName: desc.modelName,
|
||||
modelNumber: desc.modelNumber,
|
||||
udn: desc.UDN,
|
||||
iconUrl,
|
||||
},
|
||||
retryOptions
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export content types
|
||||
export type { IDlnaContentItem, IDlnaBrowseResult } from './dlna.classes.upnp.js';
|
||||
369
ts/factories/index.ts
Normal file
369
ts/factories/index.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* Device Factory Functions
|
||||
* Create UniversalDevice instances with appropriate features
|
||||
*/
|
||||
|
||||
import { UniversalDevice, type IDeviceCreateOptions } from '../device/device.classes.device.js';
|
||||
import { ScanFeature, type IScanFeatureOptions } from '../features/feature.scan.js';
|
||||
import { PrintFeature, type IPrintFeatureOptions } from '../features/feature.print.js';
|
||||
import { PlaybackFeature, type IPlaybackFeatureOptions } from '../features/feature.playback.js';
|
||||
import { VolumeFeature, type IVolumeFeatureOptions } from '../features/feature.volume.js';
|
||||
import { PowerFeature, type IPowerFeatureOptions } from '../features/feature.power.js';
|
||||
import { SnmpFeature, type ISnmpFeatureOptions } from '../features/feature.snmp.js';
|
||||
import type {
|
||||
TScannerProtocol,
|
||||
TScanFormat,
|
||||
TColorMode,
|
||||
TScanSource,
|
||||
IRetryOptions,
|
||||
} from '../interfaces/index.js';
|
||||
import type { TPrintProtocol } from '../interfaces/feature.interfaces.js';
|
||||
|
||||
// ============================================================================
|
||||
// Scanner Factory
|
||||
// ============================================================================
|
||||
|
||||
export interface IScannerDiscoveryInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
port: number;
|
||||
protocol: TScannerProtocol | 'ipp';
|
||||
txtRecords: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a scanner device (UniversalDevice with ScanFeature)
|
||||
*/
|
||||
export function createScanner(
|
||||
info: IScannerDiscoveryInfo,
|
||||
retryOptions?: IRetryOptions
|
||||
): UniversalDevice {
|
||||
const protocol = info.protocol === 'ipp' ? 'escl' : info.protocol;
|
||||
const isSecure = info.txtRecords['TLS'] === '1' || (protocol === 'escl' && info.port === 443);
|
||||
|
||||
// Parse capabilities from TXT records
|
||||
const formats = parseScanFormats(info.txtRecords);
|
||||
const resolutions = parseScanResolutions(info.txtRecords);
|
||||
const colorModes = parseScanColorModes(info.txtRecords);
|
||||
const sources = parseScanSources(info.txtRecords);
|
||||
|
||||
const device = new UniversalDevice(info.address, info.port, {
|
||||
name: info.name,
|
||||
manufacturer: info.txtRecords['usb_MFG'] || info.txtRecords['mfg'],
|
||||
model: info.txtRecords['usb_MDL'] || info.txtRecords['mdl'] || info.txtRecords['ty'],
|
||||
retryOptions,
|
||||
});
|
||||
|
||||
// Override the generated ID with discovery ID
|
||||
(device as { id: string }).id = info.id;
|
||||
|
||||
// Add scan feature
|
||||
const scanFeature = new ScanFeature(device.getDeviceReference(), info.port, {
|
||||
protocol: protocol as 'escl' | 'sane',
|
||||
secure: isSecure,
|
||||
supportedFormats: formats,
|
||||
supportedResolutions: resolutions,
|
||||
supportedColorModes: colorModes,
|
||||
supportedSources: sources,
|
||||
hasAdf: sources.includes('adf') || sources.includes('adf-duplex'),
|
||||
hasDuplex: sources.includes('adf-duplex'),
|
||||
});
|
||||
|
||||
device.addFeature(scanFeature);
|
||||
return device;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Printer Factory
|
||||
// ============================================================================
|
||||
|
||||
export interface IPrinterDiscoveryInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
port: number;
|
||||
txtRecords: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a printer device (UniversalDevice with PrintFeature)
|
||||
*/
|
||||
export function createPrinter(
|
||||
info: IPrinterDiscoveryInfo,
|
||||
retryOptions?: IRetryOptions
|
||||
): UniversalDevice {
|
||||
const ippPath = info.txtRecords['rp'] || info.txtRecords['rfo'] || '/ipp/print';
|
||||
const uri = `ipp://${info.address}:${info.port}${ippPath.startsWith('/') ? '' : '/'}${ippPath}`;
|
||||
|
||||
const device = new UniversalDevice(info.address, info.port, {
|
||||
name: info.name,
|
||||
manufacturer: info.txtRecords['usb_MFG'] || info.txtRecords['mfg'],
|
||||
model: info.txtRecords['usb_MDL'] || info.txtRecords['mdl'] || info.txtRecords['ty'],
|
||||
retryOptions,
|
||||
});
|
||||
|
||||
// Override the generated ID with discovery ID
|
||||
(device as { id: string }).id = info.id;
|
||||
|
||||
// Add print feature
|
||||
const printFeature = new PrintFeature(device.getDeviceReference(), info.port, {
|
||||
protocol: 'ipp',
|
||||
uri,
|
||||
supportsColor: info.txtRecords['Color'] === 'T' || info.txtRecords['color'] === 'true',
|
||||
supportsDuplex: info.txtRecords['Duplex'] === 'T' || info.txtRecords['duplex'] === 'true',
|
||||
});
|
||||
|
||||
device.addFeature(printFeature);
|
||||
return device;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SNMP Device Factory
|
||||
// ============================================================================
|
||||
|
||||
export interface ISnmpDiscoveryInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
port: number;
|
||||
community?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an SNMP device (UniversalDevice with SnmpFeature)
|
||||
*/
|
||||
export function createSnmpDevice(
|
||||
info: ISnmpDiscoveryInfo,
|
||||
retryOptions?: IRetryOptions
|
||||
): UniversalDevice {
|
||||
const device = new UniversalDevice(info.address, info.port, {
|
||||
name: info.name,
|
||||
retryOptions,
|
||||
});
|
||||
|
||||
// Override the generated ID with discovery ID
|
||||
(device as { id: string }).id = info.id;
|
||||
|
||||
// Add SNMP feature
|
||||
const snmpFeature = new SnmpFeature(device.getDeviceReference(), info.port, {
|
||||
community: info.community ?? 'public',
|
||||
});
|
||||
|
||||
device.addFeature(snmpFeature);
|
||||
return device;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// UPS Device Factory
|
||||
// ============================================================================
|
||||
|
||||
export interface IUpsDiscoveryInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
port: number;
|
||||
protocol: 'nut' | 'snmp';
|
||||
upsName?: string;
|
||||
community?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a UPS device (UniversalDevice with PowerFeature)
|
||||
*/
|
||||
export function createUpsDevice(
|
||||
info: IUpsDiscoveryInfo,
|
||||
retryOptions?: IRetryOptions
|
||||
): UniversalDevice {
|
||||
const device = new UniversalDevice(info.address, info.port, {
|
||||
name: info.name,
|
||||
retryOptions,
|
||||
});
|
||||
|
||||
// Override the generated ID with discovery ID
|
||||
(device as { id: string }).id = info.id;
|
||||
|
||||
// Add power feature
|
||||
const powerFeature = new PowerFeature(device.getDeviceReference(), info.port, {
|
||||
protocol: info.protocol,
|
||||
upsName: info.upsName,
|
||||
community: info.community,
|
||||
});
|
||||
|
||||
device.addFeature(powerFeature);
|
||||
return device;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Speaker Factory
|
||||
// ============================================================================
|
||||
|
||||
export interface ISpeakerDiscoveryInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
port: number;
|
||||
protocol: 'sonos' | 'airplay' | 'chromecast' | 'dlna';
|
||||
roomName?: string;
|
||||
modelName?: string;
|
||||
features?: number; // AirPlay feature flags
|
||||
deviceId?: string;
|
||||
friendlyName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a speaker device (UniversalDevice with PlaybackFeature and VolumeFeature)
|
||||
*/
|
||||
export function createSpeaker(
|
||||
info: ISpeakerDiscoveryInfo,
|
||||
retryOptions?: IRetryOptions
|
||||
): UniversalDevice {
|
||||
const device = new UniversalDevice(info.address, info.port, {
|
||||
name: info.name,
|
||||
model: info.modelName,
|
||||
retryOptions,
|
||||
});
|
||||
|
||||
// Override the generated ID with discovery ID
|
||||
(device as { id: string }).id = info.id;
|
||||
|
||||
// Add playback feature
|
||||
const playbackFeature = new PlaybackFeature(device.getDeviceReference(), info.port, {
|
||||
protocol: info.protocol,
|
||||
supportsQueue: info.protocol === 'sonos',
|
||||
supportsSeek: info.protocol !== 'airplay',
|
||||
});
|
||||
|
||||
device.addFeature(playbackFeature);
|
||||
|
||||
// Add volume feature
|
||||
const volumeFeature = new VolumeFeature(device.getDeviceReference(), info.port, {
|
||||
volumeProtocol: info.protocol,
|
||||
minVolume: 0,
|
||||
maxVolume: 100,
|
||||
supportsMute: true,
|
||||
});
|
||||
|
||||
device.addFeature(volumeFeature);
|
||||
|
||||
return device;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DLNA Factory
|
||||
// ============================================================================
|
||||
|
||||
export interface IDlnaRendererDiscoveryInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
port: number;
|
||||
controlUrl: string;
|
||||
friendlyName: string;
|
||||
modelName?: string;
|
||||
manufacturer?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a DLNA renderer device (UniversalDevice with PlaybackFeature)
|
||||
*/
|
||||
export function createDlnaRenderer(
|
||||
info: IDlnaRendererDiscoveryInfo,
|
||||
retryOptions?: IRetryOptions
|
||||
): UniversalDevice {
|
||||
const device = new UniversalDevice(info.address, info.port, {
|
||||
name: info.friendlyName || info.name,
|
||||
manufacturer: info.manufacturer,
|
||||
model: info.modelName,
|
||||
retryOptions,
|
||||
});
|
||||
|
||||
// Override the generated ID with discovery ID
|
||||
(device as { id: string }).id = info.id;
|
||||
|
||||
// Add playback feature for DLNA
|
||||
const playbackFeature = new PlaybackFeature(device.getDeviceReference(), info.port, {
|
||||
protocol: 'dlna',
|
||||
supportsQueue: false,
|
||||
supportsSeek: true,
|
||||
});
|
||||
|
||||
device.addFeature(playbackFeature);
|
||||
|
||||
// Add volume feature
|
||||
const volumeFeature = new VolumeFeature(device.getDeviceReference(), info.port, {
|
||||
volumeProtocol: 'dlna',
|
||||
minVolume: 0,
|
||||
maxVolume: 100,
|
||||
supportsMute: true,
|
||||
});
|
||||
|
||||
device.addFeature(volumeFeature);
|
||||
|
||||
return device;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Parsing Helpers
|
||||
// ============================================================================
|
||||
|
||||
function parseScanFormats(txtRecords: Record<string, string>): TScanFormat[] {
|
||||
const formats: TScanFormat[] = [];
|
||||
const pdl = txtRecords['pdl'] || txtRecords['DocumentFormat'] || '';
|
||||
|
||||
if (pdl.includes('jpeg') || pdl.includes('jpg')) formats.push('jpeg');
|
||||
if (pdl.includes('png')) formats.push('png');
|
||||
if (pdl.includes('pdf')) formats.push('pdf');
|
||||
if (pdl.includes('tiff')) formats.push('tiff');
|
||||
|
||||
return formats.length > 0 ? formats : ['jpeg', 'png'];
|
||||
}
|
||||
|
||||
function parseScanResolutions(txtRecords: Record<string, string>): number[] {
|
||||
const rs = txtRecords['rs'] || '';
|
||||
const parts = rs.split(',').map((s) => parseInt(s.trim())).filter((n) => !isNaN(n) && n > 0);
|
||||
return parts.length > 0 ? parts : [75, 150, 300, 600];
|
||||
}
|
||||
|
||||
function parseScanColorModes(txtRecords: Record<string, string>): TColorMode[] {
|
||||
const cs = txtRecords['cs'] || txtRecords['ColorSpace'] || '';
|
||||
const modes: TColorMode[] = [];
|
||||
|
||||
if (cs.includes('color') || cs.includes('RGB')) modes.push('color');
|
||||
if (cs.includes('gray') || cs.includes('grayscale')) modes.push('grayscale');
|
||||
if (cs.includes('binary') || cs.includes('bw')) modes.push('blackwhite');
|
||||
|
||||
return modes.length > 0 ? modes : ['color', 'grayscale'];
|
||||
}
|
||||
|
||||
function parseScanSources(txtRecords: Record<string, string>): TScanSource[] {
|
||||
const is = txtRecords['is'] || txtRecords['InputSource'] || '';
|
||||
const sources: TScanSource[] = [];
|
||||
|
||||
if (is.includes('platen') || is.includes('flatbed') || is === '') {
|
||||
sources.push('flatbed');
|
||||
}
|
||||
if (is.includes('adf') || is.includes('feeder')) {
|
||||
sources.push('adf');
|
||||
}
|
||||
if (is.includes('duplex')) {
|
||||
sources.push('adf-duplex');
|
||||
}
|
||||
|
||||
return sources.length > 0 ? sources : ['flatbed'];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Exports
|
||||
// ============================================================================
|
||||
|
||||
export {
|
||||
// Re-export device and feature types for convenience
|
||||
UniversalDevice,
|
||||
ScanFeature,
|
||||
PrintFeature,
|
||||
PlaybackFeature,
|
||||
VolumeFeature,
|
||||
PowerFeature,
|
||||
SnmpFeature,
|
||||
};
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
||||
import { IppProtocol } from '../printer/printer.classes.ippprotocol.js';
|
||||
import { IppProtocol } from '../protocols/index.js';
|
||||
import type {
|
||||
TPrintProtocol,
|
||||
TPrintSides,
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
*/
|
||||
|
||||
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 { EsclProtocol, SaneProtocol } from '../protocols/index.js';
|
||||
import type {
|
||||
TScanProtocol,
|
||||
TScanFormat,
|
||||
|
||||
172
ts/index.ts
172
ts/index.ts
@@ -4,31 +4,24 @@
|
||||
* Supports: Scanners, Printers, SNMP devices, UPS, DLNA, Sonos, AirPlay, Chromecast
|
||||
*/
|
||||
|
||||
// Main exports from DeviceManager
|
||||
// ============================================================================
|
||||
// Core Device Manager
|
||||
// ============================================================================
|
||||
|
||||
export {
|
||||
DeviceManager,
|
||||
MdnsDiscovery,
|
||||
NetworkScanner,
|
||||
SsdpDiscovery,
|
||||
Scanner,
|
||||
Printer,
|
||||
SnmpDevice,
|
||||
UpsDevice,
|
||||
DlnaRenderer,
|
||||
DlnaServer,
|
||||
Speaker,
|
||||
SonosSpeaker,
|
||||
AirPlaySpeaker,
|
||||
ChromecastSpeaker,
|
||||
SERVICE_TYPES,
|
||||
SSDP_SERVICE_TYPES,
|
||||
} from './devicemanager.classes.devicemanager.js';
|
||||
|
||||
// Abstract/base classes
|
||||
export { Device } from './abstract/device.abstract.js';
|
||||
// ============================================================================
|
||||
// Universal Device & Features
|
||||
// ============================================================================
|
||||
|
||||
// Universal Device & Features (new architecture)
|
||||
export { UniversalDevice } from './device/device.classes.device.js';
|
||||
export { UniversalDevice, type IUniversalDeviceInfo, type IDeviceCreateOptions } from './device/device.classes.device.js';
|
||||
export {
|
||||
Feature,
|
||||
ScanFeature,
|
||||
@@ -47,34 +40,66 @@ export {
|
||||
type ISnmpFeatureOptions,
|
||||
} from './features/index.js';
|
||||
|
||||
// Scanner protocol implementations
|
||||
export { EsclProtocol } from './scanner/scanner.classes.esclprotocol.js';
|
||||
export { SaneProtocol } from './scanner/scanner.classes.saneprotocol.js';
|
||||
// ============================================================================
|
||||
// Device Factories
|
||||
// ============================================================================
|
||||
|
||||
// Printer protocol
|
||||
export { IppProtocol } from './printer/printer.classes.ippprotocol.js';
|
||||
|
||||
// SNMP protocol
|
||||
export { SnmpProtocol, SNMP_OIDS } from './snmp/snmp.classes.snmpprotocol.js';
|
||||
|
||||
// UPS protocols
|
||||
export { NutProtocol, NUT_COMMANDS, NUT_VARIABLES } from './ups/ups.classes.nutprotocol.js';
|
||||
export { UpsSnmpHandler, UPS_SNMP_OIDS } from './ups/ups.classes.upssnmp.js';
|
||||
|
||||
// DLNA/UPnP protocol
|
||||
export {
|
||||
createScanner,
|
||||
createPrinter,
|
||||
createSnmpDevice,
|
||||
createUpsDevice,
|
||||
createSpeaker,
|
||||
createDlnaRenderer,
|
||||
type IScannerDiscoveryInfo,
|
||||
type IPrinterDiscoveryInfo,
|
||||
type ISnmpDiscoveryInfo,
|
||||
type IUpsDiscoveryInfo,
|
||||
type ISpeakerDiscoveryInfo,
|
||||
type IDlnaRendererDiscoveryInfo,
|
||||
} from './factories/index.js';
|
||||
|
||||
// ============================================================================
|
||||
// Protocol Implementations
|
||||
// ============================================================================
|
||||
|
||||
export {
|
||||
EsclProtocol,
|
||||
SaneProtocol,
|
||||
IppProtocol,
|
||||
SnmpProtocol,
|
||||
SNMP_OIDS,
|
||||
NutProtocol,
|
||||
NUT_COMMANDS,
|
||||
NUT_VARIABLES,
|
||||
UpnpSoapClient,
|
||||
UPNP_SERVICE_TYPES,
|
||||
UPNP_DEVICE_TYPES,
|
||||
} from './dlna/dlna.classes.upnp.js';
|
||||
|
||||
// Chromecast app IDs
|
||||
export { CHROMECAST_APPS } from './speaker/speaker.classes.chromecast.js';
|
||||
|
||||
// AirPlay features
|
||||
export { AIRPLAY_FEATURES } from './speaker/speaker.classes.airplay.js';
|
||||
UpsSnmpHandler,
|
||||
UPS_SNMP_OIDS,
|
||||
type ISnmpOptions,
|
||||
type ISnmpVarbind,
|
||||
type TSnmpValueType,
|
||||
type TNutStatusFlag,
|
||||
type INutUpsInfo,
|
||||
type INutVariable,
|
||||
type TDlnaTransportState,
|
||||
type TDlnaTransportStatus,
|
||||
type IDlnaPositionInfo,
|
||||
type IDlnaTransportInfo,
|
||||
type IDlnaMediaInfo,
|
||||
type IDlnaContentItem,
|
||||
type IDlnaBrowseResult,
|
||||
type TUpsBatteryStatus,
|
||||
type TUpsOutputSource,
|
||||
type TUpsTestResult,
|
||||
type IUpsSnmpStatus,
|
||||
} from './protocols/index.js';
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
export { withRetry, createRetryable, defaultRetryOptions } from './helpers/helpers.retry.js';
|
||||
export {
|
||||
isValidIp,
|
||||
@@ -86,56 +111,12 @@ export {
|
||||
countIpsInCidr,
|
||||
} from './helpers/helpers.iprange.js';
|
||||
|
||||
// All interfaces and types
|
||||
// ============================================================================
|
||||
// All Interfaces and Types
|
||||
// ============================================================================
|
||||
|
||||
export * from './interfaces/index.js';
|
||||
|
||||
// SNMP types
|
||||
export type {
|
||||
ISnmpOptions,
|
||||
ISnmpVarbind,
|
||||
TSnmpValueType,
|
||||
} from './snmp/snmp.classes.snmpprotocol.js';
|
||||
export type { ISnmpDeviceInfo } from './snmp/snmp.classes.snmpdevice.js';
|
||||
|
||||
// UPS types
|
||||
export type {
|
||||
TNutStatusFlag,
|
||||
INutUpsInfo,
|
||||
INutVariable,
|
||||
} from './ups/ups.classes.nutprotocol.js';
|
||||
export type {
|
||||
TUpsBatteryStatus,
|
||||
TUpsOutputSource,
|
||||
IUpsSnmpStatus,
|
||||
} from './ups/ups.classes.upssnmp.js';
|
||||
export type {
|
||||
TUpsStatus,
|
||||
TUpsProtocol,
|
||||
IUpsDeviceInfo,
|
||||
IUpsBatteryInfo,
|
||||
IUpsPowerInfo,
|
||||
IUpsFullStatus,
|
||||
} from './ups/ups.classes.upsdevice.js';
|
||||
|
||||
// DLNA types
|
||||
export type {
|
||||
TDlnaTransportState,
|
||||
TDlnaTransportStatus,
|
||||
IDlnaPositionInfo,
|
||||
IDlnaTransportInfo,
|
||||
IDlnaMediaInfo,
|
||||
IDlnaContentItem,
|
||||
IDlnaBrowseResult,
|
||||
} from './dlna/dlna.classes.upnp.js';
|
||||
export type {
|
||||
IDlnaRendererInfo,
|
||||
IDlnaPlaybackState,
|
||||
} from './dlna/dlna.classes.renderer.js';
|
||||
export type {
|
||||
IDlnaServerInfo,
|
||||
IDlnaServerStats,
|
||||
} from './dlna/dlna.classes.server.js';
|
||||
|
||||
// SSDP types
|
||||
export type {
|
||||
ISsdpDevice,
|
||||
@@ -143,26 +124,3 @@ export type {
|
||||
ISsdpService,
|
||||
ISsdpIcon,
|
||||
} from './discovery/discovery.classes.ssdp.js';
|
||||
|
||||
// Speaker types
|
||||
export type {
|
||||
TSpeakerProtocol,
|
||||
TPlaybackState,
|
||||
ITrackInfo,
|
||||
IPlaybackStatus,
|
||||
ISpeakerInfo,
|
||||
} from './speaker/speaker.classes.speaker.js';
|
||||
export type {
|
||||
ISonosZoneInfo,
|
||||
ISonosSpeakerInfo,
|
||||
} from './speaker/speaker.classes.sonos.js';
|
||||
export type {
|
||||
IAirPlaySpeakerInfo,
|
||||
IAirPlayPlaybackInfo,
|
||||
} from './speaker/speaker.classes.airplay.js';
|
||||
export type {
|
||||
TChromecastType,
|
||||
IChromecastSpeakerInfo,
|
||||
IChromecastMediaMetadata,
|
||||
IChromecastMediaStatus,
|
||||
} from './speaker/speaker.classes.chromecast.js';
|
||||
|
||||
@@ -1,255 +0,0 @@
|
||||
import { Device } from '../abstract/device.abstract.js';
|
||||
import { IppProtocol } from './printer.classes.ippprotocol.js';
|
||||
import type {
|
||||
IPrinterInfo,
|
||||
IPrinterCapabilities,
|
||||
IPrintOptions,
|
||||
IPrintJob,
|
||||
IRetryOptions,
|
||||
} from '../interfaces/index.js';
|
||||
|
||||
/**
|
||||
* Printer class for IPP network printers
|
||||
*/
|
||||
export class Printer extends Device {
|
||||
public readonly uri: string;
|
||||
public supportsColor: boolean = false;
|
||||
public supportsDuplex: boolean = false;
|
||||
public supportedMediaTypes: string[] = [];
|
||||
public supportedMediaSizes: string[] = [];
|
||||
public maxCopies: number = 99;
|
||||
|
||||
private ippClient: IppProtocol | null = null;
|
||||
private ippPath: string;
|
||||
|
||||
constructor(
|
||||
info: IPrinterInfo,
|
||||
options?: {
|
||||
ippPath?: string;
|
||||
retryOptions?: IRetryOptions;
|
||||
}
|
||||
) {
|
||||
super(info, options?.retryOptions);
|
||||
this.uri = info.uri;
|
||||
this.supportsColor = info.supportsColor;
|
||||
this.supportsDuplex = info.supportsDuplex;
|
||||
this.supportedMediaTypes = info.supportedMediaTypes;
|
||||
this.supportedMediaSizes = info.supportedMediaSizes;
|
||||
this.maxCopies = info.maxCopies;
|
||||
this.ippPath = options?.ippPath ?? '/ipp/print';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Printer from discovery info
|
||||
*/
|
||||
public static fromDiscovery(
|
||||
discoveredDevice: {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
port: number;
|
||||
txtRecords: Record<string, string>;
|
||||
},
|
||||
retryOptions?: IRetryOptions
|
||||
): Printer {
|
||||
// Parse capabilities from TXT records
|
||||
const txtRecords = discoveredDevice.txtRecords;
|
||||
|
||||
// Get IPP path from TXT records
|
||||
const rp = txtRecords['rp'] || 'ipp/print';
|
||||
const ippPath = rp.startsWith('/') ? rp : `/${rp}`;
|
||||
|
||||
// Parse color support
|
||||
const colorSupported =
|
||||
txtRecords['Color'] === 'T' ||
|
||||
txtRecords['color'] === 'true' ||
|
||||
txtRecords['URF']?.includes('W8') ||
|
||||
false;
|
||||
|
||||
// Parse duplex support
|
||||
const duplexSupported =
|
||||
txtRecords['Duplex'] === 'T' ||
|
||||
txtRecords['duplex'] === 'true' ||
|
||||
txtRecords['URF']?.includes('DM') ||
|
||||
false;
|
||||
|
||||
// Build printer URI
|
||||
const isSecure = txtRecords['TLS'] === '1' || discoveredDevice.port === 443;
|
||||
const protocol = isSecure ? 'ipps' : 'ipp';
|
||||
const uri = `${protocol}://${discoveredDevice.address}:${discoveredDevice.port}${ippPath}`;
|
||||
|
||||
const info: IPrinterInfo = {
|
||||
id: discoveredDevice.id,
|
||||
name: discoveredDevice.name,
|
||||
type: 'printer',
|
||||
address: discoveredDevice.address,
|
||||
port: discoveredDevice.port,
|
||||
status: 'online',
|
||||
uri: uri,
|
||||
supportsColor: colorSupported,
|
||||
supportsDuplex: duplexSupported,
|
||||
supportedMediaTypes: [],
|
||||
supportedMediaSizes: [],
|
||||
maxCopies: 99,
|
||||
manufacturer: txtRecords['usb_MFG'] || txtRecords['mfg'],
|
||||
model: txtRecords['usb_MDL'] || txtRecords['mdl'] || txtRecords['ty'],
|
||||
};
|
||||
|
||||
return new Printer(info, { ippPath, retryOptions });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get printer info
|
||||
*/
|
||||
public getPrinterInfo(): IPrinterInfo {
|
||||
return {
|
||||
...this.getInfo(),
|
||||
type: 'printer',
|
||||
uri: this.uri,
|
||||
supportsColor: this.supportsColor,
|
||||
supportsDuplex: this.supportsDuplex,
|
||||
supportedMediaTypes: this.supportedMediaTypes,
|
||||
supportedMediaSizes: this.supportedMediaSizes,
|
||||
maxCopies: this.maxCopies,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get printer capabilities
|
||||
*/
|
||||
public async getCapabilities(): Promise<IPrinterCapabilities> {
|
||||
if (!this.isConnected) {
|
||||
await this.connect();
|
||||
}
|
||||
|
||||
if (!this.ippClient) {
|
||||
throw new Error('IPP client not initialized');
|
||||
}
|
||||
|
||||
const caps = await this.withRetry(() => this.ippClient!.getAttributes());
|
||||
|
||||
// Update local properties
|
||||
this.supportsColor = caps.colorSupported;
|
||||
this.supportsDuplex = caps.duplexSupported;
|
||||
this.supportedMediaSizes = caps.mediaSizes;
|
||||
this.supportedMediaTypes = caps.mediaTypes;
|
||||
this.maxCopies = caps.maxCopies;
|
||||
|
||||
return caps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Print a document
|
||||
*/
|
||||
public async print(data: Buffer, options?: IPrintOptions): Promise<IPrintJob> {
|
||||
if (!this.isConnected) {
|
||||
await this.connect();
|
||||
}
|
||||
|
||||
if (!this.ippClient) {
|
||||
throw new Error('IPP client not initialized');
|
||||
}
|
||||
|
||||
this.setStatus('busy');
|
||||
this.emit('print:started', options);
|
||||
|
||||
try {
|
||||
const job = await this.withRetry(() => this.ippClient!.print(data, options));
|
||||
this.setStatus('online');
|
||||
this.emit('print:submitted', job);
|
||||
return job;
|
||||
} catch (error) {
|
||||
this.setStatus('online');
|
||||
this.emit('print:error', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all print jobs
|
||||
*/
|
||||
public async getJobs(): Promise<IPrintJob[]> {
|
||||
if (!this.isConnected) {
|
||||
await this.connect();
|
||||
}
|
||||
|
||||
if (!this.ippClient) {
|
||||
throw new Error('IPP client not initialized');
|
||||
}
|
||||
|
||||
return this.withRetry(() => this.ippClient!.getJobs());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get specific job info
|
||||
*/
|
||||
public async getJobInfo(jobId: number): Promise<IPrintJob> {
|
||||
if (!this.isConnected) {
|
||||
await this.connect();
|
||||
}
|
||||
|
||||
if (!this.ippClient) {
|
||||
throw new Error('IPP client not initialized');
|
||||
}
|
||||
|
||||
return this.withRetry(() => this.ippClient!.getJobInfo(jobId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a print job
|
||||
*/
|
||||
public async cancelJob(jobId: number): Promise<void> {
|
||||
if (!this.isConnected) {
|
||||
await this.connect();
|
||||
}
|
||||
|
||||
if (!this.ippClient) {
|
||||
throw new Error('IPP client not initialized');
|
||||
}
|
||||
|
||||
await this.withRetry(() => this.ippClient!.cancelJob(jobId));
|
||||
this.emit('print:canceled', jobId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the printer
|
||||
*/
|
||||
protected async doConnect(): Promise<void> {
|
||||
this.ippClient = new IppProtocol(this.address, this.port, this.ippPath);
|
||||
|
||||
// Test connection by checking availability
|
||||
const available = await this.ippClient.checkAvailability();
|
||||
if (!available) {
|
||||
throw new Error('Printer not available');
|
||||
}
|
||||
|
||||
// Fetch capabilities to populate local properties
|
||||
await this.getCapabilities();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from the printer
|
||||
*/
|
||||
protected async doDisconnect(): Promise<void> {
|
||||
this.ippClient = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh printer status
|
||||
*/
|
||||
public async refreshStatus(): Promise<void> {
|
||||
try {
|
||||
if (this.ippClient) {
|
||||
const available = await this.ippClient.checkAvailability();
|
||||
this.setStatus(available ? 'online' : 'offline');
|
||||
} else {
|
||||
this.setStatus('offline');
|
||||
}
|
||||
} catch (error) {
|
||||
this.setStatus('error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { IppProtocol };
|
||||
56
ts/protocols/index.ts
Normal file
56
ts/protocols/index.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Protocol implementations
|
||||
* All network communication protocols for device interaction
|
||||
*/
|
||||
|
||||
// eSCL/AirScan scanner protocol
|
||||
export { EsclProtocol } from './protocol.escl.js';
|
||||
|
||||
// SANE network scanner protocol
|
||||
export { SaneProtocol } from './protocol.sane.js';
|
||||
|
||||
// IPP printer protocol
|
||||
export { IppProtocol } from './protocol.ipp.js';
|
||||
|
||||
// SNMP query protocol
|
||||
export {
|
||||
SnmpProtocol,
|
||||
SNMP_OIDS,
|
||||
type TSnmpValueType,
|
||||
type ISnmpVarbind,
|
||||
type ISnmpOptions,
|
||||
} from './protocol.snmp.js';
|
||||
|
||||
// Network UPS Tools protocol
|
||||
export {
|
||||
NutProtocol,
|
||||
NUT_VARIABLES,
|
||||
NUT_COMMANDS,
|
||||
type TNutStatusFlag,
|
||||
type INutUpsInfo,
|
||||
type INutVariable,
|
||||
} from './protocol.nut.js';
|
||||
|
||||
// UPnP/DLNA SOAP protocol
|
||||
export {
|
||||
UpnpSoapClient,
|
||||
UPNP_SERVICE_TYPES,
|
||||
UPNP_DEVICE_TYPES,
|
||||
type TDlnaTransportState,
|
||||
type TDlnaTransportStatus,
|
||||
type IDlnaPositionInfo,
|
||||
type IDlnaTransportInfo,
|
||||
type IDlnaMediaInfo,
|
||||
type IDlnaContentItem,
|
||||
type IDlnaBrowseResult,
|
||||
} from './protocol.upnp.js';
|
||||
|
||||
// UPS SNMP (UPS-MIB RFC 1628)
|
||||
export {
|
||||
UpsSnmpHandler,
|
||||
UPS_SNMP_OIDS,
|
||||
type TUpsBatteryStatus,
|
||||
type TUpsOutputSource,
|
||||
type TUpsTestResult,
|
||||
type IUpsSnmpStatus,
|
||||
} from './protocol.upssnmp.js';
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SnmpProtocol, SNMP_OIDS, type ISnmpOptions } from '../snmp/snmp.classes.snmpprotocol.js';
|
||||
import { SnmpProtocol, SNMP_OIDS, type ISnmpOptions } from '../protocols/index.js';
|
||||
|
||||
/**
|
||||
* Extended UPS-MIB OIDs (RFC 1628)
|
||||
@@ -1,370 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { Device } from '../abstract/device.abstract.js';
|
||||
import { EsclProtocol } from './scanner.classes.esclprotocol.js';
|
||||
import { SaneProtocol } from './scanner.classes.saneprotocol.js';
|
||||
import type {
|
||||
IScannerInfo,
|
||||
IScannerCapabilities,
|
||||
IScanOptions,
|
||||
IScanResult,
|
||||
TScannerProtocol,
|
||||
TScanFormat,
|
||||
TColorMode,
|
||||
TScanSource,
|
||||
IRetryOptions,
|
||||
} from '../interfaces/index.js';
|
||||
|
||||
/**
|
||||
* Unified Scanner class that abstracts over eSCL and SANE protocols
|
||||
*/
|
||||
export class Scanner extends Device {
|
||||
public readonly protocol: TScannerProtocol;
|
||||
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
|
||||
|
||||
private esclClient: EsclProtocol | null = null;
|
||||
private saneClient: SaneProtocol | null = null;
|
||||
private deviceName: string = '';
|
||||
private isSecure: boolean = false;
|
||||
|
||||
constructor(
|
||||
info: IScannerInfo,
|
||||
options?: {
|
||||
deviceName?: string;
|
||||
secure?: boolean;
|
||||
retryOptions?: IRetryOptions;
|
||||
}
|
||||
) {
|
||||
super(info, options?.retryOptions);
|
||||
this.protocol = info.protocol;
|
||||
this.supportedFormats = info.supportedFormats;
|
||||
this.supportedResolutions = info.supportedResolutions;
|
||||
this.supportedColorModes = info.supportedColorModes;
|
||||
this.supportedSources = info.supportedSources;
|
||||
this.hasAdf = info.hasAdf;
|
||||
this.hasDuplex = info.hasDuplex;
|
||||
this.maxWidth = info.maxWidth ?? this.maxWidth;
|
||||
this.maxHeight = info.maxHeight ?? this.maxHeight;
|
||||
this.deviceName = options?.deviceName ?? '';
|
||||
this.isSecure = options?.secure ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Scanner from discovery info
|
||||
*/
|
||||
public static fromDiscovery(
|
||||
discoveredDevice: {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
port: number;
|
||||
protocol: TScannerProtocol | 'ipp';
|
||||
txtRecords: Record<string, string>;
|
||||
},
|
||||
retryOptions?: IRetryOptions
|
||||
): Scanner {
|
||||
const protocol = discoveredDevice.protocol === 'ipp' ? 'escl' : discoveredDevice.protocol;
|
||||
|
||||
// Parse capabilities from TXT records
|
||||
const formats = Scanner.parseFormats(discoveredDevice.txtRecords);
|
||||
const resolutions = Scanner.parseResolutions(discoveredDevice.txtRecords);
|
||||
const colorModes = Scanner.parseColorModes(discoveredDevice.txtRecords);
|
||||
const sources = Scanner.parseSources(discoveredDevice.txtRecords);
|
||||
|
||||
const info: IScannerInfo = {
|
||||
id: discoveredDevice.id,
|
||||
name: discoveredDevice.name,
|
||||
type: 'scanner',
|
||||
address: discoveredDevice.address,
|
||||
port: discoveredDevice.port,
|
||||
status: 'online',
|
||||
protocol: protocol,
|
||||
supportedFormats: formats,
|
||||
supportedResolutions: resolutions,
|
||||
supportedColorModes: colorModes,
|
||||
supportedSources: sources,
|
||||
hasAdf: sources.includes('adf') || sources.includes('adf-duplex'),
|
||||
hasDuplex: sources.includes('adf-duplex'),
|
||||
manufacturer: discoveredDevice.txtRecords['usb_MFG'] || discoveredDevice.txtRecords['mfg'],
|
||||
model: discoveredDevice.txtRecords['usb_MDL'] || discoveredDevice.txtRecords['mdl'] || discoveredDevice.txtRecords['ty'],
|
||||
};
|
||||
|
||||
const isSecure = discoveredDevice.txtRecords['TLS'] === '1' ||
|
||||
discoveredDevice.protocol === 'escl' && discoveredDevice.port === 443;
|
||||
|
||||
return new Scanner(info, {
|
||||
secure: isSecure,
|
||||
retryOptions,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse supported formats from TXT records
|
||||
*/
|
||||
private static parseFormats(txtRecords: Record<string, string>): TScanFormat[] {
|
||||
const formats: TScanFormat[] = [];
|
||||
const pdl = txtRecords['pdl'] || txtRecords['DocumentFormat'] || '';
|
||||
|
||||
if (pdl.includes('jpeg') || pdl.includes('jpg')) formats.push('jpeg');
|
||||
if (pdl.includes('png')) formats.push('png');
|
||||
if (pdl.includes('pdf')) formats.push('pdf');
|
||||
|
||||
// Default to jpeg if nothing found
|
||||
if (formats.length === 0) {
|
||||
formats.push('jpeg', 'png');
|
||||
}
|
||||
|
||||
return formats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse supported resolutions from TXT records
|
||||
*/
|
||||
private static parseResolutions(txtRecords: Record<string, string>): number[] {
|
||||
const rs = txtRecords['rs'] || '';
|
||||
const resolutions: number[] = [];
|
||||
|
||||
// Try to parse comma-separated resolutions
|
||||
const parts = rs.split(',').map((s) => parseInt(s.trim())).filter((n) => !isNaN(n) && n > 0);
|
||||
if (parts.length > 0) {
|
||||
return parts;
|
||||
}
|
||||
|
||||
// Default common resolutions
|
||||
return [75, 150, 300, 600];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse color modes from TXT records
|
||||
*/
|
||||
private static parseColorModes(txtRecords: Record<string, string>): TColorMode[] {
|
||||
const cs = txtRecords['cs'] || txtRecords['ColorSpace'] || '';
|
||||
const modes: TColorMode[] = [];
|
||||
|
||||
if (cs.includes('color') || cs.includes('RGB')) modes.push('color');
|
||||
if (cs.includes('gray') || cs.includes('grayscale')) modes.push('grayscale');
|
||||
if (cs.includes('binary') || cs.includes('bw')) modes.push('blackwhite');
|
||||
|
||||
// Default to color and grayscale
|
||||
if (modes.length === 0) {
|
||||
modes.push('color', 'grayscale');
|
||||
}
|
||||
|
||||
return modes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse input sources from TXT records
|
||||
*/
|
||||
private static parseSources(txtRecords: Record<string, string>): TScanSource[] {
|
||||
const is = txtRecords['is'] || txtRecords['InputSource'] || '';
|
||||
const sources: TScanSource[] = [];
|
||||
|
||||
if (is.includes('platen') || is.includes('flatbed') || is === '') {
|
||||
sources.push('flatbed');
|
||||
}
|
||||
if (is.includes('adf') || is.includes('feeder')) {
|
||||
sources.push('adf');
|
||||
}
|
||||
if (is.includes('duplex')) {
|
||||
sources.push('adf-duplex');
|
||||
}
|
||||
|
||||
// Default to flatbed
|
||||
if (sources.length === 0) {
|
||||
sources.push('flatbed');
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scanner info
|
||||
*/
|
||||
public getScannerInfo(): IScannerInfo {
|
||||
return {
|
||||
...this.getInfo(),
|
||||
type: 'scanner',
|
||||
protocol: this.protocol,
|
||||
supportedFormats: this.supportedFormats,
|
||||
supportedResolutions: this.supportedResolutions,
|
||||
supportedColorModes: this.supportedColorModes,
|
||||
supportedSources: this.supportedSources,
|
||||
hasAdf: this.hasAdf,
|
||||
hasDuplex: this.hasDuplex,
|
||||
maxWidth: this.maxWidth,
|
||||
maxHeight: this.maxHeight,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scanner capabilities
|
||||
*/
|
||||
public async getCapabilities(): Promise<IScannerCapabilities> {
|
||||
if (!this.isConnected) {
|
||||
await this.connect();
|
||||
}
|
||||
|
||||
if (this.protocol === 'escl' && this.esclClient) {
|
||||
const caps = await this.esclClient.getCapabilities();
|
||||
|
||||
const platen = caps.platen;
|
||||
return {
|
||||
resolutions: platen?.supportedResolutions ?? this.supportedResolutions,
|
||||
formats: this.supportedFormats,
|
||||
colorModes: this.supportedColorModes,
|
||||
sources: this.supportedSources,
|
||||
maxWidth: platen ? platen.maxWidth / 300 * 25.4 : this.maxWidth,
|
||||
maxHeight: platen ? platen.maxHeight / 300 * 25.4 : this.maxHeight,
|
||||
minWidth: platen ? platen.minWidth / 300 * 25.4 : 0,
|
||||
minHeight: platen ? platen.minHeight / 300 * 25.4 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Return defaults for SANE (would need to query options)
|
||||
return {
|
||||
resolutions: this.supportedResolutions,
|
||||
formats: this.supportedFormats,
|
||||
colorModes: this.supportedColorModes,
|
||||
sources: this.supportedSources,
|
||||
maxWidth: this.maxWidth,
|
||||
maxHeight: this.maxHeight,
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a scan
|
||||
*/
|
||||
public async scan(options?: IScanOptions): Promise<IScanResult> {
|
||||
if (!this.isConnected) {
|
||||
await this.connect();
|
||||
}
|
||||
|
||||
const scanOptions: IScanOptions = {
|
||||
resolution: options?.resolution ?? 300,
|
||||
format: options?.format ?? 'jpeg',
|
||||
colorMode: options?.colorMode ?? 'color',
|
||||
source: options?.source ?? 'flatbed',
|
||||
area: options?.area,
|
||||
intent: options?.intent ?? 'document',
|
||||
quality: options?.quality ?? 85,
|
||||
};
|
||||
|
||||
this.setStatus('busy');
|
||||
this.emit('scan:started', scanOptions);
|
||||
|
||||
try {
|
||||
let result: IScanResult;
|
||||
|
||||
if (this.protocol === 'escl' && this.esclClient) {
|
||||
result = await this.withRetry(() => this.esclClient!.scan(scanOptions));
|
||||
} else if (this.protocol === 'sane' && this.saneClient) {
|
||||
result = await this.withRetry(() => this.saneClient!.scan(scanOptions));
|
||||
} else {
|
||||
throw new Error(`No protocol client available for ${this.protocol}`);
|
||||
}
|
||||
|
||||
this.setStatus('online');
|
||||
this.emit('scan:completed', result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.setStatus('online');
|
||||
this.emit('scan:error', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel an ongoing scan
|
||||
*/
|
||||
public async cancelScan(): Promise<void> {
|
||||
if (this.protocol === 'sane' && this.saneClient) {
|
||||
await this.saneClient.cancel();
|
||||
}
|
||||
// eSCL cancellation is handled via job deletion in the protocol
|
||||
this.emit('scan:canceled');
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the scanner
|
||||
*/
|
||||
protected async doConnect(): Promise<void> {
|
||||
if (this.protocol === 'escl') {
|
||||
this.esclClient = new EsclProtocol(this.address, this.port, this.isSecure);
|
||||
// Test connection by getting capabilities
|
||||
await this.esclClient.getCapabilities();
|
||||
} else if (this.protocol === 'sane') {
|
||||
this.saneClient = new SaneProtocol(this.address, this.port);
|
||||
await this.saneClient.connect();
|
||||
|
||||
// Get available devices
|
||||
const devices = await this.saneClient.getDevices();
|
||||
if (devices.length === 0) {
|
||||
throw new Error('No SANE devices available');
|
||||
}
|
||||
|
||||
// Open the first device or the specified one
|
||||
const deviceToOpen = this.deviceName || devices[0].name;
|
||||
await this.saneClient.open(deviceToOpen);
|
||||
} else {
|
||||
throw new Error(`Unsupported protocol: ${this.protocol}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from the scanner
|
||||
*/
|
||||
protected async doDisconnect(): Promise<void> {
|
||||
if (this.esclClient) {
|
||||
this.esclClient = null;
|
||||
}
|
||||
|
||||
if (this.saneClient) {
|
||||
await this.saneClient.disconnect();
|
||||
this.saneClient = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh scanner status
|
||||
*/
|
||||
public async refreshStatus(): Promise<void> {
|
||||
try {
|
||||
if (this.protocol === 'escl' && this.esclClient) {
|
||||
const status = await this.esclClient.getStatus();
|
||||
switch (status.state) {
|
||||
case 'Idle':
|
||||
this.setStatus('online');
|
||||
break;
|
||||
case 'Processing':
|
||||
this.setStatus('busy');
|
||||
break;
|
||||
case 'Stopped':
|
||||
case 'Testing':
|
||||
this.setStatus('offline');
|
||||
break;
|
||||
}
|
||||
} else if (this.protocol === 'sane') {
|
||||
// SANE doesn't have a direct status query
|
||||
// Just check if we can still communicate
|
||||
if (this.saneClient) {
|
||||
await this.saneClient.getParameters();
|
||||
this.setStatus('online');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.setStatus('error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { EsclProtocol, SaneProtocol };
|
||||
@@ -1,271 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { Device } from '../abstract/device.abstract.js';
|
||||
import { SnmpProtocol, SNMP_OIDS, type ISnmpOptions, type ISnmpVarbind } from './snmp.classes.snmpprotocol.js';
|
||||
import type { IDeviceInfo, IRetryOptions, TDeviceStatus } from '../interfaces/index.js';
|
||||
|
||||
/**
|
||||
* SNMP device information
|
||||
*/
|
||||
export interface ISnmpDeviceInfo extends IDeviceInfo {
|
||||
type: 'snmp';
|
||||
sysDescr: string;
|
||||
sysObjectID: string;
|
||||
sysUpTime: number;
|
||||
sysContact?: string;
|
||||
sysName?: string;
|
||||
sysLocation?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* SNMP Device class for generic SNMP-enabled devices
|
||||
*/
|
||||
export class SnmpDevice extends Device {
|
||||
private protocol: SnmpProtocol | null = null;
|
||||
private snmpOptions: ISnmpOptions;
|
||||
|
||||
private _sysDescr: string = '';
|
||||
private _sysObjectID: string = '';
|
||||
private _sysUpTime: number = 0;
|
||||
private _sysContact?: string;
|
||||
private _sysName?: string;
|
||||
private _sysLocation?: string;
|
||||
|
||||
constructor(
|
||||
info: IDeviceInfo,
|
||||
snmpOptions?: ISnmpOptions,
|
||||
retryOptions?: IRetryOptions
|
||||
) {
|
||||
super(info, retryOptions);
|
||||
this.snmpOptions = { port: info.port, ...snmpOptions };
|
||||
}
|
||||
|
||||
// Getters for SNMP properties
|
||||
public get sysDescr(): string {
|
||||
return this._sysDescr;
|
||||
}
|
||||
|
||||
public get sysObjectID(): string {
|
||||
return this._sysObjectID;
|
||||
}
|
||||
|
||||
public get sysUpTime(): number {
|
||||
return this._sysUpTime;
|
||||
}
|
||||
|
||||
public get sysContact(): string | undefined {
|
||||
return this._sysContact;
|
||||
}
|
||||
|
||||
public get sysName(): string | undefined {
|
||||
return this._sysName;
|
||||
}
|
||||
|
||||
public get sysLocation(): string | undefined {
|
||||
return this._sysLocation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the SNMP device
|
||||
*/
|
||||
protected async doConnect(): Promise<void> {
|
||||
this.protocol = new SnmpProtocol(this.address, this.snmpOptions);
|
||||
|
||||
// Verify connection by fetching system info
|
||||
const sysInfo = await this.protocol.getSystemInfo();
|
||||
|
||||
this._sysDescr = sysInfo.sysDescr;
|
||||
this._sysObjectID = sysInfo.sysObjectID;
|
||||
this._sysUpTime = sysInfo.sysUpTime;
|
||||
this._sysContact = sysInfo.sysContact || undefined;
|
||||
this._sysName = sysInfo.sysName || undefined;
|
||||
this._sysLocation = sysInfo.sysLocation || undefined;
|
||||
|
||||
// Update device name if sysName is available
|
||||
if (sysInfo.sysName && !this.name.includes('SNMP Device')) {
|
||||
// Keep custom name
|
||||
} else if (sysInfo.sysName) {
|
||||
(this as { name: string }).name = sysInfo.sysName;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from the SNMP device
|
||||
*/
|
||||
protected async doDisconnect(): Promise<void> {
|
||||
if (this.protocol) {
|
||||
this.protocol.close();
|
||||
this.protocol = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh device status
|
||||
*/
|
||||
public async refreshStatus(): Promise<void> {
|
||||
if (!this.protocol) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
const sysInfo = await this.protocol.getSystemInfo();
|
||||
this._sysUpTime = sysInfo.sysUpTime;
|
||||
this.emit('status:updated', this.getDeviceInfo());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single OID value
|
||||
*/
|
||||
public async get(oid: string): Promise<ISnmpVarbind> {
|
||||
if (!this.protocol) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
return this.protocol.get(oid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get multiple OID values
|
||||
*/
|
||||
public async getMultiple(oids: string[]): Promise<ISnmpVarbind[]> {
|
||||
if (!this.protocol) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
return this.protocol.getMultiple(oids);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next OID in the MIB tree
|
||||
*/
|
||||
public async getNext(oid: string): Promise<ISnmpVarbind> {
|
||||
if (!this.protocol) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
return this.protocol.getNext(oid);
|
||||
}
|
||||
|
||||
/**
|
||||
* GETBULK operation for efficient table retrieval
|
||||
*/
|
||||
public async getBulk(
|
||||
oids: string[],
|
||||
nonRepeaters?: number,
|
||||
maxRepetitions?: number
|
||||
): Promise<ISnmpVarbind[]> {
|
||||
if (!this.protocol) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
return this.protocol.getBulk(oids, nonRepeaters, maxRepetitions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a MIB tree
|
||||
*/
|
||||
public async walk(baseOid: string): Promise<ISnmpVarbind[]> {
|
||||
if (!this.protocol) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
return this.protocol.walk(baseOid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set an OID value
|
||||
*/
|
||||
public async set(
|
||||
oid: string,
|
||||
type: 'Integer' | 'OctetString' | 'ObjectIdentifier' | 'IpAddress',
|
||||
value: unknown
|
||||
): Promise<ISnmpVarbind> {
|
||||
if (!this.protocol) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
return this.protocol.set(oid, type, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get device information
|
||||
*/
|
||||
public getDeviceInfo(): ISnmpDeviceInfo {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
type: 'snmp',
|
||||
address: this.address,
|
||||
port: this.port,
|
||||
status: this.status,
|
||||
sysDescr: this._sysDescr,
|
||||
sysObjectID: this._sysObjectID,
|
||||
sysUpTime: this._sysUpTime,
|
||||
sysContact: this._sysContact,
|
||||
sysName: this._sysName,
|
||||
sysLocation: this._sysLocation,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create SnmpDevice from discovery data
|
||||
*/
|
||||
public static fromDiscovery(
|
||||
data: {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
port?: number;
|
||||
community?: string;
|
||||
},
|
||||
retryOptions?: IRetryOptions
|
||||
): SnmpDevice {
|
||||
const info: IDeviceInfo = {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
type: 'snmp',
|
||||
address: data.address,
|
||||
port: data.port ?? 161,
|
||||
status: 'unknown',
|
||||
};
|
||||
return new SnmpDevice(
|
||||
info,
|
||||
{ community: data.community ?? 'public' },
|
||||
retryOptions
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe an IP address for SNMP device
|
||||
*/
|
||||
public static async probe(
|
||||
address: string,
|
||||
port: number = 161,
|
||||
community: string = 'public',
|
||||
timeout: number = 5000
|
||||
): Promise<ISnmpDeviceInfo | null> {
|
||||
const protocol = new SnmpProtocol(address, {
|
||||
community,
|
||||
port,
|
||||
timeout,
|
||||
retries: 0,
|
||||
});
|
||||
|
||||
try {
|
||||
const sysInfo = await protocol.getSystemInfo();
|
||||
|
||||
return {
|
||||
id: `snmp:${address}:${port}`,
|
||||
name: sysInfo.sysName || `SNMP Device at ${address}`,
|
||||
type: 'snmp',
|
||||
address,
|
||||
port,
|
||||
status: 'online',
|
||||
sysDescr: sysInfo.sysDescr,
|
||||
sysObjectID: sysInfo.sysObjectID,
|
||||
sysUpTime: sysInfo.sysUpTime,
|
||||
sysContact: sysInfo.sysContact || undefined,
|
||||
sysName: sysInfo.sysName || undefined,
|
||||
sysLocation: sysInfo.sysLocation || undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
protocol.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { SNMP_OIDS };
|
||||
@@ -1,548 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { Speaker, type ITrackInfo, type IPlaybackStatus, type TPlaybackState, type ISpeakerInfo } from './speaker.classes.speaker.js';
|
||||
import type { IDeviceInfo, IRetryOptions } from '../interfaces/index.js';
|
||||
|
||||
/**
|
||||
* AirPlay features bitmask
|
||||
*/
|
||||
export const AIRPLAY_FEATURES = {
|
||||
Video: 1 << 0,
|
||||
Photo: 1 << 1,
|
||||
VideoFairPlay: 1 << 2,
|
||||
VideoVolumeControl: 1 << 3,
|
||||
VideoHTTPLiveStreams: 1 << 4,
|
||||
Slideshow: 1 << 5,
|
||||
Screen: 1 << 7,
|
||||
ScreenRotate: 1 << 8,
|
||||
Audio: 1 << 9,
|
||||
AudioRedundant: 1 << 11,
|
||||
FPSAPv2pt5_AES_GCM: 1 << 12,
|
||||
PhotoCaching: 1 << 13,
|
||||
Authentication4: 1 << 14,
|
||||
MetadataFeatures: 1 << 15,
|
||||
AudioFormats: 1 << 16,
|
||||
Authentication1: 1 << 17,
|
||||
};
|
||||
|
||||
/**
|
||||
* AirPlay device info
|
||||
*/
|
||||
export interface IAirPlaySpeakerInfo extends ISpeakerInfo {
|
||||
protocol: 'airplay';
|
||||
features: number;
|
||||
supportsVideo: boolean;
|
||||
supportsAudio: boolean;
|
||||
supportsScreen: boolean;
|
||||
deviceId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* AirPlay playback info
|
||||
*/
|
||||
export interface IAirPlayPlaybackInfo {
|
||||
duration: number;
|
||||
position: number;
|
||||
rate: number;
|
||||
readyToPlay: boolean;
|
||||
playbackBufferEmpty: boolean;
|
||||
playbackBufferFull: boolean;
|
||||
playbackLikelyToKeepUp: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* AirPlay Speaker device
|
||||
* Basic implementation for AirPlay-compatible devices
|
||||
*/
|
||||
export class AirPlaySpeaker extends Speaker {
|
||||
private _features: number = 0;
|
||||
private _deviceId?: string;
|
||||
private _supportsVideo: boolean = false;
|
||||
private _supportsAudio: boolean = true;
|
||||
private _supportsScreen: boolean = false;
|
||||
private _currentUri?: string;
|
||||
private _currentPosition: number = 0;
|
||||
private _currentDuration: number = 0;
|
||||
private _isPlaying: boolean = false;
|
||||
|
||||
constructor(
|
||||
info: IDeviceInfo,
|
||||
options?: {
|
||||
roomName?: string;
|
||||
modelName?: string;
|
||||
features?: number;
|
||||
deviceId?: string;
|
||||
},
|
||||
retryOptions?: IRetryOptions
|
||||
) {
|
||||
super(info, 'airplay', options, retryOptions);
|
||||
this._features = options?.features || 0;
|
||||
this._deviceId = options?.deviceId;
|
||||
|
||||
// Parse features
|
||||
if (this._features) {
|
||||
this._supportsVideo = !!(this._features & AIRPLAY_FEATURES.Video);
|
||||
this._supportsAudio = !!(this._features & AIRPLAY_FEATURES.Audio);
|
||||
this._supportsScreen = !!(this._features & AIRPLAY_FEATURES.Screen);
|
||||
}
|
||||
}
|
||||
|
||||
// Getters
|
||||
public get features(): number {
|
||||
return this._features;
|
||||
}
|
||||
|
||||
public get deviceId(): string | undefined {
|
||||
return this._deviceId;
|
||||
}
|
||||
|
||||
public get supportsVideo(): boolean {
|
||||
return this._supportsVideo;
|
||||
}
|
||||
|
||||
public get supportsAudio(): boolean {
|
||||
return this._supportsAudio;
|
||||
}
|
||||
|
||||
public get supportsScreen(): boolean {
|
||||
return this._supportsScreen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to AirPlay device
|
||||
* AirPlay 2 devices (HomePods) may not respond to /server-info,
|
||||
* so we consider them connected even if we can't get device info.
|
||||
*/
|
||||
protected async doConnect(): Promise<void> {
|
||||
// Try /server-info endpoint (works for older AirPlay devices)
|
||||
const url = `http://${this.address}:${this.port}/server-info`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Parse server info (plist format)
|
||||
const text = await response.text();
|
||||
|
||||
// Extract features if available
|
||||
const featuresMatch = text.match(/<key>features<\/key>\s*<integer>(\d+)<\/integer>/);
|
||||
if (featuresMatch) {
|
||||
this._features = parseInt(featuresMatch[1]);
|
||||
this._supportsVideo = !!(this._features & AIRPLAY_FEATURES.Video);
|
||||
this._supportsAudio = !!(this._features & AIRPLAY_FEATURES.Audio);
|
||||
this._supportsScreen = !!(this._features & AIRPLAY_FEATURES.Screen);
|
||||
}
|
||||
|
||||
// Extract device ID
|
||||
const deviceIdMatch = text.match(/<key>deviceid<\/key>\s*<string>([^<]+)<\/string>/);
|
||||
if (deviceIdMatch) {
|
||||
this._deviceId = deviceIdMatch[1];
|
||||
}
|
||||
|
||||
// Extract model
|
||||
const modelMatch = text.match(/<key>model<\/key>\s*<string>([^<]+)<\/string>/);
|
||||
if (modelMatch) {
|
||||
this._modelName = modelMatch[1];
|
||||
this.model = modelMatch[1];
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Non-OK response - might be AirPlay 2, continue below
|
||||
} catch {
|
||||
// /server-info failed, might be AirPlay 2 device
|
||||
}
|
||||
|
||||
// For AirPlay 2 devices (HomePods), /server-info doesn't work
|
||||
// Try a simple port check - if the port responds, consider it connected
|
||||
// HomePods will respond to proper AirPlay 2 protocol even if HTTP endpoints fail
|
||||
// We'll assume it's an AirPlay 2 audio device
|
||||
this._supportsAudio = true;
|
||||
this._supportsVideo = false;
|
||||
this._supportsScreen = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect
|
||||
*/
|
||||
protected async doDisconnect(): Promise<void> {
|
||||
try {
|
||||
await this.stop();
|
||||
} catch {
|
||||
// Ignore stop errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh status
|
||||
*/
|
||||
public async refreshStatus(): Promise<void> {
|
||||
try {
|
||||
const info = await this.getAirPlayPlaybackInfo();
|
||||
this._isPlaying = info.rate > 0;
|
||||
this._currentPosition = info.position;
|
||||
this._currentDuration = info.duration;
|
||||
this._playbackState = this._isPlaying ? 'playing' : 'paused';
|
||||
} catch {
|
||||
this._playbackState = 'stopped';
|
||||
}
|
||||
|
||||
this.emit('status:updated', this.getSpeakerInfo());
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Playback Control
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Play media URL
|
||||
*/
|
||||
public async play(uri?: string): Promise<void> {
|
||||
if (uri) {
|
||||
this._currentUri = uri;
|
||||
|
||||
const body = `Content-Location: ${uri}\nStart-Position: 0\n`;
|
||||
|
||||
const response = await fetch(`http://${this.address}:${this.port}/play`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/parameters',
|
||||
},
|
||||
body,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Play failed: ${response.status}`);
|
||||
}
|
||||
} else {
|
||||
// Resume playback
|
||||
await this.setRate(1);
|
||||
}
|
||||
|
||||
this._isPlaying = true;
|
||||
this._playbackState = 'playing';
|
||||
this.emit('playback:started');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause playback
|
||||
*/
|
||||
public async pause(): Promise<void> {
|
||||
await this.setRate(0);
|
||||
this._isPlaying = false;
|
||||
this._playbackState = 'paused';
|
||||
this.emit('playback:paused');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop playback
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
const response = await fetch(`http://${this.address}:${this.port}/stop`, {
|
||||
method: 'POST',
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Stop failed: ${response.status}`);
|
||||
}
|
||||
|
||||
this._isPlaying = false;
|
||||
this._playbackState = 'stopped';
|
||||
this._currentUri = undefined;
|
||||
this.emit('playback:stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Next track (not supported on basic AirPlay)
|
||||
*/
|
||||
public async next(): Promise<void> {
|
||||
throw new Error('Next track not supported on AirPlay');
|
||||
}
|
||||
|
||||
/**
|
||||
* Previous track (not supported on basic AirPlay)
|
||||
*/
|
||||
public async previous(): Promise<void> {
|
||||
throw new Error('Previous track not supported on AirPlay');
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek to position
|
||||
*/
|
||||
public async seek(seconds: number): Promise<void> {
|
||||
const body = `position: ${seconds}\n`;
|
||||
|
||||
const response = await fetch(`http://${this.address}:${this.port}/scrub`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/parameters',
|
||||
},
|
||||
body,
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Seek failed: ${response.status}`);
|
||||
}
|
||||
|
||||
this._currentPosition = seconds;
|
||||
this.emit('playback:seeked', { position: seconds });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Volume Control (limited support)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get volume (not always supported)
|
||||
*/
|
||||
public async getVolume(): Promise<number> {
|
||||
// AirPlay volume control varies by device
|
||||
return this._volume;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set volume (not always supported)
|
||||
*/
|
||||
public async setVolume(level: number): Promise<void> {
|
||||
const clamped = Math.max(0, Math.min(100, level));
|
||||
|
||||
try {
|
||||
const body = `volume: ${clamped / 100}\n`;
|
||||
|
||||
const response = await fetch(`http://${this.address}:${this.port}/volume`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/parameters',
|
||||
},
|
||||
body,
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
this._volume = clamped;
|
||||
this.emit('volume:changed', { volume: clamped });
|
||||
}
|
||||
} catch {
|
||||
// Volume control may not be supported
|
||||
throw new Error('Volume control not supported on this device');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mute state (not always supported)
|
||||
*/
|
||||
public async getMute(): Promise<boolean> {
|
||||
return this._muted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set mute state (not always supported)
|
||||
*/
|
||||
public async setMute(muted: boolean): Promise<void> {
|
||||
// Mute by setting volume to 0
|
||||
if (muted) {
|
||||
await this.setVolume(0);
|
||||
} else {
|
||||
await this.setVolume(this._volume || 50);
|
||||
}
|
||||
this._muted = muted;
|
||||
this.emit('mute:changed', { muted });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Track Information
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get current track
|
||||
*/
|
||||
public async getCurrentTrack(): Promise<ITrackInfo | null> {
|
||||
if (!this._currentUri) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
title: this._currentUri.split('/').pop() || 'Unknown',
|
||||
duration: this._currentDuration,
|
||||
position: this._currentPosition,
|
||||
uri: this._currentUri,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get playback status
|
||||
*/
|
||||
public async getPlaybackStatus(): Promise<IPlaybackStatus> {
|
||||
await this.refreshStatus();
|
||||
|
||||
return {
|
||||
state: this._playbackState,
|
||||
volume: this._volume,
|
||||
muted: this._muted,
|
||||
track: await this.getCurrentTrack() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AirPlay-specific Methods
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Set playback rate
|
||||
*/
|
||||
private async setRate(rate: number): Promise<void> {
|
||||
const body = `value: ${rate}\n`;
|
||||
|
||||
const response = await fetch(`http://${this.address}:${this.port}/rate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/parameters',
|
||||
},
|
||||
body,
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Set rate failed: ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get AirPlay playback info
|
||||
*/
|
||||
public async getAirPlayPlaybackInfo(): Promise<IAirPlayPlaybackInfo> {
|
||||
const response = await fetch(`http://${this.address}:${this.port}/playback-info`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Get playback info failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
|
||||
// Parse plist response
|
||||
const extractReal = (key: string): number => {
|
||||
const match = text.match(new RegExp(`<key>${key}</key>\\s*<real>([\\d.]+)</real>`));
|
||||
return match ? parseFloat(match[1]) : 0;
|
||||
};
|
||||
|
||||
const extractBool = (key: string): boolean => {
|
||||
const match = text.match(new RegExp(`<key>${key}</key>\\s*<(true|false)/>`));
|
||||
return match?.[1] === 'true';
|
||||
};
|
||||
|
||||
return {
|
||||
duration: extractReal('duration'),
|
||||
position: extractReal('position'),
|
||||
rate: extractReal('rate'),
|
||||
readyToPlay: extractBool('readyToPlay'),
|
||||
playbackBufferEmpty: extractBool('playbackBufferEmpty'),
|
||||
playbackBufferFull: extractBool('playbackBufferFull'),
|
||||
playbackLikelyToKeepUp: extractBool('playbackLikelyToKeepUp'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scrub position
|
||||
*/
|
||||
public async getScrubPosition(): Promise<{ position: number; duration: number }> {
|
||||
const response = await fetch(`http://${this.address}:${this.port}/scrub`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Get scrub position failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
|
||||
const durationMatch = text.match(/duration:\s*([\d.]+)/);
|
||||
const positionMatch = text.match(/position:\s*([\d.]+)/);
|
||||
|
||||
return {
|
||||
duration: durationMatch ? parseFloat(durationMatch[1]) : 0,
|
||||
position: positionMatch ? parseFloat(positionMatch[1]) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Device Info
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get speaker info
|
||||
*/
|
||||
public getSpeakerInfo(): IAirPlaySpeakerInfo {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
type: 'speaker',
|
||||
address: this.address,
|
||||
port: this.port,
|
||||
status: this.status,
|
||||
protocol: 'airplay',
|
||||
roomName: this._roomName,
|
||||
modelName: this._modelName,
|
||||
features: this._features,
|
||||
supportsVideo: this._supportsVideo,
|
||||
supportsAudio: this._supportsAudio,
|
||||
supportsScreen: this._supportsScreen,
|
||||
deviceId: this._deviceId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from mDNS discovery
|
||||
*/
|
||||
public static fromDiscovery(
|
||||
data: {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
port?: number;
|
||||
roomName?: string;
|
||||
modelName?: string;
|
||||
features?: number;
|
||||
deviceId?: string;
|
||||
},
|
||||
retryOptions?: IRetryOptions
|
||||
): AirPlaySpeaker {
|
||||
const info: IDeviceInfo = {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
type: 'speaker',
|
||||
address: data.address,
|
||||
port: data.port ?? 7000,
|
||||
status: 'unknown',
|
||||
};
|
||||
|
||||
return new AirPlaySpeaker(
|
||||
info,
|
||||
{
|
||||
roomName: data.roomName,
|
||||
modelName: data.modelName,
|
||||
features: data.features,
|
||||
deviceId: data.deviceId,
|
||||
},
|
||||
retryOptions
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe for AirPlay device
|
||||
*/
|
||||
public static async probe(address: string, port: number = 7000, timeout: number = 3000): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`http://${address}:${port}/server-info`, {
|
||||
signal: AbortSignal.timeout(timeout),
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,725 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { Speaker, type ITrackInfo, type IPlaybackStatus, type TPlaybackState, type ISpeakerInfo } from './speaker.classes.speaker.js';
|
||||
import type { IDeviceInfo, IRetryOptions } from '../interfaces/index.js';
|
||||
|
||||
/**
|
||||
* Chromecast device types
|
||||
*/
|
||||
export type TChromecastType = 'audio' | 'video' | 'group';
|
||||
|
||||
/**
|
||||
* Chromecast application IDs
|
||||
*/
|
||||
export const CHROMECAST_APPS = {
|
||||
DEFAULT_MEDIA_RECEIVER: 'CC1AD845',
|
||||
BACKDROP: 'E8C28D3C',
|
||||
YOUTUBE: '233637DE',
|
||||
NETFLIX: 'CA5E8412',
|
||||
PLEX: '9AC194DC',
|
||||
};
|
||||
|
||||
/**
|
||||
* Chromecast device info
|
||||
*/
|
||||
export interface IChromecastSpeakerInfo extends ISpeakerInfo {
|
||||
protocol: 'chromecast';
|
||||
friendlyName: string;
|
||||
deviceType: TChromecastType;
|
||||
capabilities: string[];
|
||||
currentAppId?: string;
|
||||
currentAppName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chromecast media metadata
|
||||
*/
|
||||
export interface IChromecastMediaMetadata {
|
||||
metadataType?: number;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
artist?: string;
|
||||
albumName?: string;
|
||||
albumArtist?: string;
|
||||
trackNumber?: number;
|
||||
discNumber?: number;
|
||||
images?: { url: string; width?: number; height?: number }[];
|
||||
releaseDate?: string;
|
||||
studio?: string;
|
||||
seriesTitle?: string;
|
||||
season?: number;
|
||||
episode?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chromecast media status
|
||||
*/
|
||||
export interface IChromecastMediaStatus {
|
||||
mediaSessionId: number;
|
||||
playbackRate: number;
|
||||
playerState: 'IDLE' | 'PLAYING' | 'PAUSED' | 'BUFFERING';
|
||||
currentTime: number;
|
||||
idleReason?: 'CANCELLED' | 'INTERRUPTED' | 'FINISHED' | 'ERROR';
|
||||
media?: {
|
||||
contentId: string;
|
||||
contentType: string;
|
||||
duration: number;
|
||||
metadata?: IChromecastMediaMetadata;
|
||||
};
|
||||
volume: {
|
||||
level: number;
|
||||
muted: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Chromecast Speaker device
|
||||
*/
|
||||
export class ChromecastSpeaker extends Speaker {
|
||||
private client: InstanceType<typeof plugins.castv2Client.Client> | null = null;
|
||||
private player: unknown = null;
|
||||
|
||||
private _friendlyName: string = '';
|
||||
private _deviceType: TChromecastType = 'audio';
|
||||
private _capabilities: string[] = [];
|
||||
private _currentAppId?: string;
|
||||
private _currentAppName?: string;
|
||||
private _mediaSessionId?: number;
|
||||
|
||||
constructor(
|
||||
info: IDeviceInfo,
|
||||
options?: {
|
||||
roomName?: string;
|
||||
modelName?: string;
|
||||
friendlyName?: string;
|
||||
deviceType?: TChromecastType;
|
||||
capabilities?: string[];
|
||||
},
|
||||
retryOptions?: IRetryOptions
|
||||
) {
|
||||
super(info, 'chromecast', options, retryOptions);
|
||||
this._friendlyName = options?.friendlyName || info.name;
|
||||
this._deviceType = options?.deviceType || 'audio';
|
||||
this._capabilities = options?.capabilities || [];
|
||||
}
|
||||
|
||||
// Getters
|
||||
public get friendlyName(): string {
|
||||
return this._friendlyName;
|
||||
}
|
||||
|
||||
public get deviceType(): TChromecastType {
|
||||
return this._deviceType;
|
||||
}
|
||||
|
||||
public get capabilities(): string[] {
|
||||
return this._capabilities;
|
||||
}
|
||||
|
||||
public get currentAppId(): string | undefined {
|
||||
return this._currentAppId;
|
||||
}
|
||||
|
||||
public get currentAppName(): string | undefined {
|
||||
return this._currentAppName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to Chromecast
|
||||
*/
|
||||
protected async doConnect(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.client = new plugins.castv2Client.Client();
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (this.client) {
|
||||
this.client.close();
|
||||
this.client = null;
|
||||
}
|
||||
reject(new Error('Connection timeout'));
|
||||
}, 10000);
|
||||
|
||||
this.client.on('error', (err: Error) => {
|
||||
clearTimeout(timeout);
|
||||
if (this.client) {
|
||||
this.client.close();
|
||||
this.client = null;
|
||||
}
|
||||
reject(err);
|
||||
});
|
||||
|
||||
this.client.connect(this.address, () => {
|
||||
clearTimeout(timeout);
|
||||
|
||||
// Get receiver status
|
||||
this.client!.getStatus((err: Error | null, status: { applications?: Array<{ appId: string; displayName: string }> }) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (status && status.applications && status.applications.length > 0) {
|
||||
const app = status.applications[0];
|
||||
this._currentAppId = app.appId;
|
||||
this._currentAppName = app.displayName;
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect
|
||||
*/
|
||||
protected async doDisconnect(): Promise<void> {
|
||||
if (this.client) {
|
||||
this.client.close();
|
||||
this.client = null;
|
||||
}
|
||||
this.player = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh status
|
||||
*/
|
||||
public async refreshStatus(): Promise<void> {
|
||||
if (!this.client) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.client!.getStatus((err: Error | null, status: {
|
||||
applications?: Array<{ appId: string; displayName: string }>;
|
||||
volume?: { level: number; muted: boolean };
|
||||
}) => {
|
||||
if (!err && status) {
|
||||
if (status.applications && status.applications.length > 0) {
|
||||
const app = status.applications[0];
|
||||
this._currentAppId = app.appId;
|
||||
this._currentAppName = app.displayName;
|
||||
}
|
||||
|
||||
if (status.volume) {
|
||||
this._volume = Math.round(status.volume.level * 100);
|
||||
this._muted = status.volume.muted;
|
||||
}
|
||||
}
|
||||
|
||||
this.emit('status:updated', this.getSpeakerInfo());
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch media receiver and get player
|
||||
*/
|
||||
private async getMediaPlayer(): Promise<InstanceType<typeof plugins.castv2Client.DefaultMediaReceiver>> {
|
||||
if (!this.client) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.client!.launch(plugins.castv2Client.DefaultMediaReceiver, (err: Error | null, player: InstanceType<typeof plugins.castv2Client.DefaultMediaReceiver>) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
this.player = player;
|
||||
|
||||
player.on('status', (status: IChromecastMediaStatus) => {
|
||||
this.handleMediaStatus(status);
|
||||
});
|
||||
|
||||
resolve(player);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle media status update
|
||||
*/
|
||||
private handleMediaStatus(status: IChromecastMediaStatus): void {
|
||||
if (!status) return;
|
||||
|
||||
this._mediaSessionId = status.mediaSessionId;
|
||||
|
||||
// Update playback state
|
||||
switch (status.playerState) {
|
||||
case 'PLAYING':
|
||||
this._playbackState = 'playing';
|
||||
break;
|
||||
case 'PAUSED':
|
||||
this._playbackState = 'paused';
|
||||
break;
|
||||
case 'BUFFERING':
|
||||
this._playbackState = 'transitioning';
|
||||
break;
|
||||
case 'IDLE':
|
||||
default:
|
||||
this._playbackState = 'stopped';
|
||||
break;
|
||||
}
|
||||
|
||||
// Update volume
|
||||
if (status.volume) {
|
||||
this._volume = Math.round(status.volume.level * 100);
|
||||
this._muted = status.volume.muted;
|
||||
}
|
||||
|
||||
this.emit('playback:status', status);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Playback Control
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Play media URL
|
||||
*/
|
||||
public async play(uri?: string): Promise<void> {
|
||||
if (!this.client) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
const player = await this.getMediaPlayer() as InstanceType<typeof plugins.castv2Client.DefaultMediaReceiver>;
|
||||
|
||||
if (uri) {
|
||||
// Determine content type
|
||||
const contentType = this.guessContentType(uri);
|
||||
|
||||
const media = {
|
||||
contentId: uri,
|
||||
contentType,
|
||||
streamType: 'BUFFERED' as const,
|
||||
metadata: {
|
||||
type: 0,
|
||||
metadataType: 0,
|
||||
title: uri.split('/').pop() || 'Media',
|
||||
},
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
player.load(media, { autoplay: true }, (err: Error | null) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
this._playbackState = 'playing';
|
||||
this.emit('playback:started');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// Resume playback
|
||||
return new Promise((resolve, reject) => {
|
||||
player.play((err: Error | null) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
this._playbackState = 'playing';
|
||||
this.emit('playback:started');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause playback
|
||||
*/
|
||||
public async pause(): Promise<void> {
|
||||
if (!this.player) {
|
||||
throw new Error('No active media session');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
(this.player as InstanceType<typeof plugins.castv2Client.DefaultMediaReceiver>).pause((err: Error | null) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
this._playbackState = 'paused';
|
||||
this.emit('playback:paused');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop playback
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
if (!this.player) {
|
||||
throw new Error('No active media session');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
(this.player as InstanceType<typeof plugins.castv2Client.DefaultMediaReceiver>).stop((err: Error | null) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
this._playbackState = 'stopped';
|
||||
this.emit('playback:stopped');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Next track (not supported)
|
||||
*/
|
||||
public async next(): Promise<void> {
|
||||
throw new Error('Next track not supported on basic Chromecast');
|
||||
}
|
||||
|
||||
/**
|
||||
* Previous track (not supported)
|
||||
*/
|
||||
public async previous(): Promise<void> {
|
||||
throw new Error('Previous track not supported on basic Chromecast');
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek to position
|
||||
*/
|
||||
public async seek(seconds: number): Promise<void> {
|
||||
if (!this.player) {
|
||||
throw new Error('No active media session');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
(this.player as InstanceType<typeof plugins.castv2Client.DefaultMediaReceiver>).seek(seconds, (err: Error | null) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
this.emit('playback:seeked', { position: seconds });
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Volume Control
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get volume
|
||||
*/
|
||||
public async getVolume(): Promise<number> {
|
||||
await this.refreshStatus();
|
||||
return this._volume;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set volume
|
||||
*/
|
||||
public async setVolume(level: number): Promise<void> {
|
||||
if (!this.client) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
const clamped = Math.max(0, Math.min(100, level));
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.client!.setVolume({ level: clamped / 100 }, (err: Error | null) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
this._volume = clamped;
|
||||
this.emit('volume:changed', { volume: clamped });
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mute state
|
||||
*/
|
||||
public async getMute(): Promise<boolean> {
|
||||
await this.refreshStatus();
|
||||
return this._muted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set mute state
|
||||
*/
|
||||
public async setMute(muted: boolean): Promise<void> {
|
||||
if (!this.client) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.client!.setVolume({ muted }, (err: Error | null) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
this._muted = muted;
|
||||
this.emit('mute:changed', { muted });
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Track Information
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get current track
|
||||
*/
|
||||
public async getCurrentTrack(): Promise<ITrackInfo | null> {
|
||||
if (!this.player) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
(this.player as InstanceType<typeof plugins.castv2Client.DefaultMediaReceiver>).getStatus((err: Error | null, status: IChromecastMediaStatus) => {
|
||||
if (err || !status || !status.media) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const media = status.media;
|
||||
const metadata = media.metadata;
|
||||
|
||||
resolve({
|
||||
title: metadata?.title || 'Unknown',
|
||||
artist: metadata?.artist,
|
||||
album: metadata?.albumName,
|
||||
duration: media.duration || 0,
|
||||
position: status.currentTime || 0,
|
||||
albumArtUri: metadata?.images?.[0]?.url,
|
||||
uri: media.contentId,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get playback status
|
||||
*/
|
||||
public async getPlaybackStatus(): Promise<IPlaybackStatus> {
|
||||
await this.refreshStatus();
|
||||
|
||||
return {
|
||||
state: this._playbackState,
|
||||
volume: this._volume,
|
||||
muted: this._muted,
|
||||
track: await this.getCurrentTrack() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Chromecast-specific Methods
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Launch an application
|
||||
*/
|
||||
public async launchApp(appId: string): Promise<void> {
|
||||
if (!this.client) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.client!.launch({ id: appId } as Parameters<typeof plugins.castv2Client.Client.prototype.launch>[0], (err: Error | null) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
this._currentAppId = appId;
|
||||
this.emit('app:launched', { appId });
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop current application
|
||||
*/
|
||||
public async stopApp(): Promise<void> {
|
||||
if (!this.client) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.client!.stop(this.player as InstanceType<typeof plugins.castv2Client.DefaultMediaReceiver>, (err: Error | null) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
this._currentAppId = undefined;
|
||||
this._currentAppName = undefined;
|
||||
this.player = null;
|
||||
this.emit('app:stopped');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get receiver status
|
||||
*/
|
||||
public async getReceiverStatus(): Promise<{
|
||||
applications?: Array<{ appId: string; displayName: string }>;
|
||||
volume: { level: number; muted: boolean };
|
||||
}> {
|
||||
if (!this.client) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.client!.getStatus((err: Error | null, status: {
|
||||
applications?: Array<{ appId: string; displayName: string }>;
|
||||
volume: { level: number; muted: boolean };
|
||||
}) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(status);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Guess content type from URL
|
||||
*/
|
||||
private guessContentType(url: string): string {
|
||||
const ext = url.split('.').pop()?.toLowerCase();
|
||||
|
||||
switch (ext) {
|
||||
case 'mp3':
|
||||
return 'audio/mpeg';
|
||||
case 'mp4':
|
||||
case 'm4v':
|
||||
return 'video/mp4';
|
||||
case 'webm':
|
||||
return 'video/webm';
|
||||
case 'mkv':
|
||||
return 'video/x-matroska';
|
||||
case 'ogg':
|
||||
return 'audio/ogg';
|
||||
case 'flac':
|
||||
return 'audio/flac';
|
||||
case 'wav':
|
||||
return 'audio/wav';
|
||||
case 'm3u8':
|
||||
return 'application/x-mpegURL';
|
||||
case 'mpd':
|
||||
return 'application/dash+xml';
|
||||
default:
|
||||
return 'video/mp4';
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Device Info
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get speaker info
|
||||
*/
|
||||
public getSpeakerInfo(): IChromecastSpeakerInfo {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
type: 'speaker',
|
||||
address: this.address,
|
||||
port: this.port,
|
||||
status: this.status,
|
||||
protocol: 'chromecast',
|
||||
roomName: this._roomName,
|
||||
modelName: this._modelName,
|
||||
friendlyName: this._friendlyName,
|
||||
deviceType: this._deviceType,
|
||||
capabilities: this._capabilities,
|
||||
currentAppId: this._currentAppId,
|
||||
currentAppName: this._currentAppName,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from mDNS discovery
|
||||
*/
|
||||
public static fromDiscovery(
|
||||
data: {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
port?: number;
|
||||
roomName?: string;
|
||||
modelName?: string;
|
||||
friendlyName?: string;
|
||||
deviceType?: TChromecastType;
|
||||
capabilities?: string[];
|
||||
},
|
||||
retryOptions?: IRetryOptions
|
||||
): ChromecastSpeaker {
|
||||
const info: IDeviceInfo = {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
type: 'speaker',
|
||||
address: data.address,
|
||||
port: data.port ?? 8009,
|
||||
status: 'unknown',
|
||||
};
|
||||
|
||||
return new ChromecastSpeaker(
|
||||
info,
|
||||
{
|
||||
roomName: data.roomName,
|
||||
modelName: data.modelName,
|
||||
friendlyName: data.friendlyName,
|
||||
deviceType: data.deviceType,
|
||||
capabilities: data.capabilities,
|
||||
},
|
||||
retryOptions
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe for Chromecast device
|
||||
*/
|
||||
public static async probe(address: string, port: number = 8009, timeout: number = 5000): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const client = new plugins.castv2Client.Client();
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
client.close();
|
||||
resolve(false);
|
||||
}, timeout);
|
||||
|
||||
client.on('error', () => {
|
||||
clearTimeout(timer);
|
||||
client.close();
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
client.connect(address, () => {
|
||||
clearTimeout(timer);
|
||||
client.close();
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,654 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { Speaker, type ITrackInfo, type IPlaybackStatus, type TPlaybackState, type ISpeakerInfo } from './speaker.classes.speaker.js';
|
||||
import type { IDeviceInfo, IRetryOptions } from '../interfaces/index.js';
|
||||
|
||||
/**
|
||||
* Sonos zone (room) information
|
||||
*/
|
||||
export interface ISonosZoneInfo {
|
||||
name: string;
|
||||
uuid: string;
|
||||
coordinator: boolean;
|
||||
groupId: string;
|
||||
members: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sonos speaker device info
|
||||
*/
|
||||
export interface ISonosSpeakerInfo extends ISpeakerInfo {
|
||||
protocol: 'sonos';
|
||||
zoneName: string;
|
||||
zoneUuid: string;
|
||||
isCoordinator: boolean;
|
||||
groupId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sonos Speaker device
|
||||
*/
|
||||
export class SonosSpeaker extends Speaker {
|
||||
private device: InstanceType<typeof plugins.sonos.Sonos> | null = null;
|
||||
|
||||
private _zoneName: string = '';
|
||||
private _zoneUuid: string = '';
|
||||
private _isCoordinator: boolean = false;
|
||||
private _groupId?: string;
|
||||
|
||||
constructor(
|
||||
info: IDeviceInfo,
|
||||
options?: {
|
||||
roomName?: string;
|
||||
modelName?: string;
|
||||
},
|
||||
retryOptions?: IRetryOptions
|
||||
) {
|
||||
super(info, 'sonos', options, retryOptions);
|
||||
}
|
||||
|
||||
// Getters
|
||||
public get zoneName(): string {
|
||||
return this._zoneName;
|
||||
}
|
||||
|
||||
public get zoneUuid(): string {
|
||||
return this._zoneUuid;
|
||||
}
|
||||
|
||||
public get isCoordinator(): boolean {
|
||||
return this._isCoordinator;
|
||||
}
|
||||
|
||||
public get groupId(): string | undefined {
|
||||
return this._groupId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to Sonos device
|
||||
*/
|
||||
protected async doConnect(): Promise<void> {
|
||||
this.device = new plugins.sonos.Sonos(this.address, this.port);
|
||||
|
||||
// Get device info
|
||||
try {
|
||||
const zoneInfo = await this.device.getZoneInfo();
|
||||
this._zoneName = zoneInfo.ZoneName || '';
|
||||
this._roomName = this._zoneName;
|
||||
|
||||
const attrs = await this.device.getZoneAttrs();
|
||||
this._zoneUuid = attrs.CurrentZoneName || '';
|
||||
} catch (error) {
|
||||
// Some info may not be available
|
||||
}
|
||||
|
||||
// Get device description
|
||||
try {
|
||||
const desc = await this.device.deviceDescription();
|
||||
this._modelName = desc.modelName;
|
||||
this.model = desc.modelName;
|
||||
this.manufacturer = desc.manufacturer;
|
||||
this.serialNumber = desc.serialNum;
|
||||
} catch {
|
||||
// Optional info
|
||||
}
|
||||
|
||||
// Get current state
|
||||
await this.refreshStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect
|
||||
*/
|
||||
protected async doDisconnect(): Promise<void> {
|
||||
this.device = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh status
|
||||
*/
|
||||
public async refreshStatus(): Promise<void> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
try {
|
||||
const [volume, muted, state] = await Promise.all([
|
||||
this.device.getVolume(),
|
||||
this.device.getMuted(),
|
||||
this.device.getCurrentState(),
|
||||
]);
|
||||
|
||||
this._volume = volume;
|
||||
this._muted = muted;
|
||||
this._playbackState = this.mapSonosState(state);
|
||||
} catch {
|
||||
// Status refresh failed
|
||||
}
|
||||
|
||||
this.emit('status:updated', this.getSpeakerInfo());
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Sonos state to our state
|
||||
*/
|
||||
private mapSonosState(state: string): TPlaybackState {
|
||||
switch (state.toLowerCase()) {
|
||||
case 'playing':
|
||||
return 'playing';
|
||||
case 'paused':
|
||||
case 'paused_playback':
|
||||
return 'paused';
|
||||
case 'stopped':
|
||||
return 'stopped';
|
||||
case 'transitioning':
|
||||
return 'transitioning';
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Playback Control
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Play
|
||||
*/
|
||||
public async play(uri?: string): Promise<void> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
if (uri) {
|
||||
await this.device.play(uri);
|
||||
} else {
|
||||
await this.device.play();
|
||||
}
|
||||
|
||||
this._playbackState = 'playing';
|
||||
this.emit('playback:started');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause
|
||||
*/
|
||||
public async pause(): Promise<void> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
await this.device.pause();
|
||||
this._playbackState = 'paused';
|
||||
this.emit('playback:paused');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
await this.device.stop();
|
||||
this._playbackState = 'stopped';
|
||||
this.emit('playback:stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Next track
|
||||
*/
|
||||
public async next(): Promise<void> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
await this.device.next();
|
||||
this.emit('playback:next');
|
||||
}
|
||||
|
||||
/**
|
||||
* Previous track
|
||||
*/
|
||||
public async previous(): Promise<void> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
await this.device.previous();
|
||||
this.emit('playback:previous');
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek to position
|
||||
*/
|
||||
public async seek(seconds: number): Promise<void> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
await this.device.seek(seconds);
|
||||
this.emit('playback:seeked', { position: seconds });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Volume Control
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get volume
|
||||
*/
|
||||
public async getVolume(): Promise<number> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
const volume = await this.device.getVolume();
|
||||
this._volume = volume;
|
||||
return volume;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set volume
|
||||
*/
|
||||
public async setVolume(level: number): Promise<void> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
const clamped = Math.max(0, Math.min(100, level));
|
||||
await this.device.setVolume(clamped);
|
||||
this._volume = clamped;
|
||||
this.emit('volume:changed', { volume: clamped });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mute state
|
||||
*/
|
||||
public async getMute(): Promise<boolean> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
const muted = await this.device.getMuted();
|
||||
this._muted = muted;
|
||||
return muted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set mute state
|
||||
*/
|
||||
public async setMute(muted: boolean): Promise<void> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
await this.device.setMuted(muted);
|
||||
this._muted = muted;
|
||||
this.emit('mute:changed', { muted });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Track Information
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get current track
|
||||
*/
|
||||
public async getCurrentTrack(): Promise<ITrackInfo | null> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
try {
|
||||
const track = await this.device.currentTrack();
|
||||
|
||||
if (!track) return null;
|
||||
|
||||
return {
|
||||
title: track.title || 'Unknown',
|
||||
artist: track.artist,
|
||||
album: track.album,
|
||||
duration: track.duration || 0,
|
||||
position: track.position || 0,
|
||||
albumArtUri: track.albumArtURI || track.albumArtURL,
|
||||
uri: track.uri,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get playback status
|
||||
*/
|
||||
public async getPlaybackStatus(): Promise<IPlaybackStatus> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
const [state, volume, muted, track] = await Promise.all([
|
||||
this.device.getCurrentState(),
|
||||
this.device.getVolume(),
|
||||
this.device.getMuted(),
|
||||
this.getCurrentTrack(),
|
||||
]);
|
||||
|
||||
return {
|
||||
state: this.mapSonosState(state),
|
||||
volume,
|
||||
muted,
|
||||
track: track || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Sonos-specific Features
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Play from queue
|
||||
*/
|
||||
public async playFromQueue(index: number): Promise<void> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
await this.device.selectQueue();
|
||||
await this.device.selectTrack(index);
|
||||
await this.device.play();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add URI to queue
|
||||
*/
|
||||
public async addToQueue(uri: string, positionInQueue?: number): Promise<void> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
await this.device.queue(uri, positionInQueue);
|
||||
this.emit('queue:added', { uri, position: positionInQueue });
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear queue
|
||||
*/
|
||||
public async clearQueue(): Promise<void> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
await this.device.flush();
|
||||
this.emit('queue:cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue contents
|
||||
*/
|
||||
public async getQueue(): Promise<ITrackInfo[]> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
const queue = await this.device.getQueue();
|
||||
|
||||
if (!queue || !queue.items) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return queue.items.map((item: { title?: string; artist?: string; album?: string; albumArtURI?: string; uri?: string }) => ({
|
||||
title: item.title || 'Unknown',
|
||||
artist: item.artist,
|
||||
album: item.album,
|
||||
duration: 0,
|
||||
position: 0,
|
||||
albumArtUri: item.albumArtURI,
|
||||
uri: item.uri,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a Sonos playlist
|
||||
*/
|
||||
public async playPlaylist(playlistName: string): Promise<void> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
const playlists = await this.device.getMusicLibrary('sonos_playlists');
|
||||
const playlist = playlists.items?.find((p: { title?: string }) =>
|
||||
p.title?.toLowerCase().includes(playlistName.toLowerCase())
|
||||
);
|
||||
|
||||
if (playlist && playlist.uri) {
|
||||
await this.device.play(playlist.uri);
|
||||
} else {
|
||||
throw new Error(`Playlist "${playlistName}" not found`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play favorite by name
|
||||
*/
|
||||
public async playFavorite(favoriteName: string): Promise<void> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
const favorites = await this.device.getFavorites();
|
||||
const favorite = favorites.items?.find((f: { title?: string }) =>
|
||||
f.title?.toLowerCase().includes(favoriteName.toLowerCase())
|
||||
);
|
||||
|
||||
if (favorite && favorite.uri) {
|
||||
await this.device.play(favorite.uri);
|
||||
} else {
|
||||
throw new Error(`Favorite "${favoriteName}" not found`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get favorites
|
||||
*/
|
||||
public async getFavorites(): Promise<{ title: string; uri: string; albumArtUri?: string }[]> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
const favorites = await this.device.getFavorites();
|
||||
|
||||
if (!favorites.items) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return favorites.items.map((f: { title?: string; uri?: string; albumArtURI?: string }) => ({
|
||||
title: f.title || 'Unknown',
|
||||
uri: f.uri || '',
|
||||
albumArtUri: f.albumArtURI,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Play TuneIn radio station by ID
|
||||
*/
|
||||
public async playTuneInRadio(stationId: string): Promise<void> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
await this.device.playTuneinRadio(stationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Play Spotify URI
|
||||
*/
|
||||
public async playSpotify(spotifyUri: string): Promise<void> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
await this.device.play(spotifyUri);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Grouping
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Join another speaker's group
|
||||
*/
|
||||
public async joinGroup(coordinatorAddress: string): Promise<void> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
const coordinator = new plugins.sonos.Sonos(coordinatorAddress);
|
||||
await this.device.joinGroup(await coordinator.getName());
|
||||
this.emit('group:joined', { coordinator: coordinatorAddress });
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave current group
|
||||
*/
|
||||
public async leaveGroup(): Promise<void> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
await this.device.leaveGroup();
|
||||
this.emit('group:left');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get group information
|
||||
*/
|
||||
public async getGroupInfo(): Promise<ISonosZoneInfo | null> {
|
||||
if (!this.device) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
try {
|
||||
const groups = await this.device.getAllGroups();
|
||||
|
||||
// Find our group
|
||||
for (const group of groups) {
|
||||
const members = group.ZoneGroupMember || [];
|
||||
const memberArray = Array.isArray(members) ? members : [members];
|
||||
|
||||
for (const member of memberArray) {
|
||||
if (member.Location?.includes(this.address)) {
|
||||
const coordinator = memberArray.find((m: { UUID?: string }) => m.UUID === group.Coordinator);
|
||||
|
||||
return {
|
||||
name: group.Name || 'Group',
|
||||
uuid: group.Coordinator || '',
|
||||
coordinator: member.UUID === group.Coordinator,
|
||||
groupId: group.ID || '',
|
||||
members: memberArray.map((m: { ZoneName?: string }) => m.ZoneName || ''),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Device Info
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get speaker info
|
||||
*/
|
||||
public getSpeakerInfo(): ISonosSpeakerInfo {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
type: 'speaker',
|
||||
address: this.address,
|
||||
port: this.port,
|
||||
status: this.status,
|
||||
protocol: 'sonos',
|
||||
roomName: this._roomName,
|
||||
modelName: this._modelName,
|
||||
zoneName: this._zoneName,
|
||||
zoneUuid: this._zoneUuid,
|
||||
isCoordinator: this._isCoordinator,
|
||||
groupId: this._groupId,
|
||||
supportsGrouping: true,
|
||||
isGroupCoordinator: this._isCoordinator,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from discovery
|
||||
*/
|
||||
public static fromDiscovery(
|
||||
data: {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
port?: number;
|
||||
roomName?: string;
|
||||
modelName?: string;
|
||||
},
|
||||
retryOptions?: IRetryOptions
|
||||
): SonosSpeaker {
|
||||
const info: IDeviceInfo = {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
type: 'speaker',
|
||||
address: data.address,
|
||||
port: data.port ?? 1400,
|
||||
status: 'unknown',
|
||||
};
|
||||
|
||||
return new SonosSpeaker(
|
||||
info,
|
||||
{
|
||||
roomName: data.roomName,
|
||||
modelName: data.modelName,
|
||||
},
|
||||
retryOptions
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover Sonos devices on the network
|
||||
*/
|
||||
public static async discover(timeout: number = 5000): Promise<SonosSpeaker[]> {
|
||||
return new Promise((resolve) => {
|
||||
const speakers: SonosSpeaker[] = [];
|
||||
const discovery = new plugins.sonos.AsyncDeviceDiscovery();
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
resolve(speakers);
|
||||
}, timeout);
|
||||
|
||||
discovery.discover().then((device: { host: string; port: number }) => {
|
||||
clearTimeout(timer);
|
||||
|
||||
const speaker = new SonosSpeaker(
|
||||
{
|
||||
id: `sonos:${device.host}`,
|
||||
name: `Sonos ${device.host}`,
|
||||
type: 'speaker',
|
||||
address: device.host,
|
||||
port: device.port || 1400,
|
||||
status: 'unknown',
|
||||
}
|
||||
);
|
||||
|
||||
speakers.push(speaker);
|
||||
resolve(speakers);
|
||||
}).catch(() => {
|
||||
clearTimeout(timer);
|
||||
resolve(speakers);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { Device } from '../abstract/device.abstract.js';
|
||||
import type { IDeviceInfo, IRetryOptions } from '../interfaces/index.js';
|
||||
|
||||
/**
|
||||
* Speaker protocol types
|
||||
*/
|
||||
export type TSpeakerProtocol = 'sonos' | 'airplay' | 'chromecast' | 'dlna';
|
||||
|
||||
/**
|
||||
* Playback state
|
||||
*/
|
||||
export type TPlaybackState = 'playing' | 'paused' | 'stopped' | 'transitioning' | 'unknown';
|
||||
|
||||
/**
|
||||
* Track information
|
||||
*/
|
||||
export interface ITrackInfo {
|
||||
title: string;
|
||||
artist?: string;
|
||||
album?: string;
|
||||
duration: number; // seconds
|
||||
position: number; // seconds
|
||||
albumArtUri?: string;
|
||||
uri?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Speaker playback status
|
||||
*/
|
||||
export interface IPlaybackStatus {
|
||||
state: TPlaybackState;
|
||||
volume: number; // 0-100
|
||||
muted: boolean;
|
||||
track?: ITrackInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Speaker device info
|
||||
*/
|
||||
export interface ISpeakerInfo extends IDeviceInfo {
|
||||
type: 'speaker';
|
||||
protocol: TSpeakerProtocol;
|
||||
roomName?: string;
|
||||
modelName?: string;
|
||||
supportsGrouping?: boolean;
|
||||
groupId?: string;
|
||||
isGroupCoordinator?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract Speaker base class
|
||||
* Common interface for all speaker types (Sonos, AirPlay, Chromecast)
|
||||
*/
|
||||
export abstract class Speaker extends Device {
|
||||
protected _protocol: TSpeakerProtocol;
|
||||
protected _roomName?: string;
|
||||
protected _modelName?: string;
|
||||
protected _volume: number = 0;
|
||||
protected _muted: boolean = false;
|
||||
protected _playbackState: TPlaybackState = 'unknown';
|
||||
|
||||
constructor(
|
||||
info: IDeviceInfo,
|
||||
protocol: TSpeakerProtocol,
|
||||
options?: {
|
||||
roomName?: string;
|
||||
modelName?: string;
|
||||
},
|
||||
retryOptions?: IRetryOptions
|
||||
) {
|
||||
super(info, retryOptions);
|
||||
this._protocol = protocol;
|
||||
this._roomName = options?.roomName;
|
||||
this._modelName = options?.modelName;
|
||||
}
|
||||
|
||||
// Getters
|
||||
public get protocol(): TSpeakerProtocol {
|
||||
return this._protocol;
|
||||
}
|
||||
|
||||
public get roomName(): string | undefined {
|
||||
return this._roomName;
|
||||
}
|
||||
|
||||
public get speakerModelName(): string | undefined {
|
||||
return this._modelName;
|
||||
}
|
||||
|
||||
public get volume(): number {
|
||||
return this._volume;
|
||||
}
|
||||
|
||||
public get muted(): boolean {
|
||||
return this._muted;
|
||||
}
|
||||
|
||||
public get playbackState(): TPlaybackState {
|
||||
return this._playbackState;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Abstract Methods - Must be implemented by subclasses
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Play media from URI
|
||||
*/
|
||||
public abstract play(uri?: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Pause playback
|
||||
*/
|
||||
public abstract pause(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Stop playback
|
||||
*/
|
||||
public abstract stop(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Next track
|
||||
*/
|
||||
public abstract next(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Previous track
|
||||
*/
|
||||
public abstract previous(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Seek to position
|
||||
*/
|
||||
public abstract seek(seconds: number): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get volume level (0-100)
|
||||
*/
|
||||
public abstract getVolume(): Promise<number>;
|
||||
|
||||
/**
|
||||
* Set volume level (0-100)
|
||||
*/
|
||||
public abstract setVolume(level: number): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get mute state
|
||||
*/
|
||||
public abstract getMute(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Set mute state
|
||||
*/
|
||||
public abstract setMute(muted: boolean): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get current track info
|
||||
*/
|
||||
public abstract getCurrentTrack(): Promise<ITrackInfo | null>;
|
||||
|
||||
/**
|
||||
* Get playback status
|
||||
*/
|
||||
public abstract getPlaybackStatus(): Promise<IPlaybackStatus>;
|
||||
|
||||
// ============================================================================
|
||||
// Common Methods
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Toggle mute
|
||||
*/
|
||||
public async toggleMute(): Promise<boolean> {
|
||||
const currentMute = await this.getMute();
|
||||
await this.setMute(!currentMute);
|
||||
return !currentMute;
|
||||
}
|
||||
|
||||
/**
|
||||
* Volume up
|
||||
*/
|
||||
public async volumeUp(step: number = 5): Promise<number> {
|
||||
const current = await this.getVolume();
|
||||
const newVolume = Math.min(100, current + step);
|
||||
await this.setVolume(newVolume);
|
||||
return newVolume;
|
||||
}
|
||||
|
||||
/**
|
||||
* Volume down
|
||||
*/
|
||||
public async volumeDown(step: number = 5): Promise<number> {
|
||||
const current = await this.getVolume();
|
||||
const newVolume = Math.max(0, current - step);
|
||||
await this.setVolume(newVolume);
|
||||
return newVolume;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get speaker info
|
||||
*/
|
||||
public getSpeakerInfo(): ISpeakerInfo {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
type: 'speaker',
|
||||
address: this.address,
|
||||
port: this.port,
|
||||
status: this.status,
|
||||
protocol: this._protocol,
|
||||
roomName: this._roomName,
|
||||
modelName: this._modelName,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,548 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { Device } from '../abstract/device.abstract.js';
|
||||
import { NutProtocol, NUT_COMMANDS, NUT_VARIABLES, type TNutStatusFlag } from './ups.classes.nutprotocol.js';
|
||||
import { UpsSnmpHandler, type TUpsBatteryStatus, type TUpsOutputSource } from './ups.classes.upssnmp.js';
|
||||
import type { IDeviceInfo, IRetryOptions } from '../interfaces/index.js';
|
||||
|
||||
/**
|
||||
* UPS status enumeration
|
||||
*/
|
||||
export type TUpsStatus = 'online' | 'onbattery' | 'lowbattery' | 'charging' | 'discharging' | 'bypass' | 'offline' | 'error' | 'unknown';
|
||||
|
||||
/**
|
||||
* UPS protocol type
|
||||
*/
|
||||
export type TUpsProtocol = 'nut' | 'snmp';
|
||||
|
||||
/**
|
||||
* UPS device information
|
||||
*/
|
||||
export interface IUpsDeviceInfo extends IDeviceInfo {
|
||||
type: 'ups';
|
||||
protocol: TUpsProtocol;
|
||||
upsName?: string; // NUT ups name
|
||||
manufacturer: string;
|
||||
model: string;
|
||||
serialNumber?: string;
|
||||
firmwareVersion?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* UPS battery information
|
||||
*/
|
||||
export interface IUpsBatteryInfo {
|
||||
charge: number; // 0-100%
|
||||
runtime: number; // seconds remaining
|
||||
voltage: number; // volts
|
||||
temperature?: number; // celsius
|
||||
status: 'normal' | 'low' | 'depleted' | 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* UPS input/output power info
|
||||
*/
|
||||
export interface IUpsPowerInfo {
|
||||
inputVoltage: number;
|
||||
inputFrequency?: number;
|
||||
outputVoltage: number;
|
||||
outputFrequency?: number;
|
||||
outputCurrent?: number;
|
||||
outputPower?: number;
|
||||
load: number; // 0-100%
|
||||
}
|
||||
|
||||
/**
|
||||
* Full UPS status
|
||||
*/
|
||||
export interface IUpsFullStatus {
|
||||
status: TUpsStatus;
|
||||
battery: IUpsBatteryInfo;
|
||||
power: IUpsPowerInfo;
|
||||
alarms: string[];
|
||||
secondsOnBattery: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* UPS Device class supporting both NUT and SNMP protocols
|
||||
*/
|
||||
export class UpsDevice extends Device {
|
||||
private nutProtocol: NutProtocol | null = null;
|
||||
private snmpHandler: UpsSnmpHandler | null = null;
|
||||
private upsProtocol: TUpsProtocol;
|
||||
private upsName: string;
|
||||
private snmpCommunity: string;
|
||||
|
||||
private _upsStatus: TUpsStatus = 'unknown';
|
||||
private _manufacturer: string = '';
|
||||
private _model: string = '';
|
||||
private _batteryCharge: number = 0;
|
||||
private _batteryRuntime: number = 0;
|
||||
private _inputVoltage: number = 0;
|
||||
private _outputVoltage: number = 0;
|
||||
private _load: number = 0;
|
||||
|
||||
constructor(
|
||||
info: IDeviceInfo,
|
||||
options: {
|
||||
protocol: TUpsProtocol;
|
||||
upsName?: string; // Required for NUT
|
||||
snmpCommunity?: string; // For SNMP
|
||||
},
|
||||
retryOptions?: IRetryOptions
|
||||
) {
|
||||
super(info, retryOptions);
|
||||
this.upsProtocol = options.protocol;
|
||||
this.upsName = options.upsName || 'ups';
|
||||
this.snmpCommunity = options.snmpCommunity || 'public';
|
||||
}
|
||||
|
||||
// Getters for UPS properties
|
||||
public get upsStatus(): TUpsStatus {
|
||||
return this._upsStatus;
|
||||
}
|
||||
|
||||
public get upsManufacturer(): string {
|
||||
return this._manufacturer;
|
||||
}
|
||||
|
||||
public get upsModel(): string {
|
||||
return this._model;
|
||||
}
|
||||
|
||||
public get batteryCharge(): number {
|
||||
return this._batteryCharge;
|
||||
}
|
||||
|
||||
public get batteryRuntime(): number {
|
||||
return this._batteryRuntime;
|
||||
}
|
||||
|
||||
public get inputVoltage(): number {
|
||||
return this._inputVoltage;
|
||||
}
|
||||
|
||||
public get outputVoltage(): number {
|
||||
return this._outputVoltage;
|
||||
}
|
||||
|
||||
public get load(): number {
|
||||
return this._load;
|
||||
}
|
||||
|
||||
public get protocol(): TUpsProtocol {
|
||||
return this.upsProtocol;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to UPS
|
||||
*/
|
||||
protected async doConnect(): Promise<void> {
|
||||
if (this.upsProtocol === 'nut') {
|
||||
await this.connectNut();
|
||||
} else {
|
||||
await this.connectSnmp();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect via NUT protocol
|
||||
*/
|
||||
private async connectNut(): Promise<void> {
|
||||
this.nutProtocol = new NutProtocol(this.address, this.port);
|
||||
await this.nutProtocol.connect();
|
||||
|
||||
// Get device info
|
||||
const deviceInfo = await this.nutProtocol.getDeviceInfo(this.upsName);
|
||||
this._manufacturer = deviceInfo.manufacturer;
|
||||
this._model = deviceInfo.model;
|
||||
this.manufacturer = deviceInfo.manufacturer;
|
||||
this.model = deviceInfo.model;
|
||||
this.serialNumber = deviceInfo.serial;
|
||||
|
||||
// Get initial status
|
||||
await this.refreshStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect via SNMP protocol
|
||||
*/
|
||||
private async connectSnmp(): Promise<void> {
|
||||
this.snmpHandler = new UpsSnmpHandler(this.address, {
|
||||
community: this.snmpCommunity,
|
||||
port: this.port,
|
||||
});
|
||||
|
||||
// Verify it's a UPS
|
||||
const isUps = await this.snmpHandler.isUpsDevice();
|
||||
if (!isUps) {
|
||||
this.snmpHandler.close();
|
||||
this.snmpHandler = null;
|
||||
throw new Error('Device does not support UPS-MIB');
|
||||
}
|
||||
|
||||
// Get identity
|
||||
const identity = await this.snmpHandler.getIdentity();
|
||||
this._manufacturer = identity.manufacturer;
|
||||
this._model = identity.model;
|
||||
this.manufacturer = identity.manufacturer;
|
||||
this.model = identity.model;
|
||||
this.firmwareVersion = identity.softwareVersion;
|
||||
|
||||
// Get initial status
|
||||
await this.refreshStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from UPS
|
||||
*/
|
||||
protected async doDisconnect(): Promise<void> {
|
||||
if (this.nutProtocol) {
|
||||
await this.nutProtocol.disconnect();
|
||||
this.nutProtocol = null;
|
||||
}
|
||||
if (this.snmpHandler) {
|
||||
this.snmpHandler.close();
|
||||
this.snmpHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh UPS status
|
||||
*/
|
||||
public async refreshStatus(): Promise<void> {
|
||||
if (this.upsProtocol === 'nut' && this.nutProtocol) {
|
||||
await this.refreshNutStatus();
|
||||
} else if (this.snmpHandler) {
|
||||
await this.refreshSnmpStatus();
|
||||
} else {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
this.emit('status:updated', this.getDeviceInfo());
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh status via NUT
|
||||
*/
|
||||
private async refreshNutStatus(): Promise<void> {
|
||||
if (!this.nutProtocol) return;
|
||||
|
||||
const status = await this.nutProtocol.getUpsStatus(this.upsName);
|
||||
|
||||
this._batteryCharge = status.batteryCharge;
|
||||
this._batteryRuntime = status.batteryRuntime;
|
||||
this._inputVoltage = status.inputVoltage;
|
||||
this._outputVoltage = status.outputVoltage;
|
||||
this._load = status.load;
|
||||
|
||||
// Convert NUT status flags to our status
|
||||
this._upsStatus = this.nutStatusToUpsStatus(status.status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh status via SNMP
|
||||
*/
|
||||
private async refreshSnmpStatus(): Promise<void> {
|
||||
if (!this.snmpHandler) return;
|
||||
|
||||
const status = await this.snmpHandler.getFullStatus();
|
||||
|
||||
this._batteryCharge = status.estimatedChargeRemaining;
|
||||
this._batteryRuntime = status.estimatedMinutesRemaining * 60; // Convert to seconds
|
||||
this._inputVoltage = status.inputVoltage;
|
||||
this._outputVoltage = status.outputVoltage;
|
||||
this._load = status.outputPercentLoad;
|
||||
|
||||
// Convert SNMP status to our status
|
||||
this._upsStatus = this.snmpStatusToUpsStatus(status.outputSource, status.batteryStatus);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert NUT status flags to TUpsStatus
|
||||
*/
|
||||
private nutStatusToUpsStatus(flags: TNutStatusFlag[]): TUpsStatus {
|
||||
if (flags.includes('OFF')) return 'offline';
|
||||
if (flags.includes('LB')) return 'lowbattery';
|
||||
if (flags.includes('OB')) return 'onbattery';
|
||||
if (flags.includes('BYPASS')) return 'bypass';
|
||||
if (flags.includes('CHRG')) return 'charging';
|
||||
if (flags.includes('DISCHRG')) return 'discharging';
|
||||
if (flags.includes('OL')) return 'online';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert SNMP status to TUpsStatus
|
||||
*/
|
||||
private snmpStatusToUpsStatus(source: TUpsOutputSource, battery: TUpsBatteryStatus): TUpsStatus {
|
||||
if (source === 'none') return 'offline';
|
||||
if (source === 'battery') {
|
||||
if (battery === 'batteryLow') return 'lowbattery';
|
||||
if (battery === 'batteryDepleted') return 'lowbattery';
|
||||
return 'onbattery';
|
||||
}
|
||||
if (source === 'bypass') return 'bypass';
|
||||
if (source === 'normal') return 'online';
|
||||
if (source === 'booster' || source === 'reducer') return 'online';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get battery information
|
||||
*/
|
||||
public async getBatteryInfo(): Promise<IUpsBatteryInfo> {
|
||||
if (this.upsProtocol === 'nut' && this.nutProtocol) {
|
||||
const vars = await this.nutProtocol.getVariables(this.upsName, [
|
||||
NUT_VARIABLES.batteryCharge,
|
||||
NUT_VARIABLES.batteryRuntime,
|
||||
NUT_VARIABLES.batteryVoltage,
|
||||
NUT_VARIABLES.batteryTemperature,
|
||||
]);
|
||||
|
||||
return {
|
||||
charge: parseFloat(vars.get(NUT_VARIABLES.batteryCharge) || '0'),
|
||||
runtime: parseFloat(vars.get(NUT_VARIABLES.batteryRuntime) || '0'),
|
||||
voltage: parseFloat(vars.get(NUT_VARIABLES.batteryVoltage) || '0'),
|
||||
temperature: vars.has(NUT_VARIABLES.batteryTemperature)
|
||||
? parseFloat(vars.get(NUT_VARIABLES.batteryTemperature)!)
|
||||
: undefined,
|
||||
status: 'normal',
|
||||
};
|
||||
} else if (this.snmpHandler) {
|
||||
const battery = await this.snmpHandler.getBatteryStatus();
|
||||
|
||||
const statusMap: Record<TUpsBatteryStatus, IUpsBatteryInfo['status']> = {
|
||||
unknown: 'unknown',
|
||||
batteryNormal: 'normal',
|
||||
batteryLow: 'low',
|
||||
batteryDepleted: 'depleted',
|
||||
};
|
||||
|
||||
return {
|
||||
charge: battery.estimatedChargeRemaining,
|
||||
runtime: battery.estimatedMinutesRemaining * 60,
|
||||
voltage: battery.voltage,
|
||||
temperature: battery.temperature || undefined,
|
||||
status: statusMap[battery.status],
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get power information
|
||||
*/
|
||||
public async getPowerInfo(): Promise<IUpsPowerInfo> {
|
||||
if (this.upsProtocol === 'nut' && this.nutProtocol) {
|
||||
const vars = await this.nutProtocol.getVariables(this.upsName, [
|
||||
NUT_VARIABLES.inputVoltage,
|
||||
NUT_VARIABLES.inputFrequency,
|
||||
NUT_VARIABLES.outputVoltage,
|
||||
NUT_VARIABLES.outputCurrent,
|
||||
NUT_VARIABLES.upsLoad,
|
||||
]);
|
||||
|
||||
return {
|
||||
inputVoltage: parseFloat(vars.get(NUT_VARIABLES.inputVoltage) || '0'),
|
||||
inputFrequency: vars.has(NUT_VARIABLES.inputFrequency)
|
||||
? parseFloat(vars.get(NUT_VARIABLES.inputFrequency)!)
|
||||
: undefined,
|
||||
outputVoltage: parseFloat(vars.get(NUT_VARIABLES.outputVoltage) || '0'),
|
||||
outputCurrent: vars.has(NUT_VARIABLES.outputCurrent)
|
||||
? parseFloat(vars.get(NUT_VARIABLES.outputCurrent)!)
|
||||
: undefined,
|
||||
load: parseFloat(vars.get(NUT_VARIABLES.upsLoad) || '0'),
|
||||
};
|
||||
} else if (this.snmpHandler) {
|
||||
const [input, output] = await Promise.all([
|
||||
this.snmpHandler.getInputStatus(),
|
||||
this.snmpHandler.getOutputStatus(),
|
||||
]);
|
||||
|
||||
return {
|
||||
inputVoltage: input.voltage,
|
||||
inputFrequency: input.frequency,
|
||||
outputVoltage: output.voltage,
|
||||
outputFrequency: output.frequency,
|
||||
outputCurrent: output.current,
|
||||
outputPower: output.power,
|
||||
load: output.percentLoad,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full status
|
||||
*/
|
||||
public async getFullStatus(): Promise<IUpsFullStatus> {
|
||||
const [battery, power] = await Promise.all([
|
||||
this.getBatteryInfo(),
|
||||
this.getPowerInfo(),
|
||||
]);
|
||||
|
||||
let secondsOnBattery = 0;
|
||||
const alarms: string[] = [];
|
||||
|
||||
if (this.upsProtocol === 'nut' && this.nutProtocol) {
|
||||
const vars = await this.nutProtocol.getVariables(this.upsName, [
|
||||
NUT_VARIABLES.upsStatus,
|
||||
NUT_VARIABLES.upsAlarm,
|
||||
]);
|
||||
const alarm = vars.get(NUT_VARIABLES.upsAlarm);
|
||||
if (alarm) {
|
||||
alarms.push(alarm);
|
||||
}
|
||||
} else if (this.snmpHandler) {
|
||||
const snmpStatus = await this.snmpHandler.getFullStatus();
|
||||
secondsOnBattery = snmpStatus.secondsOnBattery;
|
||||
if (snmpStatus.alarmsPresent > 0) {
|
||||
alarms.push(`${snmpStatus.alarmsPresent} alarm(s) present`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: this._upsStatus,
|
||||
battery,
|
||||
power,
|
||||
alarms,
|
||||
secondsOnBattery,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a UPS command (NUT only)
|
||||
*/
|
||||
public async runCommand(command: string): Promise<boolean> {
|
||||
if (this.upsProtocol !== 'nut' || !this.nutProtocol) {
|
||||
throw new Error('Commands only supported via NUT protocol');
|
||||
}
|
||||
|
||||
const result = await this.nutProtocol.runCommand(this.upsName, command);
|
||||
this.emit('command:executed', { command, success: result });
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start battery test
|
||||
*/
|
||||
public async startBatteryTest(type: 'quick' | 'deep' = 'quick'): Promise<boolean> {
|
||||
const command = type === 'deep'
|
||||
? NUT_COMMANDS.testBatteryStartDeep
|
||||
: NUT_COMMANDS.testBatteryStartQuick;
|
||||
return this.runCommand(command);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop battery test
|
||||
*/
|
||||
public async stopBatteryTest(): Promise<boolean> {
|
||||
return this.runCommand(NUT_COMMANDS.testBatteryStop);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle beeper
|
||||
*/
|
||||
public async toggleBeeper(): Promise<boolean> {
|
||||
return this.runCommand(NUT_COMMANDS.beeperToggle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get device info
|
||||
*/
|
||||
public getDeviceInfo(): IUpsDeviceInfo {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
type: 'ups',
|
||||
address: this.address,
|
||||
port: this.port,
|
||||
status: this.status,
|
||||
protocol: this.upsProtocol,
|
||||
upsName: this.upsName,
|
||||
manufacturer: this._manufacturer,
|
||||
model: this._model,
|
||||
serialNumber: this.serialNumber,
|
||||
firmwareVersion: this.firmwareVersion,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create UPS device from discovery
|
||||
*/
|
||||
public static fromDiscovery(
|
||||
data: {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
port?: number;
|
||||
protocol: TUpsProtocol;
|
||||
upsName?: string;
|
||||
community?: string;
|
||||
},
|
||||
retryOptions?: IRetryOptions
|
||||
): UpsDevice {
|
||||
const info: IDeviceInfo = {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
type: 'ups',
|
||||
address: data.address,
|
||||
port: data.port ?? (data.protocol === 'nut' ? 3493 : 161),
|
||||
status: 'unknown',
|
||||
};
|
||||
|
||||
return new UpsDevice(
|
||||
info,
|
||||
{
|
||||
protocol: data.protocol,
|
||||
upsName: data.upsName,
|
||||
snmpCommunity: data.community,
|
||||
},
|
||||
retryOptions
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe for UPS device (NUT or SNMP)
|
||||
*/
|
||||
public static async probe(
|
||||
address: string,
|
||||
options?: {
|
||||
nutPort?: number;
|
||||
snmpPort?: number;
|
||||
snmpCommunity?: string;
|
||||
timeout?: number;
|
||||
}
|
||||
): Promise<{ protocol: TUpsProtocol; port: number } | null> {
|
||||
const nutPort = options?.nutPort ?? 3493;
|
||||
const snmpPort = options?.snmpPort ?? 161;
|
||||
const community = options?.snmpCommunity ?? 'public';
|
||||
|
||||
// Try NUT first
|
||||
const nutAvailable = await NutProtocol.probe(address, nutPort, options?.timeout);
|
||||
if (nutAvailable) {
|
||||
return { protocol: 'nut', port: nutPort };
|
||||
}
|
||||
|
||||
// Try SNMP UPS-MIB
|
||||
try {
|
||||
const handler = new UpsSnmpHandler(address, { community, port: snmpPort, timeout: options?.timeout ?? 3000 });
|
||||
const isUps = await handler.isUpsDevice();
|
||||
handler.close();
|
||||
|
||||
if (isUps) {
|
||||
return { protocol: 'snmp', port: snmpPort };
|
||||
}
|
||||
} catch {
|
||||
// Ignore SNMP errors
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export types
|
||||
export { NUT_COMMANDS, NUT_VARIABLES, type TNutStatusFlag } from './ups.classes.nutprotocol.js';
|
||||
export { UPS_SNMP_OIDS, type TUpsBatteryStatus, type TUpsOutputSource } from './ups.classes.upssnmp.js';
|
||||
Reference in New Issue
Block a user