hm2mqtt 3.6.2 → 3.8.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/README.md CHANGED
@@ -76,6 +76,9 @@ the automatic detection.
76
76
 
77
77
  The addon packages are marked `-beta` until someone has confirmed an install on real hardware.
78
78
 
79
+ On [openccu-lite](#openccu-lite) the same package is installed from the system's addon catalogue
80
+ (_Addons → Catalogue_); there is nothing to download by hand.
81
+
79
82
  ## Finding the CCU
80
83
 
81
84
  ```
@@ -271,6 +274,21 @@ names, rooms and functions from the box's **metadata API** instead of from the R
271
274
  RaspberryMatic or OpenCCU answers 404 there and everything stays exactly as before — the same
272
275
  configuration works on both, which is what a backup restored on the other kind of box needs.
273
276
 
277
+ **Install and start.** On the system itself hm2mqtt is installed from the addon catalogue
278
+ (_Addons → Catalogue_, the same package as on a CCU; its manifest `openccu-lite.json` ships in the
279
+ package). The addon declares the early start: its unit starts at boot before `rfd` and `hmipserver`,
280
+ and hm2mqtt waits for them quietly — an interface that does not answer yet is probed and subscribed
281
+ again after 1, 2, 4 and 8 s, then every 15 s, with one info line and no warning, and one that comes
282
+ up later (or after a restart of its process) is picked up without a restart of hm2mqtt. The system
283
+ can switch the early start off on its Addons page; the unit then starts after both. The broker URL
284
+ is the one setting to make, as on a CCU. The log is in the journal and nowhere else — no
285
+ `var/hm2mqtt.log` on the SD card: `journalctl -u addon-hm2mqtt`, the system's _Log_ page
286
+ (`/system/log?unit=addon-hm2mqtt`), and the settings page's log view, which reads it with the
287
+ addon's own API token (the manifest asks for `logs:read`). The system opens the settings page
288
+ without `?sid=`: the page and its CGIs take the session from the system's `X-Occulite-Session`
289
+ header and confirm it with the system (the manifest declares `ui.session_header`); `?sid=` keeps
290
+ working for a CCU and for an openccu-lite image from before the header.
291
+
274
292
  Names arrive as a snapshot at start and then over the box's event stream (`/api/meta/v1/events/sse`):
275
293
  a rename in the box's UI is in the topics about a second later, without a poll and without a
276
294
  restart. The store is cached in `meta.json` in the state directory, so a start without the box
package/index.js CHANGED
@@ -15,7 +15,15 @@ import {Rega} from 'homematic-rega';
15
15
  import config from './config.js';
16
16
  import pkg from './package.json' with {type: 'json'};
17
17
  import {handle as handleInstall} from './lib/install.js';
18
- import {parseInterfaces, probeInterfaces, interfaceConfig, detectLocal, regaPort} from './lib/interfaces.js';
18
+ import {
19
+ parseInterfaces,
20
+ probeInterfaces,
21
+ watchInterfaces,
22
+ interfaceConfig,
23
+ detectLocal,
24
+ regaPort,
25
+ INTERFACE_NAMES,
26
+ } from './lib/interfaces.js';
19
27
  import {RpcServers, RpcConnection} from './lib/rpc.js';
20
28
  import {Metadata} from './lib/metadata.js';
21
29
  import {ValueStore, hmBlock, isEvent} from './lib/values.js';
@@ -69,6 +77,8 @@ const STOP_TIMEOUT_MS = 1500;
69
77
  const RESOLVE_RETRY_MS = 10000;
70
78
  /** how often an unreachable box is probed for the metadata api again (H-47) */
71
79
  const REDETECT_MS = 60000;
80
+ /** how long the local-mode probe waits for the interface processes on a loopback address (B-1) */
81
+ const LOCAL_WAIT_MS = 120000;
72
82
 
73
83
  const here = path.dirname(fileURLToPath(import.meta.url));
74
84
  /** the configured CCU address (the `ccu` field of every payload); connections use the resolved ip */
@@ -234,7 +244,16 @@ for (const pattern of listenPatterns) {
234
244
  */
235
245
  // an explicit --ccu-tls is a tunnel or a proxy by definition - local mode would silently drop the
236
246
  // TLS and the ports the user asked for, so it is never probed into, only chosen with --local
237
- const localMode = config.local === undefined ? !config.ccuTls && (await detectLocal(host)) : Boolean(config.local);
247
+ // on a loopback address where nothing answers yet the interface processes may still be starting
248
+ // (a boot, openccu-lite's early start): wait for them up to two minutes before deciding (B-1)
249
+ const localMode =
250
+ config.local === undefined
251
+ ? !config.ccuTls &&
252
+ (await detectLocal(host, {
253
+ waitMs: LOCAL_WAIT_MS,
254
+ onWait: () => log.info('no interface process answers on', host, 'yet - waiting for them'),
255
+ }))
256
+ : Boolean(config.local);
238
257
  if (localMode) {
239
258
  log.info('local mode: BidCos over binrpc (32001/32000), hmipserver on 32010, ReGa on 8183');
240
259
  }
@@ -331,6 +350,8 @@ function publishTopic(topic, value, {retain = true, extra, ts, lc} = {}) {
331
350
  let indexTimer = null;
332
351
 
333
352
  let enabled = [];
353
+ /** --interfaces auto: the interfaces the start probe missed, probed on until they answer (B-1) */
354
+ let interfaceWatch = null;
334
355
  const connections = {};
335
356
  let servers = null;
336
357
  let regaOk = false;
@@ -995,10 +1016,16 @@ async function start() {
995
1016
  } else if (regaSync) {
996
1017
  regaSync.setHost(ccuIp);
997
1018
  }
998
- enabled =
999
- parseInterfaces(config.interfaces) || (await probeInterfaces(ccuIp, {tls: config.ccuTls, local: localMode}));
1019
+ const listed = parseInterfaces(config.interfaces);
1020
+ enabled = listed || (await probeInterfaces(ccuIp, {tls: config.ccuTls, local: localMode}));
1000
1021
  if (enabled.length === 0) {
1001
- log.error('no interface found on', host, '- check --ccu-address / --interfaces');
1022
+ // nothing answers yet: on the box itself the interface processes are still starting, which is
1023
+ // no fault; the probe goes on (B-1)
1024
+ (localMode ? log.info : log.warn)(
1025
+ 'no interface answers on',
1026
+ host,
1027
+ 'yet - probing again' + (localMode ? '' : '; check --ccu-address / --interfaces if this stays'),
1028
+ );
1002
1029
  }
1003
1030
  log.info('interfaces:', enabled.join(', ') || '(none)');
1004
1031
  // locally the CCU calls back over loopback, so nothing of ours needs to listen on the LAN
@@ -1009,6 +1036,7 @@ async function start() {
1009
1036
  xmlrpcPort: config.xmlrpcPort,
1010
1037
  binrpcPort: config.binrpcPort,
1011
1038
  log,
1039
+ ownPrefix: `hm2mqtt_${config.name}_`,
1012
1040
  });
1013
1041
  for (const iface of enabled) {
1014
1042
  connections[iface] = createConnection(iface);
@@ -1022,6 +1050,13 @@ async function start() {
1022
1050
  await Promise.all(enabled.map((iface) => connections[iface].start()));
1023
1051
  updateConnected();
1024
1052
  adapter.publishInfo();
1053
+ if (!listed) {
1054
+ interfaceWatch = watchInterfaces(
1055
+ ccuIp,
1056
+ INTERFACE_NAMES.filter((iface) => !enabled.includes(iface)),
1057
+ {tls: config.ccuTls, local: localMode, onFound: addInterface},
1058
+ );
1059
+ }
1025
1060
 
1026
1061
  timers.push(setInterval(publishCounters, COUNTER_INTERVAL_MS));
1027
1062
  if (config.dutyCycleInterval > 0) {
@@ -1031,7 +1066,25 @@ async function start() {
1031
1066
  timers.push(setInterval(() => values.save(), VALUES_SAVE_MS));
1032
1067
  }
1033
1068
 
1069
+ /** An interface the start probe missed answers now: subscribe to it like to the others (B-1). */
1070
+ function addInterface(iface) {
1071
+ if (enabled.includes(iface)) {
1072
+ return;
1073
+ }
1074
+ enabled = INTERFACE_NAMES.filter((name) => name === iface || enabled.includes(name));
1075
+ log.info('interface', iface, 'answers now - interfaces:', enabled.join(', '));
1076
+ connections[iface] = createConnection(iface);
1077
+ publishItem(`interface/${iface}/connected`, false);
1078
+ rebuildIndex();
1079
+ updateConnected();
1080
+ adapter.publishInfo();
1081
+ connections[iface].start().catch((err) => log.warn('rpc', iface, 'start failed:', err.message));
1082
+ }
1083
+
1034
1084
  async function shutdown() {
1085
+ if (interfaceWatch) {
1086
+ interfaceWatch.stop();
1087
+ }
1035
1088
  for (const timer of timers) {
1036
1089
  clearInterval(timer);
1037
1090
  }
package/lib/interfaces.js CHANGED
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import net from 'node:net';
12
+ import {initRetryDelay} from './rpc.js';
12
13
 
13
14
  export const INTERFACES = {
14
15
  'BidCos-RF': {
@@ -139,12 +140,15 @@ function portOpen(host, port, timeout) {
139
140
  /**
140
141
  * Probes which interface ports answer on the CCU (--interfaces auto).
141
142
  * @param {string} host
142
- * @param {{tls?: boolean, local?: boolean, timeout?: number, connect?: Function}} [options]
143
- * @returns {Promise<string[]>} interface names in table order
143
+ * @param {{tls?: boolean, local?: boolean, timeout?: number, connect?: Function, names?: string[]}} [options]
144
+ * @returns {Promise<string[]>} interface names in table order (of `names`)
144
145
  */
145
- export async function probeInterfaces(host, {tls = false, local = false, timeout = 2000, connect = portOpen} = {}) {
146
+ export async function probeInterfaces(
147
+ host,
148
+ {tls = false, local = false, timeout = 2000, connect = portOpen, names = INTERFACE_NAMES} = {},
149
+ ) {
146
150
  const found = [];
147
- for (const name of INTERFACE_NAMES) {
151
+ for (const name of names) {
148
152
  const {port} = interfaceConfig(name, {tls, local});
149
153
  if (await connect(host, port, timeout)) {
150
154
  found.push(name);
@@ -153,6 +157,67 @@ export async function probeInterfaces(host, {tls = false, local = false, timeout
153
157
  return found;
154
158
  }
155
159
 
160
+ /**
161
+ * Probes the interfaces `--interfaces auto` did not find at the start again, on the schedule of a
162
+ * failed init (1, 2, 4, 8 s, then every 15 s), and hands each one that answers to `onFound` - once;
163
+ * the others are probed on until they answer or `stop()` is called (B-1). An interface process that
164
+ * comes up after hm2mqtt (openccu-lite's early start, a restart of hmipserver, a LAN gateway added
165
+ * later) is picked up this way without a restart.
166
+ * @param {string} host
167
+ * @param {string[]} missing interface names
168
+ * @param {object} o
169
+ * @param {(name: string) => void} o.onFound
170
+ * @param {boolean} [o.tls]
171
+ * @param {boolean} [o.local]
172
+ * @param {number} [o.timeout] ms per probe
173
+ * @param {Function} [o.connect] for tests
174
+ * @param {{setTimeout: Function, clearTimeout: Function}} [o.timers] for tests
175
+ * @returns {{stop: () => void, missing: () => string[]}}
176
+ */
177
+ export function watchInterfaces(
178
+ host,
179
+ missing,
180
+ {onFound, tls = false, local = false, timeout = 2000, connect = portOpen, timers = {setTimeout, clearTimeout}},
181
+ ) {
182
+ let left = INTERFACE_NAMES.filter((name) => missing.includes(name));
183
+ let rounds = 0;
184
+ let timer = null;
185
+ let stopped = false;
186
+ const round = async () => {
187
+ timer = null;
188
+ const found = await probeInterfaces(host, {tls, local, timeout, connect, names: left});
189
+ if (stopped) {
190
+ return;
191
+ }
192
+ left = left.filter((name) => !found.includes(name));
193
+ for (const name of found) {
194
+ onFound(name);
195
+ }
196
+ schedule();
197
+ };
198
+ const schedule = () => {
199
+ if (stopped || left.length === 0) {
200
+ return;
201
+ }
202
+ rounds += 1;
203
+ timer = timers.setTimeout(round, initRetryDelay(rounds));
204
+ if (timer && typeof timer.unref === 'function') {
205
+ timer.unref();
206
+ }
207
+ };
208
+ schedule();
209
+ return {
210
+ stop() {
211
+ stopped = true;
212
+ if (timer) {
213
+ timers.clearTimeout(timer);
214
+ timer = null;
215
+ }
216
+ },
217
+ missing: () => [...left],
218
+ };
219
+ }
220
+
156
221
  /**
157
222
  * Is this address the machine we run on?
158
223
  * @param {string} host
@@ -171,17 +236,52 @@ export function isLocalHost(host) {
171
236
  * node-red-contrib-ccu does - that check looks for `"port" => 32001` in
172
237
  * /etc/lighttpd/conf.d/proxy.conf, which on firmware 3.8x is a one-line include, so it silently
173
238
  * stopped detecting anything while the ports themselves are all still there.
239
+ *
240
+ * On a loopback address where nothing answers yet, the processes may still be starting (a boot, or
241
+ * openccu-lite's early start): with `waitMs` the probe is repeated on the init schedule (1, 2, 4,
242
+ * 8 s, then every 15 s) until one answers or `waitMs` has passed (B-1); `onWait` is called once,
243
+ * before the first repetition. When the CCU's usual ports answer instead (a tunnel, a simulator,
244
+ * lighttpd's proxies without the processes behind them) it is not local mode and nothing waits.
174
245
  * @param {string} host
175
- * @param {{timeout?: number, connect?: Function}} [options]
246
+ * @param {{timeout?: number, connect?: Function, waitMs?: number, onWait?: Function,
247
+ * sleep?: (ms: number) => Promise<void>, now?: () => number}} [options]
176
248
  * @returns {Promise<boolean>}
177
249
  */
178
- export async function detectLocal(host, {timeout = 500, connect = portOpen} = {}) {
250
+ export async function detectLocal(
251
+ host,
252
+ {
253
+ timeout = 500,
254
+ connect = portOpen,
255
+ waitMs = 0,
256
+ onWait = () => {},
257
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
258
+ now = Date.now,
259
+ } = {},
260
+ ) {
179
261
  if (!isLocalHost(host)) {
180
262
  return false;
181
263
  }
182
264
  const ports = INTERFACE_NAMES.map((name) => INTERFACES[name].localPort).filter(Boolean);
183
- // concurrently: this runs at every startup, and closed-but-filtered ports would otherwise
184
- // stack their timeouts
185
- const results = await Promise.all([...ports, REGA_LOCAL_PORT].map((port) => connect(host, port, timeout)));
186
- return results.some(Boolean);
265
+ const start = now();
266
+ for (let attempt = 1; ; attempt += 1) {
267
+ // concurrently: this runs at every startup, and closed-but-filtered ports would otherwise
268
+ // stack their timeouts
269
+ const results = await Promise.all([...ports, REGA_LOCAL_PORT].map((port) => connect(host, port, timeout)));
270
+ if (results.some(Boolean)) {
271
+ return true;
272
+ }
273
+ const delay = initRetryDelay(attempt);
274
+ if (now() - start + delay > waitMs) {
275
+ return false;
276
+ }
277
+ const usual = INTERFACE_NAMES.map((name) => INTERFACES[name].port).filter(Boolean);
278
+ const proxied = await Promise.all([...usual, REGA_PORT].map((port) => connect(host, port, timeout)));
279
+ if (proxied.some(Boolean)) {
280
+ return false;
281
+ }
282
+ if (attempt === 1) {
283
+ onWait();
284
+ }
285
+ await sleep(delay);
286
+ }
187
287
  }
package/lib/rpc.js CHANGED
@@ -22,9 +22,9 @@ export const METHODS = [
22
22
  ];
23
23
 
24
24
  const TX_METHODS = new Set(['setValue', 'putParamset', 'activateLinkParamset']);
25
- /** a failed init is retried after 2, 4, 8, 16 s, then every INIT_RETRY_MS (the cap) */
26
- const INIT_BACKOFF_MS = [2000, 4000, 8000, 16000];
27
- const INIT_RETRY_MS = 30000;
25
+ /** a failed init is retried after 1, 2, 4, 8 s, then every INIT_RETRY_MS (the cap; task 21) */
26
+ const INIT_BACKOFF_MS = [1000, 2000, 4000, 8000];
27
+ export const INIT_RETRY_MS = 15000;
28
28
  const DEINIT_TIMEOUT_MS = 1500;
29
29
  const CALL_TIMEOUT_MS = 30000;
30
30
 
@@ -50,6 +50,19 @@ export function batchHints(events) {
50
50
  return hints;
51
51
  }
52
52
 
53
+ /**
54
+ * Whether an error only says that nothing listens on the port yet - an interface process that is
55
+ * still starting (openccu-lite starts the addon before rfd and hmipserver), or one that is
56
+ * restarting. Such a wait is normal at boot and logged as info, not as a warning.
57
+ * @param {Error|string} err
58
+ * @returns {boolean}
59
+ */
60
+ export function notListening(err) {
61
+ const code = err && err.code;
62
+ const msg = String((err && err.message) || err || '');
63
+ return code === 'ECONNREFUSED' || /ECONNREFUSED/.test(msg);
64
+ }
65
+
53
66
  /**
54
67
  * The delay before the next init attempt.
55
68
  * @param {number} failures failed attempts in a row (1 after the first failure)
@@ -72,8 +85,12 @@ export class RpcServers {
72
85
  * @param {number} o.binrpcPort
73
86
  * @param {object} o.log
74
87
  * @param {{xmlrpc?: object, binrpc?: object}} [o.libs] for tests
88
+ * @param {string} [o.ownPrefix] the start of this instance's init ids: a call for one of them that
89
+ * is not registered (yet) is expected - an interface process that re-announces itself to a
90
+ * subscription of the last run before this one subscribed again - and logged as debug
75
91
  */
76
- constructor({listenAddress, initAddress, xmlrpcPort, binrpcPort, log, libs = {}}) {
92
+ constructor({listenAddress, initAddress, xmlrpcPort, binrpcPort, log, libs = {}, ownPrefix}) {
93
+ this.ownPrefix = ownPrefix;
77
94
  this.listenAddress = listenAddress;
78
95
  this.initAddress = initAddress || listenAddress;
79
96
  this.ports = {xmlrpc: xmlrpcPort, binrpc: binrpcPort};
@@ -130,7 +147,8 @@ export class RpcServers {
130
147
  const initId = Array.isArray(params) ? params[0] : undefined;
131
148
  const connection = this.connections.get(initId);
132
149
  if (!connection) {
133
- this.log.warn('rpc < call for unknown init id', String(initId));
150
+ const own = this.ownPrefix && typeof initId === 'string' && initId.startsWith(this.ownPrefix);
151
+ (own ? this.log.debug : this.log.warn)('rpc < call for unknown init id', String(initId));
134
152
  }
135
153
  return connection;
136
154
  }
@@ -300,6 +318,18 @@ export class RpcConnection extends EventEmitter {
300
318
  this.pingTimer = null;
301
319
  this.lastInitError = null;
302
320
  this.initFailures = 0;
321
+ this.everInitialized = false;
322
+ }
323
+
324
+ /** Closes the client (binrpc: its socket and reconnect timer); the next call creates a new one. */
325
+ dropClient() {
326
+ if (this.protocol !== 'binrpc' || !this.client) {
327
+ return;
328
+ }
329
+ if (typeof this.client.close === 'function') {
330
+ this.client.close();
331
+ }
332
+ this.client = null;
303
333
  }
304
334
 
305
335
  createClient() {
@@ -384,6 +414,7 @@ export class RpcConnection extends EventEmitter {
384
414
  this.log.info('rpc', this.name, '> init', url, this.initId);
385
415
  await this.methodCall('init', [url, this.initId]);
386
416
  this.initialized = true;
417
+ this.everInitialized = true;
387
418
  if (this.initFailures > 0) {
388
419
  this.log.info('rpc', this.name, 'init succeeded after', this.initFailures + 1, 'attempts');
389
420
  }
@@ -397,17 +428,19 @@ export class RpcConnection extends EventEmitter {
397
428
  const msg = err.message || String(err);
398
429
  this.initFailures += 1;
399
430
  const delay = initRetryDelay(this.initFailures);
400
- if (msg !== this.lastInitError) {
401
- this.log.warn(
402
- 'rpc',
403
- this.name,
404
- 'init failed:',
405
- msg,
406
- `- retrying in ${delay / 1000} s (then up to every ${INIT_RETRY_MS / 1000} s)`,
407
- );
431
+ // a binrpc client whose socket is gone waits for its own reconnect timer (2.5 s doubling
432
+ // up to 30 s) and fails every call meanwhile; a fresh one connects at the next attempt
433
+ this.dropClient();
434
+ const retry = `retrying in ${delay / 1000} s (then up to every ${INIT_RETRY_MS / 1000} s)`;
435
+ if (msg === this.lastInitError) {
436
+ this.log.debug('rpc', this.name, 'init failed:', msg, `- next attempt in ${delay / 1000} s`);
437
+ } else if (!this.everInitialized && notListening(err)) {
438
+ // the start before the interface process (openccu-lite's early start): a wait, not a fault
439
+ this.log.info('rpc', this.name, `nothing is listening on ${this.host}:${this.port} yet - ${retry}`);
408
440
  this.lastInitError = msg;
409
441
  } else {
410
- this.log.debug('rpc', this.name, 'init failed:', msg, `- next attempt in ${delay / 1000} s`);
442
+ this.log.warn('rpc', this.name, 'init failed:', msg, `- ${retry}`);
443
+ this.lastInitError = msg;
411
444
  }
412
445
  this.setConnected(false);
413
446
  this.initTimer = this.timers.setTimeout(() => this.init(), delay);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hm2mqtt",
3
- "version": "3.6.2",
3
+ "version": "3.8.0",
4
4
  "description": "Interface between Homematic CCU and MQTT",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -67,7 +67,7 @@
67
67
  "serviceExtra": []
68
68
  },
69
69
  "dependencies": {
70
- "binrpc": "^3.3.1",
70
+ "binrpc": "^4.3.0",
71
71
  "homematic-rega": "^2.0.0",
72
72
  "homematic-xmlrpc": "^2.0.0",
73
73
  "mqtt-interfaces-core": "^0.15.2"