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/install.js ADDED
@@ -0,0 +1,251 @@
1
+ /**
2
+ * --install / --uninstall: run wiim2mqtt as a systemd template service, one instance per device.
3
+ *
4
+ * wiim2mqtt@<name>.service instance = --name (= mqtt topic prefix)
5
+ * /etc/wiim2mqtt/<name>.env per-instance config (WIIM2MQTT_* variables)
6
+ * system user wiim2mqtt shared by all instances
7
+ *
8
+ * Mirrors lgtv2mqtt's lib/install.js; keep the two in sync.
9
+ */
10
+
11
+ import fs from 'node:fs';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+ import {execFileSync} from 'node:child_process';
15
+
16
+ export const SERVICE = 'wiim2mqtt';
17
+ const UNIT_PATH = `/etc/systemd/system/${SERVICE}@.service`;
18
+ const CONF_DIR = `/etc/${SERVICE}`;
19
+ const STATE_DIR = `/var/lib/${SERVICE}`;
20
+ /** first callback port handed out by --install when --callback-port is not given */
21
+ export const CALLBACK_PORT_BASE = 49200;
22
+
23
+ // options that are written to the env file (everything except --name, which is the instance)
24
+ const ENV_OPTIONS = [
25
+ 'address',
26
+ 'apiUrl',
27
+ 'mqttUrl',
28
+ 'mqttUsername',
29
+ 'mqttPassword',
30
+ 'jsonPayloads',
31
+ 'haDiscovery',
32
+ 'haPrefix',
33
+ 'rawSet',
34
+ 'upnp',
35
+ 'upnpPort',
36
+ 'callbackHost',
37
+ 'callbackPort',
38
+ 'pollInterval',
39
+ 'positionInterval',
40
+ 'albumArtData',
41
+ 'verbosity',
42
+ ];
43
+
44
+ function run(cmd, args) {
45
+ return execFileSync(cmd, args, {stdio: ['ignore', 'pipe', 'inherit']})
46
+ .toString()
47
+ .trim();
48
+ }
49
+
50
+ export function envVarName(option) {
51
+ return 'WIIM2MQTT_' + option.replace(/[A-Z]/g, (c) => '_' + c).toUpperCase();
52
+ }
53
+
54
+ export function instanceName(name) {
55
+ if (!/^[A-Za-z0-9_.-]+$/.test(name)) {
56
+ throw new Error(`--name "${name}" cannot be used as systemd instance name (allowed: letters, digits, _ . -)`);
57
+ }
58
+ return name;
59
+ }
60
+
61
+ function envPath(name) {
62
+ return path.join(CONF_DIR, `${name}.env`);
63
+ }
64
+
65
+ function unitName(name) {
66
+ return `${SERVICE}@${name}.service`;
67
+ }
68
+
69
+ export function unitFile(execStart) {
70
+ return `[Unit]
71
+ Description=wiim2mqtt %i - WiiM to MQTT bridge
72
+ Documentation=https://github.com/hobbyquaker/wiim2mqtt
73
+ After=network-online.target
74
+ Wants=network-online.target
75
+
76
+ [Service]
77
+ Type=simple
78
+ EnvironmentFile=${CONF_DIR}/%i.env
79
+ Environment=WIIM2MQTT_NAME=%i
80
+ ExecStart=${execStart}
81
+ Restart=on-failure
82
+ RestartSec=10
83
+ SyslogIdentifier=${SERVICE}@%i
84
+ SyslogLevelPrefix=true
85
+ User=${SERVICE}
86
+ Group=${SERVICE}
87
+ StateDirectory=${SERVICE}/%i
88
+ NoNewPrivileges=true
89
+ ProtectSystem=full
90
+ ProtectHome=true
91
+ PrivateTmp=true
92
+
93
+ [Install]
94
+ WantedBy=multi-user.target
95
+ `;
96
+ }
97
+
98
+ /**
99
+ * Pick a deterministic callback port for a new instance: the lowest port >= CALLBACK_PORT_BASE
100
+ * not used by another installed instance's env file.
101
+ */
102
+ export function chooseCallbackPort(usedPorts, base = CALLBACK_PORT_BASE) {
103
+ const used = new Set(usedPorts.map(Number));
104
+ let port = base;
105
+ while (used.has(port)) {
106
+ port++;
107
+ }
108
+ return port;
109
+ }
110
+
111
+ /** Build the env file content from the parsed CLI options. */
112
+ export function envFile(argv) {
113
+ const lines = [
114
+ `# wiim2mqtt instance "${argv.name}" - read by ${unitName(argv.name)}.`,
115
+ `# Edit and run: systemctl restart ${unitName(argv.name)}`,
116
+ ];
117
+ for (const option of ENV_OPTIONS) {
118
+ const value = argv[option];
119
+ if (value === undefined || value === null || value === '') {
120
+ continue;
121
+ }
122
+ if (option === 'callbackPort' && Number(value) === 0) {
123
+ continue;
124
+ }
125
+ lines.push(`${envVarName(option)}=${String(value).replace(/\n/g, ' ')}`);
126
+ }
127
+ return lines.join('\n') + '\n';
128
+ }
129
+
130
+ function installedInstances() {
131
+ if (!fs.existsSync(CONF_DIR)) {
132
+ return [];
133
+ }
134
+ return fs
135
+ .readdirSync(CONF_DIR)
136
+ .filter((f) => f.endsWith('.env'))
137
+ .map((f) => f.slice(0, -4));
138
+ }
139
+
140
+ function installedCallbackPorts(except) {
141
+ const ports = [];
142
+ for (const instance of installedInstances()) {
143
+ if (instance === except) {
144
+ continue;
145
+ }
146
+ const match = fs.readFileSync(envPath(instance), 'utf8').match(/^WIIM2MQTT_CALLBACK_PORT=(\d+)$/m);
147
+ if (match) {
148
+ ports.push(Number(match[1]));
149
+ }
150
+ }
151
+ return ports;
152
+ }
153
+
154
+ function requireRoot(option) {
155
+ if (os.platform() !== 'linux') {
156
+ throw new Error(`${option} is only supported on Linux with systemd`);
157
+ }
158
+ if (typeof process.getuid === 'function' && process.getuid() !== 0) {
159
+ throw new Error(`${option} must run as root, e.g. sudo wiim2mqtt ${option} --name <name> ...`);
160
+ }
161
+ if (!fs.existsSync('/run/systemd/system')) {
162
+ throw new Error('systemd is not running on this system');
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Install the instance `argv.name` as systemd service using the other CLI options as its
168
+ * configuration, then enable and start it. Must run as root.
169
+ */
170
+ export function installService(argv, log) {
171
+ requireRoot('--install');
172
+ const name = instanceName(argv.name);
173
+ const execStart = `${process.execPath} ${fs.realpathSync(process.argv[1])}`;
174
+
175
+ // shared system user
176
+ try {
177
+ run('id', ['-u', SERVICE]);
178
+ } catch {
179
+ log(`creating system user ${SERVICE}`);
180
+ run('useradd', [
181
+ '--system',
182
+ '--no-create-home',
183
+ '--home-dir',
184
+ STATE_DIR,
185
+ '--shell',
186
+ '/usr/sbin/nologin',
187
+ SERVICE,
188
+ ]);
189
+ }
190
+
191
+ // config
192
+ fs.mkdirSync(CONF_DIR, {recursive: true, mode: 0o750});
193
+ const conf = envPath(name);
194
+ if (!argv.callbackPort && argv.upnp !== false) {
195
+ // a fixed port per instance keeps firewall rules stable (and the docs honest)
196
+ let port;
197
+ if (fs.existsSync(conf)) {
198
+ const match = fs.readFileSync(conf, 'utf8').match(/^WIIM2MQTT_CALLBACK_PORT=(\d+)$/m);
199
+ port = match && Number(match[1]);
200
+ }
201
+ argv = {...argv, callbackPort: port || chooseCallbackPort(installedCallbackPorts(name))};
202
+ log(`upnp event listener port: ${argv.callbackPort} (WIIM2MQTT_CALLBACK_PORT)`);
203
+ }
204
+ if (fs.existsSync(conf)) {
205
+ fs.copyFileSync(conf, conf + '.bak');
206
+ log(`existing ${conf} backed up to ${conf}.bak`);
207
+ }
208
+ fs.writeFileSync(conf, envFile(argv), {mode: 0o640});
209
+ run('chown', ['-R', `root:${SERVICE}`, CONF_DIR]);
210
+ log(`wrote ${conf}`);
211
+
212
+ // template unit (shared by all instances, rewritten so ExecStart follows node/package updates)
213
+ fs.writeFileSync(UNIT_PATH, unitFile(execStart), {mode: 0o644});
214
+ log(`wrote ${UNIT_PATH} (ExecStart=${execStart})`);
215
+
216
+ run('systemctl', ['daemon-reload']);
217
+ run('systemctl', ['enable', '--now', unitName(name)]);
218
+ const others = installedInstances().filter((i) => i !== name);
219
+ if (others.length > 0) {
220
+ log(`other instances: ${others.map(unitName).join(', ')}`);
221
+ }
222
+ log(`${unitName(name)} enabled and started. logs: journalctl -u ${unitName(name)} -f`);
223
+ }
224
+
225
+ /** Stop, disable and remove the instance `argv.name`; remove the template when it was the last one. */
226
+ export function uninstallService(argv, log) {
227
+ requireRoot('--uninstall');
228
+ const name = instanceName(argv.name);
229
+ const unit = unitName(name);
230
+
231
+ try {
232
+ run('systemctl', ['disable', '--now', unit]);
233
+ } catch {
234
+ // not installed
235
+ }
236
+ const conf = envPath(name);
237
+ if (fs.existsSync(conf)) {
238
+ fs.rmSync(conf);
239
+ log(`removed ${conf}`);
240
+ }
241
+ const remaining = installedInstances();
242
+ if (remaining.length === 0 && fs.existsSync(UNIT_PATH)) {
243
+ fs.rmSync(UNIT_PATH);
244
+ log(`removed ${UNIT_PATH} (no instances left)`);
245
+ }
246
+ run('systemctl', ['daemon-reload']);
247
+ log(`${unit} removed.`);
248
+ if (remaining.length > 0) {
249
+ log(`remaining instances: ${remaining.map(unitName).join(', ')}`);
250
+ }
251
+ }
package/lib/log.js ADDED
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Minimal leveled logger (debug/info/warn/error).
3
+ *
4
+ * Two output formats:
5
+ * - journal: when stdout is connected to the systemd journal (JOURNAL_STREAM is set) or
6
+ * forced via WIIM2MQTT_LOG_FORMAT=journal. No timestamp (the journal has its own) and
7
+ * the severity as sd-daemon prefix `<N>` which journald turns into the PRIORITY field
8
+ * (`journalctl -p warning` works). Identifier/pid come from SyslogIdentifier= in the unit.
9
+ * - text: `2026-08-21 14:30:25.761 <info> message`, colored on a tty.
10
+ */
11
+
12
+ import util from 'node:util';
13
+
14
+ const LEVELS = {debug: 0, info: 1, warn: 2, error: 3};
15
+ const SYSLOG = {debug: 7, info: 6, warn: 4, error: 3};
16
+ const COLOR = {
17
+ debug: '\x1b[44m<debug>\x1b[49m',
18
+ info: '\x1b[30;42m<info> \x1b[39;49m',
19
+ warn: '\x1b[30;43m<warn> \x1b[39;49m',
20
+ error: '\x1b[37;1;41m<error>\x1b[49;22;39m',
21
+ };
22
+ const PLAIN = {debug: '<debug>', info: '<info> ', warn: '<warn> ', error: '<error>'};
23
+
24
+ function timestamp(d = new Date()) {
25
+ const p = (n, l = 2) => String(n).padStart(l, '0');
26
+ return (
27
+ `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ` +
28
+ `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}.${p(d.getMilliseconds(), 3)}`
29
+ );
30
+ }
31
+
32
+ export function detectFormat(env = process.env, stream = process.stdout) {
33
+ if (env.WIIM2MQTT_LOG_FORMAT === 'journal' || env.WIIM2MQTT_LOG_FORMAT === 'text') {
34
+ return env.WIIM2MQTT_LOG_FORMAT;
35
+ }
36
+ if (env.JOURNAL_STREAM && !stream.isTTY) {
37
+ return 'journal';
38
+ }
39
+ return 'text';
40
+ }
41
+
42
+ export function createLogger(options = {}) {
43
+ const format = options.format || detectFormat();
44
+ const color = options.color !== undefined ? options.color : format === 'text' && Boolean(process.stdout.isTTY);
45
+ const write = options.write || ((line) => process.stdout.write(line + '\n'));
46
+ let threshold = LEVELS.info;
47
+
48
+ const log = {};
49
+ log.format = format;
50
+ log.setLevel = (level) => {
51
+ if (!(level in LEVELS)) {
52
+ throw new Error(`unknown log level ${level}`);
53
+ }
54
+ threshold = LEVELS[level];
55
+ };
56
+ log.formatLine = (level, args) => {
57
+ // util.format handles printf-style strings and pretty-prints objects like console.log
58
+ const msg = util.format(...args).replace(/\n/g, format === 'journal' ? '\n ' : '\n');
59
+ if (format === 'journal') {
60
+ return `<${SYSLOG[level]}>${msg}`;
61
+ }
62
+ return `${timestamp()} ${(color ? COLOR : PLAIN)[level]} ${msg}`;
63
+ };
64
+ for (const level of Object.keys(LEVELS)) {
65
+ log[level] = (...args) => {
66
+ if (LEVELS[level] >= threshold) {
67
+ write(log.formatLine(level, args));
68
+ }
69
+ };
70
+ }
71
+ return log;
72
+ }
73
+
74
+ export default createLogger();
package/lib/payload.js ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * MQTT payload helpers (mqtt-smarthome conventions).
3
+ */
4
+
5
+ /**
6
+ * Converts an incoming MQTT payload (Buffer or string) to a JS value.
7
+ * Accepts plain values (numbers, booleans, strings), JSON objects/arrays and
8
+ * mqtt-smarthome style JSON {val: ...} (unwrapped to the value).
9
+ * Returns undefined for empty payloads.
10
+ */
11
+ export function parsePayload(payload) {
12
+ const trimmed = String(payload).trim();
13
+ if (trimmed === '') {
14
+ return undefined;
15
+ }
16
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
17
+ try {
18
+ const parsed = JSON.parse(trimmed);
19
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && 'val' in parsed) {
20
+ return parsed.val;
21
+ }
22
+ return parsed;
23
+ } catch {
24
+ // not JSON, treat as string
25
+ }
26
+ }
27
+ if (trimmed === 'true') {
28
+ return true;
29
+ }
30
+ if (trimmed === 'false') {
31
+ return false;
32
+ }
33
+ if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
34
+ return Number(trimmed);
35
+ }
36
+ return trimmed;
37
+ }
38
+
39
+ /**
40
+ * Interprets a parsed payload as boolean: true/false, 1/0, "on"/"off", "yes"/"no".
41
+ * Returns undefined if the value is not recognized.
42
+ */
43
+ export function toBoolean(value) {
44
+ if (typeof value === 'boolean') {
45
+ return value;
46
+ }
47
+ if (typeof value === 'number') {
48
+ return value !== 0;
49
+ }
50
+ if (typeof value === 'string') {
51
+ const s = value.trim().toLowerCase();
52
+ if (['true', '1', 'on', 'yes'].includes(s)) {
53
+ return true;
54
+ }
55
+ if (['false', '0', 'off', 'no'].includes(s)) {
56
+ return false;
57
+ }
58
+ }
59
+ return undefined;
60
+ }
61
+
62
+ /**
63
+ * Interprets a parsed payload as volume 0..100 (integer). Returns undefined if not a number.
64
+ */
65
+ export function toVolume(value) {
66
+ const n = typeof value === 'number' ? value : Number(String(value).trim());
67
+ if (!Number.isFinite(n)) {
68
+ return undefined;
69
+ }
70
+ return Math.min(100, Math.max(0, Math.round(n)));
71
+ }
72
+
73
+ /**
74
+ * Remembers the last value per item and produces outgoing status payloads,
75
+ * either plain or as {val, ts, lc} JSON.
76
+ */
77
+ export class StatusTracker {
78
+ /**
79
+ * @param {object} options
80
+ * @param {boolean} [options.json] emit {val, ts, lc} objects instead of plain values
81
+ * @param {() => number} [options.now] clock, for tests
82
+ */
83
+ constructor({json = false, now = Date.now} = {}) {
84
+ this.json = json;
85
+ this.now = now;
86
+ this.state = new Map();
87
+ }
88
+
89
+ /** Last known value of an item (undefined if never seen). */
90
+ get(item) {
91
+ const entry = this.state.get(item);
92
+ return entry && entry.val;
93
+ }
94
+
95
+ /**
96
+ * Record a new value and return what to publish.
97
+ * @returns {{payload: *, changed: boolean}}
98
+ */
99
+ update(item, val) {
100
+ const ts = this.now();
101
+ const previous = this.state.get(item);
102
+ const changed = !previous || JSON.stringify(previous.val) !== JSON.stringify(val);
103
+ const lc = changed ? ts : previous.lc;
104
+ this.state.set(item, {val, ts, lc});
105
+ return {payload: this.json ? {val, ts, lc} : val, changed};
106
+ }
107
+
108
+ /** Forget an item (e.g. when it no longer applies). Returns true if it was known. */
109
+ delete(item) {
110
+ return this.state.delete(item);
111
+ }
112
+ }