node-red-contrib-ccu 4.1.0 → 4.3.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.
Files changed (51) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/HANDOFF.md +67 -16
  3. package/README.md +42 -0
  4. package/ROADMAP.md +129 -47
  5. package/examples/home-assistant.json +127 -0
  6. package/nodes/ccu-alexa.html +3 -1
  7. package/nodes/ccu-connection.html +26 -0
  8. package/nodes/ccu-connection.js +124 -13
  9. package/nodes/ccu-display.js +29 -13
  10. package/nodes/ccu-get-value.html +3 -1
  11. package/nodes/ccu-homeassistant.html +334 -0
  12. package/nodes/ccu-homeassistant.js +272 -0
  13. package/nodes/ccu-mqtt.html +21 -4
  14. package/nodes/ccu-mqtt.js +188 -105
  15. package/nodes/ccu-rpc-event.html +6 -0
  16. package/nodes/ccu-rpc-event.js +31 -12
  17. package/nodes/ccu-set-value.html +38 -0
  18. package/nodes/ccu-set-value.js +59 -235
  19. package/nodes/ccu-signal.html +55 -1
  20. package/nodes/ccu-signal.js +52 -9
  21. package/nodes/ccu-switch.html +17 -4
  22. package/nodes/ccu-switch.js +28 -4
  23. package/nodes/ccu-sysvar.html +40 -1
  24. package/nodes/ccu-sysvar.js +78 -8
  25. package/nodes/ccu-value.html +27 -1
  26. package/nodes/ccu-value.js +69 -19
  27. package/nodes/lib/cast.js +59 -14
  28. package/nodes/lib/channelfilter.js +136 -0
  29. package/nodes/lib/dynconfig.js +81 -0
  30. package/nodes/lib/hadiscovery.js +846 -0
  31. package/nodes/lib/haroles.js +193 -0
  32. package/nodes/lib/localccu.js +81 -0
  33. package/nodes/lib/message.js +6 -2
  34. package/nodes/lib/valuestatus.js +145 -0
  35. package/nodes/locales/de/ccu-display.html +17 -0
  36. package/nodes/locales/de/ccu-rpc-event.json +1 -0
  37. package/nodes/locales/de/ccu-sysvar.json +3 -2
  38. package/nodes/locales/de/ccu-value.json +2 -1
  39. package/nodes/locales/en-US/ccu-display.html +17 -0
  40. package/nodes/locales/en-US/ccu-rpc-event.json +3 -2
  41. package/nodes/locales/en-US/ccu-switch.html +12 -0
  42. package/nodes/locales/en-US/ccu-sysvar.json +3 -2
  43. package/nodes/locales/en-US/ccu-value.json +2 -1
  44. package/package.json +2 -1
  45. package/patch-certainonly.py +51 -0
  46. package/roadmap-archive/B-16.md +31 -0
  47. package/roadmap-archive/B-2.md +69 -0
  48. package/roadmap-archive/B-3.md +57 -0
  49. package/roadmap-archive/B-4.md +44 -0
  50. package/roadmap-archive/B-5.md +57 -0
  51. package/roadmap-archive/README.md +11 -0
@@ -0,0 +1,334 @@
1
+ <script type="text/javascript">
2
+ RED.nodes.registerType('ccu-homeassistant', {
3
+ category: 'ccu',
4
+ defaults: {
5
+ name: {value: ''},
6
+ ccuConfig: {value: 'localhost', type: 'ccu-connection', required: true},
7
+ mqttNode: {value: '', required: true},
8
+ prefix: {value: 'homeassistant'},
9
+ generic: {value: true},
10
+ devices: {value: {}},
11
+ },
12
+ inputs: 1,
13
+ outputs: 1,
14
+ icon: 'ccu.png',
15
+ color: '#c2d5e4',
16
+ paletteLabel: 'home assistant',
17
+ align: 'right',
18
+ label() {
19
+ return this.name || 'home assistant';
20
+ },
21
+ labelStyle() {
22
+ return this.name ? 'node_label_italic' : '';
23
+ },
24
+ oneditprepare() {
25
+ const that = this;
26
+ if (!this.devices) {
27
+ this.devices = {};
28
+ }
29
+
30
+ const $ccuConfig = $('#node-input-ccuConfig');
31
+ const $mqttNode = $('#node-input-mqttNode');
32
+ const $devices = $('#ha-devices');
33
+ const $filter = $('#ha-filter');
34
+ const $spinner = $('#ha-spinner');
35
+ const $message = $('#ha-message');
36
+
37
+ function escape(text) {
38
+ return $('<div>')
39
+ .text(String(text === undefined || text === null ? '' : text))
40
+ .html();
41
+ }
42
+
43
+ // the ccu-mqtt nodes of the flows whose topics this node reuses
44
+ function loadMqttNodes() {
45
+ const config = $ccuConfig.val();
46
+ const wanted = $mqttNode.val() || that.mqttNode;
47
+ $mqttNode.empty();
48
+ RED.nodes.eachNode((n) => {
49
+ if (n.type !== 'ccu-mqtt') {
50
+ return;
51
+ }
52
+
53
+ if (config && config !== '_ADD_' && n.ccuConfig !== config) {
54
+ return;
55
+ }
56
+
57
+ const label = (n.name || 'mqtt') + ' (' + (n.topicOutputEvent || '') + ')';
58
+ $mqttNode.append($('<option>').val(n.id).text(label));
59
+ });
60
+ if ($mqttNode.children().length === 0) {
61
+ $mqttNode.append($('<option>').val('').text('— no ccu-mqtt node for this CCU —'));
62
+ }
63
+
64
+ if (wanted && $mqttNode.find('option[value="' + wanted + '"]').length > 0) {
65
+ $mqttNode.val(wanted);
66
+ }
67
+ }
68
+
69
+ function applyFilter() {
70
+ const needle = String($filter.val() || '').toLowerCase();
71
+ $devices.find('tr.ha-device-row').each(function () {
72
+ const $row = $(this);
73
+ const haystack = $row.data('search');
74
+ $row.toggle(!needle || haystack.indexOf(needle) !== -1);
75
+ });
76
+ }
77
+
78
+ function renderDevices(data) {
79
+ $devices.empty();
80
+ const addresses = Object.keys(data).sort((a, b) => {
81
+ const na = data[a].name || a;
82
+ const nb = data[b].name || b;
83
+ return na.localeCompare(nb);
84
+ });
85
+ if (addresses.length === 0) {
86
+ $message.text('No devices known yet — is the CCU connection deployed and initialized?').show();
87
+ return;
88
+ }
89
+
90
+ addresses.forEach((addr) => {
91
+ const dev = data[addr];
92
+ const entry = that.devices[addr];
93
+ const checked = Boolean(entry && entry.enabled === true);
94
+ const search = [dev.name, addr, dev.type, dev.iface].join(' ').toLowerCase();
95
+ const $row = $(
96
+ '<tr class="ha-device-row">' +
97
+ '<td class="ha-enabled"><input type="checkbox" class="ha-device" data-addr="' +
98
+ escape(addr) +
99
+ '"' +
100
+ (checked ? ' checked' : '') +
101
+ '></td>' +
102
+ '<td class="ha-text" title="' +
103
+ escape(dev.name) +
104
+ '">' +
105
+ escape(dev.name || addr) +
106
+ '</td>' +
107
+ '<td class="ha-text ha-small">' +
108
+ escape(addr) +
109
+ '</td>' +
110
+ '<td class="ha-text ha-small">' +
111
+ escape(dev.type) +
112
+ '</td>' +
113
+ '<td class="ha-text ha-small">' +
114
+ escape(dev.iface) +
115
+ '</td>' +
116
+ '</tr>',
117
+ );
118
+ $row.data('search', search);
119
+ $devices.append($row);
120
+ });
121
+ applyFilter();
122
+ }
123
+
124
+ let loading = false;
125
+ function loadDevices() {
126
+ const config = $ccuConfig.val();
127
+ $message.hide();
128
+ if (!config || config === '_ADD_') {
129
+ $devices.empty();
130
+ $message.text('Select a CCU connection first.').show();
131
+ return;
132
+ }
133
+
134
+ if (loading) {
135
+ return;
136
+ }
137
+
138
+ loading = true;
139
+ $spinner.show();
140
+ $.getJSON('ccu?config=' + config + '&type=devices')
141
+ .done((data) => {
142
+ renderDevices(data || {});
143
+ })
144
+ .fail(() => {
145
+ $devices.empty();
146
+ $message.text('CCU connection not ready — deploy the CCU config node first, then reopen.').show();
147
+ })
148
+ .always(() => {
149
+ loading = false;
150
+ $spinner.hide();
151
+ });
152
+ }
153
+
154
+ $ccuConfig.on('change', () => {
155
+ loadMqttNodes();
156
+ loadDevices();
157
+ });
158
+ $filter.on('input keyup', applyFilter);
159
+ $('#ha-enable-all').on('click', () => {
160
+ $devices.find('tr.ha-device-row:visible input.ha-device').prop('checked', true);
161
+ });
162
+ $('#ha-disable-all').on('click', () => {
163
+ $devices.find('tr.ha-device-row:visible input.ha-device').prop('checked', false);
164
+ });
165
+
166
+ loadMqttNodes();
167
+ loadDevices();
168
+ },
169
+ oneditsave() {
170
+ const previous = this.devices || {};
171
+ const devices = {};
172
+ $('#ha-devices input.ha-device').each(function () {
173
+ const addr = $(this).data('addr');
174
+ if ($(this).is(':checked')) {
175
+ devices[addr] = {enabled: true};
176
+ } else if (previous[addr]) {
177
+ // was known before → publish a removal so Home Assistant forgets it
178
+ devices[addr] = {enabled: false};
179
+ }
180
+ });
181
+ // keep entries of devices that were not listed this time (interface
182
+ // temporarily down) instead of dropping them
183
+ Object.keys(previous).forEach((addr) => {
184
+ if (!(addr in devices)) {
185
+ devices[addr] = previous[addr];
186
+ }
187
+ });
188
+ this.devices = devices;
189
+ },
190
+ });
191
+ </script>
192
+
193
+ <script type="text/x-red" data-template-name="ccu-homeassistant">
194
+ <style>
195
+ label.ccu-checkbox {
196
+ display: block !important;
197
+ width: auto !important;
198
+ }
199
+ label.ccu-checkbox input {
200
+ width: auto !important;
201
+ margin-top: -3px !important;
202
+ }
203
+ #ha-devices {
204
+ width: 100%;
205
+ border-collapse: collapse;
206
+ }
207
+ #ha-devices td {
208
+ padding: 1px 4px;
209
+ border-bottom: 1px solid #eee;
210
+ }
211
+ #ha-devices td.ha-text {
212
+ white-space: nowrap;
213
+ overflow: hidden;
214
+ text-overflow: ellipsis;
215
+ max-width: 220px;
216
+ }
217
+ #ha-devices td.ha-small {
218
+ font-size: 10px;
219
+ color: #666;
220
+ max-width: 120px;
221
+ }
222
+ #ha-devices td.ha-enabled {
223
+ width: 20px;
224
+ }
225
+ #ha-devices input.ha-device {
226
+ width: auto !important;
227
+ margin: 0 !important;
228
+ }
229
+ #ha-toolbar {
230
+ display: flex;
231
+ gap: 8px;
232
+ align-items: center;
233
+ margin-bottom: 6px;
234
+ }
235
+ #ha-toolbar input {
236
+ flex: 1;
237
+ width: auto !important;
238
+ }
239
+ #ha-message {
240
+ display: none;
241
+ color: #888;
242
+ font-style: italic;
243
+ }
244
+ </style>
245
+ <div class="form-row">
246
+ <label for="node-input-name"><i class="icon-bookmark"></i> Name</label>
247
+ <input type="text" id="node-input-name">
248
+ </div>
249
+ <div class="form-row">
250
+ <label for="node-input-ccuConfig"><i class="icon-global"></i> CCU</label>
251
+ <input type="text" id="node-input-ccuConfig">
252
+ </div>
253
+ <div class="form-row">
254
+ <label for="node-input-mqttNode"><i class="fa fa-exchange"></i> MQTT node</label>
255
+ <select id="node-input-mqttNode" style="width: 70%"></select>
256
+ </div>
257
+ <div class="form-row">
258
+ <label for="node-input-prefix"><i class="fa fa-home"></i> Prefix</label>
259
+ <input type="text" id="node-input-prefix" placeholder="homeassistant">
260
+ </div>
261
+ <div class="form-row">
262
+ <label for="node-input-generic"></label>
263
+ <div style="width: 70%; display: inline-block; vertical-align: text-top;">
264
+ <label class="ccu-checkbox">
265
+ <input type="checkbox" id="node-input-generic">
266
+ <span>Also create (disabled) entities for all other datapoints</span>
267
+ </label>
268
+ </div>
269
+ </div>
270
+
271
+ <div class="form-row">
272
+ <b>Devices published to Home Assistant</b>
273
+ </div>
274
+ <div class="form-row">
275
+ <div id="ha-toolbar">
276
+ <input type="text" id="ha-filter" placeholder="filter by name, address, type">
277
+ <button type="button" class="red-ui-button red-ui-button-small" id="ha-enable-all">check all</button>
278
+ <button type="button" class="red-ui-button red-ui-button-small" id="ha-disable-all">uncheck all</button>
279
+ <img id="ha-spinner" src="red/images/spin.svg" style="display: none; height: 16px;">
280
+ </div>
281
+ <div id="ha-message"></div>
282
+ <div style="max-height: 320px; overflow-y: auto;">
283
+ <table id="ha-devices"></table>
284
+ </div>
285
+ </div>
286
+ </script>
287
+
288
+ <script type="text/x-red" data-help-name="ccu-homeassistant" lang="de-DE">
289
+ <p>Veröffentlicht Home-Assistant-MQTT-Discovery-Konfigurationen für ausgewählte Geräte.</p>
290
+ <p>Der Node ist ein Begleiter zum <b>ccu-mqtt</b>-Node: er übernimmt dessen Topic-Vorlagen und
291
+ Payload-Format, so dass die Discovery-Konfigurationen auf die Topics zeigen, die ccu-mqtt bereits
292
+ veröffentlicht bzw. entgegennimmt. Status und Befehle laufen weiterhin über ccu-mqtt.</p>
293
+ <h3>Verkabelung</h3>
294
+ <p>Den Ausgang mit demselben <b>mqtt out</b>-Node verbinden wie den ccu-mqtt-Node. Der Node sendet
295
+ <code>msg.topic</code>, <code>msg.payload</code> und <code>msg.retain</code>.</p>
296
+ <h3>Geräteauswahl</h3>
297
+ <p>Nur angehakte Geräte werden an Home Assistant gemeldet (ein HA-Gerät pro Homematic-Gerät).
298
+ Wird ein zuvor angehaktes Gerät abgehakt, sendet der Node beim nächsten Deploy eine leere
299
+ Konfiguration, die das Gerät in Home Assistant entfernt.</p>
300
+ <h3>Entitäten</h3>
301
+ <p>Schalter, Dimmer (light), Rollladen/Jalousie (cover), Thermostate (climate), Kontakte/Bewegung/
302
+ Rauch/Wasser (binary_sensor), Tasten (event), KeyMatic (lock), Energie/Wetter (sensor). Mit der
303
+ Option <i>weitere Datenpunkte</i> werden alle übrigen Datenpunkte als standardmäßig deaktivierte
304
+ Entitäten mitgeliefert.</p>
305
+ <h3>Eingang</h3>
306
+ <p>Jede eingehende Nachricht veröffentlicht die Discovery-Konfigurationen erneut.</p>
307
+ <h3>Voraussetzungen</h3>
308
+ <p>Home Assistant ≥ 2024.11 (gerätebasierte Discovery). Die Verfügbarkeit der Geräte wird aus dem
309
+ UNREACH-Datenpunkt des Wartungskanals abgeleitet.</p>
310
+ </script>
311
+
312
+ <script type="text/x-red" data-help-name="ccu-homeassistant" lang="en-US">
313
+ <p>Publishes Home Assistant MQTT discovery configurations for selected devices.</p>
314
+ <p>The node is a companion to the <b>ccu-mqtt</b> node: it reuses that node's topic templates and
315
+ payload format, so the discovery configs point at the topics ccu-mqtt already publishes and
316
+ consumes. State and commands keep flowing through ccu-mqtt.</p>
317
+ <h3>Wiring</h3>
318
+ <p>Connect the output to the same <b>mqtt out</b> node as the ccu-mqtt node. The node sends
319
+ <code>msg.topic</code>, <code>msg.payload</code> and <code>msg.retain</code>.</p>
320
+ <h3>Device selection</h3>
321
+ <p>Only checked devices are announced to Home Assistant (one HA device per Homematic device).
322
+ Unchecking a previously checked device publishes an empty config on the next deploy, which
323
+ removes the device from Home Assistant.</p>
324
+ <h3>Entities</h3>
325
+ <p>Switches, dimmers (light), blinds/shutters (cover), thermostats (climate), contacts/motion/
326
+ smoke/water (binary_sensor), keys (event), KeyMatic (lock), energy/weather (sensor). With the
327
+ <i>other datapoints</i> option every remaining datapoint is included as an entity that is
328
+ disabled by default.</p>
329
+ <h3>Input</h3>
330
+ <p>Any incoming message republishes the discovery configs.</p>
331
+ <h3>Requirements</h3>
332
+ <p>Home Assistant ≥ 2024.11 (device-based discovery). Device availability is derived from the
333
+ UNREACH datapoint of the maintenance channel.</p>
334
+ </script>
@@ -0,0 +1,272 @@
1
+ const path = require('path');
2
+
3
+ const statusHelper = require(path.join(__dirname, '/lib/status.js'));
4
+ const {topicReplace} = require('./lib/topic.js');
5
+ const {discoveryMessages, removalMessage} = require('./lib/hadiscovery.js');
6
+
7
+ const pkg = require(path.join(__dirname, '..', 'package.json'));
8
+
9
+ const READY_INTERVAL = 5000;
10
+ const READY_ATTEMPTS = 60;
11
+
12
+ /**
13
+ * Home Assistant MQTT auto-discovery for a checkbox-selected set of devices (B-16).
14
+ *
15
+ * Companion to a ccu-mqtt node: reuses its topic templates and payload format, so
16
+ * the discovery configs point at the topics ccu-mqtt already publishes/consumes.
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.
20
+ */
21
+ module.exports = function (RED) {
22
+ class CcuHomeAssistantNode {
23
+ constructor(config) {
24
+ RED.nodes.createNode(this, config);
25
+
26
+ this.ccu = RED.nodes.getNode(config.ccuConfig);
27
+
28
+ if (!this.ccu) {
29
+ return;
30
+ }
31
+
32
+ this.mqttNodeId = config.mqttNode;
33
+ this.prefix = String(config.prefix || 'homeassistant').replace(/\/+$/, '');
34
+ this.generic = config.generic !== false;
35
+ this.devices = config.devices || {};
36
+ this.selected = Object.keys(this.devices).filter(
37
+ (a) => this.devices[a] && this.devices[a].enabled === true,
38
+ );
39
+ this.removed = Object.keys(this.devices).filter(
40
+ (a) => this.devices[a] && this.devices[a].enabled === false,
41
+ );
42
+ this.selectedSet = new Set(this.selected);
43
+
44
+ this.ccu.register(this);
45
+
46
+ this.on('input', (message, send, done) => {
47
+ // any message republishes the discovery configs
48
+ this.publish(true);
49
+ if (done) {
50
+ done();
51
+ }
52
+ });
53
+
54
+ this.idEventSubscription = this.ccu.subscribe({cache: false}, (message) => {
55
+ this.event(message);
56
+ });
57
+
58
+ this.on('close', (done) => {
59
+ this._destructor(done);
60
+ });
61
+
62
+ this.attempts = 0;
63
+ this.showStatus({fill: 'grey', shape: 'ring', text: 'waiting for CCU'});
64
+ this.readyTimer = setTimeout(() => this.whenReady(), 1000);
65
+ }
66
+
67
+ _destructor(done) {
68
+ this.trace('ccu-homeassistant close');
69
+ clearTimeout(this.readyTimer);
70
+ this.ccu.unsubscribe(this.idEventSubscription);
71
+ this.ccu.deregister(this, done);
72
+ }
73
+
74
+ /** connection status pushed by ccu-connection: only override while disconnected */
75
+ setStatus(data) {
76
+ const ifaceStatus = (data && data.ifaceStatus) || {};
77
+ const connected = Object.keys(ifaceStatus).some((iface) => ifaceStatus[iface]);
78
+ if (connected) {
79
+ this.status(this.ownStatus || {fill: 'grey', shape: 'ring', text: 'waiting for CCU'});
80
+ } else {
81
+ statusHelper(this, data);
82
+ }
83
+ }
84
+
85
+ /** the node's own status (device count etc.), restored after reconnects */
86
+ showStatus(status) {
87
+ this.ownStatus = status;
88
+ this.status(status);
89
+ }
90
+
91
+ /** the referenced ccu-mqtt node, or null with a red status */
92
+ mqtt() {
93
+ const node = this.mqttNodeId && RED.nodes.getNode(this.mqttNodeId);
94
+ if (!node || node.type !== 'ccu-mqtt') {
95
+ this.showStatus({fill: 'red', shape: 'ring', text: 'ccu-mqtt node not found'});
96
+ return null;
97
+ }
98
+
99
+ if (node.ccu !== this.ccu) {
100
+ this.showStatus({fill: 'red', shape: 'ring', text: 'ccu-mqtt node uses another CCU'});
101
+ return null;
102
+ }
103
+
104
+ return node;
105
+ }
106
+
107
+ /** metadata, names and descriptions of every selected device are there */
108
+ ready() {
109
+ const {ccu} = this;
110
+ if (!ccu.metadata || !ccu.metadata.devices || !ccu.channelNames) {
111
+ return false;
112
+ }
113
+
114
+ if (Object.keys(ccu.channelNames).length === 0) {
115
+ return false;
116
+ }
117
+
118
+ return this.selected.every((address) => {
119
+ const iface = ccu.findIface(address);
120
+ const device = iface && ccu.metadata.devices[iface] && ccu.metadata.devices[iface][address];
121
+ if (!device) {
122
+ // not (yet) known — do not wait forever for an unpaired device
123
+ return true;
124
+ }
125
+
126
+ return (device.CHILDREN || []).every((ch) => {
127
+ const channel = ccu.metadata.devices[iface][ch];
128
+ if (!channel) {
129
+ return true;
130
+ }
131
+
132
+ // cached = key present; a legitimately empty VALUES paramset ({}) counts as
133
+ // cached, only a missing key means the fetch is still pending
134
+ const key = ccu.paramsetName(iface, channel, 'VALUES');
135
+ if (ccu.paramsetDescriptions[key] !== undefined) {
136
+ return true;
137
+ }
138
+
139
+ ccu.getParamsetDescription(iface, channel, 'VALUES'); // queues the fetch
140
+ return false;
141
+ });
142
+ });
143
+ }
144
+
145
+ whenReady() {
146
+ this.attempts += 1;
147
+ if (this.ready() || this.attempts >= READY_ATTEMPTS) {
148
+ if (!this.ready()) {
149
+ this.warn(
150
+ 'CCU metadata still incomplete after ' + this.attempts + ' attempts, publishing what is there',
151
+ );
152
+ }
153
+
154
+ this.publish(false);
155
+ return;
156
+ }
157
+
158
+ this.readyTimer = setTimeout(() => this.whenReady(), READY_INTERVAL);
159
+ }
160
+
161
+ /** the message-shaped object the ccu-mqtt topic templates are rendered against */
162
+ topicMessage(iface, channel, datapoint) {
163
+ const {ccu} = this;
164
+ const devices = (ccu.metadata && ccu.metadata.devices && ccu.metadata.devices[iface]) || {};
165
+ const channelMeta = devices[channel] || {};
166
+ const device = channelMeta.PARENT || String(channel).split(':')[0];
167
+ return {
168
+ ccu: ccu.host,
169
+ iface,
170
+ device,
171
+ deviceName: ccu.channelNames[device],
172
+ deviceType: devices[device] && devices[device].TYPE,
173
+ channel,
174
+ // same fallback as ccu-mqtt: unnamed channels use their address
175
+ channelName: ccu.channelNames[channel] || channel,
176
+ channelNameOrAddress: channel,
177
+ channelType: channelMeta.TYPE,
178
+ channelIndex: Number.parseInt(String(channel).split(':')[1], 10),
179
+ datapoint,
180
+ datapointName: iface + '.' + channel + '.' + datapoint,
181
+ };
182
+ }
183
+
184
+ publish(manual) {
185
+ const mqtt = this.mqtt();
186
+ if (!mqtt) {
187
+ return;
188
+ }
189
+
190
+ const {ccu} = this;
191
+ const ctx = {
192
+ prefix: this.prefix,
193
+ origin: {name: pkg.name, sw: pkg.version, url: pkg.homepage},
194
+ jsonPayloads: mqtt.payloadOutput !== 'plain',
195
+ generic: this.generic,
196
+ devices: ccu.metadata.devices,
197
+ selected: this.selected,
198
+ description: (iface, address) => {
199
+ const device = ccu.metadata.devices[iface] && ccu.metadata.devices[iface][address];
200
+ return device && ccu.getParamsetDescription(iface, device, 'VALUES');
201
+ },
202
+ channelName: (address) => ccu.channelNames[address],
203
+ rooms: (address) => ccu.channelRooms && ccu.channelRooms[address],
204
+ statusTopicFor: (iface, address, dp) =>
205
+ topicReplace(mqtt.topicOutputEvent, this.topicMessage(iface, address, dp)),
206
+ setTopicFor: (iface, address, dp) =>
207
+ topicReplace(mqtt.topicInputSetValue, this.topicMessage(iface, address, dp)),
208
+ };
209
+
210
+ const {messages, missing, empty} = discoveryMessages(ctx);
211
+
212
+ for (const address of this.removed) {
213
+ const {topic, payload} = removalMessage(this.prefix, address);
214
+ this.send({topic, payload, retain: true});
215
+ }
216
+
217
+ for (const {topic, payload} of messages) {
218
+ this.send({topic, payload, retain: true});
219
+ }
220
+
221
+ if (missing.length > 0) {
222
+ this.warn('selected devices not found on the CCU: ' + missing.join(', '));
223
+ }
224
+
225
+ if (empty.length > 0) {
226
+ this.debug('selected devices without any Home Assistant entity: ' + empty.join(', '));
227
+ }
228
+
229
+ this.log(
230
+ (manual ? 're' : '') +
231
+ 'published discovery for ' +
232
+ messages.length +
233
+ ' devices' +
234
+ (this.removed.length > 0 ? ', removed ' + this.removed.length : ''),
235
+ );
236
+ this.showStatus({
237
+ fill: missing.length > 0 ? 'yellow' : 'green',
238
+ shape: 'dot',
239
+ text: messages.length + ' devices' + (missing.length > 0 ? ', ' + missing.length + ' missing' : ''),
240
+ });
241
+ }
242
+
243
+ /** key presses: Home Assistant's event entity wants one topic per key → <channel>/PRESS */
244
+ event(message) {
245
+ if (!message.datapoint || !message.datapoint.startsWith('PRESS_') || !message.payload) {
246
+ return;
247
+ }
248
+
249
+ if (!this.selectedSet.has(message.device)) {
250
+ return;
251
+ }
252
+
253
+ const mqtt = this.mqttNodeId && RED.nodes.getNode(this.mqttNodeId);
254
+ if (!mqtt || typeof mqtt.output !== 'function') {
255
+ return;
256
+ }
257
+
258
+ const press = RED.util.cloneMessage(message);
259
+ press.payload = message.datapoint;
260
+ press.value = message.datapoint;
261
+ press.datapoint = 'PRESS';
262
+ press.datapointName = String(message.datapointName || '').replace(/PRESS_\w+$/, 'PRESS');
263
+ if (!press.channelName) {
264
+ press.channelName = press.channel;
265
+ }
266
+
267
+ this.send({topic: topicReplace(mqtt.topicOutputEvent, press), payload: mqtt.output(press), retain: false});
268
+ }
269
+ }
270
+
271
+ RED.nodes.registerType('ccu-homeassistant', CcuHomeAssistantNode);
272
+ };
@@ -10,6 +10,7 @@
10
10
 
11
11
  topicOutputEvent: {value: 'hm/status/${channelName}/${datapoint}'},
12
12
  topicInputSetValue: {value: 'hm/set/${channelNameOrAddress}/${datapoint}'},
13
+ topicInputGet: {value: 'hm/get/${channelNameOrAddress}/${datapoint}'},
13
14
 
14
15
  topicOutputSysvar: {value: 'hm/status/${name}'},
15
16
  topicInputSysvar: {value: 'hm/set/${name}'},
@@ -17,7 +18,7 @@
17
18
  topicInputPutParam: {value: 'hm/paramset/${channelNameOrAddress}/${paramset}/${param}'},
18
19
  topicInputPutParamset: {value: 'hm/paramset/${channelNameOrAddress}/${paramset}'},
19
20
 
20
- topicInputRpc: {value: 'hm/rpc/${iface}/${method}/${command}/${callid}'},
21
+ topicInputRpc: {value: 'hm/rpc/${iface}/${method}/${callid}'},
21
22
  topicOutputRpc: {value: 'hm/response/${callid}'},
22
23
 
23
24
  topicCounters: {value: 'hm/status/counter/${iface}/${rxtx}'},
@@ -36,6 +37,20 @@
36
37
  labelStyle() {
37
38
  return this.name ? 'node_label_italic' : '';
38
39
  },
40
+ oneditprepare() {
41
+ // The RPC command was never implemented, so its topic template
42
+ // carried a meaningless ${command} segment. Nothing can have
43
+ // depended on it - migrate nodes that still hold the old default.
44
+ if (this.topicInputRpc === 'hm/rpc/${iface}/${method}/${command}/${callid}') {
45
+ this.topicInputRpc = 'hm/rpc/${iface}/${method}/${callid}';
46
+ $('#node-input-topicInputRpc').val(this.topicInputRpc);
47
+ }
48
+
49
+ if (typeof this.topicInputGet === 'undefined') {
50
+ this.topicInputGet = 'hm/get/${channelNameOrAddress}/${datapoint}';
51
+ $('#node-input-topicInputGet').val(this.topicInputGet);
52
+ }
53
+ },
39
54
  oneditsave() {
40
55
  }
41
56
  });
@@ -84,6 +99,10 @@
84
99
  <label for="node-input-topicInputSetValue">setValue</label>
85
100
  <input type="text" id="node-input-topicInputSetValue">
86
101
  </div>
102
+ <div class="form-row">
103
+ <label for="node-input-topicInputGet">get</label>
104
+ <input type="text" id="node-input-topicInputGet">
105
+ </div>
87
106
  <div class="form-row">
88
107
  <label for="node-input-topicOutputSysvar">Sysvar/Program Change</label>
89
108
  <input type="text" id="node-input-topicOutputSysvar">
@@ -100,16 +119,14 @@
100
119
  <label for="node-input-topicInputPutParamset">putParamset</label>
101
120
  <input type="text" id="node-input-topicInputPutParamset">
102
121
  </div>
103
- <!--
104
122
  <div class="form-row">
105
123
  <label for="node-input-topicInputRpc">RPC</label>
106
124
  <input type="text" id="node-input-topicInputRpc">
107
125
  </div>
108
126
  <div class="form-row">
109
- <label for="node-input-topicInputRpc">RPC Response</label>
127
+ <label for="node-input-topicOutputRpc">RPC Response</label>
110
128
  <input type="text" id="node-input-topicOutputRpc">
111
129
  </div>
112
- -->
113
130
  <div class="form-row">
114
131
  <label for="node-input-topicCounters">Message Counters</label>
115
132
  <input type="text" id="node-input-topicCounters">