@ecobridge.xyz/devicemanager 3.1.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gitea/workflows/default_tags.yaml +0 -21
- package/.smartconfig.json +69 -0
- package/changelog.md +38 -1
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/device/device.classes.device.d.ts +2 -2
- package/dist_ts/device/device.classes.device.js +3 -3
- package/dist_ts/devicemanager.classes.devicemanager.d.ts +17 -4
- package/dist_ts/devicemanager.classes.devicemanager.js +99 -11
- package/dist_ts/discovery/discovery.classes.mdns.js +46 -11
- package/dist_ts/discovery/discovery.classes.networkscanner.d.ts +5 -1
- package/dist_ts/discovery/discovery.classes.networkscanner.js +44 -3
- package/dist_ts/discovery/discovery.classes.ssdp.d.ts +52 -21
- package/dist_ts/discovery/discovery.classes.ssdp.js +407 -117
- package/dist_ts/events/events.observable.d.ts +15 -0
- package/dist_ts/events/events.observable.js +22 -0
- package/dist_ts/events/index.d.ts +1 -0
- package/dist_ts/events/index.js +2 -0
- package/dist_ts/factories/index.js +7 -5
- package/dist_ts/features/feature.abstract.d.ts +2 -2
- package/dist_ts/features/feature.abstract.js +3 -3
- package/dist_ts/features/feature.print.d.ts +3 -0
- package/dist_ts/features/feature.print.js +17 -2
- package/dist_ts/features/feature.scan.d.ts +5 -2
- package/dist_ts/features/feature.scan.js +98 -27
- package/dist_ts/index.d.ts +4 -2
- package/dist_ts/index.js +4 -2
- package/dist_ts/interfaces/feature.interfaces.d.ts +22 -2
- package/dist_ts/interfaces/index.d.ts +22 -4
- package/dist_ts/interfaces/index.js +1 -1
- package/dist_ts/plugins.d.ts +6 -15
- package/dist_ts/plugins.js +7 -19
- package/dist_ts/protocols/index.d.ts +2 -1
- package/dist_ts/protocols/index.js +4 -2
- package/dist_ts/protocols/protocol.brother.d.ts +67 -0
- package/dist_ts/protocols/protocol.brother.js +560 -0
- package/dist_ts/protocols/protocol.escl.d.ts +13 -4
- package/dist_ts/protocols/protocol.escl.js +345 -178
- package/dist_ts/protocols/protocol.ipp.d.ts +2 -0
- package/dist_ts/protocols/protocol.ipp.js +22 -2
- package/dist_ts/providers/index.d.ts +1 -0
- package/dist_ts/providers/index.js +2 -0
- package/dist_ts/providers/provider.smb.d.ts +60 -0
- package/dist_ts/providers/provider.smb.js +224 -0
- package/license.md +21 -0
- package/package.json +15 -20
- package/pnpm-workspace.yaml +6 -0
- package/readme.md +157 -78
- package/test/test.brother.node.ts +306 -0
- package/test/test.escl.node.ts +233 -0
- package/test/test.ipp.node.ts +75 -0
- package/test/{test.ts → test.node.ts} +37 -45
- package/test/test.smb.node.ts +123 -0
- package/test/test.ssdp.node.ts +978 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/device/device.classes.device.ts +2 -2
- package/ts/devicemanager.classes.devicemanager.ts +125 -12
- package/ts/discovery/discovery.classes.mdns.ts +40 -11
- package/ts/discovery/discovery.classes.networkscanner.ts +51 -2
- package/ts/discovery/discovery.classes.ssdp.ts +493 -130
- package/ts/events/events.observable.ts +31 -0
- package/ts/events/index.ts +4 -0
- package/ts/factories/index.ts +7 -5
- package/ts/features/feature.abstract.ts +2 -2
- package/ts/features/feature.print.ts +16 -1
- package/ts/features/feature.scan.ts +93 -27
- package/ts/index.ts +21 -0
- package/ts/interfaces/feature.interfaces.ts +23 -2
- package/ts/interfaces/index.ts +23 -4
- package/ts/plugins.ts +6 -25
- package/ts/protocols/index.ts +11 -1
- package/ts/protocols/protocol.brother.ts +648 -0
- package/ts/protocols/protocol.escl.ts +348 -191
- package/ts/protocols/protocol.ipp.ts +24 -1
- package/ts/providers/index.ts +7 -0
- package/ts/providers/provider.smb.ts +326 -0
- package/dist_ts/protocols/protocol.ipp.old.d.ts +0 -45
- package/dist_ts/protocols/protocol.ipp.old.js +0 -284
- 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
|
-
*
|
|
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
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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((
|
|
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
|
-
|
|
179
|
-
|
|
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:
|
|
449
|
+
rinfo: plugins.dgram.RemoteInfo,
|
|
450
|
+
activeGeneration: number
|
|
189
451
|
): void {
|
|
190
|
-
const usn = headers
|
|
191
|
-
const location = headers
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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:
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
542
|
+
if (!signal.aborted) {
|
|
543
|
+
device.description = this.parseDeviceDescription(xml);
|
|
544
|
+
}
|
|
250
545
|
} catch {
|
|
251
|
-
//
|
|
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
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
352
|
-
|
|
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
|
-
|
|
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
|
}
|