node-red-contrib-ccu 4.1.0 → 4.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/CHANGELOG.md +29 -0
- package/HANDOFF.md +58 -15
- package/README.md +13 -0
- package/ROADMAP.md +24 -21
- package/examples/home-assistant.json +127 -0
- package/nodes/ccu-connection.js +37 -2
- package/nodes/ccu-homeassistant.html +334 -0
- package/nodes/ccu-homeassistant.js +272 -0
- package/nodes/ccu-mqtt.js +62 -3
- package/nodes/lib/hadiscovery.js +846 -0
- package/nodes/lib/haroles.js +193 -0
- package/package.json +2 -1
- package/roadmap-archive/B-16.md +31 -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
|
+
};
|