iobroker.squeezeboxrpc 2.0.2 → 3.0.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/README.md +29 -18
- package/admin/i18n/de.json +7 -0
- package/admin/i18n/en.json +7 -0
- package/admin/i18n/es.json +7 -0
- package/admin/i18n/fr.json +7 -0
- package/admin/i18n/it.json +7 -0
- package/admin/i18n/nl.json +7 -0
- package/admin/i18n/pl.json +7 -0
- package/admin/i18n/pt.json +7 -0
- package/admin/i18n/ru.json +7 -0
- package/admin/i18n/uk.json +7 -0
- package/admin/i18n/zh-cn.json +7 -0
- package/admin/jsonConfig.json +36 -0
- package/io-package.json +26 -52
- package/lib/iosbplayer.js +3 -4
- package/lib/iosbserver.js +75 -180
- package/lib/lms/client.js +22 -0
- package/lib/lms/connection.js +104 -0
- package/lib/lms/discovery.js +145 -0
- package/lib/lms/jsonRpcClient.js +139 -0
- package/lib/lms/telnetEventClient.js +122 -0
- package/lib/lms/webSocketClient.js +352 -0
- package/package.json +4 -7
- package/widgets/squeezeboxrpc/build/squeezeboxrpc-dist.js +1 -1
- package/widgets/squeezeboxrpc/build/squeezeboxrpc-dist.js.map +1 -1
- package/lib/squeezenode/squeezeplayer.js +0 -480
- package/lib/squeezenode/squeezerequest.js +0 -273
- package/lib/squeezenode/squeezeserver.js +0 -352
package/lib/iosbserver.js
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const dgram = require('node:dgram');
|
|
4
3
|
const { ioUtil } = require('./ioUtil');
|
|
5
|
-
const
|
|
4
|
+
const { createLmsConnection } = require('./lms/connection');
|
|
5
|
+
const { LmsDiscovery } = require('./lms/discovery');
|
|
6
6
|
const ioSBPlayer = require(`${__dirname}/iosbplayer`);
|
|
7
7
|
|
|
8
|
+
const SERVER_REFRESH_EVENTS = new Set(['client reconnect', 'client disconnect', 'client new', 'power 0', 'power 1']);
|
|
9
|
+
const PLAYER_REFRESH_EVENTS = new Set(['playlist', 'newmetadata', 'mode', 'play', 'pause', 'stop', 'power', 'mixer']);
|
|
10
|
+
const FULL_PLAYER_REFRESH_EVENTS = new Set(['playlist', 'newmetadata']);
|
|
11
|
+
|
|
8
12
|
/**
|
|
9
13
|
* Returns the names of all initialized players.
|
|
10
14
|
*
|
|
11
15
|
* @param players - Player collection indexed by player ID.
|
|
12
16
|
* @returns The initialized player names in registration order.
|
|
13
17
|
*/
|
|
14
|
-
function getPlayerNames(players) {
|
|
15
|
-
return Object.values(players
|
|
18
|
+
function getPlayerNames(players = {}) {
|
|
19
|
+
return Object.values(players)
|
|
16
20
|
.map(player => player?.playername)
|
|
17
21
|
.filter(playername => typeof playername === 'string' && playername.length > 0);
|
|
18
22
|
}
|
|
@@ -241,20 +245,46 @@ function IoSbServer(adapter, i18n) {
|
|
|
241
245
|
|
|
242
246
|
this.currentStates = {};
|
|
243
247
|
this.players = [];
|
|
248
|
+
this.pendingFullPlayerRefresh = new Set();
|
|
244
249
|
this.adapter = adapter;
|
|
245
250
|
|
|
246
251
|
this.adapter.setState('info.connection', false, true);
|
|
247
252
|
this.adapter.subscribeStates('*');
|
|
248
253
|
|
|
249
|
-
this.sbServer =
|
|
250
|
-
adapter.config.
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
254
|
+
this.sbServer = createLmsConnection({
|
|
255
|
+
host: this.adapter.config.server,
|
|
256
|
+
port: Number.parseInt(this.adapter.config.port),
|
|
257
|
+
username: this.adapter.config.username,
|
|
258
|
+
password: this.adapter.config.password,
|
|
259
|
+
connectionType: this.adapter.config.connectionType || 'jsonrpc',
|
|
260
|
+
webSocketUrl: this.adapter.config.webSocketUrl,
|
|
261
|
+
useTelnet: this.adapter.config.usetelnet,
|
|
262
|
+
telnetPort: Number.parseInt(this.adapter.config.telnetport),
|
|
263
|
+
setTimeout: this.adapter.setTimeout.bind(this.adapter),
|
|
264
|
+
clearTimeout: this.adapter.clearTimeout.bind(this.adapter),
|
|
265
|
+
logDebug: message => this.ioUtil.logdebug(message),
|
|
266
|
+
logError: message => this.ioUtil.logerror(message),
|
|
267
|
+
onEvent: event => {
|
|
268
|
+
if (SERVER_REFRESH_EVENTS.has(event.command)) {
|
|
269
|
+
this.ioUtil.logdebug(`received LMS event ${event.command} for : ${event.playerId}`);
|
|
270
|
+
this.getServerstatus();
|
|
271
|
+
}
|
|
272
|
+
const commandName = event.command.split(' ')[0];
|
|
273
|
+
if (event.playerId && PLAYER_REFRESH_EVENTS.has(commandName)) {
|
|
274
|
+
if (FULL_PLAYER_REFRESH_EVENTS.has(commandName)) {
|
|
275
|
+
this.pendingFullPlayerRefresh.add(event.playerId);
|
|
276
|
+
}
|
|
277
|
+
this.ioUtil.setMyTimeout(
|
|
278
|
+
`player-event-${event.playerId}`,
|
|
279
|
+
() => {
|
|
280
|
+
const forceFull = this.pendingFullPlayerRefresh.delete(event.playerId);
|
|
281
|
+
this.players[event.playerId]?.getPlayerUpdateStatus(forceFull);
|
|
282
|
+
},
|
|
283
|
+
100,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
});
|
|
258
288
|
|
|
259
289
|
this.log = {};
|
|
260
290
|
|
|
@@ -262,9 +292,7 @@ function IoSbServer(adapter, i18n) {
|
|
|
262
292
|
this.errcnt = -1;
|
|
263
293
|
this.connected = 0;
|
|
264
294
|
this.firstStart = true;
|
|
265
|
-
this.
|
|
266
|
-
this.telnet = null;
|
|
267
|
-
this.otherserver = [];
|
|
295
|
+
this.discovery = null;
|
|
268
296
|
|
|
269
297
|
/**
|
|
270
298
|
* Initialization function of the Server object.
|
|
@@ -280,7 +308,7 @@ function IoSbServer(adapter, i18n) {
|
|
|
280
308
|
this.setState('connection', true, 'info');
|
|
281
309
|
this.doObserverServer();
|
|
282
310
|
this.doObserverFavorites();
|
|
283
|
-
this.
|
|
311
|
+
this.sbServer.start();
|
|
284
312
|
};
|
|
285
313
|
/**
|
|
286
314
|
* Observes the server status at regular intervals.
|
|
@@ -388,10 +416,10 @@ function IoSbServer(adapter, i18n) {
|
|
|
388
416
|
*/
|
|
389
417
|
this.discoverLMS = function (command, message, from, callback) {
|
|
390
418
|
this.ioUtil.logdebug(`discoverLMS ${JSON.stringify(message)}`);
|
|
391
|
-
const data = []
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
}
|
|
419
|
+
const data = (this.discovery?.getServers() || []).map(srv => ({
|
|
420
|
+
label: `${srv.NAME}/${srv.ADDRESS}`,
|
|
421
|
+
value: srv.ADDRESS,
|
|
422
|
+
}));
|
|
395
423
|
this.adapter.sendTo(from, command, [{ value: '', label: '---' }, ...data], callback);
|
|
396
424
|
};
|
|
397
425
|
/**
|
|
@@ -441,38 +469,21 @@ function IoSbServer(adapter, i18n) {
|
|
|
441
469
|
});
|
|
442
470
|
return;
|
|
443
471
|
}
|
|
444
|
-
this.
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
if (data.toString().charAt(0) == 'E') {
|
|
452
|
-
let msg = data.toString();
|
|
453
|
-
msg = msg.substr(1);
|
|
454
|
-
const srv = {};
|
|
455
|
-
let len = msg.length;
|
|
456
|
-
let tag, len2, val;
|
|
457
|
-
while (len > 0) {
|
|
458
|
-
tag = msg.substr(0, 4);
|
|
459
|
-
len2 = msg.charCodeAt(4);
|
|
460
|
-
val = msg.substr(5, len2);
|
|
461
|
-
msg = msg.substr(len2 + 5);
|
|
462
|
-
len = len - len2 - 5;
|
|
463
|
-
srv[tag] = val;
|
|
464
|
-
}
|
|
465
|
-
srv['ADDRESS'] = rinfo.address;
|
|
466
|
-
srv['TIMESTAMP'] = Date.now();
|
|
467
|
-
this.otherserver[srv['ADDRESS']] = srv;
|
|
472
|
+
this.discovery = new LmsDiscovery({
|
|
473
|
+
intervalMs: this.adapter.config.discoveryrefresh * 1000,
|
|
474
|
+
schedule: (callback, delay) => this.ioUtil.setMyTimeout('discover', callback, delay),
|
|
475
|
+
cancelSchedule: () => this.ioUtil.clearTimeout('discover'),
|
|
476
|
+
logDebug: message => this.ioUtil.logdebug(message),
|
|
477
|
+
onServer: async srv => {
|
|
478
|
+
const address = String(srv.ADDRESS);
|
|
468
479
|
const state = {};
|
|
469
480
|
Object.assign(state, this.sbServerStatus['otherServers']);
|
|
470
|
-
state.name =
|
|
481
|
+
state.name = address.replace(/\./g, '-');
|
|
471
482
|
state.def = JSON.stringify(srv);
|
|
472
483
|
await this.createFolder(this.sbServerStatus['otherServers'].name, this.ServerStatePath);
|
|
473
484
|
this.createObject(state, this.ServerStatePath, this.sbServerStatus['otherServers'].name, () => {
|
|
474
485
|
this.setState(
|
|
475
|
-
|
|
486
|
+
address.replace(/\./g, '-'),
|
|
476
487
|
JSON.stringify(srv),
|
|
477
488
|
this.ServerStatePath,
|
|
478
489
|
this.sbServerStatus['otherServers'].name,
|
|
@@ -484,143 +495,29 @@ function IoSbServer(adapter, i18n) {
|
|
|
484
495
|
srv['UUID']
|
|
485
496
|
}`,
|
|
486
497
|
);
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
this.
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
this.server = null;
|
|
499
|
-
});
|
|
500
|
-
} catch (err) {
|
|
501
|
-
this.ioUtil.logerror(`server.close() ${err.message}`);
|
|
502
|
-
}
|
|
498
|
+
},
|
|
499
|
+
onExpired: srv => {
|
|
500
|
+
const address = String(srv.ADDRESS);
|
|
501
|
+
this.delObject(
|
|
502
|
+
address.replace(/\./g, '-'),
|
|
503
|
+
this.ServerStatePath,
|
|
504
|
+
this.sbServerStatus.otherServers.name,
|
|
505
|
+
);
|
|
506
|
+
this.ioUtil.logdebug(`Autodiscover: Server removed ${srv.NAME} IP: ${address}`);
|
|
507
|
+
},
|
|
508
|
+
onError: err => this.ioUtil.logerror(`Error with Server discovery ${err.message}`),
|
|
503
509
|
});
|
|
504
|
-
this.
|
|
505
|
-
};
|
|
506
|
-
/**
|
|
507
|
-
* Called periodically to send a broadcast message to find other servers,
|
|
508
|
-
* and remove old entries from the list.
|
|
509
|
-
*
|
|
510
|
-
*/
|
|
511
|
-
this.doDiscoverServerSearch = () => {
|
|
512
|
-
this.ioUtil.logdebug('doDiscoverServerSearch');
|
|
513
|
-
const msg = Buffer.from('eIPAD\0NAME\0JSON\0UUID\0VERS');
|
|
514
|
-
const broadcastAddress = '255.255.255.255';
|
|
515
|
-
const broadcastPort = 3483;
|
|
516
|
-
this.getStates('*', this.ServerStatePath, this.sbServerStatus['otherServers'].name, (err, states) => {
|
|
517
|
-
for (const id in states) {
|
|
518
|
-
const srv = JSON.parse(states[id]?.val || '{}');
|
|
519
|
-
if ((Date.now() - srv.TIMESTAMP) / 1000 > 60) {
|
|
520
|
-
this.delObject(id);
|
|
521
|
-
this.ioUtil.logdebug(
|
|
522
|
-
`Autodiscover: Server removed ${srv['NAME']} IP: ${srv['ADDRESS']} Port ${
|
|
523
|
-
srv['JSON']
|
|
524
|
-
} UUID ${srv['UUID']}`,
|
|
525
|
-
);
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
});
|
|
529
|
-
if (!this.server) {
|
|
530
|
-
return;
|
|
531
|
-
}
|
|
532
|
-
this.server.send(msg, 0, msg.length, broadcastPort, broadcastAddress, err => {
|
|
533
|
-
if (err) {
|
|
534
|
-
this.ioUtil.logerror(err);
|
|
535
|
-
}
|
|
536
|
-
});
|
|
537
|
-
this.ioUtil.setMyTimeout(
|
|
538
|
-
'discover',
|
|
539
|
-
this.doDiscoverServerSearch.bind(this),
|
|
540
|
-
this.adapter.config.discoveryrefresh * 1000,
|
|
541
|
-
);
|
|
510
|
+
this.discovery.start();
|
|
542
511
|
};
|
|
543
512
|
/**
|
|
544
513
|
* Close the UDP server for discovery mode.
|
|
545
514
|
*
|
|
546
515
|
*/
|
|
547
|
-
this.doDiscoverServerClose =
|
|
516
|
+
this.doDiscoverServerClose = () => {
|
|
548
517
|
this.ioUtil.logdebug('doDiscoverServerClose');
|
|
549
|
-
this.
|
|
550
|
-
|
|
551
|
-
/**
|
|
552
|
-
* Connect to the LMS server via Telnet, login and listen for player events.
|
|
553
|
-
*
|
|
554
|
-
*/
|
|
555
|
-
this.doTelnet = () => {
|
|
556
|
-
this.ioUtil.logdebug('doTelnet');
|
|
557
|
-
if (!this.adapter.config.usetelnet) {
|
|
558
|
-
return;
|
|
559
|
-
}
|
|
560
|
-
const net = require('node:net');
|
|
561
|
-
this.telnet = net.createConnection(
|
|
562
|
-
{ host: this.adapter.config.server, port: this.adapter.config.telnetport },
|
|
563
|
-
() => {
|
|
564
|
-
this.ioUtil.logdebug('doTelnet connected to server!');
|
|
565
|
-
this.telnet.write(`login ${this.adapter.config.username} ${this.adapter.config.password}\r\n`);
|
|
566
|
-
this.telnet.write('listen 1\r\n');
|
|
567
|
-
},
|
|
568
|
-
);
|
|
569
|
-
this.telnet.on('data', data => {
|
|
570
|
-
this.ioUtil.logdebug(`doTelnet received Data: ${data.toString()}`);
|
|
571
|
-
//console.log(decodeURIComponent(data.toString()));
|
|
572
|
-
const regex = /^((?:[0-9A-Fa-f]{2}[:-]){5}(?:[0-9A-Fa-f]{2}))\s*(.*)/gm;
|
|
573
|
-
let m;
|
|
574
|
-
const cmdQueue = decodeURIComponent(data.toString()).split('\r\n');
|
|
575
|
-
for (const cmd of cmdQueue) {
|
|
576
|
-
if ((m = regex.exec(cmd)) !== null) {
|
|
577
|
-
const command = m[2].trim();
|
|
578
|
-
if (command == 'client reconnect') {
|
|
579
|
-
this.ioUtil.logdebug(`doTelnet received reconnect for : ${m[1]}`);
|
|
580
|
-
this.getServerstatus();
|
|
581
|
-
}
|
|
582
|
-
if (command == 'client disconnect') {
|
|
583
|
-
this.ioUtil.logdebug(`doTelnet received disconnect for : ${m[1]}`);
|
|
584
|
-
this.getServerstatus();
|
|
585
|
-
}
|
|
586
|
-
if (command == 'client new') {
|
|
587
|
-
this.ioUtil.logdebug(`doTelnet received client new for : ${m[1]}`);
|
|
588
|
-
this.getServerstatus();
|
|
589
|
-
}
|
|
590
|
-
if (command == 'power 0') {
|
|
591
|
-
this.ioUtil.logdebug(`doTelnet received power off : ${m[1]}`);
|
|
592
|
-
this.getServerstatus();
|
|
593
|
-
}
|
|
594
|
-
if (command == 'power 1') {
|
|
595
|
-
this.ioUtil.logdebug(`doTelnet received power on : ${m[1]}`);
|
|
596
|
-
this.getServerstatus();
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
|
-
});
|
|
601
|
-
this.telnet.on('end', () => {
|
|
602
|
-
this.ioUtil.logdebug('doTelnet disconnected from server');
|
|
603
|
-
});
|
|
604
|
-
this.telnet.on('timeout', () => {
|
|
605
|
-
this.ioUtil.logdebug('doTelnet Socket timeout, closing');
|
|
606
|
-
this.telnet.close();
|
|
607
|
-
});
|
|
608
|
-
this.telnet.on('error', err => {
|
|
609
|
-
this.ioUtil.logdebug(`doTelnet Socket error: ${err.message}`);
|
|
610
|
-
});
|
|
611
|
-
this.telnet.on('close', err => {
|
|
612
|
-
this.ioUtil.logdebug(`doTelnet Socket closed ${err.message}`);
|
|
613
|
-
});
|
|
518
|
+
this.discovery?.close();
|
|
519
|
+
this.discovery = null;
|
|
614
520
|
};
|
|
615
|
-
/**
|
|
616
|
-
* Closes the Telnet connection by ending the socket connection with the server.
|
|
617
|
-
* Logs the action for debugging purposes.
|
|
618
|
-
*/
|
|
619
|
-
this.doTelnetClose = function () {
|
|
620
|
-
this.ioUtil.logdebug('doTelnetClose');
|
|
621
|
-
this.telnet.end();
|
|
622
|
-
};
|
|
623
|
-
|
|
624
521
|
/**
|
|
625
522
|
* Closes all active connections by invoking the appropriate closing
|
|
626
523
|
* methods for Telnet and Discovery servers based on the adapter configuration.
|
|
@@ -632,12 +529,10 @@ function IoSbServer(adapter, i18n) {
|
|
|
632
529
|
for (const key in this.players) {
|
|
633
530
|
this.players[key].ioUtil.closeConnections();
|
|
634
531
|
}
|
|
635
|
-
if (this.adapter.config.usetelnet) {
|
|
636
|
-
this.doTelnetClose();
|
|
637
|
-
}
|
|
638
532
|
if (this.adapter.config.usediscovery) {
|
|
639
533
|
this.doDiscoverServerClose();
|
|
640
534
|
}
|
|
535
|
+
this.sbServer.close();
|
|
641
536
|
};
|
|
642
537
|
/**
|
|
643
538
|
* Retrieves the current server status and sync group information.
|
|
@@ -1298,7 +1193,7 @@ function IoSbServer(adapter, i18n) {
|
|
|
1298
1193
|
this.adapter.log.info(s);
|
|
1299
1194
|
}; */
|
|
1300
1195
|
this.getDiscoverServers();
|
|
1301
|
-
this.
|
|
1196
|
+
this.init();
|
|
1302
1197
|
}
|
|
1303
1198
|
module.exports = IoSbServer;
|
|
1304
1199
|
module.exports.getPlayerNames = getPlayerNames;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { LmsJsonRpcClient } = require('./jsonRpcClient');
|
|
4
|
+
const { LmsWebSocketClient } = require('./webSocketClient');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Creates the configured LMS connection.
|
|
8
|
+
*
|
|
9
|
+
* Keeping transport selection in one place allows an experimental WebSocket
|
|
10
|
+
* connection to be added without changing server, player or announcement code.
|
|
11
|
+
*
|
|
12
|
+
* @param options LMS connection options.
|
|
13
|
+
* @returns The configured LMS client.
|
|
14
|
+
*/
|
|
15
|
+
function createLmsClient(options) {
|
|
16
|
+
if (options.connectionType === 'websocket') {
|
|
17
|
+
return new LmsWebSocketClient(options);
|
|
18
|
+
}
|
|
19
|
+
return new LmsJsonRpcClient(options);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
module.exports = { createLmsClient };
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { createLmsClient } = require('./client');
|
|
4
|
+
const { LmsTelnetEventClient } = require('./telnetEventClient');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Unified LMS connection facade.
|
|
8
|
+
*
|
|
9
|
+
* Commands currently use JSON-RPC. Optional notifications currently use the
|
|
10
|
+
* LMS CLI connection. Future WebSocket support can replace both behind this
|
|
11
|
+
* facade without changing adapter server or player code.
|
|
12
|
+
*/
|
|
13
|
+
class LmsConnection {
|
|
14
|
+
/**
|
|
15
|
+
* @param {object} options Connection options.
|
|
16
|
+
* @param {string} options.host LMS host.
|
|
17
|
+
* @param {number} options.port LMS JSON-RPC port.
|
|
18
|
+
* @param {string} [options.username] LMS username.
|
|
19
|
+
* @param {string} [options.password] LMS password.
|
|
20
|
+
* @param {'jsonrpc'|'websocket'} [options.connectionType] Command transport.
|
|
21
|
+
* @param {string} [options.webSocketUrl] Independent WebSocket web-server URL.
|
|
22
|
+
* @param {boolean} [options.useTelnet] Enables LMS CLI notifications.
|
|
23
|
+
* @param {number} [options.telnetPort] LMS CLI port.
|
|
24
|
+
* @param {typeof setTimeout} [options.setTimeout] Adapter-managed timer creator.
|
|
25
|
+
* @param {typeof clearTimeout} [options.clearTimeout] Adapter-managed timer cleanup.
|
|
26
|
+
* @param {(event: { playerId: string, command: string }) => void} [options.onEvent] Notification callback.
|
|
27
|
+
* @param {(message: string) => void} [options.logDebug] Debug logger.
|
|
28
|
+
* @param {(message: string) => void} [options.logError] Error logger.
|
|
29
|
+
* @param {(options: object) => {
|
|
30
|
+
* request: (playerId: string, command: unknown[], callback?: (result: object) => void) => Promise<object>,
|
|
31
|
+
* requestAsync: (playerId: string, command: unknown[]) => Promise<object>, start?: () => void,
|
|
32
|
+
* close: () => void}} [options.clientFactory]
|
|
33
|
+
* Command client factory used by tests.
|
|
34
|
+
* @param {new (options: object) => {start: () => void, close: () => void}} [options.telnetClientClass]
|
|
35
|
+
* Telnet client class used by tests.
|
|
36
|
+
*/
|
|
37
|
+
constructor(options) {
|
|
38
|
+
const clientFactory = options.clientFactory || createLmsClient;
|
|
39
|
+
this.commandClient = clientFactory(options);
|
|
40
|
+
this.telnetClient = null;
|
|
41
|
+
|
|
42
|
+
if (options.connectionType !== 'websocket' && options.useTelnet) {
|
|
43
|
+
const TelnetClient = options.telnetClientClass || LmsTelnetEventClient;
|
|
44
|
+
this.telnetClient = new TelnetClient({
|
|
45
|
+
host: options.host,
|
|
46
|
+
port: options.telnetPort || 9090,
|
|
47
|
+
username: options.username,
|
|
48
|
+
password: options.password,
|
|
49
|
+
onEvent: options.onEvent || (() => undefined),
|
|
50
|
+
logDebug: options.logDebug,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Starts configured push-notification transports. */
|
|
56
|
+
start() {
|
|
57
|
+
if ('start' in this.commandClient && typeof this.commandClient.start === 'function') {
|
|
58
|
+
this.commandClient.start();
|
|
59
|
+
}
|
|
60
|
+
this.telnetClient?.start();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Delegates a command while retaining the adapter's callback contract.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} playerId LMS player ID, or an empty string for server commands.
|
|
67
|
+
* @param {unknown[]} command LMS command tokens.
|
|
68
|
+
* @param {(result: object) => void} [callback] Compatibility callback.
|
|
69
|
+
* @returns {Promise<object>} Pending command result.
|
|
70
|
+
*/
|
|
71
|
+
request(playerId, command, callback) {
|
|
72
|
+
return this.commandClient.request(playerId, command, callback);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Delegates a command using the promise API.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} playerId LMS player ID, or an empty string for server commands.
|
|
79
|
+
* @param {unknown[]} command LMS command tokens.
|
|
80
|
+
* @returns {Promise<object>} Pending command result.
|
|
81
|
+
*/
|
|
82
|
+
requestAsync(playerId, command) {
|
|
83
|
+
return this.commandClient.requestAsync(playerId, command);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Closes command and notification transports. */
|
|
87
|
+
close() {
|
|
88
|
+
this.telnetClient?.close();
|
|
89
|
+
this.telnetClient = null;
|
|
90
|
+
this.commandClient.close();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Creates the configured LMS connection facade.
|
|
96
|
+
*
|
|
97
|
+
* @param {ConstructorParameters<typeof LmsConnection>[0]} options Connection options.
|
|
98
|
+
* @returns {LmsConnection} Configured connection.
|
|
99
|
+
*/
|
|
100
|
+
function createLmsConnection(options) {
|
|
101
|
+
return new LmsConnection(options);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = { LmsConnection, createLmsConnection };
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const dgram = require('node:dgram');
|
|
4
|
+
|
|
5
|
+
const DISCOVERY_PORT = 3483;
|
|
6
|
+
const BROADCAST_ADDRESS = '255.255.255.255';
|
|
7
|
+
const SERVER_TTL_MS = 60_000;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Parses an LMS UDP discovery response using byte lengths from the protocol.
|
|
11
|
+
*
|
|
12
|
+
* @param {Buffer} packet UDP response packet.
|
|
13
|
+
* @param {string} address Sender address.
|
|
14
|
+
* @param {number} [timestamp] Reception timestamp.
|
|
15
|
+
* @returns {Record<string, string | number> | null} Parsed LMS data.
|
|
16
|
+
*/
|
|
17
|
+
function parseDiscoveryResponse(packet, address, timestamp = Date.now()) {
|
|
18
|
+
if (!Buffer.isBuffer(packet) || packet.length === 0 || packet[0] !== 0x45) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const server = Object.create(null);
|
|
23
|
+
let offset = 1;
|
|
24
|
+
while (offset < packet.length) {
|
|
25
|
+
if (offset + 5 > packet.length) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
const tag = packet.toString('ascii', offset, offset + 4);
|
|
29
|
+
const valueLength = packet[offset + 4];
|
|
30
|
+
offset += 5;
|
|
31
|
+
if (offset + valueLength > packet.length) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
server[tag] = packet.toString('utf8', offset, offset + valueLength);
|
|
35
|
+
offset += valueLength;
|
|
36
|
+
}
|
|
37
|
+
server.ADDRESS = address;
|
|
38
|
+
server.TIMESTAMP = timestamp;
|
|
39
|
+
return server;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Returns the unchanged LMS UDP discovery request. */
|
|
43
|
+
function createDiscoveryRequest() {
|
|
44
|
+
return Buffer.from('eIPAD\0NAME\0JSON\0UUID\0VERS');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Owns LMS UDP discovery independently from ioBroker state handling. */
|
|
48
|
+
class LmsDiscovery {
|
|
49
|
+
/**
|
|
50
|
+
* @param {object} options Discovery options.
|
|
51
|
+
* @param {number} options.intervalMs Broadcast interval.
|
|
52
|
+
* @param {(server: Record<string, string | number>) => void | Promise<void>} options.onServer Server callback.
|
|
53
|
+
* @param {(server: Record<string, string | number>) => void} options.onExpired Expiration callback.
|
|
54
|
+
* @param {(error: Error) => void} options.onError Error callback.
|
|
55
|
+
* @param {(message: string) => void} [options.logDebug] Debug logger.
|
|
56
|
+
* @param {(callback: () => void, delay: number) => void} options.schedule Timer scheduler.
|
|
57
|
+
* @param {() => void} options.cancelSchedule Timer cancellation.
|
|
58
|
+
* @param {typeof dgram.createSocket} [options.socketFactory] Socket factory used by tests.
|
|
59
|
+
*/
|
|
60
|
+
constructor(options) {
|
|
61
|
+
this.intervalMs = options.intervalMs;
|
|
62
|
+
this.onServer = options.onServer;
|
|
63
|
+
this.onExpired = options.onExpired;
|
|
64
|
+
this.onError = options.onError;
|
|
65
|
+
this.logDebug = options.logDebug || (() => undefined);
|
|
66
|
+
this.schedule = options.schedule;
|
|
67
|
+
this.cancelSchedule = options.cancelSchedule;
|
|
68
|
+
this.socketFactory = options.socketFactory || dgram.createSocket;
|
|
69
|
+
this.socket = null;
|
|
70
|
+
this.servers = new Map();
|
|
71
|
+
this.closed = false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Opens the UDP listener and schedules the first discovery broadcast. */
|
|
75
|
+
start() {
|
|
76
|
+
if (this.socket || this.closed) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const socket = this.socketFactory({ type: 'udp4', reuseAddr: true });
|
|
80
|
+
this.socket = socket;
|
|
81
|
+
socket.on('message', (packet, remote) => {
|
|
82
|
+
const server = parseDiscoveryResponse(packet, remote.address);
|
|
83
|
+
if (!server) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
this.servers.set(remote.address, server);
|
|
87
|
+
Promise.resolve(this.onServer(server)).catch(error => this.onError(error));
|
|
88
|
+
});
|
|
89
|
+
socket.on('error', error => {
|
|
90
|
+
this.onError(error);
|
|
91
|
+
this.#closeSocket();
|
|
92
|
+
});
|
|
93
|
+
socket.bind(DISCOVERY_PORT, '0.0.0.0', () => socket.setBroadcast(true));
|
|
94
|
+
this.schedule(() => this.search(), 1_000);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Sends one broadcast, expires stale entries and schedules the next run. */
|
|
98
|
+
search() {
|
|
99
|
+
if (!this.socket || this.closed) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const now = Date.now();
|
|
103
|
+
for (const [address, server] of this.servers) {
|
|
104
|
+
if (now - Number(server.TIMESTAMP) > SERVER_TTL_MS) {
|
|
105
|
+
this.servers.delete(address);
|
|
106
|
+
this.onExpired(server);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
this.socket.send(createDiscoveryRequest(), DISCOVERY_PORT, BROADCAST_ADDRESS, error => {
|
|
110
|
+
if (error) {
|
|
111
|
+
this.onError(error);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
this.schedule(() => this.search(), this.intervalMs);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Returns currently known non-expired servers. */
|
|
118
|
+
getServers() {
|
|
119
|
+
const now = Date.now();
|
|
120
|
+
return [...this.servers.values()].filter(server => now - Number(server.TIMESTAMP) <= SERVER_TTL_MS);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Stops timers and closes the UDP socket. */
|
|
124
|
+
close() {
|
|
125
|
+
this.closed = true;
|
|
126
|
+
this.cancelSchedule();
|
|
127
|
+
this.#closeSocket();
|
|
128
|
+
this.servers.clear();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
#closeSocket() {
|
|
132
|
+
const socket = this.socket;
|
|
133
|
+
this.socket = null;
|
|
134
|
+
if (!socket) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
socket.close();
|
|
139
|
+
} catch (error) {
|
|
140
|
+
this.logDebug(`LMS discovery socket close failed: ${error.message}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
module.exports = { createDiscoveryRequest, LmsDiscovery, parseDiscoveryResponse };
|