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 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
- | `--hm-payload` / `--no-hm-payload` | on | the `hm` meta data block in status payloads ([Payloads](#payloads)) |
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). `--no-hm-payload` leaves `{val, ts, lc}`,
169
- `--no-json-payloads` publishes the bare value. `--plain-tree state` mirrors every item to
170
- `<name>/state/…` with plain values and booleans as `0`/`1`.
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
- The core's discovery options (`--ha-discovery`, `--ha-prefix`) exist, but 3.0 does not announce
182
- devices yet — channel-type to entity mapping is planned for 3.1 (ROADMAP §10).
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
- 'hm-payload': {type: 'boolean', describe: 'add the "hm" meta data block to every status payload', default: true},
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
- info: () => ({ccu: host, interfaces: enabled, devices: metadata.count(), rega: Boolean(regaSync)}),
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 = config.hmPayload ? {hm: hmBlock(message)} : undefined;
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 = config.hmPayload ? {hm: hmBlock(message)} : undefined;
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
- const description = metadata.valueDescription(iface, address, datapoint);
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(/�|&#176;/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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hm2mqtt",
3
- "version": "3.0.0",
3
+ "version": "3.2.0",
4
4
  "description": "Interface between Homematic CCU and MQTT",
5
5
  "type": "module",
6
6
  "main": "index.js",