hm2mqtt 3.5.2 → 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 */
@@ -186,6 +189,7 @@ const adapter = createAdapter({
186
189
  interfaces: enabled,
187
190
  devices: metadata.count(),
188
191
  rega: Boolean(regaSync),
192
+ names: regaSync ? regaSync.label : 'addresses',
189
193
  payload: payloadFormat,
190
194
  }),
191
195
  /*
@@ -238,8 +242,20 @@ if (localMode) {
238
242
  const metadata = new Metadata({stateDir: config.stateDir, seedFile: path.join(here, 'paramsets.json'), log});
239
243
  metadata.load();
240
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;
241
256
  let regaSync = null;
242
- if (config.rega) {
257
+
258
+ function createRega() {
243
259
  const rega = new Rega({
244
260
  host: ccuIp,
245
261
  port: regaPort({tls: config.ccuTls, local: localMode}),
@@ -249,7 +265,37 @@ if (config.rega) {
249
265
  password: config.ccuPassword,
250
266
  timeZone: config.ccuTimezone,
251
267
  });
252
- 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
+ }
253
299
  regaSync.load();
254
300
  }
255
301
 
@@ -806,56 +852,113 @@ function createConnection(iface) {
806
852
  */
807
853
 
808
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
+ }
809
864
  try {
810
865
  await regaSync.syncNames();
811
866
  } catch (err) {
812
867
  log.warn(
813
- 'rega: names not available (' + err.message + '), using',
868
+ regaSync.label + ': names not available (' + err.message + '), using',
814
869
  Object.keys(regaSync.channelNames).length,
815
870
  'cached names',
816
871
  );
817
872
  }
818
- const duplicates = Object.entries(
819
- Object.values(regaSync.channelNames).reduce((acc, name) => ((acc[name] = (acc[name] || 0) + 1), acc), {}),
820
- )
821
- .filter(([, n]) => n > 1)
822
- .map(([name]) => name);
823
- if (duplicates.length > 0) {
824
- log.warn(
825
- 'duplicate channel names share a topic:',
826
- duplicates.slice(0, 20).join(', '),
827
- 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,
828
889
  );
890
+ timers.push(redetectTimer);
829
891
  }
892
+ }
893
+
894
+ /** The provider's events, wired the same way whichever provider it is. */
895
+ function attachProvider() {
830
896
  regaSync.on('names', scheduleIndex);
831
897
  regaSync.on('sysvar', publishRega);
832
898
  regaSync.on('program', publishRega);
833
899
  regaSync.on('polled', () => {
834
900
  if (!regaOk) {
835
901
  regaOk = true;
836
- log.info('rega connected');
902
+ log.info(regaSync.label + ' connected');
837
903
  updateConnected();
838
904
  }
839
905
  });
840
906
  regaSync.on('error', () => {
841
907
  if (regaOk) {
842
908
  regaOk = false;
843
- log.warn('rega disconnected');
909
+ log.warn(regaSync.label + ' disconnected');
844
910
  updateConnected();
845
911
  }
846
912
  });
847
- regaSync.startPolling(config.regaPollInterval);
848
- loadCache().catch((err) => log.warn('rega getValues failed:', err.message));
849
- if (config.regaNamesInterval > 0) {
850
- timers.push(
851
- setInterval(
852
- () => regaSync.syncNames().catch((err) => log.warn('rega names re-sync failed:', err.message)),
853
- config.regaNamesInterval * 1000,
854
- ),
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 ? '…' : '',
855
926
  );
856
927
  }
857
928
  }
858
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
+
859
962
  /** The ReGa's copy of every datapoint value: seeds the value store, published with --publish-cache. */
860
963
  async function loadCache() {
861
964
  const list = await regaSync.rega.getValues();
@@ -885,10 +988,12 @@ async function loadCache() {
885
988
  async function start() {
886
989
  adapter.start();
887
990
  ccuIp = await resolveCcu();
888
- if (regaSync) {
991
+ if (regaSync && regaSync.rega) {
889
992
  regaSync.rega.host = ccuIp;
890
993
  regaSync.rega.url = regaSync.rega.url.replace(host, ccuIp);
891
994
  regaSync.rega.webUrl = regaSync.rega.webUrl.replace(host, ccuIp);
995
+ } else if (regaSync) {
996
+ regaSync.setHost(ccuIp);
892
997
  }
893
998
  enabled =
894
999
  parseInterfaces(config.interfaces) || (await probeInterfaces(ccuIp, {tls: config.ccuTls, local: localMode}));
package/lib/meta.js ADDED
@@ -0,0 +1,724 @@
1
+ /**
2
+ * The openccu-lite side: device and channel names, rooms and functions from the metadata API of a
3
+ * box that has no ReGaHSS (`GET /api/meta/v1/...`, served by occulited through lighttpd).
4
+ *
5
+ * Same public surface as lib/rega.js's `RegaSync` — `channelName()`, `channelAddress()`,
6
+ * `rooms()`, `functions()`, `syncNames()`, the `names` event — so index.js and everything below it
7
+ * cannot tell the two apart and the published payloads do not change. What ReGa had and this box
8
+ * has not (system variables, programs, HM-Script, the value cache) is empty here: `sysvars` and
9
+ * `programs` stay `{}` and `hasVariable()`/`hasProgram()` answer false, which is what makes the
10
+ * variable and program topics silently absent instead of failing.
11
+ *
12
+ * The startup call is `/snapshot`; everything after it comes from `/events/sse`, so a rename in the
13
+ * box's UI arrives within a second without polling. Node 20 has no `EventSource` and the package
14
+ * takes no new dependency for one: the stream is node:http, which also gives us the socket timeout
15
+ * (the box sends an SSE heartbeat every 30 s) and `--ccu-insecure` for TLS.
16
+ */
17
+
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import http from 'node:http';
21
+ import https from 'node:https';
22
+ import {EventEmitter} from 'node:events';
23
+ import {indexNames, resolveAddress} from './names.js';
24
+
25
+ /** everything the metadata API serves lives below this path */
26
+ export const API_PATH = '/api/meta/v1';
27
+ /** the box's own read-only credential, root-readable, for programs running on it */
28
+ export const LOCAL_TOKEN_FILE = '/usr/local/etc/occulite/local-token';
29
+
30
+ const DETECT_TIMEOUT_MS = 3000;
31
+ const REQUEST_TIMEOUT_MS = 30000;
32
+ /** the box sends an SSE comment every 30 s; three missed heartbeats are a dead connection */
33
+ const STREAM_IDLE_MS = 95000;
34
+ const RECONNECT_MIN_MS = 2000;
35
+ const RECONNECT_MAX_MS = 300000;
36
+ /** a burst of events (a bulk edit, an import) becomes one snapshot read */
37
+ const COALESCE_MS = 250;
38
+ const MAX_BODY = 64 * 1024 * 1024;
39
+
40
+ /**
41
+ * @param {string} url
42
+ * @param {object} [options]
43
+ * @returns {Promise<import('node:http').IncomingMessage>}
44
+ */
45
+ function requestStream(url, {headers = {}, insecure = false, timeout = 0, signal} = {}) {
46
+ return new Promise((resolve, reject) => {
47
+ let target;
48
+ try {
49
+ target = new URL(url);
50
+ } catch {
51
+ reject(new Error(`invalid url: ${url}`));
52
+ return;
53
+ }
54
+ const client = target.protocol === 'https:' ? https : http;
55
+ const request = client.request(
56
+ target,
57
+ {method: 'GET', headers, signal, ...(insecure ? {rejectUnauthorized: false} : {})},
58
+ resolve,
59
+ );
60
+ if (timeout > 0) {
61
+ request.setTimeout(timeout, () => request.destroy(new Error(`no answer for ${timeout} ms`)));
62
+ }
63
+ request.on('error', reject);
64
+ request.end();
65
+ });
66
+ }
67
+
68
+ function readBody(res) {
69
+ return new Promise((resolve, reject) => {
70
+ let body = '';
71
+ res.setEncoding('utf8');
72
+ res.on('data', (chunk) => {
73
+ body += chunk;
74
+ if (body.length > MAX_BODY) {
75
+ res.destroy(new Error('answer too large'));
76
+ }
77
+ });
78
+ res.on('end', () => resolve(body));
79
+ res.on('error', reject);
80
+ });
81
+ }
82
+
83
+ /** GET a JSON document; a non-200 becomes an Error carrying `status` and the API's error code. */
84
+ async function getJson(url, options = {}) {
85
+ const res = await requestStream(url, {timeout: REQUEST_TIMEOUT_MS, ...options});
86
+ const body = await readBody(res);
87
+ if (res.statusCode !== 200) {
88
+ let code = '';
89
+ try {
90
+ code = JSON.parse(body).error || '';
91
+ } catch {
92
+ // a proxy or a CCU answering HTML: the status is all we have
93
+ }
94
+ const error = new Error(`${url} answered ${res.statusCode}${code ? ' ' + code : ''}`);
95
+ error.status = res.statusCode;
96
+ error.code = code;
97
+ throw error;
98
+ }
99
+ return JSON.parse(body);
100
+ }
101
+
102
+ /**
103
+ * Feature detection (the one call that needs no credential): openccu-lite answers the version
104
+ * document here, a CCU/RaspberryMatic/OpenCCU answers 404 or HTML.
105
+ * @param {string} baseUrl e.g. `http://ccu`
106
+ * @returns {Promise<{ok: boolean, reachable: boolean, version?: object, reason?: string}>}
107
+ */
108
+ export async function detectMeta(baseUrl, {insecure = false, timeout = DETECT_TIMEOUT_MS} = {}) {
109
+ try {
110
+ const doc = await getJson(`${baseUrl}${API_PATH}/version`, {insecure, timeout});
111
+ if (doc && doc.api === 'meta' && Number(doc.version) >= 1) {
112
+ return {ok: true, reachable: true, version: doc};
113
+ }
114
+ return {ok: false, reachable: true, reason: 'not the metadata API'};
115
+ } catch (error) {
116
+ // an answer we could not parse still proves that something is listening - that is a CCU,
117
+ // and re-probing it forever would be pointless; a connection error is not.
118
+ const reachable = typeof error.status === 'number' || error instanceof SyntaxError;
119
+ return {ok: false, reachable, reason: error.message};
120
+ }
121
+ }
122
+
123
+ /** The box's own token, when we are running on the box. */
124
+ export function readLocalToken(file = LOCAL_TOKEN_FILE) {
125
+ try {
126
+ const token = fs.readFileSync(file, 'utf8').trim();
127
+ return token || undefined;
128
+ } catch {
129
+ return undefined;
130
+ }
131
+ }
132
+
133
+ /** `<enum>/<id>/<id>` -> the name of the node it points at, for every node of every enum. */
134
+ export function enumLabels(enums) {
135
+ const labels = new Map();
136
+ const walk = (nodes, prefix) => {
137
+ for (const node of nodes || []) {
138
+ if (!node || typeof node.id !== 'string') {
139
+ continue;
140
+ }
141
+ const nodePath = `${prefix}/${node.id}`;
142
+ labels.set(nodePath, typeof node.name === 'string' && node.name !== '' ? node.name : node.id);
143
+ walk(node.children, nodePath);
144
+ }
145
+ };
146
+ for (const [id, def] of Object.entries(enums || {})) {
147
+ walk(def && def.tree, id);
148
+ }
149
+ return labels;
150
+ }
151
+
152
+ /** The address of a ref: `HmIP-RF.0001D3C99C7D4B:3` -> `0001D3C99C7D4B:3`. */
153
+ export function addressOf(ref) {
154
+ const dot = typeof ref === 'string' ? ref.indexOf('.') : -1;
155
+ return dot > 0 ? ref.slice(dot + 1) : '';
156
+ }
157
+
158
+ /**
159
+ * The metadata document as hm2mqtt sees it: names by address, and rooms and functions as arrays of
160
+ * names — the shape ReGa's `getRooms()`/`getFunctions()` produced, so nothing downstream changes.
161
+ *
162
+ * Rooms and functions are trees here (`room/eg/wohnzimmer`) and a member of a node is implicitly a
163
+ * member of its parents. Only the node the object is actually in is named, not its ancestors: that
164
+ * is what a CCU would have reported, and `${room}` in a topic template must not suddenly become
165
+ * two levels (H-48).
166
+ */
167
+ export function deriveNames(objects, enums) {
168
+ const labels = enumLabels(enums);
169
+ const channelNames = {};
170
+ const channelRooms = {};
171
+ const channelFunctions = {};
172
+ for (const [ref, object] of Object.entries(objects || {})) {
173
+ if (!object || typeof object !== 'object') {
174
+ continue;
175
+ }
176
+ const address = addressOf(ref);
177
+ if (!address) {
178
+ continue;
179
+ }
180
+ if (typeof object.name === 'string' && object.name !== '') {
181
+ channelNames[address] = object.name;
182
+ }
183
+ for (const nodePath of object.enums || []) {
184
+ const name = labels.get(nodePath);
185
+ if (!name) {
186
+ continue;
187
+ }
188
+ const target = nodePath.startsWith('room/')
189
+ ? channelRooms
190
+ : nodePath.startsWith('function/')
191
+ ? channelFunctions
192
+ : null;
193
+ if (!target) {
194
+ // floor and whatever else the user created: hm2mqtt has no field for them
195
+ continue;
196
+ }
197
+ const list = (target[address] = target[address] || []);
198
+ if (!list.includes(name)) {
199
+ list.push(name);
200
+ }
201
+ }
202
+ }
203
+ return {channelNames, channelRooms, channelFunctions};
204
+ }
205
+
206
+ export class MetaSync extends EventEmitter {
207
+ /**
208
+ * @param {object} o
209
+ * @param {string} o.url base url of the box, e.g. `http://ccu` (no path)
210
+ * @param {string} [o.token] API token (`olt_…`) or session id; default: the box's local token
211
+ * @param {string} [o.tokenFile] where the local token lives
212
+ * @param {boolean} [o.insecure] accept a self-signed certificate (--ccu-insecure)
213
+ * @param {boolean} [o.fixedUrl] the url was configured explicitly and must not follow the ccu ip
214
+ * @param {object} [o.metadata] Metadata (to tell an address from a name on set topics)
215
+ * @param {string} [o.stateDir]
216
+ * @param {Object<string, string>} [o.nameFile] {address: name} overriding the box's names
217
+ * @param {object} o.log
218
+ */
219
+ constructor({
220
+ url,
221
+ token,
222
+ tokenFile = LOCAL_TOKEN_FILE,
223
+ insecure = false,
224
+ fixedUrl = false,
225
+ metadata,
226
+ stateDir,
227
+ nameFile,
228
+ log,
229
+ }) {
230
+ super();
231
+ this.label = 'occulite';
232
+ this.url = String(url || '').replace(/\/+$/, '');
233
+ this.tokenFile = tokenFile;
234
+ this.token = token || readLocalToken(tokenFile);
235
+ this.insecure = insecure;
236
+ this.fixedUrl = fixedUrl;
237
+ this.metadata = metadata;
238
+ this.stateDir = stateDir;
239
+ this.nameFile = nameFile || {};
240
+ this.log = log;
241
+ /** the store as served, kept so a restart without the box still has the names */
242
+ this.objects = {};
243
+ this.enums = {};
244
+ this.revision = 0;
245
+ this.channelNames = {};
246
+ this.addresses = {};
247
+ this.channelRooms = {};
248
+ this.channelFunctions = {};
249
+ /** ReGa concepts without a counterpart here; kept so the lookups below stay valid */
250
+ this.sysvars = {};
251
+ this.programs = {};
252
+ this.synced = false;
253
+ this.stopped = false;
254
+ this.warnedAuth = false;
255
+ this.backoff = RECONNECT_MIN_MS;
256
+ this.streamRes = null;
257
+ this.streamPending = false;
258
+ this.streamAbort = null;
259
+ this.streamTimer = null;
260
+ this.refreshTimer = null;
261
+ this.applyTimer = null;
262
+ this.refreshing = false;
263
+ this.refreshAgain = false;
264
+ }
265
+
266
+ /*
267
+ * persistence — same place and purpose as rega.json, one file further so switching a
268
+ * configuration between a CCU and openccu-lite does not overwrite the other one's cache
269
+ */
270
+
271
+ file() {
272
+ return this.stateDir ? path.join(this.stateDir, 'meta.json') : null;
273
+ }
274
+
275
+ load() {
276
+ const file = this.file();
277
+ if (!file) {
278
+ return;
279
+ }
280
+ try {
281
+ const data = JSON.parse(fs.readFileSync(file, 'utf8'));
282
+ this.objects = data.objects || {};
283
+ this.enums = data.enums || {};
284
+ this.revision = Number(data.revision) || 0;
285
+ this.rebuild();
286
+ this.log.info('loaded', Object.keys(this.channelNames).length, 'names from', file);
287
+ } catch (err) {
288
+ if (err.code !== 'ENOENT') {
289
+ this.log.warn('cannot read', file, '-', err.message);
290
+ }
291
+ }
292
+ }
293
+
294
+ save() {
295
+ const file = this.file();
296
+ if (!file) {
297
+ return;
298
+ }
299
+ try {
300
+ fs.mkdirSync(this.stateDir, {recursive: true});
301
+ fs.writeFileSync(file, JSON.stringify({revision: this.revision, objects: this.objects, enums: this.enums}));
302
+ } catch (err) {
303
+ this.log.warn('cannot save', file, '-', err.message);
304
+ }
305
+ }
306
+
307
+ /*
308
+ * lookups — the surface index.js calls
309
+ */
310
+
311
+ channelName(address) {
312
+ return this.channelNames[address];
313
+ }
314
+
315
+ channelAddress(nameOrAddress, devices = false) {
316
+ return resolveAddress(this, nameOrAddress, devices);
317
+ }
318
+
319
+ rooms(address) {
320
+ return this.channelRooms[address];
321
+ }
322
+
323
+ functions(address) {
324
+ return this.channelFunctions[address];
325
+ }
326
+
327
+ hasVariable() {
328
+ return false;
329
+ }
330
+
331
+ hasProgram() {
332
+ return false;
333
+ }
334
+
335
+ /*
336
+ * the box
337
+ */
338
+
339
+ /** The CCU address is resolved once at start (H-20); follow it, unless a url was configured. */
340
+ setHost(ip) {
341
+ if (this.fixedUrl || !ip) {
342
+ return;
343
+ }
344
+ try {
345
+ const url = new URL(this.url);
346
+ url.hostname = ip;
347
+ this.url = url.toString().replace(/\/+$/, '');
348
+ } catch {
349
+ // an unparsable url stays as it is; the requests below will say so
350
+ }
351
+ }
352
+
353
+ headers(extra = {}) {
354
+ return {
355
+ accept: 'application/json',
356
+ ...(this.token ? {authorization: `Bearer ${this.token}`} : {}),
357
+ ...extra,
358
+ };
359
+ }
360
+
361
+ get(endpoint) {
362
+ return getJson(`${this.url}${API_PATH}${endpoint}`, {headers: this.headers(), insecure: this.insecure});
363
+ }
364
+
365
+ /*
366
+ * names, rooms, functions
367
+ */
368
+
369
+ /** Reads the snapshot and follows the change stream. Emits 'names'. */
370
+ async syncNames() {
371
+ this.stopped = false;
372
+ this.startStream();
373
+ await this.refresh({announce: true});
374
+ }
375
+
376
+ /** ReGa's variable/program poll has no counterpart here; the stream is the update path. */
377
+ async poll() {
378
+ this.startStream();
379
+ }
380
+
381
+ startPolling() {
382
+ this.startStream();
383
+ return Promise.resolve();
384
+ }
385
+
386
+ stopPolling() {
387
+ this.stopped = true;
388
+ for (const timer of [this.streamTimer, this.refreshTimer, this.applyTimer]) {
389
+ if (timer) {
390
+ clearTimeout(timer);
391
+ }
392
+ }
393
+ this.streamTimer = this.refreshTimer = this.applyTimer = null;
394
+ this.closeStream();
395
+ }
396
+
397
+ /** Fetches the snapshot and applies it. Throws once, and retries by itself afterwards. */
398
+ async refresh({announce = false} = {}) {
399
+ if (this.refreshTimer) {
400
+ clearTimeout(this.refreshTimer);
401
+ this.refreshTimer = null;
402
+ }
403
+ // one snapshot at a time: the stream and an event burst both ask for one
404
+ if (this.refreshing) {
405
+ this.refreshAgain = true;
406
+ return;
407
+ }
408
+ this.refreshing = true;
409
+ // scheduled in the finally, where `refreshing` is false again: a failure retries with the
410
+ // backoff, an event that overtook the snapshot as soon as the burst is over
411
+ let retry = null;
412
+ try {
413
+ let doc;
414
+ try {
415
+ doc = await this.get('/snapshot');
416
+ } catch (err) {
417
+ if (err.status === 401 || err.status === 403) {
418
+ this.denied(err);
419
+ return;
420
+ }
421
+ retry = this.backoff;
422
+ throw err;
423
+ }
424
+ this.accepted();
425
+ // events that arrived while the snapshot was in flight are newer than it
426
+ const overtaken = this.revision > (Number(doc.revision) || 0);
427
+ const changed = this.applySnapshot(doc);
428
+ this.save();
429
+ if (announce || changed) {
430
+ this.logSummary();
431
+ }
432
+ this.emit('names');
433
+ this.emit('polled');
434
+ if (overtaken) {
435
+ this.refreshAgain = true;
436
+ }
437
+ } finally {
438
+ this.refreshing = false;
439
+ if (this.refreshAgain || retry !== null) {
440
+ this.refreshAgain = false;
441
+ this.scheduleRefresh(retry === null ? COALESCE_MS : retry);
442
+ }
443
+ }
444
+ }
445
+
446
+ applySnapshot(doc) {
447
+ this.objects = (doc && doc.objects) || {};
448
+ this.enums = (doc && doc.enums) || {};
449
+ this.revision = Number(doc && doc.revision) || 0;
450
+ this.synced = true;
451
+ return this.rebuild();
452
+ }
453
+
454
+ /** Derives names, rooms and functions from the store; true when anything changed. */
455
+ rebuild() {
456
+ const before = JSON.stringify([this.channelNames, this.channelRooms, this.channelFunctions]);
457
+ const derived = deriveNames(this.objects, this.enums);
458
+ this.channelNames = derived.channelNames;
459
+ this.channelRooms = derived.channelRooms;
460
+ this.channelFunctions = derived.channelFunctions;
461
+ this.addresses = indexNames(this.channelNames, this.nameFile);
462
+ return JSON.stringify([this.channelNames, this.channelRooms, this.channelFunctions]) !== before;
463
+ }
464
+
465
+ logSummary() {
466
+ const labels = [...enumLabels(this.enums).keys()];
467
+ this.log.info(
468
+ 'occulite:',
469
+ Object.keys(this.channelNames).length,
470
+ 'names,',
471
+ labels.filter((p) => p.startsWith('room/')).length,
472
+ 'rooms,',
473
+ labels.filter((p) => p.startsWith('function/')).length,
474
+ 'functions (revision ' + this.revision + ')',
475
+ );
476
+ }
477
+
478
+ scheduleRefresh(delay = this.backoff) {
479
+ if (this.stopped || this.refreshTimer) {
480
+ return;
481
+ }
482
+ if (this.refreshing) {
483
+ this.refreshAgain = true;
484
+ return;
485
+ }
486
+ this.refreshTimer = setTimeout(() => {
487
+ this.refreshTimer = null;
488
+ this.refresh().catch((err) => this.log.debug('occulite: snapshot failed:', err.message));
489
+ }, delay);
490
+ }
491
+
492
+ /** Rebuild after object events, coalesced: a bulk edit is one 'names'. */
493
+ scheduleApply() {
494
+ if (this.stopped || this.applyTimer) {
495
+ return;
496
+ }
497
+ this.applyTimer = setTimeout(() => {
498
+ this.applyTimer = null;
499
+ if (this.rebuild()) {
500
+ this.save();
501
+ this.log.debug('occulite: names updated (revision', this.revision + ')');
502
+ this.emit('names');
503
+ }
504
+ }, 50);
505
+ }
506
+
507
+ /*
508
+ * the change stream
509
+ */
510
+
511
+ startStream() {
512
+ if (this.stopped || this.streamRes || this.streamPending || !this.url) {
513
+ return;
514
+ }
515
+ if (this.streamTimer) {
516
+ clearTimeout(this.streamTimer);
517
+ this.streamTimer = null;
518
+ }
519
+ this.streamPending = true;
520
+ // ?since replays what we missed; without a snapshot there is nothing to replay from
521
+ const since = this.synced && this.revision > 0 ? `?since=${this.revision}` : '';
522
+ const controller = new AbortController();
523
+ this.streamAbort = controller;
524
+ requestStream(`${this.url}${API_PATH}/events/sse${since}`, {
525
+ headers: this.headers({accept: 'text/event-stream'}),
526
+ insecure: this.insecure,
527
+ timeout: STREAM_IDLE_MS,
528
+ signal: controller.signal,
529
+ }).then(
530
+ (res) => this.onStream(res),
531
+ (err) => this.onStreamEnd(err),
532
+ );
533
+ }
534
+
535
+ onStream(res) {
536
+ this.streamPending = false;
537
+ if (this.stopped) {
538
+ res.destroy();
539
+ return;
540
+ }
541
+ if (res.statusCode !== 200) {
542
+ res.resume();
543
+ const error = new Error(`event stream answered ${res.statusCode}`);
544
+ error.status = res.statusCode;
545
+ if (res.statusCode === 401 || res.statusCode === 403) {
546
+ this.denied(error);
547
+ }
548
+ this.onStreamEnd(error);
549
+ return;
550
+ }
551
+ this.streamRes = res;
552
+ this.backoff = RECONNECT_MIN_MS;
553
+ this.accepted();
554
+ this.log.debug('occulite: event stream open');
555
+ this.emit('polled');
556
+ if (!this.synced) {
557
+ // the stream is up before the first snapshot: fetch it, unless syncNames() already is
558
+ this.scheduleRefresh(0);
559
+ }
560
+ let buffer = '';
561
+ res.setEncoding('utf8');
562
+ res.on('data', (chunk) => {
563
+ buffer += chunk;
564
+ let index;
565
+ while ((index = buffer.search(/\r?\n\r?\n/)) !== -1) {
566
+ const [separator] = buffer.slice(index).match(/^\r?\n\r?\n/);
567
+ const frame = buffer.slice(0, index);
568
+ buffer = buffer.slice(index + separator.length);
569
+ this.onFrame(frame);
570
+ }
571
+ if (buffer.length > MAX_BODY) {
572
+ buffer = '';
573
+ }
574
+ });
575
+ const end = (err) => {
576
+ if (this.streamRes === res) {
577
+ this.streamRes = null;
578
+ this.onStreamEnd(err);
579
+ }
580
+ };
581
+ res.on('end', () => end(null));
582
+ res.on('close', () => end(null));
583
+ res.on('error', end);
584
+ }
585
+
586
+ onStreamEnd(err) {
587
+ this.streamPending = false;
588
+ this.streamRes = null;
589
+ this.streamAbort = null;
590
+ if (this.stopped) {
591
+ return;
592
+ }
593
+ this.log.debug('occulite: event stream closed', err ? '(' + err.message + ')' : '');
594
+ this.emitError(err || new Error('event stream closed'));
595
+ if (this.streamTimer) {
596
+ return;
597
+ }
598
+ this.streamTimer = setTimeout(() => {
599
+ this.streamTimer = null;
600
+ this.startStream();
601
+ }, this.backoff);
602
+ this.backoff = Math.min(this.backoff * 2, RECONNECT_MAX_MS);
603
+ }
604
+
605
+ closeStream() {
606
+ if (this.streamAbort) {
607
+ this.streamAbort.abort();
608
+ this.streamAbort = null;
609
+ }
610
+ if (this.streamRes) {
611
+ this.streamRes.destroy();
612
+ this.streamRes = null;
613
+ }
614
+ this.streamPending = false;
615
+ }
616
+
617
+ /** One SSE frame: the `data:` lines are the event, a comment (heartbeat) has none. */
618
+ onFrame(frame) {
619
+ const data = frame
620
+ .split(/\r?\n/)
621
+ .filter((line) => line.startsWith('data:'))
622
+ .map((line) => line.slice(5).replace(/^ /, ''))
623
+ .join('\n');
624
+ if (data === '') {
625
+ return;
626
+ }
627
+ let event;
628
+ try {
629
+ event = JSON.parse(data);
630
+ } catch {
631
+ this.log.debug('occulite: unparsable event', data.slice(0, 200));
632
+ return;
633
+ }
634
+ this.onEvent(event);
635
+ }
636
+
637
+ onEvent(event) {
638
+ if (!event || typeof event !== 'object') {
639
+ return;
640
+ }
641
+ if (event.kind === 'resync') {
642
+ this.log.debug('occulite: resync requested by the box');
643
+ this.revision = Number(event.revision) || this.revision;
644
+ this.scheduleRefresh(0);
645
+ return;
646
+ }
647
+ const revision = Number(event.revision);
648
+ if (Number.isFinite(revision)) {
649
+ if (this.synced && this.revision > 0 && revision > this.revision + 1) {
650
+ this.log.debug('occulite: revision gap', this.revision, '->', revision);
651
+ this.revision = revision;
652
+ this.scheduleRefresh(0);
653
+ return;
654
+ }
655
+ this.revision = revision;
656
+ }
657
+ switch (event.kind) {
658
+ case 'object.updated':
659
+ if (event.ref && event.value) {
660
+ this.objects[event.ref] = event.value;
661
+ this.scheduleApply();
662
+ }
663
+ break;
664
+ case 'object.deleted':
665
+ if (event.ref) {
666
+ delete this.objects[event.ref];
667
+ this.scheduleApply();
668
+ }
669
+ break;
670
+ default:
671
+ // enum.*, node.* and import change the names of rooms or the paths of their
672
+ // members: the snapshot is the cheapest correct answer
673
+ this.scheduleRefresh(COALESCE_MS);
674
+ }
675
+ }
676
+
677
+ /*
678
+ * the credential
679
+ */
680
+
681
+ /** 'error' on an EventEmitter with no listener throws; the stream may start before index.js listens */
682
+ emitError(err) {
683
+ if (this.listenerCount('error') > 0) {
684
+ this.emit('error', err);
685
+ }
686
+ }
687
+
688
+ denied(err) {
689
+ this.emitError(err);
690
+ if (this.warnedAuth) {
691
+ return;
692
+ }
693
+ this.warnedAuth = true;
694
+ this.log.warn(
695
+ `occulite: the metadata API rejected the credential (${err.status}) - running without names, rooms and functions;`,
696
+ this.token
697
+ ? 'the token was revoked or belongs to another box - create a new one on the box under Benutzer and set --meta-token'
698
+ : `no token configured and none in ${this.tokenFile} - create an API token on the box under Benutzer and set --meta-token`,
699
+ );
700
+ }
701
+
702
+ accepted() {
703
+ if (this.warnedAuth) {
704
+ this.warnedAuth = false;
705
+ this.log.info('occulite: the metadata API accepts the credential again');
706
+ }
707
+ }
708
+
709
+ /*
710
+ * what this box does not have (kept so a misrouted set says why instead of throwing a TypeError)
711
+ */
712
+
713
+ async setVariable() {
714
+ throw new Error('system variables are not available on this box (no ReGaHSS)');
715
+ }
716
+
717
+ async programActive() {
718
+ throw new Error('programs are not available on this box (no ReGaHSS)');
719
+ }
720
+
721
+ async programExecute() {
722
+ throw new Error('programs are not available on this box (no ReGaHSS)');
723
+ }
724
+ }
package/lib/names.js ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * What every names provider does with its result, whatever it read it from: apply the name file
3
+ * on top, index the names for the reverse lookup, and resolve a name or address coming in on a
4
+ * set topic. Shared by lib/rega.js (ReGaHSS) and lib/meta.js (openccu-lite).
5
+ */
6
+
7
+ /**
8
+ * Overrides the names read from the box with the ones from `--name-file` and returns the reverse
9
+ * index (name -> address). `channelNames` is mutated, as the providers' own state.
10
+ * @param {Object<string, string>} channelNames
11
+ * @param {Object<string, string>} [nameFile]
12
+ * @returns {Object<string, string>} name -> address
13
+ */
14
+ export function indexNames(channelNames, nameFile = {}) {
15
+ for (const [address, name] of Object.entries(nameFile)) {
16
+ if (typeof name === 'string' && name !== '') {
17
+ channelNames[address] = name;
18
+ }
19
+ }
20
+ const addresses = {};
21
+ for (const [address, name] of Object.entries(channelNames)) {
22
+ // the first address of a duplicate name wins; channels win over devices
23
+ if (!addresses[name] || (address.includes(':') && !addresses[name].includes(':'))) {
24
+ addresses[name] = address;
25
+ }
26
+ }
27
+ return addresses;
28
+ }
29
+
30
+ /**
31
+ * Address of a channel (or device, with `devices`) by name or address.
32
+ * @param {{addresses: Object<string, string>, metadata?: object}} provider
33
+ * @param {string} nameOrAddress
34
+ * @param {boolean} [devices]
35
+ */
36
+ export function resolveAddress({addresses, metadata}, nameOrAddress, devices = false) {
37
+ let address;
38
+ if (metadata && metadata.findIface(nameOrAddress)) {
39
+ address = nameOrAddress;
40
+ } else if (addresses[nameOrAddress]) {
41
+ address = addresses[nameOrAddress];
42
+ } else if (!metadata && /^[\w-]+(:\d+)?$/.test(nameOrAddress)) {
43
+ address = nameOrAddress;
44
+ }
45
+ if (!address) {
46
+ return undefined;
47
+ }
48
+ return devices || address.includes(':') ? address : undefined;
49
+ }
package/lib/rega.js CHANGED
@@ -8,6 +8,7 @@ import fs from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import {EventEmitter} from 'node:events';
10
10
  import {castVariable} from './cast.js';
11
+ import {indexNames, resolveAddress} from './names.js';
11
12
 
12
13
  export class RegaSync extends EventEmitter {
13
14
  /**
@@ -29,6 +30,7 @@ export class RegaSync extends EventEmitter {
29
30
  this.nameFile = nameFile || {};
30
31
  this.log = log;
31
32
  this.now = now || Date.now;
33
+ this.label = 'rega';
32
34
  this.channelNames = {};
33
35
  this.addresses = {};
34
36
  this.regaIdChannel = {};
@@ -88,18 +90,7 @@ export class RegaSync extends EventEmitter {
88
90
  }
89
91
 
90
92
  applyNameFile() {
91
- for (const [address, name] of Object.entries(this.nameFile)) {
92
- if (typeof name === 'string' && name !== '') {
93
- this.channelNames[address] = name;
94
- }
95
- }
96
- this.addresses = {};
97
- for (const [address, name] of Object.entries(this.channelNames)) {
98
- // the first address of a duplicate name wins; channels win over devices
99
- if (!this.addresses[name] || (address.includes(':') && !this.addresses[name].includes(':'))) {
100
- this.addresses[name] = address;
101
- }
102
- }
93
+ this.addresses = indexNames(this.channelNames, this.nameFile);
103
94
  }
104
95
 
105
96
  /*
@@ -112,18 +103,7 @@ export class RegaSync extends EventEmitter {
112
103
 
113
104
  /** Address of a channel (or device, with `devices`) by ReGa name or address. */
114
105
  channelAddress(nameOrAddress, devices = false) {
115
- let address;
116
- if (this.metadata && this.metadata.findIface(nameOrAddress)) {
117
- address = nameOrAddress;
118
- } else if (this.addresses[nameOrAddress]) {
119
- address = this.addresses[nameOrAddress];
120
- } else if (!this.metadata && /^[\w-]+(:\d+)?$/.test(nameOrAddress)) {
121
- address = nameOrAddress;
122
- }
123
- if (!address) {
124
- return undefined;
125
- }
126
- return devices || address.includes(':') ? address : undefined;
106
+ return resolveAddress(this, nameOrAddress, devices);
127
107
  }
128
108
 
129
109
  rooms(address) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hm2mqtt",
3
- "version": "3.5.2",
3
+ "version": "3.6.0",
4
4
  "description": "Interface between Homematic CCU and MQTT",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -27,7 +27,8 @@
27
27
  "test": "node --test",
28
28
  "test:watch": "node --test --watch",
29
29
  "deploy": "bash deploy.sh",
30
- "test:e2e": "HM2MQTT_E2E=1 node --test --test-force-exit test/e2e.test.js"
30
+ "test:e2e": "HM2MQTT_E2E=1 node --test --test-force-exit test/e2e.test.js",
31
+ "test:occulite": "node --test --test-force-exit test/occulite.test.js"
31
32
  },
32
33
  "repository": {
33
34
  "type": "git",