diff --git a/changelog.md b/changelog.md index 1dae17a..cb4003d 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,16 @@ # Changelog +## 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 diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index cb48a06..4c718cc 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@ecobridge.xyz/devicemanager', - version: '1.1.0', + version: '2.0.0', description: 'a device manager for talking to devices on network and over usb' } diff --git a/ts/abstract/device.abstract.ts b/ts/abstract/device.abstract.ts deleted file mode 100644 index 477280b..0000000 --- a/ts/abstract/device.abstract.ts +++ /dev/null @@ -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(fn: () => Promise): Promise { - return withRetry(fn, this.retryOptions); - } - - /** - * Connect to the device - */ - public async connect(): Promise { - 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 { - 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; - - /** - * Implementation-specific disconnect logic - * Override in subclasses - */ - protected abstract doDisconnect(): Promise; - - /** - * Refresh device status - * Override in subclasses - */ - public abstract refreshStatus(): Promise; -} diff --git a/ts/devicemanager.classes.devicemanager.ts b/ts/devicemanager.classes.devicemanager.ts index 152ba3f..1c9c134 100644 --- a/ts/devicemanager.classes.devicemanager.ts +++ b/ts/devicemanager.classes.devicemanager.ts @@ -1,32 +1,45 @@ +/** + * Device Manager + * Unified device discovery and management using UniversalDevice with Features + */ + import * as plugins from './plugins.js'; import { MdnsDiscovery, SERVICE_TYPES } from './discovery/discovery.classes.mdns.js'; import { NetworkScanner } from './discovery/discovery.classes.networkscanner.js'; import { SsdpDiscovery, SSDP_SERVICE_TYPES, type ISsdpDevice } from './discovery/discovery.classes.ssdp.js'; -import { Scanner } from './scanner/scanner.classes.scanner.js'; -import { Printer } from './printer/printer.classes.printer.js'; -import { SnmpDevice, type ISnmpDeviceInfo } from './snmp/snmp.classes.snmpdevice.js'; -import { UpsDevice, type IUpsDeviceInfo, type TUpsProtocol } from './ups/ups.classes.upsdevice.js'; -import { DlnaRenderer, type IDlnaRendererInfo } from './dlna/dlna.classes.renderer.js'; -import { DlnaServer, type IDlnaServerInfo } from './dlna/dlna.classes.server.js'; -import { Speaker, type ISpeakerInfo } from './speaker/speaker.classes.speaker.js'; -import { SonosSpeaker, type ISonosSpeakerInfo } from './speaker/speaker.classes.sonos.js'; -import { AirPlaySpeaker, type IAirPlaySpeakerInfo } from './speaker/speaker.classes.airplay.js'; -import { ChromecastSpeaker, type IChromecastSpeakerInfo } from './speaker/speaker.classes.chromecast.js'; +import { UniversalDevice } from './device/device.classes.device.js'; +import { + ScanFeature, + PrintFeature, + PlaybackFeature, + VolumeFeature, + PowerFeature, + SnmpFeature, +} from './features/index.js'; +import type { Feature } from './features/index.js'; +import { + createScanner, + createPrinter, + createSnmpDevice, + createUpsDevice, + createSpeaker, + createDlnaRenderer, + type IScannerDiscoveryInfo, + type IPrinterDiscoveryInfo, + type ISnmpDiscoveryInfo, + type IUpsDiscoveryInfo, + type ISpeakerDiscoveryInfo, + type IDlnaRendererDiscoveryInfo, +} from './factories/index.js'; import type { IDeviceManagerOptions, IDiscoveredDevice, IRetryOptions, - TDeviceManagerEvents, INetworkScanOptions, INetworkScanResult, - TDeviceType, TFeatureType, } from './interfaces/index.js'; -// Universal Device & Features -import { UniversalDevice } from './device/device.classes.device.js'; -import type { Feature } from './features/index.js'; - /** * Default device manager options */ @@ -39,26 +52,67 @@ const DEFAULT_OPTIONS: Required = { }; /** - * Main Device Manager class for discovering and managing network devices + * Check if a name is a "real" name vs a generic placeholder + */ +function isRealName(name: string | undefined): boolean { + if (!name) return false; + const lower = name.toLowerCase(); + // Generic names that indicate no real name was discovered + return !( + lower.includes(' at ') || + lower.startsWith('device ') || + lower.startsWith('scanner ') || + lower.startsWith('printer ') || + lower.startsWith('speaker ') || + lower.startsWith('ups ') || + lower.startsWith('snmp ') || + /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/.test(name) // Just an IP address + ); +} + +/** + * Choose the best name from multiple options + */ +function chooseBestName(existing: string | undefined, newName: string | undefined): string { + // If new name is "real" and existing isn't, use new + if (isRealName(newName) && !isRealName(existing)) { + return newName!; + } + // If existing is "real", keep it + if (isRealName(existing)) { + return existing!; + } + // Both are generic or undefined - prefer new if it exists + return newName || existing || 'Unknown Device'; +} + +/** + * Create a device reference for feature construction + */ +function makeDeviceRef(address: string, port: number, name: string): { id: string; address: string; port: number; name: string } { + return { + id: `device:${address}`, + address, + port, + name, + }; +} + +/** + * Main Device Manager class + * Discovers and manages network devices using the UniversalDevice architecture + * + * Devices are deduplicated by IP address - if the same device is discovered + * via multiple methods (mDNS, SSDP, IP scan), features are merged into a + * single UniversalDevice instance. */ export class DeviceManager extends plugins.events.EventEmitter { private mdnsDiscovery: MdnsDiscovery; private ssdpDiscovery: SsdpDiscovery; private _networkScanner: NetworkScanner | null = null; - // Device collections - private scanners: Map = new Map(); - private printers: Map = new Map(); - private snmpDevices: Map = new Map(); - private upsDevices: Map = new Map(); - private dlnaRenderers: Map = new Map(); - private dlnaServers: Map = new Map(); - private sonosSpeakers: Map = new Map(); - private airplaySpeakers: Map = new Map(); - private chromecastSpeakers: Map = new Map(); - - // Universal devices (new architecture) - private universalDevices: Map = new Map(); + // Devices keyed by IP address for deduplication + private devicesByIp: Map = new Map(); private options: Required; private retryOptions: IRetryOptions; @@ -85,16 +139,73 @@ export class DeviceManager extends plugins.events.EventEmitter { this.setupSsdpDiscoveryEvents(); } + // ============================================================================ + // Device Registration (with deduplication by IP) + // ============================================================================ + /** - * Setup event forwarding from mDNS discovery service + * Register or merge a device by IP address + * Returns the device (existing or new) and whether it's newly created */ + private registerDevice( + address: string, + port: number, + name: string | undefined, + manufacturer: string | undefined, + model: string | undefined, + feature: Feature + ): { device: UniversalDevice; isNew: boolean; featureAdded: boolean } { + const existing = this.devicesByIp.get(address); + + if (existing) { + // Update name if new one is better + const betterName = chooseBestName(existing.name, name); + if (betterName !== existing.name) { + (existing as { name: string }).name = betterName; + } + + // Update manufacturer/model if we have better info + if (manufacturer && !existing.manufacturer) { + (existing as { manufacturer: string | undefined }).manufacturer = manufacturer; + } + if (model && !existing.model) { + (existing as { model: string | undefined }).model = model; + } + + // Add feature if not already present + if (!existing.hasFeature(feature.type)) { + existing.addFeature(feature); + return { device: existing, isNew: false, featureAdded: true }; + } + + return { device: existing, isNew: false, featureAdded: false }; + } + + // Create new device + const device = new UniversalDevice(address, port, { + name: name || `Device at ${address}`, + manufacturer, + model, + retryOptions: this.options.enableRetry ? this.retryOptions : undefined, + }); + + device.addFeature(feature); + this.devicesByIp.set(address, device); + + return { device, isNew: true, featureAdded: true }; + } + + // ============================================================================ + // Discovery Event Handlers + // ============================================================================ + private setupDiscoveryEvents(): void { this.mdnsDiscovery.on('device:found', (device: IDiscoveredDevice) => { this.handleMdnsDeviceFound(device); }); this.mdnsDiscovery.on('device:lost', (device: IDiscoveredDevice) => { - this.handleDeviceLost(device); + this.handleDeviceLost(device.address); }); this.mdnsDiscovery.on('started', () => { @@ -106,9 +217,6 @@ export class DeviceManager extends plugins.events.EventEmitter { }); } - /** - * Setup event forwarding from SSDP discovery service - */ private setupSsdpDiscoveryEvents(): void { this.ssdpDiscovery.on('device:found', (device: ISsdpDevice) => { this.handleSsdpDeviceFound(device); @@ -123,182 +231,274 @@ export class DeviceManager extends plugins.events.EventEmitter { }); } - /** - * Handle newly discovered mDNS device - */ - private handleMdnsDeviceFound(device: IDiscoveredDevice): void { - if (device.type === 'scanner') { - // Create Scanner instance - const scanner = Scanner.fromDiscovery( + private handleMdnsDeviceFound(discovered: IDiscoveredDevice): void { + const manufacturer = discovered.txtRecords['usb_MFG'] || discovered.txtRecords['mfg']; + const model = discovered.txtRecords['usb_MDL'] || discovered.txtRecords['mdl'] || discovered.txtRecords['ty']; + + if (discovered.type === 'scanner') { + const protocol = discovered.protocol === 'ipp' ? 'escl' : discovered.protocol as 'escl' | 'sane'; + const isSecure = discovered.txtRecords['TLS'] === '1' || (protocol === 'escl' && discovered.port === 443); + + const feature = new ScanFeature( + makeDeviceRef(discovered.address, discovered.port, discovered.name), + discovered.port, { - id: device.id, - name: device.name, - address: device.address, - port: device.port, - protocol: device.protocol as 'sane' | 'escl', - txtRecords: device.txtRecords, - }, - this.options.enableRetry ? this.retryOptions : undefined + protocol, + secure: isSecure, + supportedFormats: this.parseScanFormats(discovered.txtRecords), + supportedResolutions: this.parseScanResolutions(discovered.txtRecords), + supportedColorModes: this.parseScanColorModes(discovered.txtRecords), + supportedSources: this.parseScanSources(discovered.txtRecords), + hasAdf: discovered.txtRecords['is']?.includes('adf') || false, + hasDuplex: discovered.txtRecords['is']?.includes('duplex') || false, + } ); - this.scanners.set(device.id, scanner); - this.emit('scanner:found', scanner.getScannerInfo()); - } else if (device.type === 'printer') { - // Create Printer instance - const printer = Printer.fromDiscovery( - { - id: device.id, - name: device.name, - address: device.address, - port: device.port, - txtRecords: device.txtRecords, - }, - this.options.enableRetry ? this.retryOptions : undefined + const { device, isNew, featureAdded } = this.registerDevice( + discovered.address, + discovered.port, + discovered.name, + manufacturer, + model, + feature ); - this.printers.set(device.id, printer); - this.emit('printer:found', printer.getPrinterInfo()); - } else if (device.type === 'speaker') { - // Create appropriate speaker instance based on protocol - this.handleMdnsSpeakerFound(device); + if (isNew || featureAdded) { + this.emit('device:found', { device, featureType: 'scan' }); + } + } else if (discovered.type === 'printer') { + const ippPath = discovered.txtRecords['rp'] || discovered.txtRecords['rfo'] || '/ipp/print'; + const uri = `ipp://${discovered.address}:${discovered.port}${ippPath.startsWith('/') ? '' : '/'}${ippPath}`; + + const feature = new PrintFeature( + makeDeviceRef(discovered.address, discovered.port, discovered.name), + discovered.port, + { + protocol: 'ipp', + uri, + supportsColor: discovered.txtRecords['Color'] === 'T' || discovered.txtRecords['color'] === 'true', + supportsDuplex: discovered.txtRecords['Duplex'] === 'T' || discovered.txtRecords['duplex'] === 'true', + } + ); + + const { device, isNew, featureAdded } = this.registerDevice( + discovered.address, + discovered.port, + discovered.name, + manufacturer, + model, + feature + ); + + if (isNew || featureAdded) { + this.emit('device:found', { device, featureType: 'print' }); + } + } else if (discovered.type === 'speaker') { + const protocol = discovered.protocol as 'sonos' | 'airplay' | 'chromecast' | 'dlna'; + + const playbackFeature = new PlaybackFeature( + makeDeviceRef(discovered.address, discovered.port, discovered.name), + discovered.port, + { + protocol, + supportsQueue: protocol === 'sonos', + supportsSeek: protocol !== 'airplay', + } + ); + + const volumeFeature = new VolumeFeature( + makeDeviceRef(discovered.address, discovered.port, discovered.name), + discovered.port, + { + volumeProtocol: protocol, + minVolume: 0, + maxVolume: 100, + supportsMute: true, + } + ); + + const modelName = discovered.txtRecords['model'] || discovered.txtRecords['md']; + const { device, isNew } = this.registerDevice( + discovered.address, + discovered.port, + discovered.name, + undefined, + modelName, + playbackFeature + ); + + // Also add volume feature + if (!device.hasFeature('volume')) { + device.addFeature(volumeFeature); + } + + if (isNew) { + this.emit('device:found', { device, featureType: 'playback' }); + } } } - /** - * Handle mDNS speaker discovery - */ - private handleMdnsSpeakerFound(device: IDiscoveredDevice): void { - const protocol = device.protocol; - const txt = device.txtRecords || {}; - - if (protocol === 'sonos') { - // Sonos speaker - const speaker = SonosSpeaker.fromDiscovery( - { - id: device.id, - name: device.name, - address: device.address, - port: device.port || 1400, - roomName: txt['room'] || device.name, - modelName: txt['model'] || txt['md'], - }, - this.options.enableRetry ? this.retryOptions : undefined - ); - this.sonosSpeakers.set(device.id, speaker); - this.emit('speaker:found', speaker.getSpeakerInfo()); - } else if (protocol === 'airplay') { - // AirPlay speaker (HomePod, Apple TV, etc.) - const features = txt['features'] ? parseInt(txt['features'], 16) : 0; - const speaker = AirPlaySpeaker.fromDiscovery( - { - id: device.id, - name: device.name, - address: device.address, - port: device.port || 7000, - roomName: txt['room'] || device.name, - modelName: txt['model'] || txt['am'], - features, - deviceId: txt['deviceid'] || txt['pk'], - }, - this.options.enableRetry ? this.retryOptions : undefined - ); - this.airplaySpeakers.set(device.id, speaker); - this.emit('speaker:found', speaker.getSpeakerInfo()); - } else if (protocol === 'chromecast') { - // Chromecast / Google Cast - const speaker = ChromecastSpeaker.fromDiscovery( - { - id: device.id, - name: txt['fn'] || device.name, - address: device.address, - port: device.port || 8009, - friendlyName: txt['fn'] || device.name, - modelName: txt['md'], - }, - this.options.enableRetry ? this.retryOptions : undefined - ); - this.chromecastSpeakers.set(device.id, speaker); - this.emit('speaker:found', speaker.getSpeakerInfo()); - } - } - - /** - * Handle newly discovered SSDP/UPnP device - */ - private handleSsdpDeviceFound(device: ISsdpDevice): void { - if (!device.description) { + private handleSsdpDeviceFound(ssdpDevice: ISsdpDevice): void { + if (!ssdpDevice.description) { return; } - const serviceType = device.serviceType; - const deviceType = device.description.deviceType; + const serviceType = ssdpDevice.serviceType; + const deviceType = ssdpDevice.description.deviceType; + const desc = ssdpDevice.description; // Check for DLNA Media Renderer if (serviceType.includes('MediaRenderer') || deviceType.includes('MediaRenderer')) { - const renderer = DlnaRenderer.fromSsdpDevice(device, this.retryOptions); - if (renderer) { - this.dlnaRenderers.set(renderer.id, renderer); - this.emit('dlna:renderer:found', renderer.getDeviceInfo()); - } - } + const port = parseInt(new URL(ssdpDevice.location).port) || 80; - // Check for DLNA Media Server - if (serviceType.includes('MediaServer') || deviceType.includes('MediaServer')) { - const server = DlnaServer.fromSsdpDevice(device, this.retryOptions); - if (server) { - this.dlnaServers.set(server.id, server); - this.emit('dlna:server:found', server.getDeviceInfo()); + const playbackFeature = new PlaybackFeature( + makeDeviceRef(ssdpDevice.address, port, desc.friendlyName), + port, + { + protocol: 'dlna', + supportsQueue: false, + supportsSeek: true, + } + ); + + const volumeFeature = new VolumeFeature( + makeDeviceRef(ssdpDevice.address, port, desc.friendlyName), + port, + { + volumeProtocol: 'dlna', + minVolume: 0, + maxVolume: 100, + supportsMute: true, + } + ); + + const { device, isNew } = this.registerDevice( + ssdpDevice.address, + port, + desc.friendlyName, + desc.manufacturer, + desc.modelName, + playbackFeature + ); + + if (!device.hasFeature('volume')) { + device.addFeature(volumeFeature); + } + + if (isNew) { + this.emit('device:found', { device, featureType: 'playback' }); } } // Check for Sonos ZonePlayer if (serviceType.includes('ZonePlayer') || deviceType.includes('ZonePlayer')) { - const speaker = SonosSpeaker.fromDiscovery( + const playbackFeature = new PlaybackFeature( + makeDeviceRef(ssdpDevice.address, 1400, desc.friendlyName), + 1400, { - id: `sonos:${device.usn}`, - name: device.description.friendlyName, - address: device.address, - port: 1400, - roomName: device.description.friendlyName, - modelName: device.description.modelName, - }, - this.retryOptions + protocol: 'sonos', + supportsQueue: true, + supportsSeek: true, + } ); - this.sonosSpeakers.set(speaker.id, speaker); - this.emit('speaker:found', speaker.getSpeakerInfo()); - } - } - /** - * Handle lost device - */ - private handleDeviceLost(device: IDiscoveredDevice): void { - if (device.type === 'scanner') { - const scanner = this.scanners.get(device.id); - if (scanner) { - // Disconnect if connected - if (scanner.isConnected) { - scanner.disconnect().catch(() => {}); + const volumeFeature = new VolumeFeature( + makeDeviceRef(ssdpDevice.address, 1400, desc.friendlyName), + 1400, + { + volumeProtocol: 'sonos', + minVolume: 0, + maxVolume: 100, + supportsMute: true, } - this.scanners.delete(device.id); - this.emit('scanner:lost', device.id); + ); + + const { device, isNew } = this.registerDevice( + ssdpDevice.address, + 1400, + desc.friendlyName, + desc.manufacturer, + desc.modelName, + playbackFeature + ); + + if (!device.hasFeature('volume')) { + device.addFeature(volumeFeature); } - } else if (device.type === 'printer') { - const printer = this.printers.get(device.id); - if (printer) { - // Disconnect if connected - if (printer.isConnected) { - printer.disconnect().catch(() => {}); - } - this.printers.delete(device.id); - this.emit('printer:lost', device.id); + + if (isNew) { + this.emit('device:found', { device, featureType: 'playback' }); } } } - /** - * Start device discovery (mDNS and SSDP) - */ + private async handleDeviceLost(address: string): Promise { + const device = this.devicesByIp.get(address); + if (device) { + try { + await device.disconnect(); + } catch { + // Ignore disconnect errors + } + this.devicesByIp.delete(address); + this.emit('device:lost', address); + } + } + + // ============================================================================ + // Parsing Helpers + // ============================================================================ + + private parseScanFormats(txtRecords: Record): ('jpeg' | 'png' | 'pdf' | 'tiff')[] { + const formats: ('jpeg' | 'png' | 'pdf' | 'tiff')[] = []; + 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']; + } + + private parseScanResolutions(txtRecords: Record): 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]; + } + + private parseScanColorModes(txtRecords: Record): ('color' | 'grayscale' | 'blackwhite')[] { + const cs = txtRecords['cs'] || txtRecords['ColorSpace'] || ''; + const modes: ('color' | 'grayscale' | 'blackwhite')[] = []; + + 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']; + } + + private parseScanSources(txtRecords: Record): ('flatbed' | 'adf' | 'adf-duplex')[] { + const is = txtRecords['is'] || txtRecords['InputSource'] || ''; + const sources: ('flatbed' | 'adf' | 'adf-duplex')[] = []; + + 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']; + } + + // ============================================================================ + // Discovery Control + // ============================================================================ + public async startDiscovery(): Promise { await Promise.all([ this.mdnsDiscovery.start(), @@ -306,9 +506,6 @@ export class DeviceManager extends plugins.events.EventEmitter { ]); } - /** - * Stop device discovery - */ public async stopDiscovery(): Promise { await Promise.all([ this.mdnsDiscovery.stop(), @@ -316,266 +513,346 @@ export class DeviceManager extends plugins.events.EventEmitter { ]); } - /** - * Check if discovery is running - */ public get isDiscovering(): boolean { return this.mdnsDiscovery.running || this.ssdpDiscovery.isRunning; } + // ============================================================================ + // Device Access - All Devices + // ============================================================================ + /** - * Get all discovered scanners + * Get all devices */ - public getScanners(): Scanner[] { - return Array.from(this.scanners.values()); + public getDevices(): UniversalDevice[] { + return Array.from(this.devicesByIp.values()); } /** - * Get all discovered printers + * Get device by ID */ - public getPrinters(): Printer[] { - return Array.from(this.printers.values()); + public getDevice(id: string): UniversalDevice | undefined { + for (const device of this.devicesByIp.values()) { + if (device.id === id) { + return device; + } + } + return undefined; } /** - * Get all discovered SNMP devices + * Get device by address */ - public getSnmpDevices(): SnmpDevice[] { - return Array.from(this.snmpDevices.values()); + public getDeviceByAddress(address: string): UniversalDevice | undefined { + return this.devicesByIp.get(address); + } + + // ============================================================================ + // Device Access - By Feature Type + // ============================================================================ + + /** + * Get all devices that have a specific feature + */ + public getDevicesWithFeature(featureType: TFeatureType): UniversalDevice[] { + return this.getDevices().filter(device => device.hasFeature(featureType)); } /** - * Get all discovered UPS devices + * Get all devices that have ALL specified features */ - public getUpsDevices(): UpsDevice[] { - return Array.from(this.upsDevices.values()); + public getDevicesWithFeatures(featureTypes: TFeatureType[]): UniversalDevice[] { + return this.getDevices().filter(device => device.hasFeatures(featureTypes)); } /** - * Get all DLNA media renderers + * Get all devices that have ANY of the specified features */ - public getDlnaRenderers(): DlnaRenderer[] { - return Array.from(this.dlnaRenderers.values()); + public getDevicesWithAnyFeature(featureTypes: TFeatureType[]): UniversalDevice[] { + return this.getDevices().filter(device => + featureTypes.some(type => device.hasFeature(type)) + ); + } + + // ============================================================================ + // Convenience Accessors (by feature type) + // ============================================================================ + + /** + * Get all scanner devices + */ + public getScanners(): UniversalDevice[] { + return this.getDevicesWithFeature('scan'); } /** - * Get all DLNA media servers + * Get all printer devices */ - public getDlnaServers(): DlnaServer[] { - return Array.from(this.dlnaServers.values()); + public getPrinters(): UniversalDevice[] { + return this.getDevicesWithFeature('print'); } /** - * Get all speakers (all protocols) + * Get all speaker devices (playback feature) */ - public getSpeakers(): Speaker[] { - return [ - ...this.getSonosSpeakers(), - ...this.getAirPlaySpeakers(), - ...this.getChromecastSpeakers(), - ]; + public getSpeakers(): UniversalDevice[] { + return this.getDevicesWithFeature('playback'); } /** - * Get all Sonos speakers + * Get all UPS/power devices */ - public getSonosSpeakers(): SonosSpeaker[] { - return Array.from(this.sonosSpeakers.values()); + public getUpsDevices(): UniversalDevice[] { + return this.getDevicesWithFeature('power'); } /** - * Get all AirPlay speakers + * Get all SNMP devices */ - public getAirPlaySpeakers(): AirPlaySpeaker[] { - return Array.from(this.airplaySpeakers.values()); + public getSnmpDevices(): UniversalDevice[] { + return this.getDevicesWithFeature('snmp'); } - /** - * Get all Chromecast speakers - */ - public getChromecastSpeakers(): ChromecastSpeaker[] { - return Array.from(this.chromecastSpeakers.values()); - } + // ============================================================================ + // Manual Device Addition + // ============================================================================ /** - * Get scanner by ID + * Add a device */ - public getScanner(id: string): Scanner | undefined { - return this.scanners.get(id); - } - - /** - * Get printer by ID - */ - public getPrinter(id: string): Printer | undefined { - return this.printers.get(id); - } - - /** - * Get SNMP device by ID - */ - public getSnmpDevice(id: string): SnmpDevice | undefined { - return this.snmpDevices.get(id); - } - - /** - * Get UPS device by ID - */ - public getUpsDevice(id: string): UpsDevice | undefined { - return this.upsDevices.get(id); - } - - /** - * Get DLNA renderer by ID - */ - public getDlnaRenderer(id: string): DlnaRenderer | undefined { - return this.dlnaRenderers.get(id); - } - - /** - * Get DLNA server by ID - */ - public getDlnaServer(id: string): DlnaServer | undefined { - return this.dlnaServers.get(id); - } - - /** - * Get speaker by ID (any protocol) - */ - public getSpeaker(id: string): Speaker | undefined { - return this.sonosSpeakers.get(id) ?? - this.airplaySpeakers.get(id) ?? - this.chromecastSpeakers.get(id); - } - - /** - * Get all devices (all types) - */ - public getAllDevices(): (Scanner | Printer | SnmpDevice | UpsDevice | DlnaRenderer | DlnaServer | Speaker)[] { - return [ - ...this.getScanners(), - ...this.getPrinters(), - ...this.getSnmpDevices(), - ...this.getUpsDevices(), - ...this.getDlnaRenderers(), - ...this.getDlnaServers(), - ...this.getSpeakers(), - ]; - } - - /** - * Get devices by type - */ - public getDevicesByType(type: TDeviceType): (Scanner | Printer | SnmpDevice | UpsDevice | DlnaRenderer | DlnaServer | Speaker)[] { - switch (type) { - case 'scanner': - return this.getScanners(); - case 'printer': - return this.getPrinters(); - case 'snmp': - return this.getSnmpDevices(); - case 'ups': - return this.getUpsDevices(); - case 'dlna-renderer': - return this.getDlnaRenderers(); - case 'dlna-server': - return this.getDlnaServers(); - case 'speaker': - return this.getSpeakers(); - default: - return []; + public addDevice(device: UniversalDevice): void { + const existing = this.devicesByIp.get(device.address); + if (existing) { + // Merge features into existing device + for (const feature of device.getFeatures()) { + if (!existing.hasFeature(feature.type)) { + existing.addFeature(feature); + } + } + // Update name if better + const betterName = chooseBestName(existing.name, device.name); + if (betterName !== existing.name) { + (existing as { name: string }).name = betterName; + } + } else { + this.devicesByIp.set(device.address, device); + this.emit('device:added', device); } } /** - * Get device by ID (any type) - */ - public getDeviceById(id: string): Scanner | Printer | SnmpDevice | UpsDevice | DlnaRenderer | DlnaServer | Speaker | undefined { - return this.scanners.get(id) ?? - this.printers.get(id) ?? - this.snmpDevices.get(id) ?? - this.upsDevices.get(id) ?? - this.dlnaRenderers.get(id) ?? - this.dlnaServers.get(id) ?? - this.getSpeaker(id); - } - - /** - * Add a scanner manually (without discovery) + * Add a scanner manually */ public async addScanner( address: string, port: number, protocol: 'escl' | 'sane' = 'escl', name?: string - ): Promise { - const id = `manual:${protocol}:${address}:${port}`; - - // Check if already exists - if (this.scanners.has(id)) { - return this.scanners.get(id)!; - } - - const scanner = Scanner.fromDiscovery( - { - id, - name: name ?? `Scanner at ${address}`, - address, - port, - protocol, - txtRecords: {}, - }, - this.options.enableRetry ? this.retryOptions : undefined + ): Promise { + const feature = new ScanFeature( + makeDeviceRef(address, port, name || `Scanner at ${address}`), + port, + { protocol } ); - // Try to connect to validate - await scanner.connect(); + const { device } = this.registerDevice( + address, + port, + name, + undefined, + undefined, + feature + ); - this.scanners.set(id, scanner); - this.emit('scanner:found', scanner.getScannerInfo()); - - return scanner; + await device.connect(); + return device; } /** - * Add a printer manually (without discovery) + * Add a printer manually */ public async addPrinter( address: string, port: number = 631, name?: string, ippPath?: string - ): Promise { - const id = `manual:ipp:${address}:${port}`; + ): Promise { + const path = ippPath || '/ipp/print'; + const uri = `ipp://${address}:${port}${path.startsWith('/') ? '' : '/'}${path}`; - // Check if already exists - if (this.printers.has(id)) { - return this.printers.get(id)!; - } - - const printer = Printer.fromDiscovery( - { - id, - name: name ?? `Printer at ${address}`, - address, - port, - txtRecords: ippPath ? { rp: ippPath } : {}, - }, - this.options.enableRetry ? this.retryOptions : undefined + const feature = new PrintFeature( + makeDeviceRef(address, port, name || `Printer at ${address}`), + port, + { protocol: 'ipp', uri } ); - // Try to connect to validate - await printer.connect(); + const { device } = this.registerDevice( + address, + port, + name, + undefined, + undefined, + feature + ); - this.printers.set(id, printer); - this.emit('printer:found', printer.getPrinterInfo()); - - return printer; + await device.connect(); + return device; } /** - * Get the NetworkScanner instance for advanced control + * Add an SNMP device manually */ + public async addSnmpDevice( + address: string, + port: number = 161, + options?: { name?: string; community?: string } + ): Promise { + const feature = new SnmpFeature( + makeDeviceRef(address, port, options?.name || `SNMP Device at ${address}`), + port, + { community: options?.community ?? 'public' } + ); + + const { device } = this.registerDevice( + address, + port, + options?.name, + undefined, + undefined, + feature + ); + + await device.connect(); + return device; + } + + /** + * Add a UPS device manually + */ + public async addUpsDevice( + address: string, + protocol: 'nut' | 'snmp', + options?: { name?: string; port?: number; upsName?: string; community?: string } + ): Promise { + const port = options?.port ?? (protocol === 'nut' ? 3493 : 161); + + const feature = new PowerFeature( + makeDeviceRef(address, port, options?.name || `UPS at ${address}`), + port, + { + protocol, + upsName: options?.upsName, + community: options?.community, + } + ); + + const { device } = this.registerDevice( + address, + port, + options?.name, + undefined, + undefined, + feature + ); + + await device.connect(); + return device; + } + + /** + * Add a speaker manually + */ + public async addSpeaker( + address: string, + protocol: 'sonos' | 'airplay' | 'chromecast' | 'dlna', + options?: { name?: string; port?: number } + ): Promise { + const defaultPort = protocol === 'sonos' ? 1400 : protocol === 'airplay' ? 7000 : protocol === 'chromecast' ? 8009 : 80; + const port = options?.port ?? defaultPort; + + const deviceName = options?.name || `${protocol} at ${address}`; + const playbackFeature = new PlaybackFeature( + makeDeviceRef(address, port, deviceName), + port, + { + protocol, + supportsQueue: protocol === 'sonos', + supportsSeek: protocol !== 'airplay', + } + ); + + const volumeFeature = new VolumeFeature( + makeDeviceRef(address, port, deviceName), + port, + { + volumeProtocol: protocol, + minVolume: 0, + maxVolume: 100, + supportsMute: true, + } + ); + + const { device } = this.registerDevice( + address, + port, + options?.name, + undefined, + undefined, + playbackFeature + ); + + if (!device.hasFeature('volume')) { + device.addFeature(volumeFeature); + } + + await device.connect(); + return device; + } + + // ============================================================================ + // Feature Access + // ============================================================================ + + /** + * Get a feature from a device + */ + public getFeature(deviceId: string, featureType: TFeatureType): T | undefined { + const device = this.getDevice(deviceId); + return device?.getFeature(featureType); + } + + /** + * Add a feature to a device + */ + public addFeature(deviceId: string, feature: Feature): boolean { + const device = this.getDevice(deviceId); + if (device) { + device.addFeature(feature); + this.emit('feature:added', { device, feature }); + return true; + } + return false; + } + + /** + * Remove a feature from a device + */ + public async removeFeature(deviceId: string, featureType: TFeatureType): Promise { + const device = this.getDevice(deviceId); + if (device) { + const removed = await device.removeFeature(featureType); + if (removed) { + this.emit('feature:removed', { device, featureType }); + } + return removed; + } + return false; + } + + // ============================================================================ + // Network Scanning + // ============================================================================ + public get networkScanner(): NetworkScanner { if (!this._networkScanner) { this._networkScanner = new NetworkScanner(); @@ -584,9 +861,6 @@ export class DeviceManager extends plugins.events.EventEmitter { return this._networkScanner; } - /** - * Setup event forwarding from network scanner - */ private setupNetworkScannerEvents(): void { if (!this._networkScanner) return; @@ -607,120 +881,88 @@ export class DeviceManager extends plugins.events.EventEmitter { }); } - /** - * Scan a network range for devices (IP-based, not mDNS) - * Found devices are automatically added to the device manager - */ - public async scanNetwork( - options: INetworkScanOptions - ): Promise<{ scanners: Scanner[]; printers: Printer[]; speakers: Speaker[] }> { + public async scanNetwork(options: INetworkScanOptions): Promise { const results = await this.networkScanner.scan(options); - const foundScanners: Scanner[] = []; - const foundPrinters: Printer[] = []; - const foundSpeakers: Speaker[] = []; + const foundDevices: UniversalDevice[] = []; + const seenAddresses = new Set(); for (const result of results) { - for (const device of result.devices) { + // Skip if we already processed this IP in this scan + if (seenAddresses.has(result.address)) continue; + seenAddresses.add(result.address); + + for (const deviceInfo of result.devices) { try { - if (device.type === 'scanner') { - if (device.protocol === 'escl') { - const scanner = await this.addScanner( - result.address, - device.port, - 'escl', - device.name - ); - foundScanners.push(scanner); - } else if (device.protocol === 'sane') { - const scanner = await this.addScanner( - result.address, - device.port, - 'sane', - device.name - ); - foundScanners.push(scanner); - } - } else if (device.type === 'printer') { - if (device.protocol === 'ipp') { - const printer = await this.addPrinter( - result.address, - device.port, - device.name - ); - foundPrinters.push(printer); - } - // JetDirect printers don't have a protocol handler yet - } else if (device.type === 'speaker') { - if (device.protocol === 'airplay') { - const speaker = await this.addAirPlaySpeaker( - result.address, - { name: device.name, port: device.port } - ); - foundSpeakers.push(speaker); - } else if (device.protocol === 'sonos') { - const speaker = await this.addSonosSpeaker( - result.address, - { name: device.name, port: device.port } - ); - foundSpeakers.push(speaker); - } else if (device.protocol === 'chromecast') { - const speaker = await this.addChromecastSpeaker( - result.address, - { name: device.name, port: device.port } - ); - foundSpeakers.push(speaker); - } + let device: UniversalDevice | null = null; + + if (deviceInfo.type === 'scanner') { + device = await this.addScanner( + result.address, + deviceInfo.port, + deviceInfo.protocol as 'escl' | 'sane', + deviceInfo.name + ); + } else if (deviceInfo.type === 'printer' && deviceInfo.protocol === 'ipp') { + device = await this.addPrinter(result.address, deviceInfo.port, deviceInfo.name); + } else if (deviceInfo.type === 'speaker') { + device = await this.addSpeaker( + result.address, + deviceInfo.protocol as 'sonos' | 'airplay' | 'chromecast' | 'dlna', + { name: deviceInfo.name, port: deviceInfo.port } + ); + } + + if (device && !foundDevices.includes(device)) { + foundDevices.push(device); } } catch (error) { - // Device could not be added (connection failed, etc.) this.emit('error', error instanceof Error ? error : new Error(String(error))); } } } - return { scanners: foundScanners, printers: foundPrinters, speakers: foundSpeakers }; + return foundDevices; } - /** - * Cancel an ongoing network scan - */ public async cancelNetworkScan(): Promise { if (this._networkScanner) { await this._networkScanner.cancel(); } } - /** - * Check if a network scan is in progress - */ public get isNetworkScanning(): boolean { return this._networkScanner?.isScanning ?? false; } + // ============================================================================ + // Device Removal and Cleanup + // ============================================================================ + /** - * Remove a device + * Remove a device by ID */ public async removeDevice(id: string): Promise { - const scanner = this.scanners.get(id); - if (scanner) { - if (scanner.isConnected) { - await scanner.disconnect(); - } - this.scanners.delete(id); - this.emit('scanner:lost', id); + const device = this.getDevice(id); + if (device) { + await device.disconnect(); + this.devicesByIp.delete(device.address); + this.emit('device:removed', id); return true; } + return false; + } - const printer = this.printers.get(id); - if (printer) { - if (printer.isConnected) { - await printer.disconnect(); - } - this.printers.delete(id); - this.emit('printer:lost', id); + /** + * Remove a device by address + */ + public async removeDeviceByAddress(address: string): Promise { + const device = this.devicesByIp.get(address); + if (device) { + await device.disconnect(); + this.devicesByIp.delete(address); + this.emit('device:removed', device.id); return true; } - return false; } @@ -728,17 +970,11 @@ export class DeviceManager extends plugins.events.EventEmitter { * Disconnect all devices */ public async disconnectAll(): Promise { - const disconnectPromises: Promise[] = []; - - // Disconnect all device types - const allDevices = this.getAllDevices(); - for (const device of allDevices) { - if (device.isConnected) { - disconnectPromises.push(device.disconnect().catch(() => {})); - } + const promises: Promise[] = []; + for (const device of this.devicesByIp.values()) { + promises.push(device.disconnect().catch(() => {})); } - - await Promise.all(disconnectPromises); + await Promise.all(promises); } /** @@ -747,330 +983,14 @@ export class DeviceManager extends plugins.events.EventEmitter { public async shutdown(): Promise { await this.stopDiscovery(); await this.disconnectAll(); - - // Clear all device collections - this.scanners.clear(); - this.printers.clear(); - this.snmpDevices.clear(); - this.upsDevices.clear(); - this.dlnaRenderers.clear(); - this.dlnaServers.clear(); - this.sonosSpeakers.clear(); - this.airplaySpeakers.clear(); - this.chromecastSpeakers.clear(); - this.universalDevices.clear(); - } - - /** - * Refresh status of all devices - */ - public async refreshAllStatus(): Promise { - const refreshPromises: Promise[] = []; - - const allDevices = this.getAllDevices(); - for (const device of allDevices) { - if (device.isConnected) { - refreshPromises.push( - device.refreshStatus().catch((error) => { - this.emit('error', error); - }) - ); - } - } - - await Promise.all(refreshPromises); - } - - // ============================================================================ - // Manual Device Addition Methods - // ============================================================================ - - /** - * Add an SNMP device manually - */ - public async addSnmpDevice( - address: string, - port: number = 161, - options?: { name?: string; community?: string } - ): Promise { - const id = `manual:snmp:${address}:${port}`; - - if (this.snmpDevices.has(id)) { - return this.snmpDevices.get(id)!; - } - - const device = SnmpDevice.fromDiscovery( - { - id, - name: options?.name ?? `SNMP Device at ${address}`, - address, - port, - community: options?.community ?? 'public', - }, - this.retryOptions - ); - - await device.connect(); - this.snmpDevices.set(id, device); - this.emit('snmp:found', device.getDeviceInfo()); - - return device; - } - - /** - * Add a UPS device manually - */ - public async addUpsDevice( - address: string, - protocol: TUpsProtocol, - options?: { name?: string; port?: number; upsName?: string; community?: string } - ): Promise { - const port = options?.port ?? (protocol === 'nut' ? 3493 : 161); - const id = `manual:ups:${address}:${port}`; - - if (this.upsDevices.has(id)) { - return this.upsDevices.get(id)!; - } - - const device = UpsDevice.fromDiscovery( - { - id, - name: options?.name ?? `UPS at ${address}`, - address, - port, - protocol, - upsName: options?.upsName, - community: options?.community, - }, - this.retryOptions - ); - - await device.connect(); - this.upsDevices.set(id, device); - this.emit('ups:found', device.getDeviceInfo()); - - return device; - } - - /** - * Add a Sonos speaker manually - */ - public async addSonosSpeaker( - address: string, - options?: { name?: string; port?: number } - ): Promise { - const port = options?.port ?? 1400; - const id = `manual:sonos:${address}:${port}`; - - if (this.sonosSpeakers.has(id)) { - return this.sonosSpeakers.get(id)!; - } - - const speaker = SonosSpeaker.fromDiscovery( - { - id, - name: options?.name ?? `Sonos at ${address}`, - address, - port, - }, - this.retryOptions - ); - - await speaker.connect(); - this.sonosSpeakers.set(id, speaker); - this.emit('speaker:found', speaker.getSpeakerInfo()); - - return speaker; - } - - /** - * Add an AirPlay speaker manually - */ - public async addAirPlaySpeaker( - address: string, - options?: { name?: string; port?: number } - ): Promise { - const port = options?.port ?? 7000; - const id = `manual:airplay:${address}:${port}`; - - if (this.airplaySpeakers.has(id)) { - return this.airplaySpeakers.get(id)!; - } - - const speaker = AirPlaySpeaker.fromDiscovery( - { - id, - name: options?.name ?? `AirPlay at ${address}`, - address, - port, - }, - this.retryOptions - ); - - await speaker.connect(); - this.airplaySpeakers.set(id, speaker); - this.emit('speaker:found', speaker.getSpeakerInfo()); - - return speaker; - } - - /** - * Add a Chromecast speaker manually - */ - public async addChromecastSpeaker( - address: string, - options?: { name?: string; port?: number } - ): Promise { - const port = options?.port ?? 8009; - const id = `manual:chromecast:${address}:${port}`; - - if (this.chromecastSpeakers.has(id)) { - return this.chromecastSpeakers.get(id)!; - } - - const speaker = ChromecastSpeaker.fromDiscovery( - { - id, - name: options?.name ?? `Chromecast at ${address}`, - address, - port, - }, - this.retryOptions - ); - - await speaker.connect(); - this.chromecastSpeakers.set(id, speaker); - this.emit('speaker:found', speaker.getSpeakerInfo()); - - return speaker; - } - - // ============================================================================ - // Universal Device & Feature-Based API (New Architecture) - // ============================================================================ - - /** - * Get all universal devices - */ - public getUniversalDevices(): UniversalDevice[] { - return Array.from(this.universalDevices.values()); - } - - /** - * Get a universal device by ID - */ - public getUniversalDevice(id: string): UniversalDevice | undefined { - return this.universalDevices.get(id); - } - - /** - * Get a universal device by address - */ - public getUniversalDeviceByAddress(address: string): UniversalDevice | undefined { - for (const device of this.universalDevices.values()) { - if (device.address === address) { - return device; - } - } - return undefined; - } - - /** - * Get all universal devices that have a specific feature - * @param featureType The feature type to search for - */ - public getDevicesWithFeature(featureType: TFeatureType): UniversalDevice[] { - return this.getUniversalDevices().filter(device => device.hasFeature(featureType)); - } - - /** - * Get all universal devices that have ALL specified features - * @param featureTypes Array of feature types - device must have ALL of them - */ - public getDevicesWithFeatures(featureTypes: TFeatureType[]): UniversalDevice[] { - return this.getUniversalDevices().filter(device => device.hasFeatures(featureTypes)); - } - - /** - * Get all universal devices that have ANY of the specified features - * @param featureTypes Array of feature types - device must have at least one - */ - public getDevicesWithAnyFeature(featureTypes: TFeatureType[]): UniversalDevice[] { - return this.getUniversalDevices().filter(device => - featureTypes.some(type => device.hasFeature(type)) - ); - } - - /** - * Add a universal device - */ - public addUniversalDevice(device: UniversalDevice): void { - const existingDevice = this.universalDevices.get(device.id); - if (existingDevice) { - // Merge features into existing device - for (const feature of device.getFeatures()) { - if (!existingDevice.hasFeature(feature.type)) { - existingDevice.addFeature(feature); - } - } - } else { - this.universalDevices.set(device.id, device); - this.emit('universal:device:found', device); - } - } - - /** - * Add a feature to an existing universal device - */ - public addFeatureToDevice(deviceId: string, feature: Feature): boolean { - const device = this.universalDevices.get(deviceId); - if (device) { - device.addFeature(feature); - this.emit('feature:added', { device, feature }); - return true; - } - return false; - } - - /** - * Remove a feature from a universal device - */ - public async removeFeatureFromDevice(deviceId: string, featureType: TFeatureType): Promise { - const device = this.universalDevices.get(deviceId); - if (device) { - const removed = await device.removeFeature(featureType); - if (removed) { - this.emit('feature:removed', { device, featureType }); - } - return removed; - } - return false; - } - - /** - * Remove a universal device - */ - public async removeUniversalDevice(id: string): Promise { - const device = this.universalDevices.get(id); - if (device) { - await device.disconnect(); - this.universalDevices.delete(id); - this.emit('universal:device:lost', id); - return true; - } - return false; - } - - /** - * Get feature from a device by type - */ - public getFeatureFromDevice(deviceId: string, featureType: TFeatureType): T | undefined { - const device = this.universalDevices.get(deviceId); - return device?.getFeature(featureType); + this.devicesByIp.clear(); } } -// Export all classes and types +// ============================================================================ +// Exports +// ============================================================================ + export { // Discovery MdnsDiscovery, @@ -1079,26 +999,22 @@ export { SERVICE_TYPES, SSDP_SERVICE_TYPES, - // Scanner & Printer - Scanner, - Printer, - - // SNMP - SnmpDevice, - - // UPS - UpsDevice, - - // DLNA - DlnaRenderer, - DlnaServer, - - // Speakers - Speaker, - SonosSpeaker, - AirPlaySpeaker, - ChromecastSpeaker, - - // Universal Device (new architecture) + // Universal Device UniversalDevice, + + // Features + ScanFeature, + PrintFeature, + PlaybackFeature, + VolumeFeature, + PowerFeature, + SnmpFeature, + + // Factories + createScanner, + createPrinter, + createSnmpDevice, + createUpsDevice, + createSpeaker, + createDlnaRenderer, }; diff --git a/ts/discovery/discovery.classes.networkscanner.ts b/ts/discovery/discovery.classes.networkscanner.ts index 59573a6..d3a27fc 100644 --- a/ts/discovery/discovery.classes.networkscanner.ts +++ b/ts/discovery/discovery.classes.networkscanner.ts @@ -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, diff --git a/ts/dlna/dlna.classes.renderer.ts b/ts/dlna/dlna.classes.renderer.ts deleted file mode 100644 index 224d2c7..0000000 --- a/ts/dlna/dlna.classes.renderer.ts +++ /dev/null @@ -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 { - 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 { - this.soapClient = null; - } - - /** - * Refresh status - */ - public async refreshStatus(): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - if (!this.soapClient) { - throw new Error('Not connected'); - } - - await this.soapClient.next(this.avTransportUrl); - this.emit('playback:next'); - } - - /** - * Previous track - */ - public async previous(): Promise { - 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 { - if (!this.soapClient) { - throw new Error('Not connected'); - } - - return this.soapClient.getVolume(this.renderingControlUrl); - } - - /** - * Set volume level - */ - public async setVolume(level: number): Promise { - 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 { - if (!this.soapClient) { - throw new Error('Not connected'); - } - - return this.soapClient.getMute(this.renderingControlUrl); - } - - /** - * Set mute state - */ - public async setMute(muted: boolean): Promise { - 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 { - const newMuted = !this._currentMuted; - await this.setMute(newMuted); - return newMuted; - } - - // ============================================================================ - // Status Information - // ============================================================================ - - /** - * Get transport info - */ - public async getTransportInfo(): Promise { - if (!this.soapClient) { - throw new Error('Not connected'); - } - - return this.soapClient.getTransportInfo(this.avTransportUrl); - } - - /** - * Get position info - */ - public async getPositionInfo(): Promise { - if (!this.soapClient) { - throw new Error('Not connected'); - } - - return this.soapClient.getPositionInfo(this.avTransportUrl); - } - - /** - * Get media info - */ - public async getMediaInfo(): Promise { - if (!this.soapClient) { - throw new Error('Not connected'); - } - - return this.soapClient.getMediaInfo(this.avTransportUrl); - } - - /** - * Get full playback state - */ - public async getPlaybackState(): Promise { - 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 - ); - } -} diff --git a/ts/dlna/dlna.classes.server.ts b/ts/dlna/dlna.classes.server.ts deleted file mode 100644 index 722711b..0000000 --- a/ts/dlna/dlna.classes.server.ts +++ /dev/null @@ -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 { - 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 { - this.soapClient = null; - } - - /** - * Refresh status - */ - public async refreshStatus(): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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'; diff --git a/ts/factories/index.ts b/ts/factories/index.ts new file mode 100644 index 0000000..1b52550 --- /dev/null +++ b/ts/factories/index.ts @@ -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; +} + +/** + * 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; +} + +/** + * 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): 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): 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): 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): 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, +}; diff --git a/ts/features/feature.print.ts b/ts/features/feature.print.ts index 399d5c0..0c092b0 100644 --- a/ts/features/feature.print.ts +++ b/ts/features/feature.print.ts @@ -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, diff --git a/ts/features/feature.scan.ts b/ts/features/feature.scan.ts index 1219645..a3ebca4 100644 --- a/ts/features/feature.scan.ts +++ b/ts/features/feature.scan.ts @@ -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, diff --git a/ts/index.ts b/ts/index.ts index 7f389a4..d6ebbe9 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -4,31 +4,24 @@ * Supports: Scanners, Printers, SNMP devices, UPS, DLNA, Sonos, AirPlay, Chromecast */ -// Main exports from DeviceManager +// ============================================================================ +// Core Device Manager +// ============================================================================ + export { DeviceManager, MdnsDiscovery, NetworkScanner, SsdpDiscovery, - Scanner, - Printer, - SnmpDevice, - UpsDevice, - DlnaRenderer, - DlnaServer, - Speaker, - SonosSpeaker, - AirPlaySpeaker, - ChromecastSpeaker, SERVICE_TYPES, SSDP_SERVICE_TYPES, } from './devicemanager.classes.devicemanager.js'; -// Abstract/base classes -export { Device } from './abstract/device.abstract.js'; +// ============================================================================ +// Universal Device & Features +// ============================================================================ -// Universal Device & Features (new architecture) -export { UniversalDevice } from './device/device.classes.device.js'; +export { UniversalDevice, type IUniversalDeviceInfo, type IDeviceCreateOptions } from './device/device.classes.device.js'; export { Feature, ScanFeature, @@ -47,34 +40,66 @@ export { type ISnmpFeatureOptions, } from './features/index.js'; -// Scanner protocol implementations -export { EsclProtocol } from './scanner/scanner.classes.esclprotocol.js'; -export { SaneProtocol } from './scanner/scanner.classes.saneprotocol.js'; +// ============================================================================ +// Device Factories +// ============================================================================ -// Printer protocol -export { IppProtocol } from './printer/printer.classes.ippprotocol.js'; - -// SNMP protocol -export { SnmpProtocol, SNMP_OIDS } from './snmp/snmp.classes.snmpprotocol.js'; - -// UPS protocols -export { NutProtocol, NUT_COMMANDS, NUT_VARIABLES } from './ups/ups.classes.nutprotocol.js'; -export { UpsSnmpHandler, UPS_SNMP_OIDS } from './ups/ups.classes.upssnmp.js'; - -// DLNA/UPnP protocol export { + createScanner, + createPrinter, + createSnmpDevice, + createUpsDevice, + createSpeaker, + createDlnaRenderer, + type IScannerDiscoveryInfo, + type IPrinterDiscoveryInfo, + type ISnmpDiscoveryInfo, + type IUpsDiscoveryInfo, + type ISpeakerDiscoveryInfo, + type IDlnaRendererDiscoveryInfo, +} from './factories/index.js'; + +// ============================================================================ +// Protocol Implementations +// ============================================================================ + +export { + EsclProtocol, + SaneProtocol, + IppProtocol, + SnmpProtocol, + SNMP_OIDS, + NutProtocol, + NUT_COMMANDS, + NUT_VARIABLES, UpnpSoapClient, UPNP_SERVICE_TYPES, UPNP_DEVICE_TYPES, -} from './dlna/dlna.classes.upnp.js'; - -// Chromecast app IDs -export { CHROMECAST_APPS } from './speaker/speaker.classes.chromecast.js'; - -// AirPlay features -export { AIRPLAY_FEATURES } from './speaker/speaker.classes.airplay.js'; + UpsSnmpHandler, + UPS_SNMP_OIDS, + type ISnmpOptions, + type ISnmpVarbind, + type TSnmpValueType, + type TNutStatusFlag, + type INutUpsInfo, + type INutVariable, + type TDlnaTransportState, + type TDlnaTransportStatus, + type IDlnaPositionInfo, + type IDlnaTransportInfo, + type IDlnaMediaInfo, + type IDlnaContentItem, + type IDlnaBrowseResult, + type TUpsBatteryStatus, + type TUpsOutputSource, + type TUpsTestResult, + type IUpsSnmpStatus, +} from './protocols/index.js'; +// ============================================================================ // Helpers +// ============================================================================ + export { withRetry, createRetryable, defaultRetryOptions } from './helpers/helpers.retry.js'; export { isValidIp, @@ -86,56 +111,12 @@ export { countIpsInCidr, } from './helpers/helpers.iprange.js'; -// All interfaces and types +// ============================================================================ +// All Interfaces and Types +// ============================================================================ + export * from './interfaces/index.js'; -// SNMP types -export type { - ISnmpOptions, - ISnmpVarbind, - TSnmpValueType, -} from './snmp/snmp.classes.snmpprotocol.js'; -export type { ISnmpDeviceInfo } from './snmp/snmp.classes.snmpdevice.js'; - -// UPS types -export type { - TNutStatusFlag, - INutUpsInfo, - INutVariable, -} from './ups/ups.classes.nutprotocol.js'; -export type { - TUpsBatteryStatus, - TUpsOutputSource, - IUpsSnmpStatus, -} from './ups/ups.classes.upssnmp.js'; -export type { - TUpsStatus, - TUpsProtocol, - IUpsDeviceInfo, - IUpsBatteryInfo, - IUpsPowerInfo, - IUpsFullStatus, -} from './ups/ups.classes.upsdevice.js'; - -// DLNA types -export type { - TDlnaTransportState, - TDlnaTransportStatus, - IDlnaPositionInfo, - IDlnaTransportInfo, - IDlnaMediaInfo, - IDlnaContentItem, - IDlnaBrowseResult, -} from './dlna/dlna.classes.upnp.js'; -export type { - IDlnaRendererInfo, - IDlnaPlaybackState, -} from './dlna/dlna.classes.renderer.js'; -export type { - IDlnaServerInfo, - IDlnaServerStats, -} from './dlna/dlna.classes.server.js'; - // SSDP types export type { ISsdpDevice, @@ -143,26 +124,3 @@ export type { ISsdpService, ISsdpIcon, } from './discovery/discovery.classes.ssdp.js'; - -// Speaker types -export type { - TSpeakerProtocol, - TPlaybackState, - ITrackInfo, - IPlaybackStatus, - ISpeakerInfo, -} from './speaker/speaker.classes.speaker.js'; -export type { - ISonosZoneInfo, - ISonosSpeakerInfo, -} from './speaker/speaker.classes.sonos.js'; -export type { - IAirPlaySpeakerInfo, - IAirPlayPlaybackInfo, -} from './speaker/speaker.classes.airplay.js'; -export type { - TChromecastType, - IChromecastSpeakerInfo, - IChromecastMediaMetadata, - IChromecastMediaStatus, -} from './speaker/speaker.classes.chromecast.js'; diff --git a/ts/printer/printer.classes.printer.ts b/ts/printer/printer.classes.printer.ts deleted file mode 100644 index 3ed3b81..0000000 --- a/ts/printer/printer.classes.printer.ts +++ /dev/null @@ -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; - }, - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - this.ippClient = null; - } - - /** - * Refresh printer status - */ - public async refreshStatus(): Promise { - 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 }; diff --git a/ts/protocols/index.ts b/ts/protocols/index.ts new file mode 100644 index 0000000..937385c --- /dev/null +++ b/ts/protocols/index.ts @@ -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'; diff --git a/ts/ups/ups.classes.upssnmp.ts b/ts/protocols/protocol.upssnmp.ts similarity index 99% rename from ts/ups/ups.classes.upssnmp.ts rename to ts/protocols/protocol.upssnmp.ts index c00ad7e..1a2cf65 100644 --- a/ts/ups/ups.classes.upssnmp.ts +++ b/ts/protocols/protocol.upssnmp.ts @@ -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) diff --git a/ts/scanner/scanner.classes.scanner.ts b/ts/scanner/scanner.classes.scanner.ts deleted file mode 100644 index b9ffee6..0000000 --- a/ts/scanner/scanner.classes.scanner.ts +++ /dev/null @@ -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; - }, - 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): 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): 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): 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): 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 { - 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 { - 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 { - 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 { - 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 { - if (this.esclClient) { - this.esclClient = null; - } - - if (this.saneClient) { - await this.saneClient.disconnect(); - this.saneClient = null; - } - } - - /** - * Refresh scanner status - */ - public async refreshStatus(): Promise { - 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 }; diff --git a/ts/snmp/snmp.classes.snmpdevice.ts b/ts/snmp/snmp.classes.snmpdevice.ts deleted file mode 100644 index 16f034d..0000000 --- a/ts/snmp/snmp.classes.snmpdevice.ts +++ /dev/null @@ -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 { - 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 { - if (this.protocol) { - this.protocol.close(); - this.protocol = null; - } - } - - /** - * Refresh device status - */ - public async refreshStatus(): Promise { - 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 { - if (!this.protocol) { - throw new Error('Not connected'); - } - return this.protocol.get(oid); - } - - /** - * Get multiple OID values - */ - public async getMultiple(oids: string[]): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 }; diff --git a/ts/speaker/speaker.classes.airplay.ts b/ts/speaker/speaker.classes.airplay.ts deleted file mode 100644 index 5d5d3f7..0000000 --- a/ts/speaker/speaker.classes.airplay.ts +++ /dev/null @@ -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 { - // 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(/features<\/key>\s*(\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(/deviceid<\/key>\s*([^<]+)<\/string>/); - if (deviceIdMatch) { - this._deviceId = deviceIdMatch[1]; - } - - // Extract model - const modelMatch = text.match(/model<\/key>\s*([^<]+)<\/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 { - try { - await this.stop(); - } catch { - // Ignore stop errors - } - } - - /** - * Refresh status - */ - public async refreshStatus(): Promise { - 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 { - 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 { - await this.setRate(0); - this._isPlaying = false; - this._playbackState = 'paused'; - this.emit('playback:paused'); - } - - /** - * Stop playback - */ - public async stop(): Promise { - 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 { - throw new Error('Next track not supported on AirPlay'); - } - - /** - * Previous track (not supported on basic AirPlay) - */ - public async previous(): Promise { - throw new Error('Previous track not supported on AirPlay'); - } - - /** - * Seek to position - */ - public async seek(seconds: number): Promise { - 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 { - // AirPlay volume control varies by device - return this._volume; - } - - /** - * Set volume (not always supported) - */ - public async setVolume(level: number): Promise { - 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 { - return this._muted; - } - - /** - * Set mute state (not always supported) - */ - public async setMute(muted: boolean): Promise { - // 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 { - 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 { - 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 { - 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 { - 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}\\s*([\\d.]+)`)); - return match ? parseFloat(match[1]) : 0; - }; - - const extractBool = (key: string): boolean => { - const match = text.match(new RegExp(`${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 { - try { - const response = await fetch(`http://${address}:${port}/server-info`, { - signal: AbortSignal.timeout(timeout), - }); - return response.ok; - } catch { - return false; - } - } -} diff --git a/ts/speaker/speaker.classes.chromecast.ts b/ts/speaker/speaker.classes.chromecast.ts deleted file mode 100644 index 2a0a16b..0000000 --- a/ts/speaker/speaker.classes.chromecast.ts +++ /dev/null @@ -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 | 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 { - 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 { - if (this.client) { - this.client.close(); - this.client = null; - } - this.player = null; - } - - /** - * Refresh status - */ - public async refreshStatus(): Promise { - 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> { - if (!this.client) { - throw new Error('Not connected'); - } - - return new Promise((resolve, reject) => { - this.client!.launch(plugins.castv2Client.DefaultMediaReceiver, (err: Error | null, player: InstanceType) => { - 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 { - if (!this.client) { - throw new Error('Not connected'); - } - - const player = await this.getMediaPlayer() as InstanceType; - - 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 { - if (!this.player) { - throw new Error('No active media session'); - } - - return new Promise((resolve, reject) => { - (this.player as InstanceType).pause((err: Error | null) => { - if (err) { - reject(err); - return; - } - - this._playbackState = 'paused'; - this.emit('playback:paused'); - resolve(); - }); - }); - } - - /** - * Stop playback - */ - public async stop(): Promise { - if (!this.player) { - throw new Error('No active media session'); - } - - return new Promise((resolve, reject) => { - (this.player as InstanceType).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 { - throw new Error('Next track not supported on basic Chromecast'); - } - - /** - * Previous track (not supported) - */ - public async previous(): Promise { - throw new Error('Previous track not supported on basic Chromecast'); - } - - /** - * Seek to position - */ - public async seek(seconds: number): Promise { - if (!this.player) { - throw new Error('No active media session'); - } - - return new Promise((resolve, reject) => { - (this.player as InstanceType).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 { - await this.refreshStatus(); - return this._volume; - } - - /** - * Set volume - */ - public async setVolume(level: number): Promise { - 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 { - await this.refreshStatus(); - return this._muted; - } - - /** - * Set mute state - */ - public async setMute(muted: boolean): Promise { - 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 { - if (!this.player) { - return null; - } - - return new Promise((resolve) => { - (this.player as InstanceType).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 { - 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 { - if (!this.client) { - throw new Error('Not connected'); - } - - return new Promise((resolve, reject) => { - this.client!.launch({ id: appId } as Parameters[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 { - if (!this.client) { - throw new Error('Not connected'); - } - - return new Promise((resolve, reject) => { - this.client!.stop(this.player as InstanceType, (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 { - 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); - }); - }); - } -} - diff --git a/ts/speaker/speaker.classes.sonos.ts b/ts/speaker/speaker.classes.sonos.ts deleted file mode 100644 index 39cf6a0..0000000 --- a/ts/speaker/speaker.classes.sonos.ts +++ /dev/null @@ -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 | 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 { - 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 { - this.device = null; - } - - /** - * Refresh status - */ - public async refreshStatus(): Promise { - 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 { - 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 { - if (!this.device) { - throw new Error('Not connected'); - } - - await this.device.pause(); - this._playbackState = 'paused'; - this.emit('playback:paused'); - } - - /** - * Stop - */ - public async stop(): Promise { - 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 { - if (!this.device) { - throw new Error('Not connected'); - } - - await this.device.next(); - this.emit('playback:next'); - } - - /** - * Previous track - */ - public async previous(): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - if (!this.device) { - throw new Error('Not connected'); - } - - await this.device.flush(); - this.emit('queue:cleared'); - } - - /** - * Get queue contents - */ - public async getQueue(): Promise { - 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 { - 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 { - 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 { - if (!this.device) { - throw new Error('Not connected'); - } - - await this.device.playTuneinRadio(stationId); - } - - /** - * Play Spotify URI - */ - public async playSpotify(spotifyUri: string): Promise { - 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 { - 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 { - if (!this.device) { - throw new Error('Not connected'); - } - - await this.device.leaveGroup(); - this.emit('group:left'); - } - - /** - * Get group information - */ - public async getGroupInfo(): Promise { - 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 { - 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); - }); - }); - } -} diff --git a/ts/speaker/speaker.classes.speaker.ts b/ts/speaker/speaker.classes.speaker.ts deleted file mode 100644 index a7d43af..0000000 --- a/ts/speaker/speaker.classes.speaker.ts +++ /dev/null @@ -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; - - /** - * Pause playback - */ - public abstract pause(): Promise; - - /** - * Stop playback - */ - public abstract stop(): Promise; - - /** - * Next track - */ - public abstract next(): Promise; - - /** - * Previous track - */ - public abstract previous(): Promise; - - /** - * Seek to position - */ - public abstract seek(seconds: number): Promise; - - /** - * Get volume level (0-100) - */ - public abstract getVolume(): Promise; - - /** - * Set volume level (0-100) - */ - public abstract setVolume(level: number): Promise; - - /** - * Get mute state - */ - public abstract getMute(): Promise; - - /** - * Set mute state - */ - public abstract setMute(muted: boolean): Promise; - - /** - * Get current track info - */ - public abstract getCurrentTrack(): Promise; - - /** - * Get playback status - */ - public abstract getPlaybackStatus(): Promise; - - // ============================================================================ - // Common Methods - // ============================================================================ - - /** - * Toggle mute - */ - public async toggleMute(): Promise { - const currentMute = await this.getMute(); - await this.setMute(!currentMute); - return !currentMute; - } - - /** - * Volume up - */ - public async volumeUp(step: number = 5): Promise { - 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 { - 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, - }; - } -} diff --git a/ts/ups/ups.classes.upsdevice.ts b/ts/ups/ups.classes.upsdevice.ts deleted file mode 100644 index 2f2c6d4..0000000 --- a/ts/ups/ups.classes.upsdevice.ts +++ /dev/null @@ -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 { - if (this.upsProtocol === 'nut') { - await this.connectNut(); - } else { - await this.connectSnmp(); - } - } - - /** - * Connect via NUT protocol - */ - private async connectNut(): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 = { - 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 { - 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 { - 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 { - 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 { - const command = type === 'deep' - ? NUT_COMMANDS.testBatteryStartDeep - : NUT_COMMANDS.testBatteryStartQuick; - return this.runCommand(command); - } - - /** - * Stop battery test - */ - public async stopBatteryTest(): Promise { - return this.runCommand(NUT_COMMANDS.testBatteryStop); - } - - /** - * Toggle beeper - */ - public async toggleBeeper(): Promise { - 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';