4 Commits

Author SHA1 Message Date
7bcec69658 v2.1.0
Some checks failed
Default (tags) / security (push) Successful in 35s
Default (tags) / test (push) Failing after 45s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-09 14:09:55 +00:00
4b61ed31bc feat(devicemanager): prefer higher-priority discovery source when resolving device names and track per-device name source 2026-01-09 14:09:55 +00:00
de34e83b3d v2.0.0
Some checks failed
Default (tags) / security (push) Successful in 37s
Default (tags) / test (push) Failing after 47s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-09 09:36:43 +00:00
181c4f5d5d 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) 2026-01-09 09:36:43 +00:00
22 changed files with 1303 additions and 5707 deletions

View File

@@ -1,5 +1,26 @@
# Changelog
## 2026-01-09 - 2.1.0 - feat(devicemanager)
prefer higher-priority discovery source when resolving device names and track per-device name source
- Add TNameSource type and NAME_SOURCE_PRIORITY to rank name sources (generic, manual, airplay, chromecast, mdns, dlna, sonos).
- Replace chooseBestName with shouldUpdateName that validates 'real' names and uses source priority when deciding to update a device name.
- Add nameSourceByIp map to track which discovery source provided the current name and persist updates during registration.
- Register devices with an explicit nameSource (e.g. 'mdns', 'dlna', 'sonos', 'manual') and map speaker protocols to appropriate name sources.
- Ensure manual additions use 'manual' source and non-real names default to 'generic'.
- Clear nameSourceByIp entries when devices are removed/disconnected and on shutdown.
## 2026-01-09 - 2.0.0 - 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)
- Consolidated protocol implementations into ts/protocols and added protocols/index.ts for unified exports.
- Added device factory layer at ts/factories/index.ts to create UniversalDevice instances with appropriate features.
- Introduced protocols/protocol.upssnmp.ts (UPS SNMP handler) and other protocol reorganizations.
- Removed legacy concrete device classes and related files (Device abstract, Scanner, Printer, SnmpDevice, UpsDevice, DlnaRenderer/Server, Speaker and Sonos/AirPlay/Chromecast implementations).
- Updated top-level ts/index.ts exports to prefer UniversalDevice, factories and the new protocols module.
- Updated feature and discovery modules to import protocols from the new protocols index (import path changes).
- BREAKING: Consumers must update imports and device creation flows to use the new factories/UniversalDevice and protocols exports instead of the removed legacy classes.
## 2026-01-09 - 1.1.0 - feat(devicemanager)
Introduce a UniversalDevice architecture with composable Feature system; add extensive new device/protocol support and discovery/refactors

View File

@@ -1,6 +1,6 @@
{
"name": "@ecobridge.xyz/devicemanager",
"version": "1.1.0",
"version": "2.1.0",
"private": false,
"description": "a device manager for talking to devices on network and over usb",
"main": "dist_ts/index.js",

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@ecobridge.xyz/devicemanager',
version: '1.1.0',
version: '2.1.0',
description: 'a device manager for talking to devices on network and over usb'
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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
);
}
}

View File

@@ -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
View 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,
};

View File

@@ -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,

View File

@@ -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,

View File

@@ -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';

View File

@@ -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
View 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';

View File

@@ -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)

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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;
}
}
}

View File

@@ -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);
});
});
}
}

View File

@@ -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);
});
});
}
}

View File

@@ -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,
};
}
}

View File

@@ -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';