node-red-contrib-ccu 4.1.0 → 4.3.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.
Files changed (51) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/HANDOFF.md +67 -16
  3. package/README.md +42 -0
  4. package/ROADMAP.md +129 -47
  5. package/examples/home-assistant.json +127 -0
  6. package/nodes/ccu-alexa.html +3 -1
  7. package/nodes/ccu-connection.html +26 -0
  8. package/nodes/ccu-connection.js +124 -13
  9. package/nodes/ccu-display.js +29 -13
  10. package/nodes/ccu-get-value.html +3 -1
  11. package/nodes/ccu-homeassistant.html +334 -0
  12. package/nodes/ccu-homeassistant.js +272 -0
  13. package/nodes/ccu-mqtt.html +21 -4
  14. package/nodes/ccu-mqtt.js +188 -105
  15. package/nodes/ccu-rpc-event.html +6 -0
  16. package/nodes/ccu-rpc-event.js +31 -12
  17. package/nodes/ccu-set-value.html +38 -0
  18. package/nodes/ccu-set-value.js +59 -235
  19. package/nodes/ccu-signal.html +55 -1
  20. package/nodes/ccu-signal.js +52 -9
  21. package/nodes/ccu-switch.html +17 -4
  22. package/nodes/ccu-switch.js +28 -4
  23. package/nodes/ccu-sysvar.html +40 -1
  24. package/nodes/ccu-sysvar.js +78 -8
  25. package/nodes/ccu-value.html +27 -1
  26. package/nodes/ccu-value.js +69 -19
  27. package/nodes/lib/cast.js +59 -14
  28. package/nodes/lib/channelfilter.js +136 -0
  29. package/nodes/lib/dynconfig.js +81 -0
  30. package/nodes/lib/hadiscovery.js +846 -0
  31. package/nodes/lib/haroles.js +193 -0
  32. package/nodes/lib/localccu.js +81 -0
  33. package/nodes/lib/message.js +6 -2
  34. package/nodes/lib/valuestatus.js +145 -0
  35. package/nodes/locales/de/ccu-display.html +17 -0
  36. package/nodes/locales/de/ccu-rpc-event.json +1 -0
  37. package/nodes/locales/de/ccu-sysvar.json +3 -2
  38. package/nodes/locales/de/ccu-value.json +2 -1
  39. package/nodes/locales/en-US/ccu-display.html +17 -0
  40. package/nodes/locales/en-US/ccu-rpc-event.json +3 -2
  41. package/nodes/locales/en-US/ccu-switch.html +12 -0
  42. package/nodes/locales/en-US/ccu-sysvar.json +3 -2
  43. package/nodes/locales/en-US/ccu-value.json +2 -1
  44. package/package.json +2 -1
  45. package/patch-certainonly.py +51 -0
  46. package/roadmap-archive/B-16.md +31 -0
  47. package/roadmap-archive/B-2.md +69 -0
  48. package/roadmap-archive/B-3.md +57 -0
  49. package/roadmap-archive/B-4.md +44 -0
  50. package/roadmap-archive/B-5.md +57 -0
  51. package/roadmap-archive/README.md +11 -0
@@ -0,0 +1,846 @@
1
+ /* Home Assistant MQTT discovery, device-based (HA >= 2024.11):
2
+ https://www.home-assistant.io/integrations/mqtt/#device-discovery-payload
3
+
4
+ Ported from hm2mqtt.js lib/hadiscovery.js (+ the scaffold helpers of
5
+ mqtt-interfaces-core) for the ccu-homeassistant node (B-16). One HA device
6
+ per Homematic device, composite entities from the channel roles (light,
7
+ cover, climate, lock, event, switch, ...) and generic entities for every
8
+ other datapoint (disabled by default).
9
+
10
+ Pure: devices, descriptions, names and topic builders in, discovery
11
+ messages out. The topics are whole strings rendered by the caller from the
12
+ ccu-mqtt node's templates — a controller has to be told where the values
13
+ actually are, not where a convention would put them. */
14
+
15
+ const {channelRole, haUnit, isFraction, PARAMETERS} = require('./haroles.js');
16
+
17
+ const TILT_TYPES = /BLIND/;
18
+
19
+ /** Platforms without a state topic. */
20
+ const STATELESS = new Set(['button', 'notify', 'scene', 'tag']);
21
+
22
+ /** device classes that only binary_sensor knows — a sensor/number with one of them is rejected by HA */
23
+ const BINARY_ONLY_CLASSES = new Set([
24
+ 'running',
25
+ 'update',
26
+ 'problem',
27
+ 'tamper',
28
+ 'connectivity',
29
+ 'motion',
30
+ 'occupancy',
31
+ 'safety',
32
+ 'opening',
33
+ 'window',
34
+ 'door',
35
+ 'smoke',
36
+ 'plug',
37
+ 'light',
38
+ 'lock',
39
+ 'presence',
40
+ 'vibration',
41
+ 'sound',
42
+ ]);
43
+
44
+ /**
45
+ * @typedef {object} DiscoveryContext
46
+ * @property {string} [prefix] discovery prefix (default homeassistant)
47
+ * @property {{name: string, sw: string, url?: string}} origin the `o` block (package name/version/url)
48
+ * @property {boolean} [jsonPayloads] status payloads are {val, ts, lc} (ccu-mqtt mqsh-*), false = plain
49
+ * @property {boolean} [generic] generic entities for datapoints without a role (default true)
50
+ * @property {Object<string, Object<string, object>>} devices metadata.devices (iface → address → device)
51
+ * @property {string[]} selected device addresses to publish
52
+ * @property {(iface: string, address: string) => object | undefined} description VALUES description of a channel
53
+ * @property {(address: string) => string | undefined} channelName
54
+ * @property {(address: string) => string[] | undefined} [rooms]
55
+ * @property {(iface: string, address: string, datapoint: string) => string} statusTopicFor
56
+ * @property {(iface: string, address: string, datapoint: string) => string} setTopicFor
57
+ */
58
+
59
+ /** Stable device/unique-id base: ccu_<address>, sanitised. */
60
+ function discoveryId(address) {
61
+ return 'ccu_' + String(address).replace(/[^a-zA-Z0-9_-]/g, '_');
62
+ }
63
+
64
+ function discoveryTopic(prefix, id) {
65
+ return (prefix || 'homeassistant') + '/device/' + id + '/config';
66
+ }
67
+
68
+ function templates(json) {
69
+ const v = json ? 'value_json.val' : 'value';
70
+ return {
71
+ v,
72
+ num: `{{ ${v} }}`,
73
+ bool: (on = 'ON', off = 'OFF') =>
74
+ json
75
+ ? `{{ '${on}' if ${v} else '${off}' }}`
76
+ : `{{ '${on}' if (${v} | string | lower) in ('1', 'true', 'on') else '${off}' }}`,
77
+ percent: `{{ ((${v} | float(0)) * 100) | round }}`,
78
+ int: `${v} | int(0)`,
79
+ float: `${v} | float(0)`,
80
+ };
81
+ }
82
+
83
+ function labelOf(channel, deviceName, fallback) {
84
+ const name = channel.name;
85
+ if (!name) {
86
+ return fallback;
87
+ }
88
+
89
+ if (deviceName && name !== deviceName && name.startsWith(deviceName)) {
90
+ const rest = name.slice(deviceName.length).replace(/^[\s:.-]+/, '');
91
+ return rest || fallback;
92
+ }
93
+
94
+ if (name === deviceName) {
95
+ return fallback;
96
+ }
97
+
98
+ return name;
99
+ }
100
+
101
+ function prettify(dp) {
102
+ return dp
103
+ .toLowerCase()
104
+ .split('_')
105
+ .map((w) => (w ? w[0].toUpperCase() + w.slice(1) : w))
106
+ .join(' ');
107
+ }
108
+
109
+ /** drops undefined values so `extra: {stat_t: undefined}` removes a default */
110
+ function compact(object) {
111
+ const out = {};
112
+ for (const [k, v] of Object.entries(object)) {
113
+ if (v !== undefined) {
114
+ out[k] = v;
115
+ }
116
+ }
117
+
118
+ return out;
119
+ }
120
+
121
+ /**
122
+ * Finds the interface a device address belongs to.
123
+ * @param {Object<string, Object<string, object>>} devices
124
+ * @param {string} address
125
+ * @returns {string | undefined}
126
+ */
127
+ function ifaceOf(devices, address) {
128
+ return Object.keys(devices || {}).find((iface) => devices[iface] && devices[iface][address]);
129
+ }
130
+
131
+ /**
132
+ * HmIP actuators: a *_TRANSMITTER channel (state) followed by three *_VIRTUAL_RECEIVER channels
133
+ * (control). The first receiver becomes the control entity with the transmitter's state topic,
134
+ * the others are secondary (disabled by default).
135
+ */
136
+ function resolveVirtualReceivers(channels) {
137
+ const families = [
138
+ {transmitter: 'switch_state', receiver: 'switch'},
139
+ {transmitter: 'dimmer_state', receiver: 'dimmer'},
140
+ {transmitter: 'cover_state', receiver: 'cover'},
141
+ ];
142
+ for (const {transmitter, receiver} of families) {
143
+ let current = null;
144
+ let count = 0;
145
+ for (const ch of channels) {
146
+ if (ch.role === transmitter && /_TRANSMITTER$/.test(ch.TYPE)) {
147
+ current = ch;
148
+ count = 0;
149
+ continue;
150
+ }
151
+
152
+ if (ch.role === receiver && /_VIRTUAL_RECEIVER$/.test(ch.TYPE) && current) {
153
+ count += 1;
154
+ ch.stateChannel = current;
155
+ if (count === 1) {
156
+ current.transmitterFor = ch;
157
+ } else {
158
+ ch.secondary = true;
159
+ }
160
+ } else if (ch.role === receiver && /_VIRTUAL_RECEIVER$/.test(ch.TYPE)) {
161
+ // standalone receiver blocks without a transmitter: first of three is primary
162
+ count += 1;
163
+ if (count > 1 && (count - 1) % 3 !== 0) {
164
+ ch.secondary = true;
165
+ }
166
+ }
167
+ }
168
+ }
169
+
170
+ // HM virtual dimmers (HM-LC-Dim1TPBU-FM ch 2/3) are secondaries of channel 1
171
+ for (const ch of channels) {
172
+ if (ch.TYPE === 'VIRTUAL_DIMMER') {
173
+ ch.secondary = true;
174
+ }
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Builds the discovery block of one device.
180
+ * @param {DiscoveryContext} ctx
181
+ * @param {string} iface
182
+ * @param {object} device device metadata (ADDRESS, TYPE, CHILDREN, FIRMWARE)
183
+ * @returns {{id: string, device: object, components: object, availability: object[]} | null}
184
+ * null when the device yields no entity
185
+ */
186
+ function deviceBlock(ctx, iface, device) {
187
+ const json = ctx.jsonPayloads !== false;
188
+ const generic = ctx.generic !== false;
189
+ const t = templates(json);
190
+ const list = ctx.devices[iface];
191
+ const st = ctx.statusTopicFor;
192
+ const cmd = ctx.setTopicFor;
193
+ const roomsOf = ctx.rooms || (() => []);
194
+
195
+ const id = discoveryId(device.ADDRESS);
196
+ const deviceName = ctx.channelName(device.ADDRESS) || device.ADDRESS;
197
+ const channels = (device.CHILDREN || [])
198
+ .map((address) => list[address])
199
+ .filter(Boolean)
200
+ .map((ch) => ({
201
+ ...ch,
202
+ index: Number.parseInt(String(ch.ADDRESS).split(':')[1], 10),
203
+ name: ctx.channelName(ch.ADDRESS),
204
+ description: ctx.description(iface, ch.ADDRESS) || {},
205
+ }))
206
+ .sort((a, b) => a.index - b.index);
207
+ for (const ch of channels) {
208
+ ch.role = channelRole(ch.TYPE, ch.description);
209
+ }
210
+
211
+ resolveVirtualReceivers(channels);
212
+
213
+ const components = {};
214
+ const add = (key, e) => {
215
+ components[key] = e;
216
+ };
217
+
218
+ const rooms = new Set();
219
+ for (const ch of channels) {
220
+ for (const r of roomsOf(ch.ADDRESS) || []) {
221
+ rooms.add(r);
222
+ }
223
+ }
224
+
225
+ const maintenance = channels.find((ch) => ch.index === 0);
226
+
227
+ for (const ch of channels) {
228
+ const consumed = new Set();
229
+ const stateCh = ch.stateChannel || ch;
230
+ const sst = (dp) => st(iface, stateCh.ADDRESS, dp);
231
+ const own = (dp) => st(iface, ch.ADDRESS, dp);
232
+ const scmd = (dp) => cmd(iface, ch.ADDRESS, dp);
233
+
234
+ /** one entity: common fields + the platform specific `extra` (which may override) */
235
+ const e = (dp, platform, label, more = {}) => {
236
+ const stateless = STATELESS.has(platform);
237
+ const uid = more.uid || `${ch.index}_${dp}`;
238
+ return compact({
239
+ p: platform,
240
+ uniq_id: `${id}_${uid}`,
241
+ name: label,
242
+ ...(!stateless && {stat_t: own(dp)}),
243
+ ...(!stateless && json && {val_tpl: '{{ value_json.val }}'}),
244
+ ...(more.command && {cmd_t: scmd(dp)}),
245
+ ...(more.extra || {}),
246
+ });
247
+ };
248
+
249
+ const fallback = ch.index === 0 ? 'Maintenance' : `Channel ${ch.index}`;
250
+ const label = labelOf(ch, deviceName, fallback);
251
+ const has = (dp) => Boolean(ch.description[dp]);
252
+ const key = (dp) => `${ch.index}_${dp}`;
253
+ const disabled = ch.secondary ? {en: false} : {};
254
+
255
+ /** key presses: one event entity per channel, fed by the aggregate <channel>/PRESS item
256
+ that the ccu-homeassistant node publishes */
257
+ const pressEvent = () => {
258
+ const types = ['PRESS_SHORT', 'PRESS_LONG', 'PRESS_LONG_RELEASE', 'PRESS_CONT'].filter((dp) => has(dp));
259
+ if (types.length === 0) {
260
+ return;
261
+ }
262
+
263
+ add(
264
+ key('PRESS'),
265
+ e('PRESS', 'event', label, {
266
+ extra: {
267
+ dev_cla: 'button',
268
+ evt_typ: types.map((x) => x.toLowerCase()),
269
+ val_tpl: `{{ {'event_type': (${t.v} | string | lower)} | tojson }}`,
270
+ },
271
+ }),
272
+ );
273
+ for (const dp of types) {
274
+ consumed.add(dp);
275
+ }
276
+ };
277
+
278
+ // HA compares the templated state with state_on/state_off, which default to the
279
+ // command payloads — say explicitly that the template yields ON/OFF
280
+ const onOff = {pl_on: 'true', pl_off: 'false', stat_on: 'ON', stat_off: 'OFF'};
281
+
282
+ switch (ch.role) {
283
+ case 'switch':
284
+ if (has('STATE')) {
285
+ add(
286
+ key('STATE'),
287
+ e('STATE', 'switch', label, {
288
+ command: true,
289
+ extra: {
290
+ stat_t: sst('STATE'),
291
+ val_tpl: t.bool(),
292
+ ...onOff,
293
+ ...disabled,
294
+ },
295
+ }),
296
+ );
297
+ consumed.add('STATE');
298
+ }
299
+
300
+ break;
301
+ case 'switch_state':
302
+ if (has('STATE') && !ch.transmitterFor) {
303
+ add(key('STATE'), e('STATE', 'binary_sensor', label, {extra: {val_tpl: t.bool(), en: false}}));
304
+ consumed.add('STATE');
305
+ }
306
+
307
+ if (ch.transmitterFor) {
308
+ consumed.add('STATE');
309
+ }
310
+
311
+ break;
312
+ case 'dimmer':
313
+ if (has('LEVEL')) {
314
+ add(
315
+ key('LEVEL'),
316
+ e('LEVEL', 'light', label, {
317
+ command: true,
318
+ extra: {
319
+ stat_t: sst('LEVEL'),
320
+ val_tpl: undefined,
321
+ stat_val_tpl: `{{ 'ON' if (${t.float}) > 0 else 'OFF' }}`,
322
+ pl_on: 'ON',
323
+ pl_off: 'OFF',
324
+ on_cmd_type: 'brightness',
325
+ bri_cmd_t: scmd('LEVEL'),
326
+ bri_cmd_tpl: '{{ (value / 100) | round(3) }}',
327
+ bri_scl: 100,
328
+ bri_stat_t: sst('LEVEL'),
329
+ bri_val_tpl: t.percent,
330
+ ...disabled,
331
+ },
332
+ }),
333
+ );
334
+ consumed.add('LEVEL');
335
+ }
336
+
337
+ break;
338
+ case 'dimmer_state':
339
+ if (ch.transmitterFor) {
340
+ consumed.add('LEVEL');
341
+ }
342
+
343
+ break;
344
+ case 'cover': {
345
+ if (has('LEVEL')) {
346
+ const tilt = has('LEVEL_2');
347
+ const direction = stateCh.description.ACTIVITY_STATE
348
+ ? 'ACTIVITY_STATE'
349
+ : stateCh.description.DIRECTION
350
+ ? 'DIRECTION'
351
+ : null;
352
+ add(
353
+ key('LEVEL'),
354
+ e('LEVEL', 'cover', label, {
355
+ command: true,
356
+ extra: {
357
+ stat_t: undefined,
358
+ val_tpl: undefined,
359
+ dev_cla: TILT_TYPES.test(ch.TYPE) ? 'blind' : 'shutter',
360
+ pl_open: 'OPEN',
361
+ pl_cls: 'CLOSE',
362
+ pl_stop: 'STOP',
363
+ pos_t: sst('LEVEL'),
364
+ pos_tpl: t.percent,
365
+ set_pos_t: scmd('LEVEL'),
366
+ set_pos_tpl: '{{ (position / 100) | round(3) }}',
367
+ pos_open: 100,
368
+ pos_clsd: 0,
369
+ ...(direction && {
370
+ stat_t: sst(direction),
371
+ val_tpl: `{% set d = ${t.int} %}{{ 'opening' if d == 1 else 'closing' if d == 2 else 'stopped' }}`,
372
+ stat_opening: 'opening',
373
+ stat_closing: 'closing',
374
+ stat_stopped: 'stopped',
375
+ }),
376
+ ...(tilt && {
377
+ tilt_cmd_t: scmd('LEVEL_2'),
378
+ tilt_cmd_tpl: '{{ (tilt_position / 100) | round(3) }}',
379
+ tilt_status_t: sst('LEVEL_2'),
380
+ tilt_status_tpl: t.percent,
381
+ }),
382
+ ...disabled,
383
+ },
384
+ }),
385
+ );
386
+ for (const dp of ['LEVEL', 'LEVEL_2', 'STOP']) {
387
+ consumed.add(dp);
388
+ }
389
+ }
390
+
391
+ break;
392
+ }
393
+
394
+ case 'cover_state':
395
+ if (ch.transmitterFor) {
396
+ for (const dp of ['LEVEL', 'LEVEL_2', 'ACTIVITY_STATE', 'DIRECTION']) {
397
+ consumed.add(dp);
398
+ }
399
+ }
400
+
401
+ break;
402
+ case 'contact':
403
+ if (has('STATE')) {
404
+ add(
405
+ key('STATE'),
406
+ e('STATE', 'binary_sensor', label, {
407
+ extra: {
408
+ dev_cla:
409
+ ch.TYPE === 'MULTI_MODE_INPUT_TRANSMITTER'
410
+ ? 'opening'
411
+ : ch.TYPE === 'TILT_SENSOR'
412
+ ? 'moving'
413
+ : 'window',
414
+ val_tpl: t.bool(),
415
+ },
416
+ }),
417
+ );
418
+ consumed.add('STATE');
419
+ }
420
+
421
+ // HmIP input channels (FCI, DRI16/32, SCI) report presses as well
422
+ pressEvent();
423
+ break;
424
+ case 'rotary_handle':
425
+ if (has('STATE')) {
426
+ add(
427
+ key('STATE'),
428
+ e('STATE', 'binary_sensor', label, {
429
+ extra: {dev_cla: 'window', val_tpl: `{{ 'ON' if (${t.int}) != 0 else 'OFF' }}`},
430
+ }),
431
+ );
432
+ add(
433
+ key('STATE_text'),
434
+ e('STATE', 'sensor', `${label} handle`, {
435
+ uid: `${ch.index}_STATE_text`,
436
+ extra: {val_tpl: `{{ ['closed', 'tilted', 'open'][${t.int}] }}`, en: false},
437
+ }),
438
+ );
439
+ consumed.add('STATE');
440
+ }
441
+
442
+ break;
443
+ case 'key':
444
+ pressEvent();
445
+ break;
446
+
447
+ case 'climate_hmip':
448
+ if (has('SET_POINT_TEMPERATURE')) {
449
+ const d = ch.description;
450
+ add(
451
+ key('CLIMATE'),
452
+ e('SET_POINT_TEMPERATURE', 'climate', label, {
453
+ uid: `${ch.index}_CLIMATE`,
454
+ extra: {
455
+ stat_t: undefined,
456
+ val_tpl: undefined,
457
+ temp_unit: 'C',
458
+ temp_step: 0.5,
459
+ min_temp: d.SET_POINT_TEMPERATURE.MIN ?? 4.5,
460
+ max_temp: d.SET_POINT_TEMPERATURE.MAX ?? 30.5,
461
+ temp_stat_t: own('SET_POINT_TEMPERATURE'),
462
+ temp_stat_tpl: t.num,
463
+ temp_cmd_t: scmd('SET_POINT_TEMPERATURE'),
464
+ ...(has('ACTUAL_TEMPERATURE') && {
465
+ curr_temp_t: own('ACTUAL_TEMPERATURE'),
466
+ curr_temp_tpl: t.num,
467
+ }),
468
+ ...(has('HUMIDITY') && {curr_hum_t: own('HUMIDITY'), curr_hum_tpl: t.num}),
469
+ modes: ['auto', 'heat'],
470
+ ...(has('SET_POINT_MODE') && {
471
+ mode_stat_t: own('SET_POINT_MODE'),
472
+ mode_stat_tpl: `{{ 'auto' if (${t.int}) == 0 else 'heat' }}`,
473
+ }),
474
+ ...(has('CONTROL_MODE') && {
475
+ mode_cmd_t: scmd('CONTROL_MODE'),
476
+ mode_cmd_tpl: "{{ 0 if value == 'auto' else 1 }}",
477
+ }),
478
+ ...(has('BOOST_MODE') && {
479
+ pr_modes: ['boost'],
480
+ pr_mode_stat_t: own('BOOST_MODE'),
481
+ pr_mode_val_tpl: `{{ 'boost' if ${t.v} else 'none' }}`,
482
+ pr_mode_cmd_t: scmd('BOOST_MODE'),
483
+ pr_mode_cmd_tpl: "{{ 'true' if value == 'boost' else 'false' }}",
484
+ }),
485
+ ...(has('LEVEL') && {
486
+ act_t: own('LEVEL'),
487
+ act_tpl: `{{ 'heating' if (${t.float}) > 0 else 'idle' }}`,
488
+ }),
489
+ },
490
+ }),
491
+ );
492
+ for (const dp of [
493
+ 'SET_POINT_TEMPERATURE',
494
+ 'ACTUAL_TEMPERATURE',
495
+ 'HUMIDITY',
496
+ 'SET_POINT_MODE',
497
+ 'CONTROL_MODE',
498
+ 'BOOST_MODE',
499
+ 'LEVEL',
500
+ ]) {
501
+ consumed.add(dp);
502
+ }
503
+ }
504
+
505
+ break;
506
+ case 'climate_hm':
507
+ if (has('SET_TEMPERATURE')) {
508
+ const d = ch.description;
509
+ add(
510
+ key('CLIMATE'),
511
+ e('SET_TEMPERATURE', 'climate', label, {
512
+ uid: `${ch.index}_CLIMATE`,
513
+ extra: {
514
+ stat_t: undefined,
515
+ val_tpl: undefined,
516
+ temp_unit: 'C',
517
+ temp_step: 0.5,
518
+ min_temp: d.SET_TEMPERATURE.MIN ?? 4.5,
519
+ max_temp: d.SET_TEMPERATURE.MAX ?? 30.5,
520
+ temp_stat_t: own('SET_TEMPERATURE'),
521
+ temp_stat_tpl: t.num,
522
+ temp_cmd_t: scmd('SET_TEMPERATURE'),
523
+ ...(has('ACTUAL_TEMPERATURE') && {
524
+ curr_temp_t: own('ACTUAL_TEMPERATURE'),
525
+ curr_temp_tpl: t.num,
526
+ }),
527
+ ...(has('ACTUAL_HUMIDITY') && {
528
+ curr_hum_t: own('ACTUAL_HUMIDITY'),
529
+ curr_hum_tpl: t.num,
530
+ }),
531
+ modes: ['auto', 'heat'],
532
+ ...(has('CONTROL_MODE') && {
533
+ // CONTROL_MODE is read-only on HM thermostats; the words are
534
+ // translated to the *_MODE actions by ccu-mqtt's setValue
535
+ mode_stat_t: own('CONTROL_MODE'),
536
+ mode_stat_tpl: `{{ 'heat' if (${t.int}) == 1 else 'auto' }}`,
537
+ mode_cmd_t: scmd('CONTROL_MODE'),
538
+ mode_cmd_tpl: "{{ 'AUTO-MODE' if value == 'auto' else 'MANU-MODE' }}",
539
+ pr_modes: ['boost', 'comfort', 'eco'],
540
+ pr_mode_stat_t: own('CONTROL_MODE'),
541
+ pr_mode_val_tpl: `{{ 'boost' if (${t.int}) == 3 else 'none' }}`,
542
+ pr_mode_cmd_t: scmd('CONTROL_MODE'),
543
+ pr_mode_cmd_tpl:
544
+ "{{ {'boost': 'BOOST-MODE', 'comfort': 'COMFORT-MODE', 'eco': 'LOWERING-MODE'}.get(value, 'AUTO-MODE') }}",
545
+ }),
546
+ ...(has('VALVE_STATE') && {
547
+ act_t: own('VALVE_STATE'),
548
+ act_tpl: `{{ 'heating' if (${t.int}) > 0 else 'idle' }}`,
549
+ }),
550
+ },
551
+ }),
552
+ );
553
+ for (const dp of [
554
+ 'SET_TEMPERATURE',
555
+ 'ACTUAL_TEMPERATURE',
556
+ 'ACTUAL_HUMIDITY',
557
+ 'CONTROL_MODE',
558
+ 'BOOST_MODE',
559
+ 'AUTO_MODE',
560
+ 'MANU_MODE',
561
+ 'COMFORT_MODE',
562
+ 'LOWERING_MODE',
563
+ ]) {
564
+ consumed.add(dp);
565
+ }
566
+ }
567
+
568
+ break;
569
+ case 'lock':
570
+ if (has('STATE')) {
571
+ add(
572
+ key('STATE'),
573
+ e('STATE', 'lock', label, {
574
+ command: true,
575
+ extra: {
576
+ val_tpl: t.bool('UNLOCKED', 'LOCKED'),
577
+ stat_locked: 'LOCKED',
578
+ stat_unlocked: 'UNLOCKED',
579
+ pl_lock: 'false',
580
+ pl_unlk: 'true',
581
+ },
582
+ }),
583
+ );
584
+ consumed.add('STATE');
585
+ }
586
+
587
+ break;
588
+ case 'smoke':
589
+ if (has('SMOKE_DETECTOR_ALARM_STATUS')) {
590
+ add(
591
+ key('SMOKE'),
592
+ e('SMOKE_DETECTOR_ALARM_STATUS', 'binary_sensor', label, {
593
+ uid: `${ch.index}_SMOKE`,
594
+ extra: {dev_cla: 'smoke', val_tpl: `{{ 'ON' if (${t.int}) in (1, 3) else 'OFF' }}`},
595
+ }),
596
+ );
597
+ consumed.add('SMOKE_DETECTOR_ALARM_STATUS');
598
+ } else if (has('STATE')) {
599
+ add(
600
+ key('STATE'),
601
+ e('STATE', 'binary_sensor', label, {extra: {dev_cla: 'smoke', val_tpl: t.bool()}}),
602
+ );
603
+ consumed.add('STATE');
604
+ }
605
+
606
+ break;
607
+ case 'water':
608
+ for (const dp of ['STATE', 'ALARMSTATE', 'MOISTURE_DETECTED', 'WATERLEVEL_DETECTED']) {
609
+ if (has(dp)) {
610
+ add(
611
+ key(dp),
612
+ e(
613
+ dp,
614
+ 'binary_sensor',
615
+ dp === 'STATE' ? label : `${label} ${dp.toLowerCase().replace(/_/g, ' ')}`,
616
+ {extra: {dev_cla: 'moisture', val_tpl: t.bool()}},
617
+ ),
618
+ );
619
+ consumed.add(dp);
620
+ }
621
+ }
622
+
623
+ break;
624
+ default:
625
+ break;
626
+ }
627
+
628
+ // generic layer: everything else in the channel's VALUES description
629
+ for (const [dp, d] of Object.entries(ch.description)) {
630
+ if (consumed.has(dp) || !d || typeof d !== 'object') {
631
+ continue;
632
+ }
633
+
634
+ const facts = PARAMETERS[dp] || {};
635
+ const semantic = Boolean(facts.dev_cla || facts.enabled);
636
+ if (!generic && !semantic) {
637
+ continue;
638
+ }
639
+
640
+ const ops = typeof d.OPERATIONS === 'number' ? d.OPERATIONS : 5;
641
+ const readable = Boolean(ops & 1) || Boolean(ops & 4);
642
+ const writable = Boolean(ops & 2);
643
+ const dpLabel = ch.index === 0 && ch.role === 'maintenance' ? prettify(dp) : `${label} ${prettify(dp)}`;
644
+ const enabled = facts.enabled === true;
645
+ const common = {
646
+ ...(facts.dev_cla && {dev_cla: facts.dev_cla}),
647
+ ...(facts.ent_cat && {ent_cat: facts.ent_cat}),
648
+ ...(!enabled && {en: false}),
649
+ };
650
+ // sensor/number accept only their own device classes, select none at all
651
+ const sensorCommon = BINARY_ONLY_CLASSES.has(common.dev_cla) ? {...common, dev_cla: undefined} : common;
652
+ const selectCommon = {...common, dev_cla: undefined};
653
+ const unit = facts.unit || haUnit(d.UNIT);
654
+ const value = isFraction(d) ? t.percent : t.num;
655
+ if (d.TYPE === 'BOOL' || d.TYPE === 'ACTION') {
656
+ if (writable && !readable) {
657
+ if (d.TYPE === 'ACTION') {
658
+ add(key(dp), e(dp, 'button', dpLabel, {command: true, extra: {pl_prs: 'true', ...common}}));
659
+ }
660
+
661
+ continue;
662
+ }
663
+
664
+ if (writable && d.TYPE === 'BOOL') {
665
+ add(
666
+ key(dp),
667
+ e(dp, 'switch', dpLabel, {
668
+ command: true,
669
+ extra: {val_tpl: t.bool(), ...onOff, ...common},
670
+ }),
671
+ );
672
+ } else if (d.TYPE === 'BOOL') {
673
+ add(
674
+ key(dp),
675
+ e(dp, 'binary_sensor', dpLabel, {
676
+ extra: {val_tpl: facts.inverted ? t.bool('OFF', 'ON') : t.bool(), ...common},
677
+ }),
678
+ );
679
+ }
680
+
681
+ continue;
682
+ }
683
+
684
+ if (d.TYPE === 'ENUM' && Array.isArray(d.VALUE_LIST)) {
685
+ if (writable) {
686
+ add(
687
+ key(dp),
688
+ e(dp, 'select', dpLabel, {
689
+ command: true,
690
+ extra: {
691
+ options: d.VALUE_LIST,
692
+ val_tpl: `{{ ${JSON.stringify(d.VALUE_LIST)}[${t.int}] }}`,
693
+ cmd_tpl: `{{ ${JSON.stringify(d.VALUE_LIST)}.index(value) }}`,
694
+ ...selectCommon,
695
+ },
696
+ }),
697
+ );
698
+ } else if (readable) {
699
+ add(
700
+ key(dp),
701
+ e(dp, 'sensor', dpLabel, {
702
+ extra: {val_tpl: `{{ ${JSON.stringify(d.VALUE_LIST)}[${t.int}] }}`, ...sensorCommon},
703
+ }),
704
+ );
705
+ }
706
+
707
+ continue;
708
+ }
709
+
710
+ if (d.TYPE === 'FLOAT' || d.TYPE === 'INTEGER') {
711
+ if (writable && !readable) {
712
+ add(
713
+ key(dp),
714
+ e(dp, 'number', dpLabel, {
715
+ command: true,
716
+ extra: {
717
+ ...(typeof d.MIN === 'number' && {min: isFraction(d) ? d.MIN * 100 : d.MIN}),
718
+ ...(typeof d.MAX === 'number' && {
719
+ max: isFraction(d) ? Math.min(d.MAX, 1) * 100 : d.MAX,
720
+ }),
721
+ step: d.TYPE === 'INTEGER' ? 1 : isFraction(d) ? 1 : 0.1,
722
+ ...(unit && {unit_of_meas: unit}),
723
+ ...(isFraction(d) && {cmd_tpl: '{{ (value / 100) | round(3) }}'}),
724
+ ...sensorCommon,
725
+ },
726
+ }),
727
+ );
728
+ continue;
729
+ }
730
+
731
+ add(
732
+ key(dp),
733
+ e(dp, 'sensor', dpLabel, {
734
+ extra: {
735
+ val_tpl: value,
736
+ ...(unit && {unit_of_meas: unit}),
737
+ ...(facts.stat_cla && {stat_cla: facts.stat_cla}),
738
+ ...sensorCommon,
739
+ },
740
+ }),
741
+ );
742
+ continue;
743
+ }
744
+
745
+ if (d.TYPE === 'STRING' && readable) {
746
+ add(key(dp), e(dp, 'sensor', dpLabel, {extra: {val_tpl: t.num, ...sensorCommon}}));
747
+ }
748
+ }
749
+ }
750
+
751
+ if (Object.keys(components).length === 0) {
752
+ return null;
753
+ }
754
+
755
+ // availability: the device's UNREACH (retained by ccu-mqtt) — there is no
756
+ // bridge "connected" topic in the ccu-mqtt topic scheme
757
+ // (an entry of the availability list takes `val_tpl`; `avty_tpl` is only valid at
758
+ // the top level next to `avty_t` — HA rejects the whole device payload otherwise)
759
+ const availability = [];
760
+ if (maintenance && maintenance.description.UNREACH) {
761
+ availability.push({t: st(iface, maintenance.ADDRESS, 'UNREACH'), val_tpl: t.bool('offline', 'online')});
762
+ }
763
+
764
+ return {
765
+ id,
766
+ device: compact({
767
+ name: deviceName,
768
+ mf: 'eQ-3',
769
+ mdl: device.TYPE,
770
+ sw: device.FIRMWARE ? String(device.FIRMWARE) : undefined,
771
+ sa: rooms.size === 1 ? [...rooms][0] : undefined,
772
+ }),
773
+ components,
774
+ availability,
775
+ };
776
+ }
777
+
778
+ /**
779
+ * The full discovery message of one device block.
780
+ * @param {DiscoveryContext} ctx
781
+ * @param {{id: string, device: object, components: object, availability: object[]}} block
782
+ * @returns {{topic: string, payload: object}}
783
+ */
784
+ function devicePayload(ctx, block) {
785
+ const origin = ctx.origin || {name: 'node-red-contrib-ccu'};
786
+ const payload = {
787
+ dev: {ids: [block.id], ...block.device},
788
+ o: compact({name: origin.name, sw: origin.sw, url: origin.url}),
789
+ ...(block.availability.length > 0 && {avty: block.availability}),
790
+ ...(block.availability.length > 1 && {avty_mode: 'all'}),
791
+ qos: 0,
792
+ cmps: block.components,
793
+ };
794
+ return {topic: discoveryTopic(ctx.prefix, block.id), payload};
795
+ }
796
+
797
+ /**
798
+ * Empty retained config: removes the device (and all its entities) from Home Assistant.
799
+ * @param {string} prefix
800
+ * @param {string} address device address
801
+ * @returns {{topic: string, payload: string}}
802
+ */
803
+ function removalMessage(prefix, address) {
804
+ return {topic: discoveryTopic(prefix, discoveryId(address)), payload: ''};
805
+ }
806
+
807
+ /**
808
+ * Discovery messages for the selected devices.
809
+ * @param {DiscoveryContext} ctx
810
+ * @returns {{messages: Array<{topic: string, payload: object}>, missing: string[], empty: string[]}}
811
+ * missing: selected addresses not found in the metadata; empty: devices without entities
812
+ */
813
+ function discoveryMessages(ctx) {
814
+ const messages = [];
815
+ const missing = [];
816
+ const empty = [];
817
+ for (const address of ctx.selected || []) {
818
+ const iface = ifaceOf(ctx.devices, address);
819
+ const device = iface && ctx.devices[iface][address];
820
+ if (!device || device.PARENT) {
821
+ missing.push(address);
822
+ continue;
823
+ }
824
+
825
+ const block = deviceBlock(ctx, iface, device);
826
+ if (!block) {
827
+ empty.push(address);
828
+ continue;
829
+ }
830
+
831
+ messages.push(devicePayload(ctx, block));
832
+ }
833
+
834
+ return {messages, missing, empty};
835
+ }
836
+
837
+ module.exports = {
838
+ discoveryId,
839
+ discoveryTopic,
840
+ ifaceOf,
841
+ resolveVirtualReceivers,
842
+ deviceBlock,
843
+ devicePayload,
844
+ removalMessage,
845
+ discoveryMessages,
846
+ };