hm2mqtt 3.5.2 → 3.6.1
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 +47 -3
- package/config.js +10 -0
- package/index.js +129 -24
- package/lib/hadiscovery.js +4 -1
- package/lib/meta.js +724 -0
- package/lib/names.js +49 -0
- package/lib/rega.js +4 -24
- package/package.json +4 -3
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
|
|
|
@@ -328,8 +372,8 @@ Same topics for events, variables, programs, counters, `set` and `paramset`. Dif
|
|
|
328
372
|
## Development
|
|
329
373
|
|
|
330
374
|
`npm test` (node:test, no CCU needed), `npm run lint`. `deploy.sh` ships the package (and
|
|
331
|
-
`file:../` siblings) to a host and restarts the `hm2mqtt@*` units. Plan and decisions:
|
|
332
|
-
|
|
375
|
+
`file:../` siblings) to a host and restarts the `hm2mqtt@*` units. Plan and decisions: the
|
|
376
|
+
roadmap, kept outside this repository; changes: [CHANGELOG.md](CHANGELOG.md).
|
|
333
377
|
|
|
334
378
|
## License
|
|
335
379
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
'
|
|
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
|
-
|
|
819
|
-
|
|
820
|
-
)
|
|
821
|
-
|
|
822
|
-
.
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
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('
|
|
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('
|
|
909
|
+
log.warn(regaSync.label + ' disconnected');
|
|
844
910
|
updateConnected();
|
|
845
911
|
}
|
|
846
912
|
});
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
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/hadiscovery.js
CHANGED
|
@@ -635,7 +635,10 @@ function deviceBlock({iface, device, list, ctx, t, st, cmd, bridgeId, ignored})
|
|
|
635
635
|
}
|
|
636
636
|
const avty = [...availability(name, 2)];
|
|
637
637
|
if (maintenance && maintenance.description.UNREACH && !ignored(iface, maintenance.ADDRESS, 'UNREACH')) {
|
|
638
|
-
|
|
638
|
+
// the template of an entry of an availability *list* is `val_tpl`; `avty_tpl` expands to
|
|
639
|
+
// `availability_template`, which Home Assistant's schema for such an entry does not allow
|
|
640
|
+
// - and it refuses the whole device payload over it (mqtt-interfaces-core 0.15.2)
|
|
641
|
+
avty.push({t: st(iface, maintenance.ADDRESS, 'UNREACH'), val_tpl: t.bool('offline', 'online')});
|
|
639
642
|
}
|
|
640
643
|
return {
|
|
641
644
|
id,
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "3.6.1",
|
|
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",
|
|
@@ -69,7 +70,7 @@
|
|
|
69
70
|
"binrpc": "^3.3.1",
|
|
70
71
|
"homematic-rega": "^2.0.0",
|
|
71
72
|
"homematic-xmlrpc": "^2.0.0",
|
|
72
|
-
"mqtt-interfaces-core": "^0.15.
|
|
73
|
+
"mqtt-interfaces-core": "^0.15.2"
|
|
73
74
|
},
|
|
74
75
|
"devDependencies": {
|
|
75
76
|
"@eslint/js": "^9.0.0",
|