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 ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) Sebastian Raff <hq@ccu.io> (https://hobbyquaker.github.io)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,223 @@
1
+ # wiim2mqtt
2
+
3
+ [![npm](https://img.shields.io/npm/v/wiim2mqtt.svg)](https://www.npmjs.com/package/wiim2mqtt)
4
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
5
+
6
+ > Control WiiM audio streamers via MQTT
7
+
8
+ Connects to a WiiM (Pro, Pro Plus, Mini, Amp, Ultra — all LinkPlay based) over UPnP
9
+ events and the LinkPlay HTTP API and bridges it to an MQTT broker, following the
10
+ [mqtt-smarthome](https://github.com/mqtt-smarthome/mqtt-smarthome) architecture. Announces
11
+ the device to Home Assistant via MQTT discovery.
12
+
13
+ State changes (play/pause, track, volume, …) arrive as UPnP events within milliseconds;
14
+ the HTTP API is used for commands, for everything UPnP does not expose (presets, device
15
+ info, multiroom) and as a polling fallback when events are not available.
16
+
17
+ ## Install
18
+
19
+ ```
20
+ npm install -g wiim2mqtt
21
+ ```
22
+
23
+ Requires Node.js >= 20.19.
24
+
25
+ ## Usage
26
+
27
+ ```
28
+ wiim2mqtt --address <wiim-ip> --mqtt-url mqtt://<broker>
29
+ ```
30
+
31
+ ```
32
+ Options:
33
+ -a, --address ip or hostname of the wiim [string]
34
+ --api-url base url of the http api (default https://<address>) [string]
35
+ -u, --mqtt-url, --url mqtt broker url [string] [default: "mqtt://localhost"]
36
+ --mqtt-username mqtt broker username [string]
37
+ --mqtt-password mqtt broker password [string]
38
+ -n, --name instance name. used as mqtt client id and as prefix for topics
39
+ [string] [default: "wiim"]
40
+ --json-payloads publish status as JSON {"val": ..., "ts": ..., "lc": ...}
41
+ [boolean] [default: false]
42
+ --ha-discovery publish Home Assistant MQTT discovery (--no-ha-discovery to
43
+ disable and clear) [boolean] [default: true]
44
+ --ha-prefix Home Assistant discovery prefix [string] [default: "homeassistant"]
45
+ --raw-set accept raw http api commands on <name>/set/cmd
46
+ (unrestricted remote api!) [boolean] [default: false]
47
+ --upnp subscribe to upnp events (--no-upnp = polling only)
48
+ [boolean] [default: true]
49
+ --upnp-port port of the upnp interface on the device [number] [default: 49152]
50
+ --callback-host address the wiim uses to reach the upnp event listener
51
+ (docker/wsl/multi-homed hosts). Default: auto [string]
52
+ --callback-port fixed port of the upnp event listener. Default: random free port
53
+ [number] [default: 0]
54
+ --poll-interval seconds between http status polls while playing when upnp events
55
+ are unavailable [number] [default: 1]
56
+ --position-interval seconds between status/position updates while playing (0 = off)
57
+ [number] [default: 1]
58
+ --album-art-data also publish the cover image bytes on status/album_art_data
59
+ [boolean] [default: false]
60
+ -v, --verbosity log level [string] [choices: "error", "warn", "info", "debug"]
61
+ --install install as systemd service wiim2mqtt@<name>. needs root [boolean]
62
+ --uninstall remove the systemd service wiim2mqtt@<name>. needs root [boolean]
63
+ --probe print name, model, firmware, uuid and mac of the device and exit
64
+ --version Show version number [boolean]
65
+ -h, --help Show help [boolean]
66
+ ```
67
+
68
+ Every option can also be set via environment variable with the prefix `WIIM2MQTT_`, e.g.
69
+ `WIIM2MQTT_ADDRESS=192.168.1.30 WIIM2MQTT_MQTT_URL=mqtt://broker wiim2mqtt`.
70
+
71
+ ### UPnP events and the callback listener
72
+
73
+ The WiiM pushes state changes to an HTTP listener that wiim2mqtt opens (UPnP GENA). The
74
+ device must be able to reach it:
75
+
76
+ - the listener address is detected automatically (the interface that routes to the WiiM);
77
+ override it with `--callback-host` on multi-homed hosts, WSL or Docker bridge networks.
78
+ - the port is random unless `--callback-port` is given. `--install` assigns a fixed port
79
+ per instance (49200, 49201, …) so firewall rules stay stable.
80
+ - If no event arrives within 30 s of subscribing, wiim2mqtt logs a warning naming the
81
+ callback URL, keeps working with HTTP polling (`status/upnp` = `polling`) and retries the
82
+ subscription in the background. `--no-upnp` turns the listener off altogether.
83
+
84
+ ### Run as a systemd service
85
+
86
+ ```
87
+ sudo wiim2mqtt --install --name wiim --address 192.168.1.30 --mqtt-url mqtt://192.168.1.2
88
+ ```
89
+
90
+ `--install` creates a system user `wiim2mqtt`, writes the given options to
91
+ `/etc/wiim2mqtt/<name>.env` (`WIIM2MQTT_*` variables — edit and
92
+ `systemctl restart wiim2mqtt@<name>` to change), installs the template unit
93
+ `/etc/systemd/system/wiim2mqtt@.service` and enables + starts `wiim2mqtt@<name>`. The
94
+ instance name is the `--name` option, i.e. the MQTT topic prefix.
95
+ Logs: `journalctl -u wiim2mqtt@<name> -f`.
96
+
97
+ **Several devices**: run `--install` once per device with a different `--name` — each
98
+ becomes its own instance with its own config, topic prefix and callback port:
99
+
100
+ ```
101
+ sudo wiim2mqtt --install --name wiim-living --address 192.168.1.30 --mqtt-url mqtt://broker
102
+ sudo wiim2mqtt --install --name wiim-kitchen --address 192.168.1.31 --mqtt-url mqtt://broker
103
+ systemctl status 'wiim2mqtt@*'
104
+ ```
105
+
106
+ `sudo wiim2mqtt --uninstall --name wiim-kitchen` removes one instance.
107
+
108
+ ### Docker
109
+
110
+ ```
111
+ docker run -d --name wiim2mqtt --network host \
112
+ -e WIIM2MQTT_ADDRESS=192.168.1.30 \
113
+ -e WIIM2MQTT_MQTT_URL=mqtt://broker \
114
+ ghcr.io/hobbyquaker/wiim2mqtt
115
+ ```
116
+
117
+ `--network host` lets the WiiM reach the event listener. With a bridge network instead,
118
+ publish the port and tell the adapter the host's address:
119
+ `-p 49200:49200 -e WIIM2MQTT_CALLBACK_PORT=49200 -e WIIM2MQTT_CALLBACK_HOST=<docker-host-ip>`.
120
+
121
+ ## Topics
122
+
123
+ `<name>` defaults to `wiim`.
124
+
125
+ ### `<name>/connected`
126
+
127
+ Retained. `0` = not connected to the broker (set via last will), `1` = connected to the
128
+ broker but the WiiM's HTTP API is unreachable, `2` = connected to both. Whether UPnP events
129
+ are flowing is reported separately in `status/upnp`.
130
+
131
+ ### `<name>/status/<item>`
132
+
133
+ Retained status reports (plain values; lists as JSON arrays). Published on start and
134
+ whenever the device reports a change (also when changed via the WiiM app, AirPlay, …). With
135
+ `--json-payloads` every status is `{"val": <value>, "ts": <ms received>, "lc": <ms last changed>}`.
136
+
137
+ | item | type | set | notes |
138
+ | ----------------------------------------------------------------------------------- | -------- | --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
139
+ | `play_state` | string | yes | `playing`, `paused`, `stopped`, `loading` |
140
+ | `source` | string | yes | `wifi`, `airplay`, `dlna`, `spotify`, `tidal`, `bluetooth`, `line_in`, `optical`, `usb`, `hdmi`, `coaxial`, `phono`, `multiroom`; only the ones in `source_list` can be set |
141
+ | `source_list` | string[] | | selectable sources |
142
+ | `volume` | int | yes | 0–100 |
143
+ | `mute` | bool | yes | |
144
+ | `title`, `artist`, `album`, `album_art` | string | | empty while stopped; `album_art` is the URL the device reports |
145
+ | `album_art_data` | bytes | | the cover image itself, only with `--album-art-data` |
146
+ | `quality`, `sample_rate`, `bit_depth`, `bitrate`, `origin_source` | mixed | | e.g. `LOSSLESS`, `44100`, `16`, `1411`, `Tidal` (depends on the source) |
147
+ | `duration` | int | | seconds |
148
+ | `position` | int | yes | seconds, **not retained**, published every `--position-interval` s while playing (interpolated locally) |
149
+ | `repeat` | string | yes | `off`, `one`, `all` |
150
+ | `shuffle` | bool | yes | |
151
+ | `queue_index`, `queue_length` | int | | position in / length of the device's play queue |
152
+ | `preset_list`, `preset_max` | JSON | | `[{number, name, source, pic}]` and the number of preset slots |
153
+ | `group_role`, `group_master`, `group_slaves` | mixed | | `standalone` / `master` / `slave`, the master's ip, `[{name, ip, uuid}]` |
154
+ | `device_name`, `model`, `firmware`, `update_available`, `rssi`, `ip`, `mac`, `uuid` | mixed | | device info, refreshed every 60 s |
155
+ | `upnp` | string | | `events` (UPnP events drive the state), `polling` (events unavailable, polling), `off` (`--no-upnp`) |
156
+
157
+ ### `<name>/set/<item>`
158
+
159
+ Change requests. Payload is a plain value or mqtt-smarthome style JSON (`{"val": 12}`).
160
+ Booleans accept `true/false`, `1/0`, `on/off`.
161
+
162
+ | item | payload | notes |
163
+ | ----------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------ |
164
+ | `volume` | 0–100 | |
165
+ | `volume_up`, `volume_down` | optional step (default 5) | |
166
+ | `mute` | bool | |
167
+ | `play` | empty, or a stream URL | resume, or play the given URL |
168
+ | `pause`, `stop`, `next`, `prev`, `toggle` | any | |
169
+ | `play_state` | `playing`, `paused`, `stopped` | |
170
+ | `seek` / `position` | seconds or `mm:ss` | |
171
+ | `source` | name from `source_list` | |
172
+ | `repeat` | `off`, `one`, `all` | |
173
+ | `shuffle` | bool | |
174
+ | `preset` | number or name | 1..`preset_max`; names from `preset_list` (case-insensitive) |
175
+ | `join` | ip of the master | this device joins the master's group |
176
+ | `leave` | any | this device leaves its group (sent to the master) |
177
+ | `ungroup` | any | dissolve the group this device masters |
178
+ | `kick` | ip or name of a slave | |
179
+ | `reboot` | `true` | |
180
+ | `cmd` | raw http api command | only with `--raw-set`; the response goes to `status/cmd_result` (not retained) |
181
+
182
+ ```
183
+ mosquitto_pub -t wiim/set/volume -m 12
184
+ mosquitto_pub -t wiim/set/preset -m "Radio One"
185
+ mosquitto_pub -t wiim/set/source -m bluetooth
186
+ mosquitto_pub -t wiim/set/repeat -m all
187
+ ```
188
+
189
+ Every set is verified by re-reading the affected items after 500 ms; a set that changed
190
+ nothing is logged as a warning.
191
+
192
+ ### `<name>/get/<item>`
193
+
194
+ Explicit refresh: `status`, `presets`, `device`, `group`, `metadata`.
195
+
196
+ ### `<name>/info`
197
+
198
+ Retained JSON: adapter name/version, node version, host, pid, uptime, the device's name,
199
+ model, firmware, uuid, mac, ip and the current state source (`upnp`).
200
+
201
+ ## Home Assistant
202
+
203
+ MQTT discovery is on by default (HA ≥ 2024.11, device-based discovery). The WiiM appears
204
+ as one device with: volume (number), mute and shuffle (switches), source / repeat / preset
205
+ (selects), play/pause/stop/next/previous buttons, sensors for play state, title, artist,
206
+ album, quality, sample rate, bit depth, bitrate, an album art image entity, a firmware
207
+ update entity and diagnostic sensors. Availability follows `<name>/connected`.
208
+
209
+ `--no-ha-discovery` disables discovery and removes the device announcement on startup;
210
+ `--ha-prefix` changes the discovery prefix. HA has no MQTT media player platform; a payload
211
+ for a community media player component is planned.
212
+
213
+ ## Verification status
214
+
215
+ Developed against a **WiiM Pro Plus** (firmware 4.8.814734). Other models share the platform but may differ in
216
+ available sources (`hdmi`, `phono`, `coaxial` on Amp/Ultra) and in details of the API.
217
+ `node scripts/dump.js <address> --listen 20` prints a read-only dump of your device (api
218
+ responses, UPnP description, 20 s of events) — attach it to issues about missing or wrong
219
+ items. `wiim2mqtt --probe -a <address>` prints the device identity.
220
+
221
+ ## License
222
+
223
+ MIT © Sebastian Raff
package/config.js ADDED
@@ -0,0 +1,153 @@
1
+ import yargs from 'yargs';
2
+ import {hideBin} from 'yargs/helpers';
3
+ import pkg from './package.json' with {type: 'json'};
4
+
5
+ /** Build the CLI parser; exported for tests. Every option is also read from WIIM2MQTT_* env vars. */
6
+ export function buildParser(argv = hideBin(process.argv)) {
7
+ return yargs(argv)
8
+ .scriptName('wiim2mqtt')
9
+ .usage('Usage: $0 [options]')
10
+ .env('WIIM2MQTT')
11
+ .option('address', {
12
+ alias: 'a',
13
+ type: 'string',
14
+ describe: 'ip or hostname of the wiim',
15
+ })
16
+ .option('api-url', {
17
+ type: 'string',
18
+ describe:
19
+ 'base url of the http api (default https://<address>; e.g. http://<ip> for other linkplay devices)',
20
+ })
21
+ .option('upnp-port', {
22
+ type: 'number',
23
+ describe: 'port of the upnp description/control/event interface on the device',
24
+ default: 49152,
25
+ })
26
+ .option('mqtt-url', {
27
+ alias: ['u', 'url'],
28
+ type: 'string',
29
+ describe: 'mqtt broker url. See https://github.com/mqttjs/MQTT.js#connect-using-a-url',
30
+ default: 'mqtt://localhost',
31
+ })
32
+ .option('mqtt-username', {
33
+ type: 'string',
34
+ describe: 'mqtt broker username',
35
+ })
36
+ .option('mqtt-password', {
37
+ type: 'string',
38
+ describe: 'mqtt broker password',
39
+ })
40
+ .option('name', {
41
+ alias: 'n',
42
+ type: 'string',
43
+ describe: 'instance name. used as mqtt client id and as prefix for topics',
44
+ default: 'wiim',
45
+ })
46
+ .option('json-payloads', {
47
+ type: 'boolean',
48
+ describe: 'publish status as JSON {"val": ..., "ts": ..., "lc": ...} instead of plain values',
49
+ default: false,
50
+ })
51
+ .option('ha-discovery', {
52
+ type: 'boolean',
53
+ describe: 'publish Home Assistant MQTT discovery (use --no-ha-discovery to disable and clear)',
54
+ default: true,
55
+ })
56
+ .option('ha-prefix', {
57
+ type: 'string',
58
+ describe: 'Home Assistant discovery prefix',
59
+ default: 'homeassistant',
60
+ })
61
+ .option('raw-set', {
62
+ type: 'boolean',
63
+ describe: 'accept raw http api commands on <name>/set/cmd (unrestricted remote api!)',
64
+ default: false,
65
+ })
66
+ .option('upnp', {
67
+ type: 'boolean',
68
+ describe: 'subscribe to upnp events (use --no-upnp for polling only, no listener)',
69
+ default: true,
70
+ })
71
+ .option('callback-host', {
72
+ type: 'string',
73
+ describe:
74
+ 'address the wiim uses to reach the upnp event listener (docker/wsl/multi-homed hosts). Default: auto',
75
+ })
76
+ .option('callback-port', {
77
+ type: 'number',
78
+ describe: 'fixed port of the upnp event listener. Default: random free port',
79
+ default: 0,
80
+ })
81
+ .option('poll-interval', {
82
+ type: 'number',
83
+ describe: 'seconds between http status polls while playing when upnp events are unavailable',
84
+ default: 1,
85
+ })
86
+ .option('position-interval', {
87
+ type: 'number',
88
+ describe: 'seconds between status/position updates while playing (0 = off)',
89
+ default: 1,
90
+ })
91
+ .option('album-art-data', {
92
+ type: 'boolean',
93
+ describe: 'also publish the cover image bytes on status/album_art_data',
94
+ default: false,
95
+ })
96
+ .option('verbosity', {
97
+ alias: 'v',
98
+ type: 'string',
99
+ describe: 'log level',
100
+ choices: ['error', 'warn', 'info', 'debug'],
101
+ default: 'info',
102
+ })
103
+ .option('install', {
104
+ type: 'boolean',
105
+ describe:
106
+ 'install as systemd service wiim2mqtt@<name> using the other options as its config, enable and start it. needs root',
107
+ })
108
+ .option('uninstall', {
109
+ type: 'boolean',
110
+ describe: 'stop, disable and remove the systemd service wiim2mqtt@<name>. needs root',
111
+ })
112
+ .option('probe', {
113
+ type: 'boolean',
114
+ describe: 'print name, model, firmware, uuid and mac of the device as json and exit',
115
+ })
116
+ .check((argv) => {
117
+ if (!argv.address && !argv.apiUrl && !argv.uninstall) {
118
+ throw new Error('Missing required argument: address');
119
+ }
120
+ if (argv.pollInterval < 0.2) {
121
+ throw new Error('--poll-interval must be >= 0.2 seconds');
122
+ }
123
+ if (argv.callbackPort < 0 || argv.callbackPort > 65535) {
124
+ throw new Error('--callback-port must be 0..65535');
125
+ }
126
+ return true;
127
+ })
128
+ .example('$0 -a 192.168.1.30 -u mqtt://broker', 'run in the foreground')
129
+ .example(
130
+ 'sudo $0 --install -n wiim-living -a 192.168.1.30 -u mqtt://broker',
131
+ 'install as service wiim2mqtt@wiim-living',
132
+ )
133
+ .epilog(
134
+ 'Every option can also be set via environment variable, e.g. WIIM2MQTT_ADDRESS, WIIM2MQTT_MQTT_URL, WIIM2MQTT_NAME.\n' +
135
+ pkg.homepage,
136
+ )
137
+ .version(pkg.version)
138
+ .help('help')
139
+ .alias('h', 'help')
140
+ .strict();
141
+ }
142
+
143
+ /** Parse argv (default: the process arguments) and derive apiUrl/address from each other. */
144
+ export function parseConfig(argv, {exitProcess = true} = {}) {
145
+ const config = buildParser(argv).exitProcess(exitProcess).parse();
146
+ if (!config.apiUrl && config.address) {
147
+ config.apiUrl = `https://${config.address}`;
148
+ }
149
+ if (!config.address && config.apiUrl) {
150
+ config.address = new URL(config.apiUrl).hostname;
151
+ }
152
+ return config;
153
+ }