node-red-contrib-ccu 4.5.2 → 4.5.3
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 +21 -0
- package/README.md +5 -0
- package/examples/home-assistant.json +16 -0
- package/nodes/ccu-homeassistant.js +110 -5
- package/nodes/lib/hadiscovery.js +27 -6
- package/nodes/locales/de/ccu-homeassistant.html +11 -3
- package/nodes/locales/en-US/ccu-homeassistant.html +11 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,27 @@ Notable changes to node-red-contrib-ccu. Format follows
|
|
|
5
5
|
user-visible symptom and the cause, not the commit list (the release notes
|
|
6
6
|
append commits automatically).
|
|
7
7
|
|
|
8
|
+
## 4.5.3 (2026-10-05)
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **Home Assistant showed every entity of a device as "unavailable" for
|
|
13
|
+
good** (B-38, reported by mail). The discovery configs name the device's
|
|
14
|
+
`…:0/UNREACH` topic as its availability, and Home Assistant treats an
|
|
15
|
+
entity as unavailable until that topic delivers a value. Only `ccu-mqtt`
|
|
16
|
+
published UNREACH: when it changed, or once in its start-up burst of
|
|
17
|
+
cached values - which is off unless the node's _Cache_ option is ticked,
|
|
18
|
+
and lost when the MQTT broker is not connected yet at that moment (Node-RED's
|
|
19
|
+
`mqtt out` drops what it gets while disconnected). A device that never went
|
|
20
|
+
unreachable therefore never got a value on that topic, while its state
|
|
21
|
+
updates arrived normally. The `ccu-homeassistant` node now publishes each
|
|
22
|
+
device's current UNREACH itself, retained, right after the discovery
|
|
23
|
+
configs (asking the CCU for it) and every change after that; _republish
|
|
24
|
+
discovery_ sends it again. The example flow also wires Home Assistant's
|
|
25
|
+
birth message (`homeassistant/status`) to the node, so a Home Assistant
|
|
26
|
+
restart republishes discovery and availability; an `offline` there is
|
|
27
|
+
ignored.
|
|
28
|
+
|
|
8
29
|
## 4.5.2 (2026-10-02)
|
|
9
30
|
|
|
10
31
|
### Fixed
|
package/README.md
CHANGED
|
@@ -76,6 +76,11 @@ device with switch, light, cover, climate, binary_sensor, event, lock and sensor
|
|
|
76
76
|
optionally be included as a disabled-by-default entity. Unticking a device removes it from Home Assistant on the
|
|
77
77
|
next deploy.
|
|
78
78
|
|
|
79
|
+
A device's availability in Home Assistant follows the `UNREACH` datapoint of its maintenance channel. The node
|
|
80
|
+
publishes its current value (retained) right after the discovery configs and every change after that, so a device
|
|
81
|
+
is available without ever having been unreachable. Any message on the node's input republishes both; the example
|
|
82
|
+
wires Home Assistant's birth message (`homeassistant/status`) to it, so a Home Assistant restart gets them again.
|
|
83
|
+
|
|
79
84
|
## Dynamic configuration
|
|
80
85
|
|
|
81
86
|
Most nodes can be reconfigured per message: put an object in `msg.config` and its
|
|
@@ -81,6 +81,22 @@
|
|
|
81
81
|
"y": 180,
|
|
82
82
|
"wires": [["ccu-ha-example"]]
|
|
83
83
|
},
|
|
84
|
+
{
|
|
85
|
+
"id": "mqtt-in-ha-status",
|
|
86
|
+
"type": "mqtt in",
|
|
87
|
+
"name": "homeassistant/status",
|
|
88
|
+
"topic": "homeassistant/status",
|
|
89
|
+
"qos": "0",
|
|
90
|
+
"datatype": "utf8",
|
|
91
|
+
"broker": "mqtt-broker-example",
|
|
92
|
+
"nl": false,
|
|
93
|
+
"rap": true,
|
|
94
|
+
"rh": 0,
|
|
95
|
+
"inputs": 0,
|
|
96
|
+
"x": 170,
|
|
97
|
+
"y": 220,
|
|
98
|
+
"wires": [["ccu-ha-example"]]
|
|
99
|
+
},
|
|
84
100
|
{
|
|
85
101
|
"id": "comment-ha",
|
|
86
102
|
"type": "comment",
|
|
@@ -15,8 +15,13 @@ const READY_ATTEMPTS = 60;
|
|
|
15
15
|
* Companion to a ccu-mqtt node: reuses its topic templates and payload format, so
|
|
16
16
|
* the discovery configs point at the topics ccu-mqtt already publishes/consumes.
|
|
17
17
|
* The node itself only emits (a) the retained discovery configs — wire its output
|
|
18
|
-
* to the same mqtt-out node as the ccu-mqtt node
|
|
19
|
-
* event Home Assistant's event entities need
|
|
18
|
+
* to the same mqtt-out node as the ccu-mqtt node —, (b) the aggregate PRESS
|
|
19
|
+
* event Home Assistant's event entities need and (c) the devices' UNREACH on the
|
|
20
|
+
* availability topics the configs name (B-38): Home Assistant shows an entity
|
|
21
|
+
* "unavailable" until its availability topic delivers a value, and ccu-mqtt
|
|
22
|
+
* publishes UNREACH only when it changes or in its start-up cache burst (which
|
|
23
|
+
* is off by default and lost when the broker is not connected yet), so a device
|
|
24
|
+
* that never went unreachable would stay unavailable for good.
|
|
20
25
|
*/
|
|
21
26
|
module.exports = function (RED) {
|
|
22
27
|
class CcuHomeAssistantNode {
|
|
@@ -43,9 +48,18 @@ module.exports = function (RED) {
|
|
|
43
48
|
|
|
44
49
|
this.ccu.register(this);
|
|
45
50
|
|
|
51
|
+
/** datapointName → {iface, channel, datapoint, topic} of the published availability topics */
|
|
52
|
+
this.availability = new Map();
|
|
53
|
+
/** datapointName → the UNREACH value last published on its topic */
|
|
54
|
+
this.availabilitySent = new Map();
|
|
55
|
+
|
|
46
56
|
this.on('input', (message, send, done) => {
|
|
47
|
-
// any message republishes the discovery configs
|
|
48
|
-
|
|
57
|
+
// any message republishes the discovery configs - except Home Assistant's
|
|
58
|
+
// own "offline" (its last will) when <prefix>/status is wired in
|
|
59
|
+
if (!this.isHomeAssistantOffline(message)) {
|
|
60
|
+
this.publish(true);
|
|
61
|
+
}
|
|
62
|
+
|
|
49
63
|
if (done) {
|
|
50
64
|
done();
|
|
51
65
|
}
|
|
@@ -55,6 +69,11 @@ module.exports = function (RED) {
|
|
|
55
69
|
this.event(message);
|
|
56
70
|
});
|
|
57
71
|
|
|
72
|
+
// B-38: UNREACH from the ReGa's values (cache) and every later event
|
|
73
|
+
this.idUnreachSubscription = this.ccu.subscribe({cache: true, datapoint: 'UNREACH'}, (message) => {
|
|
74
|
+
this.unreach(message);
|
|
75
|
+
});
|
|
76
|
+
|
|
58
77
|
this.on('close', (done) => {
|
|
59
78
|
this._destructor(done);
|
|
60
79
|
});
|
|
@@ -66,8 +85,10 @@ module.exports = function (RED) {
|
|
|
66
85
|
|
|
67
86
|
_destructor(done) {
|
|
68
87
|
this.trace('ccu-homeassistant close');
|
|
88
|
+
this.closed = true;
|
|
69
89
|
clearTimeout(this.readyTimer);
|
|
70
90
|
this.ccu.unsubscribe(this.idEventSubscription);
|
|
91
|
+
this.ccu.unsubscribe(this.idUnreachSubscription);
|
|
71
92
|
this.ccu.deregister(this, done);
|
|
72
93
|
}
|
|
73
94
|
|
|
@@ -207,7 +228,7 @@ module.exports = function (RED) {
|
|
|
207
228
|
topicReplace(mqtt.topicInputSetValue, this.topicMessage(iface, address, dp)),
|
|
208
229
|
};
|
|
209
230
|
|
|
210
|
-
const {messages, missing, empty} = discoveryMessages(ctx);
|
|
231
|
+
const {messages, availability, missing, empty} = discoveryMessages(ctx);
|
|
211
232
|
|
|
212
233
|
for (const address of this.removed) {
|
|
213
234
|
const {topic, payload} = removalMessage(this.prefix, address);
|
|
@@ -218,6 +239,8 @@ module.exports = function (RED) {
|
|
|
218
239
|
this.send({topic, payload, retain: true});
|
|
219
240
|
}
|
|
220
241
|
|
|
242
|
+
this.publishAvailability(availability);
|
|
243
|
+
|
|
221
244
|
if (missing.length > 0) {
|
|
222
245
|
this.warn('selected devices not found on the CCU: ' + missing.join(', '));
|
|
223
246
|
}
|
|
@@ -240,6 +263,88 @@ module.exports = function (RED) {
|
|
|
240
263
|
});
|
|
241
264
|
}
|
|
242
265
|
|
|
266
|
+
/** Home Assistant's last will on `<prefix>/status` - not a reason to republish */
|
|
267
|
+
isHomeAssistantOffline(message) {
|
|
268
|
+
return (
|
|
269
|
+
message &&
|
|
270
|
+
message.topic === this.prefix + '/status' &&
|
|
271
|
+
String(message.payload).trim().toLowerCase() !== 'online'
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* B-38: put the current UNREACH of every published device on its availability
|
|
277
|
+
* topic, retained, right after the discovery configs - from the values the
|
|
278
|
+
* connection holds when they are certain, and from the interface process
|
|
279
|
+
* (getValue, deferred by the connection until the interface is up) in any case.
|
|
280
|
+
* @param {Array<{iface: string, channel: string, datapoint: string, topic: string}>} availability
|
|
281
|
+
*/
|
|
282
|
+
publishAvailability(availability) {
|
|
283
|
+
this.availability = new Map();
|
|
284
|
+
this.availabilitySent = new Map();
|
|
285
|
+
for (const source of availability) {
|
|
286
|
+
const datapointName = source.iface + '.' + source.channel + '.' + source.datapoint;
|
|
287
|
+
this.availability.set(datapointName, source);
|
|
288
|
+
|
|
289
|
+
const cached = this.ccu.values && this.ccu.values[datapointName];
|
|
290
|
+
if (cached && !cached.uncertain && typeof cached.value === 'boolean') {
|
|
291
|
+
this.sendAvailability(datapointName, cached.value, cached);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const known = this.availability;
|
|
295
|
+
this.ccu
|
|
296
|
+
.methodCall(source.iface, 'getValue', [source.channel, source.datapoint], {quiet: true})
|
|
297
|
+
.then((value) => {
|
|
298
|
+
// a republish in between started a new round
|
|
299
|
+
if (this.availability === known) {
|
|
300
|
+
this.sendAvailability(datapointName, Boolean(value));
|
|
301
|
+
}
|
|
302
|
+
})
|
|
303
|
+
.catch((error) => {
|
|
304
|
+
this.debug('getValue ' + datapointName + ': ' + error.message);
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** an UNREACH message of the connection (cache or event) */
|
|
310
|
+
unreach(message) {
|
|
311
|
+
if (message && this.availability.has(message.datapointName) && typeof message.value === 'boolean') {
|
|
312
|
+
this.sendAvailability(message.datapointName, message.value, message);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* One retained UNREACH on an availability topic, in the ccu-mqtt node's payload
|
|
318
|
+
* format; skipped when the topic already carries that value from this node.
|
|
319
|
+
* @param {string} datapointName
|
|
320
|
+
* @param {boolean} value
|
|
321
|
+
* @param {object} [message] the connection's message for the datapoint, if there is one
|
|
322
|
+
*/
|
|
323
|
+
sendAvailability(datapointName, value, message) {
|
|
324
|
+
const source = this.availability.get(datapointName);
|
|
325
|
+
if (this.closed || !source || this.availabilitySent.get(datapointName) === value) {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const mqtt = this.mqttNodeId && RED.nodes.getNode(this.mqttNodeId);
|
|
330
|
+
if (!mqtt || typeof mqtt.output !== 'function') {
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const now = Date.now();
|
|
335
|
+
const out = {
|
|
336
|
+
ts: now,
|
|
337
|
+
lc: now,
|
|
338
|
+
...message,
|
|
339
|
+
...this.topicMessage(source.iface, source.channel, source.datapoint),
|
|
340
|
+
payload: value,
|
|
341
|
+
value,
|
|
342
|
+
};
|
|
343
|
+
delete out.topic;
|
|
344
|
+
this.availabilitySent.set(datapointName, value);
|
|
345
|
+
this.send({topic: source.topic, payload: mqtt.output(out), retain: true});
|
|
346
|
+
}
|
|
347
|
+
|
|
243
348
|
/** key presses: Home Assistant's event entity wants one topic per key → <channel>/PRESS */
|
|
244
349
|
event(message) {
|
|
245
350
|
if (!message.datapoint || !message.datapoint.startsWith('PRESS_') || !message.payload) {
|
package/nodes/lib/hadiscovery.js
CHANGED
|
@@ -175,12 +175,21 @@ function resolveVirtualReceivers(channels) {
|
|
|
175
175
|
}
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
+
/**
|
|
179
|
+
* The datapoint behind a device's availability topic (the maintenance channel's UNREACH).
|
|
180
|
+
* @typedef {object} Availability
|
|
181
|
+
* @property {string} iface
|
|
182
|
+
* @property {string} channel address of the maintenance channel (…:0)
|
|
183
|
+
* @property {string} datapoint always UNREACH
|
|
184
|
+
* @property {string} topic the status topic the discovery config names in `avty`
|
|
185
|
+
*/
|
|
186
|
+
|
|
178
187
|
/**
|
|
179
188
|
* Builds the discovery block of one device.
|
|
180
189
|
* @param {DiscoveryContext} ctx
|
|
181
190
|
* @param {string} iface
|
|
182
191
|
* @param {object} device device metadata (ADDRESS, TYPE, CHILDREN, FIRMWARE)
|
|
183
|
-
* @returns {{id: string, device: object, components: object, availability: object[]} | null}
|
|
192
|
+
* @returns {{id: string, device: object, components: object, availability: object[], unreach?: Availability} | null}
|
|
184
193
|
* null when the device yields no entity
|
|
185
194
|
*/
|
|
186
195
|
function deviceBlock(ctx, iface, device) {
|
|
@@ -752,13 +761,19 @@ function deviceBlock(ctx, iface, device) {
|
|
|
752
761
|
return null;
|
|
753
762
|
}
|
|
754
763
|
|
|
755
|
-
// availability: the device's UNREACH
|
|
756
|
-
//
|
|
764
|
+
// availability: the device's UNREACH — there is no bridge "connected" topic in the
|
|
765
|
+
// ccu-mqtt topic scheme. HA shows every entity "unavailable" until this topic delivers
|
|
766
|
+
// a value, and ccu-mqtt publishes UNREACH only on a change or in its start-up cache
|
|
767
|
+
// burst, so the ccu-homeassistant node publishes the current value itself (B-38):
|
|
768
|
+
// `unreach` tells it which datapoint and topic.
|
|
757
769
|
// (an entry of the availability list takes `val_tpl`; `avty_tpl` is only valid at
|
|
758
770
|
// the top level next to `avty_t` — HA rejects the whole device payload otherwise)
|
|
759
771
|
const availability = [];
|
|
772
|
+
let unreach;
|
|
760
773
|
if (maintenance && maintenance.description.UNREACH) {
|
|
761
|
-
|
|
774
|
+
const topic = st(iface, maintenance.ADDRESS, 'UNREACH');
|
|
775
|
+
availability.push({t: topic, val_tpl: t.bool('offline', 'online')});
|
|
776
|
+
unreach = {iface, channel: maintenance.ADDRESS, datapoint: 'UNREACH', topic};
|
|
762
777
|
}
|
|
763
778
|
|
|
764
779
|
return {
|
|
@@ -772,6 +787,7 @@ function deviceBlock(ctx, iface, device) {
|
|
|
772
787
|
}),
|
|
773
788
|
components,
|
|
774
789
|
availability,
|
|
790
|
+
unreach,
|
|
775
791
|
};
|
|
776
792
|
}
|
|
777
793
|
|
|
@@ -807,11 +823,13 @@ function removalMessage(prefix, address) {
|
|
|
807
823
|
/**
|
|
808
824
|
* Discovery messages for the selected devices.
|
|
809
825
|
* @param {DiscoveryContext} ctx
|
|
810
|
-
* @returns {{messages: Array<{topic: string, payload: object}>, missing: string[], empty: string[]}}
|
|
826
|
+
* @returns {{messages: Array<{topic: string, payload: object}>, availability: Availability[], missing: string[], empty: string[]}}
|
|
827
|
+
* availability: the UNREACH datapoints the configs use as availability (B-38);
|
|
811
828
|
* missing: selected addresses not found in the metadata; empty: devices without entities
|
|
812
829
|
*/
|
|
813
830
|
function discoveryMessages(ctx) {
|
|
814
831
|
const messages = [];
|
|
832
|
+
const availability = [];
|
|
815
833
|
const missing = [];
|
|
816
834
|
const empty = [];
|
|
817
835
|
for (const address of ctx.selected || []) {
|
|
@@ -829,9 +847,12 @@ function discoveryMessages(ctx) {
|
|
|
829
847
|
}
|
|
830
848
|
|
|
831
849
|
messages.push(devicePayload(ctx, block));
|
|
850
|
+
if (block.unreach) {
|
|
851
|
+
availability.push(block.unreach);
|
|
852
|
+
}
|
|
832
853
|
}
|
|
833
854
|
|
|
834
|
-
return {messages, missing, empty};
|
|
855
|
+
return {messages, availability, missing, empty};
|
|
835
856
|
}
|
|
836
857
|
|
|
837
858
|
module.exports = {
|
|
@@ -16,8 +16,16 @@
|
|
|
16
16
|
Option <i>weitere Datenpunkte</i> werden alle übrigen Datenpunkte als standardmäßig deaktivierte
|
|
17
17
|
Entitäten mitgeliefert.</p>
|
|
18
18
|
<h3>Eingang</h3>
|
|
19
|
-
<p>Jede eingehende Nachricht veröffentlicht die Discovery-Konfigurationen
|
|
19
|
+
<p>Jede eingehende Nachricht veröffentlicht die Discovery-Konfigurationen und die Verfügbarkeit
|
|
20
|
+
erneut. Wer einen <b>mqtt in</b>-Node auf <code>homeassistant/status</code> (bzw.
|
|
21
|
+
<code><Präfix>/status</code>) anschließt, bekommt beides nach jedem Neustart von Home Assistant
|
|
22
|
+
neu; dessen <code>offline</code> auf diesem Topic wird ignoriert.</p>
|
|
20
23
|
<h3>Voraussetzungen</h3>
|
|
21
|
-
<p>Home Assistant ≥ 2024.11 (gerätebasierte Discovery)
|
|
22
|
-
|
|
24
|
+
<p>Home Assistant ≥ 2024.11 (gerätebasierte Discovery).</p>
|
|
25
|
+
<h3>Verfügbarkeit</h3>
|
|
26
|
+
<p>Die Verfügbarkeit der Geräte wird aus dem UNREACH-Datenpunkt des Wartungskanals abgeleitet
|
|
27
|
+
(<code>…:0/UNREACH</code>). Home Assistant zeigt alle Entitäten eines Geräts als „nicht verfügbar“,
|
|
28
|
+
bis auf diesem Topic ein Wert ankommt. Deshalb veröffentlicht der Node direkt nach den
|
|
29
|
+
Discovery-Konfigurationen den aktuellen UNREACH-Wert jedes Geräts (retained), fragt ihn dazu bei der
|
|
30
|
+
CCU ab und sendet jede Änderung. Ein Gerät, das nie unerreichbar war, ist so trotzdem verfügbar.</p>
|
|
23
31
|
</script>
|
|
@@ -16,8 +16,16 @@
|
|
|
16
16
|
<i>other datapoints</i> option every remaining datapoint is included as an entity that is
|
|
17
17
|
disabled by default.</p>
|
|
18
18
|
<h3>Input</h3>
|
|
19
|
-
<p>Any incoming message republishes the discovery configs
|
|
19
|
+
<p>Any incoming message republishes the discovery configs and the availability. Wire an
|
|
20
|
+
<b>mqtt in</b> node on <code>homeassistant/status</code> (or <code><prefix>/status</code>) to
|
|
21
|
+
the input to get both again after every Home Assistant restart; its <code>offline</code> on that
|
|
22
|
+
topic is ignored.</p>
|
|
20
23
|
<h3>Requirements</h3>
|
|
21
|
-
<p>Home Assistant ≥ 2024.11 (device-based discovery)
|
|
22
|
-
|
|
24
|
+
<p>Home Assistant ≥ 2024.11 (device-based discovery).</p>
|
|
25
|
+
<h3>Availability</h3>
|
|
26
|
+
<p>Device availability is derived from the UNREACH datapoint of the maintenance channel
|
|
27
|
+
(<code>…:0/UNREACH</code>). Home Assistant shows every entity of a device as "unavailable" until
|
|
28
|
+
a value arrives on that topic. So right after the discovery configs the node publishes each
|
|
29
|
+
device's current UNREACH (retained), asking the CCU for it, and sends every change. A device that
|
|
30
|
+
was never unreachable is available all the same.</p>
|
|
23
31
|
</script>
|