hm2mqtt 3.4.7 → 3.5.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
@@ -164,21 +164,39 @@ umlauts and `/` included; `+`, `#` and empty levels become `_`).
164
164
  | `<name>/rpc/<interface>/<method>/<callId>` → `<name>/response/<callId>` | in/out | `--rpc-topics` only: JSON array of parameters, answer as JSON (or `{"error": …}`) |
165
165
  | `<name>/maintenance/set/loglevel`, `…/restart` | in | `error`/`warn`/`info`/`debug`; graceful restart (core, `--no-maintenance` disables) |
166
166
 
167
- ### Item templates
168
-
169
- The part after `status/` and `set/` (the _item_) is rendered from a template with the fields of the
170
- `hm` block as placeholders, like node-red-contrib-ccu's topic templates — `--item-template`
171
- (default `${channelName|channel}/${datapoint}`), `--sysvar-item-template` and
172
- `--program-item-template` (default `${name}`). `|` is a fallback chain, field names are
173
- case-insensitive, an empty result becomes `_`. Examples: `${device}/${channelIndex}/${datapoint}`
174
- (addresses only), `${room|_}/${channelName}/${datapoint}`, `${iface}/${channel}/${datapoint}`,
175
- `sysvar/${name}`. `set` topics are resolved through a reverse index of every known channel and
176
- datapoint (the address form `<address>/<DATAPOINT>` always works too); channels rendering the same
177
- item are listed at start, the first one wins.
178
-
179
- Use the channel name (`Licht Küche`) or the address (`OEQ1234567:1`) in `set` and `paramset`
180
- topics. `set/<channel>/<DATAPOINT>` with two channels sharing a name reaches the first one — the
181
- start-up log lists duplicate names.
167
+ ### Topic templates
168
+
169
+ Every topic is a template, whole. `${prefix}` is the instance name (`--name`, default `hm`), the
170
+ other placeholders are the fields of the `hm` block:
171
+
172
+ | Option | Default |
173
+ | ------------------------ | ------------------------------------------------------- |
174
+ | `--topic-status` | `${prefix}/status/${channelName\|channel}/${datapoint}` |
175
+ | `--topic-set` | `${prefix}/set/${channelName\|channel}/${datapoint}` |
176
+ | `--topic-sysvar-status` | `${prefix}/status/${name}` |
177
+ | `--topic-sysvar-set` | `${prefix}/set/${name}` |
178
+ | `--topic-program-status` | `${prefix}/status/${name}` |
179
+ | `--topic-program-set` | `${prefix}/set/${name}` |
180
+
181
+ The defaults render exactly the topics hm2mqtt has always used, so nothing moves unless you move it.
182
+ `|` is a fallback chain, field names are case-insensitive, an empty result becomes `_`. Examples:
183
+ `${prefix}/${room|_}/${channelName}/${datapoint}`, `smarthome/${iface}/${channel}/${datapoint}`,
184
+ `${prefix}/status/${device}/${channelIndex}/${datapoint}`.
185
+
186
+ What hm2mqtt subscribes follows from the literal part of the `set` templates: everything up to the
187
+ first placeholder, plus `#` — `hm/set/#` for the default. A rendered level may contain slashes (a
188
+ channel named `Haus/OG/Licht` is legitimate), so incoming topics are resolved by looking up the
189
+ whole topic in an index of every known channel and datapoint, not by counting levels. Below the
190
+ literal part the address form still works (`hm/set/OEQ1234567:1/STATE`), as do the commands
191
+ (`hm/set/rega/sync`). Channels rendering the same topic are listed at start; the first one wins.
192
+
193
+ Moving the templates moves the topics for everything subscribed to them, and Home Assistant
194
+ discovery re-announces every entity, because the state and command topics it publishes come from
195
+ these same templates.
196
+
197
+ `--item-template`, `--sysvar-item-template` and `--program-item-template` named only the part after
198
+ `<name>/status/`. They still work — each becomes the matching pair of topic templates — but the
199
+ topic options replace them.
182
200
 
183
201
  ## Payloads
184
202
 
package/config.js CHANGED
@@ -2,7 +2,15 @@ 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
4
  import {discoveryHint} from './lib/discovery.js';
5
- import {DEFAULT_ITEM_TEMPLATE, DEFAULT_SYSVAR_ITEM_TEMPLATE, DEFAULT_PROGRAM_ITEM_TEMPLATE} from './lib/topics.js';
5
+ import {
6
+ applyItemTemplates,
7
+ DEFAULT_TOPIC_STATUS,
8
+ DEFAULT_TOPIC_SET,
9
+ DEFAULT_TOPIC_SYSVAR_STATUS,
10
+ DEFAULT_TOPIC_SYSVAR_SET,
11
+ DEFAULT_TOPIC_PROGRAM_STATUS,
12
+ DEFAULT_TOPIC_PROGRAM_SET,
13
+ } from './lib/topics.js';
6
14
 
7
15
  export const OPTIONS = {
8
16
  'ccu-address': {
@@ -76,18 +84,43 @@ export const OPTIONS = {
76
84
  describe: 'device and channel names',
77
85
  },
78
86
  },
79
- 'item-template': {
87
+ 'topic-status': {
80
88
  type: 'string',
81
89
  describe:
82
- 'item (topic part after status/ and set/) of a datapoint; ${field} placeholders with | fallbacks, every hm field',
83
- default: DEFAULT_ITEM_TEMPLATE,
90
+ 'status topic of a datapoint - the whole topic; ${field} placeholders with | fallbacks, ${prefix} is the instance name',
91
+ default: DEFAULT_TOPIC_STATUS,
92
+ },
93
+ 'topic-set': {
94
+ type: 'string',
95
+ describe: 'topic a datapoint is written on; what is subscribed follows from its literal part',
96
+ default: DEFAULT_TOPIC_SET,
97
+ },
98
+ 'topic-sysvar-status': {
99
+ type: 'string',
100
+ describe: 'status topic of a system variable',
101
+ default: DEFAULT_TOPIC_SYSVAR_STATUS,
102
+ },
103
+ 'topic-sysvar-set': {
104
+ type: 'string',
105
+ describe: 'topic a system variable is written on',
106
+ default: DEFAULT_TOPIC_SYSVAR_SET,
107
+ },
108
+ 'topic-program-status': {
109
+ type: 'string',
110
+ describe: 'status topic of a program',
111
+ default: DEFAULT_TOPIC_PROGRAM_STATUS,
84
112
  },
85
- 'sysvar-item-template': {
113
+ 'topic-program-set': {
114
+ type: 'string',
115
+ describe: 'topic a program is started on',
116
+ default: DEFAULT_TOPIC_PROGRAM_SET,
117
+ },
118
+ 'item-template': {
86
119
  type: 'string',
87
- describe: 'item of a system variable',
88
- default: DEFAULT_SYSVAR_ITEM_TEMPLATE,
120
+ describe: 'deprecated, use --topic-status/--topic-set: item part of the classic topics',
89
121
  },
90
- 'program-item-template': {type: 'string', describe: 'item of a program', default: DEFAULT_PROGRAM_ITEM_TEMPLATE},
122
+ 'sysvar-item-template': {type: 'string', describe: 'deprecated, use --topic-sysvar-status/-set'},
123
+ 'program-item-template': {type: 'string', describe: 'deprecated, use --topic-program-status/-set'},
91
124
  payload: {
92
125
  type: 'string',
93
126
  describe:
@@ -130,15 +163,17 @@ export const OPTIONS = {
130
163
  },
131
164
  };
132
165
 
133
- export default parseConfig({
134
- pkg,
135
- options: OPTIONS,
136
- defaults: {name: 'hm'},
137
- discovery: discoveryHint(),
138
- examples: [
139
- ['$0 --discover', 'find CCUs on the network and exit'],
140
- ['$0 -a homematic-ccu3 -u mqtt://broker', 'run in the foreground'],
141
- ['$0 -a 192.168.1.50 -i BidCos-RF,HmIP-RF --plain-tree state', 'two interfaces plus the plain mirror tree'],
142
- ['sudo $0 --install -n hm -a homematic-ccu3 -u mqtt://broker', 'install as service hm2mqtt@hm'],
143
- ],
144
- });
166
+ export default applyItemTemplates(
167
+ parseConfig({
168
+ pkg,
169
+ options: OPTIONS,
170
+ defaults: {name: 'hm'},
171
+ discovery: discoveryHint(),
172
+ examples: [
173
+ ['$0 --discover', 'find CCUs on the network and exit'],
174
+ ['$0 -a homematic-ccu3 -u mqtt://broker', 'run in the foreground'],
175
+ ['$0 -a 192.168.1.50 -i BidCos-RF,HmIP-RF --plain-tree state', 'two interfaces plus the plain mirror tree'],
176
+ ['sudo $0 --install -n hm -a homematic-ccu3 -u mqtt://broker', 'install as service hm2mqtt@hm'],
177
+ ],
178
+ }),
179
+ );
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, createLogger, runDiscovery, autoAddress} from 'mqtt-interfaces-core';
13
+ import {createAdapter, createLogger, runDiscovery, autoAddress, StatusTracker} 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'};
@@ -21,7 +21,16 @@ import {Metadata} from './lib/metadata.js';
21
21
  import {ValueStore, hmBlock, isEvent} from './lib/values.js';
22
22
  import {RegaSync} from './lib/rega.js';
23
23
  import {castValue, isWriteable} from './lib/cast.js';
24
- import {sanitizeName, resolveSet, resolveParamset, plainValue, compileTemplate, ItemIndex} from './lib/topics.js';
24
+ import {
25
+ sanitizeName,
26
+ resolveSet,
27
+ resolveParamset,
28
+ plainValue,
29
+ compileTemplate,
30
+ subscribePattern,
31
+ templateRemainder,
32
+ ItemIndex,
33
+ } from './lib/topics.js';
25
34
  import {compileIgnore} from './lib/roles.js';
26
35
  import {discoveryModel} from './lib/hadiscovery.js';
27
36
  import {discoveryHint} from './lib/discovery.js';
@@ -128,6 +137,23 @@ const outValue = (value) => (payloadFormat === 'plain' ? plainValue(value) : val
128
137
 
129
138
  const ignored = compileIgnore(config.ignore);
130
139
 
140
+ /*
141
+ * Topics are rendered from templates, whole. `${prefix}` is the instance name, so the defaults
142
+ * produce exactly the topics hm2mqtt has always used; anything else is the user's choice, and what
143
+ * is subscribed follows from the literal part of the set templates.
144
+ */
145
+ const topicFields = {prefix: config.name};
146
+ const renderStatus = compileTemplate(config.topicStatus);
147
+ const renderSet = compileTemplate(config.topicSet);
148
+ const renderSysvarStatus = compileTemplate(config.topicSysvarStatus);
149
+ const renderSysvarSet = compileTemplate(config.topicSysvarSet);
150
+ const renderProgramStatus = compileTemplate(config.topicProgramStatus);
151
+ const renderProgramSet = compileTemplate(config.topicProgramSet);
152
+ /** what the plain mirror tree calls an item: the status topic without the `<name>/status/` head */
153
+ const plainItem = (topic) => {
154
+ const head = `${config.name}/status/`;
155
+ return topic.startsWith(head) ? topic.slice(head.length) : topic;
156
+ };
131
157
  const adapter = createAdapter({
132
158
  pkg,
133
159
  config,
@@ -142,7 +168,10 @@ const adapter = createAdapter({
142
168
  description: (iface, address) => metadata.description(iface, address, 'VALUES'),
143
169
  channelName,
144
170
  rooms: (address) => (regaSync ? regaSync.rooms(address) : undefined),
145
- itemFor: (iface, address, datapoint) => renderItem(values.fields(iface, address, datapoint)).name,
171
+ statusTopicFor: (iface, address, datapoint) =>
172
+ renderStatus({...values.fields(iface, address, datapoint), ...topicFields}).name,
173
+ setTopicFor: (iface, address, datapoint) =>
174
+ renderSet({...values.fields(iface, address, datapoint), ...topicFields}).name,
146
175
  ignored,
147
176
  interfaces: enabled,
148
177
  }),
@@ -153,11 +182,25 @@ const adapter = createAdapter({
153
182
  rega: Boolean(regaSync),
154
183
  payload: payloadFormat,
155
184
  }),
156
- onSet: handleSet,
185
+ /*
186
+ * Set topics are configurable, and the core owns `<name>/set/#`: it intercepts that namespace
187
+ * before the listen list and returns whether or not `onSet` is defined. So a template that
188
+ * stays under `<name>/set/` has to be served through onSet, and only one that moves elsewhere
189
+ * needs a listen pattern. onSet is handed the whole topic as its third argument, which is what
190
+ * the index is keyed by.
191
+ */
192
+ 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
+ ),
157
199
  subscriptions: {
158
200
  'paramset/#': handleParamset,
159
201
  ...(config.rpcTopics ? {'rpc/+/+/+': handleRpc} : {}),
160
202
  },
203
+ onMqttConnect: () => republishTopics(),
161
204
  onShutdown: shutdown,
162
205
  });
163
206
  const {log, pubStatus} = adapter;
@@ -208,10 +251,19 @@ const values = new ValueStore({
208
251
  });
209
252
  values.load();
210
253
 
211
- const renderItem = compileTemplate(config.itemTemplate);
212
- const renderSysvarItem = compileTemplate(config.sysvarItemTemplate);
213
- const renderProgramItem = compileTemplate(config.programItemTemplate);
214
- const itemIndex = new ItemIndex();
254
+ /** full set topic -> what to write; rebuilt whenever names or devices change */
255
+ const topicIndex = new ItemIndex();
256
+
257
+ /*
258
+ * Publishing is ours rather than the core's pubStatus, because that one owns the topic
259
+ * (`<name>/status/<item>`) and here the topic is configurable. The tracker is the core's, so the
260
+ * payload shapes, change detection and the retained cache behave exactly as before.
261
+ */
262
+ const status = new StatusTracker({json: config.jsonPayloads});
263
+ function publishTopic(topic, value, {retain = true, extra, ts, lc} = {}) {
264
+ const {payload} = status.update(topic, value, {retain, extra, ts, lc});
265
+ adapter.publish(topic, payload, {retain});
266
+ }
215
267
  let indexTimer = null;
216
268
 
217
269
  let enabled = [];
@@ -237,10 +289,11 @@ function itemOf(name) {
237
289
  }
238
290
 
239
291
  function rendered(render, fields, label) {
240
- const {name: item, changed} = render(fields);
292
+ // every template may use ${prefix}
293
+ const {name: item, changed} = render({...fields, ...topicFields});
241
294
  if (changed && !warnedNames.has(label)) {
242
295
  warnedNames.add(label);
243
- log.warn('item of', label, 'rendered with replacements as', JSON.stringify(item));
296
+ log.warn('topic of', label, 'rendered with replacements as', JSON.stringify(item));
244
297
  }
245
298
  return item;
246
299
  }
@@ -251,8 +304,8 @@ function rendered(render, fields, label) {
251
304
  */
252
305
  function rebuildIndex() {
253
306
  indexTimer = null;
254
- itemIndex.clear('datapoint');
255
- itemIndex.collisions.clear();
307
+ topicIndex.clear('datapoint');
308
+ topicIndex.collisions.clear();
256
309
  for (const iface of Object.keys(metadata.devices)) {
257
310
  for (const [address, device] of Object.entries(metadata.devices[iface])) {
258
311
  if (!device.PARENT) {
@@ -264,20 +317,22 @@ function rebuildIndex() {
264
317
  }
265
318
  for (const datapoint of Object.keys(description)) {
266
319
  const target = {kind: 'datapoint', address, datapoint};
267
- itemIndex.add(renderItem(values.fields(iface, address, datapoint)).name, target);
268
- itemIndex.add(`${address}/${datapoint}`, target);
320
+ const fields = {...values.fields(iface, address, datapoint), ...topicFields};
321
+ topicIndex.add(renderSet(fields).name, target);
322
+ // the address form stays addressable whatever the template says
323
+ topicIndex.add(`${config.name}/set/${address}/${datapoint}`, target);
269
324
  }
270
325
  }
271
326
  }
272
- if (itemIndex.collisions.size > 0) {
273
- const list = [...itemIndex.collisions.keys()];
327
+ if (topicIndex.collisions.size > 0) {
328
+ const list = [...topicIndex.collisions.keys()];
274
329
  log.warn(
275
330
  'items shared by several channels (the first one wins on set):',
276
331
  list.slice(0, 10).join(', '),
277
332
  list.length > 10 ? `… ${list.length - 10} more` : '',
278
333
  );
279
334
  }
280
- log.debug('item index rebuilt:', itemIndex.size, 'items');
335
+ log.debug('topic index rebuilt:', topicIndex.size, 'topics');
281
336
  adapter.markDiscoveryDirty();
282
337
  adapter.publishDiscovery();
283
338
  }
@@ -297,19 +352,30 @@ function publishPlain(item, value, retain) {
297
352
  }
298
353
 
299
354
  function publishMessage(message, {retain = true} = {}) {
300
- const item = rendered(renderItem, message, message.datapointName);
355
+ const topic = rendered(renderStatus, message, message.datapointName);
301
356
  const extra = withHm ? {hm: hmBlock(message)} : undefined;
302
- pubStatus(item, outValue(message.value), {retain, extra, ts: message.ts, lc: message.lc});
303
- publishPlain(item, message.value, retain);
357
+ publishTopic(topic, outValue(message.value), {retain, extra, ts: message.ts, lc: message.lc});
358
+ publishPlain(plainItem(topic), message.value, retain);
304
359
  }
305
360
 
306
361
  function publishRega(message) {
307
- const render = message.type === 'PROGRAM' ? renderProgramItem : renderSysvarItem;
308
- const item = rendered(render, message, `${message.type} ${message.name}`);
309
- itemIndex.add(item, {kind: message.type === 'PROGRAM' ? 'program' : 'sysvar', name: message.name});
362
+ const program = message.type === 'PROGRAM';
363
+ const label = `${message.type} ${message.name}`;
364
+ const topic = rendered(program ? renderProgramStatus : renderSysvarStatus, message, label);
365
+ const setTopic = rendered(program ? renderProgramSet : renderSysvarSet, message, label);
366
+ topicIndex.add(setTopic, {kind: program ? 'program' : 'sysvar', name: message.name});
310
367
  const extra = withHm ? {hm: hmBlock(message)} : undefined;
311
- pubStatus(item, outValue(message.value), {retain: true, extra, ts: message.ts, lc: message.lc});
312
- publishPlain(item, message.value, true);
368
+ publishTopic(topic, outValue(message.value), {retain: true, extra, ts: message.ts, lc: message.lc});
369
+ publishPlain(plainItem(topic), message.value, true);
370
+ }
371
+
372
+ /** Every configurable topic we published, again - the core does this only for its own items. */
373
+ function republishTopics() {
374
+ for (const topic of status.state.keys()) {
375
+ if (status.isRetained(topic)) {
376
+ adapter.publish(topic, status.payload(topic), {retain: true});
377
+ }
378
+ }
313
379
  }
314
380
 
315
381
  function publishItem(item, value, {retain = true} = {}) {
@@ -479,13 +545,25 @@ async function setValue(address, datapoint, value) {
479
545
  return throttled(`${iface}.${address}.${datapoint}`, () => conn.methodCall('setValue', [address, datapoint, cast]));
480
546
  }
481
547
 
482
- async function handleSet(parts, value, topic) {
548
+ /**
549
+ * A write on one of the configured set topics. The topic arrives whole, because a rendered level
550
+ * may contain slashes and the subscription is therefore `<literal>/#`: the topic is looked up as it
551
+ * is, and only when that misses does the positional form after `<name>/set/` get a chance
552
+ * (`hm/set/ABC1234567:1/STATE`, `hm/set/rega/sync`).
553
+ * @param {string} topic
554
+ * @param {*} value
555
+ */
556
+ async function handleSetTopic(topic, value) {
483
557
  if (value === undefined) {
484
558
  log.warn('mqtt ignoring empty payload on', topic);
485
559
  return;
486
560
  }
487
- // exact item first (rendered template, address form, variables, programs), then the positional form
488
- const target = itemIndex.get(parts.join('/')) || resolveSet(parts, lookup);
561
+ let target = topicIndex.get(topic);
562
+ if (!target) {
563
+ const remainder = templateRemainder(config.topicSet, topic, topicFields);
564
+ const positional = remainder === null ? null : remainder.split('/').filter(Boolean);
565
+ target = positional && positional.length > 0 ? resolveSet(positional, lookup) : null;
566
+ }
489
567
  if (!target) {
490
568
  throw new Error('unknown channel, variable or program');
491
569
  }
@@ -20,7 +20,8 @@ const TILT_TYPES = /BLIND/;
20
20
  * @property {(iface: string, address: string) => object | undefined} description VALUES description of a channel
21
21
  * @property {(address: string) => string | undefined} channelName
22
22
  * @property {(address: string) => string[] | undefined} rooms
23
- * @property {(iface: string, address: string, datapoint: string) => string} itemFor rendered item of a datapoint
23
+ * @property {(iface: string, address: string, datapoint: string) => string} statusTopicFor status topic of a datapoint
24
+ * @property {(iface: string, address: string, datapoint: string) => string} setTopicFor set topic of a datapoint
24
25
  * @property {(iface: string, address: string, datapoint: string) => boolean} [ignored]
25
26
  * @property {string[]} [interfaces] enabled interfaces (bridge entities)
26
27
  */
@@ -61,12 +62,14 @@ function labelOf(channel, deviceName, fallback) {
61
62
  * @returns {object[]} device blocks ({id, device, components, availability})
62
63
  */
63
64
  export function discoveryModel(ctx) {
64
- const {adapterName, name, jsonPayloads: json = true, devices, itemFor} = ctx;
65
+ const {adapterName, name, jsonPayloads: json = true, devices, statusTopicFor, setTopicFor} = ctx;
65
66
  const ignored = ctx.ignored || (() => false);
66
67
  const t = templates(json);
67
68
  const bridgeId = discoveryId(adapterName, name);
68
- const st = (iface, address, dp) => `${name}/status/${itemFor(iface, address, dp)}`;
69
- const cmd = (iface, address, dp) => `${name}/set/${itemFor(iface, address, dp)}`;
69
+ // whole topics, from the configured templates - a controller has to be told where the values
70
+ // actually are, not where the convention would put them
71
+ const st = (iface, address, dp) => statusTopicFor(iface, address, dp);
72
+ const cmd = (iface, address, dp) => setTopicFor(iface, address, dp);
70
73
  const blocks = [];
71
74
 
72
75
  // the CCU / bridge device
@@ -148,7 +151,7 @@ function deviceBlock({iface, device, list, ctx, t, st, cmd, bridgeId, ignored})
148
151
  entity({
149
152
  id,
150
153
  name,
151
- item: ctx.itemFor(iface, ch.ADDRESS, dp),
154
+ item: ctx.statusTopicFor(iface, ch.ADDRESS, dp),
152
155
  platform,
153
156
  label,
154
157
  uid: `${ch.index}_${dp}`,
package/lib/topics.js CHANGED
@@ -5,6 +5,19 @@
5
5
  * counter/<iface>/rx|tx, interface/<iface>/connected, <ifaceAddress>/DUTY_CYCLE.
6
6
  */
7
7
 
8
+ /*
9
+ * Topics are templates, and the whole topic is one: `${prefix}` is the instance name, so a
10
+ * datapoint's status topic is `${prefix}/status/${channelName|channel}/${datapoint}` by default -
11
+ * exactly the topic hm2mqtt has always published, now written out where it can be changed.
12
+ */
13
+ export const DEFAULT_TOPIC_STATUS = '${prefix}/status/${channelName|channel}/${datapoint}';
14
+ export const DEFAULT_TOPIC_SET = '${prefix}/set/${channelName|channel}/${datapoint}';
15
+ export const DEFAULT_TOPIC_SYSVAR_STATUS = '${prefix}/status/${name}';
16
+ export const DEFAULT_TOPIC_SYSVAR_SET = '${prefix}/set/${name}';
17
+ export const DEFAULT_TOPIC_PROGRAM_STATUS = '${prefix}/status/${name}';
18
+ export const DEFAULT_TOPIC_PROGRAM_SET = '${prefix}/set/${name}';
19
+
20
+ /** The item part of the classic topics, kept for `--item-template` and for the plain mirror tree. */
8
21
  export const DEFAULT_ITEM_TEMPLATE = '${channelName|channel}/${datapoint}';
9
22
  export const DEFAULT_SYSVAR_ITEM_TEMPLATE = '${name}';
10
23
  export const DEFAULT_PROGRAM_ITEM_TEMPLATE = '${name}';
@@ -209,3 +222,71 @@ export class ItemIndex {
209
222
  return this.items.size;
210
223
  }
211
224
  }
225
+
226
+ /**
227
+ * The subscription a set template needs. Everything up to the first placeholder is literal and can
228
+ * be subscribed exactly; the rest becomes `#`, because a rendered level may itself contain slashes
229
+ * (a channel named "Haus/OG/Licht" is legitimate) and no fixed number of `+` levels would match it.
230
+ * The incoming topic is then resolved by exact lookup, not by position.
231
+ *
232
+ * `${prefix}/set/${channelName|channel}/${datapoint}` with prefix "hm" → `hm/set/#`.
233
+ *
234
+ * @param {string} template
235
+ * @param {object} fields values known before anything is rendered, normally `{prefix}`
236
+ * @returns {string} an mqtt subscription pattern
237
+ */
238
+ export function subscribePattern(template, fields = {}) {
239
+ let text = String(template);
240
+ for (const [key, value] of Object.entries(fields)) {
241
+ text = text.split('${' + key + '}').join(String(value));
242
+ }
243
+ const placeholder = text.indexOf('${');
244
+ if (placeholder === -1) {
245
+ return text;
246
+ }
247
+ const literal = text.slice(0, placeholder);
248
+ const cut = literal.lastIndexOf('/');
249
+ return cut === -1 ? '#' : literal.slice(0, cut + 1) + '#';
250
+ }
251
+
252
+ /**
253
+ * Is `topic` below the literal part of `template`? Used to decide whether a set topic may fall back
254
+ * to the positional form (`<prefix>/set/<address>/<datapoint>`) when it is not in the index.
255
+ * @param {string} template
256
+ * @param {string} topic
257
+ * @param {object} fields
258
+ * @returns {string | null} the remainder after the literal prefix, or null
259
+ */
260
+ export function templateRemainder(template, topic, fields = {}) {
261
+ const pattern = subscribePattern(template, fields);
262
+ if (!pattern.endsWith('#')) {
263
+ return topic === pattern ? '' : null;
264
+ }
265
+ const literal = pattern.slice(0, -1);
266
+ return topic.startsWith(literal) ? topic.slice(literal.length) : null;
267
+ }
268
+
269
+ /**
270
+ * `--item-template` and its two siblings named the part after `<name>/status/`. They still work:
271
+ * an item template becomes the corresponding pair of topic templates, so an existing config keeps
272
+ * publishing exactly where it did.
273
+ * @param {object} config
274
+ * @returns {object} the same config, with the topic templates filled in
275
+ */
276
+ export function applyItemTemplates(config) {
277
+ const pairs = [
278
+ ['itemTemplate', 'topicStatus', 'topicSet'],
279
+ ['sysvarItemTemplate', 'topicSysvarStatus', 'topicSysvarSet'],
280
+ ['programItemTemplate', 'topicProgramStatus', 'topicProgramSet'],
281
+ ];
282
+ for (const [item, status, set] of pairs) {
283
+ if (!config[item]) {
284
+ continue;
285
+ }
286
+ config.$deprecated = config.$deprecated || [];
287
+ config.$deprecated.push(item);
288
+ config[status] = '${prefix}/status/' + config[item];
289
+ config[set] = '${prefix}/set/' + config[item];
290
+ }
291
+ return config;
292
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hm2mqtt",
3
- "version": "3.4.7",
3
+ "version": "3.5.0",
4
4
  "description": "Interface between Homematic CCU and MQTT",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -73,6 +73,7 @@
73
73
  },
74
74
  "devDependencies": {
75
75
  "@eslint/js": "^9.0.0",
76
+ "aedes": "^1.1.2",
76
77
  "eslint": "^9.0.0",
77
78
  "eslint-config-prettier": "^10.0.0",
78
79
  "globals": "^16.0.0",
@@ -10,7 +10,7 @@
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
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}'
13
+ * node scripts/addon-api.js preview --host 127.0.0.1 --template '${prefix}/status/${channelName|channel}/${datapoint}' [--prefix hm]
14
14
  *
15
15
  * Errors are JSON too ({"error": "..."}), so the UI never has to parse a stack trace.
16
16
  */
@@ -19,7 +19,7 @@ import Rega from 'homematic-rega';
19
19
  import {discover} from 'mqtt-interfaces-core';
20
20
  import {probeInterfaces, detectLocal, INTERFACE_NAMES} from '../lib/interfaces.js';
21
21
  import {discoveryHint, interfacesOf} from '../lib/discovery.js';
22
- import {compileTemplate, DEFAULT_ITEM_TEMPLATE} from '../lib/topics.js';
22
+ import {compileTemplate, DEFAULT_TOPIC_STATUS} from '../lib/topics.js';
23
23
 
24
24
  const [command, ...rest] = process.argv.slice(2);
25
25
 
@@ -127,7 +127,8 @@ const commands = {
127
127
  },
128
128
 
129
129
  async preview() {
130
- const template = String(args.template || DEFAULT_ITEM_TEMPLATE);
130
+ const template = String(args.template || DEFAULT_TOPIC_STATUS);
131
+ const prefix = String(args.prefix || 'hm');
131
132
  const render = compileTemplate(template);
132
133
  const limit = Number(args.limit || 5);
133
134
  const channels = await rega(args).getChannels();
@@ -135,6 +136,7 @@ const commands = {
135
136
  const examples = channels.slice(0, limit).map((ch, index) => {
136
137
  const datapoint = datapoints[index % datapoints.length];
137
138
  const fields = {
139
+ prefix,
138
140
  channel: ch.address,
139
141
  channelName: ch.name,
140
142
  device: String(ch.address).split(':')[0],