wiim2mqtt 0.1.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/lib/device.js ADDED
@@ -0,0 +1,998 @@
1
+ /**
2
+ * Device: one WiiM, one normalised state. Feeds from UPnP events (primary), HTTP API seed +
3
+ * adaptive polling (safety net) and emits friendly item changes. See ROADMAP §3.7.
4
+ *
5
+ * Events:
6
+ * - 'connected' (bool) http api reachable
7
+ * - 'change' (item, value, opts) opts.retain false for position
8
+ * - 'upnp' ('events'|'polling'|'off')
9
+ */
10
+
11
+ import {EventEmitter} from 'node:events';
12
+ import http from 'node:http';
13
+ import https from 'node:https';
14
+ import {parseMetadata, parseDuration, EMPTY_TRACK} from './didl.js';
15
+ import {
16
+ PLAY_STATES,
17
+ TRANSPORT_STATES,
18
+ SWITCHABLE_SOURCES,
19
+ sourceFromMode,
20
+ sourceFromMedium,
21
+ loopToRepeatShuffle,
22
+ } from './commands.js';
23
+ import {HttpApi} from './api.js';
24
+
25
+ const TRACK_ITEMS = [
26
+ 'title',
27
+ 'artist',
28
+ 'album',
29
+ 'album_art',
30
+ 'quality',
31
+ 'sample_rate',
32
+ 'bit_depth',
33
+ 'bitrate',
34
+ 'origin_source',
35
+ ];
36
+ const MISS_ITEMS = ['play_state', 'volume', 'mute'];
37
+ const MISS_WINDOW = 5 * 60 * 1000;
38
+ const MISS_LIMIT = 3;
39
+ const HEALTHY_AFTER = 10 * 60 * 1000;
40
+ const IDLE_POLL = 5000;
41
+ const HEALTHY_POLL = 30000;
42
+ const DEVICE_POLL = 60000;
43
+ const UNREACHABLE_POLL_MAX = 30000;
44
+
45
+ export class Device extends EventEmitter {
46
+ /**
47
+ * @param {object} options
48
+ * @param {import('./api.js').HttpApi} options.api
49
+ * @param {import('./upnp.js').UpnpClient} [options.upnp] null for --no-upnp
50
+ * @param {number} [options.pollInterval] seconds, fast poll cadence
51
+ * @param {number} [options.positionInterval] seconds, 0 = off
52
+ * @param {boolean} [options.albumArtData] fetch and emit album_art_data
53
+ * @param {object} [options.log]
54
+ * @param {object} [options.timers] {setTimeout, clearTimeout, setInterval, clearInterval}
55
+ * @param {() => number} [options.now]
56
+ * @param {(baseUrl: string) => HttpApi} [options.createApi] for the multiroom master
57
+ */
58
+ constructor({
59
+ api,
60
+ upnp = null,
61
+ pollInterval = 1,
62
+ positionInterval = 1,
63
+ albumArtData = false,
64
+ log,
65
+ timers,
66
+ now,
67
+ createApi,
68
+ }) {
69
+ super();
70
+ this.api = api;
71
+ this.upnp = upnp;
72
+ this.pollInterval = Math.max(200, pollInterval * 1000);
73
+ this.positionInterval = positionInterval * 1000;
74
+ this.albumArtData = albumArtData;
75
+ this.log = log || {debug() {}, info() {}, warn() {}, error() {}};
76
+ this.timers = timers || {setTimeout, clearTimeout};
77
+ this.now = now || Date.now;
78
+ this.createApi = createApi || ((baseUrl) => new HttpApi({baseUrl, log: this.log}));
79
+
80
+ this.state = new Map();
81
+ this.connected = undefined;
82
+ this.running = false;
83
+ this.mode = upnp ? 'polling' : 'off';
84
+ this.misses = [];
85
+ this.lastMissAt = 0;
86
+ this.subscribedAt = 0;
87
+ this.pollTimer = undefined;
88
+ this.deviceTimer = undefined;
89
+ this.positionTimer = undefined;
90
+ this.quickPollTimer = undefined;
91
+ this.metaTimer = undefined;
92
+ this.verifyTimers = new Set();
93
+ this.anchor = {position: 0, at: 0};
94
+ this.trackKey = '';
95
+ this.unreachableSince = 0;
96
+ this.failures = 0;
97
+ this.masterApi = undefined;
98
+ this.masterUrl = undefined;
99
+ this.pollPending = false;
100
+ this.lastArtUrl = undefined;
101
+ }
102
+
103
+ get(item) {
104
+ return this.state.get(item);
105
+ }
106
+
107
+ /** Set a friendly item; emits 'change' when the value differs. */
108
+ set(item, value, opts = {}) {
109
+ const previous = this.state.get(item);
110
+ const changed = JSON.stringify(previous) !== JSON.stringify(value);
111
+ if (changed || opts.force) {
112
+ this.state.set(item, value);
113
+ this.emit('change', item, value, opts);
114
+ }
115
+ return changed;
116
+ }
117
+
118
+ /*
119
+ * lifecycle
120
+ */
121
+
122
+ async start() {
123
+ this.running = true;
124
+ if (this.upnp) {
125
+ this.upnp.on('event', (event) => this.onEvent(event));
126
+ this.upnp.on('subscribed', ({service}) => this.onSubscribed(service));
127
+ this.upnp.on('lost', ({service, error, retryIn}) => this.onLost(service, error, retryIn));
128
+ }
129
+ await this.connect();
130
+ }
131
+
132
+ /** Establish the http connection and seed; retried by the poll loop while unreachable. */
133
+ async connect() {
134
+ try {
135
+ await this.refreshDevice();
136
+ } catch (err) {
137
+ this.setConnected(false, err);
138
+ this.schedulePoll();
139
+ return;
140
+ }
141
+ this.setConnected(true);
142
+ if (this.upnp) {
143
+ await this.describeUpnp();
144
+ }
145
+ await this.seed();
146
+ if (this.upnp) {
147
+ await this.subscribeUpnp();
148
+ } else {
149
+ this.setMode('off');
150
+ }
151
+ this.schedulePoll();
152
+ this.scheduleDevicePoll();
153
+ }
154
+
155
+ async describeUpnp() {
156
+ try {
157
+ const desc = await this.upnp.describe();
158
+ if (desc.model && !this.get('model')) {
159
+ this.set('model', desc.model);
160
+ }
161
+ } catch (err) {
162
+ this.log.warn('upnp description unavailable, polling only:', err.message);
163
+ }
164
+ }
165
+
166
+ async subscribeUpnp() {
167
+ if (!this.upnp.description) {
168
+ this.setMode('polling');
169
+ return;
170
+ }
171
+ try {
172
+ await this.upnp.subscribe(['AVTransport', 'RenderingControl', 'PlayQueue']);
173
+ } catch (err) {
174
+ this.log.warn('upnp subscribe failed, polling only:', err.message);
175
+ this.setMode('polling');
176
+ }
177
+ }
178
+
179
+ async stop() {
180
+ this.running = false;
181
+ for (const timer of [
182
+ this.pollTimer,
183
+ this.deviceTimer,
184
+ this.positionTimer,
185
+ this.quickPollTimer,
186
+ this.metaTimer,
187
+ ...this.verifyTimers,
188
+ ]) {
189
+ this.timers.clearTimeout(timer);
190
+ }
191
+ this.verifyTimers.clear();
192
+ if (this.upnp) {
193
+ await this.upnp.stop();
194
+ }
195
+ this.api.close();
196
+ if (this.masterApi) {
197
+ this.masterApi.close();
198
+ }
199
+ }
200
+
201
+ setConnected(connected, err) {
202
+ if (connected === this.connected) {
203
+ return;
204
+ }
205
+ this.connected = connected;
206
+ if (connected) {
207
+ const downtime = this.unreachableSince ? Math.round((this.now() - this.unreachableSince) / 1000) : 0;
208
+ this.log.info('api reachable', this.api.baseUrl, downtime ? `(after ${downtime} s)` : '');
209
+ this.unreachableSince = 0;
210
+ this.failures = 0;
211
+ } else {
212
+ this.unreachableSince = this.now();
213
+ this.log.warn('api unreachable', this.api.baseUrl, err ? `(${err.message})` : '');
214
+ this.stopPositionTimer();
215
+ }
216
+ this.emit('connected', connected);
217
+ }
218
+
219
+ setMode(mode) {
220
+ if (mode === this.mode) {
221
+ return;
222
+ }
223
+ const previous = this.mode;
224
+ this.mode = mode;
225
+ this.log[mode === 'events' ? 'info' : 'warn']('upnp state source', previous, '→', mode);
226
+ this.set('upnp', mode);
227
+ this.emit('upnp', mode);
228
+ this.schedulePoll();
229
+ }
230
+
231
+ /*
232
+ * seeding / refresh
233
+ */
234
+
235
+ async seed() {
236
+ await this.refresh('status');
237
+ await this.refresh('metadata');
238
+ await this.refresh('presets');
239
+ await this.refresh('group');
240
+ }
241
+
242
+ /** Explicit refresh (get/<item> or after set). */
243
+ async refresh(what) {
244
+ try {
245
+ switch (what) {
246
+ case 'status':
247
+ if (this.upnp && this.upnp.description) {
248
+ try {
249
+ this.applyInfoEx(await this.upnp.getInfoEx());
250
+ } catch (err) {
251
+ this.log.debug('upnp GetInfoEx failed, using http:', err.message);
252
+ }
253
+ }
254
+ await this.pollStatus({seed: true});
255
+ return;
256
+ case 'metadata':
257
+ return this.fetchMetaInfo();
258
+ case 'presets':
259
+ return this.refreshPresets();
260
+ case 'device':
261
+ return this.refreshDevice();
262
+ case 'group':
263
+ return this.refreshGroup();
264
+ default:
265
+ throw new Error(`unknown refresh ${what}`);
266
+ }
267
+ } catch (err) {
268
+ this.onApiError(err, `refresh ${what}`);
269
+ }
270
+ }
271
+
272
+ async refreshDevice() {
273
+ const raw = await this.api.statusEx();
274
+ this.applyStatusEx(raw);
275
+ return raw;
276
+ }
277
+
278
+ applyStatusEx(raw) {
279
+ const str = (v) => (v === undefined || v === null ? '' : String(v));
280
+ if (raw.DeviceName !== undefined) {
281
+ this.set('device_name', str(raw.DeviceName));
282
+ }
283
+ const model = str(raw.project || raw.priv_prj || raw.hardware);
284
+ if (model) {
285
+ this.set('model', model);
286
+ }
287
+ if (raw.firmware !== undefined) {
288
+ this.set('firmware', str(raw.firmware));
289
+ }
290
+ if (raw.uuid !== undefined) {
291
+ this.set('uuid', str(raw.uuid));
292
+ }
293
+ if (raw.MAC !== undefined) {
294
+ this.set('mac', str(raw.MAC));
295
+ }
296
+ const ip = [raw.eth0, raw.apcli0, raw.eth2].map(str).find((v) => v && v !== '0.0.0.0');
297
+ if (ip) {
298
+ this.set('ip', ip);
299
+ }
300
+ if (raw.RSSI !== undefined && Number.isFinite(Number(raw.RSSI)) && !(Number(raw.RSSI) === 0 && !raw.BSSID)) {
301
+ this.set('rssi', Number(raw.RSSI));
302
+ }
303
+ const updateAvailable = str(raw.VersionUpdate) === '1';
304
+ this.set('update_available', updateAvailable);
305
+ this.set('firmware_update', {
306
+ installed_version: str(raw.firmware),
307
+ latest_version: updateAvailable && raw.NewVer ? str(raw.NewVer) : str(raw.firmware),
308
+ });
309
+ if (raw.group !== undefined) {
310
+ this.groupFlag = str(raw.group) === '1';
311
+ }
312
+ // a slave's master, if the firmware tells us (OQ-44)
313
+ const masterKey = Object.keys(raw).find((k) => /master.*ip|host_ip|masterip/i.test(k));
314
+ if (masterKey && str(raw[masterKey]) && str(raw[masterKey]) !== '0.0.0.0') {
315
+ this.set('group_master', str(raw[masterKey]));
316
+ }
317
+ this.applyGroupRole();
318
+ if (raw.plm_support !== undefined || raw.capability !== undefined) {
319
+ // source list is model dependent; until we know the bits, expose the documented switchable set
320
+ this.set('source_list', SWITCHABLE_SOURCES);
321
+ } else if (this.get('source_list') === undefined) {
322
+ this.set('source_list', SWITCHABLE_SOURCES);
323
+ }
324
+ }
325
+
326
+ async refreshPresets() {
327
+ let presets = [];
328
+ let max;
329
+ try {
330
+ presets = await this.api.presetInfo();
331
+ } catch (err) {
332
+ this.log.debug('getPresetInfo failed:', err.message);
333
+ }
334
+ if (this.upnp && this.upnp.description && this.upnp.description.services.PlayQueue) {
335
+ try {
336
+ const mapping = await this.upnp.getKeyMapping();
337
+ max = mapping.maxNumber;
338
+ if (presets.length === 0) {
339
+ presets = mapping.presets;
340
+ }
341
+ } catch (err) {
342
+ this.log.debug('upnp GetKeyMapping failed:', err.message);
343
+ }
344
+ }
345
+ this.set(
346
+ 'preset_list',
347
+ presets.map(({number, name, source, pic}) => ({number, name, source, pic})),
348
+ );
349
+ this.set('preset_max', max || Math.max(12, ...presets.map((p) => p.number)));
350
+ }
351
+
352
+ async refreshGroup() {
353
+ let slaves = [];
354
+ try {
355
+ slaves = await this.api.slaveList();
356
+ } catch (err) {
357
+ if (err.code !== 'EUNKNOWN' && err.code !== 'EFAILED' && err.code !== 'EFORMAT') {
358
+ throw err;
359
+ }
360
+ }
361
+ this.set(
362
+ 'group_slaves',
363
+ slaves.map(({name, ip, uuid}) => ({name, ip, uuid})),
364
+ );
365
+ this.applyGroupRole();
366
+ }
367
+
368
+ applyGroupRole() {
369
+ const slaves = this.get('group_slaves');
370
+ let role = 'standalone';
371
+ if (this.groupFlag || this.get('group_role_hint') === 'slave') {
372
+ role = 'slave';
373
+ } else if (Array.isArray(slaves) && slaves.length > 0) {
374
+ role = 'master';
375
+ }
376
+ this.set('group_role', role);
377
+ if (role !== 'slave') {
378
+ this.set('group_master', '');
379
+ this.detachMaster();
380
+ } else {
381
+ this.attachMaster();
382
+ }
383
+ if (role !== 'master' && Array.isArray(slaves) && slaves.length) {
384
+ this.set('group_slaves', []);
385
+ }
386
+ }
387
+
388
+ attachMaster() {
389
+ const ip = this.get('group_master');
390
+ if (!ip) {
391
+ this.log.debug('group: slave, master ip unknown - no metadata mirroring');
392
+ return;
393
+ }
394
+ const url = `https://${ip}`;
395
+ if (this.masterUrl === url) {
396
+ return;
397
+ }
398
+ this.detachMaster();
399
+ this.masterUrl = url;
400
+ this.masterApi = this.createApi(url);
401
+ this.log.info('group: slave of', ip, '- mirroring its playback state');
402
+ }
403
+
404
+ detachMaster() {
405
+ if (this.masterApi) {
406
+ this.masterApi.close();
407
+ this.masterApi = undefined;
408
+ this.masterUrl = undefined;
409
+ }
410
+ }
411
+
412
+ /*
413
+ * polling
414
+ */
415
+
416
+ schedulePoll() {
417
+ this.timers.clearTimeout(this.pollTimer);
418
+ if (!this.running) {
419
+ return;
420
+ }
421
+ let delay;
422
+ if (!this.connected) {
423
+ delay = Math.min(IDLE_POLL * Math.max(1, this.failures), UNREACHABLE_POLL_MAX);
424
+ } else if (this.mode === 'events') {
425
+ delay = HEALTHY_POLL;
426
+ } else {
427
+ const playing = ['playing', 'loading'].includes(this.get('play_state'));
428
+ delay = playing ? this.pollInterval : IDLE_POLL;
429
+ }
430
+ this.pollTimer = this.timers.setTimeout(() => this.poll(), delay);
431
+ }
432
+
433
+ scheduleDevicePoll() {
434
+ this.timers.clearTimeout(this.deviceTimer);
435
+ if (!this.running) {
436
+ return;
437
+ }
438
+ this.deviceTimer = this.timers.setTimeout(async () => {
439
+ if (this.connected) {
440
+ try {
441
+ await this.refreshDevice();
442
+ await this.refreshGroup();
443
+ } catch (err) {
444
+ this.onApiError(err, 'device poll');
445
+ }
446
+ }
447
+ this.scheduleDevicePoll();
448
+ }, DEVICE_POLL);
449
+ }
450
+
451
+ /** One poll cycle. */
452
+ async poll() {
453
+ if (!this.running || this.pollPending) {
454
+ return;
455
+ }
456
+ this.pollPending = true;
457
+ try {
458
+ if (!this.connected) {
459
+ await this.connect();
460
+ return;
461
+ }
462
+ await this.pollStatus({});
463
+ } catch (err) {
464
+ this.onApiError(err, 'poll');
465
+ } finally {
466
+ this.pollPending = false;
467
+ if (this.connected) {
468
+ this.schedulePoll();
469
+ }
470
+ }
471
+ }
472
+
473
+ /** Quick poll ~1 s after an event, to pick up mode/queue/position the event did not carry. */
474
+ scheduleQuickPoll() {
475
+ if (this.quickPollTimer || !this.connected) {
476
+ return;
477
+ }
478
+ this.quickPollTimer = this.timers.setTimeout(async () => {
479
+ this.quickPollTimer = undefined;
480
+ try {
481
+ await this.pollStatus({quick: true});
482
+ } catch (err) {
483
+ this.onApiError(err, 'quick poll');
484
+ }
485
+ }, 1000);
486
+ }
487
+
488
+ async pollStatus({seed = false, quick = false}) {
489
+ const status = await this.api.playerStatus();
490
+ this.failures = 0;
491
+ if (!this.connected) {
492
+ this.setConnected(true);
493
+ }
494
+ const detectMisses = this.mode === 'events' && !seed && !quick && this.now() - this.subscribedAt > 5000;
495
+ const before = detectMisses ? Object.fromEntries(MISS_ITEMS.map((i) => [i, this.get(i)])) : undefined;
496
+ this.applyPlayerStatus(status, {seed});
497
+ if (detectMisses) {
498
+ const missed = MISS_ITEMS.filter((i) => before[i] !== undefined && before[i] !== this.get(i));
499
+ if (missed.length) {
500
+ this.onMiss(missed);
501
+ }
502
+ }
503
+ if (this.masterApi) {
504
+ await this.mirrorMaster();
505
+ }
506
+ }
507
+
508
+ applyPlayerStatus(status, {seed = false} = {}) {
509
+ const playState = PLAY_STATES[status.status] || (status.status ? 'stopped' : undefined);
510
+ if (status.mode === 99 || status.type === 1) {
511
+ this.state.set('group_role_hint', 'slave');
512
+ } else {
513
+ this.state.delete('group_role_hint');
514
+ }
515
+ this.applySource(sourceFromMode(status.mode));
516
+ this.set('volume', status.vol);
517
+ this.set('mute', status.mute);
518
+ const {repeat, shuffle} = loopToRepeatShuffle(status.loop);
519
+ this.set('repeat', repeat);
520
+ this.set('shuffle', shuffle);
521
+ this.set('queue_index', status.plicurr);
522
+ this.set('queue_length', status.plicount);
523
+ if (status.totlen > 0) {
524
+ this.set('duration', Math.round(status.totlen / 1000));
525
+ }
526
+ this.setAnchor(Math.round(status.curpos / 1000));
527
+ if (playState) {
528
+ this.applyPlayState(playState);
529
+ }
530
+ // http metadata only fills gaps; upnp/getMetaInfo are richer
531
+ if (status.title !== undefined && status.title !== '' && playState !== 'stopped') {
532
+ if (!this.get('title')) {
533
+ this.applyTrack({
534
+ ...EMPTY_TRACK,
535
+ title: status.title,
536
+ artist: status.artist === 'unknown' ? '' : status.artist || '',
537
+ album: status.album === 'unknown' ? '' : status.album || '',
538
+ });
539
+ }
540
+ }
541
+ if (seed) {
542
+ this.set('upnp', this.mode);
543
+ }
544
+ }
545
+
546
+ applyInfoEx(info) {
547
+ const playState = TRANSPORT_STATES[info.transportState];
548
+ const medium = sourceFromMedium(info.playMedium);
549
+ if (medium) {
550
+ this.applySource(medium);
551
+ }
552
+ if (info.volume !== undefined && info.volume !== '') {
553
+ this.set('volume', Number(info.volume));
554
+ }
555
+ if (info.mute !== undefined && info.mute !== '') {
556
+ this.set('mute', info.mute === '1' || info.mute === 'true');
557
+ }
558
+ if (info.loopMode !== undefined && info.loopMode !== '') {
559
+ const {repeat, shuffle} = loopToRepeatShuffle(info.loopMode);
560
+ this.set('repeat', repeat);
561
+ this.set('shuffle', shuffle);
562
+ }
563
+ const duration = parseDuration(info.duration);
564
+ if (duration) {
565
+ this.set('duration', duration);
566
+ }
567
+ const track = parseMetadata(info.metadata);
568
+ if (track) {
569
+ if (!track.originSource && info.trackSource) {
570
+ track.originSource = String(info.trackSource);
571
+ }
572
+ this.applyTrack(track);
573
+ }
574
+ if (info.trackSource) {
575
+ this.state.set('track_source', String(info.trackSource));
576
+ }
577
+ // after the track: a track change resets the position anchor
578
+ const position = parseDuration(info.position);
579
+ if (position !== null) {
580
+ this.setAnchor(position);
581
+ }
582
+ if (playState) {
583
+ this.applyPlayState(playState);
584
+ }
585
+ }
586
+
587
+ /*
588
+ * upnp events
589
+ */
590
+
591
+ onSubscribed() {
592
+ if (!this.subscribedAt) {
593
+ this.subscribedAt = this.now();
594
+ }
595
+ this.evaluateMode();
596
+ }
597
+
598
+ onLost(service, error, retryIn) {
599
+ this.log.warn(
600
+ 'upnp subscription',
601
+ service,
602
+ 'lost:',
603
+ error && error.message ? error.message : error,
604
+ `- retry in ${retryIn} s`,
605
+ );
606
+ this.subscribedAt = 0;
607
+ this.evaluateMode();
608
+ }
609
+
610
+ onMiss(items) {
611
+ const now = this.now();
612
+ this.misses = this.misses.filter((t) => now - t < MISS_WINDOW);
613
+ this.misses.push(now);
614
+ this.lastMissAt = now;
615
+ this.log.debug('upnp events missed a change of', items.join(', '), `(${this.misses.length}/${MISS_LIMIT})`);
616
+ if (this.misses.length >= MISS_LIMIT) {
617
+ this.log.warn('upnp events unreliable (missed', items.join(', '), ') - polling and re-subscribing');
618
+ this.misses = [];
619
+ this.evaluateMode();
620
+ if (this.upnp) {
621
+ this.upnp.resubscribe('events missed changes');
622
+ }
623
+ }
624
+ }
625
+
626
+ evaluateMode() {
627
+ if (!this.upnp) {
628
+ this.setMode('off');
629
+ return;
630
+ }
631
+ const subscribed = this.upnp.subscribed;
632
+ const healthy = !this.lastMissAt || this.now() - this.lastMissAt > HEALTHY_AFTER;
633
+ this.setMode(subscribed && healthy ? 'events' : 'polling');
634
+ }
635
+
636
+ onEvent({service, variables}) {
637
+ if (!this.connected) {
638
+ return;
639
+ }
640
+ this.evaluateMode();
641
+ let quickPoll = false;
642
+ for (const [name, value] of Object.entries(variables)) {
643
+ switch (name) {
644
+ case 'TransportState': {
645
+ const playState = TRANSPORT_STATES[value];
646
+ if (playState) {
647
+ this.applyPlayState(playState);
648
+ quickPoll = true;
649
+ }
650
+ break;
651
+ }
652
+ case 'CurrentTrackMetaData': {
653
+ const track = parseMetadata(value);
654
+ if (track) {
655
+ this.applyTrack(track);
656
+ quickPoll = true;
657
+ }
658
+ break;
659
+ }
660
+ case 'AVTransportURIMetaData': {
661
+ if (variables.CurrentTrackMetaData === undefined) {
662
+ const track = parseMetadata(value);
663
+ if (track) {
664
+ this.applyTrack(track);
665
+ }
666
+ }
667
+ break;
668
+ }
669
+ case 'Volume':
670
+ this.set('volume', Number(value));
671
+ break;
672
+ case 'Mute':
673
+ this.set('mute', value === '1' || value === 'true');
674
+ break;
675
+ case 'LoopMode': {
676
+ const {repeat, shuffle} = loopToRepeatShuffle(value);
677
+ this.set('repeat', repeat);
678
+ this.set('shuffle', shuffle);
679
+ break;
680
+ }
681
+ case 'RelativeTimePosition':
682
+ // applied after the loop: a track change in the same event resets the anchor
683
+ break;
684
+ case 'CurrentTrackDuration': {
685
+ const dur = parseDuration(value);
686
+ if (dur) {
687
+ this.set('duration', dur);
688
+ }
689
+ break;
690
+ }
691
+ case 'PlaybackStorageMedium': {
692
+ const source = sourceFromMedium(value);
693
+ if (source) {
694
+ this.applySource(source);
695
+ } else {
696
+ quickPoll = true;
697
+ }
698
+ break;
699
+ }
700
+ case 'TrackSource':
701
+ this.state.set('track_source', String(value));
702
+ if (!this.get('origin_source') && value) {
703
+ this.set('origin_source', String(value));
704
+ }
705
+ quickPoll = true;
706
+ break;
707
+ case 'NumberOfTracks':
708
+ this.set('queue_length', Number(value) || 0);
709
+ break;
710
+ case 'CurrentTrack':
711
+ this.set('queue_index', Number(value) || 0);
712
+ break;
713
+ case 'Slave':
714
+ case 'SlaveList':
715
+ this.refresh('group');
716
+ break;
717
+ default:
718
+ this.log.debug('upnp event', service, name, 'ignored');
719
+ }
720
+ }
721
+ if (variables.RelativeTimePosition !== undefined) {
722
+ const pos = parseDuration(variables.RelativeTimePosition);
723
+ if (pos !== null) {
724
+ this.setAnchor(pos);
725
+ }
726
+ }
727
+ if (quickPoll) {
728
+ this.scheduleQuickPoll();
729
+ }
730
+ }
731
+
732
+ /*
733
+ * normalised state helpers
734
+ */
735
+
736
+ applyPlayState(playState) {
737
+ const changed = this.set('play_state', playState);
738
+ if (playState === 'stopped') {
739
+ this.clearTrack();
740
+ }
741
+ if (playState === 'playing') {
742
+ this.startPositionTimer();
743
+ } else {
744
+ this.stopPositionTimer();
745
+ if (changed) {
746
+ this.publishPosition();
747
+ }
748
+ }
749
+ if (changed) {
750
+ this.schedulePoll();
751
+ }
752
+ }
753
+
754
+ applySource(source) {
755
+ const previous = this.get('source');
756
+ if (this.set('source', source) && previous !== undefined) {
757
+ this.clearTrack();
758
+ }
759
+ }
760
+
761
+ applyTrack(track) {
762
+ const key = [track.title, track.artist, track.album, track.uri].join('|');
763
+ const changed = key !== this.trackKey;
764
+ this.trackKey = key;
765
+ this.set('title', track.title);
766
+ this.set('artist', track.artist);
767
+ this.set('album', track.album);
768
+ this.set('album_art', track.albumArt);
769
+ this.set('quality', track.quality);
770
+ this.set('sample_rate', track.sampleRate);
771
+ this.set('bit_depth', track.bitDepth);
772
+ this.set('bitrate', track.bitrate);
773
+ this.set('origin_source', track.originSource || this.state.get('track_source') || '');
774
+ if (track.duration) {
775
+ this.set('duration', track.duration);
776
+ }
777
+ if (changed) {
778
+ this.setAnchor(0);
779
+ this.scheduleMetaInfo();
780
+ }
781
+ this.updateAlbumArt();
782
+ }
783
+
784
+ clearTrack() {
785
+ this.trackKey = '';
786
+ for (const item of TRACK_ITEMS) {
787
+ this.set(item, ['sample_rate', 'bit_depth', 'bitrate'].includes(item) ? null : '');
788
+ }
789
+ this.set('duration', 0);
790
+ this.setAnchor(0);
791
+ this.updateAlbumArt();
792
+ }
793
+
794
+ /** Fill gaps (sample rate, art, …) from getMetaInfo shortly after a track change. */
795
+ scheduleMetaInfo() {
796
+ this.timers.clearTimeout(this.metaTimer);
797
+ this.metaTimer = this.timers.setTimeout(() => {
798
+ this.metaTimer = undefined;
799
+ this.fetchMetaInfo().catch((err) => this.onApiError(err, 'getMetaInfo'));
800
+ }, 1000);
801
+ }
802
+
803
+ async fetchMetaInfo() {
804
+ const meta = await this.api.metaInfo();
805
+ if (!meta || this.get('play_state') === 'stopped') {
806
+ return;
807
+ }
808
+ const fill = (item, value) => {
809
+ if (
810
+ value !== null &&
811
+ value !== '' &&
812
+ (this.get(item) === '' || this.get(item) === null || this.get(item) === undefined)
813
+ ) {
814
+ this.set(item, value);
815
+ }
816
+ };
817
+ fill('title', meta.title);
818
+ fill('artist', meta.artist);
819
+ fill('album', meta.album);
820
+ fill('album_art', meta.albumArt);
821
+ fill('sample_rate', meta.sampleRate);
822
+ fill('bit_depth', meta.bitDepth);
823
+ fill('bitrate', meta.bitrate);
824
+ this.updateAlbumArt();
825
+ }
826
+
827
+ async mirrorMaster() {
828
+ try {
829
+ const status = await this.masterApi.playerStatus();
830
+ const playState = PLAY_STATES[status.status];
831
+ if (playState) {
832
+ this.applyPlayState(playState);
833
+ }
834
+ if (status.totlen > 0) {
835
+ this.set('duration', Math.round(status.totlen / 1000));
836
+ }
837
+ this.setAnchor(Math.round(status.curpos / 1000));
838
+ const meta = await this.masterApi.metaInfo();
839
+ const track = meta
840
+ ? {...EMPTY_TRACK, ...meta, uri: ''}
841
+ : {...EMPTY_TRACK, title: status.title || '', artist: status.artist || '', album: status.album || ''};
842
+ if (track.title) {
843
+ this.applyTrack(track);
844
+ }
845
+ } catch (err) {
846
+ this.log.debug('group: master', this.masterUrl, 'not answering:', err.message);
847
+ }
848
+ }
849
+
850
+ /*
851
+ * position
852
+ */
853
+
854
+ setAnchor(position) {
855
+ this.anchor = {position, at: this.now()};
856
+ this.publishPosition();
857
+ }
858
+
859
+ currentPosition() {
860
+ let position = this.anchor.position;
861
+ if (this.get('play_state') === 'playing' && this.anchor.at) {
862
+ position += Math.floor((this.now() - this.anchor.at) / 1000);
863
+ }
864
+ const duration = this.get('duration');
865
+ if (duration > 0) {
866
+ position = Math.min(position, duration);
867
+ }
868
+ return Math.max(0, position);
869
+ }
870
+
871
+ publishPosition() {
872
+ this.set('position', this.currentPosition(), {retain: false, force: true});
873
+ }
874
+
875
+ startPositionTimer() {
876
+ if (this.positionTimer || this.positionInterval <= 0) {
877
+ return;
878
+ }
879
+ const tick = () => {
880
+ this.publishPosition();
881
+ this.positionTimer = this.timers.setTimeout(tick, this.positionInterval);
882
+ };
883
+ this.positionTimer = this.timers.setTimeout(tick, this.positionInterval);
884
+ }
885
+
886
+ stopPositionTimer() {
887
+ this.timers.clearTimeout(this.positionTimer);
888
+ this.positionTimer = undefined;
889
+ }
890
+
891
+ /*
892
+ * album art bytes
893
+ */
894
+
895
+ updateAlbumArt() {
896
+ if (!this.albumArtData) {
897
+ return;
898
+ }
899
+ const url = this.get('album_art') || '';
900
+ if (url === this.lastArtUrl) {
901
+ return;
902
+ }
903
+ this.lastArtUrl = url;
904
+ if (!url) {
905
+ this.set('album_art_data', null);
906
+ return;
907
+ }
908
+ fetchBytes(url)
909
+ .then((buffer) => {
910
+ if (this.get('album_art') === url) {
911
+ this.set('album_art_data', buffer);
912
+ }
913
+ })
914
+ .catch((err) => this.log.debug('album art fetch failed', url, err.message));
915
+ }
916
+
917
+ /*
918
+ * commands
919
+ */
920
+
921
+ /** Send a command; `target: 'master'` routes it to the group master. */
922
+ async command(cmd, {target} = {}) {
923
+ if (target === 'master') {
924
+ const ip = this.get('group_master');
925
+ if (!ip) {
926
+ throw new Error('group master unknown');
927
+ }
928
+ const api = this.masterApi || this.createApi(`https://${ip}`);
929
+ return api.command(cmd);
930
+ }
931
+ return this.api.command(cmd);
932
+ }
933
+
934
+ /** Re-read the given items 500 ms after a set and warn when nothing changed (W-15). */
935
+ verify(items, before) {
936
+ if (!items || items.length === 0) {
937
+ return;
938
+ }
939
+ const timer = this.timers.setTimeout(async () => {
940
+ this.verifyTimers.delete(timer);
941
+ try {
942
+ await this.pollStatus({quick: true});
943
+ if (items.includes('group_role') || items.includes('group_slaves')) {
944
+ await this.refreshGroup();
945
+ }
946
+ const unchanged = items.filter((i) => JSON.stringify(before[i]) === JSON.stringify(this.get(i)));
947
+ if (unchanged.length === items.length) {
948
+ this.log.warn('set had no visible effect on', items.join(', '));
949
+ }
950
+ } catch (err) {
951
+ this.onApiError(err, 'verify');
952
+ }
953
+ }, 500);
954
+ this.verifyTimers.add(timer);
955
+ }
956
+
957
+ onApiError(err, context) {
958
+ if (err && (err.code === 'ECONNFAILED' || err.code === 'ETIMEDOUT')) {
959
+ this.failures++;
960
+ if (this.failures >= 2) {
961
+ this.setConnected(false, err);
962
+ } else {
963
+ this.log.debug(context, err.message);
964
+ }
965
+ this.schedulePoll();
966
+ return;
967
+ }
968
+ this.log.warn(context, 'failed:', err && err.message ? err.message : err);
969
+ }
970
+ }
971
+
972
+ /** Fetch a small binary resource (album art); TLS verification off because WiiM serves art from its self-signed host. */
973
+ export function fetchBytes(url, {maxBytes = 2 * 1024 * 1024, timeout = 10000} = {}) {
974
+ return new Promise((resolve, reject) => {
975
+ const transport = url.startsWith('https:') ? https : http;
976
+ const req = transport.get(url, {rejectUnauthorized: false, timeout}, (res) => {
977
+ if (res.statusCode !== 200) {
978
+ res.resume();
979
+ reject(new Error(`http ${res.statusCode}`));
980
+ return;
981
+ }
982
+ const chunks = [];
983
+ let size = 0;
984
+ res.on('data', (c) => {
985
+ size += c.length;
986
+ if (size > maxBytes) {
987
+ req.destroy(new Error('too large'));
988
+ return;
989
+ }
990
+ chunks.push(c);
991
+ });
992
+ res.on('end', () => resolve(Buffer.concat(chunks)));
993
+ res.on('error', reject);
994
+ });
995
+ req.on('timeout', () => req.destroy(new Error('timeout')));
996
+ req.on('error', reject);
997
+ });
998
+ }