hm2mqtt 3.3.0 → 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 CHANGED
@@ -46,6 +46,34 @@ 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
+
49
77
  ## Finding the CCU
50
78
 
51
79
  ```
package/config.js CHANGED
@@ -22,6 +22,11 @@ export const OPTIONS = {
22
22
  describe: `comma separated interfaces (${INTERFACE_NAMES.join(', ')}) or "auto" (probe the ports)`,
23
23
  default: DEFAULT_INTERFACES.join(','),
24
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
+ },
25
30
  'bidcos-binrpc': {
26
31
  type: 'boolean',
27
32
  describe: 'talk binrpc instead of xmlrpc to BidCos-RF and BidCos-Wired',
package/index.js CHANGED
@@ -15,7 +15,7 @@ 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';
@@ -154,6 +154,18 @@ const adapter = createAdapter({
154
154
  });
155
155
  const {log, pubStatus} = adapter;
156
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
+
157
169
  const metadata = new Metadata({stateDir: config.stateDir, seedFile: path.join(here, 'paramsets.json'), log});
158
170
  metadata.load();
159
171
 
@@ -161,7 +173,8 @@ let regaSync = null;
161
173
  if (config.rega) {
162
174
  const rega = new Rega({
163
175
  host: ccuIp,
164
- tls: config.ccuTls,
176
+ port: regaPort({tls: config.ccuTls, local: localMode}),
177
+ tls: config.ccuTls && !localMode,
165
178
  insecure: config.ccuInsecure,
166
179
  username: config.ccuUsername,
167
180
  password: config.ccuPassword,
@@ -615,14 +628,14 @@ function onEvent(iface, event) {
615
628
  }
616
629
 
617
630
  function createConnection(iface) {
618
- const ic = interfaceConfig(iface, {tls: config.ccuTls, bidcosBinrpc: config.bidcosBinrpc});
631
+ const ic = interfaceConfig(iface, {tls: config.ccuTls, bidcosBinrpc: config.bidcosBinrpc, local: localMode});
619
632
  const conn = new RpcConnection({
620
633
  name: iface,
621
634
  host: ccuIp,
622
635
  protocol: ic.protocol,
623
636
  port: ic.port,
624
637
  path: ic.path,
625
- tls: config.ccuTls,
638
+ tls: config.ccuTls && !localMode,
626
639
  insecure: config.ccuInsecure,
627
640
  username: config.ccuUsername,
628
641
  password: config.ccuPassword,
@@ -763,12 +776,14 @@ async function start() {
763
776
  regaSync.rega.url = regaSync.rega.url.replace(host, ccuIp);
764
777
  regaSync.rega.webUrl = regaSync.rega.webUrl.replace(host, ccuIp);
765
778
  }
766
- enabled = parseInterfaces(config.interfaces) || (await probeInterfaces(ccuIp, {tls: config.ccuTls}));
779
+ enabled =
780
+ parseInterfaces(config.interfaces) || (await probeInterfaces(ccuIp, {tls: config.ccuTls, local: localMode}));
767
781
  if (enabled.length === 0) {
768
782
  log.error('no interface found on', host, '- check --ccu-address / --interfaces');
769
783
  }
770
784
  log.info('interfaces:', enabled.join(', ') || '(none)');
771
- const listenAddress = config.listenAddress || firstIp();
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());
772
787
  servers = new RpcServers({
773
788
  listenAddress,
774
789
  initAddress: config.initAddress,
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': {port: 2000, tlsPort: 42000, protocol: 'xmlrpc', binrpc: true, init: true, ping: true},
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: {port: 9292, tlsPort: 49292, path: '/groups', protocol: 'xmlrpc', init: true, ping: false},
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
- const binrpc = def.protocol === 'binrpc' || (def.binrpc && bidcosBinrpc && !tls);
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.0",
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.9.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
+ }