@ecobridge.xyz/devicemanager 3.0.2 → 3.1.1

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 (42) hide show
  1. package/.gitea/workflows/default_tags.yaml +0 -21
  2. package/.smartconfig.json +69 -0
  3. package/changelog.md +23 -1
  4. package/dist_ts/00_commitinfo_data.js +1 -1
  5. package/dist_ts/devicemanager.classes.devicemanager.js +33 -7
  6. package/dist_ts/discovery/discovery.classes.mdns.js +46 -11
  7. package/dist_ts/discovery/discovery.classes.networkscanner.d.ts +1 -1
  8. package/dist_ts/discovery/discovery.classes.networkscanner.js +1 -1
  9. package/dist_ts/discovery/discovery.classes.ssdp.d.ts +52 -21
  10. package/dist_ts/discovery/discovery.classes.ssdp.js +407 -117
  11. package/dist_ts/features/feature.print.d.ts +4 -2
  12. package/dist_ts/features/feature.print.js +48 -38
  13. package/dist_ts/index.d.ts +1 -1
  14. package/dist_ts/interfaces/feature.interfaces.d.ts +1 -1
  15. package/dist_ts/interfaces/index.d.ts +1 -1
  16. package/dist_ts/plugins.d.ts +5 -15
  17. package/dist_ts/plugins.js +6 -19
  18. package/dist_ts/protocols/index.d.ts +1 -1
  19. package/dist_ts/protocols/index.js +2 -2
  20. package/dist_ts/protocols/protocol.escl.js +2 -2
  21. package/dist_ts/protocols/protocol.ipp.d.ts +144 -33
  22. package/dist_ts/protocols/protocol.ipp.js +942 -239
  23. package/license.md +21 -0
  24. package/package.json +16 -25
  25. package/pnpm-workspace.yaml +4 -0
  26. package/readme.md +61 -76
  27. package/test/{test.ts → test.node.ts} +20 -45
  28. package/test/test.ssdp.node.ts +978 -0
  29. package/ts/00_commitinfo_data.ts +1 -1
  30. package/ts/devicemanager.classes.devicemanager.ts +44 -6
  31. package/ts/discovery/discovery.classes.mdns.ts +40 -11
  32. package/ts/discovery/discovery.classes.networkscanner.ts +1 -1
  33. package/ts/discovery/discovery.classes.ssdp.ts +493 -130
  34. package/ts/features/feature.print.ts +53 -45
  35. package/ts/index.ts +1 -0
  36. package/ts/interfaces/feature.interfaces.ts +1 -1
  37. package/ts/interfaces/index.ts +1 -1
  38. package/ts/plugins.ts +5 -25
  39. package/ts/protocols/index.ts +6 -1
  40. package/ts/protocols/protocol.escl.ts +1 -1
  41. package/ts/protocols/protocol.ipp.ts +1183 -260
  42. package/npmextra.json +0 -24
@@ -1,5 +1,15 @@
1
1
  import * as plugins from '../plugins.js';
2
2
 
3
+ const SSDP_ADDRESS = '239.255.255.250';
4
+ const SSDP_PORT = 1900;
5
+ const SSDP_MULTICAST_TTL = 4;
6
+ const SEARCH_INTERVAL_MS = 30000;
7
+ const DESCRIPTION_TIMEOUT_MS = 5000;
8
+ const SOAP_TIMEOUT_MS = 10000;
9
+
10
+ type TSsdpLifecycleState = 'stopped' | 'starting' | 'running' | 'stopping';
11
+ type TSsdpTimer = ReturnType<typeof setInterval>;
12
+
3
13
  /**
4
14
  * SSDP service types for device discovery
5
15
  */
@@ -19,6 +29,13 @@ export const SSDP_SERVICE_TYPES = {
19
29
  INTERNET_GATEWAY: 'urn:schemas-upnp-org:device:InternetGatewayDevice:1',
20
30
  };
21
31
 
32
+ const DEFAULT_SERVICE_TYPES = [
33
+ SSDP_SERVICE_TYPES.ROOT,
34
+ SSDP_SERVICE_TYPES.MEDIA_RENDERER,
35
+ SSDP_SERVICE_TYPES.MEDIA_SERVER,
36
+ SSDP_SERVICE_TYPES.SONOS_ZONE_PLAYER,
37
+ ];
38
+
22
39
  /**
23
40
  * SSDP discovered device information
24
41
  */
@@ -74,187 +91,462 @@ export interface ISsdpIcon {
74
91
  }
75
92
 
76
93
  /**
77
- * SSDP Discovery service using node-ssdp
94
+ * Optional runtime dependencies. These are primarily useful for deterministic
95
+ * tests and do not change the default native Node.js behavior.
78
96
  */
79
- export class SsdpDiscovery extends plugins.events.EventEmitter {
80
- private client: InstanceType<typeof plugins.nodeSsdp.Client> | null = null;
81
- private devices: Map<string, ISsdpDevice> = new Map();
82
- private running = false;
83
- private searchInterval: NodeJS.Timeout | null = null;
97
+ export interface ISsdpDiscoveryDependencies {
98
+ createSocket?: () => plugins.dgram.Socket;
99
+ networkInterfaces?: typeof plugins.os.networkInterfaces;
100
+ fetch?: typeof globalThis.fetch;
101
+ setInterval?: (callback: () => void, delay: number) => TSsdpTimer;
102
+ clearInterval?: (timer: TSsdpTimer) => void;
103
+ setTimeout?: (callback: () => void, delay: number) => TSsdpTimer;
104
+ clearTimeout?: (timer: TSsdpTimer) => void;
105
+ }
106
+
107
+ interface ISsdpSocketResource {
108
+ address: string;
109
+ socket: plugins.dgram.Socket;
110
+ onMessage: (message: Buffer, rinfo: plugins.dgram.RemoteInfo) => void;
111
+ onError: (error: Error) => void;
112
+ }
113
+
114
+ interface IPendingDescription {
115
+ device: ISsdpDevice;
116
+ abortController: AbortController;
117
+ timeout: TSsdpTimer;
118
+ promise: Promise<void>;
119
+ }
84
120
 
85
- constructor() {
121
+ interface IParsedSsdpResponse {
122
+ statusCode: number;
123
+ headers: Record<string, string>;
124
+ }
125
+
126
+ /**
127
+ * Focused SSDP discovery client backed by native Node.js UDP sockets.
128
+ */
129
+ export class SsdpDiscovery extends plugins.events.EventEmitter {
130
+ private readonly dependencies: Required<ISsdpDiscoveryDependencies>;
131
+ private readonly devices = new Map<string, ISsdpDevice>();
132
+ private readonly foundUsns = new Set<string>();
133
+ private readonly pendingDescriptions = new Map<string, IPendingDescription>();
134
+ private sockets: ISsdpSocketResource[] = [];
135
+ private lifecycleState: TSsdpLifecycleState = 'stopped';
136
+ private lifecycleTail: Promise<void> = Promise.resolve();
137
+ private generation = 0;
138
+ private startupError: Error | null = null;
139
+ private searchInterval: TSsdpTimer | null = null;
140
+ private serviceTypes: string[] = [...DEFAULT_SERVICE_TYPES];
141
+
142
+ constructor(dependencies: ISsdpDiscoveryDependencies = {}) {
86
143
  super();
144
+ this.dependencies = {
145
+ createSocket: dependencies.createSocket ?? (() => plugins.dgram.createSocket({
146
+ type: 'udp4',
147
+ reuseAddr: true,
148
+ })),
149
+ networkInterfaces: dependencies.networkInterfaces ?? plugins.os.networkInterfaces,
150
+ fetch: dependencies.fetch ?? globalThis.fetch,
151
+ setInterval: dependencies.setInterval ?? globalThis.setInterval,
152
+ clearInterval: dependencies.clearInterval ?? globalThis.clearInterval,
153
+ setTimeout: dependencies.setTimeout ?? globalThis.setTimeout,
154
+ clearTimeout: dependencies.clearTimeout ?? globalThis.clearTimeout,
155
+ };
87
156
  }
88
157
 
89
158
  /**
90
- * Start SSDP discovery
159
+ * Start SSDP discovery.
91
160
  */
92
161
  public async start(serviceTypes?: string[]): Promise<void> {
93
- if (this.running) {
94
- return;
95
- }
96
-
97
- this.running = true;
98
- this.client = new plugins.nodeSsdp.Client();
99
-
100
- // Handle SSDP responses
101
- this.client.on('response', (headers: Record<string, string>, statusCode: number, rinfo: { address: string; port: number }) => {
102
- this.handleSsdpResponse(headers, rinfo);
103
- });
104
-
105
- // Search for devices
106
- const typesToSearch = serviceTypes ?? [
107
- SSDP_SERVICE_TYPES.ROOT,
108
- SSDP_SERVICE_TYPES.MEDIA_RENDERER,
109
- SSDP_SERVICE_TYPES.MEDIA_SERVER,
110
- SSDP_SERVICE_TYPES.SONOS_ZONE_PLAYER,
111
- ];
112
-
113
- // Initial search
114
- for (const st of typesToSearch) {
115
- this.client.search(st);
162
+ const typesToSearch = [...(serviceTypes ?? DEFAULT_SERVICE_TYPES)];
163
+ for (const serviceType of typesToSearch) {
164
+ this.validateServiceType(serviceType);
116
165
  }
117
166
 
118
- // Periodic re-search (every 30 seconds)
119
- this.searchInterval = setInterval(() => {
120
- if (this.client) {
121
- for (const st of typesToSearch) {
122
- this.client.search(st);
123
- }
167
+ return this.queueLifecycle(async () => {
168
+ if (this.lifecycleState === 'running') {
169
+ return;
124
170
  }
125
- }, 30000);
126
-
127
- this.emit('started');
171
+ await this.startInternal(typesToSearch);
172
+ });
128
173
  }
129
174
 
130
175
  /**
131
- * Stop SSDP discovery
176
+ * Stop SSDP discovery.
132
177
  */
133
178
  public async stop(): Promise<void> {
134
- if (!this.running) {
135
- return;
136
- }
137
-
138
- this.running = false;
139
-
140
- if (this.searchInterval) {
141
- clearInterval(this.searchInterval);
142
- this.searchInterval = null;
143
- }
144
-
145
- if (this.client) {
146
- this.client.stop();
147
- this.client = null;
148
- }
179
+ return this.queueLifecycle(async () => {
180
+ if (this.lifecycleState === 'stopped') {
181
+ return;
182
+ }
149
183
 
150
- this.emit('stopped');
184
+ const shouldEmitStopped = this.lifecycleState === 'running';
185
+ await this.cleanupResources();
186
+ if (shouldEmitStopped) {
187
+ this.emit('stopped');
188
+ }
189
+ });
151
190
  }
152
191
 
153
192
  /**
154
- * Check if discovery is running
193
+ * Check if discovery is running.
155
194
  */
156
195
  public get isRunning(): boolean {
157
- return this.running;
196
+ return this.lifecycleState === 'running';
158
197
  }
159
198
 
160
199
  /**
161
- * Get all discovered devices
200
+ * Get all discovered devices.
162
201
  */
163
202
  public getDevices(): ISsdpDevice[] {
164
203
  return Array.from(this.devices.values());
165
204
  }
166
205
 
167
206
  /**
168
- * Get devices by service type
207
+ * Get devices by service type.
169
208
  */
170
209
  public getDevicesByType(serviceType: string): ISsdpDevice[] {
171
- return this.getDevices().filter((d) => d.serviceType === serviceType);
210
+ return this.getDevices().filter((device) => device.serviceType === serviceType);
172
211
  }
173
212
 
174
213
  /**
175
- * Search for a specific service type
214
+ * Search for a specific service type.
176
215
  */
177
216
  public search(serviceType: string): void {
178
- if (this.client && this.running) {
179
- this.client.search(serviceType);
217
+ this.validateServiceType(serviceType);
218
+ if (this.lifecycleState !== 'running') {
219
+ return;
180
220
  }
221
+
222
+ const activeGeneration = this.generation;
223
+ void this.sendSearches([serviceType], activeGeneration).catch((error: unknown) => {
224
+ this.handleRuntimeError(this.toError(error), activeGeneration);
225
+ });
226
+ }
227
+
228
+ private queueLifecycle(operation: () => Promise<void>): Promise<void> {
229
+ const result = this.lifecycleTail.then(operation);
230
+ this.lifecycleTail = result.catch(() => {});
231
+ return result;
232
+ }
233
+
234
+ private async startInternal(typesToSearch: string[]): Promise<void> {
235
+ this.lifecycleState = 'starting';
236
+ this.startupError = null;
237
+ const activeGeneration = ++this.generation;
238
+
239
+ try {
240
+ const addresses = this.getInterfaceAddresses();
241
+ if (addresses.length === 0) {
242
+ throw new Error('No non-internal IPv4 interfaces available for SSDP discovery.');
243
+ }
244
+
245
+ for (const address of addresses) {
246
+ const resource = this.createSocketResource(address, activeGeneration);
247
+ this.sockets.push(resource);
248
+ await this.bindSocket(resource);
249
+ }
250
+
251
+ await this.sendSearches(typesToSearch, activeGeneration);
252
+ if (this.startupError) {
253
+ throw this.startupError;
254
+ }
255
+
256
+ this.serviceTypes = typesToSearch;
257
+ this.lifecycleState = 'running';
258
+ this.searchInterval = this.dependencies.setInterval(() => {
259
+ const intervalGeneration = this.generation;
260
+ void this.sendSearches(this.serviceTypes, intervalGeneration).catch((error: unknown) => {
261
+ this.handleRuntimeError(this.toError(error), intervalGeneration);
262
+ });
263
+ }, SEARCH_INTERVAL_MS);
264
+ this.unrefTimer(this.searchInterval);
265
+ this.emit('started');
266
+ } catch (error) {
267
+ await this.cleanupResources();
268
+ throw error;
269
+ }
270
+ }
271
+
272
+ private getInterfaceAddresses(): string[] {
273
+ const addresses = new Set<string>();
274
+ const interfaces = this.dependencies.networkInterfaces();
275
+
276
+ for (const [, interfaceInfos] of Object.entries(interfaces).sort(([a], [b]) => a.localeCompare(b))) {
277
+ for (const interfaceInfo of interfaceInfos ?? []) {
278
+ if (!interfaceInfo.internal && interfaceInfo.family === 'IPv4') {
279
+ addresses.add(interfaceInfo.address);
280
+ }
281
+ }
282
+ }
283
+
284
+ return Array.from(addresses).sort((a, b) => a.localeCompare(b));
285
+ }
286
+
287
+ private createSocketResource(address: string, activeGeneration: number): ISsdpSocketResource {
288
+ const socket = this.dependencies.createSocket();
289
+ const onMessage = (message: Buffer, rinfo: plugins.dgram.RemoteInfo): void => {
290
+ if (this.lifecycleState === 'running' && this.generation === activeGeneration) {
291
+ this.handleSsdpMessage(message, rinfo, activeGeneration);
292
+ }
293
+ };
294
+ const onError = (error: Error): void => {
295
+ if (this.lifecycleState === 'starting' && this.generation === activeGeneration) {
296
+ this.startupError ??= error;
297
+ return;
298
+ }
299
+ this.handleRuntimeError(error, activeGeneration);
300
+ };
301
+
302
+ socket.on('message', onMessage);
303
+ socket.on('error', onError);
304
+ return { address, socket, onMessage, onError };
305
+ }
306
+
307
+ private async bindSocket(resource: ISsdpSocketResource): Promise<void> {
308
+ const { address, socket } = resource;
309
+
310
+ await new Promise<void>((resolve, reject) => {
311
+ const cleanupBindListeners = (): void => {
312
+ socket.off('listening', onListening);
313
+ socket.off('error', onBindError);
314
+ };
315
+ const onListening = (): void => {
316
+ cleanupBindListeners();
317
+ resolve();
318
+ };
319
+ const onBindError = (error: Error): void => {
320
+ cleanupBindListeners();
321
+ reject(error);
322
+ };
323
+
324
+ socket.once('listening', onListening);
325
+ socket.once('error', onBindError);
326
+ try {
327
+ socket.bind({ port: 0, address, exclusive: true });
328
+ } catch (error) {
329
+ cleanupBindListeners();
330
+ reject(error);
331
+ }
332
+ });
333
+
334
+ if (this.startupError) {
335
+ throw this.startupError;
336
+ }
337
+
338
+ socket.setMulticastInterface(address);
339
+ socket.setMulticastTTL(SSDP_MULTICAST_TTL);
340
+ // M-SEARCH responses are unicast to this bound socket, so membership is not needed.
341
+ socket.unref();
342
+ }
343
+
344
+ private async sendSearches(serviceTypes: string[], activeGeneration: number): Promise<void> {
345
+ if (
346
+ this.generation !== activeGeneration ||
347
+ (this.lifecycleState !== 'starting' && this.lifecycleState !== 'running')
348
+ ) {
349
+ return;
350
+ }
351
+
352
+ const sends: Promise<void>[] = [];
353
+ for (const serviceType of serviceTypes) {
354
+ const packet = this.createSearchPacket(serviceType);
355
+ for (const resource of this.sockets) {
356
+ sends.push(this.sendPacket(resource.socket, packet));
357
+ }
358
+ }
359
+
360
+ const results = await Promise.allSettled(sends);
361
+ const failure = results.find((result): result is PromiseRejectedResult => result.status === 'rejected');
362
+ if (failure) {
363
+ throw failure.reason;
364
+ }
365
+ }
366
+
367
+ private createSearchPacket(serviceType: string): Buffer {
368
+ return Buffer.from([
369
+ 'M-SEARCH * HTTP/1.1',
370
+ `HOST: ${SSDP_ADDRESS}:${SSDP_PORT}`,
371
+ 'MAN: "ssdp:discover"',
372
+ 'MX: 3',
373
+ `ST: ${serviceType}`,
374
+ '',
375
+ '',
376
+ ].join('\r\n'), 'ascii');
377
+ }
378
+
379
+ private async sendPacket(socket: plugins.dgram.Socket, packet: Buffer): Promise<void> {
380
+ await new Promise<void>((resolve, reject) => {
381
+ try {
382
+ socket.send(packet, SSDP_PORT, SSDP_ADDRESS, (error) => {
383
+ if (error) {
384
+ reject(error);
385
+ } else {
386
+ resolve();
387
+ }
388
+ });
389
+ } catch (error) {
390
+ reject(error);
391
+ }
392
+ });
393
+ }
394
+
395
+ private handleRuntimeError(error: Error, activeGeneration: number): void {
396
+ if (this.lifecycleState !== 'running' || this.generation !== activeGeneration) {
397
+ return;
398
+ }
399
+
400
+ void this.stop();
401
+ if (this.listenerCount('error') > 0) {
402
+ this.emit('error', error);
403
+ }
404
+ }
405
+
406
+ private handleSsdpMessage(
407
+ message: Buffer,
408
+ rinfo: plugins.dgram.RemoteInfo,
409
+ activeGeneration: number
410
+ ): void {
411
+ const response = this.parseSsdpResponse(message);
412
+ if (!response || response.statusCode !== 200) {
413
+ return;
414
+ }
415
+ this.handleSsdpResponse(response.headers, rinfo, activeGeneration);
416
+ }
417
+
418
+ private parseSsdpResponse(message: Buffer): IParsedSsdpResponse | null {
419
+ const lines = message.toString('utf8').split(/\r?\n/);
420
+ const statusLine = lines.shift()?.trim() ?? '';
421
+ const statusMatch = statusLine.match(/^HTTP\/\d\.\d\s+(\d{3})(?:\s|$)/i);
422
+ if (!statusMatch) {
423
+ return null;
424
+ }
425
+
426
+ const headers: Record<string, string> = {};
427
+ for (const line of lines) {
428
+ if (line.trim() === '') {
429
+ break;
430
+ }
431
+ const separatorIndex = line.indexOf(':');
432
+ if (separatorIndex <= 0) {
433
+ continue;
434
+ }
435
+ const name = line.slice(0, separatorIndex).trim().toUpperCase();
436
+ if (name) {
437
+ headers[name] = line.slice(separatorIndex + 1).trim();
438
+ }
439
+ }
440
+
441
+ return {
442
+ statusCode: Number.parseInt(statusMatch[1], 10),
443
+ headers,
444
+ };
181
445
  }
182
446
 
183
- /**
184
- * Handle SSDP response
185
- */
186
447
  private handleSsdpResponse(
187
448
  headers: Record<string, string>,
188
- rinfo: { address: string; port: number }
449
+ rinfo: plugins.dgram.RemoteInfo,
450
+ activeGeneration: number
189
451
  ): void {
190
- const usn = headers['USN'] || headers['usn'];
191
- const location = headers['LOCATION'] || headers['location'];
192
- const st = headers['ST'] || headers['st'];
193
-
452
+ const usn = headers.USN;
453
+ const location = headers.LOCATION;
194
454
  if (!usn || !location) {
195
455
  return;
196
456
  }
197
457
 
198
- // Parse location URL
199
- let address = rinfo.address;
200
- let port = 80;
201
-
202
- try {
203
- const url = new URL(location);
204
- address = url.hostname;
205
- port = parseInt(url.port) || (url.protocol === 'https:' ? 443 : 80);
206
- } catch {
207
- // Keep rinfo address
458
+ const locationInfo = this.parseLocation(location, rinfo.address);
459
+ const existing = this.devices.get(usn);
460
+ if (existing) {
461
+ existing.serviceType = headers.ST || 'unknown';
462
+ existing.location = location;
463
+ existing.address = locationInfo.address;
464
+ existing.port = locationInfo.port;
465
+ existing.headers = { ...headers };
466
+
467
+ if (this.foundUsns.has(usn)) {
468
+ this.emit('device:updated', existing);
469
+ }
470
+ return;
208
471
  }
209
472
 
210
473
  const device: ISsdpDevice = {
211
474
  usn,
212
- serviceType: st || 'unknown',
475
+ serviceType: headers.ST || 'unknown',
213
476
  location,
214
- address,
215
- port,
477
+ address: locationInfo.address,
478
+ port: locationInfo.port,
216
479
  headers: { ...headers },
217
480
  };
218
481
 
219
- const isNew = !this.devices.has(usn);
220
482
  this.devices.set(usn, device);
483
+ this.startDescriptionFetch(device, activeGeneration);
484
+ }
221
485
 
222
- if (isNew) {
223
- // Fetch device description
224
- this.fetchDeviceDescription(device).then(() => {
225
- this.emit('device:found', device);
226
- }).catch(() => {
227
- // Still emit even without description
228
- this.emit('device:found', device);
229
- });
230
- } else {
231
- this.emit('device:updated', device);
486
+ private parseLocation(location: string, fallbackAddress: string): { address: string; port: number } {
487
+ try {
488
+ const url = new URL(location);
489
+ return {
490
+ address: url.hostname,
491
+ port: Number.parseInt(url.port, 10) || (url.protocol === 'https:' ? 443 : 80),
492
+ };
493
+ } catch {
494
+ return { address: fallbackAddress, port: 80 };
232
495
  }
233
496
  }
234
497
 
235
- /**
236
- * Fetch and parse device description XML
237
- */
238
- private async fetchDeviceDescription(device: ISsdpDevice): Promise<void> {
239
- try {
240
- const response = await fetch(device.location, {
241
- signal: AbortSignal.timeout(5000),
242
- });
498
+ private startDescriptionFetch(device: ISsdpDevice, activeGeneration: number): void {
499
+ const abortController = new AbortController();
500
+ const timeout = this.dependencies.setTimeout(() => {
501
+ abortController.abort();
502
+ }, DESCRIPTION_TIMEOUT_MS);
503
+ this.unrefTimer(timeout);
504
+
505
+ const pending: IPendingDescription = {
506
+ device,
507
+ abortController,
508
+ timeout,
509
+ promise: Promise.resolve(),
510
+ };
511
+
512
+ pending.promise = (async () => {
513
+ await this.fetchDeviceDescription(device, abortController.signal);
514
+ if (
515
+ this.lifecycleState === 'running' &&
516
+ this.generation === activeGeneration &&
517
+ this.devices.get(device.usn) === device &&
518
+ !this.foundUsns.has(device.usn)
519
+ ) {
520
+ this.foundUsns.add(device.usn);
521
+ this.emit('device:found', device);
522
+ }
523
+ })().finally(() => {
524
+ this.dependencies.clearTimeout(timeout);
525
+ if (this.pendingDescriptions.get(device.usn) === pending) {
526
+ this.pendingDescriptions.delete(device.usn);
527
+ }
528
+ });
243
529
 
530
+ this.pendingDescriptions.set(device.usn, pending);
531
+ }
532
+
533
+ private async fetchDeviceDescription(device: ISsdpDevice, signal: AbortSignal): Promise<void> {
534
+ try {
535
+ const response = await this.dependencies.fetch(device.location, { signal });
244
536
  if (!response.ok) {
537
+ await this.cancelResponseBody(response);
245
538
  return;
246
539
  }
247
540
 
248
541
  const xml = await response.text();
249
- device.description = this.parseDeviceDescription(xml);
542
+ if (!signal.aborted) {
543
+ device.description = this.parseDeviceDescription(xml);
544
+ }
250
545
  } catch {
251
- // Ignore fetch errors
546
+ // Devices without a reachable description are still valid SSDP results.
252
547
  }
253
548
  }
254
549
 
255
- /**
256
- * Parse UPnP device description XML
257
- */
258
550
  private parseDeviceDescription(xml: string): ISsdpDeviceDescription {
259
551
  const getTagContent = (tag: string, source: string = xml): string => {
260
552
  const regex = new RegExp(`<${tag}[^>]*>([^<]*)</${tag}>`, 'i');
@@ -268,7 +560,6 @@ export class SsdpDiscovery extends plugins.events.EventEmitter {
268
560
  return match?.[1] ?? '';
269
561
  };
270
562
 
271
- // Parse services
272
563
  const services: ISsdpService[] = [];
273
564
  const serviceListBlock = getTagBlock('serviceList');
274
565
  const serviceMatches = serviceListBlock.match(/<service>[\s\S]*?<\/service>/gi) || [];
@@ -283,7 +574,6 @@ export class SsdpDiscovery extends plugins.events.EventEmitter {
283
574
  });
284
575
  }
285
576
 
286
- // Parse icons
287
577
  const icons: ISsdpIcon[] = [];
288
578
  const iconListBlock = getTagBlock('iconList');
289
579
  const iconMatches = iconListBlock.match(/<icon>[\s\S]*?<\/icon>/gi) || [];
@@ -291,9 +581,9 @@ export class SsdpDiscovery extends plugins.events.EventEmitter {
291
581
  for (const iconXml of iconMatches) {
292
582
  icons.push({
293
583
  mimetype: getTagContent('mimetype', iconXml),
294
- width: parseInt(getTagContent('width', iconXml)) || 0,
295
- height: parseInt(getTagContent('height', iconXml)) || 0,
296
- depth: parseInt(getTagContent('depth', iconXml)) || 0,
584
+ width: Number.parseInt(getTagContent('width', iconXml), 10) || 0,
585
+ height: Number.parseInt(getTagContent('height', iconXml), 10) || 0,
586
+ depth: Number.parseInt(getTagContent('depth', iconXml), 10) || 0,
297
587
  url: getTagContent('url', iconXml),
298
588
  });
299
589
  }
@@ -314,8 +604,71 @@ export class SsdpDiscovery extends plugins.events.EventEmitter {
314
604
  };
315
605
  }
316
606
 
607
+ private async cleanupResources(): Promise<void> {
608
+ this.lifecycleState = 'stopping';
609
+ ++this.generation;
610
+
611
+ if (this.searchInterval) {
612
+ this.dependencies.clearInterval(this.searchInterval);
613
+ this.searchInterval = null;
614
+ }
615
+
616
+ const pendingDescriptions = Array.from(this.pendingDescriptions.values());
617
+ for (const pending of pendingDescriptions) {
618
+ this.dependencies.clearTimeout(pending.timeout);
619
+ pending.abortController.abort();
620
+ }
621
+ await Promise.allSettled(pendingDescriptions.map((pending) => pending.promise));
622
+ for (const pending of pendingDescriptions) {
623
+ if (!this.foundUsns.has(pending.device.usn) && this.devices.get(pending.device.usn) === pending.device) {
624
+ this.devices.delete(pending.device.usn);
625
+ }
626
+ }
627
+ this.pendingDescriptions.clear();
628
+
629
+ const sockets = this.sockets;
630
+ this.sockets = [];
631
+ await Promise.all(sockets.map(async (resource) => {
632
+ resource.socket.off('message', resource.onMessage);
633
+ resource.socket.off('error', resource.onError);
634
+ await new Promise<void>((resolve) => {
635
+ try {
636
+ resource.socket.close(resolve);
637
+ } catch {
638
+ resolve();
639
+ }
640
+ });
641
+ }));
642
+
643
+ this.startupError = null;
644
+ this.lifecycleState = 'stopped';
645
+ }
646
+
647
+ private validateServiceType(serviceType: string): void {
648
+ if (!serviceType || /[\r\n]/.test(serviceType)) {
649
+ throw new Error('SSDP service type must be non-empty and cannot contain line breaks.');
650
+ }
651
+ }
652
+
653
+ private unrefTimer(timer: TSsdpTimer): void {
654
+ const unref = (timer as TSsdpTimer & { unref?: () => void }).unref;
655
+ unref?.call(timer);
656
+ }
657
+
658
+ private toError(error: unknown): Error {
659
+ return error instanceof Error ? error : new Error(String(error));
660
+ }
661
+
662
+ private async cancelResponseBody(response: Response): Promise<void> {
663
+ try {
664
+ await response.body?.cancel();
665
+ } catch {
666
+ // The fetch timeout/abort remains the final cleanup path for a failed body.
667
+ }
668
+ }
669
+
317
670
  /**
318
- * Make a UPnP SOAP request
671
+ * Make a UPnP SOAP request.
319
672
  */
320
673
  public async soapRequest(
321
674
  controlUrl: string,
@@ -323,7 +676,6 @@ export class SsdpDiscovery extends plugins.events.EventEmitter {
323
676
  action: string,
324
677
  args: Record<string, string | number> = {}
325
678
  ): Promise<string> {
326
- // Build SOAP body
327
679
  let argsXml = '';
328
680
  for (const [key, value] of Object.entries(args)) {
329
681
  argsXml += `<${key}>${value}</${key}>`;
@@ -338,20 +690,31 @@ export class SsdpDiscovery extends plugins.events.EventEmitter {
338
690
  </s:Body>
339
691
  </s:Envelope>`;
340
692
 
341
- const response = await fetch(controlUrl, {
342
- method: 'POST',
343
- headers: {
344
- 'Content-Type': 'text/xml; charset=utf-8',
345
- 'SOAPACTION': `"${serviceType}#${action}"`,
346
- },
347
- body: soapBody,
348
- signal: AbortSignal.timeout(10000),
349
- });
693
+ const abortController = new AbortController();
694
+ const timeout = this.dependencies.setTimeout(() => {
695
+ abortController.abort();
696
+ }, SOAP_TIMEOUT_MS);
697
+ this.unrefTimer(timeout);
350
698
 
351
- if (!response.ok) {
352
- throw new Error(`SOAP request failed: ${response.status}`);
353
- }
699
+ try {
700
+ const response = await this.dependencies.fetch(controlUrl, {
701
+ method: 'POST',
702
+ headers: {
703
+ 'Content-Type': 'text/xml; charset=utf-8',
704
+ 'SOAPACTION': `"${serviceType}#${action}"`,
705
+ },
706
+ body: soapBody,
707
+ signal: abortController.signal,
708
+ });
354
709
 
355
- return response.text();
710
+ if (!response.ok) {
711
+ await this.cancelResponseBody(response);
712
+ throw new Error(`SOAP request failed: ${response.status}`);
713
+ }
714
+
715
+ return await response.text();
716
+ } finally {
717
+ this.dependencies.clearTimeout(timeout);
718
+ }
356
719
  }
357
720
  }