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 +34 -0
- package/README.md +9 -0
- package/nodes/ccu-homeassistant.html +10 -0
- package/nodes/ccu-homeassistant.js +2 -0
- package/nodes/lib/hadiscovery.js +159 -10
- package/nodes/locales/de/ccu-homeassistant.html +11 -0
- package/nodes/locales/en-US/ccu-homeassistant.html +11 -0
- package/package.json +1 -1
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) => {
|
package/nodes/lib/hadiscovery.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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:
|
|
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>
|