hm2mqtt 3.5.1 → 3.6.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
@@ -259,7 +259,51 @@ the plain format (the second `ccu-mqtt` node of a flow).
259
259
  Names come from the ReGa (devices, channels, rooms, functions), are cached in the state
260
260
  directory and re-read with `<name>/set/rega/sync`. A `--name-file` overrides single addresses.
261
261
  Without the ReGa (`--no-rega`) topics use addresses. Events of channels without a name use the
262
- address as well.
262
+ address as well. On a box without ReGaHSS they come from the metadata API instead — see
263
+ [openccu-lite](#openccu-lite).
264
+
265
+ ## openccu-lite
266
+
267
+ [openccu-lite](https://github.com/hobbyquaker/openccu-lite) is a CCU firmware without ReGaHSS.
268
+ hm2mqtt works there without a changed configuration: at start (and again on reconnect) it asks
269
+ `GET http://<ccu-address>/api/meta/v1/version`, and when that answers it takes device and channel
270
+ names, rooms and functions from the box's **metadata API** instead of from the ReGa. A CCU3,
271
+ RaspberryMatic or OpenCCU answers 404 there and everything stays exactly as before — the same
272
+ configuration works on both, which is what a backup restored on the other kind of box needs.
273
+
274
+ Names arrive as a snapshot at start and then over the box's event stream (`/api/meta/v1/events/sse`):
275
+ a rename in the box's UI is in the topics about a second later, without a poll and without a
276
+ restart. The store is cached in `meta.json` in the state directory, so a start without the box
277
+ still has the names. `<name>/set/rega/sync` re-reads the snapshot here too.
278
+
279
+ **The credential.** Everything but the version call needs one:
280
+
281
+ | where hm2mqtt runs | credential |
282
+ | ------------------ | ----------------------------------------------------------------------------------------------------- |
283
+ | on the box (addon) | the box's own read-only token, read from `/usr/local/etc/occulite/local-token` — nothing to configure |
284
+ | anywhere else | `--meta-token` (`HM2MQTT_META_TOKEN`): an API token (`olt_…`) created on the box under _Benutzer_ |
285
+
286
+ Without a valid credential hm2mqtt logs one line and runs on addresses; it picks the names up as
287
+ soon as a token works, without a restart. `--meta-url` overrides the base url when the box is
288
+ behind a proxy or on another port. Note that openccu-lite's interface processes listen on loopback
289
+ only, so hm2mqtt normally runs **on** the box (addon package) or goes through its XML-RPC proxy.
290
+
291
+ **What has no replacement** on such a box (from openccu-lite's `docs/porting-from-rega.md`):
292
+
293
+ - **System variables** and **programs**: there is no ReGa DOM. Users who need them run automation
294
+ in Node-RED (RedMatic), Home Assistant, or whatever the addon is bridging to.
295
+ - **`exec()`** of HM-Script, `dom.GetObject`, `system.GetSessionVarStr` from your own code: gone.
296
+ - **ReGa ids** (`dom.GetObject(1234)`): there are none. Refs (`<interface>.<address>`) are the identity.
297
+ - **Service messages / alarms** (variables 40 and 41): interface-level state only.
298
+ - **The CCU WebUI's JSON-RPC API** (`/api/homematic.cgi`, `Session.login`, `Device.listAll`,
299
+ `Interface.*`): not present.
300
+
301
+ In hm2mqtt that means: no `status/<variable>` and `status/<program>` topics and no writes to them,
302
+ and no `--publish-cache` (the value cache was ReGa's `getValues`). `--rega-poll-interval`,
303
+ `--rega-poll-trigger` and the `--topic-sysvar-*`/`--topic-program-*` templates stay accepted so one
304
+ configuration serves both kinds of box; on openccu-lite they do nothing and one log line at start
305
+ says so. Rooms and functions are trees there — a channel in `room/eg/wohnzimmer` is reported as
306
+ `Wohnzimmer`, the parent nodes are not added, so `hm.rooms` looks exactly as it did on a CCU.
263
307
 
264
308
  ## Home Assistant
265
309
 
package/config.js CHANGED
@@ -72,6 +72,16 @@ export const OPTIONS = {
72
72
  type: 'string',
73
73
  describe: 'channel.datapoint whose event triggers a variable/program poll, e.g. BidCoS-RF:50.PRESS_SHORT',
74
74
  },
75
+ 'meta-token': {
76
+ type: 'string',
77
+ describe:
78
+ 'openccu-lite API token (olt_...) for names, rooms and functions - not needed on the box itself, where the local token is read',
79
+ secret: true,
80
+ },
81
+ 'meta-url': {
82
+ type: 'string',
83
+ describe: "base url of openccu-lite's metadata api (default: http[s]://<ccu-address>)",
84
+ },
75
85
  'ccu-timezone': {type: 'string', describe: "IANA time zone of the CCU (default: this host's time zone)"},
76
86
  'name-file': {
77
87
  alias: 'm',
package/index.js CHANGED
@@ -20,6 +20,7 @@ import {RpcServers, RpcConnection} from './lib/rpc.js';
20
20
  import {Metadata} from './lib/metadata.js';
21
21
  import {ValueStore, hmBlock, isEvent} from './lib/values.js';
22
22
  import {RegaSync} from './lib/rega.js';
23
+ import {MetaSync, detectMeta} from './lib/meta.js';
23
24
  import {castValue, isWriteable} from './lib/cast.js';
24
25
  import {
25
26
  sanitizeName,
@@ -66,6 +67,8 @@ const SET_THROTTLE_MS = 500;
66
67
  const DEVICES_WAIT_MS = 10000;
67
68
  const STOP_TIMEOUT_MS = 1500;
68
69
  const RESOLVE_RETRY_MS = 10000;
70
+ /** how often an unreachable box is probed for the metadata api again (H-47) */
71
+ const REDETECT_MS = 60000;
69
72
 
70
73
  const here = path.dirname(fileURLToPath(import.meta.url));
71
74
  /** the configured CCU address (the `ccu` field of every payload); connections use the resolved ip */
@@ -149,11 +152,17 @@ const renderSysvarStatus = compileTemplate(config.topicSysvarStatus);
149
152
  const renderSysvarSet = compileTemplate(config.topicSysvarSet);
150
153
  const renderProgramStatus = compileTemplate(config.topicProgramStatus);
151
154
  const renderProgramSet = compileTemplate(config.topicProgramSet);
152
- /** what the plain mirror tree calls an item: the status topic without the `<name>/status/` head */
155
+ /** the literal part of the status template - what every rendered status topic starts with */
156
+ const statusLiteral = subscribePattern(config.topicStatus, topicFields).replace(/#$/, '');
157
+ /** what the plain mirror tree calls an item: the status topic without the template's literal head */
153
158
  const plainItem = (topic) => {
154
- const head = `${config.name}/status/`;
155
- return topic.startsWith(head) ? topic.slice(head.length) : topic;
159
+ const remainder = templateRemainder(config.topicStatus, topic, topicFields);
160
+ return remainder === null || remainder === '' ? topic : remainder;
156
161
  };
162
+ /** the set subscriptions that leave `<name>/set/` and therefore need a listen pattern */
163
+ const listenPatterns = [...new Set([config.topicSet, config.topicSysvarSet, config.topicProgramSet])]
164
+ .map((template) => subscribePattern(template, topicFields))
165
+ .filter((pattern) => !pattern.startsWith(`${config.name}/set/`));
157
166
  const adapter = createAdapter({
158
167
  pkg,
159
168
  config,
@@ -180,6 +189,7 @@ const adapter = createAdapter({
180
189
  interfaces: enabled,
181
190
  devices: metadata.count(),
182
191
  rega: Boolean(regaSync),
192
+ names: regaSync ? regaSync.label : 'addresses',
183
193
  payload: payloadFormat,
184
194
  }),
185
195
  /*
@@ -190,12 +200,7 @@ const adapter = createAdapter({
190
200
  * the index is keyed by.
191
201
  */
192
202
  onSet: (parts, value, topic) => handleSetTopic(topic, value),
193
- listen: Object.fromEntries(
194
- [...new Set([config.topicSet, config.topicSysvarSet, config.topicProgramSet])]
195
- .map((template) => subscribePattern(template, topicFields))
196
- .filter((pattern) => !pattern.startsWith(`${config.name}/set/`))
197
- .map((pattern) => [pattern, handleSetTopic]),
198
- ),
203
+ listen: Object.fromEntries(listenPatterns.map((pattern) => [pattern, handleSetTopic])),
199
204
  subscriptions: {
200
205
  'paramset/#': handleParamset,
201
206
  ...(config.rpcTopics ? {'rpc/+/+/+': handleRpc} : {}),
@@ -205,6 +210,21 @@ const adapter = createAdapter({
205
210
  });
206
211
  const {log, pubStatus} = adapter;
207
212
 
213
+ for (const warning of config.$warnings || []) {
214
+ log.warn(warning);
215
+ }
216
+ if (config.topicStatus === config.topicSet) {
217
+ log.warn('--topic-status and --topic-set are identical: every own publication would come back as a set');
218
+ }
219
+ for (const pattern of listenPatterns) {
220
+ const literal = pattern.replace(/#$/, '');
221
+ if (statusLiteral.startsWith(literal)) {
222
+ log.warn(
223
+ `the set subscription ${pattern} also matches the status topics under ${statusLiteral} - own publications there are ignored`,
224
+ );
225
+ }
226
+ }
227
+
208
228
  /*
209
229
  * Running on the CCU itself, the interface processes are on loopback and the familiar
210
230
  * 2000/2001/2010/9292/8181 are only lighttpd proxies in front of them: an extra hop, XML over HTTP
@@ -212,7 +232,9 @@ const {log, pubStatus} = adapter;
212
232
  * default we probe, because node-red-contrib-ccu's config-file check stopped working on current
213
233
  * firmware (see lib/interfaces.js).
214
234
  */
215
- const localMode = config.local === undefined ? await detectLocal(host) : Boolean(config.local);
235
+ // an explicit --ccu-tls is a tunnel or a proxy by definition - local mode would silently drop the
236
+ // 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);
216
238
  if (localMode) {
217
239
  log.info('local mode: BidCos over binrpc (32001/32000), hmipserver on 32010, ReGa on 8183');
218
240
  }
@@ -220,8 +242,20 @@ if (localMode) {
220
242
  const metadata = new Metadata({stateDir: config.stateDir, seedFile: path.join(here, 'paramsets.json'), log});
221
243
  metadata.load();
222
244
 
245
+ /*
246
+ * Where names, rooms and functions come from (H-46): openccu-lite has no ReGaHSS, but a metadata
247
+ * api that answers `GET /api/meta/v1/version` and nothing else does. That one call is the whole
248
+ * detection - no new option, no hostname or port heuristics, so a configuration moved between a
249
+ * CCU and an openccu-lite box keeps working untouched. Explicit --no-rega still means "addresses
250
+ * only" on both.
251
+ */
252
+ const metaUrl = config.metaUrl || `${config.ccuTls ? 'https' : 'http'}://${host}`;
253
+ /** true while the box has not answered the probe at all: something may still come up there */
254
+ let redetect = false;
255
+ let redetectTimer = null;
223
256
  let regaSync = null;
224
- if (config.rega) {
257
+
258
+ function createRega() {
225
259
  const rega = new Rega({
226
260
  host: ccuIp,
227
261
  port: regaPort({tls: config.ccuTls, local: localMode}),
@@ -231,7 +265,37 @@ if (config.rega) {
231
265
  password: config.ccuPassword,
232
266
  timeZone: config.ccuTimezone,
233
267
  });
234
- regaSync = new RegaSync({rega, host, metadata, stateDir: config.stateDir, nameFile, log});
268
+ return new RegaSync({rega, host, metadata, stateDir: config.stateDir, nameFile, log});
269
+ }
270
+
271
+ function createMeta() {
272
+ return new MetaSync({
273
+ url: metaUrl,
274
+ token: config.metaToken,
275
+ insecure: config.ccuInsecure,
276
+ fixedUrl: Boolean(config.metaUrl),
277
+ metadata,
278
+ stateDir: config.stateDir,
279
+ nameFile,
280
+ log,
281
+ });
282
+ }
283
+
284
+ if (config.rega) {
285
+ const found = await detectMeta(metaUrl, {insecure: config.ccuInsecure});
286
+ if (found.ok) {
287
+ log.info(
288
+ 'openccu-lite on',
289
+ host + ':',
290
+ found.version.implementation || 'metadata api v' + found.version.version,
291
+ '- names, rooms and functions from the metadata api',
292
+ );
293
+ regaSync = createMeta();
294
+ } else {
295
+ log.debug('no metadata api on', host, '(' + found.reason + ') - using ReGa');
296
+ redetect = !found.reachable;
297
+ regaSync = createRega();
298
+ }
235
299
  regaSync.load();
236
300
  }
237
301
 
@@ -560,11 +624,21 @@ async function handleSetTopic(topic, value) {
560
624
  }
561
625
  let target = topicIndex.get(topic);
562
626
  if (!target) {
563
- const remainder = templateRemainder(config.topicSet, topic, topicFields);
627
+ // the reserved namespace stays positional whatever the template says: the address form,
628
+ // the rega commands and the sysvar/program names below <name>/set/ keep working
629
+ const head = `${config.name}/set/`;
630
+ const remainder = topic.startsWith(head)
631
+ ? topic.slice(head.length)
632
+ : templateRemainder(config.topicSet, topic, topicFields);
564
633
  const positional = remainder === null ? null : remainder.split('/').filter(Boolean);
565
634
  target = positional && positional.length > 0 ? resolveSet(positional, lookup) : null;
566
635
  }
567
636
  if (!target) {
637
+ // a set subscription that overlaps the status tree (a template whose literal part ends
638
+ // above it) delivers our own publications here - those are not errors
639
+ if (statusLiteral && topic.startsWith(statusLiteral)) {
640
+ return;
641
+ }
568
642
  throw new Error('unknown channel, variable or program');
569
643
  }
570
644
  switch (target.kind) {
@@ -778,56 +852,113 @@ function createConnection(iface) {
778
852
  */
779
853
 
780
854
  async function startRega() {
855
+ if (regaSync instanceof MetaSync) {
856
+ // one line, once: everything a ReGa box could do and this one cannot (porting invariant 4).
857
+ // The options stay accepted so a configuration works on both kinds of box.
858
+ log.info(
859
+ 'occulite: no ReGaHSS on this box - system variables, programs and the value cache are',
860
+ 'not available; --rega-poll-interval, --rega-poll-trigger, --publish-cache and the',
861
+ 'sysvar/program topics do nothing here',
862
+ );
863
+ }
781
864
  try {
782
865
  await regaSync.syncNames();
783
866
  } catch (err) {
784
867
  log.warn(
785
- 'rega: names not available (' + err.message + '), using',
868
+ regaSync.label + ': names not available (' + err.message + '), using',
786
869
  Object.keys(regaSync.channelNames).length,
787
870
  'cached names',
788
871
  );
789
872
  }
790
- const duplicates = Object.entries(
791
- Object.values(regaSync.channelNames).reduce((acc, name) => ((acc[name] = (acc[name] || 0) + 1), acc), {}),
792
- )
793
- .filter(([, n]) => n > 1)
794
- .map(([name]) => name);
795
- if (duplicates.length > 0) {
796
- log.warn(
797
- 'duplicate channel names share a topic:',
798
- duplicates.slice(0, 20).join(', '),
799
- duplicates.length > 20 ? '…' : '',
873
+ warnDuplicateNames();
874
+ attachProvider();
875
+ regaSync.startPolling(config.regaPollInterval);
876
+ if (regaSync.rega) {
877
+ loadCache().catch((err) => log.warn('rega getValues failed:', err.message));
878
+ }
879
+ if (config.regaNamesInterval > 0) {
880
+ timers.push(setInterval(() => resyncNames(), config.regaNamesInterval * 1000));
881
+ }
882
+ if (redetect) {
883
+ // nothing answered the probe at start (the box was down or still booting): ReGa was the
884
+ // fallback, and an openccu-lite that comes up later must not need a restart of this
885
+ // service to be recognised
886
+ redetectTimer = setInterval(
887
+ () => redetectProvider().catch((err) => log.debug('re-detection failed:', err.message)),
888
+ REDETECT_MS,
800
889
  );
890
+ timers.push(redetectTimer);
801
891
  }
892
+ }
893
+
894
+ /** The provider's events, wired the same way whichever provider it is. */
895
+ function attachProvider() {
802
896
  regaSync.on('names', scheduleIndex);
803
897
  regaSync.on('sysvar', publishRega);
804
898
  regaSync.on('program', publishRega);
805
899
  regaSync.on('polled', () => {
806
900
  if (!regaOk) {
807
901
  regaOk = true;
808
- log.info('rega connected');
902
+ log.info(regaSync.label + ' connected');
809
903
  updateConnected();
810
904
  }
811
905
  });
812
906
  regaSync.on('error', () => {
813
907
  if (regaOk) {
814
908
  regaOk = false;
815
- log.warn('rega disconnected');
909
+ log.warn(regaSync.label + ' disconnected');
816
910
  updateConnected();
817
911
  }
818
912
  });
819
- regaSync.startPolling(config.regaPollInterval);
820
- loadCache().catch((err) => log.warn('rega getValues failed:', err.message));
821
- if (config.regaNamesInterval > 0) {
822
- timers.push(
823
- setInterval(
824
- () => regaSync.syncNames().catch((err) => log.warn('rega names re-sync failed:', err.message)),
825
- config.regaNamesInterval * 1000,
826
- ),
913
+ }
914
+
915
+ function warnDuplicateNames() {
916
+ const duplicates = Object.entries(
917
+ Object.values(regaSync.channelNames).reduce((acc, name) => ((acc[name] = (acc[name] || 0) + 1), acc), {}),
918
+ )
919
+ .filter(([, n]) => n > 1)
920
+ .map(([name]) => name);
921
+ if (duplicates.length > 0) {
922
+ log.warn(
923
+ 'duplicate channel names share a topic:',
924
+ duplicates.slice(0, 20).join(', '),
925
+ duplicates.length > 20 ? '…' : '',
827
926
  );
828
927
  }
829
928
  }
830
929
 
930
+ function resyncNames() {
931
+ return regaSync.syncNames().catch((err) => log.warn(regaSync.label + ' names re-sync failed:', err.message));
932
+ }
933
+
934
+ /** Probes a box that did not answer at start; switches to the metadata api when it now does. */
935
+ async function redetectProvider() {
936
+ const found = await detectMeta(metaUrl, {insecure: config.ccuInsecure});
937
+ if (!found.reachable) {
938
+ return;
939
+ }
940
+ // something answers now - whatever it is, this question is settled
941
+ redetect = false;
942
+ if (redetectTimer) {
943
+ clearInterval(redetectTimer);
944
+ redetectTimer = null;
945
+ }
946
+ if (!found.ok) {
947
+ log.debug('no metadata api on', host, '(' + found.reason + ') - staying with ReGa');
948
+ return;
949
+ }
950
+ log.info('openccu-lite answered on', host + ': switching from ReGa to the metadata api');
951
+ const previous = regaSync;
952
+ previous.stopPolling();
953
+ previous.removeAllListeners();
954
+ regaSync = createMeta();
955
+ regaSync.load();
956
+ attachProvider();
957
+ regaOk = false;
958
+ await resyncNames();
959
+ scheduleIndex();
960
+ }
961
+
831
962
  /** The ReGa's copy of every datapoint value: seeds the value store, published with --publish-cache. */
832
963
  async function loadCache() {
833
964
  const list = await regaSync.rega.getValues();
@@ -857,10 +988,12 @@ async function loadCache() {
857
988
  async function start() {
858
989
  adapter.start();
859
990
  ccuIp = await resolveCcu();
860
- if (regaSync) {
991
+ if (regaSync && regaSync.rega) {
861
992
  regaSync.rega.host = ccuIp;
862
993
  regaSync.rega.url = regaSync.rega.url.replace(host, ccuIp);
863
994
  regaSync.rega.webUrl = regaSync.rega.webUrl.replace(host, ccuIp);
995
+ } else if (regaSync) {
996
+ regaSync.setHost(ccuIp);
864
997
  }
865
998
  enabled =
866
999
  parseInterfaces(config.interfaces) || (await probeInterfaces(ccuIp, {tls: config.ccuTls, local: localMode}));
@@ -147,17 +147,29 @@ function deviceBlock({iface, device, list, ctx, t, st, cmd, bridgeId, ignored})
147
147
 
148
148
  for (const ch of channels) {
149
149
  const consumed = new Set();
150
- const e = (dp, platform, label, more = {}) =>
151
- entity({
150
+ const e = (dp, platform, label, more = {}) => {
151
+ const built = entity({
152
152
  id,
153
153
  name,
154
- item: ctx.statusTopicFor(iface, ch.ADDRESS, dp),
154
+ item: `${ch.index}/${dp}`,
155
155
  platform,
156
156
  label,
157
157
  uid: `${ch.index}_${dp}`,
158
158
  jsonPayloads: json,
159
159
  ...more,
160
160
  });
161
+ // The topics are whole, rendered from the configured templates. entity() composes
162
+ // <name>/status|set/<item> for a core-style item, so exactly its composed values are
163
+ // replaced here; an explicit stat_t/cmd_t from `extra` and the stateless platforms
164
+ // (which get none) stay as they are.
165
+ if (built.stat_t === `${name}/status/${ch.index}/${dp}`) {
166
+ built.stat_t = own(dp);
167
+ }
168
+ if (built.cmd_t === `${name}/set/${ch.index}/${dp}`) {
169
+ built.cmd_t = scmd(dp);
170
+ }
171
+ return built;
172
+ };
161
173
  const fallback = ch.index === 0 ? 'Maintenance' : `Channel ${ch.index}`;
162
174
  const label = labelOf(ch, deviceName, fallback);
163
175
  const has = (dp) => Boolean(ch.description[dp]) && !ignored(iface, ch.ADDRESS, dp);
package/lib/interfaces.js CHANGED
@@ -180,10 +180,8 @@ export async function detectLocal(host, {timeout = 500, connect = portOpen} = {}
180
180
  return false;
181
181
  }
182
182
  const ports = INTERFACE_NAMES.map((name) => INTERFACES[name].localPort).filter(Boolean);
183
- for (const port of [...ports, REGA_LOCAL_PORT]) {
184
- if (await connect(host, port, timeout)) {
185
- return true;
186
- }
187
- }
188
- return false;
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);
189
187
  }