hm2mqtt 3.1.0 → 3.2.1
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 +37 -6
- package/config.js +10 -0
- package/index.js +75 -1
- package/lib/compare.js +32 -5
- package/lib/hadiscovery.js +692 -0
- package/lib/roles.js +183 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -35,12 +35,12 @@ state directory; broker credentials can live in the shared `/etc/mqtt-interfaces
|
|
|
35
35
|
`--uninstall -n hm` removes the instance. `--config-schema` prints a JSON schema of all options
|
|
36
36
|
(management UIs like [she](https://github.com/hobbyquaker/she) build their config forms from it).
|
|
37
37
|
|
|
38
|
-
Docker (
|
|
38
|
+
Docker (multi-arch image for amd64, arm64 and armv7):
|
|
39
39
|
|
|
40
40
|
```
|
|
41
|
-
docker
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
docker run -d --name hm2mqtt --restart unless-stopped --network host -v hm2mqtt:/data \
|
|
42
|
+
-e HM2MQTT_CCU_ADDRESS=homematic-ccu3 -e HM2MQTT_MQTT_URL=mqtt://broker \
|
|
43
|
+
ghcr.io/hobbyquaker/hm2mqtt.js
|
|
44
44
|
```
|
|
45
45
|
|
|
46
46
|
Host networking, or publish 2126/2127 and set `HM2MQTT_INIT_ADDRESS` to the docker host's address
|
|
@@ -71,6 +71,8 @@ so the CCU can call back. `--restart unless-stopped` brings it back after `maint
|
|
|
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
|
|
|
@@ -179,8 +181,37 @@ address as well.
|
|
|
179
181
|
|
|
180
182
|
## Home Assistant
|
|
181
183
|
|
|
182
|
-
|
|
183
|
-
|
|
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.
|
|
184
215
|
|
|
185
216
|
## Migration
|
|
186
217
|
|
package/config.js
CHANGED
|
@@ -95,6 +95,16 @@ export const OPTIONS = {
|
|
|
95
95
|
default: true,
|
|
96
96
|
},
|
|
97
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
|
+
},
|
|
98
108
|
'rpc-topics': {
|
|
99
109
|
type: 'boolean',
|
|
100
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
|
|
|
@@ -92,10 +94,26 @@ const withHm = payloadFormat === 'mqsh-extended';
|
|
|
92
94
|
/** the value as published: plain format uses node-red's 0/1 for booleans */
|
|
93
95
|
const outValue = (value) => (payloadFormat === 'plain' ? plainValue(value) : value);
|
|
94
96
|
|
|
97
|
+
const ignored = compileIgnore(config.ignore);
|
|
98
|
+
|
|
95
99
|
const adapter = createAdapter({
|
|
96
100
|
pkg,
|
|
97
101
|
config,
|
|
98
102
|
deviceLabel: 'ccu',
|
|
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
|
+
}),
|
|
99
117
|
info: () => ({
|
|
100
118
|
ccu: host,
|
|
101
119
|
interfaces: enabled,
|
|
@@ -215,6 +233,8 @@ function rebuildIndex() {
|
|
|
215
233
|
);
|
|
216
234
|
}
|
|
217
235
|
log.debug('item index rebuilt:', itemIndex.size, 'items');
|
|
236
|
+
adapter.markDiscoveryDirty();
|
|
237
|
+
adapter.publishDiscovery();
|
|
218
238
|
}
|
|
219
239
|
|
|
220
240
|
function scheduleIndex() {
|
|
@@ -363,9 +383,47 @@ function connectionOf(address) {
|
|
|
363
383
|
return {iface, conn};
|
|
364
384
|
}
|
|
365
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
|
+
|
|
366
398
|
async function setValue(address, datapoint, value) {
|
|
367
399
|
const {iface, conn} = connectionOf(address);
|
|
368
|
-
|
|
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
|
+
}
|
|
369
427
|
if (!isWriteable(description)) {
|
|
370
428
|
throw new Error(`${address} ${datapoint} is not writeable`);
|
|
371
429
|
}
|
|
@@ -502,6 +560,9 @@ function onEvent(iface, event) {
|
|
|
502
560
|
regaSync.poll().catch((err) => log.warn('rega poll failed:', err.message));
|
|
503
561
|
}
|
|
504
562
|
values.event({iface, ...event}, (message) => {
|
|
563
|
+
if (ignored(iface, event.channel, event.datapoint)) {
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
505
566
|
const description = metadata.valueDescription(iface, event.channel, event.datapoint);
|
|
506
567
|
if (!metadata.device(iface, event.channel)) {
|
|
507
568
|
log.debug(iface, 'event of unknown channel', event.channel);
|
|
@@ -509,6 +570,19 @@ function onEvent(iface, event) {
|
|
|
509
570
|
log.debug(iface, 'no description for', event.channel, event.datapoint);
|
|
510
571
|
}
|
|
511
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
|
+
}
|
|
512
586
|
const notWorking = values.notWorking(message);
|
|
513
587
|
if (notWorking) {
|
|
514
588
|
publishMessage(notWorking);
|
package/lib/compare.js
CHANGED
|
@@ -39,12 +39,14 @@ function same(a, b) {
|
|
|
39
39
|
/**
|
|
40
40
|
* @param {Map<string, string>} left item → raw payload (the reference, e.g. the flow)
|
|
41
41
|
* @param {Map<string, string>} right item → raw payload (hm2mqtt)
|
|
42
|
-
* @returns {{leftOnly: string[], rightOnly: string[], differences: Array<{item: string, field: string, left: *, right: *}>,
|
|
42
|
+
* @returns {{leftOnly: string[], rightOnly: string[], differences: Array<{item: string, field: string, left: *, right: *}>,
|
|
43
|
+
* additions: Array<{item: string, field: string, right: *}>, same: number}}
|
|
43
44
|
*/
|
|
44
45
|
export function compareTrees(left, right) {
|
|
45
46
|
const leftOnly = [];
|
|
46
47
|
const rightOnly = [];
|
|
47
48
|
const differences = [];
|
|
49
|
+
const additions = [];
|
|
48
50
|
let sameCount = 0;
|
|
49
51
|
for (const item of left.keys()) {
|
|
50
52
|
if (!right.has(item)) {
|
|
@@ -73,7 +75,11 @@ export function compareTrees(left, right) {
|
|
|
73
75
|
if (IGNORED_HM_FIELDS.has(field)) {
|
|
74
76
|
continue;
|
|
75
77
|
}
|
|
76
|
-
if (
|
|
78
|
+
if (hl[field] === undefined && hr[field] !== undefined) {
|
|
79
|
+
// a field the reference never had: an addition, not a difference
|
|
80
|
+
if (!ADDED_HM_FIELDS.has(field)) {
|
|
81
|
+
additions.push({item, field: 'hm.' + field, right: hr[field]});
|
|
82
|
+
}
|
|
77
83
|
continue;
|
|
78
84
|
}
|
|
79
85
|
if (!same(hl[field], hr[field])) {
|
|
@@ -87,17 +93,38 @@ export function compareTrees(left, right) {
|
|
|
87
93
|
}
|
|
88
94
|
leftOnly.sort();
|
|
89
95
|
rightOnly.sort();
|
|
90
|
-
return {leftOnly, rightOnly, differences, same: sameCount};
|
|
96
|
+
return {leftOnly, rightOnly, differences, additions, same: sameCount};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function countBy(list, key) {
|
|
100
|
+
const counts = new Map();
|
|
101
|
+
for (const entry of list) {
|
|
102
|
+
counts.set(entry[key], (counts.get(entry[key]) || 0) + 1);
|
|
103
|
+
}
|
|
104
|
+
return [...counts].sort((a, b) => b[1] - a[1]);
|
|
91
105
|
}
|
|
92
106
|
|
|
93
107
|
export function formatReport(
|
|
94
|
-
{leftOnly, rightOnly, differences, same},
|
|
108
|
+
{leftOnly, rightOnly, differences, additions = [], same},
|
|
95
109
|
{leftName = 'left', rightName = 'right', limit = 50} = {},
|
|
96
110
|
) {
|
|
97
111
|
const lines = [];
|
|
112
|
+
const differing = new Set(differences.map((d) => d.item)).size;
|
|
98
113
|
lines.push(
|
|
99
|
-
`${same} items identical, ${differences.length} differences, ${leftOnly.length} only in ${leftName}, ${rightOnly.length} only in ${rightName}`,
|
|
114
|
+
`${same} items identical, ${differing} items with ${differences.length} differences, ${additions.length} added fields, ${leftOnly.length} only in ${leftName}, ${rightOnly.length} only in ${rightName}`,
|
|
100
115
|
);
|
|
116
|
+
if (differences.length > 0) {
|
|
117
|
+
lines.push('', 'differences by field:');
|
|
118
|
+
for (const [field, n] of countBy(differences, 'field')) {
|
|
119
|
+
lines.push(` ${field}: ${n}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (additions.length > 0) {
|
|
123
|
+
lines.push('', `fields only in ${rightName} (additions):`);
|
|
124
|
+
for (const [field, n] of countBy(additions, 'field')) {
|
|
125
|
+
lines.push(` ${field}: ${n}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
101
128
|
const list = (title, items) => {
|
|
102
129
|
if (items.length === 0) {
|
|
103
130
|
return;
|
|
@@ -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
|
+
}
|