@ecobridge.xyz/devicemanager 3.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gitea/workflows/default_nottags.yaml +66 -0
- package/.gitea/workflows/default_tags.yaml +124 -0
- package/.vscode/launch.json +11 -0
- package/.vscode/settings.json +26 -0
- package/changelog.md +91 -0
- package/dist_ts/00_commitinfo_data.d.ts +8 -0
- package/dist_ts/00_commitinfo_data.js +9 -0
- package/dist_ts/device/device.classes.device.d.ts +205 -0
- package/dist_ts/device/device.classes.device.js +344 -0
- package/dist_ts/devicemanager.classes.devicemanager.d.ts +210 -0
- package/dist_ts/devicemanager.classes.devicemanager.js +835 -0
- package/dist_ts/discovery/discovery.classes.homeassistant.d.ts +101 -0
- package/dist_ts/discovery/discovery.classes.homeassistant.js +313 -0
- package/dist_ts/discovery/discovery.classes.mdns.d.ts +90 -0
- package/dist_ts/discovery/discovery.classes.mdns.js +291 -0
- package/dist_ts/discovery/discovery.classes.networkscanner.d.ts +64 -0
- package/dist_ts/discovery/discovery.classes.networkscanner.js +535 -0
- package/dist_ts/discovery/discovery.classes.ssdp.d.ts +112 -0
- package/dist_ts/discovery/discovery.classes.ssdp.js +255 -0
- package/dist_ts/factories/index.d.ts +190 -0
- package/dist_ts/factories/index.js +350 -0
- package/dist_ts/features/feature.abstract.d.ts +118 -0
- package/dist_ts/features/feature.abstract.js +172 -0
- package/dist_ts/features/feature.camera.d.ts +95 -0
- package/dist_ts/features/feature.camera.js +162 -0
- package/dist_ts/features/feature.climate.d.ts +174 -0
- package/dist_ts/features/feature.climate.js +328 -0
- package/dist_ts/features/feature.cover.d.ts +121 -0
- package/dist_ts/features/feature.cover.js +217 -0
- package/dist_ts/features/feature.fan.d.ts +127 -0
- package/dist_ts/features/feature.fan.js +235 -0
- package/dist_ts/features/feature.light.d.ts +156 -0
- package/dist_ts/features/feature.light.js +296 -0
- package/dist_ts/features/feature.lock.d.ts +103 -0
- package/dist_ts/features/feature.lock.js +170 -0
- package/dist_ts/features/feature.playback.d.ts +98 -0
- package/dist_ts/features/feature.playback.js +191 -0
- package/dist_ts/features/feature.power.d.ts +79 -0
- package/dist_ts/features/feature.power.js +173 -0
- package/dist_ts/features/feature.print.d.ts +78 -0
- package/dist_ts/features/feature.print.js +226 -0
- package/dist_ts/features/feature.scan.d.ts +79 -0
- package/dist_ts/features/feature.scan.js +315 -0
- package/dist_ts/features/feature.sensor.d.ts +95 -0
- package/dist_ts/features/feature.sensor.js +147 -0
- package/dist_ts/features/feature.snmp.d.ts +88 -0
- package/dist_ts/features/feature.snmp.js +175 -0
- package/dist_ts/features/feature.switch.d.ts +75 -0
- package/dist_ts/features/feature.switch.js +126 -0
- package/dist_ts/features/feature.volume.d.ts +104 -0
- package/dist_ts/features/feature.volume.js +186 -0
- package/dist_ts/features/index.d.ts +19 -0
- package/dist_ts/features/index.js +23 -0
- package/dist_ts/helpers/helpers.iprange.d.ts +35 -0
- package/dist_ts/helpers/helpers.iprange.js +151 -0
- package/dist_ts/helpers/helpers.retry.d.ts +25 -0
- package/dist_ts/helpers/helpers.retry.js +69 -0
- package/dist_ts/index.d.ts +15 -0
- package/dist_ts/index.js +40 -0
- package/dist_ts/interfaces/feature.interfaces.d.ts +255 -0
- package/dist_ts/interfaces/feature.interfaces.js +6 -0
- package/dist_ts/interfaces/homeassistant.interfaces.d.ts +550 -0
- package/dist_ts/interfaces/homeassistant.interfaces.js +111 -0
- package/dist_ts/interfaces/index.d.ts +330 -0
- package/dist_ts/interfaces/index.js +16 -0
- package/dist_ts/interfaces/smarthome.interfaces.d.ts +282 -0
- package/dist_ts/interfaces/smarthome.interfaces.js +7 -0
- package/dist_ts/paths.d.ts +1 -0
- package/dist_ts/paths.js +3 -0
- package/dist_ts/plugins.d.ts +23 -0
- package/dist_ts/plugins.js +30 -0
- package/dist_ts/protocols/index.d.ts +12 -0
- package/dist_ts/protocols/index.js +21 -0
- package/dist_ts/protocols/protocol.escl.d.ts +58 -0
- package/dist_ts/protocols/protocol.escl.js +364 -0
- package/dist_ts/protocols/protocol.homeassistant.d.ts +248 -0
- package/dist_ts/protocols/protocol.homeassistant.js +611 -0
- package/dist_ts/protocols/protocol.ipp.d.ts +45 -0
- package/dist_ts/protocols/protocol.ipp.js +284 -0
- package/dist_ts/protocols/protocol.nut.d.ts +160 -0
- package/dist_ts/protocols/protocol.nut.js +364 -0
- package/dist_ts/protocols/protocol.sane.d.ts +104 -0
- package/dist_ts/protocols/protocol.sane.js +597 -0
- package/dist_ts/protocols/protocol.snmp.d.ts +133 -0
- package/dist_ts/protocols/protocol.snmp.js +333 -0
- package/dist_ts/protocols/protocol.upnp.d.ts +207 -0
- package/dist_ts/protocols/protocol.upnp.js +442 -0
- package/dist_ts/protocols/protocol.upssnmp.d.ts +160 -0
- package/dist_ts/protocols/protocol.upssnmp.js +263 -0
- package/npmextra.json +24 -0
- package/package.json +39 -0
- package/readme.hints.md +115 -0
- package/readme.md +624 -0
- package/test/test.ts +261 -0
- package/ts/00_commitinfo_data.ts +8 -0
- package/ts/device/device.classes.device.ts +454 -0
- package/ts/devicemanager.classes.devicemanager.ts +1219 -0
- package/ts/discovery/discovery.classes.homeassistant.ts +376 -0
- package/ts/discovery/discovery.classes.mdns.ts +347 -0
- package/ts/discovery/discovery.classes.networkscanner.ts +648 -0
- package/ts/discovery/discovery.classes.ssdp.ts +357 -0
- package/ts/factories/index.ts +696 -0
- package/ts/features/feature.abstract.ts +251 -0
- package/ts/features/feature.camera.ts +214 -0
- package/ts/features/feature.climate.ts +407 -0
- package/ts/features/feature.cover.ts +278 -0
- package/ts/features/feature.fan.ts +296 -0
- package/ts/features/feature.light.ts +369 -0
- package/ts/features/feature.lock.ts +223 -0
- package/ts/features/feature.playback.ts +246 -0
- package/ts/features/feature.power.ts +231 -0
- package/ts/features/feature.print.ts +297 -0
- package/ts/features/feature.scan.ts +370 -0
- package/ts/features/feature.sensor.ts +202 -0
- package/ts/features/feature.snmp.ts +235 -0
- package/ts/features/feature.switch.ts +170 -0
- package/ts/features/feature.volume.ts +256 -0
- package/ts/features/index.ts +25 -0
- package/ts/helpers/helpers.iprange.ts +172 -0
- package/ts/helpers/helpers.retry.ts +100 -0
- package/ts/index.ts +165 -0
- package/ts/interfaces/feature.interfaces.ts +359 -0
- package/ts/interfaces/homeassistant.interfaces.ts +666 -0
- package/ts/interfaces/index.ts +421 -0
- package/ts/interfaces/smarthome.interfaces.ts +421 -0
- package/ts/paths.ts +5 -0
- package/ts/plugins.ts +41 -0
- package/ts/protocols/index.ts +59 -0
- package/ts/protocols/protocol.escl.ts +439 -0
- package/ts/protocols/protocol.homeassistant.ts +737 -0
- package/ts/protocols/protocol.ipp.ts +329 -0
- package/ts/protocols/protocol.nut.ts +471 -0
- package/ts/protocols/protocol.sane.ts +694 -0
- package/ts/protocols/protocol.snmp.ts +439 -0
- package/ts/protocols/protocol.upnp.ts +627 -0
- package/ts/protocols/protocol.upssnmp.ts +377 -0
- package/tsconfig.json +12 -0
|
@@ -0,0 +1,1219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device Manager
|
|
3
|
+
* Unified device discovery and management using UniversalDevice with Features
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as plugins from './plugins.js';
|
|
7
|
+
import { MdnsDiscovery, SERVICE_TYPES } from './discovery/discovery.classes.mdns.js';
|
|
8
|
+
import { NetworkScanner } from './discovery/discovery.classes.networkscanner.js';
|
|
9
|
+
import { SsdpDiscovery, SSDP_SERVICE_TYPES, type ISsdpDevice } from './discovery/discovery.classes.ssdp.js';
|
|
10
|
+
import { UniversalDevice } from './device/device.classes.device.js';
|
|
11
|
+
import {
|
|
12
|
+
ScanFeature,
|
|
13
|
+
PrintFeature,
|
|
14
|
+
PlaybackFeature,
|
|
15
|
+
VolumeFeature,
|
|
16
|
+
PowerFeature,
|
|
17
|
+
SnmpFeature,
|
|
18
|
+
} from './features/index.js';
|
|
19
|
+
import type { Feature } from './features/index.js';
|
|
20
|
+
import {
|
|
21
|
+
createScanner,
|
|
22
|
+
createPrinter,
|
|
23
|
+
createSnmpDevice,
|
|
24
|
+
createUpsDevice,
|
|
25
|
+
createSpeaker,
|
|
26
|
+
createDlnaRenderer,
|
|
27
|
+
type IScannerDiscoveryInfo,
|
|
28
|
+
type IPrinterDiscoveryInfo,
|
|
29
|
+
type ISnmpDiscoveryInfo,
|
|
30
|
+
type IUpsDiscoveryInfo,
|
|
31
|
+
type ISpeakerDiscoveryInfo,
|
|
32
|
+
type IDlnaRendererDiscoveryInfo,
|
|
33
|
+
} from './factories/index.js';
|
|
34
|
+
import type {
|
|
35
|
+
IDeviceManagerOptions,
|
|
36
|
+
IDiscoveredDevice,
|
|
37
|
+
IRetryOptions,
|
|
38
|
+
INetworkScanOptions,
|
|
39
|
+
INetworkScanResult,
|
|
40
|
+
TFeatureType,
|
|
41
|
+
IDeviceSelector,
|
|
42
|
+
} from './interfaces/index.js';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Default device manager options
|
|
46
|
+
*/
|
|
47
|
+
const DEFAULT_OPTIONS: Required<IDeviceManagerOptions> = {
|
|
48
|
+
autoDiscovery: true,
|
|
49
|
+
discoveryTimeout: 10000,
|
|
50
|
+
enableRetry: true,
|
|
51
|
+
maxRetries: 5,
|
|
52
|
+
retryBaseDelay: 1000,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Name source priority - higher number = higher priority
|
|
57
|
+
* Used to determine which discovery method's name should be preferred
|
|
58
|
+
*/
|
|
59
|
+
type TNameSource = 'generic' | 'manual' | 'airplay' | 'chromecast' | 'mdns' | 'dlna' | 'sonos';
|
|
60
|
+
|
|
61
|
+
const NAME_SOURCE_PRIORITY: Record<TNameSource, number> = {
|
|
62
|
+
generic: 0, // IP-based placeholder names
|
|
63
|
+
manual: 1, // Manually added devices
|
|
64
|
+
airplay: 2, // AirPlay can have generic names
|
|
65
|
+
chromecast: 3, // Chromecast has decent names
|
|
66
|
+
mdns: 4, // mDNS for scanners/printers (usually good)
|
|
67
|
+
dlna: 5, // SSDP/UPnP MediaRenderer (good friendly names)
|
|
68
|
+
sonos: 6, // Sonos SSDP ZonePlayer (most authoritative)
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Check if a name is a "real" name vs a generic placeholder
|
|
73
|
+
*/
|
|
74
|
+
function isRealName(name: string | undefined): boolean {
|
|
75
|
+
if (!name) return false;
|
|
76
|
+
const lower = name.toLowerCase();
|
|
77
|
+
// Generic names that indicate no real name was discovered
|
|
78
|
+
return !(
|
|
79
|
+
lower.includes(' at ') ||
|
|
80
|
+
lower.startsWith('device ') ||
|
|
81
|
+
lower.startsWith('scanner ') ||
|
|
82
|
+
lower.startsWith('printer ') ||
|
|
83
|
+
lower.startsWith('speaker ') ||
|
|
84
|
+
lower.startsWith('ups ') ||
|
|
85
|
+
lower.startsWith('snmp ') ||
|
|
86
|
+
/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/.test(name) // Just an IP address
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Choose the best name based on source priority
|
|
92
|
+
*/
|
|
93
|
+
function shouldUpdateName(
|
|
94
|
+
existingSource: TNameSource | undefined,
|
|
95
|
+
newSource: TNameSource,
|
|
96
|
+
existingName: string | undefined,
|
|
97
|
+
newName: string | undefined
|
|
98
|
+
): boolean {
|
|
99
|
+
// If new name isn't real, don't update
|
|
100
|
+
if (!isRealName(newName)) return false;
|
|
101
|
+
|
|
102
|
+
// If no existing name or source, use new
|
|
103
|
+
if (!existingName || !existingSource) return true;
|
|
104
|
+
|
|
105
|
+
// If existing name isn't real but new is, use new
|
|
106
|
+
if (!isRealName(existingName)) return true;
|
|
107
|
+
|
|
108
|
+
// Both are real names - use priority
|
|
109
|
+
const existingPriority = NAME_SOURCE_PRIORITY[existingSource] ?? 0;
|
|
110
|
+
const newPriority = NAME_SOURCE_PRIORITY[newSource] ?? 0;
|
|
111
|
+
|
|
112
|
+
return newPriority > existingPriority;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Create a device reference for feature construction
|
|
117
|
+
*/
|
|
118
|
+
function makeDeviceRef(address: string, port: number, name: string): { id: string; address: string; port: number; name: string } {
|
|
119
|
+
return {
|
|
120
|
+
id: `device:${address}`,
|
|
121
|
+
address,
|
|
122
|
+
port,
|
|
123
|
+
name,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Main Device Manager class
|
|
129
|
+
* Discovers and manages network devices using the UniversalDevice architecture
|
|
130
|
+
*
|
|
131
|
+
* Devices are deduplicated by IP address - if the same device is discovered
|
|
132
|
+
* via multiple methods (mDNS, SSDP, IP scan), features are merged into a
|
|
133
|
+
* single UniversalDevice instance.
|
|
134
|
+
*/
|
|
135
|
+
export class DeviceManager extends plugins.events.EventEmitter {
|
|
136
|
+
private mdnsDiscovery: MdnsDiscovery;
|
|
137
|
+
private ssdpDiscovery: SsdpDiscovery;
|
|
138
|
+
private _networkScanner: NetworkScanner | null = null;
|
|
139
|
+
|
|
140
|
+
// Devices keyed by IP address for deduplication
|
|
141
|
+
private devicesByIp: Map<string, UniversalDevice> = new Map();
|
|
142
|
+
|
|
143
|
+
// Track name source for priority-based naming
|
|
144
|
+
private nameSourceByIp: Map<string, TNameSource> = new Map();
|
|
145
|
+
|
|
146
|
+
private options: Required<IDeviceManagerOptions>;
|
|
147
|
+
private retryOptions: IRetryOptions;
|
|
148
|
+
|
|
149
|
+
constructor(options?: IDeviceManagerOptions) {
|
|
150
|
+
super();
|
|
151
|
+
this.options = { ...DEFAULT_OPTIONS, ...options };
|
|
152
|
+
|
|
153
|
+
this.retryOptions = {
|
|
154
|
+
maxRetries: this.options.maxRetries,
|
|
155
|
+
baseDelay: this.options.retryBaseDelay,
|
|
156
|
+
maxDelay: 16000,
|
|
157
|
+
multiplier: 2,
|
|
158
|
+
jitter: true,
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
this.mdnsDiscovery = new MdnsDiscovery({
|
|
162
|
+
timeout: this.options.discoveryTimeout,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
this.ssdpDiscovery = new SsdpDiscovery();
|
|
166
|
+
|
|
167
|
+
this.setupDiscoveryEvents();
|
|
168
|
+
this.setupSsdpDiscoveryEvents();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ============================================================================
|
|
172
|
+
// Device Registration (with deduplication by IP)
|
|
173
|
+
// ============================================================================
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Register or merge a device by IP address
|
|
177
|
+
* Returns the device (existing or new) and whether it's newly created
|
|
178
|
+
*/
|
|
179
|
+
private registerDevice(
|
|
180
|
+
address: string,
|
|
181
|
+
port: number,
|
|
182
|
+
name: string | undefined,
|
|
183
|
+
manufacturer: string | undefined,
|
|
184
|
+
model: string | undefined,
|
|
185
|
+
feature: Feature,
|
|
186
|
+
nameSource: TNameSource
|
|
187
|
+
): { device: UniversalDevice; isNew: boolean; featureAdded: boolean } {
|
|
188
|
+
const existing = this.devicesByIp.get(address);
|
|
189
|
+
|
|
190
|
+
if (existing) {
|
|
191
|
+
// Update name if new source has higher priority
|
|
192
|
+
const existingSource = this.nameSourceByIp.get(address);
|
|
193
|
+
if (shouldUpdateName(existingSource, nameSource, existing.name, name)) {
|
|
194
|
+
(existing as { name: string }).name = name!;
|
|
195
|
+
this.nameSourceByIp.set(address, nameSource);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Update manufacturer/model if we have better info
|
|
199
|
+
if (manufacturer && !existing.manufacturer) {
|
|
200
|
+
(existing as { manufacturer: string | undefined }).manufacturer = manufacturer;
|
|
201
|
+
}
|
|
202
|
+
if (model && !existing.model) {
|
|
203
|
+
(existing as { model: string | undefined }).model = model;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Add feature if not already present
|
|
207
|
+
if (!existing.hasFeature(feature.type)) {
|
|
208
|
+
existing.addFeature(feature);
|
|
209
|
+
return { device: existing, isNew: false, featureAdded: true };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return { device: existing, isNew: false, featureAdded: false };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Create new device
|
|
216
|
+
const device = new UniversalDevice(address, port, {
|
|
217
|
+
name: name || `Device at ${address}`,
|
|
218
|
+
manufacturer,
|
|
219
|
+
model,
|
|
220
|
+
retryOptions: this.options.enableRetry ? this.retryOptions : undefined,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
device.addFeature(feature);
|
|
224
|
+
this.devicesByIp.set(address, device);
|
|
225
|
+
this.nameSourceByIp.set(address, isRealName(name) ? nameSource : 'generic');
|
|
226
|
+
|
|
227
|
+
return { device, isNew: true, featureAdded: true };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ============================================================================
|
|
231
|
+
// Discovery Event Handlers
|
|
232
|
+
// ============================================================================
|
|
233
|
+
|
|
234
|
+
private setupDiscoveryEvents(): void {
|
|
235
|
+
this.mdnsDiscovery.on('device:found', (device: IDiscoveredDevice) => {
|
|
236
|
+
this.handleMdnsDeviceFound(device);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
this.mdnsDiscovery.on('device:lost', (device: IDiscoveredDevice) => {
|
|
240
|
+
this.handleDeviceLost(device.address);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
this.mdnsDiscovery.on('started', () => {
|
|
244
|
+
this.emit('discovery:started');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
this.mdnsDiscovery.on('stopped', () => {
|
|
248
|
+
this.emit('discovery:stopped');
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private setupSsdpDiscoveryEvents(): void {
|
|
253
|
+
this.ssdpDiscovery.on('device:found', (device: ISsdpDevice) => {
|
|
254
|
+
this.handleSsdpDeviceFound(device);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
this.ssdpDiscovery.on('started', () => {
|
|
258
|
+
this.emit('ssdp:started');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
this.ssdpDiscovery.on('stopped', () => {
|
|
262
|
+
this.emit('ssdp:stopped');
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private handleMdnsDeviceFound(discovered: IDiscoveredDevice): void {
|
|
267
|
+
const manufacturer = discovered.txtRecords['usb_MFG'] || discovered.txtRecords['mfg'];
|
|
268
|
+
const model = discovered.txtRecords['usb_MDL'] || discovered.txtRecords['mdl'] || discovered.txtRecords['ty'];
|
|
269
|
+
|
|
270
|
+
if (discovered.type === 'scanner') {
|
|
271
|
+
const protocol = discovered.protocol === 'ipp' ? 'escl' : discovered.protocol as 'escl' | 'sane';
|
|
272
|
+
const isSecure = discovered.txtRecords['TLS'] === '1' || (protocol === 'escl' && discovered.port === 443);
|
|
273
|
+
|
|
274
|
+
const feature = new ScanFeature(
|
|
275
|
+
makeDeviceRef(discovered.address, discovered.port, discovered.name),
|
|
276
|
+
discovered.port,
|
|
277
|
+
{
|
|
278
|
+
protocol,
|
|
279
|
+
secure: isSecure,
|
|
280
|
+
supportedFormats: this.parseScanFormats(discovered.txtRecords),
|
|
281
|
+
supportedResolutions: this.parseScanResolutions(discovered.txtRecords),
|
|
282
|
+
supportedColorModes: this.parseScanColorModes(discovered.txtRecords),
|
|
283
|
+
supportedSources: this.parseScanSources(discovered.txtRecords),
|
|
284
|
+
hasAdf: discovered.txtRecords['is']?.includes('adf') || false,
|
|
285
|
+
hasDuplex: discovered.txtRecords['is']?.includes('duplex') || false,
|
|
286
|
+
}
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
const { device, isNew, featureAdded } = this.registerDevice(
|
|
290
|
+
discovered.address,
|
|
291
|
+
discovered.port,
|
|
292
|
+
discovered.name,
|
|
293
|
+
manufacturer,
|
|
294
|
+
model,
|
|
295
|
+
feature,
|
|
296
|
+
'mdns'
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
if (isNew || featureAdded) {
|
|
300
|
+
this.emit('device:found', { device, featureType: 'scan' });
|
|
301
|
+
}
|
|
302
|
+
} else if (discovered.type === 'printer') {
|
|
303
|
+
const ippPath = discovered.txtRecords['rp'] || discovered.txtRecords['rfo'] || '/ipp/print';
|
|
304
|
+
const uri = `ipp://${discovered.address}:${discovered.port}${ippPath.startsWith('/') ? '' : '/'}${ippPath}`;
|
|
305
|
+
|
|
306
|
+
const feature = new PrintFeature(
|
|
307
|
+
makeDeviceRef(discovered.address, discovered.port, discovered.name),
|
|
308
|
+
discovered.port,
|
|
309
|
+
{
|
|
310
|
+
protocol: 'ipp',
|
|
311
|
+
uri,
|
|
312
|
+
supportsColor: discovered.txtRecords['Color'] === 'T' || discovered.txtRecords['color'] === 'true',
|
|
313
|
+
supportsDuplex: discovered.txtRecords['Duplex'] === 'T' || discovered.txtRecords['duplex'] === 'true',
|
|
314
|
+
}
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
const { device, isNew, featureAdded } = this.registerDevice(
|
|
318
|
+
discovered.address,
|
|
319
|
+
discovered.port,
|
|
320
|
+
discovered.name,
|
|
321
|
+
manufacturer,
|
|
322
|
+
model,
|
|
323
|
+
feature,
|
|
324
|
+
'mdns'
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
if (isNew || featureAdded) {
|
|
328
|
+
this.emit('device:found', { device, featureType: 'print' });
|
|
329
|
+
}
|
|
330
|
+
} else if (discovered.type === 'speaker') {
|
|
331
|
+
const protocol = discovered.protocol as 'sonos' | 'airplay' | 'chromecast' | 'dlna';
|
|
332
|
+
|
|
333
|
+
const playbackFeature = new PlaybackFeature(
|
|
334
|
+
makeDeviceRef(discovered.address, discovered.port, discovered.name),
|
|
335
|
+
discovered.port,
|
|
336
|
+
{
|
|
337
|
+
protocol,
|
|
338
|
+
supportsQueue: protocol === 'sonos',
|
|
339
|
+
supportsSeek: protocol !== 'airplay',
|
|
340
|
+
}
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
const volumeFeature = new VolumeFeature(
|
|
344
|
+
makeDeviceRef(discovered.address, discovered.port, discovered.name),
|
|
345
|
+
discovered.port,
|
|
346
|
+
{
|
|
347
|
+
volumeProtocol: protocol,
|
|
348
|
+
minVolume: 0,
|
|
349
|
+
maxVolume: 100,
|
|
350
|
+
supportsMute: true,
|
|
351
|
+
}
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
const modelName = discovered.txtRecords['model'] || discovered.txtRecords['md'];
|
|
355
|
+
|
|
356
|
+
// Map protocol to name source - mDNS speaker protocols
|
|
357
|
+
const speakerNameSource: TNameSource =
|
|
358
|
+
protocol === 'sonos' ? 'sonos' :
|
|
359
|
+
protocol === 'airplay' ? 'airplay' :
|
|
360
|
+
protocol === 'chromecast' ? 'chromecast' :
|
|
361
|
+
protocol === 'dlna' ? 'dlna' : 'mdns';
|
|
362
|
+
|
|
363
|
+
const { device, isNew } = this.registerDevice(
|
|
364
|
+
discovered.address,
|
|
365
|
+
discovered.port,
|
|
366
|
+
discovered.name,
|
|
367
|
+
undefined,
|
|
368
|
+
modelName,
|
|
369
|
+
playbackFeature,
|
|
370
|
+
speakerNameSource
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
// Also add volume feature
|
|
374
|
+
if (!device.hasFeature('volume')) {
|
|
375
|
+
device.addFeature(volumeFeature);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (isNew) {
|
|
379
|
+
this.emit('device:found', { device, featureType: 'playback' });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
private handleSsdpDeviceFound(ssdpDevice: ISsdpDevice): void {
|
|
385
|
+
if (!ssdpDevice.description) {
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const serviceType = ssdpDevice.serviceType;
|
|
390
|
+
const deviceType = ssdpDevice.description.deviceType;
|
|
391
|
+
const desc = ssdpDevice.description;
|
|
392
|
+
|
|
393
|
+
// Check for DLNA Media Renderer
|
|
394
|
+
if (serviceType.includes('MediaRenderer') || deviceType.includes('MediaRenderer')) {
|
|
395
|
+
const port = parseInt(new URL(ssdpDevice.location).port) || 80;
|
|
396
|
+
|
|
397
|
+
const playbackFeature = new PlaybackFeature(
|
|
398
|
+
makeDeviceRef(ssdpDevice.address, port, desc.friendlyName),
|
|
399
|
+
port,
|
|
400
|
+
{
|
|
401
|
+
protocol: 'dlna',
|
|
402
|
+
supportsQueue: false,
|
|
403
|
+
supportsSeek: true,
|
|
404
|
+
}
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
const volumeFeature = new VolumeFeature(
|
|
408
|
+
makeDeviceRef(ssdpDevice.address, port, desc.friendlyName),
|
|
409
|
+
port,
|
|
410
|
+
{
|
|
411
|
+
volumeProtocol: 'dlna',
|
|
412
|
+
minVolume: 0,
|
|
413
|
+
maxVolume: 100,
|
|
414
|
+
supportsMute: true,
|
|
415
|
+
}
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
const { device, isNew } = this.registerDevice(
|
|
419
|
+
ssdpDevice.address,
|
|
420
|
+
port,
|
|
421
|
+
desc.friendlyName,
|
|
422
|
+
desc.manufacturer,
|
|
423
|
+
desc.modelName,
|
|
424
|
+
playbackFeature,
|
|
425
|
+
'dlna'
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
if (!device.hasFeature('volume')) {
|
|
429
|
+
device.addFeature(volumeFeature);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (isNew) {
|
|
433
|
+
this.emit('device:found', { device, featureType: 'playback' });
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Check for Sonos ZonePlayer
|
|
438
|
+
if (serviceType.includes('ZonePlayer') || deviceType.includes('ZonePlayer')) {
|
|
439
|
+
const playbackFeature = new PlaybackFeature(
|
|
440
|
+
makeDeviceRef(ssdpDevice.address, 1400, desc.friendlyName),
|
|
441
|
+
1400,
|
|
442
|
+
{
|
|
443
|
+
protocol: 'sonos',
|
|
444
|
+
supportsQueue: true,
|
|
445
|
+
supportsSeek: true,
|
|
446
|
+
}
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
const volumeFeature = new VolumeFeature(
|
|
450
|
+
makeDeviceRef(ssdpDevice.address, 1400, desc.friendlyName),
|
|
451
|
+
1400,
|
|
452
|
+
{
|
|
453
|
+
volumeProtocol: 'sonos',
|
|
454
|
+
minVolume: 0,
|
|
455
|
+
maxVolume: 100,
|
|
456
|
+
supportsMute: true,
|
|
457
|
+
}
|
|
458
|
+
);
|
|
459
|
+
|
|
460
|
+
const { device, isNew } = this.registerDevice(
|
|
461
|
+
ssdpDevice.address,
|
|
462
|
+
1400,
|
|
463
|
+
desc.friendlyName,
|
|
464
|
+
desc.manufacturer,
|
|
465
|
+
desc.modelName,
|
|
466
|
+
playbackFeature,
|
|
467
|
+
'sonos' // Sonos has highest naming priority
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
if (!device.hasFeature('volume')) {
|
|
471
|
+
device.addFeature(volumeFeature);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (isNew) {
|
|
475
|
+
this.emit('device:found', { device, featureType: 'playback' });
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
private async handleDeviceLost(address: string): Promise<void> {
|
|
481
|
+
const device = this.devicesByIp.get(address);
|
|
482
|
+
if (device) {
|
|
483
|
+
try {
|
|
484
|
+
await device.disconnect();
|
|
485
|
+
} catch {
|
|
486
|
+
// Ignore disconnect errors
|
|
487
|
+
}
|
|
488
|
+
this.devicesByIp.delete(address);
|
|
489
|
+
this.nameSourceByIp.delete(address);
|
|
490
|
+
this.emit('device:lost', address);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ============================================================================
|
|
495
|
+
// Parsing Helpers
|
|
496
|
+
// ============================================================================
|
|
497
|
+
|
|
498
|
+
private parseScanFormats(txtRecords: Record<string, string>): ('jpeg' | 'png' | 'pdf' | 'tiff')[] {
|
|
499
|
+
const formats: ('jpeg' | 'png' | 'pdf' | 'tiff')[] = [];
|
|
500
|
+
const pdl = txtRecords['pdl'] || txtRecords['DocumentFormat'] || '';
|
|
501
|
+
|
|
502
|
+
if (pdl.includes('jpeg') || pdl.includes('jpg')) formats.push('jpeg');
|
|
503
|
+
if (pdl.includes('png')) formats.push('png');
|
|
504
|
+
if (pdl.includes('pdf')) formats.push('pdf');
|
|
505
|
+
if (pdl.includes('tiff')) formats.push('tiff');
|
|
506
|
+
|
|
507
|
+
return formats.length > 0 ? formats : ['jpeg', 'png'];
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
private parseScanResolutions(txtRecords: Record<string, string>): number[] {
|
|
511
|
+
const rs = txtRecords['rs'] || '';
|
|
512
|
+
const parts = rs.split(',').map((s) => parseInt(s.trim())).filter((n) => !isNaN(n) && n > 0);
|
|
513
|
+
return parts.length > 0 ? parts : [75, 150, 300, 600];
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
private parseScanColorModes(txtRecords: Record<string, string>): ('color' | 'grayscale' | 'blackwhite')[] {
|
|
517
|
+
const cs = txtRecords['cs'] || txtRecords['ColorSpace'] || '';
|
|
518
|
+
const modes: ('color' | 'grayscale' | 'blackwhite')[] = [];
|
|
519
|
+
|
|
520
|
+
if (cs.includes('color') || cs.includes('RGB')) modes.push('color');
|
|
521
|
+
if (cs.includes('gray') || cs.includes('grayscale')) modes.push('grayscale');
|
|
522
|
+
if (cs.includes('binary') || cs.includes('bw')) modes.push('blackwhite');
|
|
523
|
+
|
|
524
|
+
return modes.length > 0 ? modes : ['color', 'grayscale'];
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
private parseScanSources(txtRecords: Record<string, string>): ('flatbed' | 'adf' | 'adf-duplex')[] {
|
|
528
|
+
const is = txtRecords['is'] || txtRecords['InputSource'] || '';
|
|
529
|
+
const sources: ('flatbed' | 'adf' | 'adf-duplex')[] = [];
|
|
530
|
+
|
|
531
|
+
if (is.includes('platen') || is.includes('flatbed') || is === '') {
|
|
532
|
+
sources.push('flatbed');
|
|
533
|
+
}
|
|
534
|
+
if (is.includes('adf') || is.includes('feeder')) {
|
|
535
|
+
sources.push('adf');
|
|
536
|
+
}
|
|
537
|
+
if (is.includes('duplex')) {
|
|
538
|
+
sources.push('adf-duplex');
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
return sources.length > 0 ? sources : ['flatbed'];
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// ============================================================================
|
|
545
|
+
// Discovery Control
|
|
546
|
+
// ============================================================================
|
|
547
|
+
|
|
548
|
+
public async startDiscovery(): Promise<void> {
|
|
549
|
+
await Promise.all([
|
|
550
|
+
this.mdnsDiscovery.start(),
|
|
551
|
+
this.ssdpDiscovery.start(),
|
|
552
|
+
]);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
public async stopDiscovery(): Promise<void> {
|
|
556
|
+
await Promise.all([
|
|
557
|
+
this.mdnsDiscovery.stop(),
|
|
558
|
+
this.ssdpDiscovery.stop(),
|
|
559
|
+
]);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
public get isDiscovering(): boolean {
|
|
563
|
+
return this.mdnsDiscovery.running || this.ssdpDiscovery.isRunning;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// ============================================================================
|
|
567
|
+
// Device Access - All Devices
|
|
568
|
+
// ============================================================================
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Get devices matching the selector criteria.
|
|
572
|
+
* Returns all devices if no selector provided, or devices matching all criteria.
|
|
573
|
+
* @param selector Optional criteria to filter devices
|
|
574
|
+
* @returns Array of matching devices (empty if no matches)
|
|
575
|
+
*/
|
|
576
|
+
public getDevices(selector?: IDeviceSelector): UniversalDevice[] {
|
|
577
|
+
const devices = Array.from(this.devicesByIp.values());
|
|
578
|
+
|
|
579
|
+
if (!selector) {
|
|
580
|
+
return devices;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
return devices.filter((device) => this.matchesSelector(device, selector));
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Select exactly ONE device matching the criteria.
|
|
588
|
+
* Use this when you expect a specific device and want fail-fast behavior.
|
|
589
|
+
* @param selector Criteria to match the device
|
|
590
|
+
* @returns The matching device
|
|
591
|
+
* @throws Error if no device matches the selector
|
|
592
|
+
*/
|
|
593
|
+
public selectDevice(selector: IDeviceSelector): UniversalDevice {
|
|
594
|
+
const matches = this.getDevices(selector);
|
|
595
|
+
|
|
596
|
+
if (matches.length === 0) {
|
|
597
|
+
throw new Error(`No device found matching: ${JSON.stringify(selector)}`);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (matches.length > 1 && !selector.address && !selector.id) {
|
|
601
|
+
// Multiple matches without unique identifier - log warning
|
|
602
|
+
console.warn(
|
|
603
|
+
`Multiple devices (${matches.length}) match selector, using first: ${matches[0].name}`
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
return matches[0];
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Get device by ID
|
|
612
|
+
* @deprecated Use getDevices({ id }) or selectDevice({ id }) instead
|
|
613
|
+
*/
|
|
614
|
+
public getDevice(id: string): UniversalDevice | undefined {
|
|
615
|
+
for (const device of this.devicesByIp.values()) {
|
|
616
|
+
if (device.id === id) {
|
|
617
|
+
return device;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
return undefined;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Get device by address
|
|
625
|
+
* @deprecated Use getDevices({ address }) or selectDevice({ address }) instead
|
|
626
|
+
*/
|
|
627
|
+
public getDeviceByAddress(address: string): UniversalDevice | undefined {
|
|
628
|
+
return this.devicesByIp.get(address);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Check if a device matches the selector criteria
|
|
633
|
+
*/
|
|
634
|
+
private matchesSelector(device: UniversalDevice, selector: IDeviceSelector): boolean {
|
|
635
|
+
// Identity checks (exact match)
|
|
636
|
+
if (selector.id && device.id !== selector.id) {
|
|
637
|
+
return false;
|
|
638
|
+
}
|
|
639
|
+
if (selector.address && device.address !== selector.address) {
|
|
640
|
+
return false;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Attribute checks (partial match, case-insensitive)
|
|
644
|
+
if (selector.name && !device.name.toLowerCase().includes(selector.name.toLowerCase())) {
|
|
645
|
+
return false;
|
|
646
|
+
}
|
|
647
|
+
if (selector.model && !device.model?.toLowerCase().includes(selector.model.toLowerCase())) {
|
|
648
|
+
return false;
|
|
649
|
+
}
|
|
650
|
+
if (
|
|
651
|
+
selector.manufacturer &&
|
|
652
|
+
!device.manufacturer?.toLowerCase().includes(selector.manufacturer.toLowerCase())
|
|
653
|
+
) {
|
|
654
|
+
return false;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Capability checks
|
|
658
|
+
if (selector.hasFeature && !device.hasFeature(selector.hasFeature)) {
|
|
659
|
+
return false;
|
|
660
|
+
}
|
|
661
|
+
if (selector.hasFeatures && !device.hasFeatures(selector.hasFeatures)) {
|
|
662
|
+
return false;
|
|
663
|
+
}
|
|
664
|
+
if (selector.hasAnyFeature && !device.hasAnyFeature(selector.hasAnyFeature)) {
|
|
665
|
+
return false;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
return true;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// ============================================================================
|
|
672
|
+
// Device Access - By Feature Type
|
|
673
|
+
// ============================================================================
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Get all devices that have a specific feature
|
|
677
|
+
*/
|
|
678
|
+
public getDevicesWithFeature(featureType: TFeatureType): UniversalDevice[] {
|
|
679
|
+
return this.getDevices().filter(device => device.hasFeature(featureType));
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Get all devices that have ALL specified features
|
|
684
|
+
*/
|
|
685
|
+
public getDevicesWithFeatures(featureTypes: TFeatureType[]): UniversalDevice[] {
|
|
686
|
+
return this.getDevices().filter(device => device.hasFeatures(featureTypes));
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Get all devices that have ANY of the specified features
|
|
691
|
+
*/
|
|
692
|
+
public getDevicesWithAnyFeature(featureTypes: TFeatureType[]): UniversalDevice[] {
|
|
693
|
+
return this.getDevices().filter(device =>
|
|
694
|
+
featureTypes.some(type => device.hasFeature(type))
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// ============================================================================
|
|
699
|
+
// Convenience Accessors (by feature type)
|
|
700
|
+
// ============================================================================
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Get all scanner devices
|
|
704
|
+
*/
|
|
705
|
+
public getScanners(): UniversalDevice[] {
|
|
706
|
+
return this.getDevicesWithFeature('scan');
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Get all printer devices
|
|
711
|
+
*/
|
|
712
|
+
public getPrinters(): UniversalDevice[] {
|
|
713
|
+
return this.getDevicesWithFeature('print');
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Get all speaker devices (playback feature)
|
|
718
|
+
*/
|
|
719
|
+
public getSpeakers(): UniversalDevice[] {
|
|
720
|
+
return this.getDevicesWithFeature('playback');
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Get all UPS/power devices
|
|
725
|
+
*/
|
|
726
|
+
public getUpsDevices(): UniversalDevice[] {
|
|
727
|
+
return this.getDevicesWithFeature('power');
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Get all SNMP devices
|
|
732
|
+
*/
|
|
733
|
+
public getSnmpDevices(): UniversalDevice[] {
|
|
734
|
+
return this.getDevicesWithFeature('snmp');
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// ============================================================================
|
|
738
|
+
// Manual Device Addition
|
|
739
|
+
// ============================================================================
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Add a device
|
|
743
|
+
*/
|
|
744
|
+
public addDevice(device: UniversalDevice): void {
|
|
745
|
+
const existing = this.devicesByIp.get(device.address);
|
|
746
|
+
if (existing) {
|
|
747
|
+
// Merge features into existing device
|
|
748
|
+
for (const feature of device.getFeatures()) {
|
|
749
|
+
if (!existing.hasFeature(feature.type)) {
|
|
750
|
+
existing.addFeature(feature);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
// Update name if better using priority system
|
|
754
|
+
const existingSource = this.nameSourceByIp.get(device.address);
|
|
755
|
+
if (shouldUpdateName(existingSource, 'manual', existing.name, device.name)) {
|
|
756
|
+
(existing as { name: string }).name = device.name;
|
|
757
|
+
this.nameSourceByIp.set(device.address, 'manual');
|
|
758
|
+
}
|
|
759
|
+
} else {
|
|
760
|
+
this.devicesByIp.set(device.address, device);
|
|
761
|
+
this.nameSourceByIp.set(device.address, isRealName(device.name) ? 'manual' : 'generic');
|
|
762
|
+
this.emit('device:added', device);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Add a scanner manually
|
|
768
|
+
*/
|
|
769
|
+
public async addScanner(
|
|
770
|
+
address: string,
|
|
771
|
+
port: number,
|
|
772
|
+
protocol: 'escl' | 'sane' = 'escl',
|
|
773
|
+
name?: string
|
|
774
|
+
): Promise<UniversalDevice> {
|
|
775
|
+
const feature = new ScanFeature(
|
|
776
|
+
makeDeviceRef(address, port, name || `Scanner at ${address}`),
|
|
777
|
+
port,
|
|
778
|
+
{ protocol }
|
|
779
|
+
);
|
|
780
|
+
|
|
781
|
+
const { device } = this.registerDevice(
|
|
782
|
+
address,
|
|
783
|
+
port,
|
|
784
|
+
name,
|
|
785
|
+
undefined,
|
|
786
|
+
undefined,
|
|
787
|
+
feature,
|
|
788
|
+
'manual'
|
|
789
|
+
);
|
|
790
|
+
|
|
791
|
+
await device.connect();
|
|
792
|
+
return device;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* Add a printer manually
|
|
797
|
+
*/
|
|
798
|
+
public async addPrinter(
|
|
799
|
+
address: string,
|
|
800
|
+
port: number = 631,
|
|
801
|
+
name?: string,
|
|
802
|
+
ippPath?: string
|
|
803
|
+
): Promise<UniversalDevice> {
|
|
804
|
+
const path = ippPath || '/ipp/print';
|
|
805
|
+
const uri = `ipp://${address}:${port}${path.startsWith('/') ? '' : '/'}${path}`;
|
|
806
|
+
|
|
807
|
+
const feature = new PrintFeature(
|
|
808
|
+
makeDeviceRef(address, port, name || `Printer at ${address}`),
|
|
809
|
+
port,
|
|
810
|
+
{ protocol: 'ipp', uri }
|
|
811
|
+
);
|
|
812
|
+
|
|
813
|
+
const { device } = this.registerDevice(
|
|
814
|
+
address,
|
|
815
|
+
port,
|
|
816
|
+
name,
|
|
817
|
+
undefined,
|
|
818
|
+
undefined,
|
|
819
|
+
feature,
|
|
820
|
+
'manual'
|
|
821
|
+
);
|
|
822
|
+
|
|
823
|
+
await device.connect();
|
|
824
|
+
return device;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* Add an SNMP device manually
|
|
829
|
+
*/
|
|
830
|
+
public async addSnmpDevice(
|
|
831
|
+
address: string,
|
|
832
|
+
port: number = 161,
|
|
833
|
+
options?: { name?: string; community?: string }
|
|
834
|
+
): Promise<UniversalDevice> {
|
|
835
|
+
const feature = new SnmpFeature(
|
|
836
|
+
makeDeviceRef(address, port, options?.name || `SNMP Device at ${address}`),
|
|
837
|
+
port,
|
|
838
|
+
{ community: options?.community ?? 'public' }
|
|
839
|
+
);
|
|
840
|
+
|
|
841
|
+
const { device } = this.registerDevice(
|
|
842
|
+
address,
|
|
843
|
+
port,
|
|
844
|
+
options?.name,
|
|
845
|
+
undefined,
|
|
846
|
+
undefined,
|
|
847
|
+
feature,
|
|
848
|
+
'manual'
|
|
849
|
+
);
|
|
850
|
+
|
|
851
|
+
await device.connect();
|
|
852
|
+
return device;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* Add a UPS device manually
|
|
857
|
+
*/
|
|
858
|
+
public async addUpsDevice(
|
|
859
|
+
address: string,
|
|
860
|
+
protocol: 'nut' | 'snmp',
|
|
861
|
+
options?: { name?: string; port?: number; upsName?: string; community?: string }
|
|
862
|
+
): Promise<UniversalDevice> {
|
|
863
|
+
const port = options?.port ?? (protocol === 'nut' ? 3493 : 161);
|
|
864
|
+
|
|
865
|
+
const feature = new PowerFeature(
|
|
866
|
+
makeDeviceRef(address, port, options?.name || `UPS at ${address}`),
|
|
867
|
+
port,
|
|
868
|
+
{
|
|
869
|
+
protocol,
|
|
870
|
+
upsName: options?.upsName,
|
|
871
|
+
community: options?.community,
|
|
872
|
+
}
|
|
873
|
+
);
|
|
874
|
+
|
|
875
|
+
const { device } = this.registerDevice(
|
|
876
|
+
address,
|
|
877
|
+
port,
|
|
878
|
+
options?.name,
|
|
879
|
+
undefined,
|
|
880
|
+
undefined,
|
|
881
|
+
feature,
|
|
882
|
+
'manual'
|
|
883
|
+
);
|
|
884
|
+
|
|
885
|
+
await device.connect();
|
|
886
|
+
return device;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* Add a speaker manually
|
|
891
|
+
*/
|
|
892
|
+
public async addSpeaker(
|
|
893
|
+
address: string,
|
|
894
|
+
protocol: 'sonos' | 'airplay' | 'chromecast' | 'dlna',
|
|
895
|
+
options?: { name?: string; port?: number }
|
|
896
|
+
): Promise<UniversalDevice> {
|
|
897
|
+
const defaultPort = protocol === 'sonos' ? 1400 : protocol === 'airplay' ? 7000 : protocol === 'chromecast' ? 8009 : 80;
|
|
898
|
+
const port = options?.port ?? defaultPort;
|
|
899
|
+
|
|
900
|
+
const deviceName = options?.name || `${protocol} at ${address}`;
|
|
901
|
+
const playbackFeature = new PlaybackFeature(
|
|
902
|
+
makeDeviceRef(address, port, deviceName),
|
|
903
|
+
port,
|
|
904
|
+
{
|
|
905
|
+
protocol,
|
|
906
|
+
supportsQueue: protocol === 'sonos',
|
|
907
|
+
supportsSeek: protocol !== 'airplay',
|
|
908
|
+
}
|
|
909
|
+
);
|
|
910
|
+
|
|
911
|
+
const volumeFeature = new VolumeFeature(
|
|
912
|
+
makeDeviceRef(address, port, deviceName),
|
|
913
|
+
port,
|
|
914
|
+
{
|
|
915
|
+
volumeProtocol: protocol,
|
|
916
|
+
minVolume: 0,
|
|
917
|
+
maxVolume: 100,
|
|
918
|
+
supportsMute: true,
|
|
919
|
+
}
|
|
920
|
+
);
|
|
921
|
+
|
|
922
|
+
// Use protocol as name source for speakers
|
|
923
|
+
const speakerNameSource: TNameSource =
|
|
924
|
+
protocol === 'sonos' ? 'sonos' :
|
|
925
|
+
protocol === 'airplay' ? 'airplay' :
|
|
926
|
+
protocol === 'chromecast' ? 'chromecast' : 'dlna';
|
|
927
|
+
|
|
928
|
+
const { device } = this.registerDevice(
|
|
929
|
+
address,
|
|
930
|
+
port,
|
|
931
|
+
options?.name,
|
|
932
|
+
undefined,
|
|
933
|
+
undefined,
|
|
934
|
+
playbackFeature,
|
|
935
|
+
speakerNameSource
|
|
936
|
+
);
|
|
937
|
+
|
|
938
|
+
if (!device.hasFeature('volume')) {
|
|
939
|
+
device.addFeature(volumeFeature);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
await device.connect();
|
|
943
|
+
return device;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// ============================================================================
|
|
947
|
+
// Feature Access
|
|
948
|
+
// ============================================================================
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* Get a feature from a device
|
|
952
|
+
*/
|
|
953
|
+
public getFeature<T extends Feature>(deviceId: string, featureType: TFeatureType): T | undefined {
|
|
954
|
+
const device = this.getDevice(deviceId);
|
|
955
|
+
return device?.getFeature<T>(featureType);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* Add a feature to a device
|
|
960
|
+
*/
|
|
961
|
+
public addFeature(deviceId: string, feature: Feature): boolean {
|
|
962
|
+
const device = this.getDevice(deviceId);
|
|
963
|
+
if (device) {
|
|
964
|
+
device.addFeature(feature);
|
|
965
|
+
this.emit('feature:added', { device, feature });
|
|
966
|
+
return true;
|
|
967
|
+
}
|
|
968
|
+
return false;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* Remove a feature from a device
|
|
973
|
+
*/
|
|
974
|
+
public async removeFeature(deviceId: string, featureType: TFeatureType): Promise<boolean> {
|
|
975
|
+
const device = this.getDevice(deviceId);
|
|
976
|
+
if (device) {
|
|
977
|
+
const removed = await device.removeFeature(featureType);
|
|
978
|
+
if (removed) {
|
|
979
|
+
this.emit('feature:removed', { device, featureType });
|
|
980
|
+
}
|
|
981
|
+
return removed;
|
|
982
|
+
}
|
|
983
|
+
return false;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// ============================================================================
|
|
987
|
+
// Network Scanning
|
|
988
|
+
// ============================================================================
|
|
989
|
+
|
|
990
|
+
public get networkScanner(): NetworkScanner {
|
|
991
|
+
if (!this._networkScanner) {
|
|
992
|
+
this._networkScanner = new NetworkScanner();
|
|
993
|
+
this.setupNetworkScannerEvents();
|
|
994
|
+
}
|
|
995
|
+
return this._networkScanner;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
private setupNetworkScannerEvents(): void {
|
|
999
|
+
if (!this._networkScanner) return;
|
|
1000
|
+
|
|
1001
|
+
this._networkScanner.on('device:found', (result: INetworkScanResult) => {
|
|
1002
|
+
this.emit('network:device:found', result);
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
this._networkScanner.on('progress', (progress) => {
|
|
1006
|
+
this.emit('network:progress', progress);
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
this._networkScanner.on('complete', (results) => {
|
|
1010
|
+
this.emit('network:complete', results);
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
this._networkScanner.on('error', (error) => {
|
|
1014
|
+
this.emit('error', error);
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
public async scanNetwork(options: INetworkScanOptions): Promise<UniversalDevice[]> {
|
|
1019
|
+
const results = await this.networkScanner.scan(options);
|
|
1020
|
+
const foundDevices: UniversalDevice[] = [];
|
|
1021
|
+
const seenAddresses = new Set<string>();
|
|
1022
|
+
|
|
1023
|
+
for (const result of results) {
|
|
1024
|
+
// Skip if we already processed this IP in this scan
|
|
1025
|
+
if (seenAddresses.has(result.address)) continue;
|
|
1026
|
+
seenAddresses.add(result.address);
|
|
1027
|
+
|
|
1028
|
+
for (const deviceInfo of result.devices) {
|
|
1029
|
+
try {
|
|
1030
|
+
let device: UniversalDevice | null = null;
|
|
1031
|
+
|
|
1032
|
+
if (deviceInfo.type === 'scanner') {
|
|
1033
|
+
device = await this.addScanner(
|
|
1034
|
+
result.address,
|
|
1035
|
+
deviceInfo.port,
|
|
1036
|
+
deviceInfo.protocol as 'escl' | 'sane',
|
|
1037
|
+
deviceInfo.name
|
|
1038
|
+
);
|
|
1039
|
+
} else if (deviceInfo.type === 'printer' && deviceInfo.protocol === 'ipp') {
|
|
1040
|
+
device = await this.addPrinter(result.address, deviceInfo.port, deviceInfo.name);
|
|
1041
|
+
} else if (deviceInfo.type === 'speaker') {
|
|
1042
|
+
device = await this.addSpeaker(
|
|
1043
|
+
result.address,
|
|
1044
|
+
deviceInfo.protocol as 'sonos' | 'airplay' | 'chromecast' | 'dlna',
|
|
1045
|
+
{ name: deviceInfo.name, port: deviceInfo.port }
|
|
1046
|
+
);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
if (device && !foundDevices.includes(device)) {
|
|
1050
|
+
foundDevices.push(device);
|
|
1051
|
+
}
|
|
1052
|
+
} catch (error) {
|
|
1053
|
+
this.emit('error', error instanceof Error ? error : new Error(String(error)));
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
return foundDevices;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/**
|
|
1062
|
+
* Discover scanners in a subnet and add them to the manager.
|
|
1063
|
+
* Convenience method that focuses discovery on scanner protocols only (eSCL, SANE).
|
|
1064
|
+
*
|
|
1065
|
+
* @param subnet CIDR notation subnet (e.g., '192.168.1.0/24')
|
|
1066
|
+
* @param options Optional discovery settings
|
|
1067
|
+
* @returns Array of discovered scanner devices
|
|
1068
|
+
*
|
|
1069
|
+
* @example
|
|
1070
|
+
* ```typescript
|
|
1071
|
+
* const scanners = await manager.discoverScanners('192.168.190.0/24');
|
|
1072
|
+
* for (const scanner of scanners) {
|
|
1073
|
+
* console.log(`Found: ${scanner.name} at ${scanner.address}`);
|
|
1074
|
+
* }
|
|
1075
|
+
* ```
|
|
1076
|
+
*/
|
|
1077
|
+
public async discoverScanners(
|
|
1078
|
+
subnet: string,
|
|
1079
|
+
options?: { timeout?: number; concurrency?: number }
|
|
1080
|
+
): Promise<UniversalDevice[]> {
|
|
1081
|
+
await this.scanNetwork({
|
|
1082
|
+
ipRange: subnet,
|
|
1083
|
+
probeEscl: true,
|
|
1084
|
+
probeSane: true,
|
|
1085
|
+
probeIpp: false,
|
|
1086
|
+
probeAirplay: false,
|
|
1087
|
+
probeSonos: false,
|
|
1088
|
+
probeChromecast: false,
|
|
1089
|
+
concurrency: options?.concurrency ?? 50,
|
|
1090
|
+
timeout: options?.timeout ?? 3000,
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
return this.getScanners();
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/**
|
|
1097
|
+
* Discover printers in a subnet and add them to the manager.
|
|
1098
|
+
* Convenience method that focuses discovery on printer protocols only (IPP).
|
|
1099
|
+
*
|
|
1100
|
+
* @param subnet CIDR notation subnet (e.g., '192.168.1.0/24')
|
|
1101
|
+
* @param options Optional discovery settings
|
|
1102
|
+
* @returns Array of discovered printer devices
|
|
1103
|
+
*/
|
|
1104
|
+
public async discoverPrinters(
|
|
1105
|
+
subnet: string,
|
|
1106
|
+
options?: { timeout?: number; concurrency?: number }
|
|
1107
|
+
): Promise<UniversalDevice[]> {
|
|
1108
|
+
await this.scanNetwork({
|
|
1109
|
+
ipRange: subnet,
|
|
1110
|
+
probeEscl: false,
|
|
1111
|
+
probeSane: false,
|
|
1112
|
+
probeIpp: true,
|
|
1113
|
+
probeAirplay: false,
|
|
1114
|
+
probeSonos: false,
|
|
1115
|
+
probeChromecast: false,
|
|
1116
|
+
concurrency: options?.concurrency ?? 50,
|
|
1117
|
+
timeout: options?.timeout ?? 3000,
|
|
1118
|
+
});
|
|
1119
|
+
|
|
1120
|
+
return this.getPrinters();
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
public async cancelNetworkScan(): Promise<void> {
|
|
1124
|
+
if (this._networkScanner) {
|
|
1125
|
+
await this._networkScanner.cancel();
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
public get isNetworkScanning(): boolean {
|
|
1130
|
+
return this._networkScanner?.isScanning ?? false;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// ============================================================================
|
|
1134
|
+
// Device Removal and Cleanup
|
|
1135
|
+
// ============================================================================
|
|
1136
|
+
|
|
1137
|
+
/**
|
|
1138
|
+
* Remove a device by ID
|
|
1139
|
+
*/
|
|
1140
|
+
public async removeDevice(id: string): Promise<boolean> {
|
|
1141
|
+
const device = this.getDevice(id);
|
|
1142
|
+
if (device) {
|
|
1143
|
+
await device.disconnect();
|
|
1144
|
+
this.devicesByIp.delete(device.address);
|
|
1145
|
+
this.nameSourceByIp.delete(device.address);
|
|
1146
|
+
this.emit('device:removed', id);
|
|
1147
|
+
return true;
|
|
1148
|
+
}
|
|
1149
|
+
return false;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
/**
|
|
1153
|
+
* Remove a device by address
|
|
1154
|
+
*/
|
|
1155
|
+
public async removeDeviceByAddress(address: string): Promise<boolean> {
|
|
1156
|
+
const device = this.devicesByIp.get(address);
|
|
1157
|
+
if (device) {
|
|
1158
|
+
await device.disconnect();
|
|
1159
|
+
this.devicesByIp.delete(address);
|
|
1160
|
+
this.nameSourceByIp.delete(address);
|
|
1161
|
+
this.emit('device:removed', device.id);
|
|
1162
|
+
return true;
|
|
1163
|
+
}
|
|
1164
|
+
return false;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
/**
|
|
1168
|
+
* Disconnect all devices
|
|
1169
|
+
*/
|
|
1170
|
+
public async disconnectAll(): Promise<void> {
|
|
1171
|
+
const promises: Promise<void>[] = [];
|
|
1172
|
+
for (const device of this.devicesByIp.values()) {
|
|
1173
|
+
promises.push(device.disconnect().catch(() => {}));
|
|
1174
|
+
}
|
|
1175
|
+
await Promise.all(promises);
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/**
|
|
1179
|
+
* Stop discovery and disconnect all devices
|
|
1180
|
+
*/
|
|
1181
|
+
public async shutdown(): Promise<void> {
|
|
1182
|
+
await this.stopDiscovery();
|
|
1183
|
+
await this.disconnectAll();
|
|
1184
|
+
this.devicesByIp.clear();
|
|
1185
|
+
this.nameSourceByIp.clear();
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
// ============================================================================
|
|
1190
|
+
// Exports
|
|
1191
|
+
// ============================================================================
|
|
1192
|
+
|
|
1193
|
+
export {
|
|
1194
|
+
// Discovery
|
|
1195
|
+
MdnsDiscovery,
|
|
1196
|
+
NetworkScanner,
|
|
1197
|
+
SsdpDiscovery,
|
|
1198
|
+
SERVICE_TYPES,
|
|
1199
|
+
SSDP_SERVICE_TYPES,
|
|
1200
|
+
|
|
1201
|
+
// Universal Device
|
|
1202
|
+
UniversalDevice,
|
|
1203
|
+
|
|
1204
|
+
// Features
|
|
1205
|
+
ScanFeature,
|
|
1206
|
+
PrintFeature,
|
|
1207
|
+
PlaybackFeature,
|
|
1208
|
+
VolumeFeature,
|
|
1209
|
+
PowerFeature,
|
|
1210
|
+
SnmpFeature,
|
|
1211
|
+
|
|
1212
|
+
// Factories
|
|
1213
|
+
createScanner,
|
|
1214
|
+
createPrinter,
|
|
1215
|
+
createSnmpDevice,
|
|
1216
|
+
createUpsDevice,
|
|
1217
|
+
createSpeaker,
|
|
1218
|
+
createDlnaRenderer,
|
|
1219
|
+
};
|