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/LICENSE +21 -0
- package/README.md +223 -0
- package/config.js +153 -0
- package/index.js +386 -0
- package/lib/api.js +249 -0
- package/lib/commands.js +332 -0
- package/lib/device.js +998 -0
- package/lib/didl.js +272 -0
- package/lib/hadiscovery.js +190 -0
- package/lib/install.js +251 -0
- package/lib/log.js +74 -0
- package/lib/payload.js +112 -0
- package/lib/upnp.js +535 -0
- package/package.json +60 -0
package/lib/commands.js
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure mapping of friendly items to device commands and of device values to friendly values.
|
|
3
|
+
* Nothing in here talks to the network.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {toBoolean, toVolume} from './payload.js';
|
|
7
|
+
|
|
8
|
+
/** http getPlayerStatus.status → play_state */
|
|
9
|
+
export const PLAY_STATES = {
|
|
10
|
+
play: 'playing',
|
|
11
|
+
pause: 'paused',
|
|
12
|
+
stop: 'stopped',
|
|
13
|
+
loading: 'loading',
|
|
14
|
+
load: 'loading',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** upnp TransportState → play_state */
|
|
18
|
+
export const TRANSPORT_STATES = {
|
|
19
|
+
PLAYING: 'playing',
|
|
20
|
+
PAUSED_PLAYBACK: 'paused',
|
|
21
|
+
STOPPED: 'stopped',
|
|
22
|
+
TRANSITIONING: 'loading',
|
|
23
|
+
NO_MEDIA_PRESENT: 'stopped',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Sources. mode = getPlayerStatus.mode, switchmode = setPlayerCmd:switchmode argument,
|
|
28
|
+
* medium = upnp PlaybackStorageMedium values.
|
|
29
|
+
*/
|
|
30
|
+
export const SOURCES = [
|
|
31
|
+
{mode: 0, name: 'none'},
|
|
32
|
+
{mode: 1, name: 'airplay'},
|
|
33
|
+
{mode: 2, name: 'dlna'},
|
|
34
|
+
{mode: 10, name: 'wifi', switchmode: 'wifi'},
|
|
35
|
+
{mode: 11, name: 'usb', switchmode: 'udisk', medium: ['USB', 'UDISK']},
|
|
36
|
+
{mode: 16, name: 'sdcard'},
|
|
37
|
+
{mode: 20, name: 'api'},
|
|
38
|
+
{mode: 31, name: 'spotify'},
|
|
39
|
+
{mode: 32, name: 'tidal'},
|
|
40
|
+
{mode: 40, name: 'line_in', switchmode: 'line-in', medium: ['LINE-IN', 'RCA', 'AUX']},
|
|
41
|
+
{mode: 41, name: 'bluetooth', switchmode: 'bluetooth', medium: ['BLUETOOTH']},
|
|
42
|
+
{mode: 43, name: 'optical', switchmode: 'optical', medium: ['OPTICAL']},
|
|
43
|
+
{mode: 99, name: 'multiroom'},
|
|
44
|
+
{name: 'hdmi', switchmode: 'hdmi', medium: ['HDMI']},
|
|
45
|
+
{name: 'coaxial', switchmode: 'coaxial', medium: ['COAXIAL', 'COAX']},
|
|
46
|
+
{name: 'phono', switchmode: 'phono', medium: ['PHONO']},
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
/** switchable sources (the default source_list) */
|
|
50
|
+
export const SWITCHABLE_SOURCES = SOURCES.filter((s) => s.switchmode).map((s) => s.name);
|
|
51
|
+
|
|
52
|
+
export function sourceFromMode(mode) {
|
|
53
|
+
const n = Number(mode);
|
|
54
|
+
const source = SOURCES.find((s) => s.mode === n);
|
|
55
|
+
return source ? source.name : `mode_${n}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function sourceFromMedium(medium) {
|
|
59
|
+
if (!medium) {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
const m = String(medium).toUpperCase();
|
|
63
|
+
const source = SOURCES.find((s) => s.medium && s.medium.includes(m));
|
|
64
|
+
return source ? source.name : undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function switchmodeFor(sourceName) {
|
|
68
|
+
const source = SOURCES.find((s) => s.name === String(sourceName).toLowerCase());
|
|
69
|
+
return source && source.switchmode;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** read value (getPlayerStatus.loop / upnp LoopMode) → {repeat, shuffle} */
|
|
73
|
+
export const LOOP = {
|
|
74
|
+
0: {repeat: 'all', shuffle: false},
|
|
75
|
+
1: {repeat: 'one', shuffle: false},
|
|
76
|
+
2: {repeat: 'all', shuffle: true},
|
|
77
|
+
3: {repeat: 'off', shuffle: true},
|
|
78
|
+
4: {repeat: 'off', shuffle: false},
|
|
79
|
+
5: {repeat: 'one', shuffle: true},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** {repeat, shuffle} → setPlayerCmd:loopmode:<n> */
|
|
83
|
+
export const LOOPMODE_SET = {
|
|
84
|
+
'all:false': -1,
|
|
85
|
+
'one:false': 1,
|
|
86
|
+
'all:true': 2,
|
|
87
|
+
'off:true': 3,
|
|
88
|
+
'off:false': 0,
|
|
89
|
+
'one:true': 5,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export const REPEAT_MODES = ['off', 'one', 'all'];
|
|
93
|
+
|
|
94
|
+
export function loopToRepeatShuffle(loop) {
|
|
95
|
+
return LOOP[Number(loop)] || {repeat: 'off', shuffle: false};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function loopmodeFor(repeat, shuffle) {
|
|
99
|
+
const key = `${repeat}:${Boolean(shuffle)}`;
|
|
100
|
+
if (!(key in LOOPMODE_SET)) {
|
|
101
|
+
throw new Error(`unsupported repeat/shuffle combination ${key}`);
|
|
102
|
+
}
|
|
103
|
+
return LOOPMODE_SET[key];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** friendly items that can be set; everything else on set/ is rejected */
|
|
107
|
+
export const SET_ITEMS = [
|
|
108
|
+
'volume',
|
|
109
|
+
'volume_up',
|
|
110
|
+
'volume_down',
|
|
111
|
+
'mute',
|
|
112
|
+
'play',
|
|
113
|
+
'pause',
|
|
114
|
+
'stop',
|
|
115
|
+
'next',
|
|
116
|
+
'prev',
|
|
117
|
+
'toggle',
|
|
118
|
+
'play_state',
|
|
119
|
+
'seek',
|
|
120
|
+
'position',
|
|
121
|
+
'source',
|
|
122
|
+
'repeat',
|
|
123
|
+
'shuffle',
|
|
124
|
+
'preset',
|
|
125
|
+
'join',
|
|
126
|
+
'leave',
|
|
127
|
+
'ungroup',
|
|
128
|
+
'kick',
|
|
129
|
+
'reboot',
|
|
130
|
+
'cmd',
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
/** items that accept an empty payload */
|
|
134
|
+
export const OPTIONAL_PAYLOAD = new Set([
|
|
135
|
+
'volume_up',
|
|
136
|
+
'volume_down',
|
|
137
|
+
'play',
|
|
138
|
+
'pause',
|
|
139
|
+
'stop',
|
|
140
|
+
'next',
|
|
141
|
+
'prev',
|
|
142
|
+
'toggle',
|
|
143
|
+
'leave',
|
|
144
|
+
'ungroup',
|
|
145
|
+
'reboot',
|
|
146
|
+
]);
|
|
147
|
+
|
|
148
|
+
function int(value) {
|
|
149
|
+
const n = typeof value === 'number' ? value : Number(String(value).trim());
|
|
150
|
+
return Number.isFinite(n) ? Math.round(n) : undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** seconds from a number, "mm:ss" or "h:mm:ss" */
|
|
154
|
+
export function toSeconds(value) {
|
|
155
|
+
if (typeof value === 'number') {
|
|
156
|
+
return Number.isFinite(value) && value >= 0 ? Math.round(value) : undefined;
|
|
157
|
+
}
|
|
158
|
+
const text = String(value).trim();
|
|
159
|
+
if (/^\d+(\.\d+)?$/.test(text)) {
|
|
160
|
+
return Math.round(Number(text));
|
|
161
|
+
}
|
|
162
|
+
if (/^\d+(:\d{1,2}){1,2}$/.test(text)) {
|
|
163
|
+
return text.split(':').reduce((acc, p) => acc * 60 + Number(p), 0);
|
|
164
|
+
}
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function findPreset(list, value) {
|
|
169
|
+
const n = int(value);
|
|
170
|
+
if (n !== undefined) {
|
|
171
|
+
return n;
|
|
172
|
+
}
|
|
173
|
+
const name = String(value).trim().toLowerCase();
|
|
174
|
+
const preset = (Array.isArray(list) ? list : []).find((p) => String(p.name).toLowerCase() === name);
|
|
175
|
+
return preset ? preset.number : undefined;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function isIp(value) {
|
|
179
|
+
return /^\d{1,3}(\.\d{1,3}){3}$/.test(String(value).trim());
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Map set/<item> with a parsed payload to a command.
|
|
184
|
+
* @param {string} item
|
|
185
|
+
* @param {*} value parsed payload (parsePayload)
|
|
186
|
+
* @param {(item: string) => *} get last known friendly value
|
|
187
|
+
* @returns {{api?: string, target?: 'master', verify?: string[], noop?: true}}
|
|
188
|
+
* @throws Error with a human-readable reason when the payload is invalid
|
|
189
|
+
*/
|
|
190
|
+
export function commandFor(item, value, get) {
|
|
191
|
+
switch (item) {
|
|
192
|
+
case 'volume': {
|
|
193
|
+
const v = toVolume(value);
|
|
194
|
+
if (v === undefined) {
|
|
195
|
+
throw new Error(`invalid volume ${JSON.stringify(value)}`);
|
|
196
|
+
}
|
|
197
|
+
return {api: `setPlayerCmd:vol:${v}`, verify: ['volume']};
|
|
198
|
+
}
|
|
199
|
+
case 'volume_up':
|
|
200
|
+
case 'volume_down': {
|
|
201
|
+
const step = value === undefined || value === true || value === '' ? 5 : int(value);
|
|
202
|
+
if (step === undefined || step < 1) {
|
|
203
|
+
throw new Error(`invalid step ${JSON.stringify(value)}`);
|
|
204
|
+
}
|
|
205
|
+
const current = int(get('volume'));
|
|
206
|
+
if (current === undefined) {
|
|
207
|
+
throw new Error('current volume unknown');
|
|
208
|
+
}
|
|
209
|
+
const v = toVolume(item === 'volume_up' ? current + step : current - step);
|
|
210
|
+
return {api: `setPlayerCmd:vol:${v}`, verify: ['volume']};
|
|
211
|
+
}
|
|
212
|
+
case 'mute': {
|
|
213
|
+
const b = toBoolean(value);
|
|
214
|
+
if (b === undefined) {
|
|
215
|
+
throw new Error(`invalid mute ${JSON.stringify(value)}`);
|
|
216
|
+
}
|
|
217
|
+
return {api: `setPlayerCmd:mute:${b ? 1 : 0}`, verify: ['mute']};
|
|
218
|
+
}
|
|
219
|
+
case 'play': {
|
|
220
|
+
if (typeof value === 'string' && /^[a-z][a-z0-9+.-]*:\/\//i.test(value)) {
|
|
221
|
+
return {api: `setPlayerCmd:play:${value}`, verify: ['play_state', 'title']};
|
|
222
|
+
}
|
|
223
|
+
return {api: 'setPlayerCmd:resume', verify: ['play_state']};
|
|
224
|
+
}
|
|
225
|
+
case 'pause':
|
|
226
|
+
return {api: 'setPlayerCmd:pause', verify: ['play_state']};
|
|
227
|
+
case 'stop':
|
|
228
|
+
return {api: 'setPlayerCmd:stop', verify: ['play_state']};
|
|
229
|
+
case 'next':
|
|
230
|
+
return {api: 'setPlayerCmd:next', verify: ['title']};
|
|
231
|
+
case 'prev':
|
|
232
|
+
return {api: 'setPlayerCmd:prev', verify: ['title']};
|
|
233
|
+
case 'toggle':
|
|
234
|
+
return {api: 'setPlayerCmd:onepause', verify: ['play_state']};
|
|
235
|
+
case 'play_state': {
|
|
236
|
+
const state = String(value).toLowerCase();
|
|
237
|
+
const api = {
|
|
238
|
+
playing: 'resume',
|
|
239
|
+
play: 'resume',
|
|
240
|
+
paused: 'pause',
|
|
241
|
+
pause: 'pause',
|
|
242
|
+
stopped: 'stop',
|
|
243
|
+
stop: 'stop',
|
|
244
|
+
}[state];
|
|
245
|
+
if (!api) {
|
|
246
|
+
throw new Error(`invalid play_state ${JSON.stringify(value)} (playing|paused|stopped)`);
|
|
247
|
+
}
|
|
248
|
+
return {api: `setPlayerCmd:${api}`, verify: ['play_state']};
|
|
249
|
+
}
|
|
250
|
+
case 'seek':
|
|
251
|
+
case 'position': {
|
|
252
|
+
const s = toSeconds(value);
|
|
253
|
+
if (s === undefined) {
|
|
254
|
+
throw new Error(`invalid position ${JSON.stringify(value)} (seconds or mm:ss)`);
|
|
255
|
+
}
|
|
256
|
+
return {api: `setPlayerCmd:seek:${s}`, verify: ['position']};
|
|
257
|
+
}
|
|
258
|
+
case 'source': {
|
|
259
|
+
const switchmode = switchmodeFor(value);
|
|
260
|
+
if (!switchmode) {
|
|
261
|
+
throw new Error(
|
|
262
|
+
`source ${JSON.stringify(value)} cannot be selected (${SWITCHABLE_SOURCES.join(', ')})`,
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
return {api: `setPlayerCmd:switchmode:${switchmode}`, verify: ['source']};
|
|
266
|
+
}
|
|
267
|
+
case 'repeat': {
|
|
268
|
+
const repeat = String(value).toLowerCase();
|
|
269
|
+
if (!REPEAT_MODES.includes(repeat)) {
|
|
270
|
+
throw new Error(`invalid repeat ${JSON.stringify(value)} (off|one|all)`);
|
|
271
|
+
}
|
|
272
|
+
return {api: `setPlayerCmd:loopmode:${loopmodeFor(repeat, get('shuffle'))}`, verify: ['repeat', 'shuffle']};
|
|
273
|
+
}
|
|
274
|
+
case 'shuffle': {
|
|
275
|
+
const shuffle = toBoolean(value);
|
|
276
|
+
if (shuffle === undefined) {
|
|
277
|
+
throw new Error(`invalid shuffle ${JSON.stringify(value)}`);
|
|
278
|
+
}
|
|
279
|
+
const repeat = REPEAT_MODES.includes(get('repeat')) ? get('repeat') : 'off';
|
|
280
|
+
return {api: `setPlayerCmd:loopmode:${loopmodeFor(repeat, shuffle)}`, verify: ['repeat', 'shuffle']};
|
|
281
|
+
}
|
|
282
|
+
case 'preset': {
|
|
283
|
+
const n = findPreset(get('preset_list'), value);
|
|
284
|
+
const max = int(get('preset_max')) || 12;
|
|
285
|
+
if (n === undefined) {
|
|
286
|
+
throw new Error(`unknown preset ${JSON.stringify(value)}`);
|
|
287
|
+
}
|
|
288
|
+
if (n < 1 || n > max) {
|
|
289
|
+
throw new Error(`preset ${n} out of range 1..${max}`);
|
|
290
|
+
}
|
|
291
|
+
return {api: `MCUKeyShortClick:${n}`, verify: ['title', 'source', 'play_state']};
|
|
292
|
+
}
|
|
293
|
+
case 'join': {
|
|
294
|
+
const ip = String(value).trim();
|
|
295
|
+
if (!isIp(ip)) {
|
|
296
|
+
throw new Error(`join needs the master's ip address, got ${JSON.stringify(value)}`);
|
|
297
|
+
}
|
|
298
|
+
return {api: `ConnectMasterAp:JoinGroupMaster:eth${ip}:wifi0.0.0.0`, verify: ['group_role']};
|
|
299
|
+
}
|
|
300
|
+
case 'leave': {
|
|
301
|
+
const master = get('group_master');
|
|
302
|
+
const ip = get('ip');
|
|
303
|
+
if (!master || !ip) {
|
|
304
|
+
throw new Error('not in a group (group_master/ip unknown)');
|
|
305
|
+
}
|
|
306
|
+
return {api: `multiroom:SlaveKickout:${ip}`, target: 'master', verify: ['group_role']};
|
|
307
|
+
}
|
|
308
|
+
case 'ungroup':
|
|
309
|
+
return {api: 'multiroom:Ungroup', verify: ['group_slaves', 'group_role']};
|
|
310
|
+
case 'kick': {
|
|
311
|
+
const slaves = Array.isArray(get('group_slaves')) ? get('group_slaves') : [];
|
|
312
|
+
const text = String(value).trim();
|
|
313
|
+
const slave = isIp(text)
|
|
314
|
+
? {ip: text}
|
|
315
|
+
: slaves.find((s) => String(s.name).toLowerCase() === text.toLowerCase());
|
|
316
|
+
if (!slave) {
|
|
317
|
+
throw new Error(`unknown slave ${JSON.stringify(value)}`);
|
|
318
|
+
}
|
|
319
|
+
return {api: `multiroom:SlaveKickout:${slave.ip}`, verify: ['group_slaves']};
|
|
320
|
+
}
|
|
321
|
+
case 'reboot': {
|
|
322
|
+
if (value !== undefined && toBoolean(value) === false) {
|
|
323
|
+
return {noop: true};
|
|
324
|
+
}
|
|
325
|
+
return {api: 'reboot'};
|
|
326
|
+
}
|
|
327
|
+
case 'cmd':
|
|
328
|
+
return {api: String(value), raw: true};
|
|
329
|
+
default:
|
|
330
|
+
throw new Error(`unknown item ${item}`);
|
|
331
|
+
}
|
|
332
|
+
}
|