hm2mqtt 3.4.6 → 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 +33 -15
- package/config.js +55 -20
- package/index.js +106 -28
- package/lib/hadiscovery.js +8 -5
- package/lib/topics.js +81 -0
- package/package.json +2 -1
- package/scripts/addon-api.js +5 -3
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
|
-
###
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
`
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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 {
|
|
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
|
-
'
|
|
87
|
+
'topic-status': {
|
|
80
88
|
type: 'string',
|
|
81
89
|
describe:
|
|
82
|
-
'
|
|
83
|
-
default:
|
|
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
|
-
'
|
|
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
|
|
88
|
-
default: DEFAULT_SYSVAR_ITEM_TEMPLATE,
|
|
120
|
+
describe: 'deprecated, use --topic-status/--topic-set: item part of the classic topics',
|
|
89
121
|
},
|
|
90
|
-
'
|
|
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
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
[
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
255
|
-
|
|
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
|
-
|
|
268
|
-
|
|
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 (
|
|
273
|
-
const list = [...
|
|
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('
|
|
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
|
|
355
|
+
const topic = rendered(renderStatus, message, message.datapointName);
|
|
301
356
|
const extra = withHm ? {hm: hmBlock(message)} : undefined;
|
|
302
|
-
|
|
303
|
-
publishPlain(
|
|
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
|
|
308
|
-
const
|
|
309
|
-
|
|
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
|
-
|
|
312
|
-
publishPlain(
|
|
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
|
-
|
|
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
|
-
|
|
488
|
-
|
|
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
|
}
|
package/lib/hadiscovery.js
CHANGED
|
@@ -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}
|
|
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,
|
|
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
|
-
|
|
69
|
-
|
|
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.
|
|
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.
|
|
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",
|
package/scripts/addon-api.js
CHANGED
|
@@ -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,
|
|
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 ||
|
|
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],
|