hm2mqtt 3.2.1 → 3.4.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 +62 -0
- package/config.js +15 -1
- package/index.js +46 -7
- package/lib/discovery.js +79 -0
- package/lib/interfaces.js +88 -9
- package/package.json +2 -2
- package/scripts/addon-api.js +159 -0
package/README.md
CHANGED
|
@@ -46,6 +46,68 @@ docker run -d --name hm2mqtt --restart unless-stopped --network host -v hm2mqtt:
|
|
|
46
46
|
Host networking, or publish 2126/2127 and set `HM2MQTT_INIT_ADDRESS` to the docker host's address
|
|
47
47
|
so the CCU can call back. `--restart unless-stopped` brings it back after `maintenance/set/restart`.
|
|
48
48
|
|
|
49
|
+
### On the CCU itself (addon package)
|
|
50
|
+
|
|
51
|
+
For a setup without a server: hm2mqtt also ships as a CCU addon, installed in the WebUI under
|
|
52
|
+
_Systemsteuerung → Zusatzsoftware_. Pick the package for your hardware from the
|
|
53
|
+
[latest release](https://github.com/hobbyquaker/hm2mqtt.js/releases/latest):
|
|
54
|
+
|
|
55
|
+
| Platform | Package |
|
|
56
|
+
| ------------------------------------------------ | -------------------------------------- |
|
|
57
|
+
| CCU3 with the official eQ-3 firmware | `hm2mqtt-ccu-armv7l-<version>.tar.gz` |
|
|
58
|
+
| OpenCCU 32-bit (CCU3 hardware, Raspberry Pi 2/3) | `hm2mqtt-ccu-armv7l-<version>.tar.gz` |
|
|
59
|
+
| OpenCCU 64-bit (Raspberry Pi 4/5) | `hm2mqtt-ccu-aarch64-<version>.tar.gz` |
|
|
60
|
+
| OpenCCU on x86_64 (debmatic, virtual machines) | `hm2mqtt-ccu-x86_64-<version>.tar.gz` |
|
|
61
|
+
|
|
62
|
+
After the install a **hm2mqtt** button appears in _Systemsteuerung_: it configures every option of
|
|
63
|
+
this README in a form, starts and stops the service and shows the log. The only setting that has to
|
|
64
|
+
be made is the broker URL — a CCU has no MQTT broker of its own, so point hm2mqtt at one on your
|
|
65
|
+
network (or install the Mosquitto addon).
|
|
66
|
+
|
|
67
|
+
Everything the addon needs lives in `/usr/local/addons/hm2mqtt`, including its own Node.js runtime
|
|
68
|
+
(the CCU3's firmware is far too old to run a current Node, so the addon brings a musl build that
|
|
69
|
+
depends on nothing outside its own directory — another addon's Node.js is neither used nor
|
|
70
|
+
disturbed). On the CCU it talks to the interface processes directly instead of through lighttpd:
|
|
71
|
+
binrpc on 32001/32000 for BidCos, hmipserver on 32010, ReGa on 8183 — no CCU authentication, no
|
|
72
|
+
firewall rules, and nothing of hm2mqtt listening on the network. `--local` / `--no-local` overrides
|
|
73
|
+
the automatic detection.
|
|
74
|
+
|
|
75
|
+
The addon packages are marked `-beta` until someone has confirmed an install on real hardware.
|
|
76
|
+
|
|
77
|
+
## Finding the CCU
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
hm2mqtt --discover
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
broadcasts the eQ-3 discovery datagram (UDP 43439) and prints every CCU that answers, with its
|
|
84
|
+
firmware version and the interfaces whose ports are open:
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
172.16.24.145 eQ3-HmIP-CCU3-App serial 3014F711A0001F58A992F585 [ReGa BidCos-RF BidCos-Wired HmIP-RF VirtualDevices] (udp)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`--discover-json` prints the same as JSON. `-a auto` runs the scan at start and uses the CCU it
|
|
91
|
+
found — it refuses to start when none or more than one answers, rather than bridging the wrong
|
|
92
|
+
house:
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
hm2mqtt -a auto -u mqtt://broker
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
A broadcast does not cross a router. If the CCU is on another subnet — a separate VLAN for the
|
|
99
|
+
house automation is a common setup — name it (or its subnet's broadcast address):
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
hm2mqtt --discover --discover-address 172.16.24.145
|
|
103
|
+
hm2mqtt -a auto --discover-address 172.16.24.255 -u mqtt://broker
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`--discover-timeout` (default 5 s) is how long the scan listens. The scanning itself lives in
|
|
107
|
+
[mqtt-interfaces-core](https://github.com/hobbyquaker/mqtt-interfaces-core); this adapter only
|
|
108
|
+
declares the probe and the interface ports ([lib/discovery.js](lib/discovery.js)). The probe and
|
|
109
|
+
the reply layout come from [hm-discover](https://github.com/hobbyquaker/hm-discover).
|
|
110
|
+
|
|
49
111
|
## Options
|
|
50
112
|
|
|
51
113
|
`hm2mqtt --help` lists everything; every option is also an environment variable
|
package/config.js
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import {parseConfig} from 'mqtt-interfaces-core';
|
|
2
2
|
import pkg from './package.json' with {type: 'json'};
|
|
3
3
|
import {DEFAULT_INTERFACES, INTERFACE_NAMES} from './lib/interfaces.js';
|
|
4
|
+
import {discoveryHint} from './lib/discovery.js';
|
|
4
5
|
import {DEFAULT_ITEM_TEMPLATE, DEFAULT_SYSVAR_ITEM_TEMPLATE, DEFAULT_PROGRAM_ITEM_TEMPLATE} from './lib/topics.js';
|
|
5
6
|
|
|
6
7
|
export const OPTIONS = {
|
|
7
|
-
'ccu-address': {
|
|
8
|
+
'ccu-address': {
|
|
9
|
+
alias: 'a',
|
|
10
|
+
type: 'string',
|
|
11
|
+
describe: 'hostname or ip of the CCU, or "auto" to find it on the network (see --discover)',
|
|
12
|
+
demandOption: true,
|
|
13
|
+
discover: true,
|
|
14
|
+
},
|
|
8
15
|
'ccu-tls': {type: 'boolean', describe: 'use the TLS ports (4xxxx) and https for ReGa', default: false},
|
|
9
16
|
'ccu-insecure': {type: 'boolean', describe: "accept the CCU's self-signed certificate", default: false},
|
|
10
17
|
'ccu-username': {type: 'string', describe: 'CCU user (authentication enabled on the CCU)'},
|
|
@@ -15,6 +22,11 @@ export const OPTIONS = {
|
|
|
15
22
|
describe: `comma separated interfaces (${INTERFACE_NAMES.join(', ')}) or "auto" (probe the ports)`,
|
|
16
23
|
default: DEFAULT_INTERFACES.join(','),
|
|
17
24
|
},
|
|
25
|
+
local: {
|
|
26
|
+
type: 'boolean',
|
|
27
|
+
describe:
|
|
28
|
+
'talk to the CCU processes directly (binrpc 32001/32000, hmipserver 32010, ReGa 8183) - default: probe when the address is local',
|
|
29
|
+
},
|
|
18
30
|
'bidcos-binrpc': {
|
|
19
31
|
type: 'boolean',
|
|
20
32
|
describe: 'talk binrpc instead of xmlrpc to BidCos-RF and BidCos-Wired',
|
|
@@ -122,7 +134,9 @@ export default parseConfig({
|
|
|
122
134
|
pkg,
|
|
123
135
|
options: OPTIONS,
|
|
124
136
|
defaults: {name: 'hm'},
|
|
137
|
+
discovery: discoveryHint(),
|
|
125
138
|
examples: [
|
|
139
|
+
['$0 --discover', 'find CCUs on the network and exit'],
|
|
126
140
|
['$0 -a homematic-ccu3 -u mqtt://broker', 'run in the foreground'],
|
|
127
141
|
['$0 -a 192.168.1.50 -i BidCos-RF,HmIP-RF --plain-tree state', 'two interfaces plus the plain mirror tree'],
|
|
128
142
|
['sudo $0 --install -n hm -a homematic-ccu3 -u mqtt://broker', 'install as service hm2mqtt@hm'],
|
package/index.js
CHANGED
|
@@ -10,12 +10,12 @@ import fs from 'node:fs';
|
|
|
10
10
|
import os from 'node:os';
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
import {fileURLToPath} from 'node:url';
|
|
13
|
-
import {createAdapter} from 'mqtt-interfaces-core';
|
|
13
|
+
import {createAdapter, createLogger, runDiscovery, autoAddress} from 'mqtt-interfaces-core';
|
|
14
14
|
import {Rega} from 'homematic-rega';
|
|
15
15
|
import config from './config.js';
|
|
16
16
|
import pkg from './package.json' with {type: 'json'};
|
|
17
17
|
import {handle as handleInstall} from './lib/install.js';
|
|
18
|
-
import {parseInterfaces, probeInterfaces, interfaceConfig} from './lib/interfaces.js';
|
|
18
|
+
import {parseInterfaces, probeInterfaces, interfaceConfig, detectLocal, regaPort} from './lib/interfaces.js';
|
|
19
19
|
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';
|
|
@@ -24,6 +24,30 @@ import {castValue, isWriteable} from './lib/cast.js';
|
|
|
24
24
|
import {sanitizeName, resolveSet, resolveParamset, plainValue, compileTemplate, ItemIndex} from './lib/topics.js';
|
|
25
25
|
import {compileIgnore} from './lib/roles.js';
|
|
26
26
|
import {discoveryModel} from './lib/hadiscovery.js';
|
|
27
|
+
import {discoveryHint} from './lib/discovery.js';
|
|
28
|
+
|
|
29
|
+
/*
|
|
30
|
+
* finding the CCU (core B-2): --discover prints what answers the eQ-3 broadcast probe,
|
|
31
|
+
* --ccu-address auto uses it when exactly one CCU answers — its dns name if it has one, so a
|
|
32
|
+
* new dhcp lease does not break the config. This runs before the installer on purpose:
|
|
33
|
+
* `--install -a auto` then writes what was found, instead of leaving every service start to
|
|
34
|
+
* scan the network and fail when the CCU is briefly away. The adapter's logger does not exist
|
|
35
|
+
* yet, so discovery gets its own.
|
|
36
|
+
*/
|
|
37
|
+
if (config.discover || config.ccuAddress === 'auto') {
|
|
38
|
+
const discoveryLog = createLogger({envPrefix: config.$envPrefix || 'HM2MQTT', level: config.verbosity});
|
|
39
|
+
const hint = discoveryHint({tls: config.ccuTls});
|
|
40
|
+
if (config.discover) {
|
|
41
|
+
await runDiscovery({hint, config, log: discoveryLog}); // prints and exits
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
config.ccuAddress = await autoAddress(hint, {config, log: discoveryLog});
|
|
45
|
+
} catch (err) {
|
|
46
|
+
// no CCU or several: a wrong guess would bridge the wrong house
|
|
47
|
+
discoveryLog.error('--ccu-address auto:', err.message);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
27
51
|
|
|
28
52
|
handleInstall(config);
|
|
29
53
|
|
|
@@ -130,6 +154,18 @@ const adapter = createAdapter({
|
|
|
130
154
|
});
|
|
131
155
|
const {log, pubStatus} = adapter;
|
|
132
156
|
|
|
157
|
+
/*
|
|
158
|
+
* Running on the CCU itself, the interface processes are on loopback and the familiar
|
|
159
|
+
* 2000/2001/2010/9292/8181 are only lighttpd proxies in front of them: an extra hop, XML over HTTP
|
|
160
|
+
* for BidCos, and the CCU's authentication - all for nothing. --local/--no-local decides; by
|
|
161
|
+
* default we probe, because node-red-contrib-ccu's config-file check stopped working on current
|
|
162
|
+
* firmware (see lib/interfaces.js).
|
|
163
|
+
*/
|
|
164
|
+
const localMode = config.local === undefined ? await detectLocal(host) : Boolean(config.local);
|
|
165
|
+
if (localMode) {
|
|
166
|
+
log.info('local mode: BidCos over binrpc (32001/32000), hmipserver on 32010, ReGa on 8183');
|
|
167
|
+
}
|
|
168
|
+
|
|
133
169
|
const metadata = new Metadata({stateDir: config.stateDir, seedFile: path.join(here, 'paramsets.json'), log});
|
|
134
170
|
metadata.load();
|
|
135
171
|
|
|
@@ -137,7 +173,8 @@ let regaSync = null;
|
|
|
137
173
|
if (config.rega) {
|
|
138
174
|
const rega = new Rega({
|
|
139
175
|
host: ccuIp,
|
|
140
|
-
tls: config.ccuTls,
|
|
176
|
+
port: regaPort({tls: config.ccuTls, local: localMode}),
|
|
177
|
+
tls: config.ccuTls && !localMode,
|
|
141
178
|
insecure: config.ccuInsecure,
|
|
142
179
|
username: config.ccuUsername,
|
|
143
180
|
password: config.ccuPassword,
|
|
@@ -591,14 +628,14 @@ function onEvent(iface, event) {
|
|
|
591
628
|
}
|
|
592
629
|
|
|
593
630
|
function createConnection(iface) {
|
|
594
|
-
const ic = interfaceConfig(iface, {tls: config.ccuTls, bidcosBinrpc: config.bidcosBinrpc});
|
|
631
|
+
const ic = interfaceConfig(iface, {tls: config.ccuTls, bidcosBinrpc: config.bidcosBinrpc, local: localMode});
|
|
595
632
|
const conn = new RpcConnection({
|
|
596
633
|
name: iface,
|
|
597
634
|
host: ccuIp,
|
|
598
635
|
protocol: ic.protocol,
|
|
599
636
|
port: ic.port,
|
|
600
637
|
path: ic.path,
|
|
601
|
-
tls: config.ccuTls,
|
|
638
|
+
tls: config.ccuTls && !localMode,
|
|
602
639
|
insecure: config.ccuInsecure,
|
|
603
640
|
username: config.ccuUsername,
|
|
604
641
|
password: config.ccuPassword,
|
|
@@ -739,12 +776,14 @@ async function start() {
|
|
|
739
776
|
regaSync.rega.url = regaSync.rega.url.replace(host, ccuIp);
|
|
740
777
|
regaSync.rega.webUrl = regaSync.rega.webUrl.replace(host, ccuIp);
|
|
741
778
|
}
|
|
742
|
-
enabled =
|
|
779
|
+
enabled =
|
|
780
|
+
parseInterfaces(config.interfaces) || (await probeInterfaces(ccuIp, {tls: config.ccuTls, local: localMode}));
|
|
743
781
|
if (enabled.length === 0) {
|
|
744
782
|
log.error('no interface found on', host, '- check --ccu-address / --interfaces');
|
|
745
783
|
}
|
|
746
784
|
log.info('interfaces:', enabled.join(', ') || '(none)');
|
|
747
|
-
|
|
785
|
+
// locally the CCU calls back over loopback, so nothing of ours needs to listen on the LAN
|
|
786
|
+
const listenAddress = config.listenAddress || (localMode ? '127.0.0.1' : firstIp());
|
|
748
787
|
servers = new RpcServers({
|
|
749
788
|
listenAddress,
|
|
750
789
|
initAddress: config.initAddress,
|
package/lib/discovery.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finding CCUs on the network (core B-2): the eQ-3 UDP broadcast probe plus the interface ports,
|
|
3
|
+
* declared as a discovery hint the core scans with.
|
|
4
|
+
*
|
|
5
|
+
* The probe and the reply layout come from
|
|
6
|
+
* [hm-discover](https://github.com/hobbyquaker/hm-discover): a datagram to UDP 43439 that every
|
|
7
|
+
* eQ-3 device (CCU1/2/3, RaspberryMatic, HmIP access points) answers with its type, serial and
|
|
8
|
+
* firmware version. Which interfaces the CCU actually runs is then read off the ports that
|
|
9
|
+
* answer — the same table `--interfaces auto` probes.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {INTERFACES, INTERFACE_NAMES, interfaceConfig} from './interfaces.js';
|
|
13
|
+
|
|
14
|
+
/** eQ-3 discovery port and the magic datagram: header + "eQ3-*\0*\0I". */
|
|
15
|
+
export const EQ3_PORT = 43439;
|
|
16
|
+
export const EQ3_HEADER = '028f91c001';
|
|
17
|
+
export const EQ3_PROBE = Buffer.from([
|
|
18
|
+
0x02, 0x8f, 0x91, 0xc0, 0x01, 0x65, 0x51, 0x33, 0x2d, 0x2a, 0x00, 0x2a, 0x00, 0x49,
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Parse an eQ-3 discovery answer: the 5 byte header, then NUL terminated type and serial, three
|
|
23
|
+
* flag bytes, then the firmware version.
|
|
24
|
+
* @param {Buffer} message
|
|
25
|
+
* @returns {{type: string, serial: string, version?: string} | null} null for a foreign datagram
|
|
26
|
+
*/
|
|
27
|
+
export function parseEq3(message) {
|
|
28
|
+
if (!Buffer.isBuffer(message) || message.length < 8 || message.subarray(0, 5).toString('hex') !== EQ3_HEADER) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
let offset = 5;
|
|
32
|
+
const string = () => {
|
|
33
|
+
const end = message.indexOf(0, offset);
|
|
34
|
+
if (end < 0) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const value = message.toString('latin1', offset, end);
|
|
38
|
+
offset = end + 1;
|
|
39
|
+
return value;
|
|
40
|
+
};
|
|
41
|
+
const type = string();
|
|
42
|
+
const serial = string();
|
|
43
|
+
if (type === null || serial === null) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
offset += 3; // three flag bytes between the serial and the version
|
|
47
|
+
const version = string();
|
|
48
|
+
return {type, serial, ...(version ? {version} : {})};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The ports discovery probes on a candidate: ReGa plus every interface process. */
|
|
52
|
+
export function ports({tls = false} = {}) {
|
|
53
|
+
const map = {ReGa: tls ? 48181 : 8181};
|
|
54
|
+
for (const name of INTERFACE_NAMES) {
|
|
55
|
+
map[name] = interfaceConfig(name, {tls}).port;
|
|
56
|
+
}
|
|
57
|
+
return map;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The hint `--discover` and `--ccu-address auto` scan with. A CCU answers the broadcast; the
|
|
62
|
+
* ports say which interfaces it runs. `requirePort: false` keeps a CCU that answered the probe
|
|
63
|
+
* but has, say, only HmIP-RF enabled and every other port closed — the broadcast answer is proof
|
|
64
|
+
* enough, and the open ports are shown as information.
|
|
65
|
+
*/
|
|
66
|
+
export function discoveryHint({tls = false} = {}) {
|
|
67
|
+
return {
|
|
68
|
+
udp: {port: EQ3_PORT, payload: EQ3_PROBE, parse: parseEq3},
|
|
69
|
+
ports: ports({tls}),
|
|
70
|
+
requirePort: false,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The interfaces of a discovered CCU, in table order, from the probed ports. */
|
|
75
|
+
export function interfacesOf(entry) {
|
|
76
|
+
return INTERFACE_NAMES.filter((name) => entry && entry.services && entry.services[name]);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export {INTERFACES};
|
package/lib/interfaces.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The CCU interface processes: names as the CCU uses them, ports (plain / TLS), protocol,
|
|
2
|
+
* The CCU interface processes: names as the CCU uses them, ports (plain / TLS / local), protocol,
|
|
3
3
|
* whether they want an init() subscription and answer pings.
|
|
4
|
+
*
|
|
5
|
+
* `localPort` is what the process itself listens on, as opposed to the familiar 2000/2001/2010/9292
|
|
6
|
+
* which are lighttpd proxies in front of it. Running on the CCU we talk to the process directly:
|
|
7
|
+
* one hop less, no XML over HTTP for BidCos (rfd speaks binrpc there), and no CCU authentication -
|
|
8
|
+
* exactly what node-red-contrib-ccu does when it detects a local connection.
|
|
4
9
|
*/
|
|
5
10
|
|
|
6
11
|
import net from 'node:net';
|
|
@@ -9,30 +14,66 @@ export const INTERFACES = {
|
|
|
9
14
|
'BidCos-RF': {
|
|
10
15
|
port: 2001,
|
|
11
16
|
tlsPort: 42001,
|
|
17
|
+
localPort: 32001,
|
|
18
|
+
localBinrpc: true,
|
|
12
19
|
protocol: 'xmlrpc',
|
|
13
20
|
binrpc: true,
|
|
14
21
|
init: true,
|
|
15
22
|
ping: true,
|
|
16
23
|
dutyCycle: true,
|
|
17
24
|
},
|
|
18
|
-
'BidCos-Wired': {
|
|
25
|
+
'BidCos-Wired': {
|
|
26
|
+
port: 2000,
|
|
27
|
+
tlsPort: 42000,
|
|
28
|
+
localPort: 32000,
|
|
29
|
+
localBinrpc: true,
|
|
30
|
+
protocol: 'xmlrpc',
|
|
31
|
+
binrpc: true,
|
|
32
|
+
init: true,
|
|
33
|
+
ping: true,
|
|
34
|
+
},
|
|
19
35
|
// https://github.com/eq-3/occu/issues/42 — HmIP-RF answers pings but events are rare, node-red used 600 s
|
|
20
36
|
'HmIP-RF': {
|
|
21
37
|
port: 2010,
|
|
22
38
|
tlsPort: 42010,
|
|
39
|
+
// hmipserver speaks no binrpc; "direct" here only means past the proxy
|
|
40
|
+
localPort: 32010,
|
|
23
41
|
protocol: 'xmlrpc',
|
|
24
42
|
init: true,
|
|
25
43
|
ping: true,
|
|
26
44
|
pingTimeout: 600,
|
|
27
45
|
dutyCycle: true,
|
|
28
46
|
},
|
|
29
|
-
VirtualDevices: {
|
|
47
|
+
VirtualDevices: {
|
|
48
|
+
port: 9292,
|
|
49
|
+
tlsPort: 49292,
|
|
50
|
+
localPort: 39292,
|
|
51
|
+
path: '/groups',
|
|
52
|
+
protocol: 'xmlrpc',
|
|
53
|
+
init: true,
|
|
54
|
+
ping: false,
|
|
55
|
+
},
|
|
30
56
|
CUxD: {port: 8701, protocol: 'binrpc', init: true, ping: true},
|
|
31
57
|
};
|
|
32
58
|
|
|
33
59
|
export const INTERFACE_NAMES = Object.keys(INTERFACES);
|
|
34
60
|
export const DEFAULT_INTERFACES = ['BidCos-RF', 'HmIP-RF', 'VirtualDevices', 'BidCos-Wired'];
|
|
35
61
|
|
|
62
|
+
/** ReGa's script port: 8181 through lighttpd, 8183 is ReGaHSS itself (no authentication). */
|
|
63
|
+
export const REGA_PORT = 8181;
|
|
64
|
+
export const REGA_TLS_PORT = 48181;
|
|
65
|
+
export const REGA_LOCAL_PORT = 8183;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The port the ReGa script interface is reached on.
|
|
69
|
+
* @param {{tls?: boolean, local?: boolean}} [options]
|
|
70
|
+
* @returns {number}
|
|
71
|
+
*/
|
|
72
|
+
export function regaPort({tls = false, local = false} = {}) {
|
|
73
|
+
if (local) return REGA_LOCAL_PORT;
|
|
74
|
+
return tls ? REGA_TLS_PORT : REGA_PORT;
|
|
75
|
+
}
|
|
76
|
+
|
|
36
77
|
/**
|
|
37
78
|
* Parses the --interfaces option. Returns the list of names, or null for "auto".
|
|
38
79
|
* @param {string} value
|
|
@@ -61,16 +102,19 @@ export function parseInterfaces(value) {
|
|
|
61
102
|
* @returns {{name: string, protocol: 'xmlrpc' | 'binrpc', port: number, path?: string, init: boolean,
|
|
62
103
|
* ping: boolean, pingTimeout?: number, dutyCycle: boolean}}
|
|
63
104
|
*/
|
|
64
|
-
export function interfaceConfig(name, {tls = false, bidcosBinrpc = false} = {}) {
|
|
105
|
+
export function interfaceConfig(name, {tls = false, bidcosBinrpc = false, local = false} = {}) {
|
|
65
106
|
const def = INTERFACES[name];
|
|
66
107
|
if (!def) {
|
|
67
108
|
throw new Error('unknown interface ' + name);
|
|
68
109
|
}
|
|
69
|
-
|
|
110
|
+
// local wins over tls: the process ports carry no TLS, and they need none on loopback
|
|
111
|
+
const useLocal = local && Boolean(def.localPort);
|
|
112
|
+
const binrpc =
|
|
113
|
+
def.protocol === 'binrpc' || (useLocal && def.localBinrpc) || (def.binrpc && bidcosBinrpc && !tls && !useLocal);
|
|
70
114
|
return {
|
|
71
115
|
name,
|
|
72
116
|
protocol: binrpc ? 'binrpc' : 'xmlrpc',
|
|
73
|
-
port: tls && def.tlsPort ? def.tlsPort : def.port,
|
|
117
|
+
port: useLocal ? def.localPort : tls && def.tlsPort ? def.tlsPort : def.port,
|
|
74
118
|
path: def.path,
|
|
75
119
|
init: def.init,
|
|
76
120
|
ping: def.ping,
|
|
@@ -95,16 +139,51 @@ function portOpen(host, port, timeout) {
|
|
|
95
139
|
/**
|
|
96
140
|
* Probes which interface ports answer on the CCU (--interfaces auto).
|
|
97
141
|
* @param {string} host
|
|
98
|
-
* @param {{tls?: boolean, timeout?: number, connect?: Function}} [options]
|
|
142
|
+
* @param {{tls?: boolean, local?: boolean, timeout?: number, connect?: Function}} [options]
|
|
99
143
|
* @returns {Promise<string[]>} interface names in table order
|
|
100
144
|
*/
|
|
101
|
-
export async function probeInterfaces(host, {tls = false, timeout = 2000, connect = portOpen} = {}) {
|
|
145
|
+
export async function probeInterfaces(host, {tls = false, local = false, timeout = 2000, connect = portOpen} = {}) {
|
|
102
146
|
const found = [];
|
|
103
147
|
for (const name of INTERFACE_NAMES) {
|
|
104
|
-
const {port} = interfaceConfig(name, {tls});
|
|
148
|
+
const {port} = interfaceConfig(name, {tls, local});
|
|
105
149
|
if (await connect(host, port, timeout)) {
|
|
106
150
|
found.push(name);
|
|
107
151
|
}
|
|
108
152
|
}
|
|
109
153
|
return found;
|
|
110
154
|
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Is this address the machine we run on?
|
|
158
|
+
* @param {string} host
|
|
159
|
+
* @returns {boolean}
|
|
160
|
+
*/
|
|
161
|
+
export function isLocalHost(host) {
|
|
162
|
+
const value = String(host || '')
|
|
163
|
+
.trim()
|
|
164
|
+
.toLowerCase();
|
|
165
|
+
return value === 'localhost' || value === '::1' || value === '[::1]' || /^127\./.test(value);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Whether the CCU's interface processes can be reached directly, i.e. whether hm2mqtt runs on the
|
|
170
|
+
* CCU itself. Probes the process ports instead of reading a lighttpd config the way
|
|
171
|
+
* node-red-contrib-ccu does - that check looks for `"port" => 32001` in
|
|
172
|
+
* /etc/lighttpd/conf.d/proxy.conf, which on firmware 3.8x is a one-line include, so it silently
|
|
173
|
+
* stopped detecting anything while the ports themselves are all still there.
|
|
174
|
+
* @param {string} host
|
|
175
|
+
* @param {{timeout?: number, connect?: Function}} [options]
|
|
176
|
+
* @returns {Promise<boolean>}
|
|
177
|
+
*/
|
|
178
|
+
export async function detectLocal(host, {timeout = 500, connect = portOpen} = {}) {
|
|
179
|
+
if (!isLocalHost(host)) {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
const ports = INTERFACE_NAMES.map((name) => INTERFACES[name].localPort).filter(Boolean);
|
|
183
|
+
for (const port of [...ports, REGA_LOCAL_PORT]) {
|
|
184
|
+
if (await connect(host, port, timeout)) {
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return false;
|
|
189
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hm2mqtt",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.0",
|
|
4
4
|
"description": "Interface between Homematic CCU and MQTT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
"binrpc": "^3.3.1",
|
|
70
70
|
"homematic-rega": "^2.0.0",
|
|
71
71
|
"homematic-xmlrpc": "^2.0.0",
|
|
72
|
-
"mqtt-interfaces-core": "^0.
|
|
72
|
+
"mqtt-interfaces-core": "^0.15.1"
|
|
73
73
|
},
|
|
74
74
|
"devDependencies": {
|
|
75
75
|
"@eslint/js": "^9.0.0",
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One-shot helpers for the CCU addon's web UI, called through `www/api.cgi`. Everything here is
|
|
5
|
+
* a short-lived process that prints one JSON object and exits - the addon runs no HTTP server of
|
|
6
|
+
* its own, and the UI's interactive bits (probe the interfaces, test the broker, preview an item
|
|
7
|
+
* template) would otherwise need one.
|
|
8
|
+
*
|
|
9
|
+
* node scripts/addon-api.js discover [--timeout 4000]
|
|
10
|
+
* node scripts/addon-api.js probe --host 127.0.0.1 [--tls]
|
|
11
|
+
* node scripts/addon-api.js mqtt-test --url mqtt://host:1883 [--username u] [--password p]
|
|
12
|
+
* node scripts/addon-api.js channels --host 127.0.0.1 [--limit 20]
|
|
13
|
+
* node scripts/addon-api.js preview --host 127.0.0.1 --template '${channelName|channel}/${datapoint}'
|
|
14
|
+
*
|
|
15
|
+
* Errors are JSON too ({"error": "..."}), so the UI never has to parse a stack trace.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import Rega from 'homematic-rega';
|
|
19
|
+
import {discover} from 'mqtt-interfaces-core';
|
|
20
|
+
import {probeInterfaces, INTERFACE_NAMES} from '../lib/interfaces.js';
|
|
21
|
+
import {discoveryHint, interfacesOf} from '../lib/discovery.js';
|
|
22
|
+
import {compileTemplate, DEFAULT_ITEM_TEMPLATE} from '../lib/topics.js';
|
|
23
|
+
|
|
24
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Parses `--key value` and `--flag` arguments.
|
|
28
|
+
* @param {string[]} argv
|
|
29
|
+
* @returns {Record<string, string | boolean>}
|
|
30
|
+
*/
|
|
31
|
+
function parseArgs(argv) {
|
|
32
|
+
const args = {};
|
|
33
|
+
for (let i = 0; i < argv.length; i++) {
|
|
34
|
+
if (!argv[i].startsWith('--')) continue;
|
|
35
|
+
const key = argv[i].slice(2);
|
|
36
|
+
const next = argv[i + 1];
|
|
37
|
+
if (next === undefined || next.startsWith('--')) {
|
|
38
|
+
args[key] = true;
|
|
39
|
+
} else {
|
|
40
|
+
args[key] = next;
|
|
41
|
+
i++;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return args;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const args = parseArgs(rest);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* ReGa client for the CCU the addon runs on. Port 8183 is ReGaHSS' own listener, which needs no
|
|
51
|
+
* authentication - it exists on the CCU itself, so a local addon never has to ask for credentials.
|
|
52
|
+
* @param {object} options
|
|
53
|
+
* @returns {Rega}
|
|
54
|
+
*/
|
|
55
|
+
function rega({host = '127.0.0.1', port, username, password} = {}) {
|
|
56
|
+
const local = host === '127.0.0.1' || host === 'localhost';
|
|
57
|
+
return new Rega({
|
|
58
|
+
host,
|
|
59
|
+
port: port ? Number(port) : local ? 8183 : 8181,
|
|
60
|
+
username,
|
|
61
|
+
password,
|
|
62
|
+
translate: false,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const commands = {
|
|
67
|
+
// the same broadcast probe as --discover, for an addon that bridges a CCU elsewhere in the
|
|
68
|
+
// network rather than the one it runs on
|
|
69
|
+
async discover() {
|
|
70
|
+
const found = await discover(discoveryHint({tls: Boolean(args.tls)}), {
|
|
71
|
+
timeout: Number(args.timeout || 4000),
|
|
72
|
+
// broadcast only: a subnet sweep can run for minutes, which is not something a web
|
|
73
|
+
// page should wait for. `hm2mqtt --discover` on a shell still does the full search.
|
|
74
|
+
sweep: false,
|
|
75
|
+
});
|
|
76
|
+
return {
|
|
77
|
+
ccus: [...found.values()].map((entry) => ({
|
|
78
|
+
address: entry.address,
|
|
79
|
+
name: entry.name || entry.serial || '',
|
|
80
|
+
interfaces: interfacesOf(entry),
|
|
81
|
+
})),
|
|
82
|
+
};
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
async probe() {
|
|
86
|
+
const host = String(args.host || '127.0.0.1');
|
|
87
|
+
const found = await probeInterfaces(host, {tls: Boolean(args.tls), timeout: 2000});
|
|
88
|
+
return {host, interfaces: found, known: INTERFACE_NAMES};
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
async 'mqtt-test'() {
|
|
92
|
+
const url = String(args.url || '');
|
|
93
|
+
if (!url) throw new Error('--url is required');
|
|
94
|
+
// mqtt comes with mqtt-interfaces-core; if it cannot be resolved the test degrades to a
|
|
95
|
+
// plain connect instead of failing outright
|
|
96
|
+
const {default: mqtt} = await import('mqtt');
|
|
97
|
+
const started = Date.now();
|
|
98
|
+
const client = mqtt.connect(url, {
|
|
99
|
+
username: args.username ? String(args.username) : undefined,
|
|
100
|
+
password: args.password ? String(args.password) : undefined,
|
|
101
|
+
clientId: 'hm2mqtt-addon-test-' + Math.random().toString(16).slice(2, 8),
|
|
102
|
+
connectTimeout: 8000,
|
|
103
|
+
reconnectPeriod: 0,
|
|
104
|
+
});
|
|
105
|
+
try {
|
|
106
|
+
await new Promise((resolve, reject) => {
|
|
107
|
+
client.once('connect', resolve);
|
|
108
|
+
client.once('error', reject);
|
|
109
|
+
client.once('close', () => reject(new Error('connection closed')));
|
|
110
|
+
});
|
|
111
|
+
return {ok: true, ms: Date.now() - started};
|
|
112
|
+
} finally {
|
|
113
|
+
client.end(true);
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
async channels() {
|
|
118
|
+
const limit = Number(args.limit || 20);
|
|
119
|
+
const channels = await rega(args).getChannels();
|
|
120
|
+
return {
|
|
121
|
+
count: channels.length,
|
|
122
|
+
channels: channels.slice(0, limit).map(({address, name}) => ({address, name})),
|
|
123
|
+
};
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
async preview() {
|
|
127
|
+
const template = String(args.template || DEFAULT_ITEM_TEMPLATE);
|
|
128
|
+
const render = compileTemplate(template);
|
|
129
|
+
const limit = Number(args.limit || 5);
|
|
130
|
+
const channels = await rega(args).getChannels();
|
|
131
|
+
const datapoints = ['STATE', 'LEVEL', 'TEMPERATURE'];
|
|
132
|
+
const examples = channels.slice(0, limit).map((ch, index) => {
|
|
133
|
+
const datapoint = datapoints[index % datapoints.length];
|
|
134
|
+
const fields = {
|
|
135
|
+
channel: ch.address,
|
|
136
|
+
channelName: ch.name,
|
|
137
|
+
device: String(ch.address).split(':')[0],
|
|
138
|
+
datapoint,
|
|
139
|
+
};
|
|
140
|
+
const {name, changed} = render(fields);
|
|
141
|
+
return {channel: ch.name || ch.address, datapoint, item: name, sanitized: changed};
|
|
142
|
+
});
|
|
143
|
+
return {template, examples};
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
if (!command || !commands[command]) {
|
|
148
|
+
process.stdout.write(JSON.stringify({error: `unknown command "${command || ''}"`}) + '\n');
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
const result = await commands[command]();
|
|
154
|
+
process.stdout.write(JSON.stringify(result) + '\n');
|
|
155
|
+
process.exit(0);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
process.stdout.write(JSON.stringify({error: error.message || String(error)}) + '\n');
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|