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 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 — and (b) the aggregate PRESS
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
- this.publish(true);
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) {
@@ -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 (retained by ccu-mqtt) — there is no
756
- // bridge "connected" topic in the ccu-mqtt topic scheme
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
- availability.push({t: st(iface, maintenance.ADDRESS, 'UNREACH'), val_tpl: t.bool('offline', 'online')});
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 erneut.</p>
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>&lt;Präfix&gt;/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). Die Verfügbarkeit der Geräte wird aus dem
22
- UNREACH-Datenpunkt des Wartungskanals abgeleitet.</p>
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.</p>
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>&lt;prefix&gt;/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). Device availability is derived from the
22
- UNREACH datapoint of the maintenance channel.</p>
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>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-ccu",
3
- "version": "4.5.2",
3
+ "version": "4.5.3",
4
4
  "license": "MIT",
5
5
  "main": "none",
6
6
  "keywords": [