@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.
Files changed (137) hide show
  1. package/.gitea/workflows/default_nottags.yaml +66 -0
  2. package/.gitea/workflows/default_tags.yaml +124 -0
  3. package/.vscode/launch.json +11 -0
  4. package/.vscode/settings.json +26 -0
  5. package/changelog.md +91 -0
  6. package/dist_ts/00_commitinfo_data.d.ts +8 -0
  7. package/dist_ts/00_commitinfo_data.js +9 -0
  8. package/dist_ts/device/device.classes.device.d.ts +205 -0
  9. package/dist_ts/device/device.classes.device.js +344 -0
  10. package/dist_ts/devicemanager.classes.devicemanager.d.ts +210 -0
  11. package/dist_ts/devicemanager.classes.devicemanager.js +835 -0
  12. package/dist_ts/discovery/discovery.classes.homeassistant.d.ts +101 -0
  13. package/dist_ts/discovery/discovery.classes.homeassistant.js +313 -0
  14. package/dist_ts/discovery/discovery.classes.mdns.d.ts +90 -0
  15. package/dist_ts/discovery/discovery.classes.mdns.js +291 -0
  16. package/dist_ts/discovery/discovery.classes.networkscanner.d.ts +64 -0
  17. package/dist_ts/discovery/discovery.classes.networkscanner.js +535 -0
  18. package/dist_ts/discovery/discovery.classes.ssdp.d.ts +112 -0
  19. package/dist_ts/discovery/discovery.classes.ssdp.js +255 -0
  20. package/dist_ts/factories/index.d.ts +190 -0
  21. package/dist_ts/factories/index.js +350 -0
  22. package/dist_ts/features/feature.abstract.d.ts +118 -0
  23. package/dist_ts/features/feature.abstract.js +172 -0
  24. package/dist_ts/features/feature.camera.d.ts +95 -0
  25. package/dist_ts/features/feature.camera.js +162 -0
  26. package/dist_ts/features/feature.climate.d.ts +174 -0
  27. package/dist_ts/features/feature.climate.js +328 -0
  28. package/dist_ts/features/feature.cover.d.ts +121 -0
  29. package/dist_ts/features/feature.cover.js +217 -0
  30. package/dist_ts/features/feature.fan.d.ts +127 -0
  31. package/dist_ts/features/feature.fan.js +235 -0
  32. package/dist_ts/features/feature.light.d.ts +156 -0
  33. package/dist_ts/features/feature.light.js +296 -0
  34. package/dist_ts/features/feature.lock.d.ts +103 -0
  35. package/dist_ts/features/feature.lock.js +170 -0
  36. package/dist_ts/features/feature.playback.d.ts +98 -0
  37. package/dist_ts/features/feature.playback.js +191 -0
  38. package/dist_ts/features/feature.power.d.ts +79 -0
  39. package/dist_ts/features/feature.power.js +173 -0
  40. package/dist_ts/features/feature.print.d.ts +78 -0
  41. package/dist_ts/features/feature.print.js +226 -0
  42. package/dist_ts/features/feature.scan.d.ts +79 -0
  43. package/dist_ts/features/feature.scan.js +315 -0
  44. package/dist_ts/features/feature.sensor.d.ts +95 -0
  45. package/dist_ts/features/feature.sensor.js +147 -0
  46. package/dist_ts/features/feature.snmp.d.ts +88 -0
  47. package/dist_ts/features/feature.snmp.js +175 -0
  48. package/dist_ts/features/feature.switch.d.ts +75 -0
  49. package/dist_ts/features/feature.switch.js +126 -0
  50. package/dist_ts/features/feature.volume.d.ts +104 -0
  51. package/dist_ts/features/feature.volume.js +186 -0
  52. package/dist_ts/features/index.d.ts +19 -0
  53. package/dist_ts/features/index.js +23 -0
  54. package/dist_ts/helpers/helpers.iprange.d.ts +35 -0
  55. package/dist_ts/helpers/helpers.iprange.js +151 -0
  56. package/dist_ts/helpers/helpers.retry.d.ts +25 -0
  57. package/dist_ts/helpers/helpers.retry.js +69 -0
  58. package/dist_ts/index.d.ts +15 -0
  59. package/dist_ts/index.js +40 -0
  60. package/dist_ts/interfaces/feature.interfaces.d.ts +255 -0
  61. package/dist_ts/interfaces/feature.interfaces.js +6 -0
  62. package/dist_ts/interfaces/homeassistant.interfaces.d.ts +550 -0
  63. package/dist_ts/interfaces/homeassistant.interfaces.js +111 -0
  64. package/dist_ts/interfaces/index.d.ts +330 -0
  65. package/dist_ts/interfaces/index.js +16 -0
  66. package/dist_ts/interfaces/smarthome.interfaces.d.ts +282 -0
  67. package/dist_ts/interfaces/smarthome.interfaces.js +7 -0
  68. package/dist_ts/paths.d.ts +1 -0
  69. package/dist_ts/paths.js +3 -0
  70. package/dist_ts/plugins.d.ts +23 -0
  71. package/dist_ts/plugins.js +30 -0
  72. package/dist_ts/protocols/index.d.ts +12 -0
  73. package/dist_ts/protocols/index.js +21 -0
  74. package/dist_ts/protocols/protocol.escl.d.ts +58 -0
  75. package/dist_ts/protocols/protocol.escl.js +364 -0
  76. package/dist_ts/protocols/protocol.homeassistant.d.ts +248 -0
  77. package/dist_ts/protocols/protocol.homeassistant.js +611 -0
  78. package/dist_ts/protocols/protocol.ipp.d.ts +45 -0
  79. package/dist_ts/protocols/protocol.ipp.js +284 -0
  80. package/dist_ts/protocols/protocol.nut.d.ts +160 -0
  81. package/dist_ts/protocols/protocol.nut.js +364 -0
  82. package/dist_ts/protocols/protocol.sane.d.ts +104 -0
  83. package/dist_ts/protocols/protocol.sane.js +597 -0
  84. package/dist_ts/protocols/protocol.snmp.d.ts +133 -0
  85. package/dist_ts/protocols/protocol.snmp.js +333 -0
  86. package/dist_ts/protocols/protocol.upnp.d.ts +207 -0
  87. package/dist_ts/protocols/protocol.upnp.js +442 -0
  88. package/dist_ts/protocols/protocol.upssnmp.d.ts +160 -0
  89. package/dist_ts/protocols/protocol.upssnmp.js +263 -0
  90. package/npmextra.json +24 -0
  91. package/package.json +39 -0
  92. package/readme.hints.md +115 -0
  93. package/readme.md +624 -0
  94. package/test/test.ts +261 -0
  95. package/ts/00_commitinfo_data.ts +8 -0
  96. package/ts/device/device.classes.device.ts +454 -0
  97. package/ts/devicemanager.classes.devicemanager.ts +1219 -0
  98. package/ts/discovery/discovery.classes.homeassistant.ts +376 -0
  99. package/ts/discovery/discovery.classes.mdns.ts +347 -0
  100. package/ts/discovery/discovery.classes.networkscanner.ts +648 -0
  101. package/ts/discovery/discovery.classes.ssdp.ts +357 -0
  102. package/ts/factories/index.ts +696 -0
  103. package/ts/features/feature.abstract.ts +251 -0
  104. package/ts/features/feature.camera.ts +214 -0
  105. package/ts/features/feature.climate.ts +407 -0
  106. package/ts/features/feature.cover.ts +278 -0
  107. package/ts/features/feature.fan.ts +296 -0
  108. package/ts/features/feature.light.ts +369 -0
  109. package/ts/features/feature.lock.ts +223 -0
  110. package/ts/features/feature.playback.ts +246 -0
  111. package/ts/features/feature.power.ts +231 -0
  112. package/ts/features/feature.print.ts +297 -0
  113. package/ts/features/feature.scan.ts +370 -0
  114. package/ts/features/feature.sensor.ts +202 -0
  115. package/ts/features/feature.snmp.ts +235 -0
  116. package/ts/features/feature.switch.ts +170 -0
  117. package/ts/features/feature.volume.ts +256 -0
  118. package/ts/features/index.ts +25 -0
  119. package/ts/helpers/helpers.iprange.ts +172 -0
  120. package/ts/helpers/helpers.retry.ts +100 -0
  121. package/ts/index.ts +165 -0
  122. package/ts/interfaces/feature.interfaces.ts +359 -0
  123. package/ts/interfaces/homeassistant.interfaces.ts +666 -0
  124. package/ts/interfaces/index.ts +421 -0
  125. package/ts/interfaces/smarthome.interfaces.ts +421 -0
  126. package/ts/paths.ts +5 -0
  127. package/ts/plugins.ts +41 -0
  128. package/ts/protocols/index.ts +59 -0
  129. package/ts/protocols/protocol.escl.ts +439 -0
  130. package/ts/protocols/protocol.homeassistant.ts +737 -0
  131. package/ts/protocols/protocol.ipp.ts +329 -0
  132. package/ts/protocols/protocol.nut.ts +471 -0
  133. package/ts/protocols/protocol.sane.ts +694 -0
  134. package/ts/protocols/protocol.snmp.ts +439 -0
  135. package/ts/protocols/protocol.upnp.ts +627 -0
  136. package/ts/protocols/protocol.upssnmp.ts +377 -0
  137. 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
+ };