2026-01-09 09:36:43 +00:00
|
|
|
/**
|
|
|
|
|
* Device Manager
|
|
|
|
|
* Unified device discovery and management using UniversalDevice with Features
|
|
|
|
|
*/
|
|
|
|
|
|
2026-01-09 07:14:39 +00:00
|
|
|
import * as plugins from './plugins.js';
|
|
|
|
|
import { MdnsDiscovery, SERVICE_TYPES } from './discovery/discovery.classes.mdns.js';
|
|
|
|
|
import { NetworkScanner } from './discovery/discovery.classes.networkscanner.js';
|
2026-01-09 09:03:42 +00:00
|
|
|
import { SsdpDiscovery, SSDP_SERVICE_TYPES, type ISsdpDevice } from './discovery/discovery.classes.ssdp.js';
|
2026-01-09 09:36:43 +00:00
|
|
|
import { UniversalDevice } from './device/device.classes.device.js';
|
|
|
|
|
import {
|
|
|
|
|
ScanFeature,
|
|
|
|
|
PrintFeature,
|
|
|
|
|
PlaybackFeature,
|
|
|
|
|
VolumeFeature,
|
|
|
|
|
PowerFeature,
|
|
|
|
|
SnmpFeature,
|
|
|
|
|
} from './features/index.js';
|
|
|
|
|
import type { Feature } from './features/index.js';
|
|
|
|
|
import {
|
|
|
|
|
createScanner,
|
|
|
|
|
createPrinter,
|
|
|
|
|
createSnmpDevice,
|
|
|
|
|
createUpsDevice,
|
|
|
|
|
createSpeaker,
|
|
|
|
|
createDlnaRenderer,
|
|
|
|
|
type IScannerDiscoveryInfo,
|
|
|
|
|
type IPrinterDiscoveryInfo,
|
|
|
|
|
type ISnmpDiscoveryInfo,
|
|
|
|
|
type IUpsDiscoveryInfo,
|
|
|
|
|
type ISpeakerDiscoveryInfo,
|
|
|
|
|
type IDlnaRendererDiscoveryInfo,
|
|
|
|
|
} from './factories/index.js';
|
2026-01-09 07:14:39 +00:00
|
|
|
import type {
|
|
|
|
|
IDeviceManagerOptions,
|
|
|
|
|
IDiscoveredDevice,
|
|
|
|
|
IRetryOptions,
|
|
|
|
|
INetworkScanOptions,
|
|
|
|
|
INetworkScanResult,
|
2026-01-09 09:03:42 +00:00
|
|
|
TFeatureType,
|
2026-01-09 07:14:39 +00:00
|
|
|
} from './interfaces/index.js';
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Default device manager options
|
|
|
|
|
*/
|
|
|
|
|
const DEFAULT_OPTIONS: Required<IDeviceManagerOptions> = {
|
|
|
|
|
autoDiscovery: true,
|
|
|
|
|
discoveryTimeout: 10000,
|
|
|
|
|
enableRetry: true,
|
|
|
|
|
maxRetries: 5,
|
|
|
|
|
retryBaseDelay: 1000,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-09 09:36:43 +00:00
|
|
|
* Check if a name is a "real" name vs a generic placeholder
|
|
|
|
|
*/
|
|
|
|
|
function isRealName(name: string | undefined): boolean {
|
|
|
|
|
if (!name) return false;
|
|
|
|
|
const lower = name.toLowerCase();
|
|
|
|
|
// Generic names that indicate no real name was discovered
|
|
|
|
|
return !(
|
|
|
|
|
lower.includes(' at ') ||
|
|
|
|
|
lower.startsWith('device ') ||
|
|
|
|
|
lower.startsWith('scanner ') ||
|
|
|
|
|
lower.startsWith('printer ') ||
|
|
|
|
|
lower.startsWith('speaker ') ||
|
|
|
|
|
lower.startsWith('ups ') ||
|
|
|
|
|
lower.startsWith('snmp ') ||
|
|
|
|
|
/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/.test(name) // Just an IP address
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Choose the best name from multiple options
|
|
|
|
|
*/
|
|
|
|
|
function chooseBestName(existing: string | undefined, newName: string | undefined): string {
|
|
|
|
|
// If new name is "real" and existing isn't, use new
|
|
|
|
|
if (isRealName(newName) && !isRealName(existing)) {
|
|
|
|
|
return newName!;
|
|
|
|
|
}
|
|
|
|
|
// If existing is "real", keep it
|
|
|
|
|
if (isRealName(existing)) {
|
|
|
|
|
return existing!;
|
|
|
|
|
}
|
|
|
|
|
// Both are generic or undefined - prefer new if it exists
|
|
|
|
|
return newName || existing || 'Unknown Device';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Create a device reference for feature construction
|
|
|
|
|
*/
|
|
|
|
|
function makeDeviceRef(address: string, port: number, name: string): { id: string; address: string; port: number; name: string } {
|
|
|
|
|
return {
|
|
|
|
|
id: `device:${address}`,
|
|
|
|
|
address,
|
|
|
|
|
port,
|
|
|
|
|
name,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Main Device Manager class
|
|
|
|
|
* Discovers and manages network devices using the UniversalDevice architecture
|
|
|
|
|
*
|
|
|
|
|
* Devices are deduplicated by IP address - if the same device is discovered
|
|
|
|
|
* via multiple methods (mDNS, SSDP, IP scan), features are merged into a
|
|
|
|
|
* single UniversalDevice instance.
|
2026-01-09 07:14:39 +00:00
|
|
|
*/
|
|
|
|
|
export class DeviceManager extends plugins.events.EventEmitter {
|
2026-01-09 09:03:42 +00:00
|
|
|
private mdnsDiscovery: MdnsDiscovery;
|
|
|
|
|
private ssdpDiscovery: SsdpDiscovery;
|
2026-01-09 07:14:39 +00:00
|
|
|
private _networkScanner: NetworkScanner | null = null;
|
2026-01-09 09:03:42 +00:00
|
|
|
|
2026-01-09 09:36:43 +00:00
|
|
|
// Devices keyed by IP address for deduplication
|
|
|
|
|
private devicesByIp: Map<string, UniversalDevice> = new Map();
|
2026-01-09 09:03:42 +00:00
|
|
|
|
2026-01-09 07:14:39 +00:00
|
|
|
private options: Required<IDeviceManagerOptions>;
|
|
|
|
|
private retryOptions: IRetryOptions;
|
|
|
|
|
|
|
|
|
|
constructor(options?: IDeviceManagerOptions) {
|
|
|
|
|
super();
|
|
|
|
|
this.options = { ...DEFAULT_OPTIONS, ...options };
|
|
|
|
|
|
|
|
|
|
this.retryOptions = {
|
|
|
|
|
maxRetries: this.options.maxRetries,
|
|
|
|
|
baseDelay: this.options.retryBaseDelay,
|
|
|
|
|
maxDelay: 16000,
|
|
|
|
|
multiplier: 2,
|
|
|
|
|
jitter: true,
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-09 09:03:42 +00:00
|
|
|
this.mdnsDiscovery = new MdnsDiscovery({
|
2026-01-09 07:14:39 +00:00
|
|
|
timeout: this.options.discoveryTimeout,
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-09 09:03:42 +00:00
|
|
|
this.ssdpDiscovery = new SsdpDiscovery();
|
|
|
|
|
|
2026-01-09 07:14:39 +00:00
|
|
|
this.setupDiscoveryEvents();
|
2026-01-09 09:03:42 +00:00
|
|
|
this.setupSsdpDiscoveryEvents();
|
2026-01-09 07:14:39 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-09 09:36:43 +00:00
|
|
|
// ============================================================================
|
|
|
|
|
// Device Registration (with deduplication by IP)
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
2026-01-09 07:14:39 +00:00
|
|
|
/**
|
2026-01-09 09:36:43 +00:00
|
|
|
* Register or merge a device by IP address
|
|
|
|
|
* Returns the device (existing or new) and whether it's newly created
|
2026-01-09 07:14:39 +00:00
|
|
|
*/
|
2026-01-09 09:36:43 +00:00
|
|
|
private registerDevice(
|
|
|
|
|
address: string,
|
|
|
|
|
port: number,
|
|
|
|
|
name: string | undefined,
|
|
|
|
|
manufacturer: string | undefined,
|
|
|
|
|
model: string | undefined,
|
|
|
|
|
feature: Feature
|
|
|
|
|
): { device: UniversalDevice; isNew: boolean; featureAdded: boolean } {
|
|
|
|
|
const existing = this.devicesByIp.get(address);
|
|
|
|
|
|
|
|
|
|
if (existing) {
|
|
|
|
|
// Update name if new one is better
|
|
|
|
|
const betterName = chooseBestName(existing.name, name);
|
|
|
|
|
if (betterName !== existing.name) {
|
|
|
|
|
(existing as { name: string }).name = betterName;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update manufacturer/model if we have better info
|
|
|
|
|
if (manufacturer && !existing.manufacturer) {
|
|
|
|
|
(existing as { manufacturer: string | undefined }).manufacturer = manufacturer;
|
|
|
|
|
}
|
|
|
|
|
if (model && !existing.model) {
|
|
|
|
|
(existing as { model: string | undefined }).model = model;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Add feature if not already present
|
|
|
|
|
if (!existing.hasFeature(feature.type)) {
|
|
|
|
|
existing.addFeature(feature);
|
|
|
|
|
return { device: existing, isNew: false, featureAdded: true };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { device: existing, isNew: false, featureAdded: false };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create new device
|
|
|
|
|
const device = new UniversalDevice(address, port, {
|
|
|
|
|
name: name || `Device at ${address}`,
|
|
|
|
|
manufacturer,
|
|
|
|
|
model,
|
|
|
|
|
retryOptions: this.options.enableRetry ? this.retryOptions : undefined,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
device.addFeature(feature);
|
|
|
|
|
this.devicesByIp.set(address, device);
|
|
|
|
|
|
|
|
|
|
return { device, isNew: true, featureAdded: true };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
// Discovery Event Handlers
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
2026-01-09 07:14:39 +00:00
|
|
|
private setupDiscoveryEvents(): void {
|
2026-01-09 09:03:42 +00:00
|
|
|
this.mdnsDiscovery.on('device:found', (device: IDiscoveredDevice) => {
|
|
|
|
|
this.handleMdnsDeviceFound(device);
|
2026-01-09 07:14:39 +00:00
|
|
|
});
|
|
|
|
|
|
2026-01-09 09:03:42 +00:00
|
|
|
this.mdnsDiscovery.on('device:lost', (device: IDiscoveredDevice) => {
|
2026-01-09 09:36:43 +00:00
|
|
|
this.handleDeviceLost(device.address);
|
2026-01-09 07:14:39 +00:00
|
|
|
});
|
|
|
|
|
|
2026-01-09 09:03:42 +00:00
|
|
|
this.mdnsDiscovery.on('started', () => {
|
|
|
|
|
this.emit('discovery:started');
|
2026-01-09 07:14:39 +00:00
|
|
|
});
|
|
|
|
|
|
2026-01-09 09:03:42 +00:00
|
|
|
this.mdnsDiscovery.on('stopped', () => {
|
|
|
|
|
this.emit('discovery:stopped');
|
2026-01-09 07:14:39 +00:00
|
|
|
});
|
2026-01-09 09:03:42 +00:00
|
|
|
}
|
2026-01-09 07:14:39 +00:00
|
|
|
|
2026-01-09 09:03:42 +00:00
|
|
|
private setupSsdpDiscoveryEvents(): void {
|
|
|
|
|
this.ssdpDiscovery.on('device:found', (device: ISsdpDevice) => {
|
|
|
|
|
this.handleSsdpDeviceFound(device);
|
2026-01-09 07:14:39 +00:00
|
|
|
});
|
|
|
|
|
|
2026-01-09 09:03:42 +00:00
|
|
|
this.ssdpDiscovery.on('started', () => {
|
|
|
|
|
this.emit('ssdp:started');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.ssdpDiscovery.on('stopped', () => {
|
|
|
|
|
this.emit('ssdp:stopped');
|
2026-01-09 07:14:39 +00:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-09 09:36:43 +00:00
|
|
|
private handleMdnsDeviceFound(discovered: IDiscoveredDevice): void {
|
|
|
|
|
const manufacturer = discovered.txtRecords['usb_MFG'] || discovered.txtRecords['mfg'];
|
|
|
|
|
const model = discovered.txtRecords['usb_MDL'] || discovered.txtRecords['mdl'] || discovered.txtRecords['ty'];
|
|
|
|
|
|
|
|
|
|
if (discovered.type === 'scanner') {
|
|
|
|
|
const protocol = discovered.protocol === 'ipp' ? 'escl' : discovered.protocol as 'escl' | 'sane';
|
|
|
|
|
const isSecure = discovered.txtRecords['TLS'] === '1' || (protocol === 'escl' && discovered.port === 443);
|
|
|
|
|
|
|
|
|
|
const feature = new ScanFeature(
|
|
|
|
|
makeDeviceRef(discovered.address, discovered.port, discovered.name),
|
|
|
|
|
discovered.port,
|
2026-01-09 07:14:39 +00:00
|
|
|
{
|
2026-01-09 09:36:43 +00:00
|
|
|
protocol,
|
|
|
|
|
secure: isSecure,
|
|
|
|
|
supportedFormats: this.parseScanFormats(discovered.txtRecords),
|
|
|
|
|
supportedResolutions: this.parseScanResolutions(discovered.txtRecords),
|
|
|
|
|
supportedColorModes: this.parseScanColorModes(discovered.txtRecords),
|
|
|
|
|
supportedSources: this.parseScanSources(discovered.txtRecords),
|
|
|
|
|
hasAdf: discovered.txtRecords['is']?.includes('adf') || false,
|
|
|
|
|
hasDuplex: discovered.txtRecords['is']?.includes('duplex') || false,
|
|
|
|
|
}
|
2026-01-09 07:14:39 +00:00
|
|
|
);
|
|
|
|
|
|
2026-01-09 09:36:43 +00:00
|
|
|
const { device, isNew, featureAdded } = this.registerDevice(
|
|
|
|
|
discovered.address,
|
|
|
|
|
discovered.port,
|
|
|
|
|
discovered.name,
|
|
|
|
|
manufacturer,
|
|
|
|
|
model,
|
|
|
|
|
feature
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (isNew || featureAdded) {
|
|
|
|
|
this.emit('device:found', { device, featureType: 'scan' });
|
|
|
|
|
}
|
|
|
|
|
} else if (discovered.type === 'printer') {
|
|
|
|
|
const ippPath = discovered.txtRecords['rp'] || discovered.txtRecords['rfo'] || '/ipp/print';
|
|
|
|
|
const uri = `ipp://${discovered.address}:${discovered.port}${ippPath.startsWith('/') ? '' : '/'}${ippPath}`;
|
|
|
|
|
|
|
|
|
|
const feature = new PrintFeature(
|
|
|
|
|
makeDeviceRef(discovered.address, discovered.port, discovered.name),
|
|
|
|
|
discovered.port,
|
2026-01-09 07:14:39 +00:00
|
|
|
{
|
2026-01-09 09:36:43 +00:00
|
|
|
protocol: 'ipp',
|
|
|
|
|
uri,
|
|
|
|
|
supportsColor: discovered.txtRecords['Color'] === 'T' || discovered.txtRecords['color'] === 'true',
|
|
|
|
|
supportsDuplex: discovered.txtRecords['Duplex'] === 'T' || discovered.txtRecords['duplex'] === 'true',
|
|
|
|
|
}
|
2026-01-09 07:14:39 +00:00
|
|
|
);
|
|
|
|
|
|
2026-01-09 09:36:43 +00:00
|
|
|
const { device, isNew, featureAdded } = this.registerDevice(
|
|
|
|
|
discovered.address,
|
|
|
|
|
discovered.port,
|
|
|
|
|
discovered.name,
|
|
|
|
|
manufacturer,
|
|
|
|
|
model,
|
|
|
|
|
feature
|
|
|
|
|
);
|
2026-01-09 09:03:42 +00:00
|
|
|
|
2026-01-09 09:36:43 +00:00
|
|
|
if (isNew || featureAdded) {
|
|
|
|
|
this.emit('device:found', { device, featureType: 'print' });
|
|
|
|
|
}
|
|
|
|
|
} else if (discovered.type === 'speaker') {
|
|
|
|
|
const protocol = discovered.protocol as 'sonos' | 'airplay' | 'chromecast' | 'dlna';
|
2026-01-09 09:03:42 +00:00
|
|
|
|
2026-01-09 09:36:43 +00:00
|
|
|
const playbackFeature = new PlaybackFeature(
|
|
|
|
|
makeDeviceRef(discovered.address, discovered.port, discovered.name),
|
|
|
|
|
discovered.port,
|
2026-01-09 09:03:42 +00:00
|
|
|
{
|
2026-01-09 09:36:43 +00:00
|
|
|
protocol,
|
|
|
|
|
supportsQueue: protocol === 'sonos',
|
|
|
|
|
supportsSeek: protocol !== 'airplay',
|
|
|
|
|
}
|
2026-01-09 09:03:42 +00:00
|
|
|
);
|
2026-01-09 09:36:43 +00:00
|
|
|
|
|
|
|
|
const volumeFeature = new VolumeFeature(
|
|
|
|
|
makeDeviceRef(discovered.address, discovered.port, discovered.name),
|
|
|
|
|
discovered.port,
|
2026-01-09 09:03:42 +00:00
|
|
|
{
|
2026-01-09 09:36:43 +00:00
|
|
|
volumeProtocol: protocol,
|
|
|
|
|
minVolume: 0,
|
|
|
|
|
maxVolume: 100,
|
|
|
|
|
supportsMute: true,
|
|
|
|
|
}
|
2026-01-09 09:03:42 +00:00
|
|
|
);
|
2026-01-09 09:36:43 +00:00
|
|
|
|
|
|
|
|
const modelName = discovered.txtRecords['model'] || discovered.txtRecords['md'];
|
|
|
|
|
const { device, isNew } = this.registerDevice(
|
|
|
|
|
discovered.address,
|
|
|
|
|
discovered.port,
|
|
|
|
|
discovered.name,
|
|
|
|
|
undefined,
|
|
|
|
|
modelName,
|
|
|
|
|
playbackFeature
|
2026-01-09 09:03:42 +00:00
|
|
|
);
|
2026-01-09 09:36:43 +00:00
|
|
|
|
|
|
|
|
// Also add volume feature
|
|
|
|
|
if (!device.hasFeature('volume')) {
|
|
|
|
|
device.addFeature(volumeFeature);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isNew) {
|
|
|
|
|
this.emit('device:found', { device, featureType: 'playback' });
|
|
|
|
|
}
|
2026-01-09 09:03:42 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-09 09:36:43 +00:00
|
|
|
private handleSsdpDeviceFound(ssdpDevice: ISsdpDevice): void {
|
|
|
|
|
if (!ssdpDevice.description) {
|
2026-01-09 09:03:42 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-09 09:36:43 +00:00
|
|
|
const serviceType = ssdpDevice.serviceType;
|
|
|
|
|
const deviceType = ssdpDevice.description.deviceType;
|
|
|
|
|
const desc = ssdpDevice.description;
|
2026-01-09 09:03:42 +00:00
|
|
|
|
|
|
|
|
// Check for DLNA Media Renderer
|
|
|
|
|
if (serviceType.includes('MediaRenderer') || deviceType.includes('MediaRenderer')) {
|
2026-01-09 09:36:43 +00:00
|
|
|
const port = parseInt(new URL(ssdpDevice.location).port) || 80;
|
|
|
|
|
|
|
|
|
|
const playbackFeature = new PlaybackFeature(
|
|
|
|
|
makeDeviceRef(ssdpDevice.address, port, desc.friendlyName),
|
|
|
|
|
port,
|
|
|
|
|
{
|
|
|
|
|
protocol: 'dlna',
|
|
|
|
|
supportsQueue: false,
|
|
|
|
|
supportsSeek: true,
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const volumeFeature = new VolumeFeature(
|
|
|
|
|
makeDeviceRef(ssdpDevice.address, port, desc.friendlyName),
|
|
|
|
|
port,
|
|
|
|
|
{
|
|
|
|
|
volumeProtocol: 'dlna',
|
|
|
|
|
minVolume: 0,
|
|
|
|
|
maxVolume: 100,
|
|
|
|
|
supportsMute: true,
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const { device, isNew } = this.registerDevice(
|
|
|
|
|
ssdpDevice.address,
|
|
|
|
|
port,
|
|
|
|
|
desc.friendlyName,
|
|
|
|
|
desc.manufacturer,
|
|
|
|
|
desc.modelName,
|
|
|
|
|
playbackFeature
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (!device.hasFeature('volume')) {
|
|
|
|
|
device.addFeature(volumeFeature);
|
2026-01-09 09:03:42 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-09 09:36:43 +00:00
|
|
|
if (isNew) {
|
|
|
|
|
this.emit('device:found', { device, featureType: 'playback' });
|
2026-01-09 09:03:42 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check for Sonos ZonePlayer
|
|
|
|
|
if (serviceType.includes('ZonePlayer') || deviceType.includes('ZonePlayer')) {
|
2026-01-09 09:36:43 +00:00
|
|
|
const playbackFeature = new PlaybackFeature(
|
|
|
|
|
makeDeviceRef(ssdpDevice.address, 1400, desc.friendlyName),
|
|
|
|
|
1400,
|
2026-01-09 09:03:42 +00:00
|
|
|
{
|
2026-01-09 09:36:43 +00:00
|
|
|
protocol: 'sonos',
|
|
|
|
|
supportsQueue: true,
|
|
|
|
|
supportsSeek: true,
|
|
|
|
|
}
|
2026-01-09 09:03:42 +00:00
|
|
|
);
|
2026-01-09 07:14:39 +00:00
|
|
|
|
2026-01-09 09:36:43 +00:00
|
|
|
const volumeFeature = new VolumeFeature(
|
|
|
|
|
makeDeviceRef(ssdpDevice.address, 1400, desc.friendlyName),
|
|
|
|
|
1400,
|
|
|
|
|
{
|
|
|
|
|
volumeProtocol: 'sonos',
|
|
|
|
|
minVolume: 0,
|
|
|
|
|
maxVolume: 100,
|
|
|
|
|
supportsMute: true,
|
2026-01-09 07:14:39 +00:00
|
|
|
}
|
2026-01-09 09:36:43 +00:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const { device, isNew } = this.registerDevice(
|
|
|
|
|
ssdpDevice.address,
|
|
|
|
|
1400,
|
|
|
|
|
desc.friendlyName,
|
|
|
|
|
desc.manufacturer,
|
|
|
|
|
desc.modelName,
|
|
|
|
|
playbackFeature
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (!device.hasFeature('volume')) {
|
|
|
|
|
device.addFeature(volumeFeature);
|
2026-01-09 07:14:39 +00:00
|
|
|
}
|
2026-01-09 09:36:43 +00:00
|
|
|
|
|
|
|
|
if (isNew) {
|
|
|
|
|
this.emit('device:found', { device, featureType: 'playback' });
|
2026-01-09 07:14:39 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-09 09:36:43 +00:00
|
|
|
private async handleDeviceLost(address: string): Promise<void> {
|
|
|
|
|
const device = this.devicesByIp.get(address);
|
|
|
|
|
if (device) {
|
|
|
|
|
try {
|
|
|
|
|
await device.disconnect();
|
|
|
|
|
} catch {
|
|
|
|
|
// Ignore disconnect errors
|
|
|
|
|
}
|
|
|
|
|
this.devicesByIp.delete(address);
|
|
|
|
|
this.emit('device:lost', address);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
// Parsing Helpers
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
private parseScanFormats(txtRecords: Record<string, string>): ('jpeg' | 'png' | 'pdf' | 'tiff')[] {
|
|
|
|
|
const formats: ('jpeg' | 'png' | 'pdf' | 'tiff')[] = [];
|
|
|
|
|
const pdl = txtRecords['pdl'] || txtRecords['DocumentFormat'] || '';
|
|
|
|
|
|
|
|
|
|
if (pdl.includes('jpeg') || pdl.includes('jpg')) formats.push('jpeg');
|
|
|
|
|
if (pdl.includes('png')) formats.push('png');
|
|
|
|
|
if (pdl.includes('pdf')) formats.push('pdf');
|
|
|
|
|
if (pdl.includes('tiff')) formats.push('tiff');
|
|
|
|
|
|
|
|
|
|
return formats.length > 0 ? formats : ['jpeg', 'png'];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private parseScanResolutions(txtRecords: Record<string, string>): number[] {
|
|
|
|
|
const rs = txtRecords['rs'] || '';
|
|
|
|
|
const parts = rs.split(',').map((s) => parseInt(s.trim())).filter((n) => !isNaN(n) && n > 0);
|
|
|
|
|
return parts.length > 0 ? parts : [75, 150, 300, 600];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private parseScanColorModes(txtRecords: Record<string, string>): ('color' | 'grayscale' | 'blackwhite')[] {
|
|
|
|
|
const cs = txtRecords['cs'] || txtRecords['ColorSpace'] || '';
|
|
|
|
|
const modes: ('color' | 'grayscale' | 'blackwhite')[] = [];
|
|
|
|
|
|
|
|
|
|
if (cs.includes('color') || cs.includes('RGB')) modes.push('color');
|
|
|
|
|
if (cs.includes('gray') || cs.includes('grayscale')) modes.push('grayscale');
|
|
|
|
|
if (cs.includes('binary') || cs.includes('bw')) modes.push('blackwhite');
|
|
|
|
|
|
|
|
|
|
return modes.length > 0 ? modes : ['color', 'grayscale'];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private parseScanSources(txtRecords: Record<string, string>): ('flatbed' | 'adf' | 'adf-duplex')[] {
|
|
|
|
|
const is = txtRecords['is'] || txtRecords['InputSource'] || '';
|
|
|
|
|
const sources: ('flatbed' | 'adf' | 'adf-duplex')[] = [];
|
|
|
|
|
|
|
|
|
|
if (is.includes('platen') || is.includes('flatbed') || is === '') {
|
|
|
|
|
sources.push('flatbed');
|
|
|
|
|
}
|
|
|
|
|
if (is.includes('adf') || is.includes('feeder')) {
|
|
|
|
|
sources.push('adf');
|
|
|
|
|
}
|
|
|
|
|
if (is.includes('duplex')) {
|
|
|
|
|
sources.push('adf-duplex');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return sources.length > 0 ? sources : ['flatbed'];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
// Discovery Control
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
2026-01-09 07:14:39 +00:00
|
|
|
public async startDiscovery(): Promise<void> {
|
2026-01-09 09:03:42 +00:00
|
|
|
await Promise.all([
|
|
|
|
|
this.mdnsDiscovery.start(),
|
|
|
|
|
this.ssdpDiscovery.start(),
|
|
|
|
|
]);
|
2026-01-09 07:14:39 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async stopDiscovery(): Promise<void> {
|
2026-01-09 09:03:42 +00:00
|
|
|
await Promise.all([
|
|
|
|
|
this.mdnsDiscovery.stop(),
|
|
|
|
|
this.ssdpDiscovery.stop(),
|
|
|
|
|
]);
|
2026-01-09 07:14:39 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public get isDiscovering(): boolean {
|
2026-01-09 09:03:42 +00:00
|
|
|
return this.mdnsDiscovery.running || this.ssdpDiscovery.isRunning;
|
2026-01-09 07:14:39 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-09 09:36:43 +00:00
|
|
|
// ============================================================================
|
|
|
|
|
// Device Access - All Devices
|
|
|
|
|
// ============================================================================
|
2026-01-09 09:03:42 +00:00
|
|
|
|
|
|
|
|
/**
|
2026-01-09 09:36:43 +00:00
|
|
|
* Get all devices
|
2026-01-09 09:03:42 +00:00
|
|
|
*/
|
2026-01-09 09:36:43 +00:00
|
|
|
public getDevices(): UniversalDevice[] {
|
|
|
|
|
return Array.from(this.devicesByIp.values());
|
2026-01-09 09:03:42 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-09 09:36:43 +00:00
|
|
|
* Get device by ID
|
2026-01-09 09:03:42 +00:00
|
|
|
*/
|
2026-01-09 09:36:43 +00:00
|
|
|
public getDevice(id: string): UniversalDevice | undefined {
|
|
|
|
|
for (const device of this.devicesByIp.values()) {
|
|
|
|
|
if (device.id === id) {
|
|
|
|
|
return device;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return undefined;
|
2026-01-09 09:03:42 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-09 09:36:43 +00:00
|
|
|
* Get device by address
|
2026-01-09 09:03:42 +00:00
|
|
|
*/
|
2026-01-09 09:36:43 +00:00
|
|
|
public getDeviceByAddress(address: string): UniversalDevice | undefined {
|
|
|
|
|
return this.devicesByIp.get(address);
|
2026-01-09 09:03:42 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-09 09:36:43 +00:00
|
|
|
// ============================================================================
|
|
|
|
|
// Device Access - By Feature Type
|
|
|
|
|
// ============================================================================
|
2026-01-09 09:03:42 +00:00
|
|
|
|
|
|
|
|
/**
|
2026-01-09 09:36:43 +00:00
|
|
|
* Get all devices that have a specific feature
|
2026-01-09 09:03:42 +00:00
|
|
|
*/
|
2026-01-09 09:36:43 +00:00
|
|
|
public getDevicesWithFeature(featureType: TFeatureType): UniversalDevice[] {
|
|
|
|
|
return this.getDevices().filter(device => device.hasFeature(featureType));
|
2026-01-09 09:03:42 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-09 09:36:43 +00:00
|
|
|
* Get all devices that have ALL specified features
|
2026-01-09 09:03:42 +00:00
|
|
|
*/
|
2026-01-09 09:36:43 +00:00
|
|
|
public getDevicesWithFeatures(featureTypes: TFeatureType[]): UniversalDevice[] {
|
|
|
|
|
return this.getDevices().filter(device => device.hasFeatures(featureTypes));
|
2026-01-09 09:03:42 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-09 07:14:39 +00:00
|
|
|
/**
|
2026-01-09 09:36:43 +00:00
|
|
|
* Get all devices that have ANY of the specified features
|
2026-01-09 07:14:39 +00:00
|
|
|
*/
|
2026-01-09 09:36:43 +00:00
|
|
|
public getDevicesWithAnyFeature(featureTypes: TFeatureType[]): UniversalDevice[] {
|
|
|
|
|
return this.getDevices().filter(device =>
|
|
|
|
|
featureTypes.some(type => device.hasFeature(type))
|
|
|
|
|
);
|
2026-01-09 07:14:39 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-09 09:36:43 +00:00
|
|
|
// ============================================================================
|
|
|
|
|
// Convenience Accessors (by feature type)
|
|
|
|
|
// ============================================================================
|
2026-01-09 07:14:39 +00:00
|
|
|
|
|
|
|
|
/**
|
2026-01-09 09:36:43 +00:00
|
|
|
* Get all scanner devices
|
2026-01-09 09:03:42 +00:00
|
|
|
*/
|
2026-01-09 09:36:43 +00:00
|
|
|
public getScanners(): UniversalDevice[] {
|
|
|
|
|
return this.getDevicesWithFeature('scan');
|
2026-01-09 09:03:42 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-09 09:36:43 +00:00
|
|
|
* Get all printer devices
|
2026-01-09 09:03:42 +00:00
|
|
|
*/
|
2026-01-09 09:36:43 +00:00
|
|
|
public getPrinters(): UniversalDevice[] {
|
|
|
|
|
return this.getDevicesWithFeature('print');
|
2026-01-09 09:03:42 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-09 09:36:43 +00:00
|
|
|
* Get all speaker devices (playback feature)
|
2026-01-09 09:03:42 +00:00
|
|
|
*/
|
2026-01-09 09:36:43 +00:00
|
|
|
public getSpeakers(): UniversalDevice[] {
|
|
|
|
|
return this.getDevicesWithFeature('playback');
|
2026-01-09 09:03:42 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-09 09:36:43 +00:00
|
|
|
* Get all UPS/power devices
|
2026-01-09 09:03:42 +00:00
|
|
|
*/
|
2026-01-09 09:36:43 +00:00
|
|
|
public getUpsDevices(): UniversalDevice[] {
|
|
|
|
|
return this.getDevicesWithFeature('power');
|
2026-01-09 09:03:42 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-09 09:36:43 +00:00
|
|
|
* Get all SNMP devices
|
2026-01-09 07:14:39 +00:00
|
|
|
*/
|
2026-01-09 09:36:43 +00:00
|
|
|
public getSnmpDevices(): UniversalDevice[] {
|
|
|
|
|
return this.getDevicesWithFeature('snmp');
|
2026-01-09 07:14:39 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-09 09:36:43 +00:00
|
|
|
// ============================================================================
|
|
|
|
|
// Manual Device Addition
|
|
|
|
|
// ============================================================================
|
2026-01-09 09:03:42 +00:00
|
|
|
|
|
|
|
|
/**
|
2026-01-09 09:36:43 +00:00
|
|
|
* Add a device
|
2026-01-09 09:03:42 +00:00
|
|
|
*/
|
2026-01-09 09:36:43 +00:00
|
|
|
public addDevice(device: UniversalDevice): void {
|
|
|
|
|
const existing = this.devicesByIp.get(device.address);
|
|
|
|
|
if (existing) {
|
|
|
|
|
// Merge features into existing device
|
|
|
|
|
for (const feature of device.getFeatures()) {
|
|
|
|
|
if (!existing.hasFeature(feature.type)) {
|
|
|
|
|
existing.addFeature(feature);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Update name if better
|
|
|
|
|
const betterName = chooseBestName(existing.name, device.name);
|
|
|
|
|
if (betterName !== existing.name) {
|
|
|
|
|
(existing as { name: string }).name = betterName;
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
this.devicesByIp.set(device.address, device);
|
|
|
|
|
this.emit('device:added', device);
|
2026-01-09 09:03:42 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-09 09:36:43 +00:00
|
|
|
* Add a scanner manually
|
2026-01-09 07:14:39 +00:00
|
|
|
*/
|
|
|
|
|
public async addScanner(
|
|
|
|
|
address: string,
|
|
|
|
|
port: number,
|
|
|
|
|
protocol: 'escl' | 'sane' = 'escl',
|
|
|
|
|
name?: string
|
2026-01-09 09:36:43 +00:00
|
|
|
): Promise<UniversalDevice> {
|
|
|
|
|
const feature = new ScanFeature(
|
|
|
|
|
makeDeviceRef(address, port, name || `Scanner at ${address}`),
|
|
|
|
|
port,
|
|
|
|
|
{ protocol }
|
2026-01-09 07:14:39 +00:00
|
|
|
);
|
|
|
|
|
|
2026-01-09 09:36:43 +00:00
|
|
|
const { device } = this.registerDevice(
|
|
|
|
|
address,
|
|
|
|
|
port,
|
|
|
|
|
name,
|
|
|
|
|
undefined,
|
|
|
|
|
undefined,
|
|
|
|
|
feature
|
|
|
|
|
);
|
2026-01-09 07:14:39 +00:00
|
|
|
|
2026-01-09 09:36:43 +00:00
|
|
|
await device.connect();
|
|
|
|
|
return device;
|
2026-01-09 07:14:39 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-09 09:36:43 +00:00
|
|
|
* Add a printer manually
|
2026-01-09 07:14:39 +00:00
|
|
|
*/
|
|
|
|
|
public async addPrinter(
|
|
|
|
|
address: string,
|
|
|
|
|
port: number = 631,
|
|
|
|
|
name?: string,
|
|
|
|
|
ippPath?: string
|
2026-01-09 09:36:43 +00:00
|
|
|
): Promise<UniversalDevice> {
|
|
|
|
|
const path = ippPath || '/ipp/print';
|
|
|
|
|
const uri = `ipp://${address}:${port}${path.startsWith('/') ? '' : '/'}${path}`;
|
|
|
|
|
|
|
|
|
|
const feature = new PrintFeature(
|
|
|
|
|
makeDeviceRef(address, port, name || `Printer at ${address}`),
|
|
|
|
|
port,
|
|
|
|
|
{ protocol: 'ipp', uri }
|
2026-01-09 07:14:39 +00:00
|
|
|
);
|
|
|
|
|
|
2026-01-09 09:36:43 +00:00
|
|
|
const { device } = this.registerDevice(
|
|
|
|
|
address,
|
|
|
|
|
port,
|
|
|
|
|
name,
|
|
|
|
|
undefined,
|
|
|
|
|
undefined,
|
|
|
|
|
feature
|
|
|
|
|
);
|
2026-01-09 09:03:42 +00:00
|
|
|
|
2026-01-09 09:36:43 +00:00
|
|
|
await device.connect();
|
|
|
|
|
return device;
|
2026-01-09 07:14:39 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-09 09:03:42 +00:00
|
|
|
/**
|
|
|
|
|
* Add an SNMP device manually
|
|
|
|
|
*/
|
|
|
|
|
public async addSnmpDevice(
|
|
|
|
|
address: string,
|
|
|
|
|
port: number = 161,
|
|
|
|
|
options?: { name?: string; community?: string }
|
2026-01-09 09:36:43 +00:00
|
|
|
): Promise<UniversalDevice> {
|
|
|
|
|
const feature = new SnmpFeature(
|
|
|
|
|
makeDeviceRef(address, port, options?.name || `SNMP Device at ${address}`),
|
|
|
|
|
port,
|
|
|
|
|
{ community: options?.community ?? 'public' }
|
|
|
|
|
);
|
2026-01-09 09:03:42 +00:00
|
|
|
|
2026-01-09 09:36:43 +00:00
|
|
|
const { device } = this.registerDevice(
|
|
|
|
|
address,
|
|
|
|
|
port,
|
|
|
|
|
options?.name,
|
|
|
|
|
undefined,
|
|
|
|
|
undefined,
|
|
|
|
|
feature
|
2026-01-09 09:03:42 +00:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await device.connect();
|
|
|
|
|
return device;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Add a UPS device manually
|
|
|
|
|
*/
|
|
|
|
|
public async addUpsDevice(
|
|
|
|
|
address: string,
|
2026-01-09 09:36:43 +00:00
|
|
|
protocol: 'nut' | 'snmp',
|
2026-01-09 09:03:42 +00:00
|
|
|
options?: { name?: string; port?: number; upsName?: string; community?: string }
|
2026-01-09 09:36:43 +00:00
|
|
|
): Promise<UniversalDevice> {
|
2026-01-09 09:03:42 +00:00
|
|
|
const port = options?.port ?? (protocol === 'nut' ? 3493 : 161);
|
|
|
|
|
|
2026-01-09 09:36:43 +00:00
|
|
|
const feature = new PowerFeature(
|
|
|
|
|
makeDeviceRef(address, port, options?.name || `UPS at ${address}`),
|
|
|
|
|
port,
|
2026-01-09 09:03:42 +00:00
|
|
|
{
|
|
|
|
|
protocol,
|
|
|
|
|
upsName: options?.upsName,
|
|
|
|
|
community: options?.community,
|
2026-01-09 09:36:43 +00:00
|
|
|
}
|
2026-01-09 09:03:42 +00:00
|
|
|
);
|
|
|
|
|
|
2026-01-09 09:36:43 +00:00
|
|
|
const { device } = this.registerDevice(
|
|
|
|
|
address,
|
|
|
|
|
port,
|
|
|
|
|
options?.name,
|
|
|
|
|
undefined,
|
|
|
|
|
undefined,
|
|
|
|
|
feature
|
|
|
|
|
);
|
2026-01-09 09:03:42 +00:00
|
|
|
|
2026-01-09 09:36:43 +00:00
|
|
|
await device.connect();
|
2026-01-09 09:03:42 +00:00
|
|
|
return device;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-09 09:36:43 +00:00
|
|
|
* Add a speaker manually
|
2026-01-09 09:03:42 +00:00
|
|
|
*/
|
2026-01-09 09:36:43 +00:00
|
|
|
public async addSpeaker(
|
2026-01-09 09:03:42 +00:00
|
|
|
address: string,
|
2026-01-09 09:36:43 +00:00
|
|
|
protocol: 'sonos' | 'airplay' | 'chromecast' | 'dlna',
|
2026-01-09 09:03:42 +00:00
|
|
|
options?: { name?: string; port?: number }
|
2026-01-09 09:36:43 +00:00
|
|
|
): Promise<UniversalDevice> {
|
|
|
|
|
const defaultPort = protocol === 'sonos' ? 1400 : protocol === 'airplay' ? 7000 : protocol === 'chromecast' ? 8009 : 80;
|
|
|
|
|
const port = options?.port ?? defaultPort;
|
|
|
|
|
|
|
|
|
|
const deviceName = options?.name || `${protocol} at ${address}`;
|
|
|
|
|
const playbackFeature = new PlaybackFeature(
|
|
|
|
|
makeDeviceRef(address, port, deviceName),
|
|
|
|
|
port,
|
2026-01-09 09:03:42 +00:00
|
|
|
{
|
2026-01-09 09:36:43 +00:00
|
|
|
protocol,
|
|
|
|
|
supportsQueue: protocol === 'sonos',
|
|
|
|
|
supportsSeek: protocol !== 'airplay',
|
|
|
|
|
}
|
2026-01-09 09:03:42 +00:00
|
|
|
);
|
|
|
|
|
|
2026-01-09 09:36:43 +00:00
|
|
|
const volumeFeature = new VolumeFeature(
|
|
|
|
|
makeDeviceRef(address, port, deviceName),
|
|
|
|
|
port,
|
2026-01-09 09:03:42 +00:00
|
|
|
{
|
2026-01-09 09:36:43 +00:00
|
|
|
volumeProtocol: protocol,
|
|
|
|
|
minVolume: 0,
|
|
|
|
|
maxVolume: 100,
|
|
|
|
|
supportsMute: true,
|
|
|
|
|
}
|
2026-01-09 09:03:42 +00:00
|
|
|
);
|
|
|
|
|
|
2026-01-09 09:36:43 +00:00
|
|
|
const { device } = this.registerDevice(
|
|
|
|
|
address,
|
|
|
|
|
port,
|
|
|
|
|
options?.name,
|
|
|
|
|
undefined,
|
|
|
|
|
undefined,
|
|
|
|
|
playbackFeature
|
2026-01-09 09:03:42 +00:00
|
|
|
);
|
|
|
|
|
|
2026-01-09 09:36:43 +00:00
|
|
|
if (!device.hasFeature('volume')) {
|
|
|
|
|
device.addFeature(volumeFeature);
|
|
|
|
|
}
|
2026-01-09 09:03:42 +00:00
|
|
|
|
2026-01-09 09:36:43 +00:00
|
|
|
await device.connect();
|
|
|
|
|
return device;
|
2026-01-09 09:03:42 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
2026-01-09 09:36:43 +00:00
|
|
|
// Feature Access
|
2026-01-09 09:03:42 +00:00
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-09 09:36:43 +00:00
|
|
|
* Get a feature from a device
|
2026-01-09 09:03:42 +00:00
|
|
|
*/
|
2026-01-09 09:36:43 +00:00
|
|
|
public getFeature<T extends Feature>(deviceId: string, featureType: TFeatureType): T | undefined {
|
|
|
|
|
const device = this.getDevice(deviceId);
|
|
|
|
|
return device?.getFeature<T>(featureType);
|
2026-01-09 09:03:42 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-09 09:36:43 +00:00
|
|
|
* Add a feature to a device
|
2026-01-09 09:03:42 +00:00
|
|
|
*/
|
2026-01-09 09:36:43 +00:00
|
|
|
public addFeature(deviceId: string, feature: Feature): boolean {
|
|
|
|
|
const device = this.getDevice(deviceId);
|
|
|
|
|
if (device) {
|
|
|
|
|
device.addFeature(feature);
|
|
|
|
|
this.emit('feature:added', { device, feature });
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
return false;
|
2026-01-09 09:03:42 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-09 09:36:43 +00:00
|
|
|
* Remove a feature from a device
|
2026-01-09 09:03:42 +00:00
|
|
|
*/
|
2026-01-09 09:36:43 +00:00
|
|
|
public async removeFeature(deviceId: string, featureType: TFeatureType): Promise<boolean> {
|
|
|
|
|
const device = this.getDevice(deviceId);
|
|
|
|
|
if (device) {
|
|
|
|
|
const removed = await device.removeFeature(featureType);
|
|
|
|
|
if (removed) {
|
|
|
|
|
this.emit('feature:removed', { device, featureType });
|
2026-01-09 07:14:39 +00:00
|
|
|
}
|
2026-01-09 09:36:43 +00:00
|
|
|
return removed;
|
2026-01-09 07:14:39 +00:00
|
|
|
}
|
2026-01-09 09:36:43 +00:00
|
|
|
return false;
|
2026-01-09 09:03:42 +00:00
|
|
|
}
|
2026-01-09 07:14:39 +00:00
|
|
|
|
2026-01-09 09:36:43 +00:00
|
|
|
// ============================================================================
|
|
|
|
|
// Network Scanning
|
|
|
|
|
// ============================================================================
|
2026-01-09 09:03:42 +00:00
|
|
|
|
2026-01-09 09:36:43 +00:00
|
|
|
public get networkScanner(): NetworkScanner {
|
|
|
|
|
if (!this._networkScanner) {
|
|
|
|
|
this._networkScanner = new NetworkScanner();
|
|
|
|
|
this.setupNetworkScannerEvents();
|
|
|
|
|
}
|
|
|
|
|
return this._networkScanner;
|
2026-01-09 09:03:42 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-09 09:36:43 +00:00
|
|
|
private setupNetworkScannerEvents(): void {
|
|
|
|
|
if (!this._networkScanner) return;
|
|
|
|
|
|
|
|
|
|
this._networkScanner.on('device:found', (result: INetworkScanResult) => {
|
|
|
|
|
this.emit('network:device:found', result);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this._networkScanner.on('progress', (progress) => {
|
|
|
|
|
this.emit('network:progress', progress);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this._networkScanner.on('complete', (results) => {
|
|
|
|
|
this.emit('network:complete', results);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this._networkScanner.on('error', (error) => {
|
|
|
|
|
this.emit('error', error);
|
|
|
|
|
});
|
2026-01-09 09:03:42 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-09 09:36:43 +00:00
|
|
|
public async scanNetwork(options: INetworkScanOptions): Promise<UniversalDevice[]> {
|
|
|
|
|
const results = await this.networkScanner.scan(options);
|
|
|
|
|
const foundDevices: UniversalDevice[] = [];
|
|
|
|
|
const seenAddresses = new Set<string>();
|
|
|
|
|
|
|
|
|
|
for (const result of results) {
|
|
|
|
|
// Skip if we already processed this IP in this scan
|
|
|
|
|
if (seenAddresses.has(result.address)) continue;
|
|
|
|
|
seenAddresses.add(result.address);
|
|
|
|
|
|
|
|
|
|
for (const deviceInfo of result.devices) {
|
|
|
|
|
try {
|
|
|
|
|
let device: UniversalDevice | null = null;
|
|
|
|
|
|
|
|
|
|
if (deviceInfo.type === 'scanner') {
|
|
|
|
|
device = await this.addScanner(
|
|
|
|
|
result.address,
|
|
|
|
|
deviceInfo.port,
|
|
|
|
|
deviceInfo.protocol as 'escl' | 'sane',
|
|
|
|
|
deviceInfo.name
|
|
|
|
|
);
|
|
|
|
|
} else if (deviceInfo.type === 'printer' && deviceInfo.protocol === 'ipp') {
|
|
|
|
|
device = await this.addPrinter(result.address, deviceInfo.port, deviceInfo.name);
|
|
|
|
|
} else if (deviceInfo.type === 'speaker') {
|
|
|
|
|
device = await this.addSpeaker(
|
|
|
|
|
result.address,
|
|
|
|
|
deviceInfo.protocol as 'sonos' | 'airplay' | 'chromecast' | 'dlna',
|
|
|
|
|
{ name: deviceInfo.name, port: deviceInfo.port }
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (device && !foundDevices.includes(device)) {
|
|
|
|
|
foundDevices.push(device);
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.emit('error', error instanceof Error ? error : new Error(String(error)));
|
2026-01-09 09:03:42 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-01-09 09:36:43 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return foundDevices;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async cancelNetworkScan(): Promise<void> {
|
|
|
|
|
if (this._networkScanner) {
|
|
|
|
|
await this._networkScanner.cancel();
|
2026-01-09 09:03:42 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-09 09:36:43 +00:00
|
|
|
public get isNetworkScanning(): boolean {
|
|
|
|
|
return this._networkScanner?.isScanning ?? false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
// Device Removal and Cleanup
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
2026-01-09 09:03:42 +00:00
|
|
|
/**
|
2026-01-09 09:36:43 +00:00
|
|
|
* Remove a device by ID
|
2026-01-09 09:03:42 +00:00
|
|
|
*/
|
2026-01-09 09:36:43 +00:00
|
|
|
public async removeDevice(id: string): Promise<boolean> {
|
|
|
|
|
const device = this.getDevice(id);
|
2026-01-09 09:03:42 +00:00
|
|
|
if (device) {
|
2026-01-09 09:36:43 +00:00
|
|
|
await device.disconnect();
|
|
|
|
|
this.devicesByIp.delete(device.address);
|
|
|
|
|
this.emit('device:removed', id);
|
2026-01-09 09:03:42 +00:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-09 09:36:43 +00:00
|
|
|
* Remove a device by address
|
2026-01-09 09:03:42 +00:00
|
|
|
*/
|
2026-01-09 09:36:43 +00:00
|
|
|
public async removeDeviceByAddress(address: string): Promise<boolean> {
|
|
|
|
|
const device = this.devicesByIp.get(address);
|
2026-01-09 09:03:42 +00:00
|
|
|
if (device) {
|
2026-01-09 09:36:43 +00:00
|
|
|
await device.disconnect();
|
|
|
|
|
this.devicesByIp.delete(address);
|
|
|
|
|
this.emit('device:removed', device.id);
|
|
|
|
|
return true;
|
2026-01-09 09:03:42 +00:00
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-09 09:36:43 +00:00
|
|
|
* Disconnect all devices
|
2026-01-09 09:03:42 +00:00
|
|
|
*/
|
2026-01-09 09:36:43 +00:00
|
|
|
public async disconnectAll(): Promise<void> {
|
|
|
|
|
const promises: Promise<void>[] = [];
|
|
|
|
|
for (const device of this.devicesByIp.values()) {
|
|
|
|
|
promises.push(device.disconnect().catch(() => {}));
|
2026-01-09 09:03:42 +00:00
|
|
|
}
|
2026-01-09 09:36:43 +00:00
|
|
|
await Promise.all(promises);
|
2026-01-09 09:03:42 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-09 09:36:43 +00:00
|
|
|
* Stop discovery and disconnect all devices
|
2026-01-09 09:03:42 +00:00
|
|
|
*/
|
2026-01-09 09:36:43 +00:00
|
|
|
public async shutdown(): Promise<void> {
|
|
|
|
|
await this.stopDiscovery();
|
|
|
|
|
await this.disconnectAll();
|
|
|
|
|
this.devicesByIp.clear();
|
2026-01-09 07:14:39 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-09 09:36:43 +00:00
|
|
|
// ============================================================================
|
|
|
|
|
// Exports
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
2026-01-09 09:03:42 +00:00
|
|
|
export {
|
|
|
|
|
// Discovery
|
|
|
|
|
MdnsDiscovery,
|
|
|
|
|
NetworkScanner,
|
|
|
|
|
SsdpDiscovery,
|
|
|
|
|
SERVICE_TYPES,
|
|
|
|
|
SSDP_SERVICE_TYPES,
|
|
|
|
|
|
2026-01-09 09:36:43 +00:00
|
|
|
// Universal Device
|
2026-01-09 09:03:42 +00:00
|
|
|
UniversalDevice,
|
2026-01-09 09:36:43 +00:00
|
|
|
|
|
|
|
|
// Features
|
|
|
|
|
ScanFeature,
|
|
|
|
|
PrintFeature,
|
|
|
|
|
PlaybackFeature,
|
|
|
|
|
VolumeFeature,
|
|
|
|
|
PowerFeature,
|
|
|
|
|
SnmpFeature,
|
|
|
|
|
|
|
|
|
|
// Factories
|
|
|
|
|
createScanner,
|
|
|
|
|
createPrinter,
|
|
|
|
|
createSnmpDevice,
|
|
|
|
|
createUpsDevice,
|
|
|
|
|
createSpeaker,
|
|
|
|
|
createDlnaRenderer,
|
2026-01-09 09:03:42 +00:00
|
|
|
};
|