hm2mqtt 3.5.1 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/names.js ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * What every names provider does with its result, whatever it read it from: apply the name file
3
+ * on top, index the names for the reverse lookup, and resolve a name or address coming in on a
4
+ * set topic. Shared by lib/rega.js (ReGaHSS) and lib/meta.js (openccu-lite).
5
+ */
6
+
7
+ /**
8
+ * Overrides the names read from the box with the ones from `--name-file` and returns the reverse
9
+ * index (name -> address). `channelNames` is mutated, as the providers' own state.
10
+ * @param {Object<string, string>} channelNames
11
+ * @param {Object<string, string>} [nameFile]
12
+ * @returns {Object<string, string>} name -> address
13
+ */
14
+ export function indexNames(channelNames, nameFile = {}) {
15
+ for (const [address, name] of Object.entries(nameFile)) {
16
+ if (typeof name === 'string' && name !== '') {
17
+ channelNames[address] = name;
18
+ }
19
+ }
20
+ const addresses = {};
21
+ for (const [address, name] of Object.entries(channelNames)) {
22
+ // the first address of a duplicate name wins; channels win over devices
23
+ if (!addresses[name] || (address.includes(':') && !addresses[name].includes(':'))) {
24
+ addresses[name] = address;
25
+ }
26
+ }
27
+ return addresses;
28
+ }
29
+
30
+ /**
31
+ * Address of a channel (or device, with `devices`) by name or address.
32
+ * @param {{addresses: Object<string, string>, metadata?: object}} provider
33
+ * @param {string} nameOrAddress
34
+ * @param {boolean} [devices]
35
+ */
36
+ export function resolveAddress({addresses, metadata}, nameOrAddress, devices = false) {
37
+ let address;
38
+ if (metadata && metadata.findIface(nameOrAddress)) {
39
+ address = nameOrAddress;
40
+ } else if (addresses[nameOrAddress]) {
41
+ address = addresses[nameOrAddress];
42
+ } else if (!metadata && /^[\w-]+(:\d+)?$/.test(nameOrAddress)) {
43
+ address = nameOrAddress;
44
+ }
45
+ if (!address) {
46
+ return undefined;
47
+ }
48
+ return devices || address.includes(':') ? address : undefined;
49
+ }
package/lib/rega.js CHANGED
@@ -8,6 +8,7 @@ import fs from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import {EventEmitter} from 'node:events';
10
10
  import {castVariable} from './cast.js';
11
+ import {indexNames, resolveAddress} from './names.js';
11
12
 
12
13
  export class RegaSync extends EventEmitter {
13
14
  /**
@@ -29,6 +30,7 @@ export class RegaSync extends EventEmitter {
29
30
  this.nameFile = nameFile || {};
30
31
  this.log = log;
31
32
  this.now = now || Date.now;
33
+ this.label = 'rega';
32
34
  this.channelNames = {};
33
35
  this.addresses = {};
34
36
  this.regaIdChannel = {};
@@ -88,18 +90,7 @@ export class RegaSync extends EventEmitter {
88
90
  }
89
91
 
90
92
  applyNameFile() {
91
- for (const [address, name] of Object.entries(this.nameFile)) {
92
- if (typeof name === 'string' && name !== '') {
93
- this.channelNames[address] = name;
94
- }
95
- }
96
- this.addresses = {};
97
- for (const [address, name] of Object.entries(this.channelNames)) {
98
- // the first address of a duplicate name wins; channels win over devices
99
- if (!this.addresses[name] || (address.includes(':') && !this.addresses[name].includes(':'))) {
100
- this.addresses[name] = address;
101
- }
102
- }
93
+ this.addresses = indexNames(this.channelNames, this.nameFile);
103
94
  }
104
95
 
105
96
  /*
@@ -112,18 +103,7 @@ export class RegaSync extends EventEmitter {
112
103
 
113
104
  /** Address of a channel (or device, with `devices`) by ReGa name or address. */
114
105
  channelAddress(nameOrAddress, devices = false) {
115
- let address;
116
- if (this.metadata && this.metadata.findIface(nameOrAddress)) {
117
- address = nameOrAddress;
118
- } else if (this.addresses[nameOrAddress]) {
119
- address = this.addresses[nameOrAddress];
120
- } else if (!this.metadata && /^[\w-]+(:\d+)?$/.test(nameOrAddress)) {
121
- address = nameOrAddress;
122
- }
123
- if (!address) {
124
- return undefined;
125
- }
126
- return devices || address.includes(':') ? address : undefined;
106
+ return resolveAddress(this, nameOrAddress, devices);
127
107
  }
128
108
 
129
109
  rooms(address) {
package/lib/topics.js CHANGED
@@ -17,11 +17,6 @@ export const DEFAULT_TOPIC_SYSVAR_SET = '${prefix}/set/${name}';
17
17
  export const DEFAULT_TOPIC_PROGRAM_STATUS = '${prefix}/status/${name}';
18
18
  export const DEFAULT_TOPIC_PROGRAM_SET = '${prefix}/set/${name}';
19
19
 
20
- /** The item part of the classic topics, kept for `--item-template` and for the plain mirror tree. */
21
- export const DEFAULT_ITEM_TEMPLATE = '${channelName|channel}/${datapoint}';
22
- export const DEFAULT_SYSVAR_ITEM_TEMPLATE = '${name}';
23
- export const DEFAULT_PROGRAM_ITEM_TEMPLATE = '${name}';
24
-
25
20
  /** first topic levels an adapter uses itself; a channel or variable with such a name gets a suffix */
26
21
  export const RESERVED = new Set(['counter', 'interface', 'rega', 'paramset', 'rpc', 'response']);
27
22
 
@@ -266,25 +261,52 @@ export function templateRemainder(template, topic, fields = {}) {
266
261
  return topic.startsWith(literal) ? topic.slice(literal.length) : null;
267
262
  }
268
263
 
264
+ const kebab = (name) => name.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase());
265
+
269
266
  /**
270
267
  * `--item-template` and its two siblings named the part after `<name>/status/`. They still work:
271
268
  * an item template becomes the corresponding pair of topic templates, so an existing config keeps
272
- * publishing exactly where it did.
269
+ * publishing exactly where it did. A topic template the user changed wins over the deprecated
270
+ * option; either way the migration explains itself in `config.$warnings`.
273
271
  * @param {object} config
274
272
  * @returns {object} the same config, with the topic templates filled in
275
273
  */
276
274
  export function applyItemTemplates(config) {
277
275
  const pairs = [
278
- ['itemTemplate', 'topicStatus', 'topicSet'],
279
- ['sysvarItemTemplate', 'topicSysvarStatus', 'topicSysvarSet'],
280
- ['programItemTemplate', 'topicProgramStatus', 'topicProgramSet'],
276
+ ['itemTemplate', 'topicStatus', DEFAULT_TOPIC_STATUS, 'topicSet', DEFAULT_TOPIC_SET],
277
+ [
278
+ 'sysvarItemTemplate',
279
+ 'topicSysvarStatus',
280
+ DEFAULT_TOPIC_SYSVAR_STATUS,
281
+ 'topicSysvarSet',
282
+ DEFAULT_TOPIC_SYSVAR_SET,
283
+ ],
284
+ [
285
+ 'programItemTemplate',
286
+ 'topicProgramStatus',
287
+ DEFAULT_TOPIC_PROGRAM_STATUS,
288
+ 'topicProgramSet',
289
+ DEFAULT_TOPIC_PROGRAM_SET,
290
+ ],
281
291
  ];
282
- for (const [item, status, set] of pairs) {
292
+ for (const [item, status, statusDefault, set, setDefault] of pairs) {
283
293
  if (!config[item]) {
284
294
  continue;
285
295
  }
286
- config.$deprecated = config.$deprecated || [];
287
- config.$deprecated.push(item);
296
+ config.$warnings = config.$warnings || [];
297
+ const changed = [
298
+ [status, statusDefault],
299
+ [set, setDefault],
300
+ ]
301
+ .filter(([key, fallback]) => config[key] !== fallback)
302
+ .map(([key]) => '--' + kebab(key));
303
+ if (changed.length > 0) {
304
+ config.$warnings.push(
305
+ `--${kebab(item)} is deprecated and ignored here because ${changed.join(' and ')} ${changed.length > 1 ? 'are' : 'is'} set`,
306
+ );
307
+ continue;
308
+ }
309
+ config.$warnings.push(`--${kebab(item)} is deprecated - use --${kebab(status)} and --${kebab(set)}`);
288
310
  config[status] = '${prefix}/status/' + config[item];
289
311
  config[set] = '${prefix}/set/' + config[item];
290
312
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hm2mqtt",
3
- "version": "3.5.1",
3
+ "version": "3.6.0",
4
4
  "description": "Interface between Homematic CCU and MQTT",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -27,7 +27,8 @@
27
27
  "test": "node --test",
28
28
  "test:watch": "node --test --watch",
29
29
  "deploy": "bash deploy.sh",
30
- "test:e2e": "HM2MQTT_E2E=1 node --test --test-force-exit test/e2e.test.js"
30
+ "test:e2e": "HM2MQTT_E2E=1 node --test --test-force-exit test/e2e.test.js",
31
+ "test:occulite": "node --test --test-force-exit test/occulite.test.js"
31
32
  },
32
33
  "repository": {
33
34
  "type": "git",
@@ -9,7 +9,6 @@
9
9
  * node scripts/addon-api.js discover [--timeout 4000]
10
10
  * node scripts/addon-api.js probe --host 127.0.0.1 [--tls]
11
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
12
  * node scripts/addon-api.js preview --host 127.0.0.1 --template '${prefix}/status/${channelName|channel}/${datapoint}' [--prefix hm]
14
13
  *
15
14
  * Errors are JSON too ({"error": "..."}), so the UI never has to parse a stack trace.
@@ -17,7 +16,7 @@
17
16
 
18
17
  import Rega from 'homematic-rega';
19
18
  import {discover} from 'mqtt-interfaces-core';
20
- import {probeInterfaces, detectLocal, INTERFACE_NAMES} from '../lib/interfaces.js';
19
+ import {probeInterfaces, detectLocal, isLocalHost, regaPort, INTERFACE_NAMES} from '../lib/interfaces.js';
21
20
  import {discoveryHint, interfacesOf} from '../lib/discovery.js';
22
21
  import {compileTemplate, DEFAULT_TOPIC_STATUS} from '../lib/topics.js';
23
22
 
@@ -53,10 +52,9 @@ const args = parseArgs(rest);
53
52
  * @returns {Rega}
54
53
  */
55
54
  function rega({host = '127.0.0.1', port, username, password} = {}) {
56
- const local = host === '127.0.0.1' || host === 'localhost';
57
55
  return new Rega({
58
56
  host,
59
- port: port ? Number(port) : local ? 8183 : 8181,
57
+ port: port ? Number(port) : regaPort({local: isLocalHost(host)}),
60
58
  username,
61
59
  password,
62
60
  translate: false,
@@ -117,15 +115,6 @@ const commands = {
117
115
  }
118
116
  },
119
117
 
120
- async channels() {
121
- const limit = Number(args.limit || 20);
122
- const channels = await rega(args).getChannels();
123
- return {
124
- count: channels.length,
125
- channels: channels.slice(0, limit).map(({address, name}) => ({address, name})),
126
- };
127
- },
128
-
129
118
  async preview() {
130
119
  const template = String(args.template || DEFAULT_TOPIC_STATUS);
131
120
  const prefix = String(args.prefix || 'hm');