@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,648 @@
1
+ import * as plugins from '../plugins.js';
2
+ import { EsclProtocol } from '../protocols/index.js';
3
+ import {
4
+ cidrToIps,
5
+ ipRangeToIps,
6
+ isValidIp,
7
+ } from '../helpers/helpers.iprange.js';
8
+ import type {
9
+ INetworkScanOptions,
10
+ INetworkScanResult,
11
+ INetworkScanDevice,
12
+ INetworkScanProgress,
13
+ } from '../interfaces/index.js';
14
+
15
+ /**
16
+ * Default ports to probe for device discovery
17
+ */
18
+ const DEFAULT_PORTS = [
19
+ 631, // IPP printers
20
+ 80, // eSCL scanners (HTTP)
21
+ 443, // eSCL scanners (HTTPS)
22
+ 6566, // SANE scanners
23
+ 9100, // JetDirect printers
24
+ 7000, // AirPlay speakers
25
+ 5000, // AirPlay control / RTSP
26
+ 3689, // DAAP (iTunes/AirPlay 2)
27
+ 1400, // Sonos speakers
28
+ 8009, // Chromecast devices
29
+ ];
30
+
31
+ /**
32
+ * Default scan options
33
+ */
34
+ const DEFAULT_OPTIONS: Required<Omit<INetworkScanOptions, 'ipRange' | 'startIp' | 'endIp'>> = {
35
+ concurrency: 50,
36
+ timeout: 2000,
37
+ ports: DEFAULT_PORTS,
38
+ probeEscl: true,
39
+ probeIpp: true,
40
+ probeSane: true,
41
+ probeAirplay: true,
42
+ probeSonos: true,
43
+ probeChromecast: true,
44
+ };
45
+
46
+ /**
47
+ * Simple concurrency limiter
48
+ */
49
+ class ConcurrencyLimiter {
50
+ private running = 0;
51
+ private queue: (() => void)[] = [];
52
+
53
+ constructor(private limit: number) {}
54
+
55
+ async run<T>(fn: () => Promise<T>): Promise<T> {
56
+ while (this.running >= this.limit) {
57
+ await new Promise<void>((resolve) => this.queue.push(resolve));
58
+ }
59
+
60
+ this.running++;
61
+ try {
62
+ return await fn();
63
+ } finally {
64
+ this.running--;
65
+ const next = this.queue.shift();
66
+ if (next) next();
67
+ }
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Network Scanner - scans IP ranges for scanners and printers
73
+ */
74
+ export class NetworkScanner extends plugins.events.EventEmitter {
75
+ private cancelled = false;
76
+ private scanning = false;
77
+
78
+ /**
79
+ * Check if a scan is currently in progress
80
+ */
81
+ public get isScanning(): boolean {
82
+ return this.scanning;
83
+ }
84
+
85
+ /**
86
+ * Scan a network range for devices
87
+ */
88
+ public async scan(options: INetworkScanOptions): Promise<INetworkScanResult[]> {
89
+ if (this.scanning) {
90
+ throw new Error('A scan is already in progress');
91
+ }
92
+
93
+ this.cancelled = false;
94
+ this.scanning = true;
95
+
96
+ const opts = { ...DEFAULT_OPTIONS, ...options };
97
+ const results: INetworkScanResult[] = [];
98
+
99
+ try {
100
+ // Get list of IPs to scan
101
+ const ips = this.resolveIps(options);
102
+
103
+ if (ips.length === 0) {
104
+ throw new Error('No IPs to scan. Provide ipRange, or startIp and endIp.');
105
+ }
106
+
107
+ const limiter = new ConcurrencyLimiter(opts.concurrency);
108
+ let scanned = 0;
109
+
110
+ // Progress tracking
111
+ const emitProgress = (currentIp?: string) => {
112
+ const progress: INetworkScanProgress = {
113
+ total: ips.length,
114
+ scanned,
115
+ percentage: Math.round((scanned / ips.length) * 100),
116
+ currentIp,
117
+ devicesFound: results.reduce((sum, r) => sum + r.devices.length, 0),
118
+ };
119
+ this.emit('progress', progress);
120
+ };
121
+
122
+ emitProgress();
123
+
124
+ // Scan all IPs with concurrency limit
125
+ const scanPromises = ips.map((ip) =>
126
+ limiter.run(async () => {
127
+ if (this.cancelled) return;
128
+
129
+ const devices = await this.probeIp(ip, opts);
130
+ scanned++;
131
+
132
+ if (devices.length > 0) {
133
+ const result: INetworkScanResult = { address: ip, devices };
134
+ results.push(result);
135
+ this.emit('device:found', result);
136
+ }
137
+
138
+ emitProgress(ip);
139
+ })
140
+ );
141
+
142
+ await Promise.all(scanPromises);
143
+
144
+ if (this.cancelled) {
145
+ this.emit('cancelled');
146
+ } else {
147
+ this.emit('complete', results);
148
+ }
149
+
150
+ return results;
151
+ } finally {
152
+ this.scanning = false;
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Cancel an ongoing scan
158
+ */
159
+ public async cancel(): Promise<void> {
160
+ if (this.scanning) {
161
+ this.cancelled = true;
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Resolve IPs from options (CIDR or start/end range)
167
+ */
168
+ private resolveIps(options: INetworkScanOptions): string[] {
169
+ if (options.ipRange) {
170
+ return cidrToIps(options.ipRange);
171
+ }
172
+
173
+ if (options.startIp && options.endIp) {
174
+ return ipRangeToIps(options.startIp, options.endIp);
175
+ }
176
+
177
+ if (options.startIp && !options.endIp) {
178
+ // Single IP
179
+ if (isValidIp(options.startIp)) {
180
+ return [options.startIp];
181
+ }
182
+ }
183
+
184
+ return [];
185
+ }
186
+
187
+ /**
188
+ * Probe a single IP address for devices
189
+ */
190
+ private async probeIp(
191
+ ip: string,
192
+ opts: Required<Omit<INetworkScanOptions, 'ipRange' | 'startIp' | 'endIp'>>
193
+ ): Promise<INetworkScanDevice[]> {
194
+ const devices: INetworkScanDevice[] = [];
195
+ const timeout = opts.timeout;
196
+
197
+ // First, do a quick port scan to see which ports are open
198
+ const openPorts = await this.scanPorts(ip, opts.ports, timeout / 2);
199
+
200
+ if (openPorts.length === 0 || this.cancelled) {
201
+ return devices;
202
+ }
203
+
204
+ // Probe each open port for specific protocols
205
+ const probePromises: Promise<void>[] = [];
206
+
207
+ for (const port of openPorts) {
208
+ // IPP probe (port 631)
209
+ if (opts.probeIpp && port === 631) {
210
+ probePromises.push(
211
+ this.probeIpp(ip, port, timeout).then((device) => {
212
+ if (device) devices.push(device);
213
+ })
214
+ );
215
+ }
216
+
217
+ // eSCL probe (ports 80, 443)
218
+ if (opts.probeEscl && (port === 80 || port === 443)) {
219
+ probePromises.push(
220
+ this.probeEscl(ip, port, timeout).then((device) => {
221
+ if (device) devices.push(device);
222
+ })
223
+ );
224
+ }
225
+
226
+ // SANE probe (port 6566)
227
+ if (opts.probeSane && port === 6566) {
228
+ probePromises.push(
229
+ this.probeSane(ip, port, timeout).then((device) => {
230
+ if (device) devices.push(device);
231
+ })
232
+ );
233
+ }
234
+
235
+ // JetDirect probe (port 9100) - just mark as raw printer
236
+ if (port === 9100) {
237
+ devices.push({
238
+ type: 'printer',
239
+ protocol: 'jetdirect',
240
+ port: 9100,
241
+ name: `Raw Printer at ${ip}`,
242
+ });
243
+ }
244
+
245
+ // AirPlay probe (port 7000) - try HTTP endpoints
246
+ if (opts.probeAirplay && port === 7000) {
247
+ probePromises.push(
248
+ this.probeAirplay(ip, port, timeout).then((device) => {
249
+ if (device) devices.push(device);
250
+ })
251
+ );
252
+ }
253
+
254
+ // AirPlay ports 5000 (RTSP) and 3689 (DAAP) - if open, it's likely an AirPlay device
255
+ if (opts.probeAirplay && (port === 5000 || port === 3689)) {
256
+ devices.push({
257
+ type: 'speaker',
258
+ protocol: 'airplay',
259
+ port,
260
+ name: `AirPlay Device at ${ip}`,
261
+ });
262
+ }
263
+
264
+ // Sonos probe (port 1400)
265
+ if (opts.probeSonos && port === 1400) {
266
+ probePromises.push(
267
+ this.probeSonos(ip, port, timeout).then((device) => {
268
+ if (device) devices.push(device);
269
+ })
270
+ );
271
+ }
272
+
273
+ // Chromecast probe (port 8009)
274
+ if (opts.probeChromecast && port === 8009) {
275
+ probePromises.push(
276
+ this.probeChromecast(ip, port, timeout).then((device) => {
277
+ if (device) devices.push(device);
278
+ })
279
+ );
280
+ }
281
+ }
282
+
283
+ await Promise.all(probePromises);
284
+
285
+ return devices;
286
+ }
287
+
288
+ /**
289
+ * Quick TCP port scan
290
+ */
291
+ private async scanPorts(ip: string, ports: number[], timeout: number): Promise<number[]> {
292
+ const openPorts: number[] = [];
293
+
294
+ const scanPromises = ports.map(async (port) => {
295
+ const isOpen = await this.isPortOpen(ip, port, timeout);
296
+ if (isOpen) {
297
+ openPorts.push(port);
298
+ }
299
+ });
300
+
301
+ await Promise.all(scanPromises);
302
+ return openPorts;
303
+ }
304
+
305
+ /**
306
+ * Check if a TCP port is open
307
+ */
308
+ private isPortOpen(ip: string, port: number, timeout: number): Promise<boolean> {
309
+ return new Promise((resolve) => {
310
+ const socket = new plugins.net.Socket();
311
+
312
+ const timer = setTimeout(() => {
313
+ socket.destroy();
314
+ resolve(false);
315
+ }, timeout);
316
+
317
+ socket.on('connect', () => {
318
+ clearTimeout(timer);
319
+ socket.destroy();
320
+ resolve(true);
321
+ });
322
+
323
+ socket.on('error', () => {
324
+ clearTimeout(timer);
325
+ socket.destroy();
326
+ resolve(false);
327
+ });
328
+
329
+ socket.connect(port, ip);
330
+ });
331
+ }
332
+
333
+ /**
334
+ * Probe for IPP printer using a simple HTTP check
335
+ * We avoid using the full ipp library for probing since it can hang and produce noisy output
336
+ */
337
+ private async probeIpp(
338
+ ip: string,
339
+ port: number,
340
+ timeout: number
341
+ ): Promise<INetworkScanDevice | null> {
342
+ try {
343
+ // Use a simple HTTP OPTIONS or POST to check if IPP endpoint exists
344
+ // IPP uses HTTP POST to /ipp/print or /ipp/printer
345
+ const controller = new AbortController();
346
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
347
+
348
+ try {
349
+ const response = await fetch(`http://${ip}:${port}/ipp/print`, {
350
+ method: 'POST',
351
+ headers: {
352
+ 'Content-Type': 'application/ipp',
353
+ },
354
+ body: Buffer.from([
355
+ // Minimal IPP Get-Printer-Attributes request
356
+ 0x01, 0x01, // IPP version 1.1
357
+ 0x00, 0x0b, // operation-id: Get-Printer-Attributes
358
+ 0x00, 0x00, 0x00, 0x01, // request-id: 1
359
+ 0x01, // operation-attributes-tag
360
+ 0x47, // charset
361
+ 0x00, 0x12, // name-length: 18
362
+ ...Buffer.from('attributes-charset'),
363
+ 0x00, 0x05, // value-length: 5
364
+ ...Buffer.from('utf-8'),
365
+ 0x48, // naturalLanguage
366
+ 0x00, 0x1b, // name-length: 27
367
+ ...Buffer.from('attributes-natural-language'),
368
+ 0x00, 0x05, // value-length: 5
369
+ ...Buffer.from('en-us'),
370
+ 0x03, // end-of-attributes-tag
371
+ ]),
372
+ signal: controller.signal,
373
+ });
374
+
375
+ clearTimeout(timeoutId);
376
+
377
+ // If we get a response with application/ipp content type, it's likely an IPP printer
378
+ const contentType = response.headers.get('content-type') || '';
379
+ if (response.ok || contentType.includes('application/ipp')) {
380
+ return {
381
+ type: 'printer',
382
+ protocol: 'ipp',
383
+ port,
384
+ name: `IPP Printer at ${ip}`,
385
+ model: undefined,
386
+ };
387
+ }
388
+ } catch (fetchErr) {
389
+ clearTimeout(timeoutId);
390
+ // Fetch failed or was aborted
391
+ }
392
+ } catch {
393
+ // Not an IPP printer
394
+ }
395
+
396
+ return null;
397
+ }
398
+
399
+ /**
400
+ * Probe for eSCL scanner
401
+ */
402
+ private async probeEscl(
403
+ ip: string,
404
+ port: number,
405
+ timeout: number
406
+ ): Promise<INetworkScanDevice | null> {
407
+ try {
408
+ const secure = port === 443;
409
+ const escl = new EsclProtocol(ip, port, secure);
410
+
411
+ const caps = await Promise.race([
412
+ escl.getCapabilities(),
413
+ new Promise<null>((_, reject) =>
414
+ setTimeout(() => reject(new Error('Timeout')), timeout)
415
+ ),
416
+ ]);
417
+
418
+ if (caps) {
419
+ return {
420
+ type: 'scanner',
421
+ protocol: 'escl',
422
+ port,
423
+ name: caps.makeAndModel || `eSCL Scanner at ${ip}`,
424
+ model: caps.makeAndModel,
425
+ };
426
+ }
427
+ } catch {
428
+ // Not an eSCL scanner
429
+ }
430
+
431
+ return null;
432
+ }
433
+
434
+ /**
435
+ * Probe for SANE scanner (quick check)
436
+ */
437
+ private async probeSane(
438
+ ip: string,
439
+ port: number,
440
+ timeout: number
441
+ ): Promise<INetworkScanDevice | null> {
442
+ // SANE probing requires full protocol implementation
443
+ // For now, just check if port is open and assume it's SANE
444
+ // A more thorough implementation would send SANE_NET_INIT
445
+ try {
446
+ const isOpen = await this.isPortOpen(ip, port, timeout);
447
+ if (isOpen) {
448
+ return {
449
+ type: 'scanner',
450
+ protocol: 'sane',
451
+ port,
452
+ name: `SANE Scanner at ${ip}`,
453
+ };
454
+ }
455
+ } catch {
456
+ // Not a SANE scanner
457
+ }
458
+
459
+ return null;
460
+ }
461
+
462
+ /**
463
+ * Probe for AirPlay speaker on port 7000
464
+ * Tries HTTP endpoints to identify the device. If no HTTP response,
465
+ * but port 7000 is open, it's still likely an AirPlay device.
466
+ */
467
+ private async probeAirplay(
468
+ ip: string,
469
+ port: number,
470
+ timeout: number
471
+ ): Promise<INetworkScanDevice | null> {
472
+ try {
473
+ const controller = new AbortController();
474
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
475
+
476
+ // Try /server-info first (older AirPlay devices)
477
+ try {
478
+ const response = await fetch(`http://${ip}:${port}/server-info`, {
479
+ signal: controller.signal,
480
+ });
481
+
482
+ clearTimeout(timeoutId);
483
+
484
+ if (response.ok) {
485
+ const text = await response.text();
486
+ // Parse model from plist if available
487
+ const modelMatch = text.match(/<key>model<\/key>\s*<string>([^<]+)<\/string>/);
488
+ const model = modelMatch?.[1];
489
+
490
+ return {
491
+ type: 'speaker',
492
+ protocol: 'airplay',
493
+ port,
494
+ name: model || `AirPlay Speaker at ${ip}`,
495
+ model,
496
+ };
497
+ }
498
+ } catch {
499
+ clearTimeout(timeoutId);
500
+ }
501
+
502
+ // Try /info endpoint (some AirPlay 2 devices)
503
+ const controller2 = new AbortController();
504
+ const timeoutId2 = setTimeout(() => controller2.abort(), timeout);
505
+ try {
506
+ const response = await fetch(`http://${ip}:${port}/info`, {
507
+ signal: controller2.signal,
508
+ });
509
+
510
+ clearTimeout(timeoutId2);
511
+
512
+ if (response.ok) {
513
+ const text = await response.text();
514
+ // Try to parse model info
515
+ const modelMatch = text.match(/<key>model<\/key>\s*<string>([^<]+)<\/string>/);
516
+ const nameMatch = text.match(/<key>name<\/key>\s*<string>([^<]+)<\/string>/);
517
+ const model = modelMatch?.[1];
518
+ const name = nameMatch?.[1];
519
+
520
+ return {
521
+ type: 'speaker',
522
+ protocol: 'airplay',
523
+ port,
524
+ name: name || model || `AirPlay Speaker at ${ip}`,
525
+ model,
526
+ };
527
+ }
528
+ } catch {
529
+ clearTimeout(timeoutId2);
530
+ }
531
+
532
+ // Port 7000 is open but no HTTP endpoints responded
533
+ // Still likely an AirPlay device (AirPlay 2 / HomePod)
534
+ return {
535
+ type: 'speaker',
536
+ protocol: 'airplay',
537
+ port,
538
+ name: `AirPlay Device at ${ip}`,
539
+ };
540
+ } catch {
541
+ // Not an AirPlay speaker
542
+ }
543
+
544
+ return null;
545
+ }
546
+
547
+ /**
548
+ * Probe for Sonos speaker
549
+ */
550
+ private async probeSonos(
551
+ ip: string,
552
+ port: number,
553
+ timeout: number
554
+ ): Promise<INetworkScanDevice | null> {
555
+ try {
556
+ const controller = new AbortController();
557
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
558
+
559
+ try {
560
+ // Sonos devices respond to device description requests
561
+ const response = await fetch(`http://${ip}:${port}/xml/device_description.xml`, {
562
+ signal: controller.signal,
563
+ });
564
+
565
+ clearTimeout(timeoutId);
566
+
567
+ if (response.ok) {
568
+ const text = await response.text();
569
+
570
+ // Check if it's actually a Sonos device
571
+ if (text.includes('Sonos') || text.includes('schemas-upnp-org')) {
572
+ // Parse friendly name and model
573
+ const nameMatch = text.match(/<friendlyName>([^<]+)<\/friendlyName>/);
574
+ const modelMatch = text.match(/<modelName>([^<]+)<\/modelName>/);
575
+
576
+ return {
577
+ type: 'speaker',
578
+ protocol: 'sonos',
579
+ port,
580
+ name: nameMatch?.[1] || `Sonos Speaker at ${ip}`,
581
+ model: modelMatch?.[1],
582
+ };
583
+ }
584
+ }
585
+ } catch {
586
+ clearTimeout(timeoutId);
587
+ }
588
+ } catch {
589
+ // Not a Sonos speaker
590
+ }
591
+
592
+ return null;
593
+ }
594
+
595
+ /**
596
+ * Probe for Chromecast device
597
+ */
598
+ private async probeChromecast(
599
+ ip: string,
600
+ port: number,
601
+ timeout: number
602
+ ): Promise<INetworkScanDevice | null> {
603
+ try {
604
+ const controller = new AbortController();
605
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
606
+
607
+ try {
608
+ // Chromecast devices have an info endpoint on port 8008 (HTTP)
609
+ // Port 8009 is the Cast protocol port (TLS)
610
+ // Try fetching the eureka_info endpoint
611
+ const response = await fetch(`http://${ip}:8008/setup/eureka_info`, {
612
+ signal: controller.signal,
613
+ });
614
+
615
+ clearTimeout(timeoutId);
616
+
617
+ if (response.ok) {
618
+ const data = await response.json();
619
+
620
+ return {
621
+ type: 'speaker',
622
+ protocol: 'chromecast',
623
+ port,
624
+ name: data.name || `Chromecast at ${ip}`,
625
+ model: data.cast_build_revision || data.model_name,
626
+ };
627
+ }
628
+ } catch {
629
+ clearTimeout(timeoutId);
630
+ }
631
+
632
+ // Alternative: just check if port 8009 is open (Cast protocol)
633
+ const isOpen = await this.isPortOpen(ip, port, timeout);
634
+ if (isOpen) {
635
+ return {
636
+ type: 'speaker',
637
+ protocol: 'chromecast',
638
+ port,
639
+ name: `Chromecast at ${ip}`,
640
+ };
641
+ }
642
+ } catch {
643
+ // Not a Chromecast
644
+ }
645
+
646
+ return null;
647
+ }
648
+ }