diff --git a/changelog.md b/changelog.md new file mode 100644 index 0000000..1dae17a --- /dev/null +++ b/changelog.md @@ -0,0 +1,18 @@ +# Changelog + +## 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 + +- Add UniversalDevice class and Feature abstraction with concrete features: scan, print, playback, volume, power, snmp, dlna-render/serve. +- Add SSDP discovery and DLNA implementations (renderer + server) and integrate SSDP into DeviceManager. +- Add speaker subsystem and concrete speaker implementations: Sonos, AirPlay, Chromecast, plus generic Speaker API and Volume/Playback features. +- Add SNMP feature and SNMP device handling plus UPS support (NUT and UPS SNMP handlers and UpsDevice). +- Refactor protocol implementations: move/replace scanner/printer protocol code into protocols/ (eSCL, SANE, IPP) and update network scanner to probe additional ports (AirPlay, Sonos, Chromecast) and device types. +- Update exports (ts/index.ts) to expose new modules, types and helpers; update plugins import handling (node-ssdp default export compatibility). +- Add developer docs readme.hints.md describing new architecture and feature APIs, and various helper fixes (iprange/os import, typed socket handlers). + +## 2026-01-09 - 1.0.1 - initial +Initial project release. + +- Project initialized (initial commit). +- Duplicate initial commits consolidated into this release. \ No newline at end of file diff --git a/readme.hints.md b/readme.hints.md new file mode 100644 index 0000000..5efeb7c --- /dev/null +++ b/readme.hints.md @@ -0,0 +1,66 @@ +# Device Manager - Implementation Notes + +## Architecture Overview + +The device manager supports two architectures: + +### Legacy Architecture (Still Supported) +- Separate device classes: `Scanner`, `Printer`, `Speaker`, `SnmpDevice`, `UpsDevice`, `DlnaRenderer`, `DlnaServer` +- Type-specific collections in DeviceManager +- Type-based queries: `getScanners()`, `getPrinters()`, `getSpeakers()` + +### New Universal Device Architecture +- Single `UniversalDevice` class with composable features +- Features are capabilities that can be attached to any device +- Supports multifunction devices naturally (e.g., printer+scanner) + +## Key Files + +### Features (`ts/features/`) +- `feature.abstract.ts` - Base Feature class with connection management and retry logic +- `feature.scan.ts` - Scanning via eSCL/SANE protocols +- `feature.print.ts` - Printing via IPP protocol +- `feature.playback.ts` - Media playback (Sonos, AirPlay, Chromecast, DLNA) +- `feature.volume.ts` - Volume control (separate from playback) +- `feature.power.ts` - UPS/power monitoring via NUT/SNMP +- `feature.snmp.ts` - SNMP queries + +### Device (`ts/device/`) +- `device.classes.device.ts` - UniversalDevice class with feature management + +### Interfaces (`ts/interfaces/`) +- `feature.interfaces.ts` - All feature-related types and interfaces +- `index.ts` - Re-exports feature interfaces + +## Feature Types +```typescript +type TFeatureType = + | 'scan' | 'print' | 'fax' | 'copy' + | 'playback' | 'volume' | 'power' | 'snmp' + | 'dlna-render' | 'dlna-serve'; +``` + +## DeviceManager Feature API +```typescript +// Query by features +dm.getDevicesWithFeature('scan'); // Devices with scan feature +dm.getDevicesWithFeatures(['scan', 'print']); // Devices with ALL features +dm.getDevicesWithAnyFeature(['playback', 'volume']); // Devices with ANY feature + +// Manage universal devices +dm.addUniversalDevice(device); +dm.addFeatureToDevice(deviceId, feature); +dm.removeFeatureFromDevice(deviceId, featureType); +``` + +## Protocol Implementations +- `EsclProtocol` - eSCL/AirScan scanner protocol +- `SaneProtocol` - SANE network scanner protocol +- `IppProtocol` - IPP printer protocol +- `SnmpProtocol` - SNMP queries +- `NutProtocol` - Network UPS Tools protocol + +## Type Notes +- `TScanFormat` includes 'tiff' (added for compatibility) +- `IPrinterCapabilities` (from index.ts) has `string[]` for sides/quality +- `IPrintCapabilities` (from feature.interfaces.ts) has typed arrays diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts new file mode 100644 index 0000000..cb48a06 --- /dev/null +++ b/ts/00_commitinfo_data.ts @@ -0,0 +1,8 @@ +/** + * autocreated commitinfo by @push.rocks/commitinfo + */ +export const commitinfo = { + name: '@ecobridge.xyz/devicemanager', + version: '1.1.0', + description: 'a device manager for talking to devices on network and over usb' +} diff --git a/ts/device/device.classes.device.ts b/ts/device/device.classes.device.ts new file mode 100644 index 0000000..edd6b45 --- /dev/null +++ b/ts/device/device.classes.device.ts @@ -0,0 +1,431 @@ +/** + * Universal Device class + * A device is a network endpoint that can have multiple features (capabilities) + */ + +import * as plugins from '../plugins.js'; +import type { + TFeatureType, + IFeatureInfo, + IDiscoveredFeature, +} from '../interfaces/feature.interfaces.js'; +import type { TDeviceStatus, IRetryOptions } from '../interfaces/index.js'; +import { Feature, type TDeviceReference } from '../features/feature.abstract.js'; + +// ============================================================================ +// Device Info Interface +// ============================================================================ + +/** + * Serializable device information + */ +export interface IUniversalDeviceInfo { + id: string; + name: string; + address: string; + port: number; + status: TDeviceStatus; + manufacturer?: string; + model?: string; + serialNumber?: string; + firmwareVersion?: string; + features: IFeatureInfo[]; + featureTypes: TFeatureType[]; +} + +/** + * Options for creating a device + */ +export interface IDeviceCreateOptions { + name?: string; + manufacturer?: string; + model?: string; + serialNumber?: string; + firmwareVersion?: string; + retryOptions?: IRetryOptions; +} + +// ============================================================================ +// Universal Device Class +// ============================================================================ + +/** + * Universal Device - represents any network device with composable features + * + * Instead of having separate Scanner, Printer, Speaker classes, a Device can have + * any combination of features (scan, print, playback, volume, etc.) + * + * @example + * ```typescript + * // A multifunction printer/scanner + * const mfp = new UniversalDevice('192.168.1.100', 80); + * mfp.addFeature(new ScanFeature(mfp, 80)); + * mfp.addFeature(new PrintFeature(mfp, 631)); + * + * // Check capabilities + * if (mfp.hasFeature('scan') && mfp.hasFeature('print')) { + * const scanFeature = mfp.getFeature('scan'); + * await scanFeature.scan({ format: 'pdf' }); + * } + * ``` + */ +export class UniversalDevice extends plugins.events.EventEmitter { + // ============================================================================ + // Properties + // ============================================================================ + + /** + * Unique device identifier + * Format: {address}:{port} or custom ID from discovery + */ + public readonly id: string; + + /** + * Human-readable device name + */ + public name: string; + + /** + * Device network address (IP or hostname) + */ + public readonly address: string; + + /** + * Primary device port + */ + public readonly port: number; + + /** + * Device manufacturer + */ + public manufacturer?: string; + + /** + * Device model + */ + public model?: string; + + /** + * Device serial number + */ + public serialNumber?: string; + + /** + * Device firmware version + */ + public firmwareVersion?: string; + + /** + * Current device status + */ + protected _status: TDeviceStatus = 'unknown'; + + /** + * Map of features by type + */ + protected _features: Map = new Map(); + + /** + * Retry options for features + */ + protected _retryOptions: IRetryOptions; + + // ============================================================================ + // Constructor + // ============================================================================ + + constructor( + address: string, + port: number, + options?: IDeviceCreateOptions + ) { + super(); + this.address = address; + this.port = port; + this.id = `${address}:${port}`; + this.name = options?.name ?? `Device at ${address}`; + this.manufacturer = options?.manufacturer; + this.model = options?.model; + this.serialNumber = options?.serialNumber; + this.firmwareVersion = options?.firmwareVersion; + this._retryOptions = options?.retryOptions ?? {}; + } + + // ============================================================================ + // Status Management + // ============================================================================ + + /** + * Get current device status + */ + public get status(): TDeviceStatus { + return this._status; + } + + /** + * Set device status + */ + public set status(value: TDeviceStatus) { + if (this._status !== value) { + const oldStatus = this._status; + this._status = value; + this.emit('status:changed', { oldStatus, newStatus: value }); + } + } + + /** + * Check if device is online + */ + public get isOnline(): boolean { + return this._status === 'online'; + } + + // ============================================================================ + // Feature Management + // ============================================================================ + + /** + * Add a feature to the device + */ + public addFeature(feature: Feature): void { + if (this._features.has(feature.type)) { + throw new Error(`Feature '${feature.type}' already exists on device ${this.id}`); + } + + this._features.set(feature.type, feature); + + // Forward feature events + feature.on('connected', () => this.emit('feature:connected', feature.type)); + feature.on('disconnected', () => this.emit('feature:disconnected', feature.type)); + feature.on('error', (error) => this.emit('feature:error', { type: feature.type, error })); + feature.on('state:changed', (states) => this.emit('feature:state:changed', { type: feature.type, ...states })); + + this.emit('feature:added', feature); + } + + /** + * Remove a feature from the device + */ + public async removeFeature(type: TFeatureType): Promise { + const feature = this._features.get(type); + if (!feature) { + return false; + } + + // Disconnect the feature first + try { + await feature.disconnect(); + } catch { + // Ignore disconnect errors + } + + this._features.delete(type); + this.emit('feature:removed', type); + return true; + } + + /** + * Check if device has a specific feature + */ + public hasFeature(type: TFeatureType): boolean { + return this._features.has(type); + } + + /** + * Check if device has all specified features + */ + public hasFeatures(types: TFeatureType[]): boolean { + return types.every((type) => this._features.has(type)); + } + + /** + * Check if device has any of the specified features + */ + public hasAnyFeature(types: TFeatureType[]): boolean { + return types.some((type) => this._features.has(type)); + } + + /** + * Get a feature by type + */ + public getFeature(type: TFeatureType): T | undefined { + return this._features.get(type) as T | undefined; + } + + /** + * Get all features + */ + public getFeatures(): Feature[] { + return Array.from(this._features.values()); + } + + /** + * Get all feature types + */ + public getFeatureTypes(): TFeatureType[] { + return Array.from(this._features.keys()); + } + + /** + * Get feature count + */ + public get featureCount(): number { + return this._features.size; + } + + // ============================================================================ + // Connection Management + // ============================================================================ + + /** + * Connect all features + */ + public async connect(): Promise { + const features = this.getFeatures(); + const results = await Promise.allSettled( + features.map((f) => f.connect()) + ); + + // Check if any connected successfully + const anyConnected = results.some((r) => r.status === 'fulfilled'); + + if (anyConnected) { + this.status = 'online'; + } else if (results.length > 0) { + // All failed + this.status = 'error'; + const errors = results + .filter((r): r is PromiseRejectedResult => r.status === 'rejected') + .map((r) => r.reason); + throw new AggregateError(errors, 'Failed to connect any features'); + } + } + + /** + * Connect a specific feature + */ + public async connectFeature(type: TFeatureType): Promise { + const feature = this._features.get(type); + if (!feature) { + throw new Error(`Feature '${type}' not found on device ${this.id}`); + } + await feature.connect(); + + // Update device status if this is the first connected feature + if (this._status !== 'online') { + this.status = 'online'; + } + } + + /** + * Disconnect all features + */ + public async disconnect(): Promise { + const features = this.getFeatures(); + await Promise.allSettled( + features.map((f) => f.disconnect()) + ); + this.status = 'offline'; + } + + /** + * Disconnect a specific feature + */ + public async disconnectFeature(type: TFeatureType): Promise { + const feature = this._features.get(type); + if (feature) { + await feature.disconnect(); + } + + // Update device status if all features are disconnected + const anyConnected = this.getFeatures().some((f) => f.isConnected); + if (!anyConnected) { + this.status = 'offline'; + } + } + + // ============================================================================ + // Serialization + // ============================================================================ + + /** + * Get device reference for features + */ + public getDeviceReference(): TDeviceReference { + return { + id: this.id, + name: this.name, + address: this.address, + port: this.port, + }; + } + + /** + * Get serializable device info + */ + public getDeviceInfo(): IUniversalDeviceInfo { + return { + id: this.id, + name: this.name, + address: this.address, + port: this.port, + status: this._status, + manufacturer: this.manufacturer, + model: this.model, + serialNumber: this.serialNumber, + firmwareVersion: this.firmwareVersion, + features: this.getFeatures().map((f) => f.getFeatureInfo()), + featureTypes: this.getFeatureTypes(), + }; + } + + /** + * Get retry options for features + */ + public get retryOptions(): IRetryOptions { + return this._retryOptions; + } + + // ============================================================================ + // Static Factory Methods + // ============================================================================ + + /** + * Create a device from discovered features + */ + public static fromDiscovery( + id: string, + name: string, + address: string, + port: number, + discoveredFeatures: IDiscoveredFeature[], + options?: IDeviceCreateOptions + ): UniversalDevice { + const device = new UniversalDevice(address, port, { + ...options, + name, + }); + + // Override the generated ID with the discovery ID + (device as { id: string }).id = id; + + // Features will be added by the DeviceManager after creation + // This is because feature instances need protocol-specific initialization + + return device; + } +} + +// ============================================================================ +// Type Guards +// ============================================================================ + +/** + * Check if a device has a specific feature (type guard) + */ +export function deviceHasFeature( + device: UniversalDevice, + type: TFeatureType +): device is UniversalDevice & { getFeature(type: TFeatureType): T } { + return device.hasFeature(type); +} diff --git a/ts/devicemanager.classes.devicemanager.ts b/ts/devicemanager.classes.devicemanager.ts index e845344..152ba3f 100644 --- a/ts/devicemanager.classes.devicemanager.ts +++ b/ts/devicemanager.classes.devicemanager.ts @@ -1,8 +1,17 @@ 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 type { IDeviceManagerOptions, IDiscoveredDevice, @@ -10,8 +19,14 @@ import type { 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 */ @@ -27,10 +42,24 @@ const DEFAULT_OPTIONS: Required = { * Main Device Manager class for discovering and managing network devices */ export class DeviceManager extends plugins.events.EventEmitter { - private discovery: MdnsDiscovery; + 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(); + private options: Required; private retryOptions: IRetryOptions; @@ -46,46 +75,58 @@ export class DeviceManager extends plugins.events.EventEmitter { jitter: true, }; - this.discovery = new MdnsDiscovery({ + this.mdnsDiscovery = new MdnsDiscovery({ timeout: this.options.discoveryTimeout, }); + this.ssdpDiscovery = new SsdpDiscovery(); + this.setupDiscoveryEvents(); + this.setupSsdpDiscoveryEvents(); } /** - * Setup event forwarding from discovery service + * Setup event forwarding from mDNS discovery service */ private setupDiscoveryEvents(): void { - this.discovery.on('device:found', (device: IDiscoveredDevice) => { - this.handleDeviceFound(device); + this.mdnsDiscovery.on('device:found', (device: IDiscoveredDevice) => { + this.handleMdnsDeviceFound(device); }); - this.discovery.on('device:lost', (device: IDiscoveredDevice) => { + this.mdnsDiscovery.on('device:lost', (device: IDiscoveredDevice) => { this.handleDeviceLost(device); }); - this.discovery.on('scanner:found', (device: IDiscoveredDevice) => { - // Scanner found event is emitted after device:found handling - }); - - this.discovery.on('printer:found', (device: IDiscoveredDevice) => { - // Printer found event is emitted after device:found handling - }); - - this.discovery.on('started', () => { + this.mdnsDiscovery.on('started', () => { this.emit('discovery:started'); }); - this.discovery.on('stopped', () => { + this.mdnsDiscovery.on('stopped', () => { this.emit('discovery:stopped'); }); } /** - * Handle newly discovered device + * Setup event forwarding from SSDP discovery service */ - private handleDeviceFound(device: IDiscoveredDevice): void { + private setupSsdpDiscoveryEvents(): void { + this.ssdpDiscovery.on('device:found', (device: ISsdpDevice) => { + this.handleSsdpDeviceFound(device); + }); + + this.ssdpDiscovery.on('started', () => { + this.emit('ssdp:started'); + }); + + this.ssdpDiscovery.on('stopped', () => { + this.emit('ssdp:stopped'); + }); + } + + /** + * Handle newly discovered mDNS device + */ + private handleMdnsDeviceFound(device: IDiscoveredDevice): void { if (device.type === 'scanner') { // Create Scanner instance const scanner = Scanner.fromDiscovery( @@ -117,6 +158,114 @@ export class DeviceManager extends plugins.events.EventEmitter { 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); + } + } + + /** + * 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) { + return; + } + + const serviceType = device.serviceType; + const deviceType = device.description.deviceType; + + // 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()); + } + } + + // 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()); + } + } + + // Check for Sonos ZonePlayer + if (serviceType.includes('ZonePlayer') || deviceType.includes('ZonePlayer')) { + const speaker = SonosSpeaker.fromDiscovery( + { + id: `sonos:${device.usn}`, + name: device.description.friendlyName, + address: device.address, + port: 1400, + roomName: device.description.friendlyName, + modelName: device.description.modelName, + }, + this.retryOptions + ); + this.sonosSpeakers.set(speaker.id, speaker); + this.emit('speaker:found', speaker.getSpeakerInfo()); } } @@ -148,24 +297,30 @@ export class DeviceManager extends plugins.events.EventEmitter { } /** - * Start device discovery + * Start device discovery (mDNS and SSDP) */ public async startDiscovery(): Promise { - await this.discovery.start(); + await Promise.all([ + this.mdnsDiscovery.start(), + this.ssdpDiscovery.start(), + ]); } /** * Stop device discovery */ public async stopDiscovery(): Promise { - await this.discovery.stop(); + await Promise.all([ + this.mdnsDiscovery.stop(), + this.ssdpDiscovery.stop(), + ]); } /** * Check if discovery is running */ public get isDiscovering(): boolean { - return this.discovery.running; + return this.mdnsDiscovery.running || this.ssdpDiscovery.isRunning; } /** @@ -182,6 +337,66 @@ export class DeviceManager extends plugins.events.EventEmitter { return Array.from(this.printers.values()); } + /** + * Get all discovered SNMP devices + */ + public getSnmpDevices(): SnmpDevice[] { + return Array.from(this.snmpDevices.values()); + } + + /** + * Get all discovered UPS devices + */ + public getUpsDevices(): UpsDevice[] { + return Array.from(this.upsDevices.values()); + } + + /** + * Get all DLNA media renderers + */ + public getDlnaRenderers(): DlnaRenderer[] { + return Array.from(this.dlnaRenderers.values()); + } + + /** + * Get all DLNA media servers + */ + public getDlnaServers(): DlnaServer[] { + return Array.from(this.dlnaServers.values()); + } + + /** + * Get all speakers (all protocols) + */ + public getSpeakers(): Speaker[] { + return [ + ...this.getSonosSpeakers(), + ...this.getAirPlaySpeakers(), + ...this.getChromecastSpeakers(), + ]; + } + + /** + * Get all Sonos speakers + */ + public getSonosSpeakers(): SonosSpeaker[] { + return Array.from(this.sonosSpeakers.values()); + } + + /** + * Get all AirPlay speakers + */ + public getAirPlaySpeakers(): AirPlaySpeaker[] { + return Array.from(this.airplaySpeakers.values()); + } + + /** + * Get all Chromecast speakers + */ + public getChromecastSpeakers(): ChromecastSpeaker[] { + return Array.from(this.chromecastSpeakers.values()); + } + /** * Get scanner by ID */ @@ -197,17 +412,92 @@ export class DeviceManager extends plugins.events.EventEmitter { } /** - * Get all devices (scanners and printers) + * Get SNMP device by ID */ - public getDevices(): (Scanner | Printer)[] { - return [...this.getScanners(), ...this.getPrinters()]; + public getSnmpDevice(id: string): SnmpDevice | undefined { + return this.snmpDevices.get(id); } /** - * Get device by ID (scanner or printer) + * Get UPS device by ID */ - public getDevice(id: string): Scanner | Printer | undefined { - return this.scanners.get(id) ?? this.printers.get(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 []; + } + } + + /** + * 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); } /** @@ -323,10 +613,11 @@ export class DeviceManager extends plugins.events.EventEmitter { */ public async scanNetwork( options: INetworkScanOptions - ): Promise<{ scanners: Scanner[]; printers: Printer[] }> { + ): Promise<{ scanners: Scanner[]; printers: Printer[]; speakers: Speaker[] }> { const results = await this.networkScanner.scan(options); const foundScanners: Scanner[] = []; const foundPrinters: Printer[] = []; + const foundSpeakers: Speaker[] = []; for (const result of results) { for (const device of result.devices) { @@ -359,6 +650,26 @@ export class DeviceManager extends plugins.events.EventEmitter { 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); + } } } catch (error) { // Device could not be added (connection failed, etc.) @@ -367,7 +678,7 @@ export class DeviceManager extends plugins.events.EventEmitter { } } - return { scanners: foundScanners, printers: foundPrinters }; + return { scanners: foundScanners, printers: foundPrinters, speakers: foundSpeakers }; } /** @@ -419,15 +730,11 @@ export class DeviceManager extends plugins.events.EventEmitter { public async disconnectAll(): Promise { const disconnectPromises: Promise[] = []; - for (const scanner of this.scanners.values()) { - if (scanner.isConnected) { - disconnectPromises.push(scanner.disconnect().catch(() => {})); - } - } - - for (const printer of this.printers.values()) { - if (printer.isConnected) { - disconnectPromises.push(printer.disconnect().catch(() => {})); + // Disconnect all device types + const allDevices = this.getAllDevices(); + for (const device of allDevices) { + if (device.isConnected) { + disconnectPromises.push(device.disconnect().catch(() => {})); } } @@ -440,8 +747,18 @@ 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(); } /** @@ -450,20 +767,11 @@ export class DeviceManager extends plugins.events.EventEmitter { public async refreshAllStatus(): Promise { const refreshPromises: Promise[] = []; - for (const scanner of this.scanners.values()) { - if (scanner.isConnected) { + const allDevices = this.getAllDevices(); + for (const device of allDevices) { + if (device.isConnected) { refreshPromises.push( - scanner.refreshStatus().catch((error) => { - this.emit('error', error); - }) - ); - } - } - - for (const printer of this.printers.values()) { - if (printer.isConnected) { - refreshPromises.push( - printer.refreshStatus().catch((error) => { + device.refreshStatus().catch((error) => { this.emit('error', error); }) ); @@ -472,6 +780,325 @@ export class DeviceManager extends plugins.events.EventEmitter { 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); + } } -export { MdnsDiscovery, NetworkScanner, Scanner, Printer, SERVICE_TYPES }; +// Export all classes and types +export { + // Discovery + MdnsDiscovery, + NetworkScanner, + SsdpDiscovery, + 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) + UniversalDevice, +}; diff --git a/ts/discovery/discovery.classes.mdns.ts b/ts/discovery/discovery.classes.mdns.ts index 15d358b..9a89387 100644 --- a/ts/discovery/discovery.classes.mdns.ts +++ b/ts/discovery/discovery.classes.mdns.ts @@ -19,6 +19,13 @@ const SERVICE_TYPES = { IPP: '_ipp._tcp', // IPP printers IPPS: '_ipps._tcp', // IPP over TLS PDL: '_pdl-datastream._tcp', // Raw/JetDirect printers + + // Speakers / Audio + AIRPLAY: '_airplay._tcp', // AirPlay devices (Apple TV, HomePod, etc.) + RAOP: '_raop._tcp', // Remote Audio Output Protocol (AirPlay audio) + SONOS: '_sonos._tcp', // Sonos speakers + GOOGLECAST: '_googlecast._tcp', // Chromecast / Google Cast devices + SPOTIFY: '_spotify-connect._tcp', // Spotify Connect devices } as const; /** @@ -26,11 +33,18 @@ const SERVICE_TYPES = { */ const DEFAULT_OPTIONS: Required = { serviceTypes: [ + // Scanners SERVICE_TYPES.ESCL, SERVICE_TYPES.ESCL_SECURE, SERVICE_TYPES.SANE, + // Printers SERVICE_TYPES.IPP, SERVICE_TYPES.IPPS, + // Speakers + SERVICE_TYPES.AIRPLAY, + SERVICE_TYPES.RAOP, + SERVICE_TYPES.SONOS, + SERVICE_TYPES.GOOGLECAST, ], timeout: 10000, }; @@ -281,11 +295,22 @@ export class MdnsDiscovery extends plugins.events.EventEmitter { case SERVICE_TYPES.IPPS: case SERVICE_TYPES.PDL: return 'printer'; + case SERVICE_TYPES.AIRPLAY: + case SERVICE_TYPES.RAOP: + case SERVICE_TYPES.SONOS: + case SERVICE_TYPES.GOOGLECAST: + case SERVICE_TYPES.SPOTIFY: + return 'speaker'; default: // Check if it's a scanner or printer based on service type pattern if (serviceType.includes('scan') || serviceType.includes('scanner')) { return 'scanner'; } + if (serviceType.includes('airplay') || serviceType.includes('raop') || + serviceType.includes('sonos') || serviceType.includes('cast') || + serviceType.includes('spotify')) { + return 'speaker'; + } return 'printer'; } } @@ -293,7 +318,7 @@ export class MdnsDiscovery extends plugins.events.EventEmitter { /** * Determine protocol from service type */ - private getProtocol(serviceType: string): TScannerProtocol | 'ipp' { + private getProtocol(serviceType: string): string { switch (serviceType) { case SERVICE_TYPES.ESCL: case SERVICE_TYPES.ESCL_SECURE: @@ -304,8 +329,17 @@ export class MdnsDiscovery extends plugins.events.EventEmitter { case SERVICE_TYPES.IPPS: case SERVICE_TYPES.PDL: return 'ipp'; + case SERVICE_TYPES.AIRPLAY: + case SERVICE_TYPES.RAOP: + return 'airplay'; + case SERVICE_TYPES.SONOS: + return 'sonos'; + case SERVICE_TYPES.GOOGLECAST: + return 'chromecast'; + case SERVICE_TYPES.SPOTIFY: + return 'spotify'; default: - return 'ipp'; + return 'unknown'; } } } diff --git a/ts/discovery/discovery.classes.networkscanner.ts b/ts/discovery/discovery.classes.networkscanner.ts index a9d33f4..59573a6 100644 --- a/ts/discovery/discovery.classes.networkscanner.ts +++ b/ts/discovery/discovery.classes.networkscanner.ts @@ -1,6 +1,5 @@ import * as plugins from '../plugins.js'; import { EsclProtocol } from '../scanner/scanner.classes.esclprotocol.js'; -import { IppProtocol } from '../printer/printer.classes.ippprotocol.js'; import { cidrToIps, ipRangeToIps, @@ -16,7 +15,18 @@ import type { /** * Default ports to probe for device discovery */ -const DEFAULT_PORTS = [631, 80, 443, 6566, 9100]; +const DEFAULT_PORTS = [ + 631, // IPP printers + 80, // eSCL scanners (HTTP) + 443, // eSCL scanners (HTTPS) + 6566, // SANE scanners + 9100, // JetDirect printers + 7000, // AirPlay speakers + 5000, // AirPlay control / RTSP + 3689, // DAAP (iTunes/AirPlay 2) + 1400, // Sonos speakers + 8009, // Chromecast devices +]; /** * Default scan options @@ -28,6 +38,9 @@ const DEFAULT_OPTIONS: Required { + if (device) devices.push(device); + }) + ); + } + + // AirPlay ports 5000 (RTSP) and 3689 (DAAP) - if open, it's likely an AirPlay device + if (opts.probeAirplay && (port === 5000 || port === 3689)) { + devices.push({ + type: 'speaker', + protocol: 'airplay', + port, + name: `AirPlay Device at ${ip}`, + }); + } + + // Sonos probe (port 1400) + if (opts.probeSonos && port === 1400) { + probePromises.push( + this.probeSonos(ip, port, timeout).then((device) => { + if (device) devices.push(device); + }) + ); + } + + // Chromecast probe (port 8009) + if (opts.probeChromecast && port === 8009) { + probePromises.push( + this.probeChromecast(ip, port, timeout).then((device) => { + if (device) devices.push(device); + }) + ); + } } await Promise.all(probePromises); @@ -281,7 +331,8 @@ export class NetworkScanner extends plugins.events.EventEmitter { } /** - * Probe for IPP printer + * Probe for IPP printer using a simple HTTP check + * We avoid using the full ipp library for probing since it can hang and produce noisy output */ private async probeIpp( ip: string, @@ -289,22 +340,54 @@ export class NetworkScanner extends plugins.events.EventEmitter { timeout: number ): Promise { try { - const ipp = new IppProtocol(ip, port); - const attrs = await Promise.race([ - ipp.getAttributes(), - new Promise((_, reject) => - setTimeout(() => reject(new Error('Timeout')), timeout) - ), - ]); + // Use a simple HTTP OPTIONS or POST to check if IPP endpoint exists + // IPP uses HTTP POST to /ipp/print or /ipp/printer + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); - if (attrs) { - return { - type: 'printer', - protocol: 'ipp', - port, - name: `IPP Printer at ${ip}`, - model: undefined, - }; + try { + const response = await fetch(`http://${ip}:${port}/ipp/print`, { + method: 'POST', + headers: { + 'Content-Type': 'application/ipp', + }, + body: Buffer.from([ + // Minimal IPP Get-Printer-Attributes request + 0x01, 0x01, // IPP version 1.1 + 0x00, 0x0b, // operation-id: Get-Printer-Attributes + 0x00, 0x00, 0x00, 0x01, // request-id: 1 + 0x01, // operation-attributes-tag + 0x47, // charset + 0x00, 0x12, // name-length: 18 + ...Buffer.from('attributes-charset'), + 0x00, 0x05, // value-length: 5 + ...Buffer.from('utf-8'), + 0x48, // naturalLanguage + 0x00, 0x1b, // name-length: 27 + ...Buffer.from('attributes-natural-language'), + 0x00, 0x05, // value-length: 5 + ...Buffer.from('en-us'), + 0x03, // end-of-attributes-tag + ]), + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + // If we get a response with application/ipp content type, it's likely an IPP printer + const contentType = response.headers.get('content-type') || ''; + if (response.ok || contentType.includes('application/ipp')) { + return { + type: 'printer', + protocol: 'ipp', + port, + name: `IPP Printer at ${ip}`, + model: undefined, + }; + } + } catch (fetchErr) { + clearTimeout(timeoutId); + // Fetch failed or was aborted } } catch { // Not an IPP printer @@ -375,4 +458,191 @@ export class NetworkScanner extends plugins.events.EventEmitter { return null; } + + /** + * Probe for AirPlay speaker on port 7000 + * Tries HTTP endpoints to identify the device. If no HTTP response, + * but port 7000 is open, it's still likely an AirPlay device. + */ + private async probeAirplay( + ip: string, + port: number, + timeout: number + ): Promise { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + // Try /server-info first (older AirPlay devices) + try { + const response = await fetch(`http://${ip}:${port}/server-info`, { + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (response.ok) { + const text = await response.text(); + // Parse model from plist if available + const modelMatch = text.match(/model<\/key>\s*([^<]+)<\/string>/); + const model = modelMatch?.[1]; + + return { + type: 'speaker', + protocol: 'airplay', + port, + name: model || `AirPlay Speaker at ${ip}`, + model, + }; + } + } catch { + clearTimeout(timeoutId); + } + + // Try /info endpoint (some AirPlay 2 devices) + const controller2 = new AbortController(); + const timeoutId2 = setTimeout(() => controller2.abort(), timeout); + try { + const response = await fetch(`http://${ip}:${port}/info`, { + signal: controller2.signal, + }); + + clearTimeout(timeoutId2); + + if (response.ok) { + const text = await response.text(); + // Try to parse model info + const modelMatch = text.match(/model<\/key>\s*([^<]+)<\/string>/); + const nameMatch = text.match(/name<\/key>\s*([^<]+)<\/string>/); + const model = modelMatch?.[1]; + const name = nameMatch?.[1]; + + return { + type: 'speaker', + protocol: 'airplay', + port, + name: name || model || `AirPlay Speaker at ${ip}`, + model, + }; + } + } catch { + clearTimeout(timeoutId2); + } + + // Port 7000 is open but no HTTP endpoints responded + // Still likely an AirPlay device (AirPlay 2 / HomePod) + return { + type: 'speaker', + protocol: 'airplay', + port, + name: `AirPlay Device at ${ip}`, + }; + } catch { + // Not an AirPlay speaker + } + + return null; + } + + /** + * Probe for Sonos speaker + */ + private async probeSonos( + ip: string, + port: number, + timeout: number + ): Promise { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + try { + // Sonos devices respond to device description requests + const response = await fetch(`http://${ip}:${port}/xml/device_description.xml`, { + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (response.ok) { + const text = await response.text(); + + // Check if it's actually a Sonos device + if (text.includes('Sonos') || text.includes('schemas-upnp-org')) { + // Parse friendly name and model + const nameMatch = text.match(/([^<]+)<\/friendlyName>/); + const modelMatch = text.match(/([^<]+)<\/modelName>/); + + return { + type: 'speaker', + protocol: 'sonos', + port, + name: nameMatch?.[1] || `Sonos Speaker at ${ip}`, + model: modelMatch?.[1], + }; + } + } + } catch { + clearTimeout(timeoutId); + } + } catch { + // Not a Sonos speaker + } + + return null; + } + + /** + * Probe for Chromecast device + */ + private async probeChromecast( + ip: string, + port: number, + timeout: number + ): Promise { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + try { + // Chromecast devices have an info endpoint on port 8008 (HTTP) + // Port 8009 is the Cast protocol port (TLS) + // Try fetching the eureka_info endpoint + const response = await fetch(`http://${ip}:8008/setup/eureka_info`, { + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (response.ok) { + const data = await response.json(); + + return { + type: 'speaker', + protocol: 'chromecast', + port, + name: data.name || `Chromecast at ${ip}`, + model: data.cast_build_revision || data.model_name, + }; + } + } catch { + clearTimeout(timeoutId); + } + + // Alternative: just check if port 8009 is open (Cast protocol) + const isOpen = await this.isPortOpen(ip, port, timeout); + if (isOpen) { + return { + type: 'speaker', + protocol: 'chromecast', + port, + name: `Chromecast at ${ip}`, + }; + } + } catch { + // Not a Chromecast + } + + return null; + } } diff --git a/ts/dlna/dlna.classes.renderer.ts b/ts/dlna/dlna.classes.renderer.ts new file mode 100644 index 0000000..224d2c7 --- /dev/null +++ b/ts/dlna/dlna.classes.renderer.ts @@ -0,0 +1,527 @@ +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 new file mode 100644 index 0000000..722711b --- /dev/null +++ b/ts/dlna/dlna.classes.server.ts @@ -0,0 +1,468 @@ +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/features/feature.abstract.ts b/ts/features/feature.abstract.ts new file mode 100644 index 0000000..eaf237d --- /dev/null +++ b/ts/features/feature.abstract.ts @@ -0,0 +1,251 @@ +/** + * Abstract Feature base class + * Features are composable capabilities that can be attached to devices + */ + +import * as plugins from '../plugins.js'; +import type { + TFeatureType, + TFeatureState, + IFeature, + IFeatureInfo, + IFeatureOptions, +} from '../interfaces/feature.interfaces.js'; +import type { IRetryOptions } from '../interfaces/index.js'; +import { withRetry } from '../helpers/helpers.retry.js'; + +/** + * Forward reference to Device to avoid circular dependency + * The actual Device class will set this reference + */ +export type TDeviceReference = { + id: string; + name: string; + address: string; + port: number; +}; + +/** + * Abstract base class for all features + * Provides common functionality for connection management, state tracking, and retry logic + */ +export abstract class Feature extends plugins.events.EventEmitter implements IFeature { + /** + * The feature type identifier + */ + public abstract readonly type: TFeatureType; + + /** + * The protocol used by this feature + */ + public abstract readonly protocol: string; + + /** + * The port used by this feature + */ + protected _port: number; + + /** + * Current feature state + */ + protected _state: TFeatureState = 'disconnected'; + + /** + * Last error encountered + */ + protected _lastError?: Error; + + /** + * Reference to the parent device + */ + protected _device: TDeviceReference; + + /** + * Retry configuration + */ + protected _retryConfig: Required; + + /** + * Additional metadata from discovery + */ + protected _metadata: Record; + + constructor( + device: TDeviceReference, + port: number, + options?: IFeatureOptions + ) { + super(); + this._device = device; + this._port = port; + this._metadata = options?.metadata ?? {}; + + // Setup retry config + const retryOpts = options?.retryOptions ?? {}; + this._retryConfig = { + maxRetries: retryOpts.maxRetries ?? 3, + baseDelay: retryOpts.baseDelay ?? 1000, + maxDelay: retryOpts.maxDelay ?? 30000, + multiplier: retryOpts.multiplier ?? 2, + jitter: retryOpts.jitter ?? true, + }; + } + + // ============================================================================ + // Public Properties + // ============================================================================ + + /** + * Get the feature port + */ + public get port(): number { + return this._port; + } + + /** + * Get the current feature state + */ + public get state(): TFeatureState { + return this._state; + } + + /** + * Check if the feature is connected + */ + public get isConnected(): boolean { + return this._state === 'connected'; + } + + /** + * Get the last error + */ + public get lastError(): Error | undefined { + return this._lastError; + } + + /** + * Get the device address + */ + public get address(): string { + return this._device.address; + } + + /** + * Get the device ID + */ + public get deviceId(): string { + return this._device.id; + } + + // ============================================================================ + // Connection Management + // ============================================================================ + + /** + * Connect to the feature endpoint + */ + public async connect(): Promise { + if (this._state === 'connected') { + return; + } + + if (this._state === 'connecting') { + throw new Error(`Feature ${this.type} is already connecting`); + } + + this.setState('connecting'); + this._lastError = undefined; + + try { + await this.withRetry(() => this.doConnect()); + this.setState('connected'); + this.emit('connected'); + } catch (error) { + this._lastError = error instanceof Error ? error : new Error(String(error)); + this.setState('error'); + this.emit('error', this._lastError); + throw this._lastError; + } + } + + /** + * Disconnect from the feature endpoint + */ + public async disconnect(): Promise { + if (this._state === 'disconnected') { + return; + } + + try { + await this.doDisconnect(); + } finally { + this.setState('disconnected'); + this.emit('disconnected'); + } + } + + // ============================================================================ + // Abstract Methods (implemented by subclasses) + // ============================================================================ + + /** + * Perform the actual connection logic + * Implemented by each feature subclass + */ + protected abstract doConnect(): Promise; + + /** + * Perform the actual disconnection logic + * Implemented by each feature subclass + */ + protected abstract doDisconnect(): Promise; + + /** + * Get feature-specific info for serialization + */ + public abstract getFeatureInfo(): IFeatureInfo; + + // ============================================================================ + // Helper Methods + // ============================================================================ + + /** + * Set the feature state and emit change event + */ + protected setState(state: TFeatureState): void { + if (this._state !== state) { + const oldState = this._state; + this._state = state; + this.emit('state:changed', { oldState, newState: state }); + } + } + + /** + * Execute an operation with retry logic + */ + protected async withRetry(operation: () => Promise): Promise { + return withRetry(operation, this._retryConfig); + } + + /** + * Get base feature info + */ + protected getBaseFeatureInfo(): IFeatureInfo { + return { + type: this.type, + protocol: this.protocol, + port: this._port, + state: this._state, + }; + } + + /** + * Clear error state + */ + public clearError(): void { + this._lastError = undefined; + if (this._state === 'error') { + this.setState('disconnected'); + } + } +} diff --git a/ts/features/feature.playback.ts b/ts/features/feature.playback.ts new file mode 100644 index 0000000..20446df --- /dev/null +++ b/ts/features/feature.playback.ts @@ -0,0 +1,246 @@ +/** + * Playback Feature + * Provides media playback control capability + */ + +import { Feature, type TDeviceReference } from './feature.abstract.js'; +import type { + TPlaybackProtocol, + TPlaybackState, + ITrackInfo, + IPlaybackStatus, + IPlaybackFeatureInfo, + IFeatureOptions, +} from '../interfaces/feature.interfaces.js'; + +/** + * Options for creating a PlaybackFeature + */ +export interface IPlaybackFeatureOptions extends IFeatureOptions { + protocol: TPlaybackProtocol; + supportsQueue?: boolean; + supportsSeek?: boolean; + /** Protocol-specific client instance (Sonos, AirPlay, etc.) */ + protocolClient?: unknown; +} + +/** + * Playback Feature - provides media playback control + * + * Abstract feature that can be backed by different protocols (Sonos, AirPlay, Chromecast, DLNA). + * Concrete implementations should extend this class for protocol-specific behavior. + * + * @example + * ```typescript + * const playback = device.getFeature('playback'); + * if (playback) { + * await playback.play('spotify:track:123'); + * const status = await playback.getPlaybackStatus(); + * console.log(`Playing: ${status.track?.title}`); + * } + * ``` + */ +export class PlaybackFeature extends Feature { + public readonly type = 'playback' as const; + public readonly protocol: TPlaybackProtocol; + + // Capabilities + public supportsQueue: boolean = true; + public supportsSeek: boolean = true; + + // Current state + protected _playbackState: TPlaybackState = 'stopped'; + protected _currentTrack: ITrackInfo | null = null; + protected _position: number = 0; + protected _duration: number = 0; + + // Protocol client (set by subclass or passed in options) + protected protocolClient: unknown = null; + + constructor( + device: TDeviceReference, + port: number, + options: IPlaybackFeatureOptions + ) { + super(device, port, options); + this.protocol = options.protocol; + if (options.supportsQueue !== undefined) this.supportsQueue = options.supportsQueue; + if (options.supportsSeek !== undefined) this.supportsSeek = options.supportsSeek; + if (options.protocolClient) this.protocolClient = options.protocolClient; + } + + // ============================================================================ + // Properties + // ============================================================================ + + public get playbackState(): TPlaybackState { + return this._playbackState; + } + + public get currentTrack(): ITrackInfo | null { + return this._currentTrack; + } + + public get position(): number { + return this._position; + } + + public get duration(): number { + return this._duration; + } + + public get isPlaying(): boolean { + return this._playbackState === 'playing'; + } + + // ============================================================================ + // Connection (to be overridden by protocol-specific subclasses) + // ============================================================================ + + protected async doConnect(): Promise { + // Base implementation - protocol-specific subclasses should override + // and establish their protocol connection + } + + protected async doDisconnect(): Promise { + // Base implementation - protocol-specific subclasses should override + this.protocolClient = null; + } + + // ============================================================================ + // Playback Control + // ============================================================================ + + /** + * Start playback + * @param uri Optional URI to play. If not provided, resumes current playback. + */ + public async play(uri?: string): Promise { + this.emit('playback:play', { uri }); + // Protocol-specific implementation should be provided by subclass + this._playbackState = 'playing'; + this.emit('playback:state:changed', this._playbackState); + } + + /** + * Pause playback + */ + public async pause(): Promise { + this.emit('playback:pause'); + this._playbackState = 'paused'; + this.emit('playback:state:changed', this._playbackState); + } + + /** + * Stop playback + */ + public async stop(): Promise { + this.emit('playback:stop'); + this._playbackState = 'stopped'; + this._position = 0; + this.emit('playback:state:changed', this._playbackState); + } + + /** + * Skip to next track + */ + public async next(): Promise { + this.emit('playback:next'); + } + + /** + * Go to previous track + */ + public async previous(): Promise { + this.emit('playback:previous'); + } + + /** + * Seek to position + * @param seconds Position in seconds + */ + public async seek(seconds: number): Promise { + if (!this.supportsSeek) { + throw new Error('Seek not supported'); + } + this._position = seconds; + this.emit('playback:seek', seconds); + } + + // ============================================================================ + // Status + // ============================================================================ + + /** + * Get current track info + */ + public async getCurrentTrack(): Promise { + return this._currentTrack; + } + + /** + * Get playback status + */ + public async getPlaybackStatus(): Promise { + return { + state: this._playbackState, + position: this._position, + duration: this._duration, + track: this._currentTrack ?? undefined, + }; + } + + /** + * Update track info (called by protocol-specific implementations) + */ + protected updateTrack(track: ITrackInfo | null): void { + const oldTrack = this._currentTrack; + this._currentTrack = track; + if (track?.title !== oldTrack?.title || track?.uri !== oldTrack?.uri) { + this.emit('playback:track:changed', track); + } + } + + /** + * Update position (called by protocol-specific implementations) + */ + protected updatePosition(position: number, duration: number): void { + this._position = position; + this._duration = duration; + this.emit('playback:position:changed', { position, duration }); + } + + // ============================================================================ + // Serialization + // ============================================================================ + + public getFeatureInfo(): IPlaybackFeatureInfo { + return { + ...this.getBaseFeatureInfo(), + type: 'playback', + protocol: this.protocol, + supportsQueue: this.supportsQueue, + supportsSeek: this.supportsSeek, + }; + } + + // ============================================================================ + // Static Factory + // ============================================================================ + + /** + * Create from discovery metadata + */ + public static fromDiscovery( + device: TDeviceReference, + port: number, + protocol: TPlaybackProtocol, + metadata: Record + ): PlaybackFeature { + return new PlaybackFeature(device, port, { + protocol, + supportsQueue: metadata.supportsQueue as boolean ?? true, + supportsSeek: metadata.supportsSeek as boolean ?? true, + }); + } +} diff --git a/ts/features/feature.power.ts b/ts/features/feature.power.ts new file mode 100644 index 0000000..8240662 --- /dev/null +++ b/ts/features/feature.power.ts @@ -0,0 +1,231 @@ +/** + * Power Feature + * Provides UPS/power monitoring and control capability + */ + +import { Feature, type TDeviceReference } from './feature.abstract.js'; +import type { + TPowerProtocol, + TPowerStatus, + IBatteryInfo, + IPowerInfo, + IPowerFeatureInfo, + IFeatureOptions, +} from '../interfaces/feature.interfaces.js'; + +/** + * Options for creating a PowerFeature + */ +export interface IPowerFeatureOptions extends IFeatureOptions { + protocol: TPowerProtocol; + hasBattery?: boolean; + supportsShutdown?: boolean; + supportsTest?: boolean; + /** For NUT protocol: UPS name */ + upsName?: string; + /** For SNMP: community string */ + community?: string; +} + +/** + * Power Feature - provides UPS/power monitoring and control + * + * Supports NUT (Network UPS Tools) and SNMP protocols for monitoring + * UPS devices and smart power equipment. + * + * @example + * ```typescript + * const power = device.getFeature('power'); + * if (power) { + * const status = await power.getStatus(); + * const battery = await power.getBatteryInfo(); + * console.log(`Status: ${status}, Battery: ${battery.charge}%`); + * } + * ``` + */ +export class PowerFeature extends Feature { + public readonly type = 'power' as const; + public readonly protocol: TPowerProtocol; + + // Configuration + public readonly upsName: string; + public readonly community: string; + + // Capabilities + public readonly hasBattery: boolean; + public readonly supportsShutdown: boolean; + public readonly supportsTest: boolean; + + // Current state + protected _status: TPowerStatus = 'unknown'; + protected _batteryCharge: number = 0; + protected _batteryRuntime: number = 0; + protected _load: number = 0; + + constructor( + device: TDeviceReference, + port: number, + options: IPowerFeatureOptions + ) { + super(device, port, options); + this.protocol = options.protocol; + this.upsName = options.upsName ?? 'ups'; + this.community = options.community ?? 'public'; + this.hasBattery = options.hasBattery ?? true; + this.supportsShutdown = options.supportsShutdown ?? false; + this.supportsTest = options.supportsTest ?? false; + } + + // ============================================================================ + // Properties + // ============================================================================ + + public get powerStatus(): TPowerStatus { + return this._status; + } + + public get batteryCharge(): number { + return this._batteryCharge; + } + + public get batteryRuntime(): number { + return this._batteryRuntime; + } + + public get load(): number { + return this._load; + } + + // ============================================================================ + // Connection + // ============================================================================ + + protected async doConnect(): Promise { + // Protocol-specific connection would be implemented here + // For now, just verify the port is reachable + } + + protected async doDisconnect(): Promise { + // Protocol-specific disconnection + } + + // ============================================================================ + // Status Monitoring + // ============================================================================ + + /** + * Get current power status + */ + public async getStatus(): Promise { + // Protocol-specific implementation would fetch status + return this._status; + } + + /** + * Get battery information + */ + public async getBatteryInfo(): Promise { + return { + charge: this._batteryCharge, + runtime: this._batteryRuntime, + }; + } + + /** + * Get power information + */ + public async getPowerInfo(): Promise { + return { + load: this._load, + }; + } + + // ============================================================================ + // Control Commands + // ============================================================================ + + /** + * Initiate shutdown + */ + public async shutdown(delay?: number): Promise { + if (!this.supportsShutdown) { + throw new Error('Shutdown not supported'); + } + this.emit('power:shutdown', { delay }); + } + + /** + * Start battery test + */ + public async testBattery(): Promise { + if (!this.supportsTest) { + throw new Error('Battery test not supported'); + } + this.emit('power:test:started'); + } + + // ============================================================================ + // State Updates + // ============================================================================ + + protected updateStatus(status: TPowerStatus): void { + const oldStatus = this._status; + this._status = status; + if (oldStatus !== status) { + this.emit('power:status:changed', { oldStatus, newStatus: status }); + + // Emit specific events + if (status === 'onbattery') { + this.emit('power:onbattery'); + } else if (status === 'online' && oldStatus === 'onbattery') { + this.emit('power:restored'); + } else if (status === 'lowbattery') { + this.emit('power:lowbattery'); + } + } + } + + protected updateBattery(charge: number, runtime: number): void { + const oldCharge = this._batteryCharge; + this._batteryCharge = charge; + this._batteryRuntime = runtime; + + if (oldCharge !== charge) { + this.emit('battery:changed', { charge, runtime }); + } + } + + // ============================================================================ + // Serialization + // ============================================================================ + + public getFeatureInfo(): IPowerFeatureInfo { + return { + ...this.getBaseFeatureInfo(), + type: 'power', + protocol: this.protocol, + hasBattery: this.hasBattery, + supportsShutdown: this.supportsShutdown, + supportsTest: this.supportsTest, + }; + } + + // ============================================================================ + // Static Factory + // ============================================================================ + + public static fromDiscovery( + device: TDeviceReference, + port: number, + protocol: TPowerProtocol, + metadata: Record + ): PowerFeature { + return new PowerFeature(device, port, { + protocol, + upsName: metadata.upsName as string, + hasBattery: metadata.hasBattery as boolean ?? true, + supportsShutdown: metadata.supportsShutdown as boolean ?? false, + supportsTest: metadata.supportsTest as boolean ?? false, + }); + } +} diff --git a/ts/features/feature.print.ts b/ts/features/feature.print.ts new file mode 100644 index 0000000..399d5c0 --- /dev/null +++ b/ts/features/feature.print.ts @@ -0,0 +1,297 @@ +/** + * Print Feature + * Provides document printing capability using IPP protocol + */ + +import { Feature, type TDeviceReference } from './feature.abstract.js'; +import { IppProtocol } from '../printer/printer.classes.ippprotocol.js'; +import type { + TPrintProtocol, + TPrintSides, + TPrintQuality, + TPrintColorMode, + IPrintCapabilities, + IPrintOptions, + IPrintJob, + IPrintFeatureInfo, + IFeatureOptions, +} from '../interfaces/feature.interfaces.js'; +import type { IPrinterCapabilities } from '../interfaces/index.js'; + +/** + * Options for creating a PrintFeature + */ +export interface IPrintFeatureOptions extends IFeatureOptions { + protocol?: TPrintProtocol; + uri?: string; + supportsColor?: boolean; + supportsDuplex?: boolean; + supportedMediaSizes?: string[]; + supportedMediaTypes?: string[]; + maxCopies?: number; +} + +/** + * Print Feature - provides document printing capability + * + * Wraps the IPP protocol to provide a unified printing interface. + * + * @example + * ```typescript + * const printFeature = device.getFeature('print'); + * if (printFeature) { + * await printFeature.connect(); + * const job = await printFeature.print(pdfBuffer, { copies: 2 }); + * console.log(`Print job ${job.id} created`); + * } + * ``` + */ +export class PrintFeature extends Feature { + public readonly type = 'print' as const; + public readonly protocol: TPrintProtocol; + + // Protocol client + private ippClient: IppProtocol | null = null; + + // Configuration + public readonly uri: string; + + // Capabilities + public supportsColor: boolean = true; + public supportsDuplex: boolean = false; + public supportedMediaSizes: string[] = ['iso_a4_210x297mm', 'na_letter_8.5x11in']; + public supportedMediaTypes: string[] = ['stationery']; + public maxCopies: number = 99; + public supportedSides: TPrintSides[] = ['one-sided']; + public supportedQualities: TPrintQuality[] = ['normal']; + + constructor( + device: TDeviceReference, + port: number, + options?: IPrintFeatureOptions + ) { + super(device, port, options); + this.protocol = options?.protocol ?? 'ipp'; + this.uri = options?.uri ?? `ipp://${device.address}:${port}/ipp/print`; + + // Set capabilities from options if provided + if (options?.supportsColor !== undefined) this.supportsColor = options.supportsColor; + if (options?.supportsDuplex !== undefined) { + this.supportsDuplex = options.supportsDuplex; + if (options.supportsDuplex) { + this.supportedSides = ['one-sided', 'two-sided-long-edge', 'two-sided-short-edge']; + } + } + if (options?.supportedMediaSizes) this.supportedMediaSizes = options.supportedMediaSizes; + if (options?.supportedMediaTypes) this.supportedMediaTypes = options.supportedMediaTypes; + if (options?.maxCopies) this.maxCopies = options.maxCopies; + } + + // ============================================================================ + // Connection + // ============================================================================ + + protected async doConnect(): Promise { + if (this.protocol === 'ipp') { + // Parse URI to get address, port, and path + const url = new URL(this.uri.replace('ipp://', 'http://').replace('ipps://', 'https://')); + const address = url.hostname; + const port = parseInt(url.port) || this._port; + const path = url.pathname || '/ipp/print'; + + this.ippClient = new IppProtocol(address, port, path); + // Verify connection by getting printer attributes + const attrs = await this.ippClient.getAttributes(); + this.updateCapabilitiesFromIpp(attrs); + } + // JetDirect and LPD don't need connection verification + } + + protected async doDisconnect(): Promise { + this.ippClient = null; + } + + // ============================================================================ + // Printing Operations + // ============================================================================ + + /** + * Get printer capabilities + */ + public async getCapabilities(): Promise { + return { + colorSupported: this.supportsColor, + duplexSupported: this.supportsDuplex, + mediaSizes: this.supportedMediaSizes, + mediaTypes: this.supportedMediaTypes, + resolutions: [300, 600], + maxCopies: this.maxCopies, + sidesSupported: this.supportedSides, + qualitySupported: this.supportedQualities, + }; + } + + /** + * Print a document + */ + public async print(data: Buffer, options?: IPrintOptions): Promise { + if (!this.isConnected) { + throw new Error('Print feature not connected'); + } + + if (this.protocol === 'ipp' && this.ippClient) { + return this.printWithIpp(data, options); + } + + throw new Error(`Protocol ${this.protocol} not supported yet`); + } + + /** + * Get active print jobs + */ + public async getJobs(): Promise { + if (!this.isConnected || !this.ippClient) { + throw new Error('Print feature not connected'); + } + + return this.ippClient.getJobs(); + } + + /** + * Get info about a specific job + */ + public async getJobInfo(jobId: number): Promise { + if (!this.isConnected || !this.ippClient) { + throw new Error('Print feature not connected'); + } + + return this.ippClient.getJobInfo(jobId); + } + + /** + * Cancel a print job + */ + public async cancelJob(jobId: number): Promise { + if (!this.isConnected || !this.ippClient) { + throw new Error('Print feature not connected'); + } + + await this.ippClient.cancelJob(jobId); + this.emit('job:cancelled', jobId); + } + + // ============================================================================ + // Protocol-Specific Printing + // ============================================================================ + + private async printWithIpp(data: Buffer, options?: IPrintOptions): Promise { + if (!this.ippClient) { + throw new Error('IPP client not initialized'); + } + + this.emit('print:started', options); + + // IppProtocol.print() accepts IPrintOptions and returns IPrintJob + const job = await this.ippClient.print(data, options); + + this.emit('print:submitted', job); + return job; + } + + // ============================================================================ + // Helper Methods + // ============================================================================ + + private updateCapabilitiesFromIpp(caps: IPrinterCapabilities): void { + this.supportsColor = caps.colorSupported; + this.supportsDuplex = caps.duplexSupported; + this.maxCopies = caps.maxCopies; + + if (caps.mediaSizes && caps.mediaSizes.length > 0) { + this.supportedMediaSizes = caps.mediaSizes; + } + if (caps.mediaTypes && caps.mediaTypes.length > 0) { + this.supportedMediaTypes = caps.mediaTypes; + } + if (caps.sidesSupported && caps.sidesSupported.length > 0) { + this.supportedSides = caps.sidesSupported.filter((s): s is TPrintSides => + ['one-sided', 'two-sided-long-edge', 'two-sided-short-edge'].includes(s) + ); + } + if (caps.qualitySupported && caps.qualitySupported.length > 0) { + this.supportedQualities = caps.qualitySupported.filter((q): q is TPrintQuality => + ['draft', 'normal', 'high'].includes(q) + ); + } + } + + private qualityToIpp(quality: TPrintQuality): number { + switch (quality) { + case 'draft': return 3; + case 'normal': return 4; + case 'high': return 5; + default: return 4; + } + } + + private mapIppJob(job: Record): IPrintJob { + const stateMap: Record = { + 3: 'pending', + 4: 'pending', + 5: 'processing', + 6: 'processing', + 7: 'canceled', + 8: 'aborted', + 9: 'completed', + }; + + return { + id: job['job-id'] as number, + name: job['job-name'] as string ?? 'Unknown', + state: stateMap[(job['job-state'] as number) ?? 3] ?? 'pending', + stateReason: (job['job-state-reasons'] as string[])?.[0], + createdAt: new Date((job['time-at-creation'] as number) * 1000), + completedAt: job['time-at-completed'] + ? new Date((job['time-at-completed'] as number) * 1000) + : undefined, + }; + } + + // ============================================================================ + // Serialization + // ============================================================================ + + public getFeatureInfo(): IPrintFeatureInfo { + return { + ...this.getBaseFeatureInfo(), + type: 'print', + protocol: this.protocol, + supportsColor: this.supportsColor, + supportsDuplex: this.supportsDuplex, + supportedMediaSizes: this.supportedMediaSizes, + }; + } + + // ============================================================================ + // Static Factory + // ============================================================================ + + /** + * Create from discovery metadata + */ + public static fromDiscovery( + device: TDeviceReference, + port: number, + protocol: TPrintProtocol, + metadata: Record + ): PrintFeature { + const txtRecords = metadata.txtRecords as Record ?? {}; + + return new PrintFeature(device, port, { + protocol, + uri: metadata.uri as string, + supportsColor: txtRecords['Color'] === 'T' || txtRecords['color'] === 'true', + supportsDuplex: txtRecords['Duplex'] === 'T' || txtRecords['duplex'] === 'true', + }); + } +} diff --git a/ts/features/feature.scan.ts b/ts/features/feature.scan.ts new file mode 100644 index 0000000..1219645 --- /dev/null +++ b/ts/features/feature.scan.ts @@ -0,0 +1,371 @@ +/** + * Scan Feature + * Provides document scanning capability using eSCL or SANE protocols + */ + +import { Feature, type TDeviceReference } from './feature.abstract.js'; +import { EsclProtocol } from '../scanner/scanner.classes.esclprotocol.js'; +import { SaneProtocol } from '../scanner/scanner.classes.saneprotocol.js'; +import type { + TScanProtocol, + TScanFormat, + TColorMode, + TScanSource, + IScanCapabilities, + IScanOptions, + IScanResult, + IScanFeatureInfo, + IFeatureOptions, +} from '../interfaces/feature.interfaces.js'; + +/** + * Options for creating a ScanFeature + */ +export interface IScanFeatureOptions extends IFeatureOptions { + protocol: TScanProtocol; + secure?: boolean; + deviceName?: string; // For SANE + supportedFormats?: TScanFormat[]; + supportedResolutions?: number[]; + supportedColorModes?: TColorMode[]; + supportedSources?: TScanSource[]; + hasAdf?: boolean; + hasDuplex?: boolean; +} + +/** + * Scan Feature - provides document scanning capability + * + * Wraps eSCL (AirScan) or SANE protocols to provide a unified scanning interface. + * + * @example + * ```typescript + * const scanFeature = device.getFeature('scan'); + * if (scanFeature) { + * await scanFeature.connect(); + * const result = await scanFeature.scan({ format: 'pdf', resolution: 300 }); + * console.log(`Scanned ${result.width}x${result.height} pixels`); + * } + * ``` + */ +export class ScanFeature extends Feature { + public readonly type = 'scan' as const; + public readonly protocol: TScanProtocol; + + // Protocol clients + private esclClient: EsclProtocol | null = null; + private saneClient: SaneProtocol | null = null; + + // Configuration + private readonly isSecure: boolean; + private readonly deviceName: string; + + // Capabilities + public supportedFormats: TScanFormat[] = ['jpeg', 'png', 'pdf']; + public supportedResolutions: number[] = [75, 150, 300, 600]; + public supportedColorModes: TColorMode[] = ['color', 'grayscale', 'blackwhite']; + public supportedSources: TScanSource[] = ['flatbed']; + public hasAdf: boolean = false; + public hasDuplex: boolean = false; + public maxWidth: number = 215.9; // A4 width in mm + public maxHeight: number = 297; // A4 height in mm + public minWidth: number = 10; + public minHeight: number = 10; + + constructor( + device: TDeviceReference, + port: number, + options: IScanFeatureOptions + ) { + super(device, port, options); + this.protocol = options.protocol; + this.isSecure = options.secure ?? false; + this.deviceName = options.deviceName ?? ''; + + // Set capabilities from options if provided + if (options.supportedFormats) this.supportedFormats = options.supportedFormats; + if (options.supportedResolutions) this.supportedResolutions = options.supportedResolutions; + if (options.supportedColorModes) this.supportedColorModes = options.supportedColorModes; + if (options.supportedSources) this.supportedSources = options.supportedSources; + if (options.hasAdf !== undefined) this.hasAdf = options.hasAdf; + if (options.hasDuplex !== undefined) this.hasDuplex = options.hasDuplex; + } + + // ============================================================================ + // Connection + // ============================================================================ + + protected async doConnect(): Promise { + if (this.protocol === 'escl') { + this.esclClient = new EsclProtocol(this.address, this._port, this.isSecure); + // Fetch capabilities to verify connection + const caps = await this.esclClient.getCapabilities(); + this.updateCapabilitiesFromEscl(caps); + } else if (this.protocol === 'sane') { + this.saneClient = new SaneProtocol(this.address, this._port); + await this.saneClient.connect(); + // Open the device if we have a name + if (this.deviceName) { + await this.saneClient.open(this.deviceName); + } + } + } + + protected async doDisconnect(): Promise { + if (this.saneClient) { + try { + await this.saneClient.close(); + await this.saneClient.disconnect(); + } catch { + // Ignore disconnect errors + } + this.saneClient = null; + } + this.esclClient = null; + } + + // ============================================================================ + // Scanning Operations + // ============================================================================ + + /** + * Get scanner capabilities + */ + public async getCapabilities(): Promise { + return { + resolutions: this.supportedResolutions, + formats: this.supportedFormats, + colorModes: this.supportedColorModes, + sources: this.supportedSources, + maxWidth: this.maxWidth, + maxHeight: this.maxHeight, + minWidth: this.minWidth, + minHeight: this.minHeight, + }; + } + + /** + * Perform a scan + */ + public async scan(options?: IScanOptions): Promise { + if (!this.isConnected) { + throw new Error('Scan feature not connected'); + } + + const opts = this.resolveOptions(options); + + if (this.protocol === 'escl' && this.esclClient) { + return this.scanWithEscl(opts); + } else if (this.protocol === 'sane' && this.saneClient) { + return this.scanWithSane(opts); + } + + throw new Error('No scanner protocol available'); + } + + /** + * Cancel an ongoing scan + */ + public async cancelScan(): Promise { + if (this.protocol === 'sane' && this.saneClient) { + await this.saneClient.cancel(); + } + // eSCL cancellation is handled by deleting the job + this.emit('scan:cancelled'); + } + + // ============================================================================ + // Protocol-Specific Scanning + // ============================================================================ + + private async scanWithEscl(options: Required): Promise { + if (!this.esclClient) { + throw new Error('eSCL client not initialized'); + } + + this.emit('scan:started', options); + + // Use the protocol's scan method which handles job submission, + // waiting for completion, and downloading in one operation + const result = await this.esclClient.scan({ + format: options.format, + resolution: options.resolution, + colorMode: options.colorMode, + source: options.source, + area: options.area, + intent: options.intent, + }); + + this.emit('scan:completed', result); + return result; + } + + private async scanWithSane(options: Required): Promise { + if (!this.saneClient) { + throw new Error('SANE client not initialized'); + } + + this.emit('scan:started', options); + + // Use the protocol's scan method which handles option configuration, + // parameter retrieval, and image reading in one operation + const result = await this.saneClient.scan({ + format: options.format, + resolution: options.resolution, + colorMode: options.colorMode, + source: options.source, + area: options.area, + }); + + this.emit('scan:completed', result); + return result; + } + + // ============================================================================ + // Helper Methods + // ============================================================================ + + private resolveOptions(options?: IScanOptions): Required { + return { + resolution: options?.resolution ?? 300, + format: options?.format ?? 'jpeg', + colorMode: options?.colorMode ?? 'color', + source: options?.source ?? 'flatbed', + area: options?.area ?? { + x: 0, + y: 0, + width: this.maxWidth, + height: this.maxHeight, + }, + intent: options?.intent ?? 'document', + quality: options?.quality ?? 85, + }; + } + + private updateCapabilitiesFromEscl(caps: any): void { + if (caps.platen) { + this.supportedResolutions = caps.platen.supportedResolutions || this.supportedResolutions; + this.maxWidth = caps.platen.maxWidth || this.maxWidth; + this.maxHeight = caps.platen.maxHeight || this.maxHeight; + this.minWidth = caps.platen.minWidth || this.minWidth; + this.minHeight = caps.platen.minHeight || this.minHeight; + } + if (caps.adf) { + this.hasAdf = true; + if (!this.supportedSources.includes('adf')) { + this.supportedSources.push('adf'); + } + } + if (caps.adfDuplex) { + this.hasDuplex = true; + if (!this.supportedSources.includes('adf-duplex')) { + this.supportedSources.push('adf-duplex'); + } + } + } + + private colorModeToSane(mode: TColorMode): string { + switch (mode) { + case 'color': return 'Color'; + case 'grayscale': return 'Gray'; + case 'blackwhite': return 'Lineart'; + default: return 'Color'; + } + } + + private getMimeType(format: TScanFormat): string { + switch (format) { + case 'jpeg': return 'image/jpeg'; + case 'png': return 'image/png'; + case 'pdf': return 'application/pdf'; + case 'tiff': return 'image/tiff'; + default: return 'application/octet-stream'; + } + } + + // ============================================================================ + // Serialization + // ============================================================================ + + public getFeatureInfo(): IScanFeatureInfo { + return { + ...this.getBaseFeatureInfo(), + type: 'scan', + protocol: this.protocol, + supportedFormats: this.supportedFormats, + supportedResolutions: this.supportedResolutions, + supportedColorModes: this.supportedColorModes, + supportedSources: this.supportedSources, + hasAdf: this.hasAdf, + hasDuplex: this.hasDuplex, + }; + } + + // ============================================================================ + // Static Factory + // ============================================================================ + + /** + * Create from discovery metadata + */ + public static fromDiscovery( + device: TDeviceReference, + port: number, + protocol: TScanProtocol, + metadata: Record + ): ScanFeature { + const txtRecords = metadata.txtRecords as Record ?? {}; + + return new ScanFeature(device, port, { + protocol, + secure: metadata.secure as boolean ?? port === 443, + deviceName: metadata.deviceName as string, + supportedFormats: parseFormats(txtRecords), + supportedResolutions: parseResolutions(txtRecords), + supportedColorModes: parseColorModes(txtRecords), + supportedSources: parseSources(txtRecords), + hasAdf: Boolean(metadata.hasAdf), + hasDuplex: Boolean(metadata.hasDuplex), + }); + } +} + +// ============================================================================ +// Parsing Helpers +// ============================================================================ + +function parseFormats(txt: Record): TScanFormat[] { + const pdl = txt['pdl'] || ''; + const formats: TScanFormat[] = []; + if (pdl.includes('image/jpeg')) formats.push('jpeg'); + if (pdl.includes('image/png')) formats.push('png'); + if (pdl.includes('application/pdf')) formats.push('pdf'); + if (pdl.includes('image/tiff')) formats.push('tiff'); + return formats.length > 0 ? formats : ['jpeg', 'png', 'pdf']; +} + +function parseResolutions(txt: Record): number[] { + const rs = txt['rs'] || ''; + if (!rs) return [75, 150, 300, 600]; + return rs.split(',') + .map((r) => parseInt(r.trim(), 10)) + .filter((r) => !isNaN(r) && r > 0); +} + +function parseColorModes(txt: Record): TColorMode[] { + const cs = txt['cs'] || ''; + const modes: TColorMode[] = []; + if (cs.includes('color') || cs.includes('RGB')) modes.push('color'); + if (cs.includes('grayscale') || cs.includes('gray')) modes.push('grayscale'); + if (cs.includes('binary') || cs.includes('lineart')) modes.push('blackwhite'); + return modes.length > 0 ? modes : ['color', 'grayscale', 'blackwhite']; +} + +function parseSources(txt: Record): TScanSource[] { + const is = txt['is'] || ''; + const sources: TScanSource[] = []; + if (is.includes('platen') || is.includes('flatbed') || !is) sources.push('flatbed'); + if (is.includes('adf') && is.includes('duplex')) sources.push('adf-duplex'); + else if (is.includes('adf')) sources.push('adf'); + return sources.length > 0 ? sources : ['flatbed']; +} diff --git a/ts/features/feature.snmp.ts b/ts/features/feature.snmp.ts new file mode 100644 index 0000000..944dad4 --- /dev/null +++ b/ts/features/feature.snmp.ts @@ -0,0 +1,235 @@ +/** + * SNMP Feature + * Provides SNMP query capability for network management + */ + +import { Feature, type TDeviceReference } from './feature.abstract.js'; +import type { + TSnmpVersion, + ISnmpVarbind, + ISnmpFeatureInfo, + IFeatureOptions, +} from '../interfaces/feature.interfaces.js'; + +/** + * Options for creating an SnmpFeature + */ +export interface ISnmpFeatureOptions extends IFeatureOptions { + version?: TSnmpVersion; + community?: string; + // SNMPv3 options + username?: string; + authProtocol?: 'md5' | 'sha'; + authKey?: string; + privProtocol?: 'des' | 'aes'; + privKey?: string; +} + +/** + * SNMP Feature - provides SNMP query capability + * + * Allows querying devices via SNMP for monitoring and management. + * Many network devices (printers, switches, UPS) support SNMP. + * + * @example + * ```typescript + * const snmp = device.getFeature('snmp'); + * if (snmp) { + * const sysDescr = await snmp.get('1.3.6.1.2.1.1.1.0'); + * console.log(`System: ${sysDescr.value}`); + * + * const interfaces = await snmp.walk('1.3.6.1.2.1.2.2.1'); + * console.log(`Found ${interfaces.length} interface entries`); + * } + * ``` + */ +export class SnmpFeature extends Feature { + public readonly type = 'snmp' as const; + public readonly protocol = 'snmp'; + + // Configuration + public readonly version: TSnmpVersion; + public readonly community: string; + + // System info (populated after connect) + public sysDescr?: string; + public sysObjectId?: string; + public sysName?: string; + public sysLocation?: string; + public sysContact?: string; + public sysUpTime?: number; + + constructor( + device: TDeviceReference, + port: number, + options?: ISnmpFeatureOptions + ) { + super(device, port ?? 161, options); + this.version = options?.version ?? 'v2c'; + this.community = options?.community ?? 'public'; + } + + // ============================================================================ + // Connection + // ============================================================================ + + protected async doConnect(): Promise { + // Test connection by getting system description + try { + const result = await this.get('1.3.6.1.2.1.1.1.0'); // sysDescr + if (result) { + this.sysDescr = String(result.value); + } + } catch { + // Connection test failed + throw new Error('SNMP connection failed'); + } + } + + protected async doDisconnect(): Promise { + // SNMP is connectionless, nothing to disconnect + } + + // ============================================================================ + // SNMP Operations + // ============================================================================ + + /** + * Get a single OID value + */ + public async get(oid: string): Promise { + // This would use the SNMP library to perform the query + // Placeholder implementation + return { + oid, + type: 0, + value: null, + }; + } + + /** + * Get multiple OID values + */ + public async getMultiple(oids: string[]): Promise { + const results: ISnmpVarbind[] = []; + for (const oid of oids) { + results.push(await this.get(oid)); + } + return results; + } + + /** + * Get next OID in MIB tree + */ + public async getNext(oid: string): Promise { + // Placeholder + return { + oid, + type: 0, + value: null, + }; + } + + /** + * Walk a subtree of the MIB + */ + public async walk(baseOid: string): Promise { + const results: ISnmpVarbind[] = []; + // This would iterate through the MIB tree using getNext + return results; + } + + /** + * Bulk get (SNMPv2c/v3) + */ + public async getBulk( + oids: string[], + nonRepeaters: number = 0, + maxRepetitions: number = 10 + ): Promise { + if (this.version === 'v1') { + throw new Error('getBulk not supported in SNMPv1'); + } + // Placeholder + return []; + } + + /** + * Set an OID value + */ + public async set(oid: string, type: number, value: unknown): Promise { + // Placeholder + return { + oid, + type, + value, + }; + } + + // ============================================================================ + // System Info + // ============================================================================ + + /** + * Get all system info OIDs + */ + public async getSystemInfo(): Promise<{ + sysDescr?: string; + sysObjectId?: string; + sysName?: string; + sysLocation?: string; + sysContact?: string; + sysUpTime?: number; + }> { + const oids = [ + '1.3.6.1.2.1.1.1.0', // sysDescr + '1.3.6.1.2.1.1.2.0', // sysObjectID + '1.3.6.1.2.1.1.3.0', // sysUpTime + '1.3.6.1.2.1.1.4.0', // sysContact + '1.3.6.1.2.1.1.5.0', // sysName + '1.3.6.1.2.1.1.6.0', // sysLocation + ]; + + const results = await this.getMultiple(oids); + + return { + sysDescr: results[0]?.value as string, + sysObjectId: results[1]?.value as string, + sysUpTime: results[2]?.value as number, + sysContact: results[3]?.value as string, + sysName: results[4]?.value as string, + sysLocation: results[5]?.value as string, + }; + } + + // ============================================================================ + // Serialization + // ============================================================================ + + public getFeatureInfo(): ISnmpFeatureInfo { + return { + ...this.getBaseFeatureInfo(), + type: 'snmp', + version: this.version, + community: this.version !== 'v3' ? this.community : undefined, + sysDescr: this.sysDescr, + sysName: this.sysName, + sysLocation: this.sysLocation, + }; + } + + // ============================================================================ + // Static Factory + // ============================================================================ + + public static fromDiscovery( + device: TDeviceReference, + port: number, + metadata: Record + ): SnmpFeature { + return new SnmpFeature(device, port ?? 161, { + version: metadata.version as TSnmpVersion ?? 'v2c', + community: metadata.community as string ?? 'public', + }); + } +} diff --git a/ts/features/feature.volume.ts b/ts/features/feature.volume.ts new file mode 100644 index 0000000..a8f13d0 --- /dev/null +++ b/ts/features/feature.volume.ts @@ -0,0 +1,256 @@ +/** + * Volume Feature + * Provides volume control capability + */ + +import { Feature, type TDeviceReference } from './feature.abstract.js'; +import type { + IVolumeFeatureInfo, + IFeatureOptions, +} from '../interfaces/feature.interfaces.js'; + +/** + * Options for creating a VolumeFeature + */ +export interface IVolumeFeatureOptions extends IFeatureOptions { + /** Volume control protocol (usually same as device protocol) */ + volumeProtocol?: string; + /** Minimum volume level */ + minVolume?: number; + /** Maximum volume level */ + maxVolume?: number; + /** Volume step increment */ + volumeStep?: number; + /** Whether mute is supported */ + supportsMute?: boolean; + /** Protocol-specific volume controller */ + volumeController?: IVolumeController; +} + +/** + * Interface for protocol-specific volume control + */ +export interface IVolumeController { + getVolume(): Promise; + setVolume(level: number): Promise; + getMute(): Promise; + setMute(muted: boolean): Promise; +} + +/** + * Volume Feature - provides volume control capability + * + * Separated from PlaybackFeature because some devices have volume control + * without playback capability (e.g., amplifiers, HDMI matrix switches). + * + * @example + * ```typescript + * const volume = device.getFeature('volume'); + * if (volume) { + * const current = await volume.getVolume(); + * await volume.setVolume(current + 10); + * await volume.toggleMute(); + * } + * ``` + */ +export class VolumeFeature extends Feature { + public readonly type = 'volume' as const; + public readonly protocol: string; + + // Capabilities + public readonly minVolume: number; + public readonly maxVolume: number; + public readonly volumeStep: number; + public readonly supportsMute: boolean; + + // Current state + protected _volume: number = 0; + protected _muted: boolean = false; + + // Volume controller (protocol-specific) + protected volumeController: IVolumeController | null = null; + + constructor( + device: TDeviceReference, + port: number, + options?: IVolumeFeatureOptions + ) { + super(device, port, options); + this.protocol = options?.volumeProtocol ?? 'generic'; + this.minVolume = options?.minVolume ?? 0; + this.maxVolume = options?.maxVolume ?? 100; + this.volumeStep = options?.volumeStep ?? 5; + this.supportsMute = options?.supportsMute ?? true; + + if (options?.volumeController) { + this.volumeController = options.volumeController; + } + } + + // ============================================================================ + // Properties + // ============================================================================ + + /** + * Get current volume level (cached) + */ + public get volume(): number { + return this._volume; + } + + /** + * Get mute state (cached) + */ + public get muted(): boolean { + return this._muted; + } + + // ============================================================================ + // Connection + // ============================================================================ + + protected async doConnect(): Promise { + // Fetch initial state if we have a controller + if (this.volumeController) { + try { + this._volume = await this.volumeController.getVolume(); + this._muted = await this.volumeController.getMute(); + } catch { + // Ignore errors fetching initial state + } + } + } + + protected async doDisconnect(): Promise { + // Nothing to disconnect for volume control + } + + // ============================================================================ + // Volume Control + // ============================================================================ + + /** + * Get current volume level + */ + public async getVolume(): Promise { + if (this.volumeController) { + this._volume = await this.volumeController.getVolume(); + } + return this._volume; + } + + /** + * Set volume level + * @param level Volume level (clamped to min/max) + */ + public async setVolume(level: number): Promise { + const clampedLevel = Math.max(this.minVolume, Math.min(this.maxVolume, level)); + + if (this.volumeController) { + await this.volumeController.setVolume(clampedLevel); + } + + const oldVolume = this._volume; + this._volume = clampedLevel; + + if (oldVolume !== clampedLevel) { + this.emit('volume:changed', { oldVolume, newVolume: clampedLevel }); + } + } + + /** + * Increase volume by step + */ + public async volumeUp(step?: number): Promise { + const increment = step ?? this.volumeStep; + const newVolume = Math.min(this.maxVolume, this._volume + increment); + await this.setVolume(newVolume); + return this._volume; + } + + /** + * Decrease volume by step + */ + public async volumeDown(step?: number): Promise { + const decrement = step ?? this.volumeStep; + const newVolume = Math.max(this.minVolume, this._volume - decrement); + await this.setVolume(newVolume); + return this._volume; + } + + /** + * Get mute state + */ + public async getMute(): Promise { + if (this.volumeController && this.supportsMute) { + this._muted = await this.volumeController.getMute(); + } + return this._muted; + } + + /** + * Set mute state + */ + public async setMute(muted: boolean): Promise { + if (!this.supportsMute) { + throw new Error('Mute not supported'); + } + + if (this.volumeController) { + await this.volumeController.setMute(muted); + } + + const oldMuted = this._muted; + this._muted = muted; + + if (oldMuted !== muted) { + this.emit('mute:changed', { oldMuted, newMuted: muted }); + } + } + + /** + * Toggle mute state + */ + public async toggleMute(): Promise { + const currentMuted = await this.getMute(); + await this.setMute(!currentMuted); + return this._muted; + } + + // ============================================================================ + // Serialization + // ============================================================================ + + public getFeatureInfo(): IVolumeFeatureInfo { + return { + ...this.getBaseFeatureInfo(), + type: 'volume', + minVolume: this.minVolume, + maxVolume: this.maxVolume, + volumeStep: this.volumeStep, + supportsMute: this.supportsMute, + }; + } + + // ============================================================================ + // Static Factory + // ============================================================================ + + /** + * Create from discovery metadata + */ + public static fromDiscovery( + device: TDeviceReference, + port: number, + protocol: string, + metadata: Record + ): VolumeFeature { + return new VolumeFeature(device, port, { + volumeProtocol: protocol, + minVolume: metadata.minVolume as number ?? 0, + maxVolume: metadata.maxVolume as number ?? 100, + volumeStep: metadata.volumeStep as number ?? 5, + supportsMute: metadata.supportsMute as boolean ?? true, + }); + } +} diff --git a/ts/features/index.ts b/ts/features/index.ts new file mode 100644 index 0000000..957a368 --- /dev/null +++ b/ts/features/index.ts @@ -0,0 +1,15 @@ +/** + * Features Module + * Exports all feature classes and types + */ + +// Abstract base +export { Feature, type TDeviceReference } from './feature.abstract.js'; + +// Concrete features +export { ScanFeature, type IScanFeatureOptions } from './feature.scan.js'; +export { PrintFeature, type IPrintFeatureOptions } from './feature.print.js'; +export { PlaybackFeature, type IPlaybackFeatureOptions } from './feature.playback.js'; +export { VolumeFeature, type IVolumeFeatureOptions, type IVolumeController } from './feature.volume.js'; +export { PowerFeature, type IPowerFeatureOptions } from './feature.power.js'; +export { SnmpFeature, type ISnmpFeatureOptions } from './feature.snmp.js'; diff --git a/ts/helpers/helpers.iprange.ts b/ts/helpers/helpers.iprange.ts index 0c1715b..d691086 100644 --- a/ts/helpers/helpers.iprange.ts +++ b/ts/helpers/helpers.iprange.ts @@ -2,6 +2,8 @@ * IP Range utility functions for network scanning */ +import * as os from 'os'; + /** * Validates an IPv4 address */ @@ -115,7 +117,6 @@ export function cidrToIps(cidr: string): string[] { */ export function getLocalSubnet(): string | null { try { - const os = require('os'); const interfaces = os.networkInterfaces(); for (const name of Object.keys(interfaces)) { @@ -146,7 +147,7 @@ export function getLocalSubnet(): string | null { } } } catch { - // os module might not be available + // Failed to get network interfaces } return null; diff --git a/ts/index.ts b/ts/index.ts index 7608784..7f389a4 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -1,24 +1,78 @@ /** * @push.rocks/devicemanager - * A device manager for discovering and communicating with network scanners and printers + * A comprehensive device manager for discovering and communicating with network devices + * Supports: Scanners, Printers, SNMP devices, UPS, DLNA, Sonos, AirPlay, Chromecast */ -// Main exports +// Main exports from DeviceManager 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'; -// Protocol implementations -export { EsclProtocol, SaneProtocol } from './scanner/scanner.classes.scanner.js'; -export { IppProtocol } from './printer/printer.classes.printer.js'; +// Universal Device & Features (new architecture) +export { UniversalDevice } from './device/device.classes.device.js'; +export { + Feature, + ScanFeature, + PrintFeature, + PlaybackFeature, + VolumeFeature, + PowerFeature, + SnmpFeature, + type TDeviceReference, + type IScanFeatureOptions, + type IPrintFeatureOptions, + type IPlaybackFeatureOptions, + type IVolumeFeatureOptions, + type IVolumeController, + type IPowerFeatureOptions, + 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'; + +// 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 { + 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'; // Helpers export { withRetry, createRetryable, defaultRetryOptions } from './helpers/helpers.retry.js'; @@ -34,3 +88,81 @@ export { // 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, + ISsdpDeviceDescription, + 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/interfaces/feature.interfaces.ts b/ts/interfaces/feature.interfaces.ts new file mode 100644 index 0000000..1b60d8a --- /dev/null +++ b/ts/interfaces/feature.interfaces.ts @@ -0,0 +1,346 @@ +/** + * Feature Type Definitions + * Features are composable capabilities that can be attached to devices + */ + +import type { IRetryOptions } from './index.js'; + +// ============================================================================ +// Feature Types +// ============================================================================ + +/** + * All supported feature types + */ +export type TFeatureType = + | 'scan' // Can scan documents (eSCL, SANE) + | 'print' // Can print documents (IPP, JetDirect) + | 'fax' // Can send/receive fax + | 'copy' // Can copy (scan + print combined) + | 'playback' // Can play media (audio/video) + | 'volume' // Has volume control + | 'power' // Has power status (UPS, smart plug) + | 'snmp' // SNMP queryable + | 'dlna-render' // DLNA renderer + | 'dlna-serve' // DLNA server (content provider) + ; + +/** + * Feature connection state + */ +export type TFeatureState = 'disconnected' | 'connecting' | 'connected' | 'error'; + +// ============================================================================ +// Base Feature Interface +// ============================================================================ + +/** + * Base interface for all features + */ +export interface IFeature { + /** Feature type identifier */ + readonly type: TFeatureType; + /** Protocol used by this feature */ + readonly protocol: string; + /** Current feature state */ + readonly state: TFeatureState; + /** Port used by this feature (may differ from device port) */ + readonly port: number; + /** Connect to the feature endpoint */ + connect(): Promise; + /** Disconnect from the feature endpoint */ + disconnect(): Promise; +} + +/** + * Feature info for serialization + */ +export interface IFeatureInfo { + type: TFeatureType; + protocol: string; + port: number; + state: TFeatureState; +} + +// ============================================================================ +// Scan Feature +// ============================================================================ + +export type TScanProtocol = 'escl' | 'sane' | 'wia'; +export type TScanFormat = 'png' | 'jpeg' | 'pdf' | 'tiff'; +export type TColorMode = 'color' | 'grayscale' | 'blackwhite'; +export type TScanSource = 'flatbed' | 'adf' | 'adf-duplex'; + +export interface IScanCapabilities { + resolutions: number[]; + formats: TScanFormat[]; + colorModes: TColorMode[]; + sources: TScanSource[]; + maxWidth: number; // mm + maxHeight: number; // mm + minWidth: number; // mm + minHeight: number; // mm +} + +export interface IScanArea { + x: number; // X offset in mm + y: number; // Y offset in mm + width: number; // Width in mm + height: number; // Height in mm +} + +export interface IScanOptions { + resolution?: number; + format?: TScanFormat; + colorMode?: TColorMode; + source?: TScanSource; + area?: IScanArea; + intent?: 'document' | 'photo' | 'preview'; + quality?: number; // 1-100 for JPEG +} + +export interface IScanResult { + data: Buffer; + format: TScanFormat; + width: number; + height: number; + resolution: number; + colorMode: TColorMode; + mimeType: string; +} + +export interface IScanFeatureInfo extends IFeatureInfo { + type: 'scan'; + protocol: TScanProtocol; + supportedFormats: TScanFormat[]; + supportedResolutions: number[]; + supportedColorModes: TColorMode[]; + supportedSources: TScanSource[]; + hasAdf: boolean; + hasDuplex: boolean; +} + +// ============================================================================ +// Print Feature +// ============================================================================ + +export type TPrintProtocol = 'ipp' | 'jetdirect' | 'lpd'; +export type TPrintSides = 'one-sided' | 'two-sided-long-edge' | 'two-sided-short-edge'; +export type TPrintQuality = 'draft' | 'normal' | 'high'; +export type TPrintColorMode = 'color' | 'monochrome'; + +export interface IPrintCapabilities { + colorSupported: boolean; + duplexSupported: boolean; + mediaSizes: string[]; + mediaTypes: string[]; + resolutions: number[]; + maxCopies: number; + sidesSupported: TPrintSides[]; + qualitySupported: TPrintQuality[]; +} + +export interface IPrintOptions { + copies?: number; + mediaSize?: string; + mediaType?: string; + sides?: TPrintSides; + quality?: TPrintQuality; + colorMode?: TPrintColorMode; + jobName?: string; +} + +export interface IPrintJob { + id: number; + name: string; + state: 'pending' | 'processing' | 'completed' | 'canceled' | 'aborted'; + stateReason?: string; + createdAt: Date; + completedAt?: Date; + pagesPrinted?: number; + pagesTotal?: number; +} + +export interface IPrintFeatureInfo extends IFeatureInfo { + type: 'print'; + protocol: TPrintProtocol; + supportsColor: boolean; + supportsDuplex: boolean; + supportedMediaSizes: string[]; +} + +// ============================================================================ +// Playback Feature +// ============================================================================ + +export type TPlaybackProtocol = 'sonos' | 'airplay' | 'chromecast' | 'dlna'; +export type TPlaybackState = 'playing' | 'paused' | 'stopped' | 'buffering' | 'unknown'; + +export interface ITrackInfo { + title?: string; + artist?: string; + album?: string; + albumArtUri?: string; + duration?: number; // seconds + uri?: string; +} + +export interface IPlaybackStatus { + state: TPlaybackState; + position: number; // seconds + duration: number; // seconds + track?: ITrackInfo; +} + +export interface IPlaybackFeatureInfo extends IFeatureInfo { + type: 'playback'; + protocol: TPlaybackProtocol; + supportsQueue: boolean; + supportsSeek: boolean; +} + +// ============================================================================ +// Volume Feature +// ============================================================================ + +export interface IVolumeFeatureInfo extends IFeatureInfo { + type: 'volume'; + minVolume: number; + maxVolume: number; + volumeStep: number; + supportsMute: boolean; +} + +// ============================================================================ +// Power Feature (UPS, Smart Plugs) +// ============================================================================ + +export type TPowerProtocol = 'nut' | 'snmp' | 'smart-plug'; +export type TPowerStatus = 'online' | 'onbattery' | 'lowbattery' | 'charging' | 'discharging' | 'bypass' | 'offline' | 'error' | 'unknown'; + +export interface IBatteryInfo { + charge: number; // 0-100% + runtime: number; // seconds remaining + voltage?: number; // volts + temperature?: number; // celsius + health?: 'good' | 'weak' | 'replace'; +} + +export interface IPowerInfo { + inputVoltage?: number; + outputVoltage?: number; + inputFrequency?: number; + outputFrequency?: number; + load?: number; // 0-100% + power?: number; // watts +} + +export interface IPowerFeatureInfo extends IFeatureInfo { + type: 'power'; + protocol: TPowerProtocol; + hasBattery: boolean; + supportsShutdown: boolean; + supportsTest: boolean; +} + +// ============================================================================ +// SNMP Feature +// ============================================================================ + +export type TSnmpVersion = 'v1' | 'v2c' | 'v3'; + +export interface ISnmpVarbind { + oid: string; + type: number; + value: unknown; +} + +export interface ISnmpFeatureInfo extends IFeatureInfo { + type: 'snmp'; + version: TSnmpVersion; + community?: string; // for v1/v2c + sysDescr?: string; + sysName?: string; + sysLocation?: string; +} + +// ============================================================================ +// DLNA Render Feature +// ============================================================================ + +export interface IDlnaTransportInfo { + state: 'STOPPED' | 'PLAYING' | 'PAUSED' | 'TRANSITIONING' | 'NO_MEDIA_PRESENT'; + status: string; + speed: string; +} + +export interface IDlnaPositionInfo { + track: number; + trackDuration: string; + trackMetaData?: string; + trackUri?: string; + relTime: string; + absTime: string; +} + +export interface IDlnaRenderFeatureInfo extends IFeatureInfo { + type: 'dlna-render'; + udn: string; + friendlyName: string; + supportsVolume: boolean; + supportedProtocols: string[]; +} + +// ============================================================================ +// DLNA Server Feature +// ============================================================================ + +export interface IDlnaContentItem { + id: string; + parentId: string; + title: string; + class: string; + restricted: boolean; + uri?: string; + albumArtUri?: string; + duration?: string; + size?: number; + isContainer: boolean; + childCount?: number; +} + +export interface IDlnaBrowseResult { + items: IDlnaContentItem[]; + numberReturned: number; + totalMatches: number; + updateId: number; +} + +export interface IDlnaServeFeatureInfo extends IFeatureInfo { + type: 'dlna-serve'; + udn: string; + friendlyName: string; + contentCount?: number; +} + +// ============================================================================ +// Discovery Types +// ============================================================================ + +/** + * Feature discovered during network scan or mDNS/SSDP + */ +export interface IDiscoveredFeature { + type: TFeatureType; + protocol: string; + port: number; + metadata: Record; +} + +/** + * Options for creating a feature instance + */ +export interface IFeatureOptions { + retryOptions?: IRetryOptions; + metadata?: Record; +} diff --git a/ts/interfaces/index.ts b/ts/interfaces/index.ts index aa7823f..4082d7d 100644 --- a/ts/interfaces/index.ts +++ b/ts/interfaces/index.ts @@ -15,7 +15,7 @@ export type TConnectionState = 'disconnected' | 'connecting' | 'connected' | 'er // ============================================================================ export type TScannerProtocol = 'sane' | 'escl'; -export type TScanFormat = 'png' | 'jpeg' | 'pdf'; +export type TScanFormat = 'png' | 'jpeg' | 'pdf' | 'tiff'; export type TColorMode = 'color' | 'grayscale' | 'blackwhite'; export type TScanSource = 'flatbed' | 'adf' | 'adf-duplex'; @@ -179,7 +179,7 @@ export interface IDiscoveredDevice { id: string; name: string; type: TDeviceType; - protocol: TScannerProtocol | 'ipp'; + protocol: string; // 'escl' | 'sane' | 'ipp' | 'airplay' | 'sonos' | 'chromecast' | etc. address: string; port: number; txtRecords: Record; @@ -321,7 +321,7 @@ export interface INetworkScanOptions { concurrency?: number; /** Timeout per probe in milliseconds (default: 2000) */ timeout?: number; - /** Ports to probe (default: [80, 443, 631, 6566, 9100]) */ + /** Ports to probe (default: [80, 443, 631, 6566, 9100, 7000, 1400, 8009]) */ ports?: number[]; /** Check for eSCL scanners (default: true) */ probeEscl?: boolean; @@ -329,11 +329,17 @@ export interface INetworkScanOptions { probeIpp?: boolean; /** Check for SANE scanners (default: true) */ probeSane?: boolean; + /** Check for AirPlay speakers (default: true) */ + probeAirplay?: boolean; + /** Check for Sonos speakers (default: true) */ + probeSonos?: boolean; + /** Check for Chromecast devices (default: true) */ + probeChromecast?: boolean; } export interface INetworkScanDevice { - type: 'scanner' | 'printer'; - protocol: 'escl' | 'sane' | 'ipp' | 'jetdirect'; + type: 'scanner' | 'printer' | 'speaker'; + protocol: 'escl' | 'sane' | 'ipp' | 'jetdirect' | 'airplay' | 'sonos' | 'chromecast'; port: number; name?: string; model?: string; @@ -364,3 +370,9 @@ export type TNetworkScannerEvents = { 'error': (error: Error) => void; 'cancelled': () => void; }; + +// ============================================================================ +// Feature Types (Universal Device Architecture) +// ============================================================================ + +export * from './feature.interfaces.js'; diff --git a/ts/plugins.ts b/ts/plugins.ts index 1a5d974..792524a 100644 --- a/ts/plugins.ts +++ b/ts/plugins.ts @@ -26,9 +26,15 @@ export { // third party import * as bonjourService from 'bonjour-service'; import ipp from 'ipp'; -import * as nodeSsdp from 'node-ssdp'; +import nodeSsdpModule from 'node-ssdp'; import * as netSnmp from 'net-snmp'; import * as sonos from 'sonos'; import * as castv2Client from 'castv2-client'; +// node-ssdp exports Client/Server under default in ESM +const nodeSsdp = { + Client: nodeSsdpModule.Client, + Server: nodeSsdpModule.Server, +}; + export { bonjourService, ipp, nodeSsdp, netSnmp, sonos, castv2Client }; diff --git a/ts/scanner/scanner.classes.esclprotocol.ts b/ts/protocols/protocol.escl.ts similarity index 99% rename from ts/scanner/scanner.classes.esclprotocol.ts rename to ts/protocols/protocol.escl.ts index 351ae99..6e7d2b5 100644 --- a/ts/scanner/scanner.classes.esclprotocol.ts +++ b/ts/protocols/protocol.escl.ts @@ -33,6 +33,7 @@ const FORMAT_MIME_MAP: Record = { jpeg: 'image/jpeg', png: 'image/png', pdf: 'application/pdf', + tiff: 'image/tiff', }; /** diff --git a/ts/printer/printer.classes.ippprotocol.ts b/ts/protocols/protocol.ipp.ts similarity index 100% rename from ts/printer/printer.classes.ippprotocol.ts rename to ts/protocols/protocol.ipp.ts diff --git a/ts/ups/ups.classes.nutprotocol.ts b/ts/protocols/protocol.nut.ts similarity index 99% rename from ts/ups/ups.classes.nutprotocol.ts rename to ts/protocols/protocol.nut.ts index b847c5d..0747095 100644 --- a/ts/ups/ups.classes.nutprotocol.ts +++ b/ts/protocols/protocol.nut.ts @@ -152,7 +152,7 @@ export class NutProtocol { reject(err); }); - this.socket.on('data', (data) => { + this.socket.on('data', (data: Buffer) => { this.handleData(data); }); diff --git a/ts/scanner/scanner.classes.saneprotocol.ts b/ts/protocols/protocol.sane.ts similarity index 100% rename from ts/scanner/scanner.classes.saneprotocol.ts rename to ts/protocols/protocol.sane.ts diff --git a/ts/snmp/snmp.classes.snmpprotocol.ts b/ts/protocols/protocol.snmp.ts similarity index 100% rename from ts/snmp/snmp.classes.snmpprotocol.ts rename to ts/protocols/protocol.snmp.ts diff --git a/ts/protocols/protocol.upnp.ts b/ts/protocols/protocol.upnp.ts new file mode 100644 index 0000000..c522c9a --- /dev/null +++ b/ts/protocols/protocol.upnp.ts @@ -0,0 +1,627 @@ +import * as plugins from '../plugins.js'; + +/** + * UPnP service types for DLNA + */ +export const UPNP_SERVICE_TYPES = { + AVTransport: 'urn:schemas-upnp-org:service:AVTransport:1', + RenderingControl: 'urn:schemas-upnp-org:service:RenderingControl:1', + ConnectionManager: 'urn:schemas-upnp-org:service:ConnectionManager:1', + ContentDirectory: 'urn:schemas-upnp-org:service:ContentDirectory:1', +}; + +/** + * UPnP device types for DLNA + */ +export const UPNP_DEVICE_TYPES = { + MediaRenderer: 'urn:schemas-upnp-org:device:MediaRenderer:1', + MediaServer: 'urn:schemas-upnp-org:device:MediaServer:1', +}; + +/** + * DLNA transport state + */ +export type TDlnaTransportState = + | 'STOPPED' + | 'PLAYING' + | 'PAUSED_PLAYBACK' + | 'TRANSITIONING' + | 'NO_MEDIA_PRESENT'; + +/** + * DLNA transport status + */ +export type TDlnaTransportStatus = 'OK' | 'ERROR_OCCURRED'; + +/** + * Position info from AVTransport + */ +export interface IDlnaPositionInfo { + track: number; + trackDuration: string; + trackMetadata: string; + trackUri: string; + relativeTime: string; + absoluteTime: string; + relativeCount: number; + absoluteCount: number; +} + +/** + * Transport info from AVTransport + */ +export interface IDlnaTransportInfo { + state: TDlnaTransportState; + status: TDlnaTransportStatus; + speed: string; +} + +/** + * Media info from AVTransport + */ +export interface IDlnaMediaInfo { + nrTracks: number; + mediaDuration: string; + currentUri: string; + currentUriMetadata: string; + nextUri: string; + nextUriMetadata: string; + playMedium: string; + recordMedium: string; + writeStatus: string; +} + +/** + * Content item from ContentDirectory + */ +export interface IDlnaContentItem { + id: string; + parentId: string; + title: string; + class: string; + restricted: boolean; + res?: { + url: string; + protocolInfo: string; + size?: number; + duration?: string; + resolution?: string; + bitrate?: number; + }[]; + albumArtUri?: string; + artist?: string; + album?: string; + genre?: string; + date?: string; + childCount?: number; +} + +/** + * Browse result from ContentDirectory + */ +export interface IDlnaBrowseResult { + items: IDlnaContentItem[]; + numberReturned: number; + totalMatches: number; + updateId: number; +} + +/** + * UPnP SOAP client for DLNA operations + */ +export class UpnpSoapClient { + private baseUrl: string; + + constructor(baseUrl: string) { + this.baseUrl = baseUrl.replace(/\/$/, ''); + } + + /** + * Make a SOAP request to a UPnP service + */ + public async soapAction( + controlUrl: string, + serviceType: string, + action: string, + args: Record = {} + ): Promise { + // Build SOAP body + let argsXml = ''; + for (const [key, value] of Object.entries(args)) { + const escapedValue = this.escapeXml(String(value)); + argsXml += `<${key}>${escapedValue}`; + } + + const soapBody = ` + + + + ${argsXml} + + +`; + + const fullUrl = controlUrl.startsWith('http') ? controlUrl : `${this.baseUrl}${controlUrl}`; + + const response = await fetch(fullUrl, { + method: 'POST', + headers: { + 'Content-Type': 'text/xml; charset=utf-8', + 'SOAPACTION': `"${serviceType}#${action}"`, + }, + body: soapBody, + signal: AbortSignal.timeout(10000), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`SOAP request failed (${response.status}): ${text}`); + } + + return response.text(); + } + + /** + * Escape XML special characters + */ + private escapeXml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + /** + * Unescape XML special characters + */ + public unescapeXml(str: string): string { + return str + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, '&'); + } + + /** + * Extract value from SOAP response + */ + public extractValue(xml: string, tag: string): string { + const regex = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)`, 'i'); + const match = xml.match(regex); + return match ? match[1].trim() : ''; + } + + /** + * Extract multiple values from SOAP response + */ + public extractValues(xml: string, tags: string[]): Record { + const result: Record = {}; + for (const tag of tags) { + result[tag] = this.extractValue(xml, tag); + } + return result; + } + + // ============================================================================ + // AVTransport Actions + // ============================================================================ + + /** + * Set the URI to play + */ + public async setAVTransportURI( + controlUrl: string, + uri: string, + metadata: string = '' + ): Promise { + await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'SetAVTransportURI', { + InstanceID: 0, + CurrentURI: uri, + CurrentURIMetaData: metadata, + }); + } + + /** + * Set next URI to play + */ + public async setNextAVTransportURI( + controlUrl: string, + uri: string, + metadata: string = '' + ): Promise { + await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'SetNextAVTransportURI', { + InstanceID: 0, + NextURI: uri, + NextURIMetaData: metadata, + }); + } + + /** + * Play + */ + public async play(controlUrl: string, speed: string = '1'): Promise { + await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'Play', { + InstanceID: 0, + Speed: speed, + }); + } + + /** + * Pause + */ + public async pause(controlUrl: string): Promise { + await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'Pause', { + InstanceID: 0, + }); + } + + /** + * Stop + */ + public async stop(controlUrl: string): Promise { + await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'Stop', { + InstanceID: 0, + }); + } + + /** + * Seek + */ + public async seek(controlUrl: string, target: string, unit: string = 'REL_TIME'): Promise { + await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'Seek', { + InstanceID: 0, + Unit: unit, + Target: target, + }); + } + + /** + * Next track + */ + public async next(controlUrl: string): Promise { + await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'Next', { + InstanceID: 0, + }); + } + + /** + * Previous track + */ + public async previous(controlUrl: string): Promise { + await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'Previous', { + InstanceID: 0, + }); + } + + /** + * Get position info + */ + public async getPositionInfo(controlUrl: string): Promise { + const response = await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'GetPositionInfo', { + InstanceID: 0, + }); + + const values = this.extractValues(response, [ + 'Track', 'TrackDuration', 'TrackMetaData', 'TrackURI', + 'RelTime', 'AbsTime', 'RelCount', 'AbsCount', + ]); + + return { + track: parseInt(values['Track']) || 0, + trackDuration: values['TrackDuration'] || '0:00:00', + trackMetadata: this.unescapeXml(values['TrackMetaData'] || ''), + trackUri: values['TrackURI'] || '', + relativeTime: values['RelTime'] || '0:00:00', + absoluteTime: values['AbsTime'] || 'NOT_IMPLEMENTED', + relativeCount: parseInt(values['RelCount']) || 0, + absoluteCount: parseInt(values['AbsCount']) || 0, + }; + } + + /** + * Get transport info + */ + public async getTransportInfo(controlUrl: string): Promise { + const response = await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'GetTransportInfo', { + InstanceID: 0, + }); + + const values = this.extractValues(response, [ + 'CurrentTransportState', 'CurrentTransportStatus', 'CurrentSpeed', + ]); + + return { + state: (values['CurrentTransportState'] || 'STOPPED') as TDlnaTransportState, + status: (values['CurrentTransportStatus'] || 'OK') as TDlnaTransportStatus, + speed: values['CurrentSpeed'] || '1', + }; + } + + /** + * Get media info + */ + public async getMediaInfo(controlUrl: string): Promise { + const response = await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'GetMediaInfo', { + InstanceID: 0, + }); + + const values = this.extractValues(response, [ + 'NrTracks', 'MediaDuration', 'CurrentURI', 'CurrentURIMetaData', + 'NextURI', 'NextURIMetaData', 'PlayMedium', 'RecordMedium', 'WriteStatus', + ]); + + return { + nrTracks: parseInt(values['NrTracks']) || 0, + mediaDuration: values['MediaDuration'] || '0:00:00', + currentUri: values['CurrentURI'] || '', + currentUriMetadata: this.unescapeXml(values['CurrentURIMetaData'] || ''), + nextUri: values['NextURI'] || '', + nextUriMetadata: this.unescapeXml(values['NextURIMetaData'] || ''), + playMedium: values['PlayMedium'] || 'NONE', + recordMedium: values['RecordMedium'] || 'NOT_IMPLEMENTED', + writeStatus: values['WriteStatus'] || 'NOT_IMPLEMENTED', + }; + } + + // ============================================================================ + // RenderingControl Actions + // ============================================================================ + + /** + * Get volume + */ + public async getVolume(controlUrl: string, channel: string = 'Master'): Promise { + const response = await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.RenderingControl, 'GetVolume', { + InstanceID: 0, + Channel: channel, + }); + + const volume = this.extractValue(response, 'CurrentVolume'); + return parseInt(volume) || 0; + } + + /** + * Set volume + */ + public async setVolume(controlUrl: string, volume: number, channel: string = 'Master'): Promise { + await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.RenderingControl, 'SetVolume', { + InstanceID: 0, + Channel: channel, + DesiredVolume: Math.max(0, Math.min(100, volume)), + }); + } + + /** + * Get mute state + */ + public async getMute(controlUrl: string, channel: string = 'Master'): Promise { + const response = await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.RenderingControl, 'GetMute', { + InstanceID: 0, + Channel: channel, + }); + + const mute = this.extractValue(response, 'CurrentMute'); + return mute === '1' || mute.toLowerCase() === 'true'; + } + + /** + * Set mute state + */ + public async setMute(controlUrl: string, muted: boolean, channel: string = 'Master'): Promise { + await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.RenderingControl, 'SetMute', { + InstanceID: 0, + Channel: channel, + DesiredMute: muted ? 1 : 0, + }); + } + + // ============================================================================ + // ContentDirectory Actions + // ============================================================================ + + /** + * Browse content directory + */ + public async browse( + controlUrl: string, + objectId: string = '0', + browseFlag: 'BrowseDirectChildren' | 'BrowseMetadata' = 'BrowseDirectChildren', + filter: string = '*', + startIndex: number = 0, + requestCount: number = 100, + sortCriteria: string = '' + ): Promise { + const response = await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.ContentDirectory, 'Browse', { + ObjectID: objectId, + BrowseFlag: browseFlag, + Filter: filter, + StartingIndex: startIndex, + RequestedCount: requestCount, + SortCriteria: sortCriteria, + }); + + const values = this.extractValues(response, ['Result', 'NumberReturned', 'TotalMatches', 'UpdateID']); + + const resultXml = this.unescapeXml(values['Result'] || ''); + const items = this.parseDidlResult(resultXml); + + return { + items, + numberReturned: parseInt(values['NumberReturned']) || items.length, + totalMatches: parseInt(values['TotalMatches']) || items.length, + updateId: parseInt(values['UpdateID']) || 0, + }; + } + + /** + * Search content directory + */ + public async search( + controlUrl: string, + containerId: string, + searchCriteria: string, + filter: string = '*', + startIndex: number = 0, + requestCount: number = 100, + sortCriteria: string = '' + ): Promise { + const response = await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.ContentDirectory, 'Search', { + ContainerID: containerId, + SearchCriteria: searchCriteria, + Filter: filter, + StartingIndex: startIndex, + RequestedCount: requestCount, + SortCriteria: sortCriteria, + }); + + const values = this.extractValues(response, ['Result', 'NumberReturned', 'TotalMatches', 'UpdateID']); + + const resultXml = this.unescapeXml(values['Result'] || ''); + const items = this.parseDidlResult(resultXml); + + return { + items, + numberReturned: parseInt(values['NumberReturned']) || items.length, + totalMatches: parseInt(values['TotalMatches']) || items.length, + updateId: parseInt(values['UpdateID']) || 0, + }; + } + + /** + * Parse DIDL-Lite result XML + */ + private parseDidlResult(xml: string): IDlnaContentItem[] { + const items: IDlnaContentItem[] = []; + + // Match container and item elements + const elementRegex = /<(container|item)[^>]*>([\s\S]*?)<\/\1>/gi; + let match; + + while ((match = elementRegex.exec(xml)) !== null) { + const elementXml = match[0]; + const elementType = match[1]; + + // Extract attributes + const idMatch = elementXml.match(/id="([^"]*)"/); + const parentIdMatch = elementXml.match(/parentID="([^"]*)"/); + const restrictedMatch = elementXml.match(/restricted="([^"]*)"/); + const childCountMatch = elementXml.match(/childCount="([^"]*)"/); + + const item: IDlnaContentItem = { + id: idMatch?.[1] || '', + parentId: parentIdMatch?.[1] || '', + title: this.extractTagContent(elementXml, 'dc:title'), + class: this.extractTagContent(elementXml, 'upnp:class'), + restricted: restrictedMatch?.[1] !== '0', + childCount: childCountMatch ? parseInt(childCountMatch[1]) : undefined, + }; + + // Extract resources + const resMatches = elementXml.match(/]*>([^<]*)<\/res>/gi); + if (resMatches) { + item.res = resMatches.map((resXml) => { + const protocolInfo = resXml.match(/protocolInfo="([^"]*)"/)?.[1] || ''; + const size = resXml.match(/size="([^"]*)"/)?.[1]; + const duration = resXml.match(/duration="([^"]*)"/)?.[1]; + const resolution = resXml.match(/resolution="([^"]*)"/)?.[1]; + const bitrate = resXml.match(/bitrate="([^"]*)"/)?.[1]; + const urlMatch = resXml.match(/>([^<]+)]*>([^<]*)<\/(?:[^:]*:)?${tagName}>`, 'i'); + const match = xml.match(regex); + return match ? match[1].trim() : ''; + } + + // ============================================================================ + // Utility Methods + // ============================================================================ + + /** + * Generate DIDL-Lite metadata for a media URL + */ + public generateDidlMetadata( + title: string, + url: string, + mimeType: string = 'video/mp4' + ): string { + const protocolInfo = `http-get:*:${mimeType}:*`; + + return ` + + ${this.escapeXml(title)} + object.item.videoItem + ${this.escapeXml(url)} + +`; + } + + /** + * Convert duration string to seconds + */ + public durationToSeconds(duration: string): number { + if (!duration || duration === 'NOT_IMPLEMENTED') return 0; + + const parts = duration.split(':'); + if (parts.length !== 3) return 0; + + const hours = parseInt(parts[0]) || 0; + const minutes = parseInt(parts[1]) || 0; + const seconds = parseFloat(parts[2]) || 0; + + return hours * 3600 + minutes * 60 + seconds; + } + + /** + * Convert seconds to duration string + */ + public secondsToDuration(seconds: number): string { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = Math.floor(seconds % 60); + + return `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`; + } +} diff --git a/ts/speaker/speaker.classes.airplay.ts b/ts/speaker/speaker.classes.airplay.ts new file mode 100644 index 0000000..5d5d3f7 --- /dev/null +++ b/ts/speaker/speaker.classes.airplay.ts @@ -0,0 +1,548 @@ +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 new file mode 100644 index 0000000..2a0a16b --- /dev/null +++ b/ts/speaker/speaker.classes.chromecast.ts @@ -0,0 +1,725 @@ +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 new file mode 100644 index 0000000..39cf6a0 --- /dev/null +++ b/ts/speaker/speaker.classes.sonos.ts @@ -0,0 +1,654 @@ +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 new file mode 100644 index 0000000..a7d43af --- /dev/null +++ b/ts/speaker/speaker.classes.speaker.ts @@ -0,0 +1,216 @@ +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 new file mode 100644 index 0000000..2f2c6d4 --- /dev/null +++ b/ts/ups/ups.classes.upsdevice.ts @@ -0,0 +1,548 @@ +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';