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/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
|
+
}
|