hm2mqtt 3.2.0 → 3.3.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 +38 -4
- package/config.js +10 -1
- package/index.js +25 -1
- package/lib/compare.js +32 -5
- package/lib/discovery.js +79 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -35,17 +35,51 @@ state directory; broker credentials can live in the shared `/etc/mqtt-interfaces
|
|
|
35
35
|
`--uninstall -n hm` removes the instance. `--config-schema` prints a JSON schema of all options
|
|
36
36
|
(management UIs like [she](https://github.com/hobbyquaker/she) build their config forms from it).
|
|
37
37
|
|
|
38
|
-
Docker (
|
|
38
|
+
Docker (multi-arch image for amd64, arm64 and armv7):
|
|
39
39
|
|
|
40
40
|
```
|
|
41
|
-
docker
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
docker run -d --name hm2mqtt --restart unless-stopped --network host -v hm2mqtt:/data \
|
|
42
|
+
-e HM2MQTT_CCU_ADDRESS=homematic-ccu3 -e HM2MQTT_MQTT_URL=mqtt://broker \
|
|
43
|
+
ghcr.io/hobbyquaker/hm2mqtt.js
|
|
44
44
|
```
|
|
45
45
|
|
|
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
|
+
## Finding the CCU
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
hm2mqtt --discover
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
broadcasts the eQ-3 discovery datagram (UDP 43439) and prints every CCU that answers, with its
|
|
56
|
+
firmware version and the interfaces whose ports are open:
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
172.16.24.145 eQ3-HmIP-CCU3-App serial 3014F711A0001F58A992F585 [ReGa BidCos-RF BidCos-Wired HmIP-RF VirtualDevices] (udp)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`--discover-json` prints the same as JSON. `-a auto` runs the scan at start and uses the CCU it
|
|
63
|
+
found — it refuses to start when none or more than one answers, rather than bridging the wrong
|
|
64
|
+
house:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
hm2mqtt -a auto -u mqtt://broker
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
A broadcast does not cross a router. If the CCU is on another subnet — a separate VLAN for the
|
|
71
|
+
house automation is a common setup — name it (or its subnet's broadcast address):
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
hm2mqtt --discover --discover-address 172.16.24.145
|
|
75
|
+
hm2mqtt -a auto --discover-address 172.16.24.255 -u mqtt://broker
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`--discover-timeout` (default 5 s) is how long the scan listens. The scanning itself lives in
|
|
79
|
+
[mqtt-interfaces-core](https://github.com/hobbyquaker/mqtt-interfaces-core); this adapter only
|
|
80
|
+
declares the probe and the interface ports ([lib/discovery.js](lib/discovery.js)). The probe and
|
|
81
|
+
the reply layout come from [hm-discover](https://github.com/hobbyquaker/hm-discover).
|
|
82
|
+
|
|
49
83
|
## Options
|
|
50
84
|
|
|
51
85
|
`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)'},
|
|
@@ -122,7 +129,9 @@ export default parseConfig({
|
|
|
122
129
|
pkg,
|
|
123
130
|
options: OPTIONS,
|
|
124
131
|
defaults: {name: 'hm'},
|
|
132
|
+
discovery: discoveryHint(),
|
|
125
133
|
examples: [
|
|
134
|
+
['$0 --discover', 'find CCUs on the network and exit'],
|
|
126
135
|
['$0 -a homematic-ccu3 -u mqtt://broker', 'run in the foreground'],
|
|
127
136
|
['$0 -a 192.168.1.50 -i BidCos-RF,HmIP-RF --plain-tree state', 'two interfaces plus the plain mirror tree'],
|
|
128
137
|
['sudo $0 --install -n hm -a homematic-ccu3 -u mqtt://broker', 'install as service hm2mqtt@hm'],
|
package/index.js
CHANGED
|
@@ -10,7 +10,7 @@ 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'};
|
|
@@ -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
|
|
package/lib/compare.js
CHANGED
|
@@ -39,12 +39,14 @@ function same(a, b) {
|
|
|
39
39
|
/**
|
|
40
40
|
* @param {Map<string, string>} left item → raw payload (the reference, e.g. the flow)
|
|
41
41
|
* @param {Map<string, string>} right item → raw payload (hm2mqtt)
|
|
42
|
-
* @returns {{leftOnly: string[], rightOnly: string[], differences: Array<{item: string, field: string, left: *, right: *}>,
|
|
42
|
+
* @returns {{leftOnly: string[], rightOnly: string[], differences: Array<{item: string, field: string, left: *, right: *}>,
|
|
43
|
+
* additions: Array<{item: string, field: string, right: *}>, same: number}}
|
|
43
44
|
*/
|
|
44
45
|
export function compareTrees(left, right) {
|
|
45
46
|
const leftOnly = [];
|
|
46
47
|
const rightOnly = [];
|
|
47
48
|
const differences = [];
|
|
49
|
+
const additions = [];
|
|
48
50
|
let sameCount = 0;
|
|
49
51
|
for (const item of left.keys()) {
|
|
50
52
|
if (!right.has(item)) {
|
|
@@ -73,7 +75,11 @@ export function compareTrees(left, right) {
|
|
|
73
75
|
if (IGNORED_HM_FIELDS.has(field)) {
|
|
74
76
|
continue;
|
|
75
77
|
}
|
|
76
|
-
if (
|
|
78
|
+
if (hl[field] === undefined && hr[field] !== undefined) {
|
|
79
|
+
// a field the reference never had: an addition, not a difference
|
|
80
|
+
if (!ADDED_HM_FIELDS.has(field)) {
|
|
81
|
+
additions.push({item, field: 'hm.' + field, right: hr[field]});
|
|
82
|
+
}
|
|
77
83
|
continue;
|
|
78
84
|
}
|
|
79
85
|
if (!same(hl[field], hr[field])) {
|
|
@@ -87,17 +93,38 @@ export function compareTrees(left, right) {
|
|
|
87
93
|
}
|
|
88
94
|
leftOnly.sort();
|
|
89
95
|
rightOnly.sort();
|
|
90
|
-
return {leftOnly, rightOnly, differences, same: sameCount};
|
|
96
|
+
return {leftOnly, rightOnly, differences, additions, same: sameCount};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function countBy(list, key) {
|
|
100
|
+
const counts = new Map();
|
|
101
|
+
for (const entry of list) {
|
|
102
|
+
counts.set(entry[key], (counts.get(entry[key]) || 0) + 1);
|
|
103
|
+
}
|
|
104
|
+
return [...counts].sort((a, b) => b[1] - a[1]);
|
|
91
105
|
}
|
|
92
106
|
|
|
93
107
|
export function formatReport(
|
|
94
|
-
{leftOnly, rightOnly, differences, same},
|
|
108
|
+
{leftOnly, rightOnly, differences, additions = [], same},
|
|
95
109
|
{leftName = 'left', rightName = 'right', limit = 50} = {},
|
|
96
110
|
) {
|
|
97
111
|
const lines = [];
|
|
112
|
+
const differing = new Set(differences.map((d) => d.item)).size;
|
|
98
113
|
lines.push(
|
|
99
|
-
`${same} items identical, ${differences.length} differences, ${leftOnly.length} only in ${leftName}, ${rightOnly.length} only in ${rightName}`,
|
|
114
|
+
`${same} items identical, ${differing} items with ${differences.length} differences, ${additions.length} added fields, ${leftOnly.length} only in ${leftName}, ${rightOnly.length} only in ${rightName}`,
|
|
100
115
|
);
|
|
116
|
+
if (differences.length > 0) {
|
|
117
|
+
lines.push('', 'differences by field:');
|
|
118
|
+
for (const [field, n] of countBy(differences, 'field')) {
|
|
119
|
+
lines.push(` ${field}: ${n}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (additions.length > 0) {
|
|
123
|
+
lines.push('', `fields only in ${rightName} (additions):`);
|
|
124
|
+
for (const [field, n] of countBy(additions, 'field')) {
|
|
125
|
+
lines.push(` ${field}: ${n}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
101
128
|
const list = (title, items) => {
|
|
102
129
|
if (items.length === 0) {
|
|
103
130
|
return;
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hm2mqtt",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.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.9.0"
|
|
73
73
|
},
|
|
74
74
|
"devDependencies": {
|
|
75
75
|
"@eslint/js": "^9.0.0",
|