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 +39 -11
- package/lib/hadiscovery.js +15 -3
- package/lib/interfaces.js +4 -6
- package/lib/topics.js +34 -12
- package/package.json +1 -1
- package/scripts/addon-api.js +2 -13
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
|
-
/**
|
|
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
|
|
155
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/lib/hadiscovery.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
[
|
|
280
|
-
|
|
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.$
|
|
287
|
-
|
|
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
package/scripts/addon-api.js
CHANGED
|
@@ -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
|
|
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');
|