feat(devicemanager): Introduce a UniversalDevice architecture with composable Feature system; add extensive new device/protocol support and discovery/refactors
This commit is contained in:
18
changelog.md
Normal file
18
changelog.md
Normal file
@@ -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.
|
||||||
66
readme.hints.md
Normal file
66
readme.hints.md
Normal file
@@ -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
|
||||||
8
ts/00_commitinfo_data.ts
Normal file
8
ts/00_commitinfo_data.ts
Normal file
@@ -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'
|
||||||
|
}
|
||||||
431
ts/device/device.classes.device.ts
Normal file
431
ts/device/device.classes.device.ts
Normal file
@@ -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<ScanFeature>('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<TFeatureType, Feature> = 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<boolean> {
|
||||||
|
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<T extends Feature>(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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<T extends Feature>(
|
||||||
|
device: UniversalDevice,
|
||||||
|
type: TFeatureType
|
||||||
|
): device is UniversalDevice & { getFeature(type: TFeatureType): T } {
|
||||||
|
return device.hasFeature(type);
|
||||||
|
}
|
||||||
@@ -1,8 +1,17 @@
|
|||||||
import * as plugins from './plugins.js';
|
import * as plugins from './plugins.js';
|
||||||
import { MdnsDiscovery, SERVICE_TYPES } from './discovery/discovery.classes.mdns.js';
|
import { MdnsDiscovery, SERVICE_TYPES } from './discovery/discovery.classes.mdns.js';
|
||||||
import { NetworkScanner } from './discovery/discovery.classes.networkscanner.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 { Scanner } from './scanner/scanner.classes.scanner.js';
|
||||||
import { Printer } from './printer/printer.classes.printer.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 {
|
import type {
|
||||||
IDeviceManagerOptions,
|
IDeviceManagerOptions,
|
||||||
IDiscoveredDevice,
|
IDiscoveredDevice,
|
||||||
@@ -10,8 +19,14 @@ import type {
|
|||||||
TDeviceManagerEvents,
|
TDeviceManagerEvents,
|
||||||
INetworkScanOptions,
|
INetworkScanOptions,
|
||||||
INetworkScanResult,
|
INetworkScanResult,
|
||||||
|
TDeviceType,
|
||||||
|
TFeatureType,
|
||||||
} from './interfaces/index.js';
|
} 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
|
* Default device manager options
|
||||||
*/
|
*/
|
||||||
@@ -27,10 +42,24 @@ const DEFAULT_OPTIONS: Required<IDeviceManagerOptions> = {
|
|||||||
* Main Device Manager class for discovering and managing network devices
|
* Main Device Manager class for discovering and managing network devices
|
||||||
*/
|
*/
|
||||||
export class DeviceManager extends plugins.events.EventEmitter {
|
export class DeviceManager extends plugins.events.EventEmitter {
|
||||||
private discovery: MdnsDiscovery;
|
private mdnsDiscovery: MdnsDiscovery;
|
||||||
|
private ssdpDiscovery: SsdpDiscovery;
|
||||||
private _networkScanner: NetworkScanner | null = null;
|
private _networkScanner: NetworkScanner | null = null;
|
||||||
|
|
||||||
|
// Device collections
|
||||||
private scanners: Map<string, Scanner> = new Map();
|
private scanners: Map<string, Scanner> = new Map();
|
||||||
private printers: Map<string, Printer> = new Map();
|
private printers: Map<string, Printer> = new Map();
|
||||||
|
private snmpDevices: Map<string, SnmpDevice> = new Map();
|
||||||
|
private upsDevices: Map<string, UpsDevice> = new Map();
|
||||||
|
private dlnaRenderers: Map<string, DlnaRenderer> = new Map();
|
||||||
|
private dlnaServers: Map<string, DlnaServer> = new Map();
|
||||||
|
private sonosSpeakers: Map<string, SonosSpeaker> = new Map();
|
||||||
|
private airplaySpeakers: Map<string, AirPlaySpeaker> = new Map();
|
||||||
|
private chromecastSpeakers: Map<string, ChromecastSpeaker> = new Map();
|
||||||
|
|
||||||
|
// Universal devices (new architecture)
|
||||||
|
private universalDevices: Map<string, UniversalDevice> = new Map();
|
||||||
|
|
||||||
private options: Required<IDeviceManagerOptions>;
|
private options: Required<IDeviceManagerOptions>;
|
||||||
private retryOptions: IRetryOptions;
|
private retryOptions: IRetryOptions;
|
||||||
|
|
||||||
@@ -46,46 +75,58 @@ export class DeviceManager extends plugins.events.EventEmitter {
|
|||||||
jitter: true,
|
jitter: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.discovery = new MdnsDiscovery({
|
this.mdnsDiscovery = new MdnsDiscovery({
|
||||||
timeout: this.options.discoveryTimeout,
|
timeout: this.options.discoveryTimeout,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.ssdpDiscovery = new SsdpDiscovery();
|
||||||
|
|
||||||
this.setupDiscoveryEvents();
|
this.setupDiscoveryEvents();
|
||||||
|
this.setupSsdpDiscoveryEvents();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Setup event forwarding from discovery service
|
* Setup event forwarding from mDNS discovery service
|
||||||
*/
|
*/
|
||||||
private setupDiscoveryEvents(): void {
|
private setupDiscoveryEvents(): void {
|
||||||
this.discovery.on('device:found', (device: IDiscoveredDevice) => {
|
this.mdnsDiscovery.on('device:found', (device: IDiscoveredDevice) => {
|
||||||
this.handleDeviceFound(device);
|
this.handleMdnsDeviceFound(device);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.discovery.on('device:lost', (device: IDiscoveredDevice) => {
|
this.mdnsDiscovery.on('device:lost', (device: IDiscoveredDevice) => {
|
||||||
this.handleDeviceLost(device);
|
this.handleDeviceLost(device);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.discovery.on('scanner:found', (device: IDiscoveredDevice) => {
|
this.mdnsDiscovery.on('started', () => {
|
||||||
// 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.emit('discovery:started');
|
this.emit('discovery:started');
|
||||||
});
|
});
|
||||||
|
|
||||||
this.discovery.on('stopped', () => {
|
this.mdnsDiscovery.on('stopped', () => {
|
||||||
this.emit('discovery: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') {
|
if (device.type === 'scanner') {
|
||||||
// Create Scanner instance
|
// Create Scanner instance
|
||||||
const scanner = Scanner.fromDiscovery(
|
const scanner = Scanner.fromDiscovery(
|
||||||
@@ -117,6 +158,114 @@ export class DeviceManager extends plugins.events.EventEmitter {
|
|||||||
|
|
||||||
this.printers.set(device.id, printer);
|
this.printers.set(device.id, printer);
|
||||||
this.emit('printer:found', printer.getPrinterInfo());
|
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<void> {
|
public async startDiscovery(): Promise<void> {
|
||||||
await this.discovery.start();
|
await Promise.all([
|
||||||
|
this.mdnsDiscovery.start(),
|
||||||
|
this.ssdpDiscovery.start(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stop device discovery
|
* Stop device discovery
|
||||||
*/
|
*/
|
||||||
public async stopDiscovery(): Promise<void> {
|
public async stopDiscovery(): Promise<void> {
|
||||||
await this.discovery.stop();
|
await Promise.all([
|
||||||
|
this.mdnsDiscovery.stop(),
|
||||||
|
this.ssdpDiscovery.stop(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if discovery is running
|
* Check if discovery is running
|
||||||
*/
|
*/
|
||||||
public get isDiscovering(): boolean {
|
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());
|
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
|
* 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)[] {
|
public getSnmpDevice(id: string): SnmpDevice | undefined {
|
||||||
return [...this.getScanners(), ...this.getPrinters()];
|
return this.snmpDevices.get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get device by ID (scanner or printer)
|
* Get UPS device by ID
|
||||||
*/
|
*/
|
||||||
public getDevice(id: string): Scanner | Printer | undefined {
|
public getUpsDevice(id: string): UpsDevice | undefined {
|
||||||
return this.scanners.get(id) ?? this.printers.get(id);
|
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(
|
public async scanNetwork(
|
||||||
options: INetworkScanOptions
|
options: INetworkScanOptions
|
||||||
): Promise<{ scanners: Scanner[]; printers: Printer[] }> {
|
): Promise<{ scanners: Scanner[]; printers: Printer[]; speakers: Speaker[] }> {
|
||||||
const results = await this.networkScanner.scan(options);
|
const results = await this.networkScanner.scan(options);
|
||||||
const foundScanners: Scanner[] = [];
|
const foundScanners: Scanner[] = [];
|
||||||
const foundPrinters: Printer[] = [];
|
const foundPrinters: Printer[] = [];
|
||||||
|
const foundSpeakers: Speaker[] = [];
|
||||||
|
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
for (const device of result.devices) {
|
for (const device of result.devices) {
|
||||||
@@ -359,6 +650,26 @@ export class DeviceManager extends plugins.events.EventEmitter {
|
|||||||
foundPrinters.push(printer);
|
foundPrinters.push(printer);
|
||||||
}
|
}
|
||||||
// JetDirect printers don't have a protocol handler yet
|
// 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) {
|
} catch (error) {
|
||||||
// Device could not be added (connection failed, etc.)
|
// 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<void> {
|
public async disconnectAll(): Promise<void> {
|
||||||
const disconnectPromises: Promise<void>[] = [];
|
const disconnectPromises: Promise<void>[] = [];
|
||||||
|
|
||||||
for (const scanner of this.scanners.values()) {
|
// Disconnect all device types
|
||||||
if (scanner.isConnected) {
|
const allDevices = this.getAllDevices();
|
||||||
disconnectPromises.push(scanner.disconnect().catch(() => {}));
|
for (const device of allDevices) {
|
||||||
}
|
if (device.isConnected) {
|
||||||
}
|
disconnectPromises.push(device.disconnect().catch(() => {}));
|
||||||
|
|
||||||
for (const printer of this.printers.values()) {
|
|
||||||
if (printer.isConnected) {
|
|
||||||
disconnectPromises.push(printer.disconnect().catch(() => {}));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,8 +747,18 @@ export class DeviceManager extends plugins.events.EventEmitter {
|
|||||||
public async shutdown(): Promise<void> {
|
public async shutdown(): Promise<void> {
|
||||||
await this.stopDiscovery();
|
await this.stopDiscovery();
|
||||||
await this.disconnectAll();
|
await this.disconnectAll();
|
||||||
|
|
||||||
|
// Clear all device collections
|
||||||
this.scanners.clear();
|
this.scanners.clear();
|
||||||
this.printers.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<void> {
|
public async refreshAllStatus(): Promise<void> {
|
||||||
const refreshPromises: Promise<void>[] = [];
|
const refreshPromises: Promise<void>[] = [];
|
||||||
|
|
||||||
for (const scanner of this.scanners.values()) {
|
const allDevices = this.getAllDevices();
|
||||||
if (scanner.isConnected) {
|
for (const device of allDevices) {
|
||||||
|
if (device.isConnected) {
|
||||||
refreshPromises.push(
|
refreshPromises.push(
|
||||||
scanner.refreshStatus().catch((error) => {
|
device.refreshStatus().catch((error) => {
|
||||||
this.emit('error', error);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const printer of this.printers.values()) {
|
|
||||||
if (printer.isConnected) {
|
|
||||||
refreshPromises.push(
|
|
||||||
printer.refreshStatus().catch((error) => {
|
|
||||||
this.emit('error', error);
|
this.emit('error', error);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -472,6 +780,325 @@ export class DeviceManager extends plugins.events.EventEmitter {
|
|||||||
|
|
||||||
await Promise.all(refreshPromises);
|
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<SnmpDevice> {
|
||||||
|
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<UpsDevice> {
|
||||||
|
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<SonosSpeaker> {
|
||||||
|
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<AirPlaySpeaker> {
|
||||||
|
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<ChromecastSpeaker> {
|
||||||
|
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<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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<T extends Feature>(deviceId: string, featureType: TFeatureType): T | undefined {
|
||||||
|
const device = this.universalDevices.get(deviceId);
|
||||||
|
return device?.getFeature<T>(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,
|
||||||
|
};
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ const SERVICE_TYPES = {
|
|||||||
IPP: '_ipp._tcp', // IPP printers
|
IPP: '_ipp._tcp', // IPP printers
|
||||||
IPPS: '_ipps._tcp', // IPP over TLS
|
IPPS: '_ipps._tcp', // IPP over TLS
|
||||||
PDL: '_pdl-datastream._tcp', // Raw/JetDirect printers
|
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;
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,11 +33,18 @@ const SERVICE_TYPES = {
|
|||||||
*/
|
*/
|
||||||
const DEFAULT_OPTIONS: Required<IDiscoveryOptions> = {
|
const DEFAULT_OPTIONS: Required<IDiscoveryOptions> = {
|
||||||
serviceTypes: [
|
serviceTypes: [
|
||||||
|
// Scanners
|
||||||
SERVICE_TYPES.ESCL,
|
SERVICE_TYPES.ESCL,
|
||||||
SERVICE_TYPES.ESCL_SECURE,
|
SERVICE_TYPES.ESCL_SECURE,
|
||||||
SERVICE_TYPES.SANE,
|
SERVICE_TYPES.SANE,
|
||||||
|
// Printers
|
||||||
SERVICE_TYPES.IPP,
|
SERVICE_TYPES.IPP,
|
||||||
SERVICE_TYPES.IPPS,
|
SERVICE_TYPES.IPPS,
|
||||||
|
// Speakers
|
||||||
|
SERVICE_TYPES.AIRPLAY,
|
||||||
|
SERVICE_TYPES.RAOP,
|
||||||
|
SERVICE_TYPES.SONOS,
|
||||||
|
SERVICE_TYPES.GOOGLECAST,
|
||||||
],
|
],
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
};
|
};
|
||||||
@@ -281,11 +295,22 @@ export class MdnsDiscovery extends plugins.events.EventEmitter {
|
|||||||
case SERVICE_TYPES.IPPS:
|
case SERVICE_TYPES.IPPS:
|
||||||
case SERVICE_TYPES.PDL:
|
case SERVICE_TYPES.PDL:
|
||||||
return 'printer';
|
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:
|
default:
|
||||||
// Check if it's a scanner or printer based on service type pattern
|
// Check if it's a scanner or printer based on service type pattern
|
||||||
if (serviceType.includes('scan') || serviceType.includes('scanner')) {
|
if (serviceType.includes('scan') || serviceType.includes('scanner')) {
|
||||||
return 'scanner';
|
return 'scanner';
|
||||||
}
|
}
|
||||||
|
if (serviceType.includes('airplay') || serviceType.includes('raop') ||
|
||||||
|
serviceType.includes('sonos') || serviceType.includes('cast') ||
|
||||||
|
serviceType.includes('spotify')) {
|
||||||
|
return 'speaker';
|
||||||
|
}
|
||||||
return 'printer';
|
return 'printer';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -293,7 +318,7 @@ export class MdnsDiscovery extends plugins.events.EventEmitter {
|
|||||||
/**
|
/**
|
||||||
* Determine protocol from service type
|
* Determine protocol from service type
|
||||||
*/
|
*/
|
||||||
private getProtocol(serviceType: string): TScannerProtocol | 'ipp' {
|
private getProtocol(serviceType: string): string {
|
||||||
switch (serviceType) {
|
switch (serviceType) {
|
||||||
case SERVICE_TYPES.ESCL:
|
case SERVICE_TYPES.ESCL:
|
||||||
case SERVICE_TYPES.ESCL_SECURE:
|
case SERVICE_TYPES.ESCL_SECURE:
|
||||||
@@ -304,8 +329,17 @@ export class MdnsDiscovery extends plugins.events.EventEmitter {
|
|||||||
case SERVICE_TYPES.IPPS:
|
case SERVICE_TYPES.IPPS:
|
||||||
case SERVICE_TYPES.PDL:
|
case SERVICE_TYPES.PDL:
|
||||||
return 'ipp';
|
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:
|
default:
|
||||||
return 'ipp';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import { EsclProtocol } from '../scanner/scanner.classes.esclprotocol.js';
|
import { EsclProtocol } from '../scanner/scanner.classes.esclprotocol.js';
|
||||||
import { IppProtocol } from '../printer/printer.classes.ippprotocol.js';
|
|
||||||
import {
|
import {
|
||||||
cidrToIps,
|
cidrToIps,
|
||||||
ipRangeToIps,
|
ipRangeToIps,
|
||||||
@@ -16,7 +15,18 @@ import type {
|
|||||||
/**
|
/**
|
||||||
* Default ports to probe for device discovery
|
* 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
|
* Default scan options
|
||||||
@@ -28,6 +38,9 @@ const DEFAULT_OPTIONS: Required<Omit<INetworkScanOptions, 'ipRange' | 'startIp'
|
|||||||
probeEscl: true,
|
probeEscl: true,
|
||||||
probeIpp: true,
|
probeIpp: true,
|
||||||
probeSane: true,
|
probeSane: true,
|
||||||
|
probeAirplay: true,
|
||||||
|
probeSonos: true,
|
||||||
|
probeChromecast: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -228,6 +241,43 @@ export class NetworkScanner extends plugins.events.EventEmitter {
|
|||||||
name: `Raw Printer at ${ip}`,
|
name: `Raw Printer at ${ip}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AirPlay probe (port 7000) - try HTTP endpoints
|
||||||
|
if (opts.probeAirplay && port === 7000) {
|
||||||
|
probePromises.push(
|
||||||
|
this.probeAirplay(ip, port, timeout).then((device) => {
|
||||||
|
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);
|
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(
|
private async probeIpp(
|
||||||
ip: string,
|
ip: string,
|
||||||
@@ -289,15 +340,43 @@ export class NetworkScanner extends plugins.events.EventEmitter {
|
|||||||
timeout: number
|
timeout: number
|
||||||
): Promise<INetworkScanDevice | null> {
|
): Promise<INetworkScanDevice | null> {
|
||||||
try {
|
try {
|
||||||
const ipp = new IppProtocol(ip, port);
|
// Use a simple HTTP OPTIONS or POST to check if IPP endpoint exists
|
||||||
const attrs = await Promise.race([
|
// IPP uses HTTP POST to /ipp/print or /ipp/printer
|
||||||
ipp.getAttributes(),
|
const controller = new AbortController();
|
||||||
new Promise<null>((_, reject) =>
|
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||||
setTimeout(() => reject(new Error('Timeout')), timeout)
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (attrs) {
|
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 {
|
return {
|
||||||
type: 'printer',
|
type: 'printer',
|
||||||
protocol: 'ipp',
|
protocol: 'ipp',
|
||||||
@@ -306,6 +385,10 @@ export class NetworkScanner extends plugins.events.EventEmitter {
|
|||||||
model: undefined,
|
model: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
} catch (fetchErr) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
// Fetch failed or was aborted
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Not an IPP printer
|
// Not an IPP printer
|
||||||
}
|
}
|
||||||
@@ -375,4 +458,191 @@ export class NetworkScanner extends plugins.events.EventEmitter {
|
|||||||
|
|
||||||
return null;
|
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<INetworkScanDevice | null> {
|
||||||
|
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(/<key>model<\/key>\s*<string>([^<]+)<\/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(/<key>model<\/key>\s*<string>([^<]+)<\/string>/);
|
||||||
|
const nameMatch = text.match(/<key>name<\/key>\s*<string>([^<]+)<\/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<INetworkScanDevice | null> {
|
||||||
|
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>([^<]+)<\/friendlyName>/);
|
||||||
|
const modelMatch = text.match(/<modelName>([^<]+)<\/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<INetworkScanDevice | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
527
ts/dlna/dlna.classes.renderer.ts
Normal file
527
ts/dlna/dlna.classes.renderer.ts
Normal file
@@ -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<void> {
|
||||||
|
this.soapClient = new UpnpSoapClient(this.baseUrl);
|
||||||
|
|
||||||
|
// Test connection by getting transport info
|
||||||
|
try {
|
||||||
|
await this.getTransportInfo();
|
||||||
|
} catch (error) {
|
||||||
|
this.soapClient = null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get volume (may not be supported)
|
||||||
|
try {
|
||||||
|
this._currentVolume = await this.getVolume();
|
||||||
|
this._supportsVolume = true;
|
||||||
|
} catch {
|
||||||
|
this._supportsVolume = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect
|
||||||
|
*/
|
||||||
|
protected async doDisconnect(): Promise<void> {
|
||||||
|
this.soapClient = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh status
|
||||||
|
*/
|
||||||
|
public async refreshStatus(): Promise<void> {
|
||||||
|
if (!this.soapClient) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [transport, volume, muted] = await Promise.all([
|
||||||
|
this.getTransportInfo(),
|
||||||
|
this._supportsVolume ? this.getVolume() : Promise.resolve(0),
|
||||||
|
this._supportsVolume ? this.getMute() : Promise.resolve(false),
|
||||||
|
]);
|
||||||
|
|
||||||
|
this._currentState = transport.state;
|
||||||
|
this._currentVolume = volume;
|
||||||
|
this._currentMuted = muted;
|
||||||
|
|
||||||
|
this.emit('status:updated', this.getDeviceInfo());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Playback Control
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set media URI to play
|
||||||
|
*/
|
||||||
|
public async setAVTransportURI(uri: string, metadata?: string): Promise<void> {
|
||||||
|
if (!this.soapClient) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = metadata || this.soapClient.generateDidlMetadata('Media', uri);
|
||||||
|
await this.soapClient.setAVTransportURI(this.avTransportUrl, uri, meta);
|
||||||
|
this.emit('media:loaded', { uri });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play current media
|
||||||
|
*/
|
||||||
|
public async play(uri?: string, metadata?: string): Promise<void> {
|
||||||
|
if (!this.soapClient) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uri) {
|
||||||
|
await this.setAVTransportURI(uri, metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.soapClient.play(this.avTransportUrl);
|
||||||
|
this._currentState = 'PLAYING';
|
||||||
|
this.emit('playback:started');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause playback
|
||||||
|
*/
|
||||||
|
public async pause(): Promise<void> {
|
||||||
|
if (!this.soapClient) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.soapClient.pause(this.avTransportUrl);
|
||||||
|
this._currentState = 'PAUSED_PLAYBACK';
|
||||||
|
this.emit('playback:paused');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop playback
|
||||||
|
*/
|
||||||
|
public async stop(): Promise<void> {
|
||||||
|
if (!this.soapClient) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.soapClient.stop(this.avTransportUrl);
|
||||||
|
this._currentState = 'STOPPED';
|
||||||
|
this.emit('playback:stopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seek to position
|
||||||
|
*/
|
||||||
|
public async seek(seconds: number): Promise<void> {
|
||||||
|
if (!this.soapClient) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = this.soapClient.secondsToDuration(seconds);
|
||||||
|
await this.soapClient.seek(this.avTransportUrl, target, 'REL_TIME');
|
||||||
|
this.emit('playback:seeked', { position: seconds });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Next track
|
||||||
|
*/
|
||||||
|
public async next(): Promise<void> {
|
||||||
|
if (!this.soapClient) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.soapClient.next(this.avTransportUrl);
|
||||||
|
this.emit('playback:next');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Previous track
|
||||||
|
*/
|
||||||
|
public async previous(): Promise<void> {
|
||||||
|
if (!this.soapClient) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.soapClient.previous(this.avTransportUrl);
|
||||||
|
this.emit('playback:previous');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Volume Control
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get volume level
|
||||||
|
*/
|
||||||
|
public async getVolume(): Promise<number> {
|
||||||
|
if (!this.soapClient) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.soapClient.getVolume(this.renderingControlUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set volume level
|
||||||
|
*/
|
||||||
|
public async setVolume(level: number): Promise<void> {
|
||||||
|
if (!this.soapClient) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.soapClient.setVolume(this.renderingControlUrl, level);
|
||||||
|
this._currentVolume = level;
|
||||||
|
this.emit('volume:changed', { volume: level });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get mute state
|
||||||
|
*/
|
||||||
|
public async getMute(): Promise<boolean> {
|
||||||
|
if (!this.soapClient) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.soapClient.getMute(this.renderingControlUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set mute state
|
||||||
|
*/
|
||||||
|
public async setMute(muted: boolean): Promise<void> {
|
||||||
|
if (!this.soapClient) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.soapClient.setMute(this.renderingControlUrl, muted);
|
||||||
|
this._currentMuted = muted;
|
||||||
|
this.emit('mute:changed', { muted });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle mute
|
||||||
|
*/
|
||||||
|
public async toggleMute(): Promise<boolean> {
|
||||||
|
const newMuted = !this._currentMuted;
|
||||||
|
await this.setMute(newMuted);
|
||||||
|
return newMuted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Status Information
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get transport info
|
||||||
|
*/
|
||||||
|
public async getTransportInfo(): Promise<IDlnaTransportInfo> {
|
||||||
|
if (!this.soapClient) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.soapClient.getTransportInfo(this.avTransportUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get position info
|
||||||
|
*/
|
||||||
|
public async getPositionInfo(): Promise<IDlnaPositionInfo> {
|
||||||
|
if (!this.soapClient) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.soapClient.getPositionInfo(this.avTransportUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get media info
|
||||||
|
*/
|
||||||
|
public async getMediaInfo(): Promise<IDlnaMediaInfo> {
|
||||||
|
if (!this.soapClient) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.soapClient.getMediaInfo(this.avTransportUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get full playback state
|
||||||
|
*/
|
||||||
|
public async getPlaybackState(): Promise<IDlnaPlaybackState> {
|
||||||
|
if (!this.soapClient) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [transport, position, media, volume, muted] = await Promise.all([
|
||||||
|
this.getTransportInfo(),
|
||||||
|
this.getPositionInfo(),
|
||||||
|
this.getMediaInfo(),
|
||||||
|
this._supportsVolume ? this.getVolume() : Promise.resolve(0),
|
||||||
|
this._supportsVolume ? this.getMute() : Promise.resolve(false),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Parse metadata for track info
|
||||||
|
const trackMeta = this.parseTrackMetadata(position.trackMetadata);
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: transport.state,
|
||||||
|
volume,
|
||||||
|
muted,
|
||||||
|
currentUri: media.currentUri,
|
||||||
|
currentTrack: {
|
||||||
|
title: trackMeta.title || 'Unknown',
|
||||||
|
artist: trackMeta.artist,
|
||||||
|
album: trackMeta.album,
|
||||||
|
duration: this.soapClient.durationToSeconds(position.trackDuration),
|
||||||
|
position: this.soapClient.durationToSeconds(position.relativeTime),
|
||||||
|
albumArtUri: trackMeta.albumArtUri,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse track metadata from DIDL-Lite
|
||||||
|
*/
|
||||||
|
private parseTrackMetadata(metadata: string): {
|
||||||
|
title?: string;
|
||||||
|
artist?: string;
|
||||||
|
album?: string;
|
||||||
|
albumArtUri?: string;
|
||||||
|
} {
|
||||||
|
if (!metadata) return {};
|
||||||
|
|
||||||
|
const extractTag = (xml: string, tag: string): string | undefined => {
|
||||||
|
const regex = new RegExp(`<(?:[^:]*:)?${tag}[^>]*>([^<]*)<\/(?:[^:]*:)?${tag}>`, 'i');
|
||||||
|
const match = xml.match(regex);
|
||||||
|
return match ? match[1].trim() : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: extractTag(metadata, 'title'),
|
||||||
|
artist: extractTag(metadata, 'creator') || extractTag(metadata, 'artist'),
|
||||||
|
album: extractTag(metadata, 'album'),
|
||||||
|
albumArtUri: extractTag(metadata, 'albumArtURI'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get device info
|
||||||
|
*/
|
||||||
|
public getDeviceInfo(): IDlnaRendererInfo {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
name: this.name,
|
||||||
|
type: 'dlna-renderer',
|
||||||
|
address: this.address,
|
||||||
|
port: this.port,
|
||||||
|
status: this.status,
|
||||||
|
friendlyName: this._friendlyName,
|
||||||
|
modelName: this._modelName,
|
||||||
|
modelNumber: this._modelNumber,
|
||||||
|
manufacturer: this.manufacturer || '',
|
||||||
|
udn: this._udn,
|
||||||
|
iconUrl: this._iconUrl,
|
||||||
|
supportsVolume: this._supportsVolume,
|
||||||
|
supportsSeek: this._supportsSeek,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create from SSDP discovery
|
||||||
|
*/
|
||||||
|
public static fromSsdpDevice(
|
||||||
|
ssdpDevice: ISsdpDevice,
|
||||||
|
retryOptions?: IRetryOptions
|
||||||
|
): DlnaRenderer | null {
|
||||||
|
if (!ssdpDevice.description) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const desc = ssdpDevice.description;
|
||||||
|
|
||||||
|
// Find AVTransport and RenderingControl URLs
|
||||||
|
const avTransport = desc.services.find((s) =>
|
||||||
|
s.serviceType.includes('AVTransport')
|
||||||
|
);
|
||||||
|
const renderingControl = desc.services.find((s) =>
|
||||||
|
s.serviceType.includes('RenderingControl')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!avTransport) {
|
||||||
|
return null; // Not a media renderer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build base URL
|
||||||
|
const baseUrl = new URL(ssdpDevice.location);
|
||||||
|
const baseUrlStr = `${baseUrl.protocol}//${baseUrl.host}`;
|
||||||
|
|
||||||
|
// Get icon URL
|
||||||
|
let iconUrl: string | undefined;
|
||||||
|
if (desc.icons && desc.icons.length > 0) {
|
||||||
|
const bestIcon = desc.icons.sort((a, b) => b.width - a.width)[0];
|
||||||
|
iconUrl = bestIcon.url.startsWith('http')
|
||||||
|
? bestIcon.url
|
||||||
|
: `${baseUrlStr}${bestIcon.url}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const info: IDeviceInfo = {
|
||||||
|
id: `dlna-renderer:${desc.UDN}`,
|
||||||
|
name: desc.friendlyName,
|
||||||
|
type: 'dlna-renderer',
|
||||||
|
address: ssdpDevice.address,
|
||||||
|
port: ssdpDevice.port,
|
||||||
|
status: 'unknown',
|
||||||
|
manufacturer: desc.manufacturer,
|
||||||
|
model: desc.modelName,
|
||||||
|
};
|
||||||
|
|
||||||
|
return new DlnaRenderer(
|
||||||
|
info,
|
||||||
|
{
|
||||||
|
friendlyName: desc.friendlyName,
|
||||||
|
baseUrl: baseUrlStr,
|
||||||
|
avTransportUrl: avTransport.controlURL,
|
||||||
|
renderingControlUrl: renderingControl?.controlURL,
|
||||||
|
modelName: desc.modelName,
|
||||||
|
modelNumber: desc.modelNumber,
|
||||||
|
udn: desc.UDN,
|
||||||
|
iconUrl,
|
||||||
|
},
|
||||||
|
retryOptions
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
468
ts/dlna/dlna.classes.server.ts
Normal file
468
ts/dlna/dlna.classes.server.ts
Normal file
@@ -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<void> {
|
||||||
|
this.soapClient = new UpnpSoapClient(this.baseUrl);
|
||||||
|
|
||||||
|
// Test connection by browsing root
|
||||||
|
try {
|
||||||
|
const root = await this.browse('0', 0, 1);
|
||||||
|
this._contentCount = root.totalMatches;
|
||||||
|
} catch (error) {
|
||||||
|
this.soapClient = null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect
|
||||||
|
*/
|
||||||
|
protected async doDisconnect(): Promise<void> {
|
||||||
|
this.soapClient = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh status
|
||||||
|
*/
|
||||||
|
public async refreshStatus(): Promise<void> {
|
||||||
|
if (!this.soapClient) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = await this.browse('0', 0, 1);
|
||||||
|
this._contentCount = root.totalMatches;
|
||||||
|
|
||||||
|
this.emit('status:updated', this.getDeviceInfo());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Content Directory Browsing
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Browse content directory
|
||||||
|
*/
|
||||||
|
public async browse(
|
||||||
|
objectId: string = '0',
|
||||||
|
startIndex: number = 0,
|
||||||
|
requestCount: number = 100
|
||||||
|
): Promise<IDlnaBrowseResult> {
|
||||||
|
if (!this.soapClient) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.soapClient.browse(
|
||||||
|
this.contentDirectoryUrl,
|
||||||
|
objectId,
|
||||||
|
'BrowseDirectChildren',
|
||||||
|
'*',
|
||||||
|
startIndex,
|
||||||
|
requestCount
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get metadata for a specific item
|
||||||
|
*/
|
||||||
|
public async getMetadata(objectId: string): Promise<IDlnaContentItem | null> {
|
||||||
|
if (!this.soapClient) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.soapClient.browse(
|
||||||
|
this.contentDirectoryUrl,
|
||||||
|
objectId,
|
||||||
|
'BrowseMetadata',
|
||||||
|
'*',
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.items[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search content directory
|
||||||
|
*/
|
||||||
|
public async search(
|
||||||
|
containerId: string,
|
||||||
|
searchCriteria: string,
|
||||||
|
startIndex: number = 0,
|
||||||
|
requestCount: number = 100
|
||||||
|
): Promise<IDlnaBrowseResult> {
|
||||||
|
if (!this.soapClient) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.soapClient.search(
|
||||||
|
this.contentDirectoryUrl,
|
||||||
|
containerId,
|
||||||
|
searchCriteria,
|
||||||
|
'*',
|
||||||
|
startIndex,
|
||||||
|
requestCount
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Browse all items recursively (up to limit)
|
||||||
|
*/
|
||||||
|
public async browseAll(
|
||||||
|
objectId: string = '0',
|
||||||
|
limit: number = 1000
|
||||||
|
): Promise<IDlnaContentItem[]> {
|
||||||
|
const allItems: IDlnaContentItem[] = [];
|
||||||
|
let startIndex = 0;
|
||||||
|
const batchSize = 100;
|
||||||
|
|
||||||
|
while (allItems.length < limit) {
|
||||||
|
const result = await this.browse(objectId, startIndex, batchSize);
|
||||||
|
allItems.push(...result.items);
|
||||||
|
|
||||||
|
if (result.items.length < batchSize || allItems.length >= result.totalMatches) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
startIndex += result.items.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return allItems.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get content statistics
|
||||||
|
*/
|
||||||
|
public async getStats(): Promise<IDlnaServerStats> {
|
||||||
|
const stats: IDlnaServerStats = {
|
||||||
|
totalItems: 0,
|
||||||
|
audioItems: 0,
|
||||||
|
videoItems: 0,
|
||||||
|
imageItems: 0,
|
||||||
|
containers: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Browse root to get counts
|
||||||
|
const root = await this.browseAll('0', 500);
|
||||||
|
|
||||||
|
for (const item of root) {
|
||||||
|
stats.totalItems++;
|
||||||
|
|
||||||
|
if (item.class.includes('container')) {
|
||||||
|
stats.containers++;
|
||||||
|
} else if (item.class.includes('audioItem')) {
|
||||||
|
stats.audioItems++;
|
||||||
|
} else if (item.class.includes('videoItem')) {
|
||||||
|
stats.videoItems++;
|
||||||
|
} else if (item.class.includes('imageItem')) {
|
||||||
|
stats.imageItems++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Content Access
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get stream URL for content item
|
||||||
|
*/
|
||||||
|
public getStreamUrl(item: IDlnaContentItem): string | null {
|
||||||
|
if (!item.res || item.res.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return first resource URL
|
||||||
|
return item.res[0].url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get best quality stream URL
|
||||||
|
*/
|
||||||
|
public getBestStreamUrl(item: IDlnaContentItem, preferredType?: string): string | null {
|
||||||
|
if (!item.res || item.res.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by bitrate (highest first)
|
||||||
|
const sorted = [...item.res].sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0));
|
||||||
|
|
||||||
|
// If preferred type specified, try to find matching
|
||||||
|
if (preferredType) {
|
||||||
|
const preferred = sorted.find((r) =>
|
||||||
|
r.protocolInfo.toLowerCase().includes(preferredType.toLowerCase())
|
||||||
|
);
|
||||||
|
if (preferred) return preferred.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sorted[0].url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get album art URL for item
|
||||||
|
*/
|
||||||
|
public getAlbumArtUrl(item: IDlnaContentItem): string | null {
|
||||||
|
if (item.albumArtUri) {
|
||||||
|
// Resolve relative URLs
|
||||||
|
if (!item.albumArtUri.startsWith('http')) {
|
||||||
|
return `${this.baseUrl}${item.albumArtUri}`;
|
||||||
|
}
|
||||||
|
return item.albumArtUri;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Search Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for audio items by title
|
||||||
|
*/
|
||||||
|
public async searchAudio(
|
||||||
|
title: string,
|
||||||
|
startIndex: number = 0,
|
||||||
|
requestCount: number = 100
|
||||||
|
): Promise<IDlnaBrowseResult> {
|
||||||
|
const criteria = `dc:title contains "${title}" and upnp:class derivedfrom "object.item.audioItem"`;
|
||||||
|
return this.search('0', criteria, startIndex, requestCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for video items by title
|
||||||
|
*/
|
||||||
|
public async searchVideo(
|
||||||
|
title: string,
|
||||||
|
startIndex: number = 0,
|
||||||
|
requestCount: number = 100
|
||||||
|
): Promise<IDlnaBrowseResult> {
|
||||||
|
const criteria = `dc:title contains "${title}" and upnp:class derivedfrom "object.item.videoItem"`;
|
||||||
|
return this.search('0', criteria, startIndex, requestCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search by artist
|
||||||
|
*/
|
||||||
|
public async searchByArtist(
|
||||||
|
artist: string,
|
||||||
|
startIndex: number = 0,
|
||||||
|
requestCount: number = 100
|
||||||
|
): Promise<IDlnaBrowseResult> {
|
||||||
|
const criteria = `dc:creator contains "${artist}" or upnp:artist contains "${artist}"`;
|
||||||
|
return this.search('0', criteria, startIndex, requestCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search by album
|
||||||
|
*/
|
||||||
|
public async searchByAlbum(
|
||||||
|
album: string,
|
||||||
|
startIndex: number = 0,
|
||||||
|
requestCount: number = 100
|
||||||
|
): Promise<IDlnaBrowseResult> {
|
||||||
|
const criteria = `upnp:album contains "${album}"`;
|
||||||
|
return this.search('0', criteria, startIndex, requestCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search by genre
|
||||||
|
*/
|
||||||
|
public async searchByGenre(
|
||||||
|
genre: string,
|
||||||
|
startIndex: number = 0,
|
||||||
|
requestCount: number = 100
|
||||||
|
): Promise<IDlnaBrowseResult> {
|
||||||
|
const criteria = `upnp:genre contains "${genre}"`;
|
||||||
|
return this.search('0', criteria, startIndex, requestCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Device Info
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get device info
|
||||||
|
*/
|
||||||
|
public getDeviceInfo(): IDlnaServerInfo {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
name: this.name,
|
||||||
|
type: 'dlna-server',
|
||||||
|
address: this.address,
|
||||||
|
port: this.port,
|
||||||
|
status: this.status,
|
||||||
|
friendlyName: this._friendlyName,
|
||||||
|
modelName: this._modelName,
|
||||||
|
modelNumber: this._modelNumber,
|
||||||
|
manufacturer: this.manufacturer || '',
|
||||||
|
udn: this._udn,
|
||||||
|
iconUrl: this._iconUrl,
|
||||||
|
contentCount: this._contentCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create from SSDP discovery
|
||||||
|
*/
|
||||||
|
public static fromSsdpDevice(
|
||||||
|
ssdpDevice: ISsdpDevice,
|
||||||
|
retryOptions?: IRetryOptions
|
||||||
|
): DlnaServer | null {
|
||||||
|
if (!ssdpDevice.description) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const desc = ssdpDevice.description;
|
||||||
|
|
||||||
|
// Find ContentDirectory URL
|
||||||
|
const contentDirectory = desc.services.find((s) =>
|
||||||
|
s.serviceType.includes('ContentDirectory')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!contentDirectory) {
|
||||||
|
return null; // Not a media server
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build base URL
|
||||||
|
const baseUrl = new URL(ssdpDevice.location);
|
||||||
|
const baseUrlStr = `${baseUrl.protocol}//${baseUrl.host}`;
|
||||||
|
|
||||||
|
// Get icon URL
|
||||||
|
let iconUrl: string | undefined;
|
||||||
|
if (desc.icons && desc.icons.length > 0) {
|
||||||
|
const bestIcon = desc.icons.sort((a, b) => b.width - a.width)[0];
|
||||||
|
iconUrl = bestIcon.url.startsWith('http')
|
||||||
|
? bestIcon.url
|
||||||
|
: `${baseUrlStr}${bestIcon.url}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const info: IDeviceInfo = {
|
||||||
|
id: `dlna-server:${desc.UDN}`,
|
||||||
|
name: desc.friendlyName,
|
||||||
|
type: 'dlna-server',
|
||||||
|
address: ssdpDevice.address,
|
||||||
|
port: ssdpDevice.port,
|
||||||
|
status: 'unknown',
|
||||||
|
manufacturer: desc.manufacturer,
|
||||||
|
model: desc.modelName,
|
||||||
|
};
|
||||||
|
|
||||||
|
return new DlnaServer(
|
||||||
|
info,
|
||||||
|
{
|
||||||
|
friendlyName: desc.friendlyName,
|
||||||
|
baseUrl: baseUrlStr,
|
||||||
|
contentDirectoryUrl: contentDirectory.controlURL,
|
||||||
|
modelName: desc.modelName,
|
||||||
|
modelNumber: desc.modelNumber,
|
||||||
|
udn: desc.UDN,
|
||||||
|
iconUrl,
|
||||||
|
},
|
||||||
|
retryOptions
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export content types
|
||||||
|
export type { IDlnaContentItem, IDlnaBrowseResult } from './dlna.classes.upnp.js';
|
||||||
251
ts/features/feature.abstract.ts
Normal file
251
ts/features/feature.abstract.ts
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
/**
|
||||||
|
* Abstract Feature base class
|
||||||
|
* Features are composable capabilities that can be attached to devices
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type {
|
||||||
|
TFeatureType,
|
||||||
|
TFeatureState,
|
||||||
|
IFeature,
|
||||||
|
IFeatureInfo,
|
||||||
|
IFeatureOptions,
|
||||||
|
} from '../interfaces/feature.interfaces.js';
|
||||||
|
import type { IRetryOptions } from '../interfaces/index.js';
|
||||||
|
import { withRetry } from '../helpers/helpers.retry.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forward reference to Device to avoid circular dependency
|
||||||
|
* The actual Device class will set this reference
|
||||||
|
*/
|
||||||
|
export type TDeviceReference = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
port: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstract base class for all features
|
||||||
|
* Provides common functionality for connection management, state tracking, and retry logic
|
||||||
|
*/
|
||||||
|
export abstract class Feature extends plugins.events.EventEmitter implements IFeature {
|
||||||
|
/**
|
||||||
|
* The feature type identifier
|
||||||
|
*/
|
||||||
|
public abstract readonly type: TFeatureType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The protocol used by this feature
|
||||||
|
*/
|
||||||
|
public abstract readonly protocol: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The port used by this feature
|
||||||
|
*/
|
||||||
|
protected _port: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current feature state
|
||||||
|
*/
|
||||||
|
protected _state: TFeatureState = 'disconnected';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last error encountered
|
||||||
|
*/
|
||||||
|
protected _lastError?: Error;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reference to the parent device
|
||||||
|
*/
|
||||||
|
protected _device: TDeviceReference;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry configuration
|
||||||
|
*/
|
||||||
|
protected _retryConfig: Required<IRetryOptions>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Additional metadata from discovery
|
||||||
|
*/
|
||||||
|
protected _metadata: Record<string, unknown>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
device: TDeviceReference,
|
||||||
|
port: number,
|
||||||
|
options?: IFeatureOptions
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
this._device = device;
|
||||||
|
this._port = port;
|
||||||
|
this._metadata = options?.metadata ?? {};
|
||||||
|
|
||||||
|
// Setup retry config
|
||||||
|
const retryOpts = options?.retryOptions ?? {};
|
||||||
|
this._retryConfig = {
|
||||||
|
maxRetries: retryOpts.maxRetries ?? 3,
|
||||||
|
baseDelay: retryOpts.baseDelay ?? 1000,
|
||||||
|
maxDelay: retryOpts.maxDelay ?? 30000,
|
||||||
|
multiplier: retryOpts.multiplier ?? 2,
|
||||||
|
jitter: retryOpts.jitter ?? true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Public Properties
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the feature port
|
||||||
|
*/
|
||||||
|
public get port(): number {
|
||||||
|
return this._port;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current feature state
|
||||||
|
*/
|
||||||
|
public get state(): TFeatureState {
|
||||||
|
return this._state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the feature is connected
|
||||||
|
*/
|
||||||
|
public get isConnected(): boolean {
|
||||||
|
return this._state === 'connected';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the last error
|
||||||
|
*/
|
||||||
|
public get lastError(): Error | undefined {
|
||||||
|
return this._lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the device address
|
||||||
|
*/
|
||||||
|
public get address(): string {
|
||||||
|
return this._device.address;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the device ID
|
||||||
|
*/
|
||||||
|
public get deviceId(): string {
|
||||||
|
return this._device.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Connection Management
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to the feature endpoint
|
||||||
|
*/
|
||||||
|
public async connect(): Promise<void> {
|
||||||
|
if (this._state === 'connected') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._state === 'connecting') {
|
||||||
|
throw new Error(`Feature ${this.type} is already connecting`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState('connecting');
|
||||||
|
this._lastError = undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.withRetry(() => this.doConnect());
|
||||||
|
this.setState('connected');
|
||||||
|
this.emit('connected');
|
||||||
|
} catch (error) {
|
||||||
|
this._lastError = error instanceof Error ? error : new Error(String(error));
|
||||||
|
this.setState('error');
|
||||||
|
this.emit('error', this._lastError);
|
||||||
|
throw this._lastError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect from the feature endpoint
|
||||||
|
*/
|
||||||
|
public async disconnect(): Promise<void> {
|
||||||
|
if (this._state === 'disconnected') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.doDisconnect();
|
||||||
|
} finally {
|
||||||
|
this.setState('disconnected');
|
||||||
|
this.emit('disconnected');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Abstract Methods (implemented by subclasses)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform the actual connection logic
|
||||||
|
* Implemented by each feature subclass
|
||||||
|
*/
|
||||||
|
protected abstract doConnect(): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform the actual disconnection logic
|
||||||
|
* Implemented by each feature subclass
|
||||||
|
*/
|
||||||
|
protected abstract doDisconnect(): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get feature-specific info for serialization
|
||||||
|
*/
|
||||||
|
public abstract getFeatureInfo(): IFeatureInfo;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helper Methods
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the feature state and emit change event
|
||||||
|
*/
|
||||||
|
protected setState(state: TFeatureState): void {
|
||||||
|
if (this._state !== state) {
|
||||||
|
const oldState = this._state;
|
||||||
|
this._state = state;
|
||||||
|
this.emit('state:changed', { oldState, newState: state });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute an operation with retry logic
|
||||||
|
*/
|
||||||
|
protected async withRetry<T>(operation: () => Promise<T>): Promise<T> {
|
||||||
|
return withRetry(operation, this._retryConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get base feature info
|
||||||
|
*/
|
||||||
|
protected getBaseFeatureInfo(): IFeatureInfo {
|
||||||
|
return {
|
||||||
|
type: this.type,
|
||||||
|
protocol: this.protocol,
|
||||||
|
port: this._port,
|
||||||
|
state: this._state,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear error state
|
||||||
|
*/
|
||||||
|
public clearError(): void {
|
||||||
|
this._lastError = undefined;
|
||||||
|
if (this._state === 'error') {
|
||||||
|
this.setState('disconnected');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
246
ts/features/feature.playback.ts
Normal file
246
ts/features/feature.playback.ts
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
/**
|
||||||
|
* Playback Feature
|
||||||
|
* Provides media playback control capability
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
||||||
|
import type {
|
||||||
|
TPlaybackProtocol,
|
||||||
|
TPlaybackState,
|
||||||
|
ITrackInfo,
|
||||||
|
IPlaybackStatus,
|
||||||
|
IPlaybackFeatureInfo,
|
||||||
|
IFeatureOptions,
|
||||||
|
} from '../interfaces/feature.interfaces.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for creating a PlaybackFeature
|
||||||
|
*/
|
||||||
|
export interface IPlaybackFeatureOptions extends IFeatureOptions {
|
||||||
|
protocol: TPlaybackProtocol;
|
||||||
|
supportsQueue?: boolean;
|
||||||
|
supportsSeek?: boolean;
|
||||||
|
/** Protocol-specific client instance (Sonos, AirPlay, etc.) */
|
||||||
|
protocolClient?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playback Feature - provides media playback control
|
||||||
|
*
|
||||||
|
* Abstract feature that can be backed by different protocols (Sonos, AirPlay, Chromecast, DLNA).
|
||||||
|
* Concrete implementations should extend this class for protocol-specific behavior.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const playback = device.getFeature<PlaybackFeature>('playback');
|
||||||
|
* if (playback) {
|
||||||
|
* await playback.play('spotify:track:123');
|
||||||
|
* const status = await playback.getPlaybackStatus();
|
||||||
|
* console.log(`Playing: ${status.track?.title}`);
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class PlaybackFeature extends Feature {
|
||||||
|
public readonly type = 'playback' as const;
|
||||||
|
public readonly protocol: TPlaybackProtocol;
|
||||||
|
|
||||||
|
// Capabilities
|
||||||
|
public supportsQueue: boolean = true;
|
||||||
|
public supportsSeek: boolean = true;
|
||||||
|
|
||||||
|
// Current state
|
||||||
|
protected _playbackState: TPlaybackState = 'stopped';
|
||||||
|
protected _currentTrack: ITrackInfo | null = null;
|
||||||
|
protected _position: number = 0;
|
||||||
|
protected _duration: number = 0;
|
||||||
|
|
||||||
|
// Protocol client (set by subclass or passed in options)
|
||||||
|
protected protocolClient: unknown = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
device: TDeviceReference,
|
||||||
|
port: number,
|
||||||
|
options: IPlaybackFeatureOptions
|
||||||
|
) {
|
||||||
|
super(device, port, options);
|
||||||
|
this.protocol = options.protocol;
|
||||||
|
if (options.supportsQueue !== undefined) this.supportsQueue = options.supportsQueue;
|
||||||
|
if (options.supportsSeek !== undefined) this.supportsSeek = options.supportsSeek;
|
||||||
|
if (options.protocolClient) this.protocolClient = options.protocolClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Properties
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
public get playbackState(): TPlaybackState {
|
||||||
|
return this._playbackState;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get currentTrack(): ITrackInfo | null {
|
||||||
|
return this._currentTrack;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get position(): number {
|
||||||
|
return this._position;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get duration(): number {
|
||||||
|
return this._duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get isPlaying(): boolean {
|
||||||
|
return this._playbackState === 'playing';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Connection (to be overridden by protocol-specific subclasses)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
protected async doConnect(): Promise<void> {
|
||||||
|
// Base implementation - protocol-specific subclasses should override
|
||||||
|
// and establish their protocol connection
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async doDisconnect(): Promise<void> {
|
||||||
|
// Base implementation - protocol-specific subclasses should override
|
||||||
|
this.protocolClient = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Playback Control
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start playback
|
||||||
|
* @param uri Optional URI to play. If not provided, resumes current playback.
|
||||||
|
*/
|
||||||
|
public async play(uri?: string): Promise<void> {
|
||||||
|
this.emit('playback:play', { uri });
|
||||||
|
// Protocol-specific implementation should be provided by subclass
|
||||||
|
this._playbackState = 'playing';
|
||||||
|
this.emit('playback:state:changed', this._playbackState);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause playback
|
||||||
|
*/
|
||||||
|
public async pause(): Promise<void> {
|
||||||
|
this.emit('playback:pause');
|
||||||
|
this._playbackState = 'paused';
|
||||||
|
this.emit('playback:state:changed', this._playbackState);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop playback
|
||||||
|
*/
|
||||||
|
public async stop(): Promise<void> {
|
||||||
|
this.emit('playback:stop');
|
||||||
|
this._playbackState = 'stopped';
|
||||||
|
this._position = 0;
|
||||||
|
this.emit('playback:state:changed', this._playbackState);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skip to next track
|
||||||
|
*/
|
||||||
|
public async next(): Promise<void> {
|
||||||
|
this.emit('playback:next');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Go to previous track
|
||||||
|
*/
|
||||||
|
public async previous(): Promise<void> {
|
||||||
|
this.emit('playback:previous');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seek to position
|
||||||
|
* @param seconds Position in seconds
|
||||||
|
*/
|
||||||
|
public async seek(seconds: number): Promise<void> {
|
||||||
|
if (!this.supportsSeek) {
|
||||||
|
throw new Error('Seek not supported');
|
||||||
|
}
|
||||||
|
this._position = seconds;
|
||||||
|
this.emit('playback:seek', seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Status
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current track info
|
||||||
|
*/
|
||||||
|
public async getCurrentTrack(): Promise<ITrackInfo | null> {
|
||||||
|
return this._currentTrack;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get playback status
|
||||||
|
*/
|
||||||
|
public async getPlaybackStatus(): Promise<IPlaybackStatus> {
|
||||||
|
return {
|
||||||
|
state: this._playbackState,
|
||||||
|
position: this._position,
|
||||||
|
duration: this._duration,
|
||||||
|
track: this._currentTrack ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update track info (called by protocol-specific implementations)
|
||||||
|
*/
|
||||||
|
protected updateTrack(track: ITrackInfo | null): void {
|
||||||
|
const oldTrack = this._currentTrack;
|
||||||
|
this._currentTrack = track;
|
||||||
|
if (track?.title !== oldTrack?.title || track?.uri !== oldTrack?.uri) {
|
||||||
|
this.emit('playback:track:changed', track);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update position (called by protocol-specific implementations)
|
||||||
|
*/
|
||||||
|
protected updatePosition(position: number, duration: number): void {
|
||||||
|
this._position = position;
|
||||||
|
this._duration = duration;
|
||||||
|
this.emit('playback:position:changed', { position, duration });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Serialization
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
public getFeatureInfo(): IPlaybackFeatureInfo {
|
||||||
|
return {
|
||||||
|
...this.getBaseFeatureInfo(),
|
||||||
|
type: 'playback',
|
||||||
|
protocol: this.protocol,
|
||||||
|
supportsQueue: this.supportsQueue,
|
||||||
|
supportsSeek: this.supportsSeek,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Static Factory
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create from discovery metadata
|
||||||
|
*/
|
||||||
|
public static fromDiscovery(
|
||||||
|
device: TDeviceReference,
|
||||||
|
port: number,
|
||||||
|
protocol: TPlaybackProtocol,
|
||||||
|
metadata: Record<string, unknown>
|
||||||
|
): PlaybackFeature {
|
||||||
|
return new PlaybackFeature(device, port, {
|
||||||
|
protocol,
|
||||||
|
supportsQueue: metadata.supportsQueue as boolean ?? true,
|
||||||
|
supportsSeek: metadata.supportsSeek as boolean ?? true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
231
ts/features/feature.power.ts
Normal file
231
ts/features/feature.power.ts
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
/**
|
||||||
|
* Power Feature
|
||||||
|
* Provides UPS/power monitoring and control capability
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
||||||
|
import type {
|
||||||
|
TPowerProtocol,
|
||||||
|
TPowerStatus,
|
||||||
|
IBatteryInfo,
|
||||||
|
IPowerInfo,
|
||||||
|
IPowerFeatureInfo,
|
||||||
|
IFeatureOptions,
|
||||||
|
} from '../interfaces/feature.interfaces.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for creating a PowerFeature
|
||||||
|
*/
|
||||||
|
export interface IPowerFeatureOptions extends IFeatureOptions {
|
||||||
|
protocol: TPowerProtocol;
|
||||||
|
hasBattery?: boolean;
|
||||||
|
supportsShutdown?: boolean;
|
||||||
|
supportsTest?: boolean;
|
||||||
|
/** For NUT protocol: UPS name */
|
||||||
|
upsName?: string;
|
||||||
|
/** For SNMP: community string */
|
||||||
|
community?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Power Feature - provides UPS/power monitoring and control
|
||||||
|
*
|
||||||
|
* Supports NUT (Network UPS Tools) and SNMP protocols for monitoring
|
||||||
|
* UPS devices and smart power equipment.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const power = device.getFeature<PowerFeature>('power');
|
||||||
|
* if (power) {
|
||||||
|
* const status = await power.getStatus();
|
||||||
|
* const battery = await power.getBatteryInfo();
|
||||||
|
* console.log(`Status: ${status}, Battery: ${battery.charge}%`);
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class PowerFeature extends Feature {
|
||||||
|
public readonly type = 'power' as const;
|
||||||
|
public readonly protocol: TPowerProtocol;
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
public readonly upsName: string;
|
||||||
|
public readonly community: string;
|
||||||
|
|
||||||
|
// Capabilities
|
||||||
|
public readonly hasBattery: boolean;
|
||||||
|
public readonly supportsShutdown: boolean;
|
||||||
|
public readonly supportsTest: boolean;
|
||||||
|
|
||||||
|
// Current state
|
||||||
|
protected _status: TPowerStatus = 'unknown';
|
||||||
|
protected _batteryCharge: number = 0;
|
||||||
|
protected _batteryRuntime: number = 0;
|
||||||
|
protected _load: number = 0;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
device: TDeviceReference,
|
||||||
|
port: number,
|
||||||
|
options: IPowerFeatureOptions
|
||||||
|
) {
|
||||||
|
super(device, port, options);
|
||||||
|
this.protocol = options.protocol;
|
||||||
|
this.upsName = options.upsName ?? 'ups';
|
||||||
|
this.community = options.community ?? 'public';
|
||||||
|
this.hasBattery = options.hasBattery ?? true;
|
||||||
|
this.supportsShutdown = options.supportsShutdown ?? false;
|
||||||
|
this.supportsTest = options.supportsTest ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Properties
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
public get powerStatus(): TPowerStatus {
|
||||||
|
return this._status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get batteryCharge(): number {
|
||||||
|
return this._batteryCharge;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get batteryRuntime(): number {
|
||||||
|
return this._batteryRuntime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get load(): number {
|
||||||
|
return this._load;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Connection
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
protected async doConnect(): Promise<void> {
|
||||||
|
// Protocol-specific connection would be implemented here
|
||||||
|
// For now, just verify the port is reachable
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async doDisconnect(): Promise<void> {
|
||||||
|
// Protocol-specific disconnection
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Status Monitoring
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current power status
|
||||||
|
*/
|
||||||
|
public async getStatus(): Promise<TPowerStatus> {
|
||||||
|
// Protocol-specific implementation would fetch status
|
||||||
|
return this._status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get battery information
|
||||||
|
*/
|
||||||
|
public async getBatteryInfo(): Promise<IBatteryInfo> {
|
||||||
|
return {
|
||||||
|
charge: this._batteryCharge,
|
||||||
|
runtime: this._batteryRuntime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get power information
|
||||||
|
*/
|
||||||
|
public async getPowerInfo(): Promise<IPowerInfo> {
|
||||||
|
return {
|
||||||
|
load: this._load,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Control Commands
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initiate shutdown
|
||||||
|
*/
|
||||||
|
public async shutdown(delay?: number): Promise<void> {
|
||||||
|
if (!this.supportsShutdown) {
|
||||||
|
throw new Error('Shutdown not supported');
|
||||||
|
}
|
||||||
|
this.emit('power:shutdown', { delay });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start battery test
|
||||||
|
*/
|
||||||
|
public async testBattery(): Promise<void> {
|
||||||
|
if (!this.supportsTest) {
|
||||||
|
throw new Error('Battery test not supported');
|
||||||
|
}
|
||||||
|
this.emit('power:test:started');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// State Updates
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
protected updateStatus(status: TPowerStatus): void {
|
||||||
|
const oldStatus = this._status;
|
||||||
|
this._status = status;
|
||||||
|
if (oldStatus !== status) {
|
||||||
|
this.emit('power:status:changed', { oldStatus, newStatus: status });
|
||||||
|
|
||||||
|
// Emit specific events
|
||||||
|
if (status === 'onbattery') {
|
||||||
|
this.emit('power:onbattery');
|
||||||
|
} else if (status === 'online' && oldStatus === 'onbattery') {
|
||||||
|
this.emit('power:restored');
|
||||||
|
} else if (status === 'lowbattery') {
|
||||||
|
this.emit('power:lowbattery');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected updateBattery(charge: number, runtime: number): void {
|
||||||
|
const oldCharge = this._batteryCharge;
|
||||||
|
this._batteryCharge = charge;
|
||||||
|
this._batteryRuntime = runtime;
|
||||||
|
|
||||||
|
if (oldCharge !== charge) {
|
||||||
|
this.emit('battery:changed', { charge, runtime });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Serialization
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
public getFeatureInfo(): IPowerFeatureInfo {
|
||||||
|
return {
|
||||||
|
...this.getBaseFeatureInfo(),
|
||||||
|
type: 'power',
|
||||||
|
protocol: this.protocol,
|
||||||
|
hasBattery: this.hasBattery,
|
||||||
|
supportsShutdown: this.supportsShutdown,
|
||||||
|
supportsTest: this.supportsTest,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Static Factory
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
public static fromDiscovery(
|
||||||
|
device: TDeviceReference,
|
||||||
|
port: number,
|
||||||
|
protocol: TPowerProtocol,
|
||||||
|
metadata: Record<string, unknown>
|
||||||
|
): PowerFeature {
|
||||||
|
return new PowerFeature(device, port, {
|
||||||
|
protocol,
|
||||||
|
upsName: metadata.upsName as string,
|
||||||
|
hasBattery: metadata.hasBattery as boolean ?? true,
|
||||||
|
supportsShutdown: metadata.supportsShutdown as boolean ?? false,
|
||||||
|
supportsTest: metadata.supportsTest as boolean ?? false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
297
ts/features/feature.print.ts
Normal file
297
ts/features/feature.print.ts
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
/**
|
||||||
|
* Print Feature
|
||||||
|
* Provides document printing capability using IPP protocol
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
||||||
|
import { IppProtocol } from '../printer/printer.classes.ippprotocol.js';
|
||||||
|
import type {
|
||||||
|
TPrintProtocol,
|
||||||
|
TPrintSides,
|
||||||
|
TPrintQuality,
|
||||||
|
TPrintColorMode,
|
||||||
|
IPrintCapabilities,
|
||||||
|
IPrintOptions,
|
||||||
|
IPrintJob,
|
||||||
|
IPrintFeatureInfo,
|
||||||
|
IFeatureOptions,
|
||||||
|
} from '../interfaces/feature.interfaces.js';
|
||||||
|
import type { IPrinterCapabilities } from '../interfaces/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for creating a PrintFeature
|
||||||
|
*/
|
||||||
|
export interface IPrintFeatureOptions extends IFeatureOptions {
|
||||||
|
protocol?: TPrintProtocol;
|
||||||
|
uri?: string;
|
||||||
|
supportsColor?: boolean;
|
||||||
|
supportsDuplex?: boolean;
|
||||||
|
supportedMediaSizes?: string[];
|
||||||
|
supportedMediaTypes?: string[];
|
||||||
|
maxCopies?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print Feature - provides document printing capability
|
||||||
|
*
|
||||||
|
* Wraps the IPP protocol to provide a unified printing interface.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const printFeature = device.getFeature<PrintFeature>('print');
|
||||||
|
* if (printFeature) {
|
||||||
|
* await printFeature.connect();
|
||||||
|
* const job = await printFeature.print(pdfBuffer, { copies: 2 });
|
||||||
|
* console.log(`Print job ${job.id} created`);
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class PrintFeature extends Feature {
|
||||||
|
public readonly type = 'print' as const;
|
||||||
|
public readonly protocol: TPrintProtocol;
|
||||||
|
|
||||||
|
// Protocol client
|
||||||
|
private ippClient: IppProtocol | null = null;
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
public readonly uri: string;
|
||||||
|
|
||||||
|
// Capabilities
|
||||||
|
public supportsColor: boolean = true;
|
||||||
|
public supportsDuplex: boolean = false;
|
||||||
|
public supportedMediaSizes: string[] = ['iso_a4_210x297mm', 'na_letter_8.5x11in'];
|
||||||
|
public supportedMediaTypes: string[] = ['stationery'];
|
||||||
|
public maxCopies: number = 99;
|
||||||
|
public supportedSides: TPrintSides[] = ['one-sided'];
|
||||||
|
public supportedQualities: TPrintQuality[] = ['normal'];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
device: TDeviceReference,
|
||||||
|
port: number,
|
||||||
|
options?: IPrintFeatureOptions
|
||||||
|
) {
|
||||||
|
super(device, port, options);
|
||||||
|
this.protocol = options?.protocol ?? 'ipp';
|
||||||
|
this.uri = options?.uri ?? `ipp://${device.address}:${port}/ipp/print`;
|
||||||
|
|
||||||
|
// Set capabilities from options if provided
|
||||||
|
if (options?.supportsColor !== undefined) this.supportsColor = options.supportsColor;
|
||||||
|
if (options?.supportsDuplex !== undefined) {
|
||||||
|
this.supportsDuplex = options.supportsDuplex;
|
||||||
|
if (options.supportsDuplex) {
|
||||||
|
this.supportedSides = ['one-sided', 'two-sided-long-edge', 'two-sided-short-edge'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (options?.supportedMediaSizes) this.supportedMediaSizes = options.supportedMediaSizes;
|
||||||
|
if (options?.supportedMediaTypes) this.supportedMediaTypes = options.supportedMediaTypes;
|
||||||
|
if (options?.maxCopies) this.maxCopies = options.maxCopies;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Connection
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
protected async doConnect(): Promise<void> {
|
||||||
|
if (this.protocol === 'ipp') {
|
||||||
|
// Parse URI to get address, port, and path
|
||||||
|
const url = new URL(this.uri.replace('ipp://', 'http://').replace('ipps://', 'https://'));
|
||||||
|
const address = url.hostname;
|
||||||
|
const port = parseInt(url.port) || this._port;
|
||||||
|
const path = url.pathname || '/ipp/print';
|
||||||
|
|
||||||
|
this.ippClient = new IppProtocol(address, port, path);
|
||||||
|
// Verify connection by getting printer attributes
|
||||||
|
const attrs = await this.ippClient.getAttributes();
|
||||||
|
this.updateCapabilitiesFromIpp(attrs);
|
||||||
|
}
|
||||||
|
// JetDirect and LPD don't need connection verification
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async doDisconnect(): Promise<void> {
|
||||||
|
this.ippClient = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Printing Operations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get printer capabilities
|
||||||
|
*/
|
||||||
|
public async getCapabilities(): Promise<IPrintCapabilities> {
|
||||||
|
return {
|
||||||
|
colorSupported: this.supportsColor,
|
||||||
|
duplexSupported: this.supportsDuplex,
|
||||||
|
mediaSizes: this.supportedMediaSizes,
|
||||||
|
mediaTypes: this.supportedMediaTypes,
|
||||||
|
resolutions: [300, 600],
|
||||||
|
maxCopies: this.maxCopies,
|
||||||
|
sidesSupported: this.supportedSides,
|
||||||
|
qualitySupported: this.supportedQualities,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print a document
|
||||||
|
*/
|
||||||
|
public async print(data: Buffer, options?: IPrintOptions): Promise<IPrintJob> {
|
||||||
|
if (!this.isConnected) {
|
||||||
|
throw new Error('Print feature not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.protocol === 'ipp' && this.ippClient) {
|
||||||
|
return this.printWithIpp(data, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Protocol ${this.protocol} not supported yet`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get active print jobs
|
||||||
|
*/
|
||||||
|
public async getJobs(): Promise<IPrintJob[]> {
|
||||||
|
if (!this.isConnected || !this.ippClient) {
|
||||||
|
throw new Error('Print feature not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.ippClient.getJobs();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get info about a specific job
|
||||||
|
*/
|
||||||
|
public async getJobInfo(jobId: number): Promise<IPrintJob> {
|
||||||
|
if (!this.isConnected || !this.ippClient) {
|
||||||
|
throw new Error('Print feature not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.ippClient.getJobInfo(jobId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel a print job
|
||||||
|
*/
|
||||||
|
public async cancelJob(jobId: number): Promise<void> {
|
||||||
|
if (!this.isConnected || !this.ippClient) {
|
||||||
|
throw new Error('Print feature not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.ippClient.cancelJob(jobId);
|
||||||
|
this.emit('job:cancelled', jobId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Protocol-Specific Printing
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
private async printWithIpp(data: Buffer, options?: IPrintOptions): Promise<IPrintJob> {
|
||||||
|
if (!this.ippClient) {
|
||||||
|
throw new Error('IPP client not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('print:started', options);
|
||||||
|
|
||||||
|
// IppProtocol.print() accepts IPrintOptions and returns IPrintJob
|
||||||
|
const job = await this.ippClient.print(data, options);
|
||||||
|
|
||||||
|
this.emit('print:submitted', job);
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helper Methods
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
private updateCapabilitiesFromIpp(caps: IPrinterCapabilities): void {
|
||||||
|
this.supportsColor = caps.colorSupported;
|
||||||
|
this.supportsDuplex = caps.duplexSupported;
|
||||||
|
this.maxCopies = caps.maxCopies;
|
||||||
|
|
||||||
|
if (caps.mediaSizes && caps.mediaSizes.length > 0) {
|
||||||
|
this.supportedMediaSizes = caps.mediaSizes;
|
||||||
|
}
|
||||||
|
if (caps.mediaTypes && caps.mediaTypes.length > 0) {
|
||||||
|
this.supportedMediaTypes = caps.mediaTypes;
|
||||||
|
}
|
||||||
|
if (caps.sidesSupported && caps.sidesSupported.length > 0) {
|
||||||
|
this.supportedSides = caps.sidesSupported.filter((s): s is TPrintSides =>
|
||||||
|
['one-sided', 'two-sided-long-edge', 'two-sided-short-edge'].includes(s)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (caps.qualitySupported && caps.qualitySupported.length > 0) {
|
||||||
|
this.supportedQualities = caps.qualitySupported.filter((q): q is TPrintQuality =>
|
||||||
|
['draft', 'normal', 'high'].includes(q)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private qualityToIpp(quality: TPrintQuality): number {
|
||||||
|
switch (quality) {
|
||||||
|
case 'draft': return 3;
|
||||||
|
case 'normal': return 4;
|
||||||
|
case 'high': return 5;
|
||||||
|
default: return 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapIppJob(job: Record<string, unknown>): IPrintJob {
|
||||||
|
const stateMap: Record<number, IPrintJob['state']> = {
|
||||||
|
3: 'pending',
|
||||||
|
4: 'pending',
|
||||||
|
5: 'processing',
|
||||||
|
6: 'processing',
|
||||||
|
7: 'canceled',
|
||||||
|
8: 'aborted',
|
||||||
|
9: 'completed',
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: job['job-id'] as number,
|
||||||
|
name: job['job-name'] as string ?? 'Unknown',
|
||||||
|
state: stateMap[(job['job-state'] as number) ?? 3] ?? 'pending',
|
||||||
|
stateReason: (job['job-state-reasons'] as string[])?.[0],
|
||||||
|
createdAt: new Date((job['time-at-creation'] as number) * 1000),
|
||||||
|
completedAt: job['time-at-completed']
|
||||||
|
? new Date((job['time-at-completed'] as number) * 1000)
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Serialization
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
public getFeatureInfo(): IPrintFeatureInfo {
|
||||||
|
return {
|
||||||
|
...this.getBaseFeatureInfo(),
|
||||||
|
type: 'print',
|
||||||
|
protocol: this.protocol,
|
||||||
|
supportsColor: this.supportsColor,
|
||||||
|
supportsDuplex: this.supportsDuplex,
|
||||||
|
supportedMediaSizes: this.supportedMediaSizes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Static Factory
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create from discovery metadata
|
||||||
|
*/
|
||||||
|
public static fromDiscovery(
|
||||||
|
device: TDeviceReference,
|
||||||
|
port: number,
|
||||||
|
protocol: TPrintProtocol,
|
||||||
|
metadata: Record<string, unknown>
|
||||||
|
): PrintFeature {
|
||||||
|
const txtRecords = metadata.txtRecords as Record<string, string> ?? {};
|
||||||
|
|
||||||
|
return new PrintFeature(device, port, {
|
||||||
|
protocol,
|
||||||
|
uri: metadata.uri as string,
|
||||||
|
supportsColor: txtRecords['Color'] === 'T' || txtRecords['color'] === 'true',
|
||||||
|
supportsDuplex: txtRecords['Duplex'] === 'T' || txtRecords['duplex'] === 'true',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
371
ts/features/feature.scan.ts
Normal file
371
ts/features/feature.scan.ts
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
/**
|
||||||
|
* Scan Feature
|
||||||
|
* Provides document scanning capability using eSCL or SANE protocols
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
||||||
|
import { EsclProtocol } from '../scanner/scanner.classes.esclprotocol.js';
|
||||||
|
import { SaneProtocol } from '../scanner/scanner.classes.saneprotocol.js';
|
||||||
|
import type {
|
||||||
|
TScanProtocol,
|
||||||
|
TScanFormat,
|
||||||
|
TColorMode,
|
||||||
|
TScanSource,
|
||||||
|
IScanCapabilities,
|
||||||
|
IScanOptions,
|
||||||
|
IScanResult,
|
||||||
|
IScanFeatureInfo,
|
||||||
|
IFeatureOptions,
|
||||||
|
} from '../interfaces/feature.interfaces.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for creating a ScanFeature
|
||||||
|
*/
|
||||||
|
export interface IScanFeatureOptions extends IFeatureOptions {
|
||||||
|
protocol: TScanProtocol;
|
||||||
|
secure?: boolean;
|
||||||
|
deviceName?: string; // For SANE
|
||||||
|
supportedFormats?: TScanFormat[];
|
||||||
|
supportedResolutions?: number[];
|
||||||
|
supportedColorModes?: TColorMode[];
|
||||||
|
supportedSources?: TScanSource[];
|
||||||
|
hasAdf?: boolean;
|
||||||
|
hasDuplex?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan Feature - provides document scanning capability
|
||||||
|
*
|
||||||
|
* Wraps eSCL (AirScan) or SANE protocols to provide a unified scanning interface.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const scanFeature = device.getFeature<ScanFeature>('scan');
|
||||||
|
* if (scanFeature) {
|
||||||
|
* await scanFeature.connect();
|
||||||
|
* const result = await scanFeature.scan({ format: 'pdf', resolution: 300 });
|
||||||
|
* console.log(`Scanned ${result.width}x${result.height} pixels`);
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class ScanFeature extends Feature {
|
||||||
|
public readonly type = 'scan' as const;
|
||||||
|
public readonly protocol: TScanProtocol;
|
||||||
|
|
||||||
|
// Protocol clients
|
||||||
|
private esclClient: EsclProtocol | null = null;
|
||||||
|
private saneClient: SaneProtocol | null = null;
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
private readonly isSecure: boolean;
|
||||||
|
private readonly deviceName: string;
|
||||||
|
|
||||||
|
// Capabilities
|
||||||
|
public supportedFormats: TScanFormat[] = ['jpeg', 'png', 'pdf'];
|
||||||
|
public supportedResolutions: number[] = [75, 150, 300, 600];
|
||||||
|
public supportedColorModes: TColorMode[] = ['color', 'grayscale', 'blackwhite'];
|
||||||
|
public supportedSources: TScanSource[] = ['flatbed'];
|
||||||
|
public hasAdf: boolean = false;
|
||||||
|
public hasDuplex: boolean = false;
|
||||||
|
public maxWidth: number = 215.9; // A4 width in mm
|
||||||
|
public maxHeight: number = 297; // A4 height in mm
|
||||||
|
public minWidth: number = 10;
|
||||||
|
public minHeight: number = 10;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
device: TDeviceReference,
|
||||||
|
port: number,
|
||||||
|
options: IScanFeatureOptions
|
||||||
|
) {
|
||||||
|
super(device, port, options);
|
||||||
|
this.protocol = options.protocol;
|
||||||
|
this.isSecure = options.secure ?? false;
|
||||||
|
this.deviceName = options.deviceName ?? '';
|
||||||
|
|
||||||
|
// Set capabilities from options if provided
|
||||||
|
if (options.supportedFormats) this.supportedFormats = options.supportedFormats;
|
||||||
|
if (options.supportedResolutions) this.supportedResolutions = options.supportedResolutions;
|
||||||
|
if (options.supportedColorModes) this.supportedColorModes = options.supportedColorModes;
|
||||||
|
if (options.supportedSources) this.supportedSources = options.supportedSources;
|
||||||
|
if (options.hasAdf !== undefined) this.hasAdf = options.hasAdf;
|
||||||
|
if (options.hasDuplex !== undefined) this.hasDuplex = options.hasDuplex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Connection
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
protected async doConnect(): Promise<void> {
|
||||||
|
if (this.protocol === 'escl') {
|
||||||
|
this.esclClient = new EsclProtocol(this.address, this._port, this.isSecure);
|
||||||
|
// Fetch capabilities to verify connection
|
||||||
|
const caps = await this.esclClient.getCapabilities();
|
||||||
|
this.updateCapabilitiesFromEscl(caps);
|
||||||
|
} else if (this.protocol === 'sane') {
|
||||||
|
this.saneClient = new SaneProtocol(this.address, this._port);
|
||||||
|
await this.saneClient.connect();
|
||||||
|
// Open the device if we have a name
|
||||||
|
if (this.deviceName) {
|
||||||
|
await this.saneClient.open(this.deviceName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async doDisconnect(): Promise<void> {
|
||||||
|
if (this.saneClient) {
|
||||||
|
try {
|
||||||
|
await this.saneClient.close();
|
||||||
|
await this.saneClient.disconnect();
|
||||||
|
} catch {
|
||||||
|
// Ignore disconnect errors
|
||||||
|
}
|
||||||
|
this.saneClient = null;
|
||||||
|
}
|
||||||
|
this.esclClient = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Scanning Operations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get scanner capabilities
|
||||||
|
*/
|
||||||
|
public async getCapabilities(): Promise<IScanCapabilities> {
|
||||||
|
return {
|
||||||
|
resolutions: this.supportedResolutions,
|
||||||
|
formats: this.supportedFormats,
|
||||||
|
colorModes: this.supportedColorModes,
|
||||||
|
sources: this.supportedSources,
|
||||||
|
maxWidth: this.maxWidth,
|
||||||
|
maxHeight: this.maxHeight,
|
||||||
|
minWidth: this.minWidth,
|
||||||
|
minHeight: this.minHeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform a scan
|
||||||
|
*/
|
||||||
|
public async scan(options?: IScanOptions): Promise<IScanResult> {
|
||||||
|
if (!this.isConnected) {
|
||||||
|
throw new Error('Scan feature not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
const opts = this.resolveOptions(options);
|
||||||
|
|
||||||
|
if (this.protocol === 'escl' && this.esclClient) {
|
||||||
|
return this.scanWithEscl(opts);
|
||||||
|
} else if (this.protocol === 'sane' && this.saneClient) {
|
||||||
|
return this.scanWithSane(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('No scanner protocol available');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel an ongoing scan
|
||||||
|
*/
|
||||||
|
public async cancelScan(): Promise<void> {
|
||||||
|
if (this.protocol === 'sane' && this.saneClient) {
|
||||||
|
await this.saneClient.cancel();
|
||||||
|
}
|
||||||
|
// eSCL cancellation is handled by deleting the job
|
||||||
|
this.emit('scan:cancelled');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Protocol-Specific Scanning
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
private async scanWithEscl(options: Required<IScanOptions>): Promise<IScanResult> {
|
||||||
|
if (!this.esclClient) {
|
||||||
|
throw new Error('eSCL client not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('scan:started', options);
|
||||||
|
|
||||||
|
// Use the protocol's scan method which handles job submission,
|
||||||
|
// waiting for completion, and downloading in one operation
|
||||||
|
const result = await this.esclClient.scan({
|
||||||
|
format: options.format,
|
||||||
|
resolution: options.resolution,
|
||||||
|
colorMode: options.colorMode,
|
||||||
|
source: options.source,
|
||||||
|
area: options.area,
|
||||||
|
intent: options.intent,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.emit('scan:completed', result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async scanWithSane(options: Required<IScanOptions>): Promise<IScanResult> {
|
||||||
|
if (!this.saneClient) {
|
||||||
|
throw new Error('SANE client not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('scan:started', options);
|
||||||
|
|
||||||
|
// Use the protocol's scan method which handles option configuration,
|
||||||
|
// parameter retrieval, and image reading in one operation
|
||||||
|
const result = await this.saneClient.scan({
|
||||||
|
format: options.format,
|
||||||
|
resolution: options.resolution,
|
||||||
|
colorMode: options.colorMode,
|
||||||
|
source: options.source,
|
||||||
|
area: options.area,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.emit('scan:completed', result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helper Methods
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
private resolveOptions(options?: IScanOptions): Required<IScanOptions> {
|
||||||
|
return {
|
||||||
|
resolution: options?.resolution ?? 300,
|
||||||
|
format: options?.format ?? 'jpeg',
|
||||||
|
colorMode: options?.colorMode ?? 'color',
|
||||||
|
source: options?.source ?? 'flatbed',
|
||||||
|
area: options?.area ?? {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: this.maxWidth,
|
||||||
|
height: this.maxHeight,
|
||||||
|
},
|
||||||
|
intent: options?.intent ?? 'document',
|
||||||
|
quality: options?.quality ?? 85,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateCapabilitiesFromEscl(caps: any): void {
|
||||||
|
if (caps.platen) {
|
||||||
|
this.supportedResolutions = caps.platen.supportedResolutions || this.supportedResolutions;
|
||||||
|
this.maxWidth = caps.platen.maxWidth || this.maxWidth;
|
||||||
|
this.maxHeight = caps.platen.maxHeight || this.maxHeight;
|
||||||
|
this.minWidth = caps.platen.minWidth || this.minWidth;
|
||||||
|
this.minHeight = caps.platen.minHeight || this.minHeight;
|
||||||
|
}
|
||||||
|
if (caps.adf) {
|
||||||
|
this.hasAdf = true;
|
||||||
|
if (!this.supportedSources.includes('adf')) {
|
||||||
|
this.supportedSources.push('adf');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (caps.adfDuplex) {
|
||||||
|
this.hasDuplex = true;
|
||||||
|
if (!this.supportedSources.includes('adf-duplex')) {
|
||||||
|
this.supportedSources.push('adf-duplex');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private colorModeToSane(mode: TColorMode): string {
|
||||||
|
switch (mode) {
|
||||||
|
case 'color': return 'Color';
|
||||||
|
case 'grayscale': return 'Gray';
|
||||||
|
case 'blackwhite': return 'Lineart';
|
||||||
|
default: return 'Color';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMimeType(format: TScanFormat): string {
|
||||||
|
switch (format) {
|
||||||
|
case 'jpeg': return 'image/jpeg';
|
||||||
|
case 'png': return 'image/png';
|
||||||
|
case 'pdf': return 'application/pdf';
|
||||||
|
case 'tiff': return 'image/tiff';
|
||||||
|
default: return 'application/octet-stream';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Serialization
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
public getFeatureInfo(): IScanFeatureInfo {
|
||||||
|
return {
|
||||||
|
...this.getBaseFeatureInfo(),
|
||||||
|
type: 'scan',
|
||||||
|
protocol: this.protocol,
|
||||||
|
supportedFormats: this.supportedFormats,
|
||||||
|
supportedResolutions: this.supportedResolutions,
|
||||||
|
supportedColorModes: this.supportedColorModes,
|
||||||
|
supportedSources: this.supportedSources,
|
||||||
|
hasAdf: this.hasAdf,
|
||||||
|
hasDuplex: this.hasDuplex,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Static Factory
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create from discovery metadata
|
||||||
|
*/
|
||||||
|
public static fromDiscovery(
|
||||||
|
device: TDeviceReference,
|
||||||
|
port: number,
|
||||||
|
protocol: TScanProtocol,
|
||||||
|
metadata: Record<string, unknown>
|
||||||
|
): ScanFeature {
|
||||||
|
const txtRecords = metadata.txtRecords as Record<string, string> ?? {};
|
||||||
|
|
||||||
|
return new ScanFeature(device, port, {
|
||||||
|
protocol,
|
||||||
|
secure: metadata.secure as boolean ?? port === 443,
|
||||||
|
deviceName: metadata.deviceName as string,
|
||||||
|
supportedFormats: parseFormats(txtRecords),
|
||||||
|
supportedResolutions: parseResolutions(txtRecords),
|
||||||
|
supportedColorModes: parseColorModes(txtRecords),
|
||||||
|
supportedSources: parseSources(txtRecords),
|
||||||
|
hasAdf: Boolean(metadata.hasAdf),
|
||||||
|
hasDuplex: Boolean(metadata.hasDuplex),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Parsing Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function parseFormats(txt: Record<string, string>): TScanFormat[] {
|
||||||
|
const pdl = txt['pdl'] || '';
|
||||||
|
const formats: TScanFormat[] = [];
|
||||||
|
if (pdl.includes('image/jpeg')) formats.push('jpeg');
|
||||||
|
if (pdl.includes('image/png')) formats.push('png');
|
||||||
|
if (pdl.includes('application/pdf')) formats.push('pdf');
|
||||||
|
if (pdl.includes('image/tiff')) formats.push('tiff');
|
||||||
|
return formats.length > 0 ? formats : ['jpeg', 'png', 'pdf'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseResolutions(txt: Record<string, string>): number[] {
|
||||||
|
const rs = txt['rs'] || '';
|
||||||
|
if (!rs) return [75, 150, 300, 600];
|
||||||
|
return rs.split(',')
|
||||||
|
.map((r) => parseInt(r.trim(), 10))
|
||||||
|
.filter((r) => !isNaN(r) && r > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseColorModes(txt: Record<string, string>): TColorMode[] {
|
||||||
|
const cs = txt['cs'] || '';
|
||||||
|
const modes: TColorMode[] = [];
|
||||||
|
if (cs.includes('color') || cs.includes('RGB')) modes.push('color');
|
||||||
|
if (cs.includes('grayscale') || cs.includes('gray')) modes.push('grayscale');
|
||||||
|
if (cs.includes('binary') || cs.includes('lineart')) modes.push('blackwhite');
|
||||||
|
return modes.length > 0 ? modes : ['color', 'grayscale', 'blackwhite'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSources(txt: Record<string, string>): TScanSource[] {
|
||||||
|
const is = txt['is'] || '';
|
||||||
|
const sources: TScanSource[] = [];
|
||||||
|
if (is.includes('platen') || is.includes('flatbed') || !is) sources.push('flatbed');
|
||||||
|
if (is.includes('adf') && is.includes('duplex')) sources.push('adf-duplex');
|
||||||
|
else if (is.includes('adf')) sources.push('adf');
|
||||||
|
return sources.length > 0 ? sources : ['flatbed'];
|
||||||
|
}
|
||||||
235
ts/features/feature.snmp.ts
Normal file
235
ts/features/feature.snmp.ts
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
/**
|
||||||
|
* SNMP Feature
|
||||||
|
* Provides SNMP query capability for network management
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
||||||
|
import type {
|
||||||
|
TSnmpVersion,
|
||||||
|
ISnmpVarbind,
|
||||||
|
ISnmpFeatureInfo,
|
||||||
|
IFeatureOptions,
|
||||||
|
} from '../interfaces/feature.interfaces.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for creating an SnmpFeature
|
||||||
|
*/
|
||||||
|
export interface ISnmpFeatureOptions extends IFeatureOptions {
|
||||||
|
version?: TSnmpVersion;
|
||||||
|
community?: string;
|
||||||
|
// SNMPv3 options
|
||||||
|
username?: string;
|
||||||
|
authProtocol?: 'md5' | 'sha';
|
||||||
|
authKey?: string;
|
||||||
|
privProtocol?: 'des' | 'aes';
|
||||||
|
privKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SNMP Feature - provides SNMP query capability
|
||||||
|
*
|
||||||
|
* Allows querying devices via SNMP for monitoring and management.
|
||||||
|
* Many network devices (printers, switches, UPS) support SNMP.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const snmp = device.getFeature<SnmpFeature>('snmp');
|
||||||
|
* if (snmp) {
|
||||||
|
* const sysDescr = await snmp.get('1.3.6.1.2.1.1.1.0');
|
||||||
|
* console.log(`System: ${sysDescr.value}`);
|
||||||
|
*
|
||||||
|
* const interfaces = await snmp.walk('1.3.6.1.2.1.2.2.1');
|
||||||
|
* console.log(`Found ${interfaces.length} interface entries`);
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class SnmpFeature extends Feature {
|
||||||
|
public readonly type = 'snmp' as const;
|
||||||
|
public readonly protocol = 'snmp';
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
public readonly version: TSnmpVersion;
|
||||||
|
public readonly community: string;
|
||||||
|
|
||||||
|
// System info (populated after connect)
|
||||||
|
public sysDescr?: string;
|
||||||
|
public sysObjectId?: string;
|
||||||
|
public sysName?: string;
|
||||||
|
public sysLocation?: string;
|
||||||
|
public sysContact?: string;
|
||||||
|
public sysUpTime?: number;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
device: TDeviceReference,
|
||||||
|
port: number,
|
||||||
|
options?: ISnmpFeatureOptions
|
||||||
|
) {
|
||||||
|
super(device, port ?? 161, options);
|
||||||
|
this.version = options?.version ?? 'v2c';
|
||||||
|
this.community = options?.community ?? 'public';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Connection
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
protected async doConnect(): Promise<void> {
|
||||||
|
// Test connection by getting system description
|
||||||
|
try {
|
||||||
|
const result = await this.get('1.3.6.1.2.1.1.1.0'); // sysDescr
|
||||||
|
if (result) {
|
||||||
|
this.sysDescr = String(result.value);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Connection test failed
|
||||||
|
throw new Error('SNMP connection failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async doDisconnect(): Promise<void> {
|
||||||
|
// SNMP is connectionless, nothing to disconnect
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SNMP Operations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single OID value
|
||||||
|
*/
|
||||||
|
public async get(oid: string): Promise<ISnmpVarbind> {
|
||||||
|
// This would use the SNMP library to perform the query
|
||||||
|
// Placeholder implementation
|
||||||
|
return {
|
||||||
|
oid,
|
||||||
|
type: 0,
|
||||||
|
value: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get multiple OID values
|
||||||
|
*/
|
||||||
|
public async getMultiple(oids: string[]): Promise<ISnmpVarbind[]> {
|
||||||
|
const results: ISnmpVarbind[] = [];
|
||||||
|
for (const oid of oids) {
|
||||||
|
results.push(await this.get(oid));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get next OID in MIB tree
|
||||||
|
*/
|
||||||
|
public async getNext(oid: string): Promise<ISnmpVarbind> {
|
||||||
|
// Placeholder
|
||||||
|
return {
|
||||||
|
oid,
|
||||||
|
type: 0,
|
||||||
|
value: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk a subtree of the MIB
|
||||||
|
*/
|
||||||
|
public async walk(baseOid: string): Promise<ISnmpVarbind[]> {
|
||||||
|
const results: ISnmpVarbind[] = [];
|
||||||
|
// This would iterate through the MIB tree using getNext
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk get (SNMPv2c/v3)
|
||||||
|
*/
|
||||||
|
public async getBulk(
|
||||||
|
oids: string[],
|
||||||
|
nonRepeaters: number = 0,
|
||||||
|
maxRepetitions: number = 10
|
||||||
|
): Promise<ISnmpVarbind[]> {
|
||||||
|
if (this.version === 'v1') {
|
||||||
|
throw new Error('getBulk not supported in SNMPv1');
|
||||||
|
}
|
||||||
|
// Placeholder
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set an OID value
|
||||||
|
*/
|
||||||
|
public async set(oid: string, type: number, value: unknown): Promise<ISnmpVarbind> {
|
||||||
|
// Placeholder
|
||||||
|
return {
|
||||||
|
oid,
|
||||||
|
type,
|
||||||
|
value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// System Info
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all system info OIDs
|
||||||
|
*/
|
||||||
|
public async getSystemInfo(): Promise<{
|
||||||
|
sysDescr?: string;
|
||||||
|
sysObjectId?: string;
|
||||||
|
sysName?: string;
|
||||||
|
sysLocation?: string;
|
||||||
|
sysContact?: string;
|
||||||
|
sysUpTime?: number;
|
||||||
|
}> {
|
||||||
|
const oids = [
|
||||||
|
'1.3.6.1.2.1.1.1.0', // sysDescr
|
||||||
|
'1.3.6.1.2.1.1.2.0', // sysObjectID
|
||||||
|
'1.3.6.1.2.1.1.3.0', // sysUpTime
|
||||||
|
'1.3.6.1.2.1.1.4.0', // sysContact
|
||||||
|
'1.3.6.1.2.1.1.5.0', // sysName
|
||||||
|
'1.3.6.1.2.1.1.6.0', // sysLocation
|
||||||
|
];
|
||||||
|
|
||||||
|
const results = await this.getMultiple(oids);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sysDescr: results[0]?.value as string,
|
||||||
|
sysObjectId: results[1]?.value as string,
|
||||||
|
sysUpTime: results[2]?.value as number,
|
||||||
|
sysContact: results[3]?.value as string,
|
||||||
|
sysName: results[4]?.value as string,
|
||||||
|
sysLocation: results[5]?.value as string,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Serialization
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
public getFeatureInfo(): ISnmpFeatureInfo {
|
||||||
|
return {
|
||||||
|
...this.getBaseFeatureInfo(),
|
||||||
|
type: 'snmp',
|
||||||
|
version: this.version,
|
||||||
|
community: this.version !== 'v3' ? this.community : undefined,
|
||||||
|
sysDescr: this.sysDescr,
|
||||||
|
sysName: this.sysName,
|
||||||
|
sysLocation: this.sysLocation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Static Factory
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
public static fromDiscovery(
|
||||||
|
device: TDeviceReference,
|
||||||
|
port: number,
|
||||||
|
metadata: Record<string, unknown>
|
||||||
|
): SnmpFeature {
|
||||||
|
return new SnmpFeature(device, port ?? 161, {
|
||||||
|
version: metadata.version as TSnmpVersion ?? 'v2c',
|
||||||
|
community: metadata.community as string ?? 'public',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
256
ts/features/feature.volume.ts
Normal file
256
ts/features/feature.volume.ts
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
/**
|
||||||
|
* Volume Feature
|
||||||
|
* Provides volume control capability
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
||||||
|
import type {
|
||||||
|
IVolumeFeatureInfo,
|
||||||
|
IFeatureOptions,
|
||||||
|
} from '../interfaces/feature.interfaces.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for creating a VolumeFeature
|
||||||
|
*/
|
||||||
|
export interface IVolumeFeatureOptions extends IFeatureOptions {
|
||||||
|
/** Volume control protocol (usually same as device protocol) */
|
||||||
|
volumeProtocol?: string;
|
||||||
|
/** Minimum volume level */
|
||||||
|
minVolume?: number;
|
||||||
|
/** Maximum volume level */
|
||||||
|
maxVolume?: number;
|
||||||
|
/** Volume step increment */
|
||||||
|
volumeStep?: number;
|
||||||
|
/** Whether mute is supported */
|
||||||
|
supportsMute?: boolean;
|
||||||
|
/** Protocol-specific volume controller */
|
||||||
|
volumeController?: IVolumeController;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for protocol-specific volume control
|
||||||
|
*/
|
||||||
|
export interface IVolumeController {
|
||||||
|
getVolume(): Promise<number>;
|
||||||
|
setVolume(level: number): Promise<void>;
|
||||||
|
getMute(): Promise<boolean>;
|
||||||
|
setMute(muted: boolean): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Volume Feature - provides volume control capability
|
||||||
|
*
|
||||||
|
* Separated from PlaybackFeature because some devices have volume control
|
||||||
|
* without playback capability (e.g., amplifiers, HDMI matrix switches).
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const volume = device.getFeature<VolumeFeature>('volume');
|
||||||
|
* if (volume) {
|
||||||
|
* const current = await volume.getVolume();
|
||||||
|
* await volume.setVolume(current + 10);
|
||||||
|
* await volume.toggleMute();
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class VolumeFeature extends Feature {
|
||||||
|
public readonly type = 'volume' as const;
|
||||||
|
public readonly protocol: string;
|
||||||
|
|
||||||
|
// Capabilities
|
||||||
|
public readonly minVolume: number;
|
||||||
|
public readonly maxVolume: number;
|
||||||
|
public readonly volumeStep: number;
|
||||||
|
public readonly supportsMute: boolean;
|
||||||
|
|
||||||
|
// Current state
|
||||||
|
protected _volume: number = 0;
|
||||||
|
protected _muted: boolean = false;
|
||||||
|
|
||||||
|
// Volume controller (protocol-specific)
|
||||||
|
protected volumeController: IVolumeController | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
device: TDeviceReference,
|
||||||
|
port: number,
|
||||||
|
options?: IVolumeFeatureOptions
|
||||||
|
) {
|
||||||
|
super(device, port, options);
|
||||||
|
this.protocol = options?.volumeProtocol ?? 'generic';
|
||||||
|
this.minVolume = options?.minVolume ?? 0;
|
||||||
|
this.maxVolume = options?.maxVolume ?? 100;
|
||||||
|
this.volumeStep = options?.volumeStep ?? 5;
|
||||||
|
this.supportsMute = options?.supportsMute ?? true;
|
||||||
|
|
||||||
|
if (options?.volumeController) {
|
||||||
|
this.volumeController = options.volumeController;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Properties
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current volume level (cached)
|
||||||
|
*/
|
||||||
|
public get volume(): number {
|
||||||
|
return this._volume;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get mute state (cached)
|
||||||
|
*/
|
||||||
|
public get muted(): boolean {
|
||||||
|
return this._muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Connection
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
protected async doConnect(): Promise<void> {
|
||||||
|
// Fetch initial state if we have a controller
|
||||||
|
if (this.volumeController) {
|
||||||
|
try {
|
||||||
|
this._volume = await this.volumeController.getVolume();
|
||||||
|
this._muted = await this.volumeController.getMute();
|
||||||
|
} catch {
|
||||||
|
// Ignore errors fetching initial state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async doDisconnect(): Promise<void> {
|
||||||
|
// Nothing to disconnect for volume control
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Volume Control
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current volume level
|
||||||
|
*/
|
||||||
|
public async getVolume(): Promise<number> {
|
||||||
|
if (this.volumeController) {
|
||||||
|
this._volume = await this.volumeController.getVolume();
|
||||||
|
}
|
||||||
|
return this._volume;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set volume level
|
||||||
|
* @param level Volume level (clamped to min/max)
|
||||||
|
*/
|
||||||
|
public async setVolume(level: number): Promise<void> {
|
||||||
|
const clampedLevel = Math.max(this.minVolume, Math.min(this.maxVolume, level));
|
||||||
|
|
||||||
|
if (this.volumeController) {
|
||||||
|
await this.volumeController.setVolume(clampedLevel);
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldVolume = this._volume;
|
||||||
|
this._volume = clampedLevel;
|
||||||
|
|
||||||
|
if (oldVolume !== clampedLevel) {
|
||||||
|
this.emit('volume:changed', { oldVolume, newVolume: clampedLevel });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increase volume by step
|
||||||
|
*/
|
||||||
|
public async volumeUp(step?: number): Promise<number> {
|
||||||
|
const increment = step ?? this.volumeStep;
|
||||||
|
const newVolume = Math.min(this.maxVolume, this._volume + increment);
|
||||||
|
await this.setVolume(newVolume);
|
||||||
|
return this._volume;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrease volume by step
|
||||||
|
*/
|
||||||
|
public async volumeDown(step?: number): Promise<number> {
|
||||||
|
const decrement = step ?? this.volumeStep;
|
||||||
|
const newVolume = Math.max(this.minVolume, this._volume - decrement);
|
||||||
|
await this.setVolume(newVolume);
|
||||||
|
return this._volume;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get mute state
|
||||||
|
*/
|
||||||
|
public async getMute(): Promise<boolean> {
|
||||||
|
if (this.volumeController && this.supportsMute) {
|
||||||
|
this._muted = await this.volumeController.getMute();
|
||||||
|
}
|
||||||
|
return this._muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set mute state
|
||||||
|
*/
|
||||||
|
public async setMute(muted: boolean): Promise<void> {
|
||||||
|
if (!this.supportsMute) {
|
||||||
|
throw new Error('Mute not supported');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.volumeController) {
|
||||||
|
await this.volumeController.setMute(muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldMuted = this._muted;
|
||||||
|
this._muted = muted;
|
||||||
|
|
||||||
|
if (oldMuted !== muted) {
|
||||||
|
this.emit('mute:changed', { oldMuted, newMuted: muted });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle mute state
|
||||||
|
*/
|
||||||
|
public async toggleMute(): Promise<boolean> {
|
||||||
|
const currentMuted = await this.getMute();
|
||||||
|
await this.setMute(!currentMuted);
|
||||||
|
return this._muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Serialization
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
public getFeatureInfo(): IVolumeFeatureInfo {
|
||||||
|
return {
|
||||||
|
...this.getBaseFeatureInfo(),
|
||||||
|
type: 'volume',
|
||||||
|
minVolume: this.minVolume,
|
||||||
|
maxVolume: this.maxVolume,
|
||||||
|
volumeStep: this.volumeStep,
|
||||||
|
supportsMute: this.supportsMute,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Static Factory
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create from discovery metadata
|
||||||
|
*/
|
||||||
|
public static fromDiscovery(
|
||||||
|
device: TDeviceReference,
|
||||||
|
port: number,
|
||||||
|
protocol: string,
|
||||||
|
metadata: Record<string, unknown>
|
||||||
|
): VolumeFeature {
|
||||||
|
return new VolumeFeature(device, port, {
|
||||||
|
volumeProtocol: protocol,
|
||||||
|
minVolume: metadata.minVolume as number ?? 0,
|
||||||
|
maxVolume: metadata.maxVolume as number ?? 100,
|
||||||
|
volumeStep: metadata.volumeStep as number ?? 5,
|
||||||
|
supportsMute: metadata.supportsMute as boolean ?? true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
15
ts/features/index.ts
Normal file
15
ts/features/index.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Features Module
|
||||||
|
* Exports all feature classes and types
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Abstract base
|
||||||
|
export { Feature, type TDeviceReference } from './feature.abstract.js';
|
||||||
|
|
||||||
|
// Concrete features
|
||||||
|
export { ScanFeature, type IScanFeatureOptions } from './feature.scan.js';
|
||||||
|
export { PrintFeature, type IPrintFeatureOptions } from './feature.print.js';
|
||||||
|
export { PlaybackFeature, type IPlaybackFeatureOptions } from './feature.playback.js';
|
||||||
|
export { VolumeFeature, type IVolumeFeatureOptions, type IVolumeController } from './feature.volume.js';
|
||||||
|
export { PowerFeature, type IPowerFeatureOptions } from './feature.power.js';
|
||||||
|
export { SnmpFeature, type ISnmpFeatureOptions } from './feature.snmp.js';
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
* IP Range utility functions for network scanning
|
* IP Range utility functions for network scanning
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import * as os from 'os';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates an IPv4 address
|
* Validates an IPv4 address
|
||||||
*/
|
*/
|
||||||
@@ -115,7 +117,6 @@ export function cidrToIps(cidr: string): string[] {
|
|||||||
*/
|
*/
|
||||||
export function getLocalSubnet(): string | null {
|
export function getLocalSubnet(): string | null {
|
||||||
try {
|
try {
|
||||||
const os = require('os');
|
|
||||||
const interfaces = os.networkInterfaces();
|
const interfaces = os.networkInterfaces();
|
||||||
|
|
||||||
for (const name of Object.keys(interfaces)) {
|
for (const name of Object.keys(interfaces)) {
|
||||||
@@ -146,7 +147,7 @@ export function getLocalSubnet(): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// os module might not be available
|
// Failed to get network interfaces
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
142
ts/index.ts
142
ts/index.ts
@@ -1,24 +1,78 @@
|
|||||||
/**
|
/**
|
||||||
* @push.rocks/devicemanager
|
* @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 {
|
export {
|
||||||
DeviceManager,
|
DeviceManager,
|
||||||
MdnsDiscovery,
|
MdnsDiscovery,
|
||||||
NetworkScanner,
|
NetworkScanner,
|
||||||
|
SsdpDiscovery,
|
||||||
Scanner,
|
Scanner,
|
||||||
Printer,
|
Printer,
|
||||||
|
SnmpDevice,
|
||||||
|
UpsDevice,
|
||||||
|
DlnaRenderer,
|
||||||
|
DlnaServer,
|
||||||
|
Speaker,
|
||||||
|
SonosSpeaker,
|
||||||
|
AirPlaySpeaker,
|
||||||
|
ChromecastSpeaker,
|
||||||
SERVICE_TYPES,
|
SERVICE_TYPES,
|
||||||
|
SSDP_SERVICE_TYPES,
|
||||||
} from './devicemanager.classes.devicemanager.js';
|
} from './devicemanager.classes.devicemanager.js';
|
||||||
|
|
||||||
// Abstract/base classes
|
// Abstract/base classes
|
||||||
export { Device } from './abstract/device.abstract.js';
|
export { Device } from './abstract/device.abstract.js';
|
||||||
|
|
||||||
// Protocol implementations
|
// Universal Device & Features (new architecture)
|
||||||
export { EsclProtocol, SaneProtocol } from './scanner/scanner.classes.scanner.js';
|
export { UniversalDevice } from './device/device.classes.device.js';
|
||||||
export { IppProtocol } from './printer/printer.classes.printer.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
|
// Helpers
|
||||||
export { withRetry, createRetryable, defaultRetryOptions } from './helpers/helpers.retry.js';
|
export { withRetry, createRetryable, defaultRetryOptions } from './helpers/helpers.retry.js';
|
||||||
@@ -34,3 +88,81 @@ export {
|
|||||||
|
|
||||||
// All interfaces and types
|
// All interfaces and types
|
||||||
export * from './interfaces/index.js';
|
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';
|
||||||
|
|||||||
346
ts/interfaces/feature.interfaces.ts
Normal file
346
ts/interfaces/feature.interfaces.ts
Normal file
@@ -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<void>;
|
||||||
|
/** Disconnect from the feature endpoint */
|
||||||
|
disconnect(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for creating a feature instance
|
||||||
|
*/
|
||||||
|
export interface IFeatureOptions {
|
||||||
|
retryOptions?: IRetryOptions;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ export type TConnectionState = 'disconnected' | 'connecting' | 'connected' | 'er
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export type TScannerProtocol = 'sane' | 'escl';
|
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 TColorMode = 'color' | 'grayscale' | 'blackwhite';
|
||||||
export type TScanSource = 'flatbed' | 'adf' | 'adf-duplex';
|
export type TScanSource = 'flatbed' | 'adf' | 'adf-duplex';
|
||||||
|
|
||||||
@@ -179,7 +179,7 @@ export interface IDiscoveredDevice {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
type: TDeviceType;
|
type: TDeviceType;
|
||||||
protocol: TScannerProtocol | 'ipp';
|
protocol: string; // 'escl' | 'sane' | 'ipp' | 'airplay' | 'sonos' | 'chromecast' | etc.
|
||||||
address: string;
|
address: string;
|
||||||
port: number;
|
port: number;
|
||||||
txtRecords: Record<string, string>;
|
txtRecords: Record<string, string>;
|
||||||
@@ -321,7 +321,7 @@ export interface INetworkScanOptions {
|
|||||||
concurrency?: number;
|
concurrency?: number;
|
||||||
/** Timeout per probe in milliseconds (default: 2000) */
|
/** Timeout per probe in milliseconds (default: 2000) */
|
||||||
timeout?: number;
|
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[];
|
ports?: number[];
|
||||||
/** Check for eSCL scanners (default: true) */
|
/** Check for eSCL scanners (default: true) */
|
||||||
probeEscl?: boolean;
|
probeEscl?: boolean;
|
||||||
@@ -329,11 +329,17 @@ export interface INetworkScanOptions {
|
|||||||
probeIpp?: boolean;
|
probeIpp?: boolean;
|
||||||
/** Check for SANE scanners (default: true) */
|
/** Check for SANE scanners (default: true) */
|
||||||
probeSane?: boolean;
|
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 {
|
export interface INetworkScanDevice {
|
||||||
type: 'scanner' | 'printer';
|
type: 'scanner' | 'printer' | 'speaker';
|
||||||
protocol: 'escl' | 'sane' | 'ipp' | 'jetdirect';
|
protocol: 'escl' | 'sane' | 'ipp' | 'jetdirect' | 'airplay' | 'sonos' | 'chromecast';
|
||||||
port: number;
|
port: number;
|
||||||
name?: string;
|
name?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
@@ -364,3 +370,9 @@ export type TNetworkScannerEvents = {
|
|||||||
'error': (error: Error) => void;
|
'error': (error: Error) => void;
|
||||||
'cancelled': () => void;
|
'cancelled': () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Feature Types (Universal Device Architecture)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export * from './feature.interfaces.js';
|
||||||
|
|||||||
@@ -26,9 +26,15 @@ export {
|
|||||||
// third party
|
// third party
|
||||||
import * as bonjourService from 'bonjour-service';
|
import * as bonjourService from 'bonjour-service';
|
||||||
import ipp from 'ipp';
|
import ipp from 'ipp';
|
||||||
import * as nodeSsdp from 'node-ssdp';
|
import nodeSsdpModule from 'node-ssdp';
|
||||||
import * as netSnmp from 'net-snmp';
|
import * as netSnmp from 'net-snmp';
|
||||||
import * as sonos from 'sonos';
|
import * as sonos from 'sonos';
|
||||||
import * as castv2Client from 'castv2-client';
|
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 };
|
export { bonjourService, ipp, nodeSsdp, netSnmp, sonos, castv2Client };
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const FORMAT_MIME_MAP: Record<TScanFormat, string> = {
|
|||||||
jpeg: 'image/jpeg',
|
jpeg: 'image/jpeg',
|
||||||
png: 'image/png',
|
png: 'image/png',
|
||||||
pdf: 'application/pdf',
|
pdf: 'application/pdf',
|
||||||
|
tiff: 'image/tiff',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -152,7 +152,7 @@ export class NutProtocol {
|
|||||||
reject(err);
|
reject(err);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.socket.on('data', (data) => {
|
this.socket.on('data', (data: Buffer) => {
|
||||||
this.handleData(data);
|
this.handleData(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
627
ts/protocols/protocol.upnp.ts
Normal file
627
ts/protocols/protocol.upnp.ts
Normal file
@@ -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<string, string | number> = {}
|
||||||
|
): Promise<string> {
|
||||||
|
// Build SOAP body
|
||||||
|
let argsXml = '';
|
||||||
|
for (const [key, value] of Object.entries(args)) {
|
||||||
|
const escapedValue = this.escapeXml(String(value));
|
||||||
|
argsXml += `<${key}>${escapedValue}</${key}>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const soapBody = `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
||||||
|
<s:Body>
|
||||||
|
<u:${action} xmlns:u="${serviceType}">
|
||||||
|
${argsXml}
|
||||||
|
</u:${action}>
|
||||||
|
</s:Body>
|
||||||
|
</s:Envelope>`;
|
||||||
|
|
||||||
|
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, '"')
|
||||||
|
.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]*?)</${tag}>`, 'i');
|
||||||
|
const match = xml.match(regex);
|
||||||
|
return match ? match[1].trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract multiple values from SOAP response
|
||||||
|
*/
|
||||||
|
public extractValues(xml: string, tags: string[]): Record<string, string> {
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'Play', {
|
||||||
|
InstanceID: 0,
|
||||||
|
Speed: speed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause
|
||||||
|
*/
|
||||||
|
public async pause(controlUrl: string): Promise<void> {
|
||||||
|
await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'Pause', {
|
||||||
|
InstanceID: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop
|
||||||
|
*/
|
||||||
|
public async stop(controlUrl: string): Promise<void> {
|
||||||
|
await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'Stop', {
|
||||||
|
InstanceID: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seek
|
||||||
|
*/
|
||||||
|
public async seek(controlUrl: string, target: string, unit: string = 'REL_TIME'): Promise<void> {
|
||||||
|
await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'Seek', {
|
||||||
|
InstanceID: 0,
|
||||||
|
Unit: unit,
|
||||||
|
Target: target,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Next track
|
||||||
|
*/
|
||||||
|
public async next(controlUrl: string): Promise<void> {
|
||||||
|
await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'Next', {
|
||||||
|
InstanceID: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Previous track
|
||||||
|
*/
|
||||||
|
public async previous(controlUrl: string): Promise<void> {
|
||||||
|
await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'Previous', {
|
||||||
|
InstanceID: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get position info
|
||||||
|
*/
|
||||||
|
public async getPositionInfo(controlUrl: string): Promise<IDlnaPositionInfo> {
|
||||||
|
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<IDlnaTransportInfo> {
|
||||||
|
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<IDlnaMediaInfo> {
|
||||||
|
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<number> {
|
||||||
|
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<void> {
|
||||||
|
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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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<IDlnaBrowseResult> {
|
||||||
|
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<IDlnaBrowseResult> {
|
||||||
|
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[^>]*>([^<]*)<\/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(/>([^<]+)</);
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: urlMatch?.[1] || '',
|
||||||
|
protocolInfo,
|
||||||
|
size: size ? parseInt(size) : undefined,
|
||||||
|
duration,
|
||||||
|
resolution,
|
||||||
|
bitrate: bitrate ? parseInt(bitrate) : undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract optional metadata
|
||||||
|
const albumArt = this.extractTagContent(elementXml, 'upnp:albumArtURI');
|
||||||
|
if (albumArt) item.albumArtUri = albumArt;
|
||||||
|
|
||||||
|
const artist = this.extractTagContent(elementXml, 'dc:creator') ||
|
||||||
|
this.extractTagContent(elementXml, 'upnp:artist');
|
||||||
|
if (artist) item.artist = artist;
|
||||||
|
|
||||||
|
const album = this.extractTagContent(elementXml, 'upnp:album');
|
||||||
|
if (album) item.album = album;
|
||||||
|
|
||||||
|
const genre = this.extractTagContent(elementXml, 'upnp:genre');
|
||||||
|
if (genre) item.genre = genre;
|
||||||
|
|
||||||
|
const date = this.extractTagContent(elementXml, 'dc:date');
|
||||||
|
if (date) item.date = date;
|
||||||
|
|
||||||
|
items.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract content from XML tag (handles namespaced tags)
|
||||||
|
*/
|
||||||
|
private extractTagContent(xml: string, tag: string): string {
|
||||||
|
// Handle both with and without namespace prefix
|
||||||
|
const tagName = tag.includes(':') ? tag.split(':')[1] : tag;
|
||||||
|
const regex = new RegExp(`<(?:[^:]*:)?${tagName}[^>]*>([^<]*)<\/(?:[^:]*:)?${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 `<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">
|
||||||
|
<item id="0" parentID="-1" restricted="1">
|
||||||
|
<dc:title>${this.escapeXml(title)}</dc:title>
|
||||||
|
<upnp:class>object.item.videoItem</upnp:class>
|
||||||
|
<res protocolInfo="${protocolInfo}">${this.escapeXml(url)}</res>
|
||||||
|
</item>
|
||||||
|
</DIDL-Lite>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
548
ts/speaker/speaker.classes.airplay.ts
Normal file
548
ts/speaker/speaker.classes.airplay.ts
Normal file
@@ -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<void> {
|
||||||
|
// Try /server-info endpoint (works for older AirPlay devices)
|
||||||
|
const url = `http://${this.address}:${this.port}/server-info`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
signal: AbortSignal.timeout(3000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// Parse server info (plist format)
|
||||||
|
const text = await response.text();
|
||||||
|
|
||||||
|
// Extract features if available
|
||||||
|
const featuresMatch = text.match(/<key>features<\/key>\s*<integer>(\d+)<\/integer>/);
|
||||||
|
if (featuresMatch) {
|
||||||
|
this._features = parseInt(featuresMatch[1]);
|
||||||
|
this._supportsVideo = !!(this._features & AIRPLAY_FEATURES.Video);
|
||||||
|
this._supportsAudio = !!(this._features & AIRPLAY_FEATURES.Audio);
|
||||||
|
this._supportsScreen = !!(this._features & AIRPLAY_FEATURES.Screen);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract device ID
|
||||||
|
const deviceIdMatch = text.match(/<key>deviceid<\/key>\s*<string>([^<]+)<\/string>/);
|
||||||
|
if (deviceIdMatch) {
|
||||||
|
this._deviceId = deviceIdMatch[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract model
|
||||||
|
const modelMatch = text.match(/<key>model<\/key>\s*<string>([^<]+)<\/string>/);
|
||||||
|
if (modelMatch) {
|
||||||
|
this._modelName = modelMatch[1];
|
||||||
|
this.model = modelMatch[1];
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Non-OK response - might be AirPlay 2, continue below
|
||||||
|
} catch {
|
||||||
|
// /server-info failed, might be AirPlay 2 device
|
||||||
|
}
|
||||||
|
|
||||||
|
// For AirPlay 2 devices (HomePods), /server-info doesn't work
|
||||||
|
// Try a simple port check - if the port responds, consider it connected
|
||||||
|
// HomePods will respond to proper AirPlay 2 protocol even if HTTP endpoints fail
|
||||||
|
// We'll assume it's an AirPlay 2 audio device
|
||||||
|
this._supportsAudio = true;
|
||||||
|
this._supportsVideo = false;
|
||||||
|
this._supportsScreen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect
|
||||||
|
*/
|
||||||
|
protected async doDisconnect(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.stop();
|
||||||
|
} catch {
|
||||||
|
// Ignore stop errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh status
|
||||||
|
*/
|
||||||
|
public async refreshStatus(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const info = await this.getAirPlayPlaybackInfo();
|
||||||
|
this._isPlaying = info.rate > 0;
|
||||||
|
this._currentPosition = info.position;
|
||||||
|
this._currentDuration = info.duration;
|
||||||
|
this._playbackState = this._isPlaying ? 'playing' : 'paused';
|
||||||
|
} catch {
|
||||||
|
this._playbackState = 'stopped';
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('status:updated', this.getSpeakerInfo());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Playback Control
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play media URL
|
||||||
|
*/
|
||||||
|
public async play(uri?: string): Promise<void> {
|
||||||
|
if (uri) {
|
||||||
|
this._currentUri = uri;
|
||||||
|
|
||||||
|
const body = `Content-Location: ${uri}\nStart-Position: 0\n`;
|
||||||
|
|
||||||
|
const response = await fetch(`http://${this.address}:${this.port}/play`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/parameters',
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Play failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Resume playback
|
||||||
|
await this.setRate(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._isPlaying = true;
|
||||||
|
this._playbackState = 'playing';
|
||||||
|
this.emit('playback:started');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause playback
|
||||||
|
*/
|
||||||
|
public async pause(): Promise<void> {
|
||||||
|
await this.setRate(0);
|
||||||
|
this._isPlaying = false;
|
||||||
|
this._playbackState = 'paused';
|
||||||
|
this.emit('playback:paused');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop playback
|
||||||
|
*/
|
||||||
|
public async stop(): Promise<void> {
|
||||||
|
const response = await fetch(`http://${this.address}:${this.port}/stop`, {
|
||||||
|
method: 'POST',
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Stop failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._isPlaying = false;
|
||||||
|
this._playbackState = 'stopped';
|
||||||
|
this._currentUri = undefined;
|
||||||
|
this.emit('playback:stopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Next track (not supported on basic AirPlay)
|
||||||
|
*/
|
||||||
|
public async next(): Promise<void> {
|
||||||
|
throw new Error('Next track not supported on AirPlay');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Previous track (not supported on basic AirPlay)
|
||||||
|
*/
|
||||||
|
public async previous(): Promise<void> {
|
||||||
|
throw new Error('Previous track not supported on AirPlay');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seek to position
|
||||||
|
*/
|
||||||
|
public async seek(seconds: number): Promise<void> {
|
||||||
|
const body = `position: ${seconds}\n`;
|
||||||
|
|
||||||
|
const response = await fetch(`http://${this.address}:${this.port}/scrub`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/parameters',
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Seek failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._currentPosition = seconds;
|
||||||
|
this.emit('playback:seeked', { position: seconds });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Volume Control (limited support)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get volume (not always supported)
|
||||||
|
*/
|
||||||
|
public async getVolume(): Promise<number> {
|
||||||
|
// AirPlay volume control varies by device
|
||||||
|
return this._volume;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set volume (not always supported)
|
||||||
|
*/
|
||||||
|
public async setVolume(level: number): Promise<void> {
|
||||||
|
const clamped = Math.max(0, Math.min(100, level));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = `volume: ${clamped / 100}\n`;
|
||||||
|
|
||||||
|
const response = await fetch(`http://${this.address}:${this.port}/volume`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/parameters',
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
this._volume = clamped;
|
||||||
|
this.emit('volume:changed', { volume: clamped });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Volume control may not be supported
|
||||||
|
throw new Error('Volume control not supported on this device');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get mute state (not always supported)
|
||||||
|
*/
|
||||||
|
public async getMute(): Promise<boolean> {
|
||||||
|
return this._muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set mute state (not always supported)
|
||||||
|
*/
|
||||||
|
public async setMute(muted: boolean): Promise<void> {
|
||||||
|
// Mute by setting volume to 0
|
||||||
|
if (muted) {
|
||||||
|
await this.setVolume(0);
|
||||||
|
} else {
|
||||||
|
await this.setVolume(this._volume || 50);
|
||||||
|
}
|
||||||
|
this._muted = muted;
|
||||||
|
this.emit('mute:changed', { muted });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Track Information
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current track
|
||||||
|
*/
|
||||||
|
public async getCurrentTrack(): Promise<ITrackInfo | null> {
|
||||||
|
if (!this._currentUri) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: this._currentUri.split('/').pop() || 'Unknown',
|
||||||
|
duration: this._currentDuration,
|
||||||
|
position: this._currentPosition,
|
||||||
|
uri: this._currentUri,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get playback status
|
||||||
|
*/
|
||||||
|
public async getPlaybackStatus(): Promise<IPlaybackStatus> {
|
||||||
|
await this.refreshStatus();
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: this._playbackState,
|
||||||
|
volume: this._volume,
|
||||||
|
muted: this._muted,
|
||||||
|
track: await this.getCurrentTrack() || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AirPlay-specific Methods
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set playback rate
|
||||||
|
*/
|
||||||
|
private async setRate(rate: number): Promise<void> {
|
||||||
|
const body = `value: ${rate}\n`;
|
||||||
|
|
||||||
|
const response = await fetch(`http://${this.address}:${this.port}/rate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/parameters',
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Set rate failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get AirPlay playback info
|
||||||
|
*/
|
||||||
|
public async getAirPlayPlaybackInfo(): Promise<IAirPlayPlaybackInfo> {
|
||||||
|
const response = await fetch(`http://${this.address}:${this.port}/playback-info`, {
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Get playback info failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
|
||||||
|
// Parse plist response
|
||||||
|
const extractReal = (key: string): number => {
|
||||||
|
const match = text.match(new RegExp(`<key>${key}</key>\\s*<real>([\\d.]+)</real>`));
|
||||||
|
return match ? parseFloat(match[1]) : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractBool = (key: string): boolean => {
|
||||||
|
const match = text.match(new RegExp(`<key>${key}</key>\\s*<(true|false)/>`));
|
||||||
|
return match?.[1] === 'true';
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
duration: extractReal('duration'),
|
||||||
|
position: extractReal('position'),
|
||||||
|
rate: extractReal('rate'),
|
||||||
|
readyToPlay: extractBool('readyToPlay'),
|
||||||
|
playbackBufferEmpty: extractBool('playbackBufferEmpty'),
|
||||||
|
playbackBufferFull: extractBool('playbackBufferFull'),
|
||||||
|
playbackLikelyToKeepUp: extractBool('playbackLikelyToKeepUp'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get scrub position
|
||||||
|
*/
|
||||||
|
public async getScrubPosition(): Promise<{ position: number; duration: number }> {
|
||||||
|
const response = await fetch(`http://${this.address}:${this.port}/scrub`, {
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Get scrub position failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
|
||||||
|
const durationMatch = text.match(/duration:\s*([\d.]+)/);
|
||||||
|
const positionMatch = text.match(/position:\s*([\d.]+)/);
|
||||||
|
|
||||||
|
return {
|
||||||
|
duration: durationMatch ? parseFloat(durationMatch[1]) : 0,
|
||||||
|
position: positionMatch ? parseFloat(positionMatch[1]) : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Device Info
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get speaker info
|
||||||
|
*/
|
||||||
|
public getSpeakerInfo(): IAirPlaySpeakerInfo {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
name: this.name,
|
||||||
|
type: 'speaker',
|
||||||
|
address: this.address,
|
||||||
|
port: this.port,
|
||||||
|
status: this.status,
|
||||||
|
protocol: 'airplay',
|
||||||
|
roomName: this._roomName,
|
||||||
|
modelName: this._modelName,
|
||||||
|
features: this._features,
|
||||||
|
supportsVideo: this._supportsVideo,
|
||||||
|
supportsAudio: this._supportsAudio,
|
||||||
|
supportsScreen: this._supportsScreen,
|
||||||
|
deviceId: this._deviceId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create from mDNS discovery
|
||||||
|
*/
|
||||||
|
public static fromDiscovery(
|
||||||
|
data: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
port?: number;
|
||||||
|
roomName?: string;
|
||||||
|
modelName?: string;
|
||||||
|
features?: number;
|
||||||
|
deviceId?: string;
|
||||||
|
},
|
||||||
|
retryOptions?: IRetryOptions
|
||||||
|
): AirPlaySpeaker {
|
||||||
|
const info: IDeviceInfo = {
|
||||||
|
id: data.id,
|
||||||
|
name: data.name,
|
||||||
|
type: 'speaker',
|
||||||
|
address: data.address,
|
||||||
|
port: data.port ?? 7000,
|
||||||
|
status: 'unknown',
|
||||||
|
};
|
||||||
|
|
||||||
|
return new AirPlaySpeaker(
|
||||||
|
info,
|
||||||
|
{
|
||||||
|
roomName: data.roomName,
|
||||||
|
modelName: data.modelName,
|
||||||
|
features: data.features,
|
||||||
|
deviceId: data.deviceId,
|
||||||
|
},
|
||||||
|
retryOptions
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Probe for AirPlay device
|
||||||
|
*/
|
||||||
|
public static async probe(address: string, port: number = 7000, timeout: number = 3000): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://${address}:${port}/server-info`, {
|
||||||
|
signal: AbortSignal.timeout(timeout),
|
||||||
|
});
|
||||||
|
return response.ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
725
ts/speaker/speaker.classes.chromecast.ts
Normal file
725
ts/speaker/speaker.classes.chromecast.ts
Normal file
@@ -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<typeof plugins.castv2Client.Client> | null = null;
|
||||||
|
private player: unknown = null;
|
||||||
|
|
||||||
|
private _friendlyName: string = '';
|
||||||
|
private _deviceType: TChromecastType = 'audio';
|
||||||
|
private _capabilities: string[] = [];
|
||||||
|
private _currentAppId?: string;
|
||||||
|
private _currentAppName?: string;
|
||||||
|
private _mediaSessionId?: number;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
info: IDeviceInfo,
|
||||||
|
options?: {
|
||||||
|
roomName?: string;
|
||||||
|
modelName?: string;
|
||||||
|
friendlyName?: string;
|
||||||
|
deviceType?: TChromecastType;
|
||||||
|
capabilities?: string[];
|
||||||
|
},
|
||||||
|
retryOptions?: IRetryOptions
|
||||||
|
) {
|
||||||
|
super(info, 'chromecast', options, retryOptions);
|
||||||
|
this._friendlyName = options?.friendlyName || info.name;
|
||||||
|
this._deviceType = options?.deviceType || 'audio';
|
||||||
|
this._capabilities = options?.capabilities || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
public get friendlyName(): string {
|
||||||
|
return this._friendlyName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get deviceType(): TChromecastType {
|
||||||
|
return this._deviceType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get capabilities(): string[] {
|
||||||
|
return this._capabilities;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get currentAppId(): string | undefined {
|
||||||
|
return this._currentAppId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get currentAppName(): string | undefined {
|
||||||
|
return this._currentAppName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to Chromecast
|
||||||
|
*/
|
||||||
|
protected async doConnect(): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.client = new plugins.castv2Client.Client();
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
if (this.client) {
|
||||||
|
this.client.close();
|
||||||
|
this.client = null;
|
||||||
|
}
|
||||||
|
reject(new Error('Connection timeout'));
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
this.client.on('error', (err: Error) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
if (this.client) {
|
||||||
|
this.client.close();
|
||||||
|
this.client = null;
|
||||||
|
}
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.connect(this.address, () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
|
||||||
|
// Get receiver status
|
||||||
|
this.client!.getStatus((err: Error | null, status: { applications?: Array<{ appId: string; displayName: string }> }) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status && status.applications && status.applications.length > 0) {
|
||||||
|
const app = status.applications[0];
|
||||||
|
this._currentAppId = app.appId;
|
||||||
|
this._currentAppName = app.displayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect
|
||||||
|
*/
|
||||||
|
protected async doDisconnect(): Promise<void> {
|
||||||
|
if (this.client) {
|
||||||
|
this.client.close();
|
||||||
|
this.client = null;
|
||||||
|
}
|
||||||
|
this.player = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh status
|
||||||
|
*/
|
||||||
|
public async refreshStatus(): Promise<void> {
|
||||||
|
if (!this.client) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
this.client!.getStatus((err: Error | null, status: {
|
||||||
|
applications?: Array<{ appId: string; displayName: string }>;
|
||||||
|
volume?: { level: number; muted: boolean };
|
||||||
|
}) => {
|
||||||
|
if (!err && status) {
|
||||||
|
if (status.applications && status.applications.length > 0) {
|
||||||
|
const app = status.applications[0];
|
||||||
|
this._currentAppId = app.appId;
|
||||||
|
this._currentAppName = app.displayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.volume) {
|
||||||
|
this._volume = Math.round(status.volume.level * 100);
|
||||||
|
this._muted = status.volume.muted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('status:updated', this.getSpeakerInfo());
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launch media receiver and get player
|
||||||
|
*/
|
||||||
|
private async getMediaPlayer(): Promise<InstanceType<typeof plugins.castv2Client.DefaultMediaReceiver>> {
|
||||||
|
if (!this.client) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.client!.launch(plugins.castv2Client.DefaultMediaReceiver, (err: Error | null, player: InstanceType<typeof plugins.castv2Client.DefaultMediaReceiver>) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.player = player;
|
||||||
|
|
||||||
|
player.on('status', (status: IChromecastMediaStatus) => {
|
||||||
|
this.handleMediaStatus(status);
|
||||||
|
});
|
||||||
|
|
||||||
|
resolve(player);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle media status update
|
||||||
|
*/
|
||||||
|
private handleMediaStatus(status: IChromecastMediaStatus): void {
|
||||||
|
if (!status) return;
|
||||||
|
|
||||||
|
this._mediaSessionId = status.mediaSessionId;
|
||||||
|
|
||||||
|
// Update playback state
|
||||||
|
switch (status.playerState) {
|
||||||
|
case 'PLAYING':
|
||||||
|
this._playbackState = 'playing';
|
||||||
|
break;
|
||||||
|
case 'PAUSED':
|
||||||
|
this._playbackState = 'paused';
|
||||||
|
break;
|
||||||
|
case 'BUFFERING':
|
||||||
|
this._playbackState = 'transitioning';
|
||||||
|
break;
|
||||||
|
case 'IDLE':
|
||||||
|
default:
|
||||||
|
this._playbackState = 'stopped';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update volume
|
||||||
|
if (status.volume) {
|
||||||
|
this._volume = Math.round(status.volume.level * 100);
|
||||||
|
this._muted = status.volume.muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('playback:status', status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Playback Control
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play media URL
|
||||||
|
*/
|
||||||
|
public async play(uri?: string): Promise<void> {
|
||||||
|
if (!this.client) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
const player = await this.getMediaPlayer() as InstanceType<typeof plugins.castv2Client.DefaultMediaReceiver>;
|
||||||
|
|
||||||
|
if (uri) {
|
||||||
|
// Determine content type
|
||||||
|
const contentType = this.guessContentType(uri);
|
||||||
|
|
||||||
|
const media = {
|
||||||
|
contentId: uri,
|
||||||
|
contentType,
|
||||||
|
streamType: 'BUFFERED' as const,
|
||||||
|
metadata: {
|
||||||
|
type: 0,
|
||||||
|
metadataType: 0,
|
||||||
|
title: uri.split('/').pop() || 'Media',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
player.load(media, { autoplay: true }, (err: Error | null) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._playbackState = 'playing';
|
||||||
|
this.emit('playback:started');
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Resume playback
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
player.play((err: Error | null) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._playbackState = 'playing';
|
||||||
|
this.emit('playback:started');
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause playback
|
||||||
|
*/
|
||||||
|
public async pause(): Promise<void> {
|
||||||
|
if (!this.player) {
|
||||||
|
throw new Error('No active media session');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
(this.player as InstanceType<typeof plugins.castv2Client.DefaultMediaReceiver>).pause((err: Error | null) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._playbackState = 'paused';
|
||||||
|
this.emit('playback:paused');
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop playback
|
||||||
|
*/
|
||||||
|
public async stop(): Promise<void> {
|
||||||
|
if (!this.player) {
|
||||||
|
throw new Error('No active media session');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
(this.player as InstanceType<typeof plugins.castv2Client.DefaultMediaReceiver>).stop((err: Error | null) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._playbackState = 'stopped';
|
||||||
|
this.emit('playback:stopped');
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Next track (not supported)
|
||||||
|
*/
|
||||||
|
public async next(): Promise<void> {
|
||||||
|
throw new Error('Next track not supported on basic Chromecast');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Previous track (not supported)
|
||||||
|
*/
|
||||||
|
public async previous(): Promise<void> {
|
||||||
|
throw new Error('Previous track not supported on basic Chromecast');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seek to position
|
||||||
|
*/
|
||||||
|
public async seek(seconds: number): Promise<void> {
|
||||||
|
if (!this.player) {
|
||||||
|
throw new Error('No active media session');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
(this.player as InstanceType<typeof plugins.castv2Client.DefaultMediaReceiver>).seek(seconds, (err: Error | null) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('playback:seeked', { position: seconds });
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Volume Control
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get volume
|
||||||
|
*/
|
||||||
|
public async getVolume(): Promise<number> {
|
||||||
|
await this.refreshStatus();
|
||||||
|
return this._volume;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set volume
|
||||||
|
*/
|
||||||
|
public async setVolume(level: number): Promise<void> {
|
||||||
|
if (!this.client) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
const clamped = Math.max(0, Math.min(100, level));
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.client!.setVolume({ level: clamped / 100 }, (err: Error | null) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._volume = clamped;
|
||||||
|
this.emit('volume:changed', { volume: clamped });
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get mute state
|
||||||
|
*/
|
||||||
|
public async getMute(): Promise<boolean> {
|
||||||
|
await this.refreshStatus();
|
||||||
|
return this._muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set mute state
|
||||||
|
*/
|
||||||
|
public async setMute(muted: boolean): Promise<void> {
|
||||||
|
if (!this.client) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.client!.setVolume({ muted }, (err: Error | null) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._muted = muted;
|
||||||
|
this.emit('mute:changed', { muted });
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Track Information
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current track
|
||||||
|
*/
|
||||||
|
public async getCurrentTrack(): Promise<ITrackInfo | null> {
|
||||||
|
if (!this.player) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
(this.player as InstanceType<typeof plugins.castv2Client.DefaultMediaReceiver>).getStatus((err: Error | null, status: IChromecastMediaStatus) => {
|
||||||
|
if (err || !status || !status.media) {
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const media = status.media;
|
||||||
|
const metadata = media.metadata;
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
title: metadata?.title || 'Unknown',
|
||||||
|
artist: metadata?.artist,
|
||||||
|
album: metadata?.albumName,
|
||||||
|
duration: media.duration || 0,
|
||||||
|
position: status.currentTime || 0,
|
||||||
|
albumArtUri: metadata?.images?.[0]?.url,
|
||||||
|
uri: media.contentId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get playback status
|
||||||
|
*/
|
||||||
|
public async getPlaybackStatus(): Promise<IPlaybackStatus> {
|
||||||
|
await this.refreshStatus();
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: this._playbackState,
|
||||||
|
volume: this._volume,
|
||||||
|
muted: this._muted,
|
||||||
|
track: await this.getCurrentTrack() || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Chromecast-specific Methods
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launch an application
|
||||||
|
*/
|
||||||
|
public async launchApp(appId: string): Promise<void> {
|
||||||
|
if (!this.client) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.client!.launch({ id: appId } as Parameters<typeof plugins.castv2Client.Client.prototype.launch>[0], (err: Error | null) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._currentAppId = appId;
|
||||||
|
this.emit('app:launched', { appId });
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop current application
|
||||||
|
*/
|
||||||
|
public async stopApp(): Promise<void> {
|
||||||
|
if (!this.client) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.client!.stop(this.player as InstanceType<typeof plugins.castv2Client.DefaultMediaReceiver>, (err: Error | null) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._currentAppId = undefined;
|
||||||
|
this._currentAppName = undefined;
|
||||||
|
this.player = null;
|
||||||
|
this.emit('app:stopped');
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get receiver status
|
||||||
|
*/
|
||||||
|
public async getReceiverStatus(): Promise<{
|
||||||
|
applications?: Array<{ appId: string; displayName: string }>;
|
||||||
|
volume: { level: number; muted: boolean };
|
||||||
|
}> {
|
||||||
|
if (!this.client) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.client!.getStatus((err: Error | null, status: {
|
||||||
|
applications?: Array<{ appId: string; displayName: string }>;
|
||||||
|
volume: { level: number; muted: boolean };
|
||||||
|
}) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(status);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guess content type from URL
|
||||||
|
*/
|
||||||
|
private guessContentType(url: string): string {
|
||||||
|
const ext = url.split('.').pop()?.toLowerCase();
|
||||||
|
|
||||||
|
switch (ext) {
|
||||||
|
case 'mp3':
|
||||||
|
return 'audio/mpeg';
|
||||||
|
case 'mp4':
|
||||||
|
case 'm4v':
|
||||||
|
return 'video/mp4';
|
||||||
|
case 'webm':
|
||||||
|
return 'video/webm';
|
||||||
|
case 'mkv':
|
||||||
|
return 'video/x-matroska';
|
||||||
|
case 'ogg':
|
||||||
|
return 'audio/ogg';
|
||||||
|
case 'flac':
|
||||||
|
return 'audio/flac';
|
||||||
|
case 'wav':
|
||||||
|
return 'audio/wav';
|
||||||
|
case 'm3u8':
|
||||||
|
return 'application/x-mpegURL';
|
||||||
|
case 'mpd':
|
||||||
|
return 'application/dash+xml';
|
||||||
|
default:
|
||||||
|
return 'video/mp4';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Device Info
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get speaker info
|
||||||
|
*/
|
||||||
|
public getSpeakerInfo(): IChromecastSpeakerInfo {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
name: this.name,
|
||||||
|
type: 'speaker',
|
||||||
|
address: this.address,
|
||||||
|
port: this.port,
|
||||||
|
status: this.status,
|
||||||
|
protocol: 'chromecast',
|
||||||
|
roomName: this._roomName,
|
||||||
|
modelName: this._modelName,
|
||||||
|
friendlyName: this._friendlyName,
|
||||||
|
deviceType: this._deviceType,
|
||||||
|
capabilities: this._capabilities,
|
||||||
|
currentAppId: this._currentAppId,
|
||||||
|
currentAppName: this._currentAppName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create from mDNS discovery
|
||||||
|
*/
|
||||||
|
public static fromDiscovery(
|
||||||
|
data: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
port?: number;
|
||||||
|
roomName?: string;
|
||||||
|
modelName?: string;
|
||||||
|
friendlyName?: string;
|
||||||
|
deviceType?: TChromecastType;
|
||||||
|
capabilities?: string[];
|
||||||
|
},
|
||||||
|
retryOptions?: IRetryOptions
|
||||||
|
): ChromecastSpeaker {
|
||||||
|
const info: IDeviceInfo = {
|
||||||
|
id: data.id,
|
||||||
|
name: data.name,
|
||||||
|
type: 'speaker',
|
||||||
|
address: data.address,
|
||||||
|
port: data.port ?? 8009,
|
||||||
|
status: 'unknown',
|
||||||
|
};
|
||||||
|
|
||||||
|
return new ChromecastSpeaker(
|
||||||
|
info,
|
||||||
|
{
|
||||||
|
roomName: data.roomName,
|
||||||
|
modelName: data.modelName,
|
||||||
|
friendlyName: data.friendlyName,
|
||||||
|
deviceType: data.deviceType,
|
||||||
|
capabilities: data.capabilities,
|
||||||
|
},
|
||||||
|
retryOptions
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Probe for Chromecast device
|
||||||
|
*/
|
||||||
|
public static async probe(address: string, port: number = 8009, timeout: number = 5000): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const client = new plugins.castv2Client.Client();
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
client.close();
|
||||||
|
resolve(false);
|
||||||
|
}, timeout);
|
||||||
|
|
||||||
|
client.on('error', () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
client.close();
|
||||||
|
resolve(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
client.connect(address, () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
client.close();
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
654
ts/speaker/speaker.classes.sonos.ts
Normal file
654
ts/speaker/speaker.classes.sonos.ts
Normal file
@@ -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<typeof plugins.sonos.Sonos> | null = null;
|
||||||
|
|
||||||
|
private _zoneName: string = '';
|
||||||
|
private _zoneUuid: string = '';
|
||||||
|
private _isCoordinator: boolean = false;
|
||||||
|
private _groupId?: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
info: IDeviceInfo,
|
||||||
|
options?: {
|
||||||
|
roomName?: string;
|
||||||
|
modelName?: string;
|
||||||
|
},
|
||||||
|
retryOptions?: IRetryOptions
|
||||||
|
) {
|
||||||
|
super(info, 'sonos', options, retryOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
public get zoneName(): string {
|
||||||
|
return this._zoneName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get zoneUuid(): string {
|
||||||
|
return this._zoneUuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get isCoordinator(): boolean {
|
||||||
|
return this._isCoordinator;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get groupId(): string | undefined {
|
||||||
|
return this._groupId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to Sonos device
|
||||||
|
*/
|
||||||
|
protected async doConnect(): Promise<void> {
|
||||||
|
this.device = new plugins.sonos.Sonos(this.address, this.port);
|
||||||
|
|
||||||
|
// Get device info
|
||||||
|
try {
|
||||||
|
const zoneInfo = await this.device.getZoneInfo();
|
||||||
|
this._zoneName = zoneInfo.ZoneName || '';
|
||||||
|
this._roomName = this._zoneName;
|
||||||
|
|
||||||
|
const attrs = await this.device.getZoneAttrs();
|
||||||
|
this._zoneUuid = attrs.CurrentZoneName || '';
|
||||||
|
} catch (error) {
|
||||||
|
// Some info may not be available
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get device description
|
||||||
|
try {
|
||||||
|
const desc = await this.device.deviceDescription();
|
||||||
|
this._modelName = desc.modelName;
|
||||||
|
this.model = desc.modelName;
|
||||||
|
this.manufacturer = desc.manufacturer;
|
||||||
|
this.serialNumber = desc.serialNum;
|
||||||
|
} catch {
|
||||||
|
// Optional info
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current state
|
||||||
|
await this.refreshStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect
|
||||||
|
*/
|
||||||
|
protected async doDisconnect(): Promise<void> {
|
||||||
|
this.device = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh status
|
||||||
|
*/
|
||||||
|
public async refreshStatus(): Promise<void> {
|
||||||
|
if (!this.device) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [volume, muted, state] = await Promise.all([
|
||||||
|
this.device.getVolume(),
|
||||||
|
this.device.getMuted(),
|
||||||
|
this.device.getCurrentState(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
this._volume = volume;
|
||||||
|
this._muted = muted;
|
||||||
|
this._playbackState = this.mapSonosState(state);
|
||||||
|
} catch {
|
||||||
|
// Status refresh failed
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('status:updated', this.getSpeakerInfo());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map Sonos state to our state
|
||||||
|
*/
|
||||||
|
private mapSonosState(state: string): TPlaybackState {
|
||||||
|
switch (state.toLowerCase()) {
|
||||||
|
case 'playing':
|
||||||
|
return 'playing';
|
||||||
|
case 'paused':
|
||||||
|
case 'paused_playback':
|
||||||
|
return 'paused';
|
||||||
|
case 'stopped':
|
||||||
|
return 'stopped';
|
||||||
|
case 'transitioning':
|
||||||
|
return 'transitioning';
|
||||||
|
default:
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Playback Control
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play
|
||||||
|
*/
|
||||||
|
public async play(uri?: string): Promise<void> {
|
||||||
|
if (!this.device) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uri) {
|
||||||
|
await this.device.play(uri);
|
||||||
|
} else {
|
||||||
|
await this.device.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
this._playbackState = 'playing';
|
||||||
|
this.emit('playback:started');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause
|
||||||
|
*/
|
||||||
|
public async pause(): Promise<void> {
|
||||||
|
if (!this.device) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.device.pause();
|
||||||
|
this._playbackState = 'paused';
|
||||||
|
this.emit('playback:paused');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop
|
||||||
|
*/
|
||||||
|
public async stop(): Promise<void> {
|
||||||
|
if (!this.device) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.device.stop();
|
||||||
|
this._playbackState = 'stopped';
|
||||||
|
this.emit('playback:stopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Next track
|
||||||
|
*/
|
||||||
|
public async next(): Promise<void> {
|
||||||
|
if (!this.device) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.device.next();
|
||||||
|
this.emit('playback:next');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Previous track
|
||||||
|
*/
|
||||||
|
public async previous(): Promise<void> {
|
||||||
|
if (!this.device) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.device.previous();
|
||||||
|
this.emit('playback:previous');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seek to position
|
||||||
|
*/
|
||||||
|
public async seek(seconds: number): Promise<void> {
|
||||||
|
if (!this.device) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.device.seek(seconds);
|
||||||
|
this.emit('playback:seeked', { position: seconds });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Volume Control
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get volume
|
||||||
|
*/
|
||||||
|
public async getVolume(): Promise<number> {
|
||||||
|
if (!this.device) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
const volume = await this.device.getVolume();
|
||||||
|
this._volume = volume;
|
||||||
|
return volume;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set volume
|
||||||
|
*/
|
||||||
|
public async setVolume(level: number): Promise<void> {
|
||||||
|
if (!this.device) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
const clamped = Math.max(0, Math.min(100, level));
|
||||||
|
await this.device.setVolume(clamped);
|
||||||
|
this._volume = clamped;
|
||||||
|
this.emit('volume:changed', { volume: clamped });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get mute state
|
||||||
|
*/
|
||||||
|
public async getMute(): Promise<boolean> {
|
||||||
|
if (!this.device) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
const muted = await this.device.getMuted();
|
||||||
|
this._muted = muted;
|
||||||
|
return muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set mute state
|
||||||
|
*/
|
||||||
|
public async setMute(muted: boolean): Promise<void> {
|
||||||
|
if (!this.device) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.device.setMuted(muted);
|
||||||
|
this._muted = muted;
|
||||||
|
this.emit('mute:changed', { muted });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Track Information
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current track
|
||||||
|
*/
|
||||||
|
public async getCurrentTrack(): Promise<ITrackInfo | null> {
|
||||||
|
if (!this.device) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const track = await this.device.currentTrack();
|
||||||
|
|
||||||
|
if (!track) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: track.title || 'Unknown',
|
||||||
|
artist: track.artist,
|
||||||
|
album: track.album,
|
||||||
|
duration: track.duration || 0,
|
||||||
|
position: track.position || 0,
|
||||||
|
albumArtUri: track.albumArtURI || track.albumArtURL,
|
||||||
|
uri: track.uri,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get playback status
|
||||||
|
*/
|
||||||
|
public async getPlaybackStatus(): Promise<IPlaybackStatus> {
|
||||||
|
if (!this.device) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [state, volume, muted, track] = await Promise.all([
|
||||||
|
this.device.getCurrentState(),
|
||||||
|
this.device.getVolume(),
|
||||||
|
this.device.getMuted(),
|
||||||
|
this.getCurrentTrack(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: this.mapSonosState(state),
|
||||||
|
volume,
|
||||||
|
muted,
|
||||||
|
track: track || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Sonos-specific Features
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play from queue
|
||||||
|
*/
|
||||||
|
public async playFromQueue(index: number): Promise<void> {
|
||||||
|
if (!this.device) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.device.selectQueue();
|
||||||
|
await this.device.selectTrack(index);
|
||||||
|
await this.device.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add URI to queue
|
||||||
|
*/
|
||||||
|
public async addToQueue(uri: string, positionInQueue?: number): Promise<void> {
|
||||||
|
if (!this.device) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.device.queue(uri, positionInQueue);
|
||||||
|
this.emit('queue:added', { uri, position: positionInQueue });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear queue
|
||||||
|
*/
|
||||||
|
public async clearQueue(): Promise<void> {
|
||||||
|
if (!this.device) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.device.flush();
|
||||||
|
this.emit('queue:cleared');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get queue contents
|
||||||
|
*/
|
||||||
|
public async getQueue(): Promise<ITrackInfo[]> {
|
||||||
|
if (!this.device) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
const queue = await this.device.getQueue();
|
||||||
|
|
||||||
|
if (!queue || !queue.items) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return queue.items.map((item: { title?: string; artist?: string; album?: string; albumArtURI?: string; uri?: string }) => ({
|
||||||
|
title: item.title || 'Unknown',
|
||||||
|
artist: item.artist,
|
||||||
|
album: item.album,
|
||||||
|
duration: 0,
|
||||||
|
position: 0,
|
||||||
|
albumArtUri: item.albumArtURI,
|
||||||
|
uri: item.uri,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play a Sonos playlist
|
||||||
|
*/
|
||||||
|
public async playPlaylist(playlistName: string): Promise<void> {
|
||||||
|
if (!this.device) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
const playlists = await this.device.getMusicLibrary('sonos_playlists');
|
||||||
|
const playlist = playlists.items?.find((p: { title?: string }) =>
|
||||||
|
p.title?.toLowerCase().includes(playlistName.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
if (playlist && playlist.uri) {
|
||||||
|
await this.device.play(playlist.uri);
|
||||||
|
} else {
|
||||||
|
throw new Error(`Playlist "${playlistName}" not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play favorite by name
|
||||||
|
*/
|
||||||
|
public async playFavorite(favoriteName: string): Promise<void> {
|
||||||
|
if (!this.device) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
const favorites = await this.device.getFavorites();
|
||||||
|
const favorite = favorites.items?.find((f: { title?: string }) =>
|
||||||
|
f.title?.toLowerCase().includes(favoriteName.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
if (favorite && favorite.uri) {
|
||||||
|
await this.device.play(favorite.uri);
|
||||||
|
} else {
|
||||||
|
throw new Error(`Favorite "${favoriteName}" not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get favorites
|
||||||
|
*/
|
||||||
|
public async getFavorites(): Promise<{ title: string; uri: string; albumArtUri?: string }[]> {
|
||||||
|
if (!this.device) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
const favorites = await this.device.getFavorites();
|
||||||
|
|
||||||
|
if (!favorites.items) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return favorites.items.map((f: { title?: string; uri?: string; albumArtURI?: string }) => ({
|
||||||
|
title: f.title || 'Unknown',
|
||||||
|
uri: f.uri || '',
|
||||||
|
albumArtUri: f.albumArtURI,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play TuneIn radio station by ID
|
||||||
|
*/
|
||||||
|
public async playTuneInRadio(stationId: string): Promise<void> {
|
||||||
|
if (!this.device) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.device.playTuneinRadio(stationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play Spotify URI
|
||||||
|
*/
|
||||||
|
public async playSpotify(spotifyUri: string): Promise<void> {
|
||||||
|
if (!this.device) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.device.play(spotifyUri);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Grouping
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Join another speaker's group
|
||||||
|
*/
|
||||||
|
public async joinGroup(coordinatorAddress: string): Promise<void> {
|
||||||
|
if (!this.device) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
const coordinator = new plugins.sonos.Sonos(coordinatorAddress);
|
||||||
|
await this.device.joinGroup(await coordinator.getName());
|
||||||
|
this.emit('group:joined', { coordinator: coordinatorAddress });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leave current group
|
||||||
|
*/
|
||||||
|
public async leaveGroup(): Promise<void> {
|
||||||
|
if (!this.device) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.device.leaveGroup();
|
||||||
|
this.emit('group:left');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get group information
|
||||||
|
*/
|
||||||
|
public async getGroupInfo(): Promise<ISonosZoneInfo | null> {
|
||||||
|
if (!this.device) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const groups = await this.device.getAllGroups();
|
||||||
|
|
||||||
|
// Find our group
|
||||||
|
for (const group of groups) {
|
||||||
|
const members = group.ZoneGroupMember || [];
|
||||||
|
const memberArray = Array.isArray(members) ? members : [members];
|
||||||
|
|
||||||
|
for (const member of memberArray) {
|
||||||
|
if (member.Location?.includes(this.address)) {
|
||||||
|
const coordinator = memberArray.find((m: { UUID?: string }) => m.UUID === group.Coordinator);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: group.Name || 'Group',
|
||||||
|
uuid: group.Coordinator || '',
|
||||||
|
coordinator: member.UUID === group.Coordinator,
|
||||||
|
groupId: group.ID || '',
|
||||||
|
members: memberArray.map((m: { ZoneName?: string }) => m.ZoneName || ''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Device Info
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get speaker info
|
||||||
|
*/
|
||||||
|
public getSpeakerInfo(): ISonosSpeakerInfo {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
name: this.name,
|
||||||
|
type: 'speaker',
|
||||||
|
address: this.address,
|
||||||
|
port: this.port,
|
||||||
|
status: this.status,
|
||||||
|
protocol: 'sonos',
|
||||||
|
roomName: this._roomName,
|
||||||
|
modelName: this._modelName,
|
||||||
|
zoneName: this._zoneName,
|
||||||
|
zoneUuid: this._zoneUuid,
|
||||||
|
isCoordinator: this._isCoordinator,
|
||||||
|
groupId: this._groupId,
|
||||||
|
supportsGrouping: true,
|
||||||
|
isGroupCoordinator: this._isCoordinator,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create from discovery
|
||||||
|
*/
|
||||||
|
public static fromDiscovery(
|
||||||
|
data: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
port?: number;
|
||||||
|
roomName?: string;
|
||||||
|
modelName?: string;
|
||||||
|
},
|
||||||
|
retryOptions?: IRetryOptions
|
||||||
|
): SonosSpeaker {
|
||||||
|
const info: IDeviceInfo = {
|
||||||
|
id: data.id,
|
||||||
|
name: data.name,
|
||||||
|
type: 'speaker',
|
||||||
|
address: data.address,
|
||||||
|
port: data.port ?? 1400,
|
||||||
|
status: 'unknown',
|
||||||
|
};
|
||||||
|
|
||||||
|
return new SonosSpeaker(
|
||||||
|
info,
|
||||||
|
{
|
||||||
|
roomName: data.roomName,
|
||||||
|
modelName: data.modelName,
|
||||||
|
},
|
||||||
|
retryOptions
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover Sonos devices on the network
|
||||||
|
*/
|
||||||
|
public static async discover(timeout: number = 5000): Promise<SonosSpeaker[]> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const speakers: SonosSpeaker[] = [];
|
||||||
|
const discovery = new plugins.sonos.AsyncDeviceDiscovery();
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
resolve(speakers);
|
||||||
|
}, timeout);
|
||||||
|
|
||||||
|
discovery.discover().then((device: { host: string; port: number }) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
|
||||||
|
const speaker = new SonosSpeaker(
|
||||||
|
{
|
||||||
|
id: `sonos:${device.host}`,
|
||||||
|
name: `Sonos ${device.host}`,
|
||||||
|
type: 'speaker',
|
||||||
|
address: device.host,
|
||||||
|
port: device.port || 1400,
|
||||||
|
status: 'unknown',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
speakers.push(speaker);
|
||||||
|
resolve(speakers);
|
||||||
|
}).catch(() => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(speakers);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
216
ts/speaker/speaker.classes.speaker.ts
Normal file
216
ts/speaker/speaker.classes.speaker.ts
Normal file
@@ -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<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause playback
|
||||||
|
*/
|
||||||
|
public abstract pause(): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop playback
|
||||||
|
*/
|
||||||
|
public abstract stop(): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Next track
|
||||||
|
*/
|
||||||
|
public abstract next(): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Previous track
|
||||||
|
*/
|
||||||
|
public abstract previous(): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seek to position
|
||||||
|
*/
|
||||||
|
public abstract seek(seconds: number): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get volume level (0-100)
|
||||||
|
*/
|
||||||
|
public abstract getVolume(): Promise<number>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set volume level (0-100)
|
||||||
|
*/
|
||||||
|
public abstract setVolume(level: number): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get mute state
|
||||||
|
*/
|
||||||
|
public abstract getMute(): Promise<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set mute state
|
||||||
|
*/
|
||||||
|
public abstract setMute(muted: boolean): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current track info
|
||||||
|
*/
|
||||||
|
public abstract getCurrentTrack(): Promise<ITrackInfo | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get playback status
|
||||||
|
*/
|
||||||
|
public abstract getPlaybackStatus(): Promise<IPlaybackStatus>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Common Methods
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle mute
|
||||||
|
*/
|
||||||
|
public async toggleMute(): Promise<boolean> {
|
||||||
|
const currentMute = await this.getMute();
|
||||||
|
await this.setMute(!currentMute);
|
||||||
|
return !currentMute;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Volume up
|
||||||
|
*/
|
||||||
|
public async volumeUp(step: number = 5): Promise<number> {
|
||||||
|
const current = await this.getVolume();
|
||||||
|
const newVolume = Math.min(100, current + step);
|
||||||
|
await this.setVolume(newVolume);
|
||||||
|
return newVolume;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Volume down
|
||||||
|
*/
|
||||||
|
public async volumeDown(step: number = 5): Promise<number> {
|
||||||
|
const current = await this.getVolume();
|
||||||
|
const newVolume = Math.max(0, current - step);
|
||||||
|
await this.setVolume(newVolume);
|
||||||
|
return newVolume;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get speaker info
|
||||||
|
*/
|
||||||
|
public getSpeakerInfo(): ISpeakerInfo {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
name: this.name,
|
||||||
|
type: 'speaker',
|
||||||
|
address: this.address,
|
||||||
|
port: this.port,
|
||||||
|
status: this.status,
|
||||||
|
protocol: this._protocol,
|
||||||
|
roomName: this._roomName,
|
||||||
|
modelName: this._modelName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
548
ts/ups/ups.classes.upsdevice.ts
Normal file
548
ts/ups/ups.classes.upsdevice.ts
Normal file
@@ -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<void> {
|
||||||
|
if (this.upsProtocol === 'nut') {
|
||||||
|
await this.connectNut();
|
||||||
|
} else {
|
||||||
|
await this.connectSnmp();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect via NUT protocol
|
||||||
|
*/
|
||||||
|
private async connectNut(): Promise<void> {
|
||||||
|
this.nutProtocol = new NutProtocol(this.address, this.port);
|
||||||
|
await this.nutProtocol.connect();
|
||||||
|
|
||||||
|
// Get device info
|
||||||
|
const deviceInfo = await this.nutProtocol.getDeviceInfo(this.upsName);
|
||||||
|
this._manufacturer = deviceInfo.manufacturer;
|
||||||
|
this._model = deviceInfo.model;
|
||||||
|
this.manufacturer = deviceInfo.manufacturer;
|
||||||
|
this.model = deviceInfo.model;
|
||||||
|
this.serialNumber = deviceInfo.serial;
|
||||||
|
|
||||||
|
// Get initial status
|
||||||
|
await this.refreshStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect via SNMP protocol
|
||||||
|
*/
|
||||||
|
private async connectSnmp(): Promise<void> {
|
||||||
|
this.snmpHandler = new UpsSnmpHandler(this.address, {
|
||||||
|
community: this.snmpCommunity,
|
||||||
|
port: this.port,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify it's a UPS
|
||||||
|
const isUps = await this.snmpHandler.isUpsDevice();
|
||||||
|
if (!isUps) {
|
||||||
|
this.snmpHandler.close();
|
||||||
|
this.snmpHandler = null;
|
||||||
|
throw new Error('Device does not support UPS-MIB');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get identity
|
||||||
|
const identity = await this.snmpHandler.getIdentity();
|
||||||
|
this._manufacturer = identity.manufacturer;
|
||||||
|
this._model = identity.model;
|
||||||
|
this.manufacturer = identity.manufacturer;
|
||||||
|
this.model = identity.model;
|
||||||
|
this.firmwareVersion = identity.softwareVersion;
|
||||||
|
|
||||||
|
// Get initial status
|
||||||
|
await this.refreshStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect from UPS
|
||||||
|
*/
|
||||||
|
protected async doDisconnect(): Promise<void> {
|
||||||
|
if (this.nutProtocol) {
|
||||||
|
await this.nutProtocol.disconnect();
|
||||||
|
this.nutProtocol = null;
|
||||||
|
}
|
||||||
|
if (this.snmpHandler) {
|
||||||
|
this.snmpHandler.close();
|
||||||
|
this.snmpHandler = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh UPS status
|
||||||
|
*/
|
||||||
|
public async refreshStatus(): Promise<void> {
|
||||||
|
if (this.upsProtocol === 'nut' && this.nutProtocol) {
|
||||||
|
await this.refreshNutStatus();
|
||||||
|
} else if (this.snmpHandler) {
|
||||||
|
await this.refreshSnmpStatus();
|
||||||
|
} else {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('status:updated', this.getDeviceInfo());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh status via NUT
|
||||||
|
*/
|
||||||
|
private async refreshNutStatus(): Promise<void> {
|
||||||
|
if (!this.nutProtocol) return;
|
||||||
|
|
||||||
|
const status = await this.nutProtocol.getUpsStatus(this.upsName);
|
||||||
|
|
||||||
|
this._batteryCharge = status.batteryCharge;
|
||||||
|
this._batteryRuntime = status.batteryRuntime;
|
||||||
|
this._inputVoltage = status.inputVoltage;
|
||||||
|
this._outputVoltage = status.outputVoltage;
|
||||||
|
this._load = status.load;
|
||||||
|
|
||||||
|
// Convert NUT status flags to our status
|
||||||
|
this._upsStatus = this.nutStatusToUpsStatus(status.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh status via SNMP
|
||||||
|
*/
|
||||||
|
private async refreshSnmpStatus(): Promise<void> {
|
||||||
|
if (!this.snmpHandler) return;
|
||||||
|
|
||||||
|
const status = await this.snmpHandler.getFullStatus();
|
||||||
|
|
||||||
|
this._batteryCharge = status.estimatedChargeRemaining;
|
||||||
|
this._batteryRuntime = status.estimatedMinutesRemaining * 60; // Convert to seconds
|
||||||
|
this._inputVoltage = status.inputVoltage;
|
||||||
|
this._outputVoltage = status.outputVoltage;
|
||||||
|
this._load = status.outputPercentLoad;
|
||||||
|
|
||||||
|
// Convert SNMP status to our status
|
||||||
|
this._upsStatus = this.snmpStatusToUpsStatus(status.outputSource, status.batteryStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert NUT status flags to TUpsStatus
|
||||||
|
*/
|
||||||
|
private nutStatusToUpsStatus(flags: TNutStatusFlag[]): TUpsStatus {
|
||||||
|
if (flags.includes('OFF')) return 'offline';
|
||||||
|
if (flags.includes('LB')) return 'lowbattery';
|
||||||
|
if (flags.includes('OB')) return 'onbattery';
|
||||||
|
if (flags.includes('BYPASS')) return 'bypass';
|
||||||
|
if (flags.includes('CHRG')) return 'charging';
|
||||||
|
if (flags.includes('DISCHRG')) return 'discharging';
|
||||||
|
if (flags.includes('OL')) return 'online';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert SNMP status to TUpsStatus
|
||||||
|
*/
|
||||||
|
private snmpStatusToUpsStatus(source: TUpsOutputSource, battery: TUpsBatteryStatus): TUpsStatus {
|
||||||
|
if (source === 'none') return 'offline';
|
||||||
|
if (source === 'battery') {
|
||||||
|
if (battery === 'batteryLow') return 'lowbattery';
|
||||||
|
if (battery === 'batteryDepleted') return 'lowbattery';
|
||||||
|
return 'onbattery';
|
||||||
|
}
|
||||||
|
if (source === 'bypass') return 'bypass';
|
||||||
|
if (source === 'normal') return 'online';
|
||||||
|
if (source === 'booster' || source === 'reducer') return 'online';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get battery information
|
||||||
|
*/
|
||||||
|
public async getBatteryInfo(): Promise<IUpsBatteryInfo> {
|
||||||
|
if (this.upsProtocol === 'nut' && this.nutProtocol) {
|
||||||
|
const vars = await this.nutProtocol.getVariables(this.upsName, [
|
||||||
|
NUT_VARIABLES.batteryCharge,
|
||||||
|
NUT_VARIABLES.batteryRuntime,
|
||||||
|
NUT_VARIABLES.batteryVoltage,
|
||||||
|
NUT_VARIABLES.batteryTemperature,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
charge: parseFloat(vars.get(NUT_VARIABLES.batteryCharge) || '0'),
|
||||||
|
runtime: parseFloat(vars.get(NUT_VARIABLES.batteryRuntime) || '0'),
|
||||||
|
voltage: parseFloat(vars.get(NUT_VARIABLES.batteryVoltage) || '0'),
|
||||||
|
temperature: vars.has(NUT_VARIABLES.batteryTemperature)
|
||||||
|
? parseFloat(vars.get(NUT_VARIABLES.batteryTemperature)!)
|
||||||
|
: undefined,
|
||||||
|
status: 'normal',
|
||||||
|
};
|
||||||
|
} else if (this.snmpHandler) {
|
||||||
|
const battery = await this.snmpHandler.getBatteryStatus();
|
||||||
|
|
||||||
|
const statusMap: Record<TUpsBatteryStatus, IUpsBatteryInfo['status']> = {
|
||||||
|
unknown: 'unknown',
|
||||||
|
batteryNormal: 'normal',
|
||||||
|
batteryLow: 'low',
|
||||||
|
batteryDepleted: 'depleted',
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
charge: battery.estimatedChargeRemaining,
|
||||||
|
runtime: battery.estimatedMinutesRemaining * 60,
|
||||||
|
voltage: battery.voltage,
|
||||||
|
temperature: battery.temperature || undefined,
|
||||||
|
status: statusMap[battery.status],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get power information
|
||||||
|
*/
|
||||||
|
public async getPowerInfo(): Promise<IUpsPowerInfo> {
|
||||||
|
if (this.upsProtocol === 'nut' && this.nutProtocol) {
|
||||||
|
const vars = await this.nutProtocol.getVariables(this.upsName, [
|
||||||
|
NUT_VARIABLES.inputVoltage,
|
||||||
|
NUT_VARIABLES.inputFrequency,
|
||||||
|
NUT_VARIABLES.outputVoltage,
|
||||||
|
NUT_VARIABLES.outputCurrent,
|
||||||
|
NUT_VARIABLES.upsLoad,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
inputVoltage: parseFloat(vars.get(NUT_VARIABLES.inputVoltage) || '0'),
|
||||||
|
inputFrequency: vars.has(NUT_VARIABLES.inputFrequency)
|
||||||
|
? parseFloat(vars.get(NUT_VARIABLES.inputFrequency)!)
|
||||||
|
: undefined,
|
||||||
|
outputVoltage: parseFloat(vars.get(NUT_VARIABLES.outputVoltage) || '0'),
|
||||||
|
outputCurrent: vars.has(NUT_VARIABLES.outputCurrent)
|
||||||
|
? parseFloat(vars.get(NUT_VARIABLES.outputCurrent)!)
|
||||||
|
: undefined,
|
||||||
|
load: parseFloat(vars.get(NUT_VARIABLES.upsLoad) || '0'),
|
||||||
|
};
|
||||||
|
} else if (this.snmpHandler) {
|
||||||
|
const [input, output] = await Promise.all([
|
||||||
|
this.snmpHandler.getInputStatus(),
|
||||||
|
this.snmpHandler.getOutputStatus(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
inputVoltage: input.voltage,
|
||||||
|
inputFrequency: input.frequency,
|
||||||
|
outputVoltage: output.voltage,
|
||||||
|
outputFrequency: output.frequency,
|
||||||
|
outputCurrent: output.current,
|
||||||
|
outputPower: output.power,
|
||||||
|
load: output.percentLoad,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get full status
|
||||||
|
*/
|
||||||
|
public async getFullStatus(): Promise<IUpsFullStatus> {
|
||||||
|
const [battery, power] = await Promise.all([
|
||||||
|
this.getBatteryInfo(),
|
||||||
|
this.getPowerInfo(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let secondsOnBattery = 0;
|
||||||
|
const alarms: string[] = [];
|
||||||
|
|
||||||
|
if (this.upsProtocol === 'nut' && this.nutProtocol) {
|
||||||
|
const vars = await this.nutProtocol.getVariables(this.upsName, [
|
||||||
|
NUT_VARIABLES.upsStatus,
|
||||||
|
NUT_VARIABLES.upsAlarm,
|
||||||
|
]);
|
||||||
|
const alarm = vars.get(NUT_VARIABLES.upsAlarm);
|
||||||
|
if (alarm) {
|
||||||
|
alarms.push(alarm);
|
||||||
|
}
|
||||||
|
} else if (this.snmpHandler) {
|
||||||
|
const snmpStatus = await this.snmpHandler.getFullStatus();
|
||||||
|
secondsOnBattery = snmpStatus.secondsOnBattery;
|
||||||
|
if (snmpStatus.alarmsPresent > 0) {
|
||||||
|
alarms.push(`${snmpStatus.alarmsPresent} alarm(s) present`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: this._upsStatus,
|
||||||
|
battery,
|
||||||
|
power,
|
||||||
|
alarms,
|
||||||
|
secondsOnBattery,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a UPS command (NUT only)
|
||||||
|
*/
|
||||||
|
public async runCommand(command: string): Promise<boolean> {
|
||||||
|
if (this.upsProtocol !== 'nut' || !this.nutProtocol) {
|
||||||
|
throw new Error('Commands only supported via NUT protocol');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.nutProtocol.runCommand(this.upsName, command);
|
||||||
|
this.emit('command:executed', { command, success: result });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start battery test
|
||||||
|
*/
|
||||||
|
public async startBatteryTest(type: 'quick' | 'deep' = 'quick'): Promise<boolean> {
|
||||||
|
const command = type === 'deep'
|
||||||
|
? NUT_COMMANDS.testBatteryStartDeep
|
||||||
|
: NUT_COMMANDS.testBatteryStartQuick;
|
||||||
|
return this.runCommand(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop battery test
|
||||||
|
*/
|
||||||
|
public async stopBatteryTest(): Promise<boolean> {
|
||||||
|
return this.runCommand(NUT_COMMANDS.testBatteryStop);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle beeper
|
||||||
|
*/
|
||||||
|
public async toggleBeeper(): Promise<boolean> {
|
||||||
|
return this.runCommand(NUT_COMMANDS.beeperToggle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get device info
|
||||||
|
*/
|
||||||
|
public getDeviceInfo(): IUpsDeviceInfo {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
name: this.name,
|
||||||
|
type: 'ups',
|
||||||
|
address: this.address,
|
||||||
|
port: this.port,
|
||||||
|
status: this.status,
|
||||||
|
protocol: this.upsProtocol,
|
||||||
|
upsName: this.upsName,
|
||||||
|
manufacturer: this._manufacturer,
|
||||||
|
model: this._model,
|
||||||
|
serialNumber: this.serialNumber,
|
||||||
|
firmwareVersion: this.firmwareVersion,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create UPS device from discovery
|
||||||
|
*/
|
||||||
|
public static fromDiscovery(
|
||||||
|
data: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
port?: number;
|
||||||
|
protocol: TUpsProtocol;
|
||||||
|
upsName?: string;
|
||||||
|
community?: string;
|
||||||
|
},
|
||||||
|
retryOptions?: IRetryOptions
|
||||||
|
): UpsDevice {
|
||||||
|
const info: IDeviceInfo = {
|
||||||
|
id: data.id,
|
||||||
|
name: data.name,
|
||||||
|
type: 'ups',
|
||||||
|
address: data.address,
|
||||||
|
port: data.port ?? (data.protocol === 'nut' ? 3493 : 161),
|
||||||
|
status: 'unknown',
|
||||||
|
};
|
||||||
|
|
||||||
|
return new UpsDevice(
|
||||||
|
info,
|
||||||
|
{
|
||||||
|
protocol: data.protocol,
|
||||||
|
upsName: data.upsName,
|
||||||
|
snmpCommunity: data.community,
|
||||||
|
},
|
||||||
|
retryOptions
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Probe for UPS device (NUT or SNMP)
|
||||||
|
*/
|
||||||
|
public static async probe(
|
||||||
|
address: string,
|
||||||
|
options?: {
|
||||||
|
nutPort?: number;
|
||||||
|
snmpPort?: number;
|
||||||
|
snmpCommunity?: string;
|
||||||
|
timeout?: number;
|
||||||
|
}
|
||||||
|
): Promise<{ protocol: TUpsProtocol; port: number } | null> {
|
||||||
|
const nutPort = options?.nutPort ?? 3493;
|
||||||
|
const snmpPort = options?.snmpPort ?? 161;
|
||||||
|
const community = options?.snmpCommunity ?? 'public';
|
||||||
|
|
||||||
|
// Try NUT first
|
||||||
|
const nutAvailable = await NutProtocol.probe(address, nutPort, options?.timeout);
|
||||||
|
if (nutAvailable) {
|
||||||
|
return { protocol: 'nut', port: nutPort };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try SNMP UPS-MIB
|
||||||
|
try {
|
||||||
|
const handler = new UpsSnmpHandler(address, { community, port: snmpPort, timeout: options?.timeout ?? 3000 });
|
||||||
|
const isUps = await handler.isUpsDevice();
|
||||||
|
handler.close();
|
||||||
|
|
||||||
|
if (isUps) {
|
||||||
|
return { protocol: 'snmp', port: snmpPort };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore SNMP errors
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export types
|
||||||
|
export { NUT_COMMANDS, NUT_VARIABLES, type TNutStatusFlag } from './ups.classes.nutprotocol.js';
|
||||||
|
export { UPS_SNMP_OIDS, type TUpsBatteryStatus, type TUpsOutputSource } from './ups.classes.upssnmp.js';
|
||||||
Reference in New Issue
Block a user