@ecobridge.xyz/devicemanager 3.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gitea/workflows/default_nottags.yaml +66 -0
- package/.gitea/workflows/default_tags.yaml +124 -0
- package/.vscode/launch.json +11 -0
- package/.vscode/settings.json +26 -0
- package/changelog.md +91 -0
- package/dist_ts/00_commitinfo_data.d.ts +8 -0
- package/dist_ts/00_commitinfo_data.js +9 -0
- package/dist_ts/device/device.classes.device.d.ts +205 -0
- package/dist_ts/device/device.classes.device.js +344 -0
- package/dist_ts/devicemanager.classes.devicemanager.d.ts +210 -0
- package/dist_ts/devicemanager.classes.devicemanager.js +835 -0
- package/dist_ts/discovery/discovery.classes.homeassistant.d.ts +101 -0
- package/dist_ts/discovery/discovery.classes.homeassistant.js +313 -0
- package/dist_ts/discovery/discovery.classes.mdns.d.ts +90 -0
- package/dist_ts/discovery/discovery.classes.mdns.js +291 -0
- package/dist_ts/discovery/discovery.classes.networkscanner.d.ts +64 -0
- package/dist_ts/discovery/discovery.classes.networkscanner.js +535 -0
- package/dist_ts/discovery/discovery.classes.ssdp.d.ts +112 -0
- package/dist_ts/discovery/discovery.classes.ssdp.js +255 -0
- package/dist_ts/factories/index.d.ts +190 -0
- package/dist_ts/factories/index.js +350 -0
- package/dist_ts/features/feature.abstract.d.ts +118 -0
- package/dist_ts/features/feature.abstract.js +172 -0
- package/dist_ts/features/feature.camera.d.ts +95 -0
- package/dist_ts/features/feature.camera.js +162 -0
- package/dist_ts/features/feature.climate.d.ts +174 -0
- package/dist_ts/features/feature.climate.js +328 -0
- package/dist_ts/features/feature.cover.d.ts +121 -0
- package/dist_ts/features/feature.cover.js +217 -0
- package/dist_ts/features/feature.fan.d.ts +127 -0
- package/dist_ts/features/feature.fan.js +235 -0
- package/dist_ts/features/feature.light.d.ts +156 -0
- package/dist_ts/features/feature.light.js +296 -0
- package/dist_ts/features/feature.lock.d.ts +103 -0
- package/dist_ts/features/feature.lock.js +170 -0
- package/dist_ts/features/feature.playback.d.ts +98 -0
- package/dist_ts/features/feature.playback.js +191 -0
- package/dist_ts/features/feature.power.d.ts +79 -0
- package/dist_ts/features/feature.power.js +173 -0
- package/dist_ts/features/feature.print.d.ts +78 -0
- package/dist_ts/features/feature.print.js +226 -0
- package/dist_ts/features/feature.scan.d.ts +79 -0
- package/dist_ts/features/feature.scan.js +315 -0
- package/dist_ts/features/feature.sensor.d.ts +95 -0
- package/dist_ts/features/feature.sensor.js +147 -0
- package/dist_ts/features/feature.snmp.d.ts +88 -0
- package/dist_ts/features/feature.snmp.js +175 -0
- package/dist_ts/features/feature.switch.d.ts +75 -0
- package/dist_ts/features/feature.switch.js +126 -0
- package/dist_ts/features/feature.volume.d.ts +104 -0
- package/dist_ts/features/feature.volume.js +186 -0
- package/dist_ts/features/index.d.ts +19 -0
- package/dist_ts/features/index.js +23 -0
- package/dist_ts/helpers/helpers.iprange.d.ts +35 -0
- package/dist_ts/helpers/helpers.iprange.js +151 -0
- package/dist_ts/helpers/helpers.retry.d.ts +25 -0
- package/dist_ts/helpers/helpers.retry.js +69 -0
- package/dist_ts/index.d.ts +15 -0
- package/dist_ts/index.js +40 -0
- package/dist_ts/interfaces/feature.interfaces.d.ts +255 -0
- package/dist_ts/interfaces/feature.interfaces.js +6 -0
- package/dist_ts/interfaces/homeassistant.interfaces.d.ts +550 -0
- package/dist_ts/interfaces/homeassistant.interfaces.js +111 -0
- package/dist_ts/interfaces/index.d.ts +330 -0
- package/dist_ts/interfaces/index.js +16 -0
- package/dist_ts/interfaces/smarthome.interfaces.d.ts +282 -0
- package/dist_ts/interfaces/smarthome.interfaces.js +7 -0
- package/dist_ts/paths.d.ts +1 -0
- package/dist_ts/paths.js +3 -0
- package/dist_ts/plugins.d.ts +23 -0
- package/dist_ts/plugins.js +30 -0
- package/dist_ts/protocols/index.d.ts +12 -0
- package/dist_ts/protocols/index.js +21 -0
- package/dist_ts/protocols/protocol.escl.d.ts +58 -0
- package/dist_ts/protocols/protocol.escl.js +364 -0
- package/dist_ts/protocols/protocol.homeassistant.d.ts +248 -0
- package/dist_ts/protocols/protocol.homeassistant.js +611 -0
- package/dist_ts/protocols/protocol.ipp.d.ts +45 -0
- package/dist_ts/protocols/protocol.ipp.js +284 -0
- package/dist_ts/protocols/protocol.nut.d.ts +160 -0
- package/dist_ts/protocols/protocol.nut.js +364 -0
- package/dist_ts/protocols/protocol.sane.d.ts +104 -0
- package/dist_ts/protocols/protocol.sane.js +597 -0
- package/dist_ts/protocols/protocol.snmp.d.ts +133 -0
- package/dist_ts/protocols/protocol.snmp.js +333 -0
- package/dist_ts/protocols/protocol.upnp.d.ts +207 -0
- package/dist_ts/protocols/protocol.upnp.js +442 -0
- package/dist_ts/protocols/protocol.upssnmp.d.ts +160 -0
- package/dist_ts/protocols/protocol.upssnmp.js +263 -0
- package/npmextra.json +24 -0
- package/package.json +39 -0
- package/readme.hints.md +115 -0
- package/readme.md +624 -0
- package/test/test.ts +261 -0
- package/ts/00_commitinfo_data.ts +8 -0
- package/ts/device/device.classes.device.ts +454 -0
- package/ts/devicemanager.classes.devicemanager.ts +1219 -0
- package/ts/discovery/discovery.classes.homeassistant.ts +376 -0
- package/ts/discovery/discovery.classes.mdns.ts +347 -0
- package/ts/discovery/discovery.classes.networkscanner.ts +648 -0
- package/ts/discovery/discovery.classes.ssdp.ts +357 -0
- package/ts/factories/index.ts +696 -0
- package/ts/features/feature.abstract.ts +251 -0
- package/ts/features/feature.camera.ts +214 -0
- package/ts/features/feature.climate.ts +407 -0
- package/ts/features/feature.cover.ts +278 -0
- package/ts/features/feature.fan.ts +296 -0
- package/ts/features/feature.light.ts +369 -0
- package/ts/features/feature.lock.ts +223 -0
- package/ts/features/feature.playback.ts +246 -0
- package/ts/features/feature.power.ts +231 -0
- package/ts/features/feature.print.ts +297 -0
- package/ts/features/feature.scan.ts +370 -0
- package/ts/features/feature.sensor.ts +202 -0
- package/ts/features/feature.snmp.ts +235 -0
- package/ts/features/feature.switch.ts +170 -0
- package/ts/features/feature.volume.ts +256 -0
- package/ts/features/index.ts +25 -0
- package/ts/helpers/helpers.iprange.ts +172 -0
- package/ts/helpers/helpers.retry.ts +100 -0
- package/ts/index.ts +165 -0
- package/ts/interfaces/feature.interfaces.ts +359 -0
- package/ts/interfaces/homeassistant.interfaces.ts +666 -0
- package/ts/interfaces/index.ts +421 -0
- package/ts/interfaces/smarthome.interfaces.ts +421 -0
- package/ts/paths.ts +5 -0
- package/ts/plugins.ts +41 -0
- package/ts/protocols/index.ts +59 -0
- package/ts/protocols/protocol.escl.ts +439 -0
- package/ts/protocols/protocol.homeassistant.ts +737 -0
- package/ts/protocols/protocol.ipp.ts +329 -0
- package/ts/protocols/protocol.nut.ts +471 -0
- package/ts/protocols/protocol.sane.ts +694 -0
- package/ts/protocols/protocol.snmp.ts +439 -0
- package/ts/protocols/protocol.upnp.ts +627 -0
- package/ts/protocols/protocol.upssnmp.ts +377 -0
- package/tsconfig.json +12 -0
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
import * as plugins from '../plugins.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* UPnP service types for DLNA
|
|
5
|
+
*/
|
|
6
|
+
export const UPNP_SERVICE_TYPES = {
|
|
7
|
+
AVTransport: 'urn:schemas-upnp-org:service:AVTransport:1',
|
|
8
|
+
RenderingControl: 'urn:schemas-upnp-org:service:RenderingControl:1',
|
|
9
|
+
ConnectionManager: 'urn:schemas-upnp-org:service:ConnectionManager:1',
|
|
10
|
+
ContentDirectory: 'urn:schemas-upnp-org:service:ContentDirectory:1',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* UPnP device types for DLNA
|
|
15
|
+
*/
|
|
16
|
+
export const UPNP_DEVICE_TYPES = {
|
|
17
|
+
MediaRenderer: 'urn:schemas-upnp-org:device:MediaRenderer:1',
|
|
18
|
+
MediaServer: 'urn:schemas-upnp-org:device:MediaServer:1',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* DLNA transport state
|
|
23
|
+
*/
|
|
24
|
+
export type TDlnaTransportState =
|
|
25
|
+
| 'STOPPED'
|
|
26
|
+
| 'PLAYING'
|
|
27
|
+
| 'PAUSED_PLAYBACK'
|
|
28
|
+
| 'TRANSITIONING'
|
|
29
|
+
| 'NO_MEDIA_PRESENT';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* DLNA transport status
|
|
33
|
+
*/
|
|
34
|
+
export type TDlnaTransportStatus = 'OK' | 'ERROR_OCCURRED';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Position info from AVTransport
|
|
38
|
+
*/
|
|
39
|
+
export interface IDlnaPositionInfo {
|
|
40
|
+
track: number;
|
|
41
|
+
trackDuration: string;
|
|
42
|
+
trackMetadata: string;
|
|
43
|
+
trackUri: string;
|
|
44
|
+
relativeTime: string;
|
|
45
|
+
absoluteTime: string;
|
|
46
|
+
relativeCount: number;
|
|
47
|
+
absoluteCount: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Transport info from AVTransport
|
|
52
|
+
*/
|
|
53
|
+
export interface IDlnaTransportInfo {
|
|
54
|
+
state: TDlnaTransportState;
|
|
55
|
+
status: TDlnaTransportStatus;
|
|
56
|
+
speed: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Media info from AVTransport
|
|
61
|
+
*/
|
|
62
|
+
export interface IDlnaMediaInfo {
|
|
63
|
+
nrTracks: number;
|
|
64
|
+
mediaDuration: string;
|
|
65
|
+
currentUri: string;
|
|
66
|
+
currentUriMetadata: string;
|
|
67
|
+
nextUri: string;
|
|
68
|
+
nextUriMetadata: string;
|
|
69
|
+
playMedium: string;
|
|
70
|
+
recordMedium: string;
|
|
71
|
+
writeStatus: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Content item from ContentDirectory
|
|
76
|
+
*/
|
|
77
|
+
export interface IDlnaContentItem {
|
|
78
|
+
id: string;
|
|
79
|
+
parentId: string;
|
|
80
|
+
title: string;
|
|
81
|
+
class: string;
|
|
82
|
+
restricted: boolean;
|
|
83
|
+
res?: {
|
|
84
|
+
url: string;
|
|
85
|
+
protocolInfo: string;
|
|
86
|
+
size?: number;
|
|
87
|
+
duration?: string;
|
|
88
|
+
resolution?: string;
|
|
89
|
+
bitrate?: number;
|
|
90
|
+
}[];
|
|
91
|
+
albumArtUri?: string;
|
|
92
|
+
artist?: string;
|
|
93
|
+
album?: string;
|
|
94
|
+
genre?: string;
|
|
95
|
+
date?: string;
|
|
96
|
+
childCount?: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Browse result from ContentDirectory
|
|
101
|
+
*/
|
|
102
|
+
export interface IDlnaBrowseResult {
|
|
103
|
+
items: IDlnaContentItem[];
|
|
104
|
+
numberReturned: number;
|
|
105
|
+
totalMatches: number;
|
|
106
|
+
updateId: number;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* UPnP SOAP client for DLNA operations
|
|
111
|
+
*/
|
|
112
|
+
export class UpnpSoapClient {
|
|
113
|
+
private baseUrl: string;
|
|
114
|
+
|
|
115
|
+
constructor(baseUrl: string) {
|
|
116
|
+
this.baseUrl = baseUrl.replace(/\/$/, '');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Make a SOAP request to a UPnP service
|
|
121
|
+
*/
|
|
122
|
+
public async soapAction(
|
|
123
|
+
controlUrl: string,
|
|
124
|
+
serviceType: string,
|
|
125
|
+
action: string,
|
|
126
|
+
args: Record<string, string | number> = {}
|
|
127
|
+
): Promise<string> {
|
|
128
|
+
// Build SOAP body
|
|
129
|
+
let argsXml = '';
|
|
130
|
+
for (const [key, value] of Object.entries(args)) {
|
|
131
|
+
const escapedValue = this.escapeXml(String(value));
|
|
132
|
+
argsXml += `<${key}>${escapedValue}</${key}>`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const soapBody = `<?xml version="1.0" encoding="utf-8"?>
|
|
136
|
+
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
|
137
|
+
<s:Body>
|
|
138
|
+
<u:${action} xmlns:u="${serviceType}">
|
|
139
|
+
${argsXml}
|
|
140
|
+
</u:${action}>
|
|
141
|
+
</s:Body>
|
|
142
|
+
</s:Envelope>`;
|
|
143
|
+
|
|
144
|
+
const fullUrl = controlUrl.startsWith('http') ? controlUrl : `${this.baseUrl}${controlUrl}`;
|
|
145
|
+
|
|
146
|
+
const response = await fetch(fullUrl, {
|
|
147
|
+
method: 'POST',
|
|
148
|
+
headers: {
|
|
149
|
+
'Content-Type': 'text/xml; charset=utf-8',
|
|
150
|
+
'SOAPACTION': `"${serviceType}#${action}"`,
|
|
151
|
+
},
|
|
152
|
+
body: soapBody,
|
|
153
|
+
signal: AbortSignal.timeout(10000),
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
if (!response.ok) {
|
|
157
|
+
const text = await response.text();
|
|
158
|
+
throw new Error(`SOAP request failed (${response.status}): ${text}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return response.text();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Escape XML special characters
|
|
166
|
+
*/
|
|
167
|
+
private escapeXml(str: string): string {
|
|
168
|
+
return str
|
|
169
|
+
.replace(/&/g, '&')
|
|
170
|
+
.replace(/</g, '<')
|
|
171
|
+
.replace(/>/g, '>')
|
|
172
|
+
.replace(/"/g, '"')
|
|
173
|
+
.replace(/'/g, ''');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Unescape XML special characters
|
|
178
|
+
*/
|
|
179
|
+
public unescapeXml(str: string): string {
|
|
180
|
+
return str
|
|
181
|
+
.replace(/</g, '<')
|
|
182
|
+
.replace(/>/g, '>')
|
|
183
|
+
.replace(/"/g, '"')
|
|
184
|
+
.replace(/'/g, "'")
|
|
185
|
+
.replace(/&/g, '&');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Extract value from SOAP response
|
|
190
|
+
*/
|
|
191
|
+
public extractValue(xml: string, tag: string): string {
|
|
192
|
+
const regex = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, 'i');
|
|
193
|
+
const match = xml.match(regex);
|
|
194
|
+
return match ? match[1].trim() : '';
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Extract multiple values from SOAP response
|
|
199
|
+
*/
|
|
200
|
+
public extractValues(xml: string, tags: string[]): Record<string, string> {
|
|
201
|
+
const result: Record<string, string> = {};
|
|
202
|
+
for (const tag of tags) {
|
|
203
|
+
result[tag] = this.extractValue(xml, tag);
|
|
204
|
+
}
|
|
205
|
+
return result;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ============================================================================
|
|
209
|
+
// AVTransport Actions
|
|
210
|
+
// ============================================================================
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Set the URI to play
|
|
214
|
+
*/
|
|
215
|
+
public async setAVTransportURI(
|
|
216
|
+
controlUrl: string,
|
|
217
|
+
uri: string,
|
|
218
|
+
metadata: string = ''
|
|
219
|
+
): Promise<void> {
|
|
220
|
+
await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'SetAVTransportURI', {
|
|
221
|
+
InstanceID: 0,
|
|
222
|
+
CurrentURI: uri,
|
|
223
|
+
CurrentURIMetaData: metadata,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Set next URI to play
|
|
229
|
+
*/
|
|
230
|
+
public async setNextAVTransportURI(
|
|
231
|
+
controlUrl: string,
|
|
232
|
+
uri: string,
|
|
233
|
+
metadata: string = ''
|
|
234
|
+
): Promise<void> {
|
|
235
|
+
await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'SetNextAVTransportURI', {
|
|
236
|
+
InstanceID: 0,
|
|
237
|
+
NextURI: uri,
|
|
238
|
+
NextURIMetaData: metadata,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Play
|
|
244
|
+
*/
|
|
245
|
+
public async play(controlUrl: string, speed: string = '1'): Promise<void> {
|
|
246
|
+
await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'Play', {
|
|
247
|
+
InstanceID: 0,
|
|
248
|
+
Speed: speed,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Pause
|
|
254
|
+
*/
|
|
255
|
+
public async pause(controlUrl: string): Promise<void> {
|
|
256
|
+
await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'Pause', {
|
|
257
|
+
InstanceID: 0,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Stop
|
|
263
|
+
*/
|
|
264
|
+
public async stop(controlUrl: string): Promise<void> {
|
|
265
|
+
await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'Stop', {
|
|
266
|
+
InstanceID: 0,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Seek
|
|
272
|
+
*/
|
|
273
|
+
public async seek(controlUrl: string, target: string, unit: string = 'REL_TIME'): Promise<void> {
|
|
274
|
+
await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'Seek', {
|
|
275
|
+
InstanceID: 0,
|
|
276
|
+
Unit: unit,
|
|
277
|
+
Target: target,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Next track
|
|
283
|
+
*/
|
|
284
|
+
public async next(controlUrl: string): Promise<void> {
|
|
285
|
+
await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'Next', {
|
|
286
|
+
InstanceID: 0,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Previous track
|
|
292
|
+
*/
|
|
293
|
+
public async previous(controlUrl: string): Promise<void> {
|
|
294
|
+
await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'Previous', {
|
|
295
|
+
InstanceID: 0,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Get position info
|
|
301
|
+
*/
|
|
302
|
+
public async getPositionInfo(controlUrl: string): Promise<IDlnaPositionInfo> {
|
|
303
|
+
const response = await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'GetPositionInfo', {
|
|
304
|
+
InstanceID: 0,
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
const values = this.extractValues(response, [
|
|
308
|
+
'Track', 'TrackDuration', 'TrackMetaData', 'TrackURI',
|
|
309
|
+
'RelTime', 'AbsTime', 'RelCount', 'AbsCount',
|
|
310
|
+
]);
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
track: parseInt(values['Track']) || 0,
|
|
314
|
+
trackDuration: values['TrackDuration'] || '0:00:00',
|
|
315
|
+
trackMetadata: this.unescapeXml(values['TrackMetaData'] || ''),
|
|
316
|
+
trackUri: values['TrackURI'] || '',
|
|
317
|
+
relativeTime: values['RelTime'] || '0:00:00',
|
|
318
|
+
absoluteTime: values['AbsTime'] || 'NOT_IMPLEMENTED',
|
|
319
|
+
relativeCount: parseInt(values['RelCount']) || 0,
|
|
320
|
+
absoluteCount: parseInt(values['AbsCount']) || 0,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Get transport info
|
|
326
|
+
*/
|
|
327
|
+
public async getTransportInfo(controlUrl: string): Promise<IDlnaTransportInfo> {
|
|
328
|
+
const response = await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'GetTransportInfo', {
|
|
329
|
+
InstanceID: 0,
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
const values = this.extractValues(response, [
|
|
333
|
+
'CurrentTransportState', 'CurrentTransportStatus', 'CurrentSpeed',
|
|
334
|
+
]);
|
|
335
|
+
|
|
336
|
+
return {
|
|
337
|
+
state: (values['CurrentTransportState'] || 'STOPPED') as TDlnaTransportState,
|
|
338
|
+
status: (values['CurrentTransportStatus'] || 'OK') as TDlnaTransportStatus,
|
|
339
|
+
speed: values['CurrentSpeed'] || '1',
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Get media info
|
|
345
|
+
*/
|
|
346
|
+
public async getMediaInfo(controlUrl: string): Promise<IDlnaMediaInfo> {
|
|
347
|
+
const response = await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'GetMediaInfo', {
|
|
348
|
+
InstanceID: 0,
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
const values = this.extractValues(response, [
|
|
352
|
+
'NrTracks', 'MediaDuration', 'CurrentURI', 'CurrentURIMetaData',
|
|
353
|
+
'NextURI', 'NextURIMetaData', 'PlayMedium', 'RecordMedium', 'WriteStatus',
|
|
354
|
+
]);
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
nrTracks: parseInt(values['NrTracks']) || 0,
|
|
358
|
+
mediaDuration: values['MediaDuration'] || '0:00:00',
|
|
359
|
+
currentUri: values['CurrentURI'] || '',
|
|
360
|
+
currentUriMetadata: this.unescapeXml(values['CurrentURIMetaData'] || ''),
|
|
361
|
+
nextUri: values['NextURI'] || '',
|
|
362
|
+
nextUriMetadata: this.unescapeXml(values['NextURIMetaData'] || ''),
|
|
363
|
+
playMedium: values['PlayMedium'] || 'NONE',
|
|
364
|
+
recordMedium: values['RecordMedium'] || 'NOT_IMPLEMENTED',
|
|
365
|
+
writeStatus: values['WriteStatus'] || 'NOT_IMPLEMENTED',
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ============================================================================
|
|
370
|
+
// RenderingControl Actions
|
|
371
|
+
// ============================================================================
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Get volume
|
|
375
|
+
*/
|
|
376
|
+
public async getVolume(controlUrl: string, channel: string = 'Master'): Promise<number> {
|
|
377
|
+
const response = await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.RenderingControl, 'GetVolume', {
|
|
378
|
+
InstanceID: 0,
|
|
379
|
+
Channel: channel,
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
const volume = this.extractValue(response, 'CurrentVolume');
|
|
383
|
+
return parseInt(volume) || 0;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Set volume
|
|
388
|
+
*/
|
|
389
|
+
public async setVolume(controlUrl: string, volume: number, channel: string = 'Master'): Promise<void> {
|
|
390
|
+
await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.RenderingControl, 'SetVolume', {
|
|
391
|
+
InstanceID: 0,
|
|
392
|
+
Channel: channel,
|
|
393
|
+
DesiredVolume: Math.max(0, Math.min(100, volume)),
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Get mute state
|
|
399
|
+
*/
|
|
400
|
+
public async getMute(controlUrl: string, channel: string = 'Master'): Promise<boolean> {
|
|
401
|
+
const response = await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.RenderingControl, 'GetMute', {
|
|
402
|
+
InstanceID: 0,
|
|
403
|
+
Channel: channel,
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
const mute = this.extractValue(response, 'CurrentMute');
|
|
407
|
+
return mute === '1' || mute.toLowerCase() === 'true';
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Set mute state
|
|
412
|
+
*/
|
|
413
|
+
public async setMute(controlUrl: string, muted: boolean, channel: string = 'Master'): Promise<void> {
|
|
414
|
+
await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.RenderingControl, 'SetMute', {
|
|
415
|
+
InstanceID: 0,
|
|
416
|
+
Channel: channel,
|
|
417
|
+
DesiredMute: muted ? 1 : 0,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ============================================================================
|
|
422
|
+
// ContentDirectory Actions
|
|
423
|
+
// ============================================================================
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Browse content directory
|
|
427
|
+
*/
|
|
428
|
+
public async browse(
|
|
429
|
+
controlUrl: string,
|
|
430
|
+
objectId: string = '0',
|
|
431
|
+
browseFlag: 'BrowseDirectChildren' | 'BrowseMetadata' = 'BrowseDirectChildren',
|
|
432
|
+
filter: string = '*',
|
|
433
|
+
startIndex: number = 0,
|
|
434
|
+
requestCount: number = 100,
|
|
435
|
+
sortCriteria: string = ''
|
|
436
|
+
): Promise<IDlnaBrowseResult> {
|
|
437
|
+
const response = await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.ContentDirectory, 'Browse', {
|
|
438
|
+
ObjectID: objectId,
|
|
439
|
+
BrowseFlag: browseFlag,
|
|
440
|
+
Filter: filter,
|
|
441
|
+
StartingIndex: startIndex,
|
|
442
|
+
RequestedCount: requestCount,
|
|
443
|
+
SortCriteria: sortCriteria,
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
const values = this.extractValues(response, ['Result', 'NumberReturned', 'TotalMatches', 'UpdateID']);
|
|
447
|
+
|
|
448
|
+
const resultXml = this.unescapeXml(values['Result'] || '');
|
|
449
|
+
const items = this.parseDidlResult(resultXml);
|
|
450
|
+
|
|
451
|
+
return {
|
|
452
|
+
items,
|
|
453
|
+
numberReturned: parseInt(values['NumberReturned']) || items.length,
|
|
454
|
+
totalMatches: parseInt(values['TotalMatches']) || items.length,
|
|
455
|
+
updateId: parseInt(values['UpdateID']) || 0,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Search content directory
|
|
461
|
+
*/
|
|
462
|
+
public async search(
|
|
463
|
+
controlUrl: string,
|
|
464
|
+
containerId: string,
|
|
465
|
+
searchCriteria: string,
|
|
466
|
+
filter: string = '*',
|
|
467
|
+
startIndex: number = 0,
|
|
468
|
+
requestCount: number = 100,
|
|
469
|
+
sortCriteria: string = ''
|
|
470
|
+
): Promise<IDlnaBrowseResult> {
|
|
471
|
+
const response = await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.ContentDirectory, 'Search', {
|
|
472
|
+
ContainerID: containerId,
|
|
473
|
+
SearchCriteria: searchCriteria,
|
|
474
|
+
Filter: filter,
|
|
475
|
+
StartingIndex: startIndex,
|
|
476
|
+
RequestedCount: requestCount,
|
|
477
|
+
SortCriteria: sortCriteria,
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
const values = this.extractValues(response, ['Result', 'NumberReturned', 'TotalMatches', 'UpdateID']);
|
|
481
|
+
|
|
482
|
+
const resultXml = this.unescapeXml(values['Result'] || '');
|
|
483
|
+
const items = this.parseDidlResult(resultXml);
|
|
484
|
+
|
|
485
|
+
return {
|
|
486
|
+
items,
|
|
487
|
+
numberReturned: parseInt(values['NumberReturned']) || items.length,
|
|
488
|
+
totalMatches: parseInt(values['TotalMatches']) || items.length,
|
|
489
|
+
updateId: parseInt(values['UpdateID']) || 0,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Parse DIDL-Lite result XML
|
|
495
|
+
*/
|
|
496
|
+
private parseDidlResult(xml: string): IDlnaContentItem[] {
|
|
497
|
+
const items: IDlnaContentItem[] = [];
|
|
498
|
+
|
|
499
|
+
// Match container and item elements
|
|
500
|
+
const elementRegex = /<(container|item)[^>]*>([\s\S]*?)<\/\1>/gi;
|
|
501
|
+
let match;
|
|
502
|
+
|
|
503
|
+
while ((match = elementRegex.exec(xml)) !== null) {
|
|
504
|
+
const elementXml = match[0];
|
|
505
|
+
const elementType = match[1];
|
|
506
|
+
|
|
507
|
+
// Extract attributes
|
|
508
|
+
const idMatch = elementXml.match(/id="([^"]*)"/);
|
|
509
|
+
const parentIdMatch = elementXml.match(/parentID="([^"]*)"/);
|
|
510
|
+
const restrictedMatch = elementXml.match(/restricted="([^"]*)"/);
|
|
511
|
+
const childCountMatch = elementXml.match(/childCount="([^"]*)"/);
|
|
512
|
+
|
|
513
|
+
const item: IDlnaContentItem = {
|
|
514
|
+
id: idMatch?.[1] || '',
|
|
515
|
+
parentId: parentIdMatch?.[1] || '',
|
|
516
|
+
title: this.extractTagContent(elementXml, 'dc:title'),
|
|
517
|
+
class: this.extractTagContent(elementXml, 'upnp:class'),
|
|
518
|
+
restricted: restrictedMatch?.[1] !== '0',
|
|
519
|
+
childCount: childCountMatch ? parseInt(childCountMatch[1]) : undefined,
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
// Extract resources
|
|
523
|
+
const resMatches = elementXml.match(/<res[^>]*>([^<]*)<\/res>/gi);
|
|
524
|
+
if (resMatches) {
|
|
525
|
+
item.res = resMatches.map((resXml) => {
|
|
526
|
+
const protocolInfo = resXml.match(/protocolInfo="([^"]*)"/)?.[1] || '';
|
|
527
|
+
const size = resXml.match(/size="([^"]*)"/)?.[1];
|
|
528
|
+
const duration = resXml.match(/duration="([^"]*)"/)?.[1];
|
|
529
|
+
const resolution = resXml.match(/resolution="([^"]*)"/)?.[1];
|
|
530
|
+
const bitrate = resXml.match(/bitrate="([^"]*)"/)?.[1];
|
|
531
|
+
const urlMatch = resXml.match(/>([^<]+)</);
|
|
532
|
+
|
|
533
|
+
return {
|
|
534
|
+
url: urlMatch?.[1] || '',
|
|
535
|
+
protocolInfo,
|
|
536
|
+
size: size ? parseInt(size) : undefined,
|
|
537
|
+
duration,
|
|
538
|
+
resolution,
|
|
539
|
+
bitrate: bitrate ? parseInt(bitrate) : undefined,
|
|
540
|
+
};
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Extract optional metadata
|
|
545
|
+
const albumArt = this.extractTagContent(elementXml, 'upnp:albumArtURI');
|
|
546
|
+
if (albumArt) item.albumArtUri = albumArt;
|
|
547
|
+
|
|
548
|
+
const artist = this.extractTagContent(elementXml, 'dc:creator') ||
|
|
549
|
+
this.extractTagContent(elementXml, 'upnp:artist');
|
|
550
|
+
if (artist) item.artist = artist;
|
|
551
|
+
|
|
552
|
+
const album = this.extractTagContent(elementXml, 'upnp:album');
|
|
553
|
+
if (album) item.album = album;
|
|
554
|
+
|
|
555
|
+
const genre = this.extractTagContent(elementXml, 'upnp:genre');
|
|
556
|
+
if (genre) item.genre = genre;
|
|
557
|
+
|
|
558
|
+
const date = this.extractTagContent(elementXml, 'dc:date');
|
|
559
|
+
if (date) item.date = date;
|
|
560
|
+
|
|
561
|
+
items.push(item);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
return items;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Extract content from XML tag (handles namespaced tags)
|
|
569
|
+
*/
|
|
570
|
+
private extractTagContent(xml: string, tag: string): string {
|
|
571
|
+
// Handle both with and without namespace prefix
|
|
572
|
+
const tagName = tag.includes(':') ? tag.split(':')[1] : tag;
|
|
573
|
+
const regex = new RegExp(`<(?:[^:]*:)?${tagName}[^>]*>([^<]*)<\/(?:[^:]*:)?${tagName}>`, 'i');
|
|
574
|
+
const match = xml.match(regex);
|
|
575
|
+
return match ? match[1].trim() : '';
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// ============================================================================
|
|
579
|
+
// Utility Methods
|
|
580
|
+
// ============================================================================
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Generate DIDL-Lite metadata for a media URL
|
|
584
|
+
*/
|
|
585
|
+
public generateDidlMetadata(
|
|
586
|
+
title: string,
|
|
587
|
+
url: string,
|
|
588
|
+
mimeType: string = 'video/mp4'
|
|
589
|
+
): string {
|
|
590
|
+
const protocolInfo = `http-get:*:${mimeType}:*`;
|
|
591
|
+
|
|
592
|
+
return `<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">
|
|
593
|
+
<item id="0" parentID="-1" restricted="1">
|
|
594
|
+
<dc:title>${this.escapeXml(title)}</dc:title>
|
|
595
|
+
<upnp:class>object.item.videoItem</upnp:class>
|
|
596
|
+
<res protocolInfo="${protocolInfo}">${this.escapeXml(url)}</res>
|
|
597
|
+
</item>
|
|
598
|
+
</DIDL-Lite>`;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Convert duration string to seconds
|
|
603
|
+
*/
|
|
604
|
+
public durationToSeconds(duration: string): number {
|
|
605
|
+
if (!duration || duration === 'NOT_IMPLEMENTED') return 0;
|
|
606
|
+
|
|
607
|
+
const parts = duration.split(':');
|
|
608
|
+
if (parts.length !== 3) return 0;
|
|
609
|
+
|
|
610
|
+
const hours = parseInt(parts[0]) || 0;
|
|
611
|
+
const minutes = parseInt(parts[1]) || 0;
|
|
612
|
+
const seconds = parseFloat(parts[2]) || 0;
|
|
613
|
+
|
|
614
|
+
return hours * 3600 + minutes * 60 + seconds;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Convert seconds to duration string
|
|
619
|
+
*/
|
|
620
|
+
public secondsToDuration(seconds: number): string {
|
|
621
|
+
const hours = Math.floor(seconds / 3600);
|
|
622
|
+
const minutes = Math.floor((seconds % 3600) / 60);
|
|
623
|
+
const secs = Math.floor(seconds % 60);
|
|
624
|
+
|
|
625
|
+
return `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
|
626
|
+
}
|
|
627
|
+
}
|