node-red-contrib-ccu 4.5.3 → 4.6.1

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,40 @@ 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.6.1 (2026-10-22)
9
+
10
+ ### Fixed
11
+
12
+ - **Home Assistant logged an error for every published device: the
13
+ maintenance channel's `STICKY_UNREACH`** went out as a `switch` with
14
+ `device_class: problem`, a class only `binary_sensor` knows, and Home
15
+ Assistant refused that component ("expected SwitchDeviceClass or one of
16
+ 'outlet', 'switch'") while it created the device and its other entities
17
+ (B-39). `STICKY_UNREACH` is no longer announced at all - the maintainer's
18
+ decision: "UNREACH is enough" -, and a device class a platform does not
19
+ accept is dropped from any component before it goes out. A test now checks
20
+ every component of every device the shipped paramsets know against Home
21
+ Assistant's device classes per platform.
22
+
23
+ ## 4.6.0 (2026-10-22)
24
+
25
+ ### Added
26
+
27
+ - **A device is created in Home Assistant in the room of its channels.** The
28
+ discovery config carries the room as the device's `suggested_area` (task
29
+ 18). It did so before only when every channel of the device lay in one and
30
+ the same room: a device with channels in two rooms got no area, and on
31
+ openccu-lite, whose rooms can be nested and whose room list of a channel
32
+ names the parents too (`Küche`, `Erdgeschoss`), no device got one at all.
33
+ Now the area is the room shared by all channels of the device that lie in a
34
+ room, otherwise the room of the lowest-numbered channel that lies in one; a
35
+ channel in several rooms counts with its first (on openccu-lite the most
36
+ specific), and a room of the device itself comes first. The rooms are the
37
+ ReGaHSS's on a CCU and the system's metadata's on openccu-lite. New option
38
+ _Suggest the channel's room as the device's area_ on the node, on by
39
+ default. Home Assistant applies the suggestion only when it first creates
40
+ the device; a room changed later stays as assigned in Home Assistant.
41
+
8
42
  ## 4.5.3 (2026-10-05)
9
43
 
10
44
  ### Fixed
package/README.md CHANGED
@@ -81,6 +81,15 @@ publishes its current value (retained) right after the discovery configs and eve
81
81
  is available without ever having been unreachable. Any message on the node's input republishes both; the example
82
82
  wires Home Assistant's birth message (`homeassistant/status`) to it, so a Home Assistant restart gets them again.
83
83
 
84
+ The device is created in the room of its channels: the discovery config carries it as the device's `suggested_area`
85
+ (Home Assistant's areas are per device, the CCU's rooms per channel). The rule: the room shared by all channels of the
86
+ device that lie in a room, otherwise the room of the lowest-numbered channel that lies in one; a channel in several
87
+ rooms counts with its first - on openccu-lite, where rooms can be nested, with the most specific one - and a room of
88
+ the device itself comes first. The rooms come from the ReGaHSS on a CCU and from the system's metadata on openccu-lite.
89
+ Home Assistant applies the suggestion only when it first creates the device; a room changed later on the CCU stays as
90
+ assigned in Home Assistant (change it there, or delete the device in Home Assistant and republish the discovery). The
91
+ node's option _Suggest the channel's room as the device's area_ (on by default) switches it off.
92
+
84
93
  ## Dynamic configuration
85
94
 
86
95
  Most nodes can be reconfigured per message: put an object in `msg.config` and its
@@ -7,6 +7,7 @@
7
7
  mqttNode: {value: '', required: true},
8
8
  prefix: {value: 'homeassistant'},
9
9
  generic: {value: true},
10
+ area: {value: true},
10
11
  devices: {value: {}},
11
12
  },
12
13
  inputs: 1,
@@ -267,6 +268,15 @@
267
268
  </label>
268
269
  </div>
269
270
  </div>
271
+ <div class="form-row">
272
+ <label for="node-input-area"></label>
273
+ <div style="width: 70%; display: inline-block; vertical-align: text-top;">
274
+ <label class="ccu-checkbox">
275
+ <input type="checkbox" id="node-input-area">
276
+ <span>Suggest the channel's room as the device's area</span>
277
+ </label>
278
+ </div>
279
+ </div>
270
280
 
271
281
  <div class="form-row">
272
282
  <b>Devices published to Home Assistant</b>
@@ -37,6 +37,7 @@ module.exports = function (RED) {
37
37
  this.mqttNodeId = config.mqttNode;
38
38
  this.prefix = String(config.prefix || 'homeassistant').replace(/\/+$/, '');
39
39
  this.generic = config.generic !== false;
40
+ this.area = config.area !== false;
40
41
  this.devices = config.devices || {};
41
42
  this.selected = Object.keys(this.devices).filter(
42
43
  (a) => this.devices[a] && this.devices[a].enabled === true,
@@ -214,6 +215,7 @@ module.exports = function (RED) {
214
215
  origin: {name: pkg.name, sw: pkg.version, url: pkg.homepage},
215
216
  jsonPayloads: mqtt.payloadOutput !== 'plain',
216
217
  generic: this.generic,
218
+ area: this.area,
217
219
  devices: ccu.metadata.devices,
218
220
  selected: this.selected,
219
221
  description: (iface, address) => {
@@ -19,6 +19,122 @@ const TILT_TYPES = /BLIND/;
19
19
  /** Platforms without a state topic. */
20
20
  const STATELESS = new Set(['button', 'notify', 'scene', 'tag']);
21
21
 
22
+ /**
23
+ * The device classes Home Assistant accepts per platform (B-39). A component whose class is not in
24
+ * its platform's list is refused by Home Assistant together with its error log line, so `e()` drops
25
+ * such a class and the unit tests check every component of every known device against this table.
26
+ * Platforms missing here (light, climate, lock, select) take no device class at all.
27
+ */
28
+ const SENSOR_CLASSES = [
29
+ 'absolute_humidity',
30
+ 'apparent_power',
31
+ 'aqi',
32
+ 'area',
33
+ 'atmospheric_pressure',
34
+ 'battery',
35
+ 'blood_glucose_concentration',
36
+ 'carbon_dioxide',
37
+ 'carbon_monoxide',
38
+ 'conductivity',
39
+ 'current',
40
+ 'data_rate',
41
+ 'data_size',
42
+ 'distance',
43
+ 'duration',
44
+ 'energy',
45
+ 'energy_distance',
46
+ 'energy_storage',
47
+ 'frequency',
48
+ 'gas',
49
+ 'humidity',
50
+ 'illuminance',
51
+ 'irradiance',
52
+ 'moisture',
53
+ 'monetary',
54
+ 'nitrogen_dioxide',
55
+ 'nitrogen_monoxide',
56
+ 'nitrous_oxide',
57
+ 'ozone',
58
+ 'ph',
59
+ 'pm1',
60
+ 'pm10',
61
+ 'pm25',
62
+ 'pm4',
63
+ 'power',
64
+ 'power_factor',
65
+ 'precipitation',
66
+ 'precipitation_intensity',
67
+ 'pressure',
68
+ 'radon',
69
+ 'reactive_energy',
70
+ 'reactive_power',
71
+ 'signal_strength',
72
+ 'sound_pressure',
73
+ 'speed',
74
+ 'sulphur_dioxide',
75
+ 'temperature',
76
+ 'temperature_delta',
77
+ 'volatile_organic_compounds',
78
+ 'volatile_organic_compounds_parts',
79
+ 'voltage',
80
+ 'volume',
81
+ 'volume_flow_rate',
82
+ 'volume_storage',
83
+ 'water',
84
+ 'weight',
85
+ 'wind_direction',
86
+ 'wind_speed',
87
+ ];
88
+ const DEVICE_CLASSES = {
89
+ binary_sensor: new Set([
90
+ 'battery',
91
+ 'battery_charging',
92
+ 'carbon_monoxide',
93
+ 'cold',
94
+ 'connectivity',
95
+ 'door',
96
+ 'garage_door',
97
+ 'gas',
98
+ 'heat',
99
+ 'light',
100
+ 'lock',
101
+ 'moisture',
102
+ 'motion',
103
+ 'moving',
104
+ 'occupancy',
105
+ 'opening',
106
+ 'plug',
107
+ 'power',
108
+ 'presence',
109
+ 'problem',
110
+ 'running',
111
+ 'safety',
112
+ 'smoke',
113
+ 'sound',
114
+ 'tamper',
115
+ 'update',
116
+ 'vibration',
117
+ 'window',
118
+ ]),
119
+ sensor: new Set([...SENSOR_CLASSES, 'date', 'enum', 'timestamp', 'uptime']),
120
+ number: new Set(SENSOR_CLASSES),
121
+ switch: new Set(['outlet', 'switch']),
122
+ button: new Set(['identify', 'restart', 'update']),
123
+ cover: new Set(['awning', 'blind', 'curtain', 'damper', 'door', 'garage', 'gate', 'shade', 'shutter', 'window']),
124
+ event: new Set(['button', 'doorbell', 'motion']),
125
+ };
126
+
127
+ /** true when Home Assistant accepts `deviceClass` on `platform` */
128
+ function validDeviceClass(platform, deviceClass) {
129
+ return Boolean(DEVICE_CLASSES[platform] && DEVICE_CLASSES[platform].has(deviceClass));
130
+ }
131
+
132
+ /**
133
+ * Datapoints that are never announced (B-39; the maintainer, 2026-10-22: "don't publish
134
+ * STICKY_UNREACH to home assistant auto discovery. UNREACH is enough.").
135
+ */
136
+ const NOT_ANNOUNCED = new Set(['STICKY_UNREACH']);
137
+
22
138
  /** device classes that only binary_sensor knows — a sensor/number with one of them is rejected by HA */
23
139
  const BINARY_ONLY_CLASSES = new Set([
24
140
  'running',
@@ -51,7 +167,9 @@ const BINARY_ONLY_CLASSES = new Set([
51
167
  * @property {string[]} selected device addresses to publish
52
168
  * @property {(iface: string, address: string) => object | undefined} description VALUES description of a channel
53
169
  * @property {(address: string) => string | undefined} channelName
54
- * @property {(address: string) => string[] | undefined} [rooms]
170
+ * @property {(address: string) => string[] | undefined} [rooms] the rooms of a channel (or device), the
171
+ * most specific first where rooms are nested (openccu-lite)
172
+ * @property {boolean} [area] the room becomes the device's `suggested_area` (default true, task 18)
55
173
  * @property {(iface: string, address: string, datapoint: string) => string} statusTopicFor
56
174
  * @property {(iface: string, address: string, datapoint: string) => string} setTopicFor
57
175
  */
@@ -184,6 +302,32 @@ function resolveVirtualReceivers(channels) {
184
302
  * @property {string} topic the status topic the discovery config names in `avty`
185
303
  */
186
304
 
305
+ /**
306
+ * The room Home Assistant is to suggest as the device's area (task 18). Home
307
+ * Assistant's area is per device, the CCU's rooms are per channel, so: a room
308
+ * every channel that lies in a room shares - the first one in the order of the
309
+ * lowest-numbered such channel - or, when they share none, the first room of
310
+ * the lowest-numbered channel that has one. A room of the device itself (an
311
+ * openccu-lite device may be in a room as a whole) comes first. Home Assistant
312
+ * applies `suggested_area` only when it creates the device; a room changed
313
+ * later stays as assigned in Home Assistant.
314
+ * @param {(address: string) => string[] | undefined} roomsOf
315
+ * @param {string} deviceAddress
316
+ * @param {Array<{ADDRESS: string}>} channels sorted by channel index
317
+ * @returns {string | undefined}
318
+ */
319
+ function suggestedArea(roomsOf, deviceAddress, channels) {
320
+ const lists = [deviceAddress, ...channels.map((ch) => ch.ADDRESS)]
321
+ .map((address) => roomsOf(address))
322
+ .filter((rooms) => Array.isArray(rooms) && rooms.length > 0);
323
+ if (lists.length === 0) {
324
+ return undefined;
325
+ }
326
+
327
+ const [first, ...rest] = lists;
328
+ return first.find((room) => rest.every((rooms) => rooms.includes(room))) || first[0];
329
+ }
330
+
187
331
  /**
188
332
  * Builds the discovery block of one device.
189
333
  * @param {DiscoveryContext} ctx
@@ -224,12 +368,7 @@ function deviceBlock(ctx, iface, device) {
224
368
  components[key] = e;
225
369
  };
226
370
 
227
- const rooms = new Set();
228
- for (const ch of channels) {
229
- for (const r of roomsOf(ch.ADDRESS) || []) {
230
- rooms.add(r);
231
- }
232
- }
371
+ const area = ctx.area === false ? undefined : suggestedArea(roomsOf, device.ADDRESS, channels);
233
372
 
234
373
  const maintenance = channels.find((ch) => ch.index === 0);
235
374
 
@@ -244,7 +383,7 @@ function deviceBlock(ctx, iface, device) {
244
383
  const e = (dp, platform, label, more = {}) => {
245
384
  const stateless = STATELESS.has(platform);
246
385
  const uid = more.uid || `${ch.index}_${dp}`;
247
- return compact({
386
+ const component = compact({
248
387
  p: platform,
249
388
  uniq_id: `${id}_${uid}`,
250
389
  name: label,
@@ -253,6 +392,12 @@ function deviceBlock(ctx, iface, device) {
253
392
  ...(more.command && {cmd_t: scmd(dp)}),
254
393
  ...(more.extra || {}),
255
394
  });
395
+ // a class the platform does not know makes HA refuse the component (B-39)
396
+ if (component.dev_cla !== undefined && !validDeviceClass(platform, component.dev_cla)) {
397
+ delete component.dev_cla;
398
+ }
399
+
400
+ return component;
256
401
  };
257
402
 
258
403
  const fallback = ch.index === 0 ? 'Maintenance' : `Channel ${ch.index}`;
@@ -636,7 +781,7 @@ function deviceBlock(ctx, iface, device) {
636
781
 
637
782
  // generic layer: everything else in the channel's VALUES description
638
783
  for (const [dp, d] of Object.entries(ch.description)) {
639
- if (consumed.has(dp) || !d || typeof d !== 'object') {
784
+ if (consumed.has(dp) || NOT_ANNOUNCED.has(dp) || !d || typeof d !== 'object') {
640
785
  continue;
641
786
  }
642
787
 
@@ -783,7 +928,7 @@ function deviceBlock(ctx, iface, device) {
783
928
  mf: 'eQ-3',
784
929
  mdl: device.TYPE,
785
930
  sw: device.FIRMWARE ? String(device.FIRMWARE) : undefined,
786
- sa: rooms.size === 1 ? [...rooms][0] : undefined,
931
+ sa: area,
787
932
  }),
788
933
  components,
789
934
  availability,
@@ -856,10 +1001,14 @@ function discoveryMessages(ctx) {
856
1001
  }
857
1002
 
858
1003
  module.exports = {
1004
+ DEVICE_CLASSES,
1005
+ NOT_ANNOUNCED,
1006
+ validDeviceClass,
859
1007
  discoveryId,
860
1008
  discoveryTopic,
861
1009
  ifaceOf,
862
1010
  resolveVirtualReceivers,
1011
+ suggestedArea,
863
1012
  deviceBlock,
864
1013
  devicePayload,
865
1014
  removalMessage,
@@ -28,4 +28,15 @@
28
28
  bis auf diesem Topic ein Wert ankommt. Deshalb veröffentlicht der Node direkt nach den
29
29
  Discovery-Konfigurationen den aktuellen UNREACH-Wert jedes Geräts (retained), fragt ihn dazu bei der
30
30
  CCU ab und sendet jede Änderung. Ein Gerät, das nie unerreichbar war, ist so trotzdem verfügbar.</p>
31
+ <h3>Raum und Bereich</h3>
32
+ <p>Home Assistant ordnet Geräte Bereichen zu, die CCU Kanäle Räumen. Der Node schlägt Home Assistant den
33
+ Raum der Kanäle als Bereich des Geräts vor (<code>suggested_area</code>): den Raum, den alle Kanäle des
34
+ Geräts teilen, die in einem Raum liegen, sonst den Raum des Kanals mit der niedrigsten Nummer, der in
35
+ einem liegt. Liegt ein Kanal in mehreren Räumen, zählt der erste; auf openccu-lite, wo Räume verschachtelt
36
+ sein können, der speziellste (das Zimmer vor dem Stockwerk), und ein Raum des Geräts selbst geht vor. Die
37
+ Räume kommen auf einer CCU aus der ReGaHSS, auf openccu-lite aus den Metadaten des Systems.</p>
38
+ <p>Home Assistant übernimmt den Vorschlag nur, wenn es das Gerät neu anlegt. Ein später auf der CCU
39
+ geänderter Raum bleibt in Home Assistant, wie er dort zugeordnet ist: dort ändern, oder das Gerät in Home
40
+ Assistant löschen und die Discovery erneut veröffentlichen. Die Option <i>Raum als Bereich vorschlagen</i>
41
+ schaltet den Vorschlag ab.</p>
31
42
  </script>
@@ -28,4 +28,15 @@
28
28
  a value arrives on that topic. So right after the discovery configs the node publishes each
29
29
  device's current UNREACH (retained), asking the CCU for it, and sends every change. A device that
30
30
  was never unreachable is available all the same.</p>
31
+ <h3>Room and area</h3>
32
+ <p>Home Assistant assigns devices to areas, the CCU assigns channels to rooms. The node suggests the
33
+ channels' room as the device's area (<code>suggested_area</code>): the room shared by all channels of
34
+ the device that lie in a room, otherwise the room of the lowest-numbered channel that lies in one. A
35
+ channel in several rooms counts with its first; on openccu-lite, where rooms can be nested, with the most
36
+ specific one (the room before the floor), and a room of the device itself comes first. The rooms come
37
+ from the ReGaHSS on a CCU and from the system's metadata on openccu-lite.</p>
38
+ <p>Home Assistant applies the suggestion only when it creates the device. A room changed later on the
39
+ CCU stays as assigned in Home Assistant: change it there, or delete the device in Home Assistant and
40
+ republish the discovery. The option <i>Suggest the channel's room as the device's area</i> switches the
41
+ suggestion off.</p>
31
42
  </script>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-ccu",
3
- "version": "4.5.3",
3
+ "version": "4.6.1",
4
4
  "license": "MIT",
5
5
  "main": "none",
6
6
  "keywords": [