hm2mqtt 2.4.0 → 3.0.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/rpc.js ADDED
@@ -0,0 +1,497 @@
1
+ /**
2
+ * The RPC side of the CCU: one RpcConnection per interface process (client, init subscription,
3
+ * ping/re-init, counters) and the shared callback servers (one per protocol) that route the
4
+ * CCU's calls to the right connection.
5
+ */
6
+
7
+ import {EventEmitter} from 'node:events';
8
+ import xmlrpcLib from 'homematic-xmlrpc';
9
+ import binrpcLib from 'binrpc';
10
+
11
+ export const METHODS = [
12
+ 'system.listMethods',
13
+ 'system.multicall',
14
+ 'event',
15
+ 'listDevices',
16
+ 'newDevices',
17
+ 'deleteDevices',
18
+ 'updateDevice',
19
+ 'replaceDevice',
20
+ 'readdedDevice',
21
+ 'setReadyConfig',
22
+ ];
23
+
24
+ const TX_METHODS = new Set(['setValue', 'putParamset', 'activateLinkParamset']);
25
+ const INIT_RETRY_MS = 30000;
26
+ const DEINIT_TIMEOUT_MS = 1500;
27
+ const CALL_TIMEOUT_MS = 30000;
28
+
29
+ /**
30
+ * Derives the working/direction hints of an event batch (system.multicall) the way
31
+ * node-red-contrib-ccu did: an actuator reports WORKING/DIRECTION together with LEVEL/STATE.
32
+ * @param {Array<[string, string, string, *]>} events [initId, channel, datapoint, value]
33
+ * @returns {{working?: boolean, direction?: number}}
34
+ */
35
+ export function batchHints(events) {
36
+ const hints = {};
37
+ for (const [, , datapoint, value] of events) {
38
+ if (datapoint === 'WORKING' || datapoint === 'WORKING_SLATS') {
39
+ hints.working = value;
40
+ } else if (datapoint === 'PROCESS') {
41
+ hints.working = Boolean(value);
42
+ } else if (datapoint === 'DIRECTION') {
43
+ hints.direction = value;
44
+ } else if (datapoint === 'ACTIVITY_STATE') {
45
+ hints.direction = value === 3 ? 0 : value === 0 ? 3 : value;
46
+ }
47
+ }
48
+ return hints;
49
+ }
50
+
51
+ /**
52
+ * The callback servers the CCU talks to. One http (xmlrpc) and/or one binrpc server, started on
53
+ * demand; calls are routed by their init id to the registered RpcConnection.
54
+ */
55
+ export class RpcServers {
56
+ /**
57
+ * @param {object} o
58
+ * @param {string} o.listenAddress
59
+ * @param {string} [o.initAddress] address in the init url (default listenAddress)
60
+ * @param {number} o.xmlrpcPort
61
+ * @param {number} o.binrpcPort
62
+ * @param {object} o.log
63
+ * @param {{xmlrpc?: object, binrpc?: object}} [o.libs] for tests
64
+ */
65
+ constructor({listenAddress, initAddress, xmlrpcPort, binrpcPort, log, libs = {}}) {
66
+ this.listenAddress = listenAddress;
67
+ this.initAddress = initAddress || listenAddress;
68
+ this.ports = {xmlrpc: xmlrpcPort, binrpc: binrpcPort};
69
+ this.log = log;
70
+ this.libs = {xmlrpc: xmlrpcLib, binrpc: binrpcLib, ...libs};
71
+ this.servers = {};
72
+ this.connections = new Map();
73
+ }
74
+
75
+ url(protocol) {
76
+ return `${protocol === 'binrpc' ? 'xmlrpc_bin' : 'http'}://${this.initAddress}:${this.ports[protocol]}`;
77
+ }
78
+
79
+ register(initId, connection) {
80
+ this.connections.set(initId, connection);
81
+ }
82
+
83
+ /** Starts the server of a protocol if it is not running yet. Resolves once it listens. */
84
+ ensure(protocol) {
85
+ if (this.servers[protocol]) {
86
+ return this.servers[protocol];
87
+ }
88
+ const lib = this.libs[protocol];
89
+ const options = {host: this.listenAddress, port: this.ports[protocol]};
90
+ this.servers[protocol] = new Promise((resolve, reject) => {
91
+ let server;
92
+ const onListening = () => {
93
+ this.log.info('rpc', protocol, 'server listening on', this.url(protocol));
94
+ resolve(server);
95
+ };
96
+ server = lib.createServer(options, onListening);
97
+ const inner = protocol === 'binrpc' ? server.server || server : server;
98
+ inner.on('error', (err) => {
99
+ this.log.error('rpc', protocol, 'server', err.message || err);
100
+ delete this.servers[protocol];
101
+ reject(err);
102
+ });
103
+ for (const method of METHODS) {
104
+ server.on(method, (err, params, callback) => {
105
+ if (err) {
106
+ this.log.warn('rpc <', protocol, method, err.message || err);
107
+ }
108
+ this.route(method, params, callback);
109
+ });
110
+ }
111
+ server.on('NotFound', (method, params) => {
112
+ this.log.debug('rpc <', protocol, 'unknown method', method, JSON.stringify(params).slice(0, 200));
113
+ });
114
+ });
115
+ return this.servers[protocol];
116
+ }
117
+
118
+ connectionFor(params) {
119
+ const initId = Array.isArray(params) ? params[0] : undefined;
120
+ const connection = this.connections.get(initId);
121
+ if (!connection) {
122
+ this.log.warn('rpc < call for unknown init id', String(initId));
123
+ }
124
+ return connection;
125
+ }
126
+
127
+ route(method, params, callback) {
128
+ const done = (result) => {
129
+ if (typeof callback === 'function') {
130
+ callback(null, result);
131
+ }
132
+ };
133
+ if (method === 'system.multicall') {
134
+ done(this.multicall(Array.isArray(params) ? params[0] : []));
135
+ return;
136
+ }
137
+ if (method === 'event') {
138
+ const connection = this.connectionFor(params);
139
+ if (connection) {
140
+ connection.handleEvents([params], batchHints([params]));
141
+ }
142
+ done('');
143
+ return;
144
+ }
145
+ const connection = this.connectionFor(params);
146
+ if (!connection) {
147
+ done(method === 'system.listMethods' ? METHODS : method === 'listDevices' ? [] : '');
148
+ return;
149
+ }
150
+ connection.alive();
151
+ switch (method) {
152
+ case 'system.listMethods':
153
+ done(METHODS);
154
+ break;
155
+ case 'listDevices': {
156
+ const answer = connection.listDevices();
157
+ this.log.debug('rpc >', connection.name, 'listDevices', answer.length, 'devices');
158
+ done(answer);
159
+ break;
160
+ }
161
+ case 'newDevices':
162
+ connection.newDevices(Array.isArray(params[1]) ? params[1] : []);
163
+ done('');
164
+ break;
165
+ case 'deleteDevices':
166
+ connection.deleteDevices(Array.isArray(params[1]) ? params[1] : []);
167
+ done('');
168
+ break;
169
+ default:
170
+ this.log.debug('rpc <', connection.name, method);
171
+ done('');
172
+ }
173
+ }
174
+
175
+ multicall(calls) {
176
+ const results = [];
177
+ const events = [];
178
+ if (!Array.isArray(calls)) {
179
+ return results;
180
+ }
181
+ for (const call of calls) {
182
+ if (!call || typeof call !== 'object') {
183
+ continue;
184
+ }
185
+ if (call.methodName === 'event') {
186
+ if (Array.isArray(call.params)) {
187
+ events.push(call.params);
188
+ }
189
+ results.push('');
190
+ } else if (METHODS.includes(call.methodName)) {
191
+ this.route(call.methodName, call.params, (_, res) => results.push(res));
192
+ } else {
193
+ this.log.debug('rpc < multicall: unknown method', call.methodName);
194
+ results.push('');
195
+ }
196
+ }
197
+ if (events.length > 0) {
198
+ const byConnection = new Map();
199
+ for (const event of events) {
200
+ const connection = this.connectionFor(event);
201
+ if (connection) {
202
+ if (!byConnection.has(connection)) {
203
+ byConnection.set(connection, []);
204
+ }
205
+ byConnection.get(connection).push(event);
206
+ }
207
+ }
208
+ for (const [connection, list] of byConnection) {
209
+ connection.handleEvents(list, batchHints(list));
210
+ }
211
+ }
212
+ return results;
213
+ }
214
+
215
+ async close() {
216
+ for (const protocol of Object.keys(this.servers)) {
217
+ try {
218
+ const server = await this.servers[protocol];
219
+ await Promise.race([
220
+ Promise.resolve(server.close && server.close()),
221
+ new Promise((resolve) => setTimeout(resolve, DEINIT_TIMEOUT_MS)),
222
+ ]);
223
+ this.log.debug('rpc', protocol, 'server closed');
224
+ } catch (err) {
225
+ this.log.debug('rpc', protocol, 'server close', err.message || err);
226
+ }
227
+ delete this.servers[protocol];
228
+ }
229
+ }
230
+ }
231
+
232
+ /**
233
+ * One interface process (BidCos-RF, HmIP-RF, ...): client, init subscription, ping/re-init,
234
+ * rx/tx counters. Events: 'connected' (bool), 'event' ({channel, datapoint, value, working,
235
+ * direction}), 'newDevices' (devices), 'deleteDevices' (addresses).
236
+ */
237
+ export class RpcConnection extends EventEmitter {
238
+ /**
239
+ * @param {object} o
240
+ * @param {string} o.name interface name (BidCos-RF, ...)
241
+ * @param {string} o.host CCU address
242
+ * @param {'xmlrpc' | 'binrpc'} o.protocol
243
+ * @param {number} o.port
244
+ * @param {string} [o.path]
245
+ * @param {boolean} [o.tls]
246
+ * @param {boolean} [o.insecure]
247
+ * @param {string} [o.username]
248
+ * @param {string} [o.password]
249
+ * @param {boolean} [o.init] subscribe with init()
250
+ * @param {boolean} [o.ping] the interface answers ping
251
+ * @param {number} [o.pingTimeout] seconds
252
+ * @param {RpcServers} o.servers
253
+ * @param {string} o.initId
254
+ * @param {() => Array} [o.listDevices] answer for the CCU's listDevices()
255
+ * @param {object} o.log
256
+ * @param {{xmlrpc?: object, binrpc?: object}} [o.libs]
257
+ * @param {number} [o.callTimeout] ms a method call may take (default 30000)
258
+ * @param {object} [o.timers] {setTimeout, clearTimeout, now} for tests
259
+ */
260
+ constructor(o) {
261
+ super();
262
+ this.name = o.name;
263
+ this.host = o.host;
264
+ this.protocol = o.protocol;
265
+ this.port = o.port;
266
+ this.path = o.path;
267
+ this.tls = Boolean(o.tls);
268
+ this.insecure = Boolean(o.insecure);
269
+ this.username = o.username;
270
+ this.password = o.password;
271
+ this.wantInit = o.init !== false;
272
+ this.wantPing = Boolean(o.ping);
273
+ this.pingTimeout = o.pingTimeout || 60;
274
+ this.servers = o.servers;
275
+ this.initId = o.initId;
276
+ this.listDevicesAnswer = o.listDevices || (() => []);
277
+ this.log = o.log;
278
+ this.libs = {xmlrpc: xmlrpcLib, binrpc: binrpcLib, ...(o.libs || {})};
279
+ this.timers = {setTimeout, clearTimeout, now: Date.now, ...(o.timers || {})};
280
+ this.callTimeout = o.callTimeout || CALL_TIMEOUT_MS;
281
+
282
+ this.client = null;
283
+ this.connected = false;
284
+ this.initialized = false;
285
+ this.stopped = false;
286
+ this.lastEvent = 0;
287
+ this.counters = {rx: 0, tx: 0};
288
+ this.initTimer = null;
289
+ this.pingTimer = null;
290
+ this.lastInitError = null;
291
+ }
292
+
293
+ createClient() {
294
+ const lib = this.libs[this.protocol];
295
+ if (this.protocol === 'binrpc') {
296
+ return lib.createClient({host: this.host, port: this.port});
297
+ }
298
+ const options = {};
299
+ if (this.path) {
300
+ options.url = `${this.tls ? 'https' : 'http'}://${this.host}:${this.port}${this.path}`;
301
+ } else {
302
+ options.host = this.host;
303
+ options.port = this.port;
304
+ options.path = '/';
305
+ }
306
+ if (this.username) {
307
+ options.basic_auth = {user: this.username, pass: this.password || ''};
308
+ }
309
+ options.rejectUnauthorized = !this.insecure;
310
+ return this.tls ? lib.createSecureClient(options) : lib.createClient(options);
311
+ }
312
+
313
+ /** Calls a method on the interface process. Rejects with an Error (faults included). */
314
+ methodCall(method, params = []) {
315
+ if (!this.client) {
316
+ this.client = this.createClient();
317
+ }
318
+ if (TX_METHODS.has(method)) {
319
+ this.counters.tx += 1;
320
+ }
321
+ this.log.debug('rpc >', this.name, method, JSON.stringify(params).slice(0, 500));
322
+ return new Promise((resolve, reject) => {
323
+ let done = false;
324
+ const timer = setTimeout(() => {
325
+ done = true;
326
+ reject(new Error(`${method} timed out after ${this.callTimeout / 1000} s`));
327
+ }, this.callTimeout);
328
+ this.client.methodCall(method, params, (err, res) => {
329
+ clearTimeout(timer);
330
+ if (done) {
331
+ return;
332
+ }
333
+ done = true;
334
+ if (err) {
335
+ reject(err instanceof Error ? err : new Error(String(err.message || err)));
336
+ } else if (res && typeof res === 'object' && res.faultCode !== undefined) {
337
+ reject(new Error(`${res.faultString || 'fault'} (${res.faultCode})`));
338
+ } else {
339
+ this.log.debug('rpc <', this.name, method, JSON.stringify(res).slice(0, 500));
340
+ resolve(res);
341
+ }
342
+ });
343
+ });
344
+ }
345
+
346
+ /** Connects: init subscription (retried every 30 s while it fails), ping supervision. */
347
+ async start() {
348
+ this.stopped = false;
349
+ if (!this.wantInit) {
350
+ try {
351
+ await this.methodCall('system.listMethods');
352
+ this.setConnected(true);
353
+ } catch (err) {
354
+ this.log.warn('rpc', this.name, 'not reachable:', err.message);
355
+ }
356
+ return;
357
+ }
358
+ await this.init();
359
+ }
360
+
361
+ async init() {
362
+ if (this.stopped) {
363
+ return;
364
+ }
365
+ this.timers.clearTimeout(this.initTimer);
366
+ this.initTimer = null;
367
+ try {
368
+ const url = this.servers.url(this.protocol);
369
+ await this.servers.ensure(this.protocol);
370
+ this.servers.register(this.initId, this);
371
+ this.lastEvent = this.timers.now();
372
+ this.log.info('rpc', this.name, '> init', url, this.initId);
373
+ await this.methodCall('init', [url, this.initId]);
374
+ this.initialized = true;
375
+ this.lastInitError = null;
376
+ this.setConnected(true);
377
+ } catch (err) {
378
+ const msg = err.message || String(err);
379
+ if (msg !== this.lastInitError) {
380
+ this.log.warn('rpc', this.name, 'init failed:', msg, `- retrying every ${INIT_RETRY_MS / 1000} s`);
381
+ this.lastInitError = msg;
382
+ } else {
383
+ this.log.debug('rpc', this.name, 'init failed:', msg);
384
+ }
385
+ this.setConnected(false);
386
+ this.initTimer = this.timers.setTimeout(() => this.init(), INIT_RETRY_MS);
387
+ return;
388
+ }
389
+ if (this.wantPing) {
390
+ this.schedulePingCheck();
391
+ }
392
+ }
393
+
394
+ schedulePingCheck() {
395
+ this.timers.clearTimeout(this.pingTimer);
396
+ this.pingTimer = this.timers.setTimeout(() => this.checkPing(), this.pingTimeout * 250);
397
+ }
398
+
399
+ /** node-red-contrib-ccu's rpcCheckInit: ping after timeout/2 without events, re-init after timeout. */
400
+ async checkPing() {
401
+ if (this.stopped) {
402
+ return;
403
+ }
404
+ const elapsed = (this.timers.now() - this.lastEvent) / 1000;
405
+ if (elapsed > this.pingTimeout) {
406
+ this.log.warn('rpc', this.name, 'no event for', Math.round(elapsed), 's - re-init');
407
+ this.setConnected(false);
408
+ await this.init();
409
+ return;
410
+ }
411
+ if (elapsed >= this.pingTimeout / 2) {
412
+ try {
413
+ await this.methodCall('ping', ['hm2mqtt']);
414
+ } catch (err) {
415
+ this.log.warn('rpc', this.name, 'ping failed:', err.message);
416
+ this.setConnected(false);
417
+ }
418
+ }
419
+ this.schedulePingCheck();
420
+ }
421
+
422
+ setConnected(connected) {
423
+ connected = Boolean(connected);
424
+ if (connected === this.connected) {
425
+ return;
426
+ }
427
+ this.connected = connected;
428
+ this.log.info('rpc', this.name, connected ? 'connected' : 'disconnected');
429
+ this.emit('connected', connected);
430
+ }
431
+
432
+ /** Any incoming call from the CCU proves the subscription is alive. */
433
+ alive() {
434
+ this.lastEvent = this.timers.now();
435
+ if (!this.connected && this.initialized) {
436
+ this.setConnected(true);
437
+ }
438
+ }
439
+
440
+ listDevices() {
441
+ return this.listDevicesAnswer();
442
+ }
443
+
444
+ newDevices(devices) {
445
+ this.log.debug('rpc <', this.name, 'newDevices', devices.length);
446
+ this.emit('newDevices', devices);
447
+ }
448
+
449
+ deleteDevices(addresses) {
450
+ this.log.debug('rpc <', this.name, 'deleteDevices', addresses.length);
451
+ this.emit('deleteDevices', addresses);
452
+ }
453
+
454
+ /**
455
+ * @param {Array<[string, string, string, *]>} events [initId, channel, datapoint, value]
456
+ * @param {{working?: boolean, direction?: number}} hints
457
+ */
458
+ handleEvents(events, hints = {}) {
459
+ this.alive();
460
+ let counted = false;
461
+ for (const [, channel, datapoint, value] of events) {
462
+ if (typeof channel !== 'string' || typeof datapoint !== 'string') {
463
+ continue;
464
+ }
465
+ if (datapoint === 'PONG' && channel.includes('CENTRAL')) {
466
+ this.log.debug('rpc <', this.name, 'PONG', String(value));
467
+ continue;
468
+ }
469
+ if (!counted) {
470
+ this.counters.rx += 1;
471
+ counted = true;
472
+ }
473
+ this.emit('event', {channel, datapoint, value, working: hints.working, direction: hints.direction});
474
+ }
475
+ }
476
+
477
+ /** Unsubscribes (init with an empty id) and stops the timers. */
478
+ async stop() {
479
+ this.stopped = true;
480
+ this.timers.clearTimeout(this.initTimer);
481
+ this.timers.clearTimeout(this.pingTimer);
482
+ if (this.initialized) {
483
+ const url = this.servers.url(this.protocol);
484
+ this.log.info('rpc', this.name, '> init', url, '(unsubscribe)');
485
+ try {
486
+ await Promise.race([
487
+ this.methodCall('init', [url, '']),
488
+ new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), DEINIT_TIMEOUT_MS)),
489
+ ]);
490
+ } catch (err) {
491
+ this.log.debug('rpc', this.name, 'unsubscribe failed:', err.message);
492
+ }
493
+ this.initialized = false;
494
+ }
495
+ this.setConnected(false);
496
+ }
497
+ }
package/lib/topics.js ADDED
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Item names and the resolution of incoming topics to CCU targets.
3
+ *
4
+ * Items are the CCU's names, verbatim (H-3): <channelName>/<DATAPOINT>, <sysvarName>, <programName>,
5
+ * counter/<iface>/rx|tx, interface/<iface>/connected, <ifaceAddress>/DUTY_CYCLE.
6
+ */
7
+
8
+ export const DEFAULT_ITEM_TEMPLATE = '${channelName|channel}/${datapoint}';
9
+ export const DEFAULT_SYSVAR_ITEM_TEMPLATE = '${name}';
10
+ export const DEFAULT_PROGRAM_ITEM_TEMPLATE = '${name}';
11
+
12
+ /** first topic levels an adapter uses itself; a channel or variable with such a name gets a suffix */
13
+ export const RESERVED = new Set(['counter', 'interface', 'rega', 'paramset', 'rpc', 'response']);
14
+
15
+ /**
16
+ * Makes a CCU name usable as topic level(s): `+`, `#` and empty levels are replaced, `/` is kept
17
+ * (a deeper topic, as node-red-contrib-ccu did). Returns the name and whether it was changed.
18
+ * @param {string} name
19
+ * @returns {{name: string, changed: boolean}}
20
+ */
21
+ export function sanitizeName(name) {
22
+ const original = String(name);
23
+ let out = original.replace(/[+#]/g, '_');
24
+ out = out
25
+ .split('/')
26
+ .map((level) => (level === '' ? '_' : level))
27
+ .join('/');
28
+ if (RESERVED.has(out.split('/')[0])) {
29
+ out = out.replace(/^[^/]+/, (m) => m + '_');
30
+ }
31
+ return {name: out, changed: out !== original};
32
+ }
33
+
34
+ /**
35
+ * Status item of a datapoint event.
36
+ * @param {string} channelName ReGa name or the address when unknown
37
+ * @param {string} datapoint
38
+ */
39
+ export function datapointItem(channelName, datapoint) {
40
+ return sanitizeName(channelName).name + '/' + datapoint;
41
+ }
42
+
43
+ /**
44
+ * Resolves the levels of <name>/set/<...> to a target.
45
+ * [<channelNameOrAddress...>, <datapoint>] → datapoint
46
+ * [<sysvarOrProgramName...>] → sysvar or program
47
+ * ['rega', 'sync'] → command
48
+ * The last level is the datapoint when the rest names a channel; a sysvar/program name wins when
49
+ * the whole path is one (they may contain "/" too).
50
+ * @param {string[]} parts
51
+ * @param {{channelAddress: (name: string) => string | undefined, isSysvar: (name: string) => boolean,
52
+ * isProgram: (name: string) => boolean}} lookup
53
+ * @returns {{kind: 'datapoint', address: string, datapoint: string} | {kind: 'sysvar' | 'program', name: string}
54
+ * | {kind: 'command', command: string} | null}
55
+ */
56
+ export function resolveSet(parts, lookup) {
57
+ if (parts.length === 0) {
58
+ return null;
59
+ }
60
+ if (parts[0] === 'rega' && parts.length === 2) {
61
+ return {kind: 'command', command: parts[1]};
62
+ }
63
+ const whole = parts.join('/');
64
+ if (lookup.isSysvar(whole)) {
65
+ return {kind: 'sysvar', name: whole};
66
+ }
67
+ if (lookup.isProgram(whole)) {
68
+ return {kind: 'program', name: whole};
69
+ }
70
+ if (parts.length >= 2) {
71
+ const datapoint = parts[parts.length - 1];
72
+ const channel = parts.slice(0, -1).join('/');
73
+ const address = lookup.channelAddress(channel);
74
+ if (address) {
75
+ return {kind: 'datapoint', address, datapoint};
76
+ }
77
+ }
78
+ return null;
79
+ }
80
+
81
+ /**
82
+ * Resolves the levels of <name>/paramset/<channelNameOrAddress...>/<paramset>[/<param>].
83
+ * With `single`, the last level is the parameter name.
84
+ * @param {string[]} parts
85
+ * @param {{channelAddress: (name: string) => string | undefined}} lookup
86
+ * @param {{single?: boolean}} [options]
87
+ * @returns {{address: string, paramset: string, param?: string} | null}
88
+ */
89
+ export function resolveParamset(parts, lookup, {single = false} = {}) {
90
+ const need = single ? 3 : 2;
91
+ if (parts.length < need) {
92
+ return null;
93
+ }
94
+ const param = single ? parts[parts.length - 1] : undefined;
95
+ const paramset = parts[parts.length - (single ? 2 : 1)];
96
+ const channel = parts.slice(0, parts.length - (single ? 2 : 1)).join('/');
97
+ const address = lookup.channelAddress(channel, true);
98
+ if (!address) {
99
+ return null;
100
+ }
101
+ return single ? {address, paramset, param} : {address, paramset};
102
+ }
103
+
104
+ /** Plain payload as the second ccu-mqtt node emitted it: booleans as 0/1, the rest as is. */
105
+ export function plainValue(value) {
106
+ return typeof value === 'boolean' ? Number(value) : value;
107
+ }
108
+
109
+ /**
110
+ * Compiles an item template like node-red-contrib-ccu's topic templates: `${field}` placeholders
111
+ * with a `|` fallback chain (`${channelName|channel}`), field names case-insensitive, every field
112
+ * of the `hm` block available. An empty result of a placeholder becomes `_`. The rendered item is
113
+ * passed through sanitizeName().
114
+ * @param {string} template
115
+ * @returns {(fields: object) => {name: string, changed: boolean}}
116
+ */
117
+ export function compileTemplate(template) {
118
+ const parts = [];
119
+ const re = /\$\{([^}]+)\}/g;
120
+ let last = 0;
121
+ let m;
122
+ while ((m = re.exec(template)) !== null) {
123
+ if (m.index > last) {
124
+ parts.push(template.slice(last, m.index));
125
+ }
126
+ parts.push(
127
+ m[1]
128
+ .split('|')
129
+ .map((f) => f.trim().toLowerCase())
130
+ .filter(Boolean),
131
+ );
132
+ last = m.index + m[0].length;
133
+ }
134
+ if (last < template.length) {
135
+ parts.push(template.slice(last));
136
+ }
137
+ return (fields) => {
138
+ const lower = {};
139
+ for (const [k, v] of Object.entries(fields || {})) {
140
+ lower[k.toLowerCase()] = v;
141
+ }
142
+ let out = '';
143
+ for (const part of parts) {
144
+ if (typeof part === 'string') {
145
+ out += part;
146
+ continue;
147
+ }
148
+ let value = '';
149
+ for (const key of part) {
150
+ const v = lower[key];
151
+ if (v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0)) {
152
+ continue;
153
+ }
154
+ value = Array.isArray(v) ? v.join(',') : String(v);
155
+ break;
156
+ }
157
+ out += value === '' ? '_' : value;
158
+ }
159
+ return sanitizeName(out);
160
+ };
161
+ }
162
+
163
+ /**
164
+ * Reverse map item → target for `set` topics: {address, datapoint} for datapoints,
165
+ * {sysvar} / {program} for ReGa objects. The first target of a colliding item wins.
166
+ */
167
+ export class ItemIndex {
168
+ constructor() {
169
+ this.items = new Map();
170
+ this.collisions = new Map();
171
+ }
172
+
173
+ clear(kind) {
174
+ if (!kind) {
175
+ this.items.clear();
176
+ this.collisions.clear();
177
+ return;
178
+ }
179
+ for (const [item, target] of this.items) {
180
+ if (target.kind === kind) {
181
+ this.items.delete(item);
182
+ }
183
+ }
184
+ }
185
+
186
+ add(item, target) {
187
+ const existing = this.items.get(item);
188
+ if (!existing) {
189
+ this.items.set(item, target);
190
+ return true;
191
+ }
192
+ if (
193
+ existing.kind === target.kind &&
194
+ existing.address === target.address &&
195
+ existing.datapoint === target.datapoint &&
196
+ existing.name === target.name
197
+ ) {
198
+ return true;
199
+ }
200
+ this.collisions.set(item, [existing, target]);
201
+ return false;
202
+ }
203
+
204
+ get(item) {
205
+ return this.items.get(item);
206
+ }
207
+
208
+ get size() {
209
+ return this.items.size;
210
+ }
211
+ }