hm2mqtt 3.1.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
@@ -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
- The core's discovery options (`--ha-discovery`, `--ha-prefix`) exist, but 3.0 does not announce
183
- 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.
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
- 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
+ }
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);
@@ -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.1.0",
3
+ "version": "3.2.0",
4
4
  "description": "Interface between Homematic CCU and MQTT",
5
5
  "type": "module",
6
6
  "main": "index.js",