hm2mqtt 3.5.0 → 3.5.2

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/index.js CHANGED
@@ -149,11 +149,17 @@ const renderSysvarStatus = compileTemplate(config.topicSysvarStatus);
149
149
  const renderSysvarSet = compileTemplate(config.topicSysvarSet);
150
150
  const renderProgramStatus = compileTemplate(config.topicProgramStatus);
151
151
  const renderProgramSet = compileTemplate(config.topicProgramSet);
152
- /** what the plain mirror tree calls an item: the status topic without the `<name>/status/` head */
152
+ /** the literal part of the status template - what every rendered status topic starts with */
153
+ const statusLiteral = subscribePattern(config.topicStatus, topicFields).replace(/#$/, '');
154
+ /** what the plain mirror tree calls an item: the status topic without the template's literal head */
153
155
  const plainItem = (topic) => {
154
- const head = `${config.name}/status/`;
155
- return topic.startsWith(head) ? topic.slice(head.length) : topic;
156
+ const remainder = templateRemainder(config.topicStatus, topic, topicFields);
157
+ return remainder === null || remainder === '' ? topic : remainder;
156
158
  };
159
+ /** the set subscriptions that leave `<name>/set/` and therefore need a listen pattern */
160
+ const listenPatterns = [...new Set([config.topicSet, config.topicSysvarSet, config.topicProgramSet])]
161
+ .map((template) => subscribePattern(template, topicFields))
162
+ .filter((pattern) => !pattern.startsWith(`${config.name}/set/`));
157
163
  const adapter = createAdapter({
158
164
  pkg,
159
165
  config,
@@ -190,12 +196,7 @@ const adapter = createAdapter({
190
196
  * the index is keyed by.
191
197
  */
192
198
  onSet: (parts, value, topic) => handleSetTopic(topic, value),
193
- listen: Object.fromEntries(
194
- [...new Set([config.topicSet, config.topicSysvarSet, config.topicProgramSet])]
195
- .map((template) => subscribePattern(template, topicFields))
196
- .filter((pattern) => !pattern.startsWith(`${config.name}/set/`))
197
- .map((pattern) => [pattern, handleSetTopic]),
198
- ),
199
+ listen: Object.fromEntries(listenPatterns.map((pattern) => [pattern, handleSetTopic])),
199
200
  subscriptions: {
200
201
  'paramset/#': handleParamset,
201
202
  ...(config.rpcTopics ? {'rpc/+/+/+': handleRpc} : {}),
@@ -205,6 +206,21 @@ const adapter = createAdapter({
205
206
  });
206
207
  const {log, pubStatus} = adapter;
207
208
 
209
+ for (const warning of config.$warnings || []) {
210
+ log.warn(warning);
211
+ }
212
+ if (config.topicStatus === config.topicSet) {
213
+ log.warn('--topic-status and --topic-set are identical: every own publication would come back as a set');
214
+ }
215
+ for (const pattern of listenPatterns) {
216
+ const literal = pattern.replace(/#$/, '');
217
+ if (statusLiteral.startsWith(literal)) {
218
+ log.warn(
219
+ `the set subscription ${pattern} also matches the status topics under ${statusLiteral} - own publications there are ignored`,
220
+ );
221
+ }
222
+ }
223
+
208
224
  /*
209
225
  * Running on the CCU itself, the interface processes are on loopback and the familiar
210
226
  * 2000/2001/2010/9292/8181 are only lighttpd proxies in front of them: an extra hop, XML over HTTP
@@ -212,7 +228,9 @@ const {log, pubStatus} = adapter;
212
228
  * default we probe, because node-red-contrib-ccu's config-file check stopped working on current
213
229
  * firmware (see lib/interfaces.js).
214
230
  */
215
- const localMode = config.local === undefined ? await detectLocal(host) : Boolean(config.local);
231
+ // an explicit --ccu-tls is a tunnel or a proxy by definition - local mode would silently drop the
232
+ // TLS and the ports the user asked for, so it is never probed into, only chosen with --local
233
+ const localMode = config.local === undefined ? !config.ccuTls && (await detectLocal(host)) : Boolean(config.local);
216
234
  if (localMode) {
217
235
  log.info('local mode: BidCos over binrpc (32001/32000), hmipserver on 32010, ReGa on 8183');
218
236
  }
@@ -560,11 +578,21 @@ async function handleSetTopic(topic, value) {
560
578
  }
561
579
  let target = topicIndex.get(topic);
562
580
  if (!target) {
563
- const remainder = templateRemainder(config.topicSet, topic, topicFields);
581
+ // the reserved namespace stays positional whatever the template says: the address form,
582
+ // the rega commands and the sysvar/program names below <name>/set/ keep working
583
+ const head = `${config.name}/set/`;
584
+ const remainder = topic.startsWith(head)
585
+ ? topic.slice(head.length)
586
+ : templateRemainder(config.topicSet, topic, topicFields);
564
587
  const positional = remainder === null ? null : remainder.split('/').filter(Boolean);
565
588
  target = positional && positional.length > 0 ? resolveSet(positional, lookup) : null;
566
589
  }
567
590
  if (!target) {
591
+ // a set subscription that overlaps the status tree (a template whose literal part ends
592
+ // above it) delivers our own publications here - those are not errors
593
+ if (statusLiteral && topic.startsWith(statusLiteral)) {
594
+ return;
595
+ }
568
596
  throw new Error('unknown channel, variable or program');
569
597
  }
570
598
  switch (target.kind) {
@@ -147,17 +147,29 @@ function deviceBlock({iface, device, list, ctx, t, st, cmd, bridgeId, ignored})
147
147
 
148
148
  for (const ch of channels) {
149
149
  const consumed = new Set();
150
- const e = (dp, platform, label, more = {}) =>
151
- entity({
150
+ const e = (dp, platform, label, more = {}) => {
151
+ const built = entity({
152
152
  id,
153
153
  name,
154
- item: ctx.statusTopicFor(iface, ch.ADDRESS, dp),
154
+ item: `${ch.index}/${dp}`,
155
155
  platform,
156
156
  label,
157
157
  uid: `${ch.index}_${dp}`,
158
158
  jsonPayloads: json,
159
159
  ...more,
160
160
  });
161
+ // The topics are whole, rendered from the configured templates. entity() composes
162
+ // <name>/status|set/<item> for a core-style item, so exactly its composed values are
163
+ // replaced here; an explicit stat_t/cmd_t from `extra` and the stateless platforms
164
+ // (which get none) stay as they are.
165
+ if (built.stat_t === `${name}/status/${ch.index}/${dp}`) {
166
+ built.stat_t = own(dp);
167
+ }
168
+ if (built.cmd_t === `${name}/set/${ch.index}/${dp}`) {
169
+ built.cmd_t = scmd(dp);
170
+ }
171
+ return built;
172
+ };
161
173
  const fallback = ch.index === 0 ? 'Maintenance' : `Channel ${ch.index}`;
162
174
  const label = labelOf(ch, deviceName, fallback);
163
175
  const has = (dp) => Boolean(ch.description[dp]) && !ignored(iface, ch.ADDRESS, dp);
package/lib/interfaces.js CHANGED
@@ -180,10 +180,8 @@ export async function detectLocal(host, {timeout = 500, connect = portOpen} = {}
180
180
  return false;
181
181
  }
182
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;
183
+ // concurrently: this runs at every startup, and closed-but-filtered ports would otherwise
184
+ // stack their timeouts
185
+ const results = await Promise.all([...ports, REGA_LOCAL_PORT].map((port) => connect(host, port, timeout)));
186
+ return results.some(Boolean);
189
187
  }
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.0",
3
+ "version": "3.5.2",
4
4
  "description": "Interface between Homematic CCU and MQTT",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -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');