hm2mqtt 3.0.0 → 3.2.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 +38 -6
- package/config.js +18 -1
- package/index.js +102 -7
- package/lib/hadiscovery.js +692 -0
- package/lib/roles.js +183 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -66,11 +66,13 @@ so the CCU can call back. `--restart unless-stopped` brings it back after `maint
|
|
|
66
66
|
| `-m, --name-file` | — | JSON `{address: name}` overriding ReGa names ([example-names.json](example-names.json)) |
|
|
67
67
|
| `--item-template`, `--sysvar-item-template`, `--program-item-template` | `${channelName\|channel}/${datapoint}`, `${name}` | how items (topic parts) are built, see [Item templates](#item-templates) |
|
|
68
68
|
| `--rega-names-interval` | 3600 | seconds between re-reads of names/rooms/functions (0 = only at start and on `set/rega/sync`) |
|
|
69
|
-
| `--
|
|
69
|
+
| `--payload` | `mqsh-extended` | status payload format: `mqsh-extended`, `mqsh-basic` or `plain` ([Payloads](#payloads)) |
|
|
70
70
|
| `--plain-tree <level>` | — | additionally publish plain payloads under `<name>/<level>/…` (the second `ccu-mqtt` node of the Node-RED flow) |
|
|
71
71
|
| `--publish-cache` | off | publish every datapoint value known to the ReGa at start (thousands of retained messages) |
|
|
72
72
|
| `--publish-counters`, `--duty-cycle-interval` | on, 90 | rpc rx/tx counters and `listBidcosInterfaces` duty cycle polling (0 = off) |
|
|
73
73
|
| `--rpc-topics` | off | arbitrary rpc calls via MQTT — an unrestricted API surface, only on a trusted broker |
|
|
74
|
+
| `--ignore` | — | comma separated globs on `<interface>.<channel>.<datapoint>` not to publish, e.g. `*.*.RSSI_*,HmIP-RF.*.*_STATUS` |
|
|
75
|
+
| `--ha-generic` / `--no-ha-generic` | on | announce datapoints without a role as (disabled) generic entities ([Home Assistant](#home-assistant)) |
|
|
74
76
|
| `--state-dir` | `$STATE_DIRECTORY` or `~/.hm2mqtt` | devices, paramset descriptions, names and last values |
|
|
75
77
|
| `-u, --mqtt-url`, `--mqtt-username`, `--mqtt-password`, `--mqtt-tls-ca`, `-n, --name`, `--json-payloads`, `--maintenance`, `-v, --verbosity` | core | shared options of every adapter; `--name` (default `hm`) is the topic prefix |
|
|
76
78
|
|
|
@@ -165,9 +167,10 @@ meta data block of node-red-contrib-ccu's messages, field for field:
|
|
|
165
167
|
`ENUM` datapoints carry `datapointEnum` (the value list) and `valueEnum` (the name of the current
|
|
166
168
|
value). Variables have `type: "SYSVAR"` with `valueType`, `unit`, `enum`, `valueEnum`, `id` and —
|
|
167
169
|
when bound to a channel — the channel fields; programs `type: "PROGRAM"` with `active`,
|
|
168
|
-
`activePrevious`, `ts` (last execution). `--
|
|
169
|
-
|
|
170
|
-
|
|
170
|
+
`activePrevious`, `ts` (last execution). `--payload` selects the format the way the `ccu-mqtt` node did:
|
|
171
|
+
`mqsh-extended` (default, as above), `mqsh-basic` (`{val, ts, lc}`) or `plain` (the bare value,
|
|
172
|
+
booleans as `0`/`1`). `--plain-tree state` additionally mirrors every item to `<name>/state/…` in
|
|
173
|
+
the plain format (the second `ccu-mqtt` node of a flow).
|
|
171
174
|
|
|
172
175
|
## Names
|
|
173
176
|
|
|
@@ -178,8 +181,37 @@ address as well.
|
|
|
178
181
|
|
|
179
182
|
## Home Assistant
|
|
180
183
|
|
|
181
|
-
|
|
182
|
-
|
|
184
|
+
Device-based MQTT discovery (`homeassistant/device/<id>/config`, HA ≥ 2024.11) is on by default
|
|
185
|
+
(`--no-ha-discovery` clears it, `--ha-prefix` changes the prefix). One HA device per Homematic
|
|
186
|
+
device (manufacturer eQ-3, model = device type, `via_device` = the CCU bridge device,
|
|
187
|
+
`suggested_area` = the room when all channels sit in one room), available while `<name>/connected`
|
|
188
|
+
is `2` **and** the device's `UNREACH` is false.
|
|
189
|
+
|
|
190
|
+
Entities are derived from the paramset descriptions, not from a device list — every device the
|
|
191
|
+
CCU knows works: the `CONTROL` hint of a channel's primary datapoint decides the role
|
|
192
|
+
(`SWITCH.STATE`, `DIMMER.LEVEL`, `BLIND.LEVEL`, `DOOR_SENSOR.STATE`, `HEATING_CONTROL(_HMIP).SETPOINT`,
|
|
193
|
+
`LOCK.STATE`, `BUTTON.SHORT`, …), the channel type covers the older HM devices.
|
|
194
|
+
|
|
195
|
+
| role | entity |
|
|
196
|
+
| ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
197
|
+
| switch (HM `SWITCH`, HmIP `*_VIRTUAL_RECEIVER`) | `switch`; HmIP: state from the `SWITCH_TRANSMITTER` channel, first receiver enabled, receivers 2/3 disabled by default |
|
|
198
|
+
| dimmer | `light` with brightness (`LEVEL` 0..1 ↔ 0..100 %) |
|
|
199
|
+
| blind / shutter | `cover` with position, `STOP`, tilt (`LEVEL_2`) when present, opening/closing from `DIRECTION`/`ACTIVITY_STATE` |
|
|
200
|
+
| thermostat (HM and HmIP) | `climate`: setpoint, current temperature/humidity, modes `auto`/`heat`, preset `boost` (HM also `comfort`/`eco`), action |
|
|
201
|
+
| contact, rotary handle, motion, presence, smoke, water | `binary_sensor` with device class (rotary handle also a `closed/tilted/open` sensor) |
|
|
202
|
+
| key | `event` per key channel with `press_short`, `press_long`, … (from the aggregate `<channel>/PRESS` item) |
|
|
203
|
+
| lock (KEYMATIC) | `lock` |
|
|
204
|
+
| energy meter, weather | `sensor`s with device/state classes and units |
|
|
205
|
+
| maintenance (`:0`) | `LOW_BAT` battery sensor enabled; `RSSI_*`, `OPERATING_VOLTAGE`, `UNREACH`, `DUTY_CYCLE`, … diagnostic, disabled |
|
|
206
|
+
| everything else | generic `sensor`/`binary_sensor`/`switch`/`select`/`number`/`button` from the description, disabled by default (`--no-ha-generic` drops them) |
|
|
207
|
+
|
|
208
|
+
The entities use the normal topics; three small additions make HA's single-topic conventions work:
|
|
209
|
+
`set/<channel>/LEVEL` accepts `OPEN`, `CLOSE`, `STOP`, `ON` (restore last level) and `OFF`; HM
|
|
210
|
+
thermostats accept `set/<channel>/CONTROL_MODE` with `AUTO-MODE`, `MANU-MODE`, `BOOST-MODE`,
|
|
211
|
+
`COMFORT-MODE`, `LOWERING-MODE` (translated to the mode actions); every key press is also
|
|
212
|
+
published as `status/<channel>/PRESS` (`val` = `PRESS_SHORT` etc., not retained). `--ignore`
|
|
213
|
+
keeps datapoints out of both MQTT and discovery. Not covered yet: RGBW/colour lights, sirens,
|
|
214
|
+
garage doors, variables/programs as entities.
|
|
183
215
|
|
|
184
216
|
## Migration
|
|
185
217
|
|
package/config.js
CHANGED
|
@@ -76,7 +76,14 @@ export const OPTIONS = {
|
|
|
76
76
|
default: DEFAULT_SYSVAR_ITEM_TEMPLATE,
|
|
77
77
|
},
|
|
78
78
|
'program-item-template': {type: 'string', describe: 'item of a program', default: DEFAULT_PROGRAM_ITEM_TEMPLATE},
|
|
79
|
-
|
|
79
|
+
payload: {
|
|
80
|
+
type: 'string',
|
|
81
|
+
describe:
|
|
82
|
+
'status payload format: mqsh-extended ({val, ts, lc, hm}), mqsh-basic ({val, ts, lc}) or plain (value only, booleans as 0/1)',
|
|
83
|
+
choices: ['plain', 'mqsh-basic', 'mqsh-extended'],
|
|
84
|
+
default: 'mqsh-extended',
|
|
85
|
+
},
|
|
86
|
+
'hm-payload': {type: 'boolean', describe: 'deprecated: --no-hm-payload = --payload mqsh-basic', hidden: true},
|
|
80
87
|
'plain-tree': {
|
|
81
88
|
type: 'string',
|
|
82
89
|
describe: 'additionally publish plain payloads under <name>/<level>/... (e.g. "state")',
|
|
@@ -88,6 +95,16 @@ export const OPTIONS = {
|
|
|
88
95
|
default: true,
|
|
89
96
|
},
|
|
90
97
|
'duty-cycle-interval': {type: 'number', describe: 'seconds between duty cycle polls, 0 = off', default: 90},
|
|
98
|
+
ignore: {
|
|
99
|
+
type: 'string',
|
|
100
|
+
describe:
|
|
101
|
+
'comma separated globs on <interface>.<channel>.<datapoint> not to publish, e.g. "*.*.RSSI_*,HmIP-RF.*.*_STATUS"',
|
|
102
|
+
},
|
|
103
|
+
'ha-generic': {
|
|
104
|
+
type: 'boolean',
|
|
105
|
+
describe: 'Home Assistant discovery: also announce datapoints without a role as (disabled) generic entities',
|
|
106
|
+
default: true,
|
|
107
|
+
},
|
|
91
108
|
'rpc-topics': {
|
|
92
109
|
type: 'boolean',
|
|
93
110
|
describe: 'accept arbitrary rpc calls on <name>/rpc/<interface>/<method>/<callid> (security surface!)',
|
package/index.js
CHANGED
|
@@ -22,6 +22,8 @@ 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
24
|
import {sanitizeName, resolveSet, resolveParamset, plainValue, compileTemplate, ItemIndex} from './lib/topics.js';
|
|
25
|
+
import {compileIgnore} from './lib/roles.js';
|
|
26
|
+
import {discoveryModel} from './lib/hadiscovery.js';
|
|
25
27
|
|
|
26
28
|
handleInstall(config);
|
|
27
29
|
|
|
@@ -77,11 +79,48 @@ if (config.nameFile) {
|
|
|
77
79
|
nameFile = JSON.parse(fs.readFileSync(path.resolve(config.nameFile), 'utf8'));
|
|
78
80
|
}
|
|
79
81
|
|
|
82
|
+
/*
|
|
83
|
+
* payload format (node-red-contrib-ccu's mqsh-extended / mqsh-basic / plain)
|
|
84
|
+
*/
|
|
85
|
+
let payloadFormat = config.payload;
|
|
86
|
+
if (config.hmPayload === false && payloadFormat === 'mqsh-extended') {
|
|
87
|
+
payloadFormat = 'mqsh-basic';
|
|
88
|
+
}
|
|
89
|
+
if (!config.jsonPayloads) {
|
|
90
|
+
payloadFormat = 'plain';
|
|
91
|
+
}
|
|
92
|
+
config.jsonPayloads = payloadFormat !== 'plain';
|
|
93
|
+
const withHm = payloadFormat === 'mqsh-extended';
|
|
94
|
+
/** the value as published: plain format uses node-red's 0/1 for booleans */
|
|
95
|
+
const outValue = (value) => (payloadFormat === 'plain' ? plainValue(value) : value);
|
|
96
|
+
|
|
97
|
+
const ignored = compileIgnore(config.ignore);
|
|
98
|
+
|
|
80
99
|
const adapter = createAdapter({
|
|
81
100
|
pkg,
|
|
82
101
|
config,
|
|
83
102
|
deviceLabel: 'ccu',
|
|
84
|
-
|
|
103
|
+
discovery: () =>
|
|
104
|
+
discoveryModel({
|
|
105
|
+
adapterName: pkg.name,
|
|
106
|
+
name: config.name,
|
|
107
|
+
jsonPayloads: config.jsonPayloads,
|
|
108
|
+
generic: config.haGeneric,
|
|
109
|
+
devices: metadata.devices,
|
|
110
|
+
description: (iface, address) => metadata.description(iface, address, 'VALUES'),
|
|
111
|
+
channelName,
|
|
112
|
+
rooms: (address) => (regaSync ? regaSync.rooms(address) : undefined),
|
|
113
|
+
itemFor: (iface, address, datapoint) => renderItem(values.fields(iface, address, datapoint)).name,
|
|
114
|
+
ignored,
|
|
115
|
+
interfaces: enabled,
|
|
116
|
+
}),
|
|
117
|
+
info: () => ({
|
|
118
|
+
ccu: host,
|
|
119
|
+
interfaces: enabled,
|
|
120
|
+
devices: metadata.count(),
|
|
121
|
+
rega: Boolean(regaSync),
|
|
122
|
+
payload: payloadFormat,
|
|
123
|
+
}),
|
|
85
124
|
onSet: handleSet,
|
|
86
125
|
subscriptions: {
|
|
87
126
|
'paramset/#': handleParamset,
|
|
@@ -194,6 +233,8 @@ function rebuildIndex() {
|
|
|
194
233
|
);
|
|
195
234
|
}
|
|
196
235
|
log.debug('item index rebuilt:', itemIndex.size, 'items');
|
|
236
|
+
adapter.markDiscoveryDirty();
|
|
237
|
+
adapter.publishDiscovery();
|
|
197
238
|
}
|
|
198
239
|
|
|
199
240
|
function scheduleIndex() {
|
|
@@ -212,8 +253,8 @@ function publishPlain(item, value, retain) {
|
|
|
212
253
|
|
|
213
254
|
function publishMessage(message, {retain = true} = {}) {
|
|
214
255
|
const item = rendered(renderItem, message, message.datapointName);
|
|
215
|
-
const extra =
|
|
216
|
-
pubStatus(item, message.value, {retain, extra, ts: message.ts, lc: message.lc});
|
|
256
|
+
const extra = withHm ? {hm: hmBlock(message)} : undefined;
|
|
257
|
+
pubStatus(item, outValue(message.value), {retain, extra, ts: message.ts, lc: message.lc});
|
|
217
258
|
publishPlain(item, message.value, retain);
|
|
218
259
|
}
|
|
219
260
|
|
|
@@ -221,13 +262,13 @@ function publishRega(message) {
|
|
|
221
262
|
const render = message.type === 'PROGRAM' ? renderProgramItem : renderSysvarItem;
|
|
222
263
|
const item = rendered(render, message, `${message.type} ${message.name}`);
|
|
223
264
|
itemIndex.add(item, {kind: message.type === 'PROGRAM' ? 'program' : 'sysvar', name: message.name});
|
|
224
|
-
const extra =
|
|
225
|
-
pubStatus(item, message.value, {retain: true, extra, ts: message.ts, lc: message.lc});
|
|
265
|
+
const extra = withHm ? {hm: hmBlock(message)} : undefined;
|
|
266
|
+
pubStatus(item, outValue(message.value), {retain: true, extra, ts: message.ts, lc: message.lc});
|
|
226
267
|
publishPlain(item, message.value, true);
|
|
227
268
|
}
|
|
228
269
|
|
|
229
270
|
function publishItem(item, value, {retain = true} = {}) {
|
|
230
|
-
pubStatus(item, value, {retain});
|
|
271
|
+
pubStatus(item, outValue(value), {retain});
|
|
231
272
|
publishPlain(item, value, retain);
|
|
232
273
|
}
|
|
233
274
|
|
|
@@ -342,9 +383,47 @@ function connectionOf(address) {
|
|
|
342
383
|
return {iface, conn};
|
|
343
384
|
}
|
|
344
385
|
|
|
386
|
+
/** HM thermostats: CONTROL_MODE is read-only, the modes are set through actions (H-26) */
|
|
387
|
+
const HM_MODES = {
|
|
388
|
+
'AUTO-MODE': () => ['AUTO_MODE', true],
|
|
389
|
+
'MANU-MODE': (setpoint) => ['MANU_MODE', setpoint],
|
|
390
|
+
'BOOST-MODE': () => ['BOOST_MODE', true],
|
|
391
|
+
'COMFORT-MODE': () => ['COMFORT_MODE', true],
|
|
392
|
+
'LOWERING-MODE': () => ['LOWERING_MODE', true],
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
/** Words Home Assistant's single-topic conventions send to LEVEL (H-23). */
|
|
396
|
+
const LEVEL_WORDS = {OPEN: 1, CLOSE: 0, ON: 1.005, OFF: 0};
|
|
397
|
+
|
|
345
398
|
async function setValue(address, datapoint, value) {
|
|
346
399
|
const {iface, conn} = connectionOf(address);
|
|
347
|
-
|
|
400
|
+
let description = metadata.valueDescription(iface, address, datapoint);
|
|
401
|
+
if (datapoint === 'LEVEL' && typeof value === 'string') {
|
|
402
|
+
const word = value.trim().toUpperCase();
|
|
403
|
+
if (word === 'STOP') {
|
|
404
|
+
if (!metadata.valueDescription(iface, address, 'STOP')) {
|
|
405
|
+
throw new Error(`${address} has no STOP`);
|
|
406
|
+
}
|
|
407
|
+
return setValue(address, 'STOP', true);
|
|
408
|
+
}
|
|
409
|
+
if (word in LEVEL_WORDS) {
|
|
410
|
+
value = LEVEL_WORDS[word];
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if (
|
|
414
|
+
datapoint === 'CONTROL_MODE' &&
|
|
415
|
+
typeof value === 'string' &&
|
|
416
|
+
HM_MODES[value.toUpperCase()] &&
|
|
417
|
+
!isWriteable(description)
|
|
418
|
+
) {
|
|
419
|
+
const current = values.get(iface, address, 'SET_TEMPERATURE');
|
|
420
|
+
const [dp, v] = HM_MODES[value.toUpperCase()](
|
|
421
|
+
current && typeof current.value === 'number' ? current.value : 20,
|
|
422
|
+
);
|
|
423
|
+
datapoint = dp;
|
|
424
|
+
value = v;
|
|
425
|
+
description = metadata.valueDescription(iface, address, datapoint);
|
|
426
|
+
}
|
|
348
427
|
if (!isWriteable(description)) {
|
|
349
428
|
throw new Error(`${address} ${datapoint} is not writeable`);
|
|
350
429
|
}
|
|
@@ -481,6 +560,9 @@ function onEvent(iface, event) {
|
|
|
481
560
|
regaSync.poll().catch((err) => log.warn('rega poll failed:', err.message));
|
|
482
561
|
}
|
|
483
562
|
values.event({iface, ...event}, (message) => {
|
|
563
|
+
if (ignored(iface, event.channel, event.datapoint)) {
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
484
566
|
const description = metadata.valueDescription(iface, event.channel, event.datapoint);
|
|
485
567
|
if (!metadata.device(iface, event.channel)) {
|
|
486
568
|
log.debug(iface, 'event of unknown channel', event.channel);
|
|
@@ -488,6 +570,19 @@ function onEvent(iface, event) {
|
|
|
488
570
|
log.debug(iface, 'no description for', event.channel, event.datapoint);
|
|
489
571
|
}
|
|
490
572
|
publishMessage(message, {retain: !isEvent(event.datapoint, description)});
|
|
573
|
+
if (event.datapoint.startsWith('PRESS_') && event.value) {
|
|
574
|
+
// one event item per key channel carrying every press type (HA event entities, H-23)
|
|
575
|
+
publishMessage(
|
|
576
|
+
{
|
|
577
|
+
...message,
|
|
578
|
+
datapoint: 'PRESS',
|
|
579
|
+
datapointName: message.datapointName.replace(/PRESS_\w+$/, 'PRESS'),
|
|
580
|
+
value: event.datapoint,
|
|
581
|
+
payload: event.datapoint,
|
|
582
|
+
},
|
|
583
|
+
{retain: false},
|
|
584
|
+
);
|
|
585
|
+
}
|
|
491
586
|
const notWorking = values.notWorking(message);
|
|
492
587
|
if (notWorking) {
|
|
493
588
|
publishMessage(notWorking);
|
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Home Assistant discovery: one HA device per Homematic device (ROADMAP §13, H-22…H-26),
|
|
3
|
+
* composite entities from the channel roles (light, cover, climate, lock, event, switch, ...)
|
|
4
|
+
* and generic entities for every other datapoint (disabled by default). Pure: devices,
|
|
5
|
+
* descriptions and names in, device blocks for the core's discovery() out.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {entity, availability, discoveryId} from 'mqtt-interfaces-core';
|
|
9
|
+
import {channelRole, haUnit, isFraction, PARAMETERS} from './roles.js';
|
|
10
|
+
|
|
11
|
+
const TILT_TYPES = /BLIND/;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {object} DiscoveryContext
|
|
15
|
+
* @property {string} adapterName package name (hm2mqtt)
|
|
16
|
+
* @property {string} name instance name / topic prefix
|
|
17
|
+
* @property {boolean} jsonPayloads
|
|
18
|
+
* @property {boolean} [generic] generic entities for datapoints without a role (default true)
|
|
19
|
+
* @property {Object<string, Object<string, object>>} devices metadata.devices (iface → address → device)
|
|
20
|
+
* @property {(iface: string, address: string) => object | undefined} description VALUES description of a channel
|
|
21
|
+
* @property {(address: string) => string | undefined} channelName
|
|
22
|
+
* @property {(address: string) => string[] | undefined} rooms
|
|
23
|
+
* @property {(iface: string, address: string, datapoint: string) => string} itemFor rendered item of a datapoint
|
|
24
|
+
* @property {(iface: string, address: string, datapoint: string) => boolean} [ignored]
|
|
25
|
+
* @property {string[]} [interfaces] enabled interfaces (bridge entities)
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
function templates(json) {
|
|
29
|
+
const v = json ? 'value_json.val' : 'value';
|
|
30
|
+
return {
|
|
31
|
+
v,
|
|
32
|
+
num: `{{ ${v} }}`,
|
|
33
|
+
bool: (on = 'ON', off = 'OFF') =>
|
|
34
|
+
json
|
|
35
|
+
? `{{ '${on}' if ${v} else '${off}' }}`
|
|
36
|
+
: `{{ '${on}' if (${v} | string | lower) in ('1', 'true', 'on') else '${off}' }}`,
|
|
37
|
+
percent: `{{ ((${v} | float(0)) * 100) | round }}`,
|
|
38
|
+
int: `${v} | int(0)`,
|
|
39
|
+
float: `${v} | float(0)`,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function labelOf(channel, deviceName, fallback) {
|
|
44
|
+
const name = channel.name;
|
|
45
|
+
if (!name) {
|
|
46
|
+
return fallback;
|
|
47
|
+
}
|
|
48
|
+
if (deviceName && name !== deviceName && name.startsWith(deviceName)) {
|
|
49
|
+
const rest = name.slice(deviceName.length).replace(/^[\s:.-]+/, '');
|
|
50
|
+
return rest || fallback;
|
|
51
|
+
}
|
|
52
|
+
if (name === deviceName) {
|
|
53
|
+
return fallback;
|
|
54
|
+
}
|
|
55
|
+
return name;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Builds the discovery device blocks.
|
|
60
|
+
* @param {DiscoveryContext} ctx
|
|
61
|
+
* @returns {object[]} device blocks ({id, device, components, availability})
|
|
62
|
+
*/
|
|
63
|
+
export function discoveryModel(ctx) {
|
|
64
|
+
const {adapterName, name, jsonPayloads: json = true, devices, itemFor} = ctx;
|
|
65
|
+
const ignored = ctx.ignored || (() => false);
|
|
66
|
+
const t = templates(json);
|
|
67
|
+
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)}`;
|
|
70
|
+
const blocks = [];
|
|
71
|
+
|
|
72
|
+
// the CCU / bridge device
|
|
73
|
+
const bridgeComponents = {};
|
|
74
|
+
for (const iface of ctx.interfaces || []) {
|
|
75
|
+
bridgeComponents[`iface_${iface}`] = entity({
|
|
76
|
+
id: bridgeId,
|
|
77
|
+
name,
|
|
78
|
+
item: `interface/${iface}/connected`,
|
|
79
|
+
platform: 'binary_sensor',
|
|
80
|
+
label: `${iface} connected`,
|
|
81
|
+
jsonPayloads: json,
|
|
82
|
+
category: 'diagnostic',
|
|
83
|
+
extra: {dev_cla: 'connectivity', val_tpl: t.bool()},
|
|
84
|
+
});
|
|
85
|
+
for (const dir of ['rx', 'tx']) {
|
|
86
|
+
bridgeComponents[`counter_${iface}_${dir}`] = entity({
|
|
87
|
+
id: bridgeId,
|
|
88
|
+
name,
|
|
89
|
+
item: `counter/${iface}/${dir}`,
|
|
90
|
+
platform: 'sensor',
|
|
91
|
+
label: `${iface} ${dir}`,
|
|
92
|
+
jsonPayloads: json,
|
|
93
|
+
category: 'diagnostic',
|
|
94
|
+
extra: {stat_cla: 'total_increasing', en: false},
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
blocks.push({id: bridgeId, device: {mf: 'eQ-3', mdl: 'CCU'}, components: bridgeComponents});
|
|
99
|
+
|
|
100
|
+
for (const [iface, list] of Object.entries(devices)) {
|
|
101
|
+
for (const device of Object.values(list)) {
|
|
102
|
+
if (device.PARENT) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const block = deviceBlock({iface, device, list, ctx, t, st, cmd, bridgeId, ignored});
|
|
106
|
+
if (block) {
|
|
107
|
+
blocks.push(block);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return blocks;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function deviceBlock({iface, device, list, ctx, t, st, cmd, bridgeId, ignored}) {
|
|
115
|
+
const {adapterName, name, jsonPayloads: json = true, generic = true} = ctx;
|
|
116
|
+
const id = discoveryId(adapterName, `${name}_${device.ADDRESS}`);
|
|
117
|
+
const deviceName = ctx.channelName(device.ADDRESS) || device.ADDRESS;
|
|
118
|
+
const channels = (device.CHILDREN || [])
|
|
119
|
+
.map((address) => list[address])
|
|
120
|
+
.filter(Boolean)
|
|
121
|
+
.map((ch) => ({
|
|
122
|
+
...ch,
|
|
123
|
+
index: Number.parseInt(String(ch.ADDRESS).split(':')[1], 10),
|
|
124
|
+
name: ctx.channelName(ch.ADDRESS),
|
|
125
|
+
description: ctx.description(iface, ch.ADDRESS) || {},
|
|
126
|
+
}))
|
|
127
|
+
.sort((a, b) => a.index - b.index);
|
|
128
|
+
for (const ch of channels) {
|
|
129
|
+
ch.role = channelRole(ch.TYPE, ch.description);
|
|
130
|
+
}
|
|
131
|
+
resolveVirtualReceivers(channels);
|
|
132
|
+
|
|
133
|
+
const components = {};
|
|
134
|
+
const add = (key, e) => {
|
|
135
|
+
components[key] = e;
|
|
136
|
+
};
|
|
137
|
+
const rooms = new Set();
|
|
138
|
+
for (const ch of channels) {
|
|
139
|
+
for (const r of ctx.rooms(ch.ADDRESS) || []) {
|
|
140
|
+
rooms.add(r);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const maintenance = channels.find((ch) => ch.index === 0);
|
|
144
|
+
|
|
145
|
+
for (const ch of channels) {
|
|
146
|
+
const consumed = new Set();
|
|
147
|
+
const e = (dp, platform, label, more = {}) =>
|
|
148
|
+
entity({
|
|
149
|
+
id,
|
|
150
|
+
name,
|
|
151
|
+
item: ctx.itemFor(iface, ch.ADDRESS, dp),
|
|
152
|
+
platform,
|
|
153
|
+
label,
|
|
154
|
+
uid: `${ch.index}_${dp}`,
|
|
155
|
+
jsonPayloads: json,
|
|
156
|
+
...more,
|
|
157
|
+
});
|
|
158
|
+
const fallback = ch.index === 0 ? 'Maintenance' : `Channel ${ch.index}`;
|
|
159
|
+
const label = labelOf(ch, deviceName, fallback);
|
|
160
|
+
const has = (dp) => Boolean(ch.description[dp]) && !ignored(iface, ch.ADDRESS, dp);
|
|
161
|
+
const key = (dp) => `${ch.index}_${dp}`;
|
|
162
|
+
const stateCh = ch.stateChannel || ch;
|
|
163
|
+
const sst = (dp) => st(iface, stateCh.ADDRESS, dp);
|
|
164
|
+
const own = (dp) => st(iface, ch.ADDRESS, dp);
|
|
165
|
+
const scmd = (dp) => cmd(iface, ch.ADDRESS, dp);
|
|
166
|
+
const disabled = ch.secondary ? {en: false} : {};
|
|
167
|
+
|
|
168
|
+
switch (ch.role) {
|
|
169
|
+
case 'switch':
|
|
170
|
+
if (has('STATE')) {
|
|
171
|
+
add(
|
|
172
|
+
key('STATE'),
|
|
173
|
+
e('STATE', 'switch', label, {
|
|
174
|
+
command: true,
|
|
175
|
+
extra: {
|
|
176
|
+
stat_t: sst('STATE'),
|
|
177
|
+
val_tpl: t.bool(),
|
|
178
|
+
pl_on: 'true',
|
|
179
|
+
pl_off: 'false',
|
|
180
|
+
...disabled,
|
|
181
|
+
},
|
|
182
|
+
}),
|
|
183
|
+
);
|
|
184
|
+
consumed.add('STATE');
|
|
185
|
+
}
|
|
186
|
+
break;
|
|
187
|
+
case 'switch_state':
|
|
188
|
+
if (has('STATE') && !ch.transmitterFor) {
|
|
189
|
+
add(key('STATE'), e('STATE', 'binary_sensor', label, {extra: {val_tpl: t.bool(), en: false}}));
|
|
190
|
+
consumed.add('STATE');
|
|
191
|
+
}
|
|
192
|
+
if (ch.transmitterFor) {
|
|
193
|
+
consumed.add('STATE');
|
|
194
|
+
}
|
|
195
|
+
break;
|
|
196
|
+
case 'dimmer':
|
|
197
|
+
if (has('LEVEL')) {
|
|
198
|
+
add(
|
|
199
|
+
key('LEVEL'),
|
|
200
|
+
e('LEVEL', 'light', label, {
|
|
201
|
+
command: true,
|
|
202
|
+
extra: {
|
|
203
|
+
stat_t: sst('LEVEL'),
|
|
204
|
+
stat_val_tpl: `{{ 'ON' if (${t.float}) > 0 else 'OFF' }}`,
|
|
205
|
+
pl_on: 'ON',
|
|
206
|
+
pl_off: 'OFF',
|
|
207
|
+
on_cmd_type: 'brightness',
|
|
208
|
+
bri_cmd_t: scmd('LEVEL'),
|
|
209
|
+
bri_cmd_tpl: '{{ (value / 100) | round(3) }}',
|
|
210
|
+
bri_scl: 100,
|
|
211
|
+
bri_stat_t: sst('LEVEL'),
|
|
212
|
+
bri_val_tpl: t.percent,
|
|
213
|
+
...disabled,
|
|
214
|
+
},
|
|
215
|
+
}),
|
|
216
|
+
);
|
|
217
|
+
consumed.add('LEVEL');
|
|
218
|
+
}
|
|
219
|
+
break;
|
|
220
|
+
case 'dimmer_state':
|
|
221
|
+
if (ch.transmitterFor) {
|
|
222
|
+
consumed.add('LEVEL');
|
|
223
|
+
}
|
|
224
|
+
break;
|
|
225
|
+
case 'cover': {
|
|
226
|
+
if (has('LEVEL')) {
|
|
227
|
+
const tilt = has('LEVEL_2');
|
|
228
|
+
const direction = stateCh.description.ACTIVITY_STATE
|
|
229
|
+
? 'ACTIVITY_STATE'
|
|
230
|
+
: stateCh.description.DIRECTION
|
|
231
|
+
? 'DIRECTION'
|
|
232
|
+
: null;
|
|
233
|
+
add(
|
|
234
|
+
key('LEVEL'),
|
|
235
|
+
e('LEVEL', 'cover', label, {
|
|
236
|
+
command: true,
|
|
237
|
+
extra: {
|
|
238
|
+
dev_cla: TILT_TYPES.test(ch.TYPE) ? 'blind' : 'shutter',
|
|
239
|
+
pl_open: 'OPEN',
|
|
240
|
+
pl_cls: 'CLOSE',
|
|
241
|
+
pl_stop: 'STOP',
|
|
242
|
+
pos_t: sst('LEVEL'),
|
|
243
|
+
pos_tpl: t.percent,
|
|
244
|
+
set_pos_t: scmd('LEVEL'),
|
|
245
|
+
set_pos_tpl: '{{ (position / 100) | round(3) }}',
|
|
246
|
+
pos_open: 100,
|
|
247
|
+
pos_clsd: 0,
|
|
248
|
+
...(direction && {
|
|
249
|
+
stat_t: sst(direction),
|
|
250
|
+
val_tpl: `{% set d = ${t.int} %}{{ 'opening' if d == 1 else 'closing' if d == 2 else 'stopped' }}`,
|
|
251
|
+
stat_opening: 'opening',
|
|
252
|
+
stat_closing: 'closing',
|
|
253
|
+
stat_stopped: 'stopped',
|
|
254
|
+
}),
|
|
255
|
+
...(tilt && {
|
|
256
|
+
tilt_cmd_t: scmd('LEVEL_2'),
|
|
257
|
+
tilt_cmd_tpl: '{{ (tilt_position / 100) | round(3) }}',
|
|
258
|
+
tilt_status_t: sst('LEVEL_2'),
|
|
259
|
+
tilt_status_tpl: t.percent,
|
|
260
|
+
}),
|
|
261
|
+
...disabled,
|
|
262
|
+
},
|
|
263
|
+
}),
|
|
264
|
+
);
|
|
265
|
+
for (const dp of ['LEVEL', 'LEVEL_2', 'STOP']) {
|
|
266
|
+
consumed.add(dp);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
case 'cover_state':
|
|
272
|
+
if (ch.transmitterFor) {
|
|
273
|
+
for (const dp of ['LEVEL', 'LEVEL_2', 'ACTIVITY_STATE', 'DIRECTION']) {
|
|
274
|
+
consumed.add(dp);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
break;
|
|
278
|
+
case 'contact':
|
|
279
|
+
if (has('STATE')) {
|
|
280
|
+
add(
|
|
281
|
+
key('STATE'),
|
|
282
|
+
e('STATE', 'binary_sensor', label, {
|
|
283
|
+
extra: {
|
|
284
|
+
dev_cla:
|
|
285
|
+
ch.TYPE === 'MULTI_MODE_INPUT_TRANSMITTER'
|
|
286
|
+
? 'opening'
|
|
287
|
+
: ch.TYPE === 'TILT_SENSOR'
|
|
288
|
+
? 'moving'
|
|
289
|
+
: 'window',
|
|
290
|
+
val_tpl: t.bool(),
|
|
291
|
+
},
|
|
292
|
+
}),
|
|
293
|
+
);
|
|
294
|
+
consumed.add('STATE');
|
|
295
|
+
}
|
|
296
|
+
break;
|
|
297
|
+
case 'rotary_handle':
|
|
298
|
+
if (has('STATE')) {
|
|
299
|
+
add(
|
|
300
|
+
key('STATE'),
|
|
301
|
+
e('STATE', 'binary_sensor', label, {
|
|
302
|
+
extra: {dev_cla: 'window', val_tpl: `{{ 'ON' if (${t.int}) != 0 else 'OFF' }}`},
|
|
303
|
+
}),
|
|
304
|
+
);
|
|
305
|
+
add(
|
|
306
|
+
key('STATE_text'),
|
|
307
|
+
e('STATE', 'sensor', `${label} handle`, {
|
|
308
|
+
uid: `${ch.index}_STATE_text`,
|
|
309
|
+
extra: {val_tpl: `{{ ['closed', 'tilted', 'open'][${t.int}] }}`, en: false},
|
|
310
|
+
}),
|
|
311
|
+
);
|
|
312
|
+
consumed.add('STATE');
|
|
313
|
+
}
|
|
314
|
+
break;
|
|
315
|
+
case 'key': {
|
|
316
|
+
const types = ['PRESS_SHORT', 'PRESS_LONG', 'PRESS_LONG_RELEASE', 'PRESS_CONT'].filter((dp) => has(dp));
|
|
317
|
+
if (types.length > 0) {
|
|
318
|
+
add(
|
|
319
|
+
key('PRESS'),
|
|
320
|
+
e('PRESS', 'event', label, {
|
|
321
|
+
extra: {
|
|
322
|
+
dev_cla: 'button',
|
|
323
|
+
evt_typ: types.map((x) => x.toLowerCase()),
|
|
324
|
+
val_tpl: `{{ {'event_type': (${t.v} | string | lower)} | tojson }}`,
|
|
325
|
+
},
|
|
326
|
+
}),
|
|
327
|
+
);
|
|
328
|
+
for (const dp of types) {
|
|
329
|
+
consumed.add(dp);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
case 'climate_hmip':
|
|
335
|
+
if (has('SET_POINT_TEMPERATURE')) {
|
|
336
|
+
const d = ch.description;
|
|
337
|
+
add(
|
|
338
|
+
key('CLIMATE'),
|
|
339
|
+
e('SET_POINT_TEMPERATURE', 'climate', label, {
|
|
340
|
+
uid: `${ch.index}_CLIMATE`,
|
|
341
|
+
extra: {
|
|
342
|
+
stat_t: undefined,
|
|
343
|
+
val_tpl: undefined,
|
|
344
|
+
temp_unit: 'C',
|
|
345
|
+
temp_step: 0.5,
|
|
346
|
+
min_temp: d.SET_POINT_TEMPERATURE.MIN ?? 4.5,
|
|
347
|
+
max_temp: d.SET_POINT_TEMPERATURE.MAX ?? 30.5,
|
|
348
|
+
temp_stat_t: own('SET_POINT_TEMPERATURE'),
|
|
349
|
+
temp_stat_tpl: t.num,
|
|
350
|
+
temp_cmd_t: scmd('SET_POINT_TEMPERATURE'),
|
|
351
|
+
...(has('ACTUAL_TEMPERATURE') && {
|
|
352
|
+
curr_temp_t: own('ACTUAL_TEMPERATURE'),
|
|
353
|
+
curr_temp_tpl: t.num,
|
|
354
|
+
}),
|
|
355
|
+
...(has('HUMIDITY') && {curr_hum_t: own('HUMIDITY'), curr_hum_tpl: t.num}),
|
|
356
|
+
modes: ['auto', 'heat'],
|
|
357
|
+
...(has('SET_POINT_MODE') && {
|
|
358
|
+
mode_stat_t: own('SET_POINT_MODE'),
|
|
359
|
+
mode_stat_tpl: `{{ 'auto' if (${t.int}) == 0 else 'heat' }}`,
|
|
360
|
+
}),
|
|
361
|
+
...(has('CONTROL_MODE') && {
|
|
362
|
+
mode_cmd_t: scmd('CONTROL_MODE'),
|
|
363
|
+
mode_cmd_tpl: "{{ 0 if value == 'auto' else 1 }}",
|
|
364
|
+
}),
|
|
365
|
+
...(has('BOOST_MODE') && {
|
|
366
|
+
pr_modes: ['boost'],
|
|
367
|
+
pr_mode_stat_t: own('BOOST_MODE'),
|
|
368
|
+
pr_mode_val_tpl: `{{ 'boost' if ${t.v} else 'none' }}`,
|
|
369
|
+
pr_mode_cmd_t: scmd('BOOST_MODE'),
|
|
370
|
+
pr_mode_cmd_tpl: "{{ 'true' if value == 'boost' else 'false' }}",
|
|
371
|
+
}),
|
|
372
|
+
...(has('LEVEL') && {
|
|
373
|
+
act_t: own('LEVEL'),
|
|
374
|
+
act_tpl: `{{ 'heating' if (${t.float}) > 0 else 'idle' }}`,
|
|
375
|
+
}),
|
|
376
|
+
},
|
|
377
|
+
}),
|
|
378
|
+
);
|
|
379
|
+
for (const dp of [
|
|
380
|
+
'SET_POINT_TEMPERATURE',
|
|
381
|
+
'ACTUAL_TEMPERATURE',
|
|
382
|
+
'HUMIDITY',
|
|
383
|
+
'SET_POINT_MODE',
|
|
384
|
+
'CONTROL_MODE',
|
|
385
|
+
'BOOST_MODE',
|
|
386
|
+
'LEVEL',
|
|
387
|
+
]) {
|
|
388
|
+
consumed.add(dp);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
break;
|
|
392
|
+
case 'climate_hm':
|
|
393
|
+
if (has('SET_TEMPERATURE')) {
|
|
394
|
+
const d = ch.description;
|
|
395
|
+
add(
|
|
396
|
+
key('CLIMATE'),
|
|
397
|
+
e('SET_TEMPERATURE', 'climate', label, {
|
|
398
|
+
uid: `${ch.index}_CLIMATE`,
|
|
399
|
+
extra: {
|
|
400
|
+
stat_t: undefined,
|
|
401
|
+
val_tpl: undefined,
|
|
402
|
+
temp_unit: 'C',
|
|
403
|
+
temp_step: 0.5,
|
|
404
|
+
min_temp: d.SET_TEMPERATURE.MIN ?? 4.5,
|
|
405
|
+
max_temp: d.SET_TEMPERATURE.MAX ?? 30.5,
|
|
406
|
+
temp_stat_t: own('SET_TEMPERATURE'),
|
|
407
|
+
temp_stat_tpl: t.num,
|
|
408
|
+
temp_cmd_t: scmd('SET_TEMPERATURE'),
|
|
409
|
+
...(has('ACTUAL_TEMPERATURE') && {
|
|
410
|
+
curr_temp_t: own('ACTUAL_TEMPERATURE'),
|
|
411
|
+
curr_temp_tpl: t.num,
|
|
412
|
+
}),
|
|
413
|
+
...(has('ACTUAL_HUMIDITY') && {
|
|
414
|
+
curr_hum_t: own('ACTUAL_HUMIDITY'),
|
|
415
|
+
curr_hum_tpl: t.num,
|
|
416
|
+
}),
|
|
417
|
+
modes: ['auto', 'heat'],
|
|
418
|
+
...(has('CONTROL_MODE') && {
|
|
419
|
+
mode_stat_t: own('CONTROL_MODE'),
|
|
420
|
+
mode_stat_tpl: `{{ 'heat' if (${t.int}) == 1 else 'auto' }}`,
|
|
421
|
+
mode_cmd_t: scmd('CONTROL_MODE'),
|
|
422
|
+
mode_cmd_tpl: "{{ 'AUTO-MODE' if value == 'auto' else 'MANU-MODE' }}",
|
|
423
|
+
pr_modes: ['boost', 'comfort', 'eco'],
|
|
424
|
+
pr_mode_stat_t: own('CONTROL_MODE'),
|
|
425
|
+
pr_mode_val_tpl: `{{ 'boost' if (${t.int}) == 3 else 'none' }}`,
|
|
426
|
+
pr_mode_cmd_t: scmd('CONTROL_MODE'),
|
|
427
|
+
pr_mode_cmd_tpl:
|
|
428
|
+
"{{ {'boost': 'BOOST-MODE', 'comfort': 'COMFORT-MODE', 'eco': 'LOWERING-MODE'}.get(value, 'AUTO-MODE') }}",
|
|
429
|
+
}),
|
|
430
|
+
...(has('VALVE_STATE') && {
|
|
431
|
+
act_t: own('VALVE_STATE'),
|
|
432
|
+
act_tpl: `{{ 'heating' if (${t.int}) > 0 else 'idle' }}`,
|
|
433
|
+
}),
|
|
434
|
+
},
|
|
435
|
+
}),
|
|
436
|
+
);
|
|
437
|
+
for (const dp of [
|
|
438
|
+
'SET_TEMPERATURE',
|
|
439
|
+
'ACTUAL_TEMPERATURE',
|
|
440
|
+
'ACTUAL_HUMIDITY',
|
|
441
|
+
'CONTROL_MODE',
|
|
442
|
+
'BOOST_MODE',
|
|
443
|
+
'AUTO_MODE',
|
|
444
|
+
'MANU_MODE',
|
|
445
|
+
'COMFORT_MODE',
|
|
446
|
+
'LOWERING_MODE',
|
|
447
|
+
]) {
|
|
448
|
+
consumed.add(dp);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
break;
|
|
452
|
+
case 'lock':
|
|
453
|
+
if (has('STATE')) {
|
|
454
|
+
add(
|
|
455
|
+
key('STATE'),
|
|
456
|
+
e('STATE', 'lock', label, {
|
|
457
|
+
command: true,
|
|
458
|
+
extra: {
|
|
459
|
+
val_tpl: t.bool('UNLOCKED', 'LOCKED'),
|
|
460
|
+
stat_locked: 'LOCKED',
|
|
461
|
+
stat_unlocked: 'UNLOCKED',
|
|
462
|
+
pl_lock: 'false',
|
|
463
|
+
pl_unlk: 'true',
|
|
464
|
+
},
|
|
465
|
+
}),
|
|
466
|
+
);
|
|
467
|
+
consumed.add('STATE');
|
|
468
|
+
}
|
|
469
|
+
break;
|
|
470
|
+
case 'smoke':
|
|
471
|
+
if (has('SMOKE_DETECTOR_ALARM_STATUS')) {
|
|
472
|
+
add(
|
|
473
|
+
key('SMOKE'),
|
|
474
|
+
e('SMOKE_DETECTOR_ALARM_STATUS', 'binary_sensor', label, {
|
|
475
|
+
uid: `${ch.index}_SMOKE`,
|
|
476
|
+
extra: {dev_cla: 'smoke', val_tpl: `{{ 'ON' if (${t.int}) in (1, 3) else 'OFF' }}`},
|
|
477
|
+
}),
|
|
478
|
+
);
|
|
479
|
+
consumed.add('SMOKE_DETECTOR_ALARM_STATUS');
|
|
480
|
+
} else if (has('STATE')) {
|
|
481
|
+
add(
|
|
482
|
+
key('STATE'),
|
|
483
|
+
e('STATE', 'binary_sensor', label, {extra: {dev_cla: 'smoke', val_tpl: t.bool()}}),
|
|
484
|
+
);
|
|
485
|
+
consumed.add('STATE');
|
|
486
|
+
}
|
|
487
|
+
break;
|
|
488
|
+
case 'water':
|
|
489
|
+
for (const dp of ['STATE', 'ALARMSTATE', 'MOISTURE_DETECTED', 'WATERLEVEL_DETECTED']) {
|
|
490
|
+
if (has(dp)) {
|
|
491
|
+
add(
|
|
492
|
+
key(dp),
|
|
493
|
+
e(
|
|
494
|
+
dp,
|
|
495
|
+
'binary_sensor',
|
|
496
|
+
dp === 'STATE' ? label : `${label} ${dp.toLowerCase().replace(/_/g, ' ')}`,
|
|
497
|
+
{extra: {dev_cla: 'moisture', val_tpl: t.bool()}},
|
|
498
|
+
),
|
|
499
|
+
);
|
|
500
|
+
consumed.add(dp);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
break;
|
|
504
|
+
default:
|
|
505
|
+
break;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// generic layer: everything else in the channel's VALUES description
|
|
509
|
+
for (const [dp, d] of Object.entries(ch.description)) {
|
|
510
|
+
if (consumed.has(dp) || !d || typeof d !== 'object' || ignored(iface, ch.ADDRESS, dp)) {
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
const facts = PARAMETERS[dp] || {};
|
|
514
|
+
const semantic = Boolean(facts.dev_cla || facts.enabled);
|
|
515
|
+
if (!generic && !semantic) {
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
const ops = typeof d.OPERATIONS === 'number' ? d.OPERATIONS : 5;
|
|
519
|
+
const readable = Boolean(ops & 1) || Boolean(ops & 4);
|
|
520
|
+
const writable = Boolean(ops & 2);
|
|
521
|
+
const dpLabel = ch.index === 0 && ch.role === 'maintenance' ? prettify(dp) : `${label} ${prettify(dp)}`;
|
|
522
|
+
const enabled = facts.enabled === true;
|
|
523
|
+
const common = {
|
|
524
|
+
...(facts.dev_cla && {dev_cla: facts.dev_cla}),
|
|
525
|
+
...(facts.ent_cat && {ent_cat: facts.ent_cat}),
|
|
526
|
+
...(!enabled && {en: false}),
|
|
527
|
+
};
|
|
528
|
+
const unit = facts.unit || haUnit(d.UNIT);
|
|
529
|
+
const value = isFraction(d) ? t.percent : t.num;
|
|
530
|
+
if (d.TYPE === 'BOOL' || d.TYPE === 'ACTION') {
|
|
531
|
+
if (writable && !readable) {
|
|
532
|
+
if (d.TYPE === 'ACTION') {
|
|
533
|
+
add(key(dp), e(dp, 'button', dpLabel, {command: true, extra: {pl_prs: 'true', ...common}}));
|
|
534
|
+
}
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
if (writable && d.TYPE === 'BOOL') {
|
|
538
|
+
add(
|
|
539
|
+
key(dp),
|
|
540
|
+
e(dp, 'switch', dpLabel, {
|
|
541
|
+
command: true,
|
|
542
|
+
extra: {val_tpl: t.bool(), pl_on: 'true', pl_off: 'false', ...common},
|
|
543
|
+
}),
|
|
544
|
+
);
|
|
545
|
+
} else if (d.TYPE === 'BOOL') {
|
|
546
|
+
add(
|
|
547
|
+
key(dp),
|
|
548
|
+
e(dp, 'binary_sensor', dpLabel, {
|
|
549
|
+
extra: {val_tpl: facts.inverted ? t.bool('OFF', 'ON') : t.bool(), ...common},
|
|
550
|
+
}),
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
if (d.TYPE === 'ENUM' && Array.isArray(d.VALUE_LIST)) {
|
|
556
|
+
if (writable) {
|
|
557
|
+
add(
|
|
558
|
+
key(dp),
|
|
559
|
+
e(dp, 'select', dpLabel, {
|
|
560
|
+
command: true,
|
|
561
|
+
extra: {
|
|
562
|
+
options: d.VALUE_LIST,
|
|
563
|
+
val_tpl: `{{ ${JSON.stringify(d.VALUE_LIST)}[${t.int}] }}`,
|
|
564
|
+
cmd_tpl: `{{ ${JSON.stringify(d.VALUE_LIST)}.index(value) }}`,
|
|
565
|
+
...common,
|
|
566
|
+
},
|
|
567
|
+
}),
|
|
568
|
+
);
|
|
569
|
+
} else if (readable) {
|
|
570
|
+
add(
|
|
571
|
+
key(dp),
|
|
572
|
+
e(dp, 'sensor', dpLabel, {
|
|
573
|
+
extra: {val_tpl: `{{ ${JSON.stringify(d.VALUE_LIST)}[${t.int}] }}`, ...common},
|
|
574
|
+
}),
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
if (d.TYPE === 'FLOAT' || d.TYPE === 'INTEGER') {
|
|
580
|
+
if (writable && !readable) {
|
|
581
|
+
add(
|
|
582
|
+
key(dp),
|
|
583
|
+
e(dp, 'number', dpLabel, {
|
|
584
|
+
command: true,
|
|
585
|
+
extra: {
|
|
586
|
+
...(typeof d.MIN === 'number' && {min: isFraction(d) ? d.MIN * 100 : d.MIN}),
|
|
587
|
+
...(typeof d.MAX === 'number' && {
|
|
588
|
+
max: isFraction(d) ? Math.min(d.MAX, 1) * 100 : d.MAX,
|
|
589
|
+
}),
|
|
590
|
+
step: d.TYPE === 'INTEGER' ? 1 : isFraction(d) ? 1 : 0.1,
|
|
591
|
+
...(unit && {unit_of_meas: unit}),
|
|
592
|
+
...(isFraction(d) && {cmd_tpl: '{{ (value / 100) | round(3) }}'}),
|
|
593
|
+
...common,
|
|
594
|
+
},
|
|
595
|
+
}),
|
|
596
|
+
);
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
add(
|
|
600
|
+
key(dp),
|
|
601
|
+
e(dp, 'sensor', dpLabel, {
|
|
602
|
+
extra: {
|
|
603
|
+
val_tpl: value,
|
|
604
|
+
...(unit && {unit_of_meas: unit}),
|
|
605
|
+
...(facts.stat_cla && {stat_cla: facts.stat_cla}),
|
|
606
|
+
...common,
|
|
607
|
+
},
|
|
608
|
+
}),
|
|
609
|
+
);
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
if (d.TYPE === 'STRING' && readable) {
|
|
613
|
+
add(key(dp), e(dp, 'sensor', dpLabel, {extra: {val_tpl: t.num, ...common}}));
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (Object.keys(components).length === 0) {
|
|
619
|
+
return null;
|
|
620
|
+
}
|
|
621
|
+
const avty = [...availability(name, 2)];
|
|
622
|
+
if (maintenance && maintenance.description.UNREACH && !ignored(iface, maintenance.ADDRESS, 'UNREACH')) {
|
|
623
|
+
avty.push({t: st(iface, maintenance.ADDRESS, 'UNREACH'), avty_tpl: t.bool('offline', 'online')});
|
|
624
|
+
}
|
|
625
|
+
return {
|
|
626
|
+
id,
|
|
627
|
+
device: {
|
|
628
|
+
name: deviceName,
|
|
629
|
+
mf: 'eQ-3',
|
|
630
|
+
mdl: device.TYPE,
|
|
631
|
+
...(device.FIRMWARE && {sw: String(device.FIRMWARE)}),
|
|
632
|
+
via_device: bridgeId,
|
|
633
|
+
...(rooms.size === 1 && {sa: [...rooms][0]}),
|
|
634
|
+
},
|
|
635
|
+
components,
|
|
636
|
+
availability: avty,
|
|
637
|
+
availabilityMode: 'all',
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* HmIP actuators: a *_TRANSMITTER channel (state) followed by three *_VIRTUAL_RECEIVER channels
|
|
643
|
+
* (control). The first receiver becomes the control entity with the transmitter's state topic,
|
|
644
|
+
* the others are secondary (disabled by default) — H-25.
|
|
645
|
+
*/
|
|
646
|
+
export function resolveVirtualReceivers(channels) {
|
|
647
|
+
const families = [
|
|
648
|
+
{transmitter: 'switch_state', receiver: 'switch'},
|
|
649
|
+
{transmitter: 'dimmer_state', receiver: 'dimmer'},
|
|
650
|
+
{transmitter: 'cover_state', receiver: 'cover'},
|
|
651
|
+
];
|
|
652
|
+
for (const {transmitter, receiver} of families) {
|
|
653
|
+
let current = null;
|
|
654
|
+
let count = 0;
|
|
655
|
+
for (const ch of channels) {
|
|
656
|
+
if (ch.role === transmitter && /_TRANSMITTER$/.test(ch.TYPE)) {
|
|
657
|
+
current = ch;
|
|
658
|
+
count = 0;
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
if (ch.role === receiver && /_VIRTUAL_RECEIVER$/.test(ch.TYPE) && current) {
|
|
662
|
+
count += 1;
|
|
663
|
+
ch.stateChannel = current;
|
|
664
|
+
if (count === 1) {
|
|
665
|
+
current.transmitterFor = ch;
|
|
666
|
+
} else {
|
|
667
|
+
ch.secondary = true;
|
|
668
|
+
}
|
|
669
|
+
} else if (ch.role === receiver && /_VIRTUAL_RECEIVER$/.test(ch.TYPE)) {
|
|
670
|
+
// standalone receiver blocks without a transmitter: first of three is primary
|
|
671
|
+
count += 1;
|
|
672
|
+
if (count > 1 && (count - 1) % 3 !== 0) {
|
|
673
|
+
ch.secondary = true;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
// HM virtual dimmers (HM-LC-Dim1TPBU-FM ch 2/3) are secondaries of channel 1
|
|
679
|
+
for (const ch of channels) {
|
|
680
|
+
if (ch.TYPE === 'VIRTUAL_DIMMER') {
|
|
681
|
+
ch.secondary = true;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function prettify(dp) {
|
|
687
|
+
return dp
|
|
688
|
+
.toLowerCase()
|
|
689
|
+
.split('_')
|
|
690
|
+
.map((w) => (w ? w[0].toUpperCase() + w.slice(1) : w))
|
|
691
|
+
.join(' ');
|
|
692
|
+
}
|
package/lib/roles.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic roles of channels and the Home Assistant facts of datapoints — derived from the
|
|
3
|
+
* paramset descriptions (the CONTROL hint eQ-3 puts on every parameter), channel types as
|
|
4
|
+
* fallback (ROADMAP H-21). Pure functions and tables, no I/O.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** CONTROL hint of a channel's primary datapoint → role; checked in this order */
|
|
8
|
+
export const ROLE_BY_CONTROL = [
|
|
9
|
+
['LOCK.STATE', 'lock'],
|
|
10
|
+
['HEATING_CONTROL_HMIP.SETPOINT', 'climate_hmip'],
|
|
11
|
+
['HEATING_CONTROL.SETPOINT', 'climate_hm'],
|
|
12
|
+
['BLIND.LEVEL', 'cover'],
|
|
13
|
+
['BLIND_VIRTUAL_RECEIVER.LEVEL', 'cover'],
|
|
14
|
+
['SHUTTER_VIRTUAL_RECEIVER.LEVEL', 'cover'],
|
|
15
|
+
['BLIND_TRANSMITTER.LEVEL', 'cover_state'],
|
|
16
|
+
['SHUTTER_TRANSMITTER.LEVEL', 'cover_state'],
|
|
17
|
+
['DIMMER.LEVEL', 'dimmer'],
|
|
18
|
+
['DIMMER_REAL.LEVEL', 'dimmer_state'],
|
|
19
|
+
['SWITCH.STATE', 'switch'],
|
|
20
|
+
['SWITCH_TRANSMITTER.STATE', 'switch_state'],
|
|
21
|
+
['DANGER.STATE', 'smoke'],
|
|
22
|
+
['DOOR_SENSOR.STATE', 'contact'],
|
|
23
|
+
['RHS.STATE', 'rotary_handle'],
|
|
24
|
+
['MOTIONDETECTOR_TRANSCEIVER.MOTION_DETECTION_STATE', 'motion'],
|
|
25
|
+
['POWERMETER.POWER', 'energy'],
|
|
26
|
+
['POWERMETER_PSM.POWER', 'energy'],
|
|
27
|
+
['WEATHER_TRANSMIT.ACTUAL_TEMPERATURE', 'weather'],
|
|
28
|
+
['BUTTON.SHORT', 'key'],
|
|
29
|
+
['BUTTON_NO_FUNCTION.SHORT', 'key'],
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
/** channel types whose role does not come from a CONTROL hint (older HM devices, maintenance) */
|
|
33
|
+
export const ROLE_BY_TYPE = {
|
|
34
|
+
MAINTENANCE: 'maintenance',
|
|
35
|
+
MOTION_DETECTOR: 'motion',
|
|
36
|
+
PRESENCEDETECTOR_TRANSCEIVER: 'presence',
|
|
37
|
+
WEATHER: 'weather',
|
|
38
|
+
SHUTTER_CONTACT: 'contact',
|
|
39
|
+
TILT_SENSOR: 'contact',
|
|
40
|
+
WATERDETECTIONSENSOR: 'water',
|
|
41
|
+
WATER_DETECTION_TRANSMITTER: 'water',
|
|
42
|
+
SMOKE_DETECTOR: 'smoke',
|
|
43
|
+
SMOKE_DETECTOR_TEAM: 'smoke',
|
|
44
|
+
SMOKE_DETECTOR_TEAM_V2: 'smoke',
|
|
45
|
+
KEY: 'key',
|
|
46
|
+
VIRTUAL_KEY: 'key',
|
|
47
|
+
KEY_TRANSCEIVER: 'key',
|
|
48
|
+
CENTRAL_KEY: 'key',
|
|
49
|
+
POWERMETER: 'energy',
|
|
50
|
+
ENERGIE_METER_TRANSMITTER: 'energy',
|
|
51
|
+
VIRTUAL_DIMMER: 'dimmer',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {string} channelType
|
|
56
|
+
* @param {object} [description] VALUES paramset description of the channel
|
|
57
|
+
* @returns {string | null}
|
|
58
|
+
*/
|
|
59
|
+
export function channelRole(channelType, description) {
|
|
60
|
+
if (ROLE_BY_TYPE[channelType] === 'maintenance') {
|
|
61
|
+
return 'maintenance';
|
|
62
|
+
}
|
|
63
|
+
if (description) {
|
|
64
|
+
const controls = new Set(Object.values(description).map((d) => d && d.CONTROL));
|
|
65
|
+
for (const [control, role] of ROLE_BY_CONTROL) {
|
|
66
|
+
if (controls.has(control)) {
|
|
67
|
+
return role;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return ROLE_BY_TYPE[channelType] || null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** units as the interface processes send them → Home Assistant */
|
|
75
|
+
export function haUnit(unit) {
|
|
76
|
+
if (!unit || unit === '""') {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
const u = String(unit).replace(/�|°/g, '°');
|
|
80
|
+
return (
|
|
81
|
+
{
|
|
82
|
+
'100%': '%',
|
|
83
|
+
'% rF': '%',
|
|
84
|
+
Lux: 'lx',
|
|
85
|
+
degree: '°',
|
|
86
|
+
'°': '°',
|
|
87
|
+
min: 'min',
|
|
88
|
+
s: 's',
|
|
89
|
+
mA: 'mA',
|
|
90
|
+
}[u] || u
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** `100%` values are 0..1 on the wire */
|
|
95
|
+
export function isFraction(description) {
|
|
96
|
+
return Boolean(description && description.UNIT === '100%');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Home Assistant facts of a datapoint by name: device class, state class, unit override,
|
|
101
|
+
* entity category, enabled by default. Anything not listed is a plain sensor / binary_sensor,
|
|
102
|
+
* disabled by default (H-22).
|
|
103
|
+
*/
|
|
104
|
+
export const PARAMETERS = {
|
|
105
|
+
ACTUAL_TEMPERATURE: {dev_cla: 'temperature', stat_cla: 'measurement', unit: '°C', enabled: true},
|
|
106
|
+
TEMPERATURE: {dev_cla: 'temperature', stat_cla: 'measurement', unit: '°C', enabled: true},
|
|
107
|
+
SET_POINT_TEMPERATURE: {dev_cla: 'temperature', unit: '°C'},
|
|
108
|
+
SET_TEMPERATURE: {dev_cla: 'temperature', unit: '°C'},
|
|
109
|
+
HUMIDITY: {dev_cla: 'humidity', stat_cla: 'measurement', unit: '%', enabled: true},
|
|
110
|
+
ACTUAL_HUMIDITY: {dev_cla: 'humidity', stat_cla: 'measurement', unit: '%', enabled: true},
|
|
111
|
+
ILLUMINATION: {dev_cla: 'illuminance', stat_cla: 'measurement', unit: 'lx', enabled: true},
|
|
112
|
+
CURRENT_ILLUMINATION: {dev_cla: 'illuminance', stat_cla: 'measurement', unit: 'lx'},
|
|
113
|
+
AVERAGE_ILLUMINATION: {dev_cla: 'illuminance', stat_cla: 'measurement', unit: 'lx'},
|
|
114
|
+
BRIGHTNESS: {stat_cla: 'measurement', enabled: true},
|
|
115
|
+
POWER: {dev_cla: 'power', stat_cla: 'measurement', unit: 'W', enabled: true},
|
|
116
|
+
CURRENT: {dev_cla: 'current', stat_cla: 'measurement', unit: 'mA', enabled: true},
|
|
117
|
+
VOLTAGE: {dev_cla: 'voltage', stat_cla: 'measurement', unit: 'V', enabled: true},
|
|
118
|
+
FREQUENCY: {dev_cla: 'frequency', stat_cla: 'measurement', unit: 'Hz'},
|
|
119
|
+
ENERGY_COUNTER: {dev_cla: 'energy', stat_cla: 'total_increasing', unit: 'Wh', enabled: true},
|
|
120
|
+
RAIN_COUNTER: {dev_cla: 'precipitation', stat_cla: 'total_increasing', unit: 'mm', enabled: true},
|
|
121
|
+
RAINING: {dev_cla: 'moisture', enabled: true},
|
|
122
|
+
WIND_SPEED: {dev_cla: 'wind_speed', stat_cla: 'measurement', unit: 'km/h', enabled: true},
|
|
123
|
+
WIND_DIRECTION: {stat_cla: 'measurement', unit: '°', enabled: true},
|
|
124
|
+
WIND_DIR: {stat_cla: 'measurement', unit: '°', enabled: true},
|
|
125
|
+
SUNSHINEDURATION: {stat_cla: 'total_increasing', enabled: true},
|
|
126
|
+
AIR_PRESSURE: {dev_cla: 'atmospheric_pressure', stat_cla: 'measurement', unit: 'hPa', enabled: true},
|
|
127
|
+
VALVE_STATE: {stat_cla: 'measurement', unit: '%', enabled: true},
|
|
128
|
+
LEVEL: {stat_cla: 'measurement', unit: '%'},
|
|
129
|
+
BOOST_STATE: {unit: 'min'},
|
|
130
|
+
LOW_BAT: {dev_cla: 'battery', ent_cat: 'diagnostic', enabled: true},
|
|
131
|
+
LOWBAT: {dev_cla: 'battery', ent_cat: 'diagnostic', enabled: true},
|
|
132
|
+
UNREACH: {dev_cla: 'connectivity', ent_cat: 'diagnostic', inverted: true},
|
|
133
|
+
STICKY_UNREACH: {dev_cla: 'problem', ent_cat: 'diagnostic'},
|
|
134
|
+
CONFIG_PENDING: {ent_cat: 'diagnostic'},
|
|
135
|
+
UPDATE_PENDING: {dev_cla: 'update', ent_cat: 'diagnostic'},
|
|
136
|
+
DUTY_CYCLE: {dev_cla: 'problem', ent_cat: 'diagnostic'},
|
|
137
|
+
DUTYCYCLE: {dev_cla: 'problem', ent_cat: 'diagnostic'},
|
|
138
|
+
SABOTAGE: {dev_cla: 'tamper', ent_cat: 'diagnostic'},
|
|
139
|
+
ERROR_SABOTAGE: {dev_cla: 'tamper', ent_cat: 'diagnostic'},
|
|
140
|
+
RSSI_DEVICE: {dev_cla: 'signal_strength', stat_cla: 'measurement', unit: 'dBm', ent_cat: 'diagnostic'},
|
|
141
|
+
RSSI_PEER: {dev_cla: 'signal_strength', stat_cla: 'measurement', unit: 'dBm', ent_cat: 'diagnostic'},
|
|
142
|
+
OPERATING_VOLTAGE: {dev_cla: 'voltage', stat_cla: 'measurement', unit: 'V', ent_cat: 'diagnostic'},
|
|
143
|
+
ERROR_CODE: {ent_cat: 'diagnostic'},
|
|
144
|
+
WORKING: {dev_cla: 'running'},
|
|
145
|
+
PROCESS: {dev_cla: 'running'},
|
|
146
|
+
MOTION: {dev_cla: 'motion', enabled: true},
|
|
147
|
+
PRESENCE_DETECTION_STATE: {dev_cla: 'occupancy', enabled: true},
|
|
148
|
+
ALARMSTATE: {dev_cla: 'safety', enabled: true},
|
|
149
|
+
MOISTURE_DETECTED: {dev_cla: 'moisture', enabled: true},
|
|
150
|
+
WATERLEVEL_DETECTED: {dev_cla: 'moisture', enabled: true},
|
|
151
|
+
ON_TIME: {ent_cat: 'config', unit: 's'},
|
|
152
|
+
RAMP_TIME: {ent_cat: 'config', unit: 's'},
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Compiles `--ignore` globs on `<iface>.<channel>.<datapoint>` (`*` any characters, `?` one).
|
|
157
|
+
* @param {string | string[]} patterns comma separated or array
|
|
158
|
+
* @returns {(iface: string, channel: string, datapoint: string) => boolean}
|
|
159
|
+
*/
|
|
160
|
+
export function compileIgnore(patterns) {
|
|
161
|
+
const list = (Array.isArray(patterns) ? patterns : String(patterns || '').split(','))
|
|
162
|
+
.map((p) => p.trim())
|
|
163
|
+
.filter(Boolean)
|
|
164
|
+
.map(
|
|
165
|
+
(p) =>
|
|
166
|
+
new RegExp(
|
|
167
|
+
'^' +
|
|
168
|
+
p
|
|
169
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
170
|
+
.replace(/\*/g, '.*')
|
|
171
|
+
.replace(/\?/g, '.') +
|
|
172
|
+
'$',
|
|
173
|
+
'i',
|
|
174
|
+
),
|
|
175
|
+
);
|
|
176
|
+
if (list.length === 0) {
|
|
177
|
+
return () => false;
|
|
178
|
+
}
|
|
179
|
+
return (iface, channel, datapoint) => {
|
|
180
|
+
const name = `${iface}.${channel}.${datapoint}`;
|
|
181
|
+
return list.some((re) => re.test(name));
|
|
182
|
+
};
|
|
183
|
+
}
|