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/index.js ADDED
@@ -0,0 +1,386 @@
1
+ #!/usr/bin/env node
2
+
3
+ import os from 'node:os';
4
+ import mqttLib from 'mqtt';
5
+ import log from './lib/log.js';
6
+ import {parseConfig} from './config.js';
7
+ import pkg from './package.json' with {type: 'json'};
8
+ import {parsePayload, StatusTracker} from './lib/payload.js';
9
+ import {commandFor, OPTIONAL_PAYLOAD} from './lib/commands.js';
10
+ import {buildDiscovery} from './lib/hadiscovery.js';
11
+ import {HttpApi} from './lib/api.js';
12
+ import {UpnpClient} from './lib/upnp.js';
13
+ import {Device} from './lib/device.js';
14
+
15
+ const config = parseConfig();
16
+
17
+ if (config.install || config.uninstall) {
18
+ const {installService, uninstallService} = await import('./lib/install.js');
19
+ const plain = (...args) => console.log(...args);
20
+ try {
21
+ if (config.uninstall) {
22
+ uninstallService(config, plain);
23
+ } else {
24
+ installService(config, plain);
25
+ }
26
+ process.exit(0);
27
+ } catch (err) {
28
+ console.error('error:', err.message);
29
+ process.exit(1);
30
+ }
31
+ }
32
+
33
+ if (config.probe) {
34
+ const api = new HttpApi({baseUrl: config.apiUrl});
35
+ try {
36
+ const raw = await api.statusEx();
37
+ console.log(
38
+ JSON.stringify(
39
+ {
40
+ name: raw.DeviceName,
41
+ model: raw.project || raw.hardware,
42
+ hardware: raw.hardware,
43
+ firmware: raw.firmware,
44
+ uuid: raw.uuid,
45
+ mac: raw.MAC,
46
+ ip: raw.eth0 && raw.eth0 !== '0.0.0.0' ? raw.eth0 : raw.apcli0,
47
+ group: raw.group,
48
+ },
49
+ null,
50
+ 2,
51
+ ),
52
+ );
53
+ process.exit(0);
54
+ } catch (err) {
55
+ console.error('error:', err.message);
56
+ process.exit(1);
57
+ }
58
+ }
59
+
60
+ const topicPrefix = config.name;
61
+ const connectedTopic = topicPrefix + '/connected';
62
+
63
+ let mqttConnected = false;
64
+ let shuttingDown = false;
65
+ const startedAt = Date.now();
66
+
67
+ /** last known friendly values, also produces plain or {val, ts, lc} payloads */
68
+ const status = new StatusTracker({json: config.jsonPayloads});
69
+
70
+ /** items whose change requires a new discovery payload (options / device info) */
71
+ const DISCOVERY_TRIGGERS = new Set(['source_list', 'preset_list', 'model', 'firmware', 'uuid', 'mac', 'ip']);
72
+ /** internal items that are not published */
73
+ const INTERNAL_ITEMS = new Set(['group_role_hint', 'track_source']);
74
+ let discoveryDirty = true;
75
+
76
+ log.setLevel(config.verbosity);
77
+
78
+ log.info(pkg.name + ' ' + pkg.version + ' starting');
79
+ log.info('mqtt trying to connect', config.mqttUrl);
80
+
81
+ const mqtt = mqttLib.connect(config.mqttUrl, {
82
+ clientId: config.name + '_' + Math.random().toString(16).slice(2, 10),
83
+ username: config.mqttUsername,
84
+ password: config.mqttPassword,
85
+ will: {topic: connectedTopic, payload: '0', retain: true},
86
+ });
87
+
88
+ const api = new HttpApi({baseUrl: config.apiUrl, log});
89
+ const upnp = config.upnp
90
+ ? new UpnpClient({
91
+ host: config.address,
92
+ port: config.upnpPort,
93
+ callbackHost: config.callbackHost,
94
+ callbackPort: config.callbackPort,
95
+ log,
96
+ })
97
+ : null;
98
+ const device = new Device({
99
+ api,
100
+ upnp,
101
+ pollInterval: config.pollInterval,
102
+ positionInterval: config.positionInterval,
103
+ albumArtData: config.albumArtData,
104
+ log,
105
+ });
106
+
107
+ /*
108
+ * MQTT publishing
109
+ */
110
+
111
+ function mqttPub(topic, payload, options) {
112
+ if (Buffer.isBuffer(payload)) {
113
+ log.debug('mqtt >', topic, `<${payload.length} bytes>`);
114
+ mqtt.publish(topic, payload, options);
115
+ return;
116
+ }
117
+ if (payload !== null && typeof payload === 'object') {
118
+ payload = JSON.stringify(payload);
119
+ }
120
+ log.debug('mqtt >', topic, payload);
121
+ mqtt.publish(topic, String(payload), options);
122
+ }
123
+
124
+ function publishConnected() {
125
+ if (!mqttConnected) {
126
+ return;
127
+ }
128
+ mqttPub(connectedTopic, device.connected ? '2' : '1', {retain: true});
129
+ }
130
+
131
+ /** Publish a friendly status item; tracks last values for discovery and json payloads. */
132
+ function pubStatus(item, value, {retain = true} = {}) {
133
+ if (INTERNAL_ITEMS.has(item)) {
134
+ return;
135
+ }
136
+ if (item === 'album_art_data') {
137
+ if (mqttConnected) {
138
+ mqttPub(`${topicPrefix}/status/${item}`, value || Buffer.alloc(0), {retain: true});
139
+ }
140
+ return;
141
+ }
142
+ const {payload, changed} = status.update(item, value === null ? '' : value);
143
+ if (mqttConnected) {
144
+ mqttPub(`${topicPrefix}/status/${item}`, payload, {retain});
145
+ }
146
+ if (changed && DISCOVERY_TRIGGERS.has(item)) {
147
+ discoveryDirty = true;
148
+ publishDiscoveryIfDirty();
149
+ }
150
+ return changed;
151
+ }
152
+
153
+ /** Re-publish every known status (after an mqtt reconnect). */
154
+ function republishStatus() {
155
+ for (const [item, entry] of status.state) {
156
+ if (item === 'position') {
157
+ continue;
158
+ }
159
+ mqttPub(`${topicPrefix}/status/${item}`, config.jsonPayloads ? entry : entry.val, {retain: true});
160
+ }
161
+ }
162
+
163
+ function publishInfo() {
164
+ if (!mqttConnected) {
165
+ return;
166
+ }
167
+ mqttPub(
168
+ `${topicPrefix}/info`,
169
+ {
170
+ name: pkg.name,
171
+ version: pkg.version,
172
+ node: process.version,
173
+ host: os.hostname(),
174
+ pid: process.pid,
175
+ started: new Date(startedAt).toISOString(),
176
+ uptime: Math.round((Date.now() - startedAt) / 1000),
177
+ address: config.address,
178
+ device: {
179
+ name: device.get('device_name'),
180
+ model: device.get('model'),
181
+ firmware: device.get('firmware'),
182
+ uuid: device.get('uuid'),
183
+ mac: device.get('mac'),
184
+ ip: device.get('ip'),
185
+ upnp: device.mode,
186
+ },
187
+ },
188
+ {retain: true},
189
+ );
190
+ }
191
+
192
+ function publishDiscoveryIfDirty() {
193
+ if (!config.haDiscovery || !discoveryDirty || !mqttConnected) {
194
+ return;
195
+ }
196
+ discoveryDirty = false;
197
+ const {topic, payload} = buildDiscovery({
198
+ name: config.name,
199
+ prefix: config.haPrefix,
200
+ get: (item) => status.get(item),
201
+ pkg,
202
+ jsonPayloads: config.jsonPayloads,
203
+ albumArtData: config.albumArtData,
204
+ });
205
+ log.info('mqtt publishing home assistant discovery', topic);
206
+ mqttPub(topic, payload, {retain: true});
207
+ }
208
+
209
+ function clearDiscovery() {
210
+ const {topic} = buildDiscovery({name: config.name, prefix: config.haPrefix, get: () => undefined, pkg});
211
+ mqttPub(topic, '', {retain: true});
212
+ }
213
+
214
+ mqtt.on('connect', () => {
215
+ const reconnect = mqttConnected;
216
+ mqttConnected = true;
217
+ log.info('mqtt connected', config.mqttUrl);
218
+ publishConnected();
219
+ publishInfo();
220
+
221
+ for (const topic of [topicPrefix + '/set/#', topicPrefix + '/get/#']) {
222
+ log.info('mqtt subscribe', topic);
223
+ mqtt.subscribe(topic);
224
+ }
225
+
226
+ if (config.haDiscovery) {
227
+ discoveryDirty = true;
228
+ publishDiscoveryIfDirty();
229
+ } else {
230
+ clearDiscovery();
231
+ }
232
+ if (reconnect || status.state.size > 0) {
233
+ republishStatus();
234
+ }
235
+ });
236
+
237
+ mqtt.on('close', () => {
238
+ if (mqttConnected) {
239
+ mqttConnected = false;
240
+ log.info('mqtt closed', config.mqttUrl);
241
+ }
242
+ });
243
+
244
+ mqtt.on('error', (err) => {
245
+ log.error('mqtt', err.message || err);
246
+ });
247
+
248
+ mqtt.on('message', (topic, payload) => {
249
+ payload = payload.toString();
250
+ log.debug('mqtt <', topic, payload);
251
+
252
+ const [prefix, action, ...parts] = topic.split('/');
253
+ if (prefix !== topicPrefix || !['set', 'get'].includes(action) || parts.length !== 1 || parts[0] === '') {
254
+ log.warn('mqtt ignoring unexpected topic', topic);
255
+ return;
256
+ }
257
+ const item = parts[0];
258
+ const value = parsePayload(payload);
259
+
260
+ if (action === 'get') {
261
+ handleGet(item).catch((err) => log.warn('get', item, 'failed:', err.message || err));
262
+ return;
263
+ }
264
+ handleSet(item, value, topic).catch((err) => {
265
+ log.warn('set', item, JSON.stringify(value), 'failed:', err.message || err);
266
+ });
267
+ });
268
+
269
+ const GET_MAP = {
270
+ status: 'status',
271
+ presets: 'presets',
272
+ preset_list: 'presets',
273
+ device: 'device',
274
+ group: 'group',
275
+ group_slaves: 'group',
276
+ metadata: 'metadata',
277
+ };
278
+
279
+ async function handleGet(item) {
280
+ const what = GET_MAP[item];
281
+ if (!what) {
282
+ log.warn('get', item, '- unknown (status, presets, device, group, metadata)');
283
+ return;
284
+ }
285
+ await device.refresh(what);
286
+ }
287
+
288
+ async function handleSet(item, value, topic) {
289
+ if (value === undefined && !OPTIONAL_PAYLOAD.has(item)) {
290
+ log.warn('mqtt ignoring empty payload on', topic);
291
+ return;
292
+ }
293
+ if (item === 'cmd' && !config.rawSet) {
294
+ log.warn('mqtt ignoring', topic, '(raw commands disabled, see --raw-set)');
295
+ return;
296
+ }
297
+ if (!device.connected) {
298
+ log.warn('set', item, '- device unreachable');
299
+ return;
300
+ }
301
+
302
+ let command;
303
+ try {
304
+ command = commandFor(item, value, (i) => device.get(i));
305
+ } catch (err) {
306
+ log.warn('set', item, JSON.stringify(value), '-', err.message);
307
+ return;
308
+ }
309
+ if (command.noop) {
310
+ return;
311
+ }
312
+
313
+ const before = Object.fromEntries((command.verify || []).map((i) => [i, device.get(i)]));
314
+ const result = await device.command(command.api, {target: command.target});
315
+ log.debug('set', item, '→', command.api, '=', typeof result === 'object' ? JSON.stringify(result) : result);
316
+ if (command.raw) {
317
+ pubStatus('cmd_result', typeof result === 'object' ? JSON.stringify(result) : String(result), {retain: false});
318
+ return;
319
+ }
320
+ if (typeof result === 'string' && !/^ok$/i.test(result) && result !== '') {
321
+ log.warn('set', item, '-', command.api, 'answered', result);
322
+ }
323
+ device.verify(command.verify, before);
324
+ }
325
+
326
+ /*
327
+ * device → mqtt
328
+ */
329
+
330
+ device.on('connected', (connected) => {
331
+ publishConnected();
332
+ publishInfo();
333
+ if (!connected) {
334
+ pubStatus('play_state', 'stopped');
335
+ }
336
+ });
337
+
338
+ device.on('change', (item, value, {retain = true} = {}) => {
339
+ pubStatus(item, value, {retain});
340
+ });
341
+
342
+ device.on('upnp', () => publishInfo());
343
+
344
+ const infoTimer = setInterval(publishInfo, 60000);
345
+
346
+ device.start().catch((err) => {
347
+ log.error('device start failed:', err.message || err);
348
+ });
349
+
350
+ /*
351
+ * shutdown
352
+ */
353
+
354
+ function shutdown(signal) {
355
+ if (shuttingDown) {
356
+ return;
357
+ }
358
+ shuttingDown = true;
359
+ log.info('received', signal, '- shutting down');
360
+ clearInterval(infoTimer);
361
+
362
+ const exit = () => process.exit(0);
363
+ const timer = setTimeout(exit, 3000);
364
+
365
+ device
366
+ .stop()
367
+ .catch(() => {})
368
+ .then(() => {
369
+ if (mqttConnected) {
370
+ mqtt.publish(connectedTopic, '0', {retain: true}, () => {
371
+ mqtt.end(false, {}, () => {
372
+ clearTimeout(timer);
373
+ exit();
374
+ });
375
+ });
376
+ } else {
377
+ mqtt.end(true, {}, () => {
378
+ clearTimeout(timer);
379
+ exit();
380
+ });
381
+ }
382
+ });
383
+ }
384
+
385
+ process.on('SIGINT', () => shutdown('SIGINT'));
386
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
package/lib/api.js ADDED
@@ -0,0 +1,249 @@
1
+ /**
2
+ * WiiM / LinkPlay HTTP API client: https://<ip>/httpapi.asp?command=<cmd>
3
+ *
4
+ * - GET only (also for setters), self-signed certificate → TLS verification off.
5
+ * - Getters answer JSON with every number as a string, setters mostly a bare "OK",
6
+ * some commands {"status":"OK"|"Failed"} or plain words.
7
+ * - Title/Artist/Album in getPlayerStatus are hex-encoded UTF-8.
8
+ * - Requests are serialised per device (one socket, one in flight).
9
+ */
10
+
11
+ import http from 'node:http';
12
+ import https from 'node:https';
13
+
14
+ export class ApiError extends Error {
15
+ constructor(code, message, extra = {}) {
16
+ super(message);
17
+ this.name = 'ApiError';
18
+ this.code = code;
19
+ Object.assign(this, extra);
20
+ }
21
+ }
22
+
23
+ /** Decode a LinkPlay hex string (UTF-8); returns the input when it is not hex. */
24
+ export function decodeHex(value) {
25
+ if (typeof value !== 'string' || value.length === 0 || value.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(value)) {
26
+ return value;
27
+ }
28
+ try {
29
+ const decoded = Buffer.from(value, 'hex').toString('utf8');
30
+ return decoded.includes('�') ? value : decoded;
31
+ } catch {
32
+ return value;
33
+ }
34
+ }
35
+
36
+ /** Encode a string as LinkPlay hex (for hex_playlist and friends). */
37
+ export function encodeHex(value) {
38
+ return Buffer.from(String(value), 'utf8').toString('hex');
39
+ }
40
+
41
+ /** Normalise a raw response body: JSON when it looks like JSON, otherwise the trimmed string. */
42
+ export function parseResponse(body) {
43
+ const text = String(body).trim();
44
+ if (text.startsWith('{') || text.startsWith('[')) {
45
+ try {
46
+ return JSON.parse(text);
47
+ } catch {
48
+ return text;
49
+ }
50
+ }
51
+ return text;
52
+ }
53
+
54
+ function num(value, fallback = 0) {
55
+ const n = Number(value);
56
+ return Number.isFinite(n) ? n : fallback;
57
+ }
58
+
59
+ /** Normalise getPlayerStatus: numbers as numbers, hex strings decoded. */
60
+ export function normalizePlayerStatus(raw) {
61
+ if (!raw || typeof raw !== 'object') {
62
+ throw new ApiError('EFORMAT', 'getPlayerStatus: unexpected response ' + JSON.stringify(raw));
63
+ }
64
+ return {
65
+ status: String(raw.status || ''),
66
+ mode: num(raw.mode),
67
+ loop: num(raw.loop),
68
+ eq: num(raw.eq),
69
+ curpos: num(raw.curpos),
70
+ totlen: num(raw.totlen),
71
+ plicount: num(raw.plicount),
72
+ plicurr: num(raw.plicurr),
73
+ vol: num(raw.vol),
74
+ mute: num(raw.mute) === 1,
75
+ type: num(raw.type),
76
+ title: raw.Title === undefined ? undefined : decodeHex(String(raw.Title)),
77
+ artist: raw.Artist === undefined ? undefined : decodeHex(String(raw.Artist)),
78
+ album: raw.Album === undefined ? undefined : decodeHex(String(raw.Album)),
79
+ };
80
+ }
81
+
82
+ /** Normalise getMetaInfo: returns null when the device has no metadata. */
83
+ export function normalizeMetaInfo(raw) {
84
+ const meta = raw && typeof raw === 'object' && raw.metaData;
85
+ if (!meta || typeof meta !== 'object') {
86
+ return null;
87
+ }
88
+ const str = (v) => (v === undefined || v === null || v === 'unknow' || v === 'unknown' ? '' : String(v));
89
+ const int = (v) => (v === undefined || v === null || v === '' || v === 'unknow' ? null : num(v, null));
90
+ return {
91
+ title: str(meta.title),
92
+ artist: str(meta.artist),
93
+ album: str(meta.album),
94
+ albumArt: str(meta.albumArtURI),
95
+ sampleRate: int(meta.sampleRate),
96
+ bitDepth: int(meta.bitDepth),
97
+ bitrate: int(meta.bitRate),
98
+ };
99
+ }
100
+
101
+ function unknow(value) {
102
+ return value === undefined || value === null || value === 'unknow' || value === 'unknown' ? '' : String(value);
103
+ }
104
+
105
+ /** Normalise getPresetInfo → [{number, name, url, source, pic}] */
106
+ export function normalizePresetInfo(raw) {
107
+ const list = raw && Array.isArray(raw.preset_list) ? raw.preset_list : [];
108
+ return list
109
+ .map((p) => ({
110
+ number: num(p.number),
111
+ name: String(p.name || ''),
112
+ url: unknow(p.url),
113
+ source: String(p.source || ''),
114
+ pic: unknow(p.picurl),
115
+ }))
116
+ .filter((p) => p.number > 0);
117
+ }
118
+
119
+ /** Normalise multiroom:getSlaveList → [{name, ip, uuid, version}] */
120
+ export function normalizeSlaveList(raw) {
121
+ const list = raw && Array.isArray(raw.slave_list) ? raw.slave_list : [];
122
+ return list.map((s) => ({
123
+ name: decodeHex(String(s.name || '')),
124
+ ip: String(s.ip || ''),
125
+ uuid: String(s.uuid || ''),
126
+ version: String(s.version || ''),
127
+ }));
128
+ }
129
+
130
+ export class HttpApi {
131
+ /**
132
+ * @param {object} options
133
+ * @param {string} options.baseUrl e.g. https://192.168.1.30 (http:// for non-WiiM LinkPlay devices)
134
+ * @param {number} [options.timeout] per request, ms
135
+ * @param {object} [options.log] logger with debug()
136
+ */
137
+ constructor({baseUrl, timeout = 10000, log}) {
138
+ this.baseUrl = String(baseUrl).replace(/\/+$/, '');
139
+ this.timeout = timeout;
140
+ this.log = log;
141
+ const secure = this.baseUrl.startsWith('https:');
142
+ this.transport = secure ? https : http;
143
+ this.agent = new this.transport.Agent({
144
+ keepAlive: true,
145
+ maxSockets: 1,
146
+ ...(secure && {rejectUnauthorized: false}),
147
+ });
148
+ this.queue = Promise.resolve();
149
+ }
150
+
151
+ /** Send one raw command; resolves with the normalised response (string or object). */
152
+ command(cmd) {
153
+ const run = () => this.request(cmd);
154
+ const result = this.queue.then(run, run);
155
+ this.queue = result.catch(() => {});
156
+ return result;
157
+ }
158
+
159
+ request(cmd) {
160
+ const url = `${this.baseUrl}/httpapi.asp?command=${encodeURIComponent(cmd)}`;
161
+ if (this.log) {
162
+ this.log.debug('api >', cmd);
163
+ }
164
+ return new Promise((resolve, reject) => {
165
+ const req = this.transport.get(url, {agent: this.agent, timeout: this.timeout}, (res) => {
166
+ const chunks = [];
167
+ res.on('data', (c) => chunks.push(c));
168
+ res.on('end', () => {
169
+ const body = Buffer.concat(chunks).toString('utf8');
170
+ if (this.log) {
171
+ this.log.debug('api <', cmd, body.length > 500 ? body.slice(0, 500) + '…' : body);
172
+ }
173
+ if (res.statusCode !== 200) {
174
+ reject(new ApiError('EHTTP', `${cmd}: http ${res.statusCode}`, {statusCode: res.statusCode}));
175
+ return;
176
+ }
177
+ const parsed = parseResponse(body);
178
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && parsed.status === 'Failed') {
179
+ reject(new ApiError('EFAILED', `${cmd}: device answered "Failed"`, {response: parsed}));
180
+ return;
181
+ }
182
+ if (parsed === 'unknown command' || parsed === 'Unknown command') {
183
+ reject(new ApiError('EUNKNOWN', `${cmd}: unknown command`, {response: parsed}));
184
+ return;
185
+ }
186
+ resolve(parsed);
187
+ });
188
+ res.on('error', (err) => reject(wrap(cmd, err)));
189
+ });
190
+ req.on('timeout', () => {
191
+ req.destroy(new ApiError('ETIMEDOUT', `${cmd}: timeout after ${this.timeout} ms`));
192
+ });
193
+ req.on('error', (err) => reject(wrap(cmd, err)));
194
+ });
195
+ }
196
+
197
+ async playerStatus() {
198
+ return normalizePlayerStatus(await this.command('getPlayerStatus'));
199
+ }
200
+
201
+ async statusEx() {
202
+ const raw = await this.command('getStatusEx');
203
+ if (!raw || typeof raw !== 'object') {
204
+ throw new ApiError('EFORMAT', 'getStatusEx: unexpected response ' + JSON.stringify(raw));
205
+ }
206
+ return raw;
207
+ }
208
+
209
+ async metaInfo() {
210
+ try {
211
+ return normalizeMetaInfo(await this.command('getMetaInfo'));
212
+ } catch (err) {
213
+ if (err.code === 'EUNKNOWN' || err.code === 'EFAILED') {
214
+ return null;
215
+ }
216
+ throw err;
217
+ }
218
+ }
219
+
220
+ async presetInfo() {
221
+ return normalizePresetInfo(await this.command('getPresetInfo'));
222
+ }
223
+
224
+ async slaveList() {
225
+ return normalizeSlaveList(await this.command('multiroom:getSlaveList'));
226
+ }
227
+
228
+ setPlayerCmd(...parts) {
229
+ return this.command(['setPlayerCmd', ...parts].join(':'));
230
+ }
231
+
232
+ close() {
233
+ this.agent.destroy();
234
+ }
235
+ }
236
+
237
+ function wrap(cmd, err) {
238
+ if (err instanceof ApiError) {
239
+ return err;
240
+ }
241
+ const code = ['ECONNREFUSED', 'EHOSTUNREACH', 'ENETUNREACH', 'ENOTFOUND', 'ECONNRESET', 'EAI_AGAIN'].includes(
242
+ err.code,
243
+ )
244
+ ? 'ECONNFAILED'
245
+ : err.code === 'ETIMEDOUT'
246
+ ? 'ETIMEDOUT'
247
+ : 'EREQUEST';
248
+ return new ApiError(code, `${cmd}: ${err.message}`, {cause: err});
249
+ }