node-red-contrib-ccu 4.4.4 → 4.5.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 (60) hide show
  1. package/CHANGELOG.md +175 -0
  2. package/README.md +35 -0
  3. package/nodes/ccu-alexa.html +12 -14
  4. package/nodes/ccu-connection.html +63 -95
  5. package/nodes/ccu-connection.js +812 -115
  6. package/nodes/ccu-get-value.html +18 -15
  7. package/nodes/ccu-get-value.js +16 -0
  8. package/nodes/ccu-homeassistant.html +0 -48
  9. package/nodes/ccu-mqtt.html +0 -8
  10. package/nodes/ccu-poll.html +0 -17
  11. package/nodes/ccu-program.html +0 -53
  12. package/nodes/ccu-rpc-event.html +0 -34
  13. package/nodes/ccu-rpc.html +0 -64
  14. package/nodes/ccu-script.html +0 -8
  15. package/nodes/ccu-set-value.html +0 -62
  16. package/nodes/ccu-set-value.js +6 -11
  17. package/nodes/ccu-signal.html +0 -64
  18. package/nodes/ccu-switch.html +12 -24
  19. package/nodes/ccu-sysvar.html +0 -64
  20. package/nodes/ccu-value.html +23 -95
  21. package/nodes/ccu-value.js +8 -2
  22. package/nodes/lib/initretry.js +46 -9
  23. package/nodes/lib/localccu.js +21 -3
  24. package/nodes/lib/metaprovider.js +47 -19
  25. package/nodes/lib/party.js +103 -0
  26. package/nodes/lib/topic.js +22 -1
  27. package/nodes/lib/transport.js +80 -0
  28. package/nodes/locales/de/ccu-alexa.html +4 -0
  29. package/nodes/locales/de/ccu-connection.html +103 -0
  30. package/nodes/locales/de/ccu-get-value.html +16 -0
  31. package/nodes/locales/de/ccu-homeassistant.html +23 -0
  32. package/nodes/locales/de/ccu-mqtt.html +21 -0
  33. package/nodes/locales/de/ccu-poll.html +8 -0
  34. package/nodes/locales/de/ccu-program.html +26 -0
  35. package/nodes/locales/de/ccu-rpc-event.html +32 -0
  36. package/nodes/locales/de/ccu-rpc.html +31 -0
  37. package/nodes/locales/de/ccu-script.html +13 -0
  38. package/nodes/locales/de/ccu-set-value.html +46 -0
  39. package/nodes/locales/de/ccu-signal.html +29 -0
  40. package/nodes/locales/de/ccu-switch.html +18 -0
  41. package/nodes/locales/de/ccu-sysvar.html +43 -0
  42. package/nodes/locales/de/ccu-value.html +98 -0
  43. package/nodes/locales/de/ccu-value.json +1 -0
  44. package/nodes/locales/en-US/ccu-alexa.html +4 -0
  45. package/nodes/locales/en-US/ccu-connection.html +41 -0
  46. package/nodes/locales/en-US/ccu-get-value.html +13 -0
  47. package/nodes/locales/en-US/ccu-homeassistant.html +23 -0
  48. package/nodes/locales/en-US/ccu-mqtt.html +21 -0
  49. package/nodes/locales/en-US/ccu-poll.html +7 -0
  50. package/nodes/locales/en-US/ccu-program.html +25 -0
  51. package/nodes/locales/en-US/ccu-rpc-event.html +31 -0
  52. package/nodes/locales/en-US/ccu-rpc.html +30 -0
  53. package/nodes/locales/en-US/ccu-script.html +13 -0
  54. package/nodes/locales/en-US/ccu-set-value.html +45 -0
  55. package/nodes/locales/en-US/ccu-signal.html +29 -0
  56. package/nodes/locales/en-US/ccu-switch.html +6 -0
  57. package/nodes/locales/en-US/ccu-sysvar.html +42 -0
  58. package/nodes/locales/en-US/ccu-value.html +81 -0
  59. package/nodes/locales/en-US/ccu-value.json +1 -0
  60. package/package.json +1 -1
@@ -137,67 +137,3 @@
137
137
  </div>
138
138
  <div class="form-tips" data-i18n="ccu-sysvar.tip"></div>
139
139
  </script>
140
-
141
- <script type="text/x-red" data-help-name="ccu-sysvar" lang="de-DE">
142
- <p>Setzen und lesen von ReGaHSS Systemvariablen.</p>
143
- <h3>Inputs</h3>
144
- <dl class="message-properties">
145
- <dt>payload
146
- <span class="property-type">*</span>
147
- </dt>
148
- <dd>
149
- Der Wert der der Systemvariablen zugewiesen werden soll.
150
- </dd>
151
- </dl>
152
- <h3>Outputs</h3>
153
- <ol class="node-ports">
154
- <li>Output
155
-
156
- <code>msg</code> Beispiel:<br>
157
- <pre><code>
158
- {
159
- "ccu": "localhost",
160
- "iface": "ReGaHSS",
161
- "type": "SYSVAR",
162
- "name": "Anwesenheit",
163
- "payload": false,
164
- "value": false,
165
- "valueType": "boolean",
166
- "valueEnum": "nicht anwesend",
167
- "ts": 1525197999611,
168
- "lc": 1525197999611,
169
- "enum": ["nicht anwesend", "anwesend"],
170
- "id": 950,
171
- "topic": "ReGaHSS/Anwesenheit"
172
- }
173
- </code></pre>
174
- </li>
175
- </ol>
176
-
177
- <h3>Dynamische Konfiguration</h3>
178
- <p><code>msg.config</code> ist ein Objekt, dessen Eigenschaften die Konfiguration dieses Nodes für genau diese Nachricht überschreiben. Die gespeicherte Konfiguration bleibt unverändert.</p>
179
- <ul>
180
- <li><code>name</code></li>
181
- </ul>
182
- <p>Woher der zu schreibende Wert kommt, stellt das Feld <em>Wert aus</em> ein (msg/flow/global/env) — er muss nicht mehr in <code>msg.payload</code> stehen.</p>
183
- </script>
184
-
185
- <script type="text/x-red" data-help-name="ccu-sysvar" lang="en-US">
186
- <p>Set and read ReGaHSS system variables.</p>
187
- <h3>Inputs</h3>
188
- <dl class="message-properties">
189
- <dt>payload
190
- <span class="property-type">*</span>
191
- </dt>
192
- <dd>
193
- The value that should be assigned to the variable.
194
- </dd>
195
- </dl>
196
-
197
- <h3>Dynamic configuration</h3>
198
- <p><code>msg.config</code> is an object whose properties override this node's configuration for that one message. The stored configuration is never modified.</p>
199
- <ul>
200
- <li><code>name</code></li>
201
- </ul>
202
- <p>The <em>Value from</em> field selects where the value to write comes from (msg/flow/global/env); it no longer has to be <code>msg.payload</code>.</p>
203
- </script>
@@ -17,6 +17,7 @@
17
17
  cache: {value: false},
18
18
  certainOnly: {value: false},
19
19
  queue: {value: false},
20
+ force: {value: false},
20
21
  on: {value: ''},
21
22
  onType: {value: 'undefined'},
22
23
  ramp: {value: ''},
@@ -57,6 +58,7 @@
57
58
  let ifacesLoaded = false;
58
59
  let ifacesPending = false;
59
60
  let ifaceLoadRetries = 5;
61
+ let channelLoadRetries = 30;
60
62
 
61
63
  function loadIfaces(iface, cb) {
62
64
  if (ifacesPending) {
@@ -100,7 +102,16 @@
100
102
 
101
103
  const nodeId = $nodeInputCcuConfig.val();
102
104
  const url = 'ccu?config=' + nodeId + '&type=channels&iface=' + $nodeInputIface.val();
103
- $.getJSON(url, d => {
105
+ $.getJSON(url, (d, _status, xhr) => {
106
+ // task 9: an empty list while the connection is still initialising is "not yet",
107
+ // not "nothing" - keep what is configured, say so, and ask again
108
+ if (Object.keys(d).length === 0 && xhr.getResponseHeader('X-CCU-Loading') && channelLoadRetries-- > 0) {
109
+ $nodeInputChannel.attr('placeholder', 'loading device data ...');
110
+ setTimeout(loadConfig, 2000);
111
+ return;
112
+ }
113
+
114
+ $nodeInputChannel.removeAttr('placeholder');
104
115
  data = d;
105
116
  autocompleteChannel();
106
117
  autocompleteDatapoint();
@@ -179,7 +190,7 @@
179
190
 
180
191
  // ${ENV} placeholders are substituted by Node-RED before the node is
181
192
  // constructed (subflow parameters, #158) - do not clear them
182
- if (!/^\$\{[^}]+\}$/.test($nodeInputChannel.val()) && !data[$nodeInputChannel.val().split(' ')[0]]) {
193
+ if (Object.keys(data).length > 0 && !/^\$\{[^}]+\}$/.test($nodeInputChannel.val()) && !data[$nodeInputChannel.val().split(' ')[0]]) {
183
194
  $nodeInputChannel.val('');
184
195
  $nodeInputDatapoint.val('');
185
196
  }
@@ -466,6 +477,16 @@
466
477
  </div>
467
478
  </div>
468
479
 
480
+ <div class="form-row">
481
+ <label for="node-input-force"><i class="icon-bookmark"></i> </label>
482
+ <div style="width: 70%; display: inline-block; vertical-align: text-top;">
483
+ <label class="ccu-checkbox" for="node-input-force">
484
+ <input type="checkbox" id="node-input-force">
485
+ <span data-i18n="ccu-value.force"></span>
486
+ </label>
487
+ </div>
488
+ </div>
489
+
469
490
  <div class="form-row">
470
491
  <label for="node-input-change"><i class="icon-bookmark"></i> </label>
471
492
  <div style="width: 70%; display: inline-block; vertical-align: text-top;">
@@ -518,96 +539,3 @@
518
539
  <div id="config-lookup-tree"></div>
519
540
  </div>
520
541
  </script>
521
-
522
-
523
-
524
- <script type="text/x-red" data-help-name="ccu-value" lang="de-DE">
525
- <p>Geräte steuern und/oder Events von einem Gerät empfangen.</p>
526
- <h3>Konfiguration</h3>
527
- <ul>
528
- <li><b>CCU:</b> Die zu verwendende CCU-Konfiguration.</li>
529
- <li><b>Topic:</b> <code>msg.topic</code></li>
530
- <li><b>Interface</b>: Hier wird das Interface gewählt, über welches das gewüschnte Geräte angebunden ist.</li>
531
- <li><b>Channel</b>: Der Kanal dessen Datenpunkte verwendet werden sollen. Er besteht aus der Serien- sowie der Kanalnummer. Eine Autovervollständigung hilft bei der Auswahl anhand der Klarnamen.</li>
532
- <li><b>Datapoint</b>: Der Datenpunkt des Kanals, auch hier wird über eine Autovervollständigung die Auswahl vereinfacht.</li>
533
- <li><b>ON_TIME</b>:
534
- Zeit in Sekunden nach der ein Schaltaktor/Dimmer nach dem anschalten wieder ausgeschaltet werden soll.<br>
535
- Dabei ist:
536
- <ul>
537
- <li><code>undefined</code> Keine automatische Ausschaltung</li>
538
- <li><code>number</code> Eine feste Verzögerung in Sekunden</li>
539
- <li><code>global</code>, <code>flow</code> Die Verzögerung in Sekunden wird aus einer Context-Variablen übernommen</li>
540
- <li><code>msg</code> Die Verzögerung in Sekunden wird kann über eine msg property übergeben werden</li>
541
- </ul>
542
-
543
- </li>
544
- <li><b>set Mode</b>:
545
- Eine Erweiterung für Geräte, die sowohl den rx_mode BURST als auch WAKEUP unterstützen wie z.B.:
546
- Batterie-Schaltaktoren oder Rauchmelder mit Sirenenfunktion. Mit dem Parameter kann in diesem Fall angegeben
547
- werden, ob der übergebene Wert über BURST oder WAKEUP übertragen werden soll.
548
- <ul>
549
- <li><b>WAKEUP</b> - Überträgt den Wert sobald sich der entsprechende Empfänger bei der Zentrale meldet.</li>
550
- <li><b>BURST</b> - Versucht alle BURST-Empfänger für die Übertragung aufzuwecken was sich negativ auf den DutyCycle und die Batterielaufzeit aller Burst-Empfänger auswirkt.</li>
551
- </ul>
552
- </li>
553
- <li><b>Flags</b>:
554
- <ul>
555
- <li><b>Nur geänderte Werte ausgeben</b> - Ein Event wird nur dann erzeugt, wenn sich der Wert seit dem letztn Event verändert hat.</li>
556
- <li><b>Während WORKING keine Werte ausgeben</b> - Es gibt Aktoren, z.B. Dimmer oder Rolladen, die während einer Rampe (Öffnen/Schließen eines Rollladen, Dimmen einer Lampe) fortwährend den aktuellen Zustand ausgeben. Um nur den End-Status auszugeben kann diese Option benutzt werden. Gebräuchlich auch bei Verwendung mit Dashboard-Nodes um "springende" Slider zu verhindern.</li>
557
- <li><b>Beim Start letzten bekannten Wert ausgeben</b> - Wenn Node-RED gestartet wird, dann wird der letzte Wert (sofern in der ReGaHSS bekannt) ausgegeben. Z.B. um beim Start einen korrekten Wert an das Dashboard zu übergeben. <b>Achtung:</b> </li>
558
- </ul>
559
-
560
- </li>
561
- </ul>
562
-
563
- <h3>Input</h3>
564
- <p>
565
- Für den Input des Value Node wird <code>msg.payload</code> als der Wert verwendet der in den Datenpunkt geschrieben wird.
566
-
567
- Die Bestimmung des Datenpunkts sowie des Kanals und der Schnittstellen können hier dynamisch sein. Dazu werden die Eingabefelder leer gelassen und über die eingehende <code>msg</code> gesetzt.
568
- </p>
569
- <b>Option 1</b>: Interface, Channel und Datapoint können in der msg als Properties angegeben werden:
570
- <code>
571
- <pre>{
572
- "interface": "BidCos-RF",
573
- "channel": "OEQ1868878:1",
574
- "datapoint": "STATE",
575
- "payload": true
576
- }</pre>
577
- </code>
578
-
579
- <b>Option 2</b>: Der Datenpunkt kann über <code>msg.topic</code> angegeben werden wobei Interface, Channel und Datapoint mit Punkt getrennt werden:
580
- <code>
581
- <pre>{
582
- "topic": "BidCos-RF.OEQ1868878:1.STATE",
583
- "payload": true
584
- }</pre>
585
- </code>
586
-
587
-
588
- <h3>Output</h3>
589
- <p>Der Value Node gibt nur Events aus, wenn Interface, Channel und Datapoint gesetzt sind.</p>
590
-
591
-
592
- <h3>Dynamische Konfiguration</h3>
593
- <p><code>msg.config</code> ist ein Objekt, dessen Eigenschaften die Konfiguration dieses Nodes für genau diese Nachricht überschreiben. Die gespeicherte Konfiguration bleibt unverändert.</p>
594
- <ul>
595
- <li><code>iface</code></li>
596
- <li><code>channel</code></li>
597
- <li><code>datapoint</code></li>
598
- </ul>
599
- <p>Eine Nachricht ohne <code>payload</code> schreibt nichts, richtet aber die Ausgabe-Seite des Nodes neu aus.</p>
600
- </script>
601
-
602
- <script type="text/x-red" data-help-name="ccu-value" lang="en-US">
603
- <p>Set and read channel datapoints.</p>
604
-
605
- <h3>Dynamic configuration</h3>
606
- <p><code>msg.config</code> is an object whose properties override this node's configuration for that one message. The stored configuration is never modified.</p>
607
- <ul>
608
- <li><code>iface</code></li>
609
- <li><code>channel</code></li>
610
- <li><code>datapoint</code></li>
611
- </ul>
612
- <p>A message without a <code>payload</code> writes nothing but retargets the node's output side.</p>
613
- </script>
@@ -1,10 +1,11 @@
1
+ const {parseTopicAddress} = require('./lib/topic.js');
1
2
  const path = require('path');
2
3
 
3
4
  const statusHelper = require(path.join(__dirname, '/lib/status.js'));
4
5
  const {effectiveConfig, configCacheKey} = require(path.join(__dirname, '/lib/dynconfig.js'));
5
6
 
6
7
  /** What a message may supply or override (B-2, #172). */
7
- const DYNAMIC_KEYS = ['iface', 'channel', 'datapoint'];
8
+ const DYNAMIC_KEYS = ['iface', 'channel', 'datapoint', 'force'];
8
9
 
9
10
  module.exports = function (RED) {
10
11
  class CcuValue {
@@ -31,7 +32,9 @@ module.exports = function (RED) {
31
32
  });
32
33
 
33
34
  this.on('input', (message, send, done) => {
34
- const [tIface, tChannel, tDatapoint] = (message.topic || '').split('.');
35
+ // task 6 (#39): the address may come as `iface.channel.datapoint` or in the
36
+ // shape the nodes emit, `${CCU}/${Interface}/${channel}/${datapoint}`
37
+ const {iface: tIface, channel: tChannel, datapoint: tDatapoint} = parseTopicAddress(message.topic);
35
38
  // msg.config wins over the node's own configuration; the flat
36
39
  // msg.iface/channel/datapoint chain below is unchanged
37
40
  const {config: over} = effectiveConfig({}, message, DYNAMIC_KEYS);
@@ -46,6 +49,8 @@ module.exports = function (RED) {
46
49
  ''
47
50
  ).split(' ')[0];
48
51
  const datapoint = over.datapoint || config.datapoint || message.datapoint || tDatapoint;
52
+ // B-19: force writes through the queue's dedupe (as set-value has it)
53
+ const force = typeof over.force === 'undefined' ? Boolean(config.force) : Boolean(over.force);
49
54
 
50
55
  if (!iface) {
51
56
  this.error('interface undefined');
@@ -119,6 +124,7 @@ module.exports = function (RED) {
119
124
  datapoint,
120
125
  message.payload,
121
126
  config.burst,
127
+ force,
122
128
  )
123
129
  .then(() => {
124
130
  done();
@@ -5,16 +5,20 @@
5
5
  * addon started before the interfaces, a daemon restart) used to leave the
6
6
  * interface without events: rpcCheckInit only runs with cached devices and
7
7
  * then waits pingTimeout (600 s for HmIP-RF). Now a failed init schedules its
8
- * own retry, 2 s, 4 s, 8 s, 16 s and then every 30 s, until it succeeds or the
9
- * node is closed.
8
+ * own retry, 1 s, 2 s, 4 s, 8 s and then every 15 s (task 12), until it
9
+ * succeeds or the node is closed.
10
10
  *
11
- * While the process is unreachable the interface is "waiting": one warn line,
12
- * then debug. A fault answer (the process is up and refuses the init) stays an
13
- * error, logged once per distinct fault.
11
+ * While the process is unreachable the interface is "waiting": one line, then
12
+ * debug. Before the interface was ever connected, a refused connection is
13
+ * info - the process is still starting (openccu-lite's early start starts
14
+ * Node-RED before rfd and hmipserver) - and every other transport error, or a
15
+ * refusal after it had been connected, is a warning. A fault answer (the
16
+ * process is up and refuses the init) stays an error, logged once per
17
+ * distinct fault.
14
18
  */
15
19
 
16
- const RETRY_DELAYS = [2000, 4000, 8000, 16000];
17
- const RETRY_MAX_DELAY = 30000;
20
+ const RETRY_DELAYS = [1000, 2000, 4000, 8000];
21
+ const RETRY_MAX_DELAY = 15000;
18
22
 
19
23
  /**
20
24
  * The delay before the next attempt.
@@ -39,6 +43,15 @@ function isFault(error) {
39
43
  return Boolean(error) && typeof error === 'object' && error.faultCode !== undefined;
40
44
  }
41
45
 
46
+ /**
47
+ * Whether nothing listens on the port (yet): the process is not started.
48
+ * @param {*} error
49
+ * @returns {boolean}
50
+ */
51
+ function isRefused(error) {
52
+ return Boolean(error) && (error.code === 'ECONNREFUSED' || /ECONNREFUSED/.test(String(error.message || '')));
53
+ }
54
+
42
55
  /**
43
56
  * A short reason for the log line, e.g. "connect ECONNREFUSED".
44
57
  * @param {*} error
@@ -57,6 +70,17 @@ function describeError(error) {
57
70
  return (error.syscall ? error.syscall + ' ' : 'connect ') + error.code;
58
71
  }
59
72
 
73
+ // task 5 (#27): the CCU's web server answers a 401 (authentication on, the
74
+ // credentials missing or wrong) with an HTML page, and the XML-RPC client
75
+ // reports only the parser's complaint about it - say what that means
76
+ if (/^Unknown XML-RPC tag '(HTML|HEAD|META|BODY|TITLE|!DOCTYPE)/i.test(String(error.message || ''))) {
77
+ return (
78
+ 'the CCU answered a web page instead of XML-RPC (' +
79
+ error.message +
80
+ ') - authentication is on and the credentials are missing or wrong, or this is not an RPC port'
81
+ );
82
+ }
83
+
60
84
  return error.message || String(error);
61
85
  }
62
86
 
@@ -77,6 +101,7 @@ class InitRetry {
77
101
  this.timers = timers || {setTimeout, clearTimeout};
78
102
  this.timer = null;
79
103
  this.stopped = false;
104
+ this.everConnected = false;
80
105
  this.reset();
81
106
  }
82
107
 
@@ -122,7 +147,18 @@ class InitRetry {
122
147
  );
123
148
  } else {
124
149
  this.warned = true;
125
- this.logger.warn(this.iface + ' not reachable yet (' + reason + '), retrying');
150
+ if (!this.everConnected && isRefused(error)) {
151
+ this.logger.info(
152
+ this.iface +
153
+ ' not listening yet (' +
154
+ reason +
155
+ '), waiting for it - retrying in ' +
156
+ seconds +
157
+ ' s',
158
+ );
159
+ } else {
160
+ this.logger.warn(this.iface + ' not reachable yet (' + reason + '), retrying');
161
+ }
126
162
  }
127
163
 
128
164
  this.onState('waiting');
@@ -160,6 +196,7 @@ class InitRetry {
160
196
  this.logger.info(this.iface + ' connected after ' + (this.failures + 1) + ' attempts');
161
197
  }
162
198
 
199
+ this.everConnected = true;
163
200
  this.reset();
164
201
  this.onState('connected');
165
202
  }
@@ -178,4 +215,4 @@ class InitRetry {
178
215
  }
179
216
  }
180
217
 
181
- module.exports = {InitRetry, retryDelay, isFault, describeError, RETRY_DELAYS, RETRY_MAX_DELAY};
218
+ module.exports = {InitRetry, retryDelay, isFault, isRefused, describeError, RETRY_DELAYS, RETRY_MAX_DELAY};
@@ -17,7 +17,15 @@
17
17
  Rather than chasing lighttpd's include chain, ask the kernel: a
18
18
  listener on the direct port is exactly the condition that makes using
19
19
  it correct. /proc/net/tcp is a plain file, so this stays synchronous -
20
- the interface table is built in the connection node's constructor. */
20
+ the interface table is built in the connection node's constructor.
21
+
22
+ A listener alone misses a start before rfd: openccu-lite starts an addon
23
+ that copes with it before the interface processes (its early start), so
24
+ the ports are not open yet and the connection went through the proxy for
25
+ good. The CCU's own interface list, /etc/config/InterfacesList.xml, is
26
+ there from the first second on every CCU firmware (CCU3, OpenCCU,
27
+ openccu-lite), so a loopback host with that file is the CCU we run on as
28
+ well. */
21
29
 
22
30
  const fs = require('fs');
23
31
 
@@ -26,6 +34,9 @@ const LOCAL_PROBE_PORTS = [32001, 31999];
26
34
 
27
35
  const PROC_NET_TCP = ['/proc/net/tcp', '/proc/net/tcp6'];
28
36
 
37
+ /** The CCU firmware's list of its interface processes. */
38
+ const INTERFACES_LIST = '/etc/config/InterfacesList.xml';
39
+
29
40
  /** /proc/net/tcp connection state 0A = TCP_LISTEN. */
30
41
  const TCP_LISTEN = '0A';
31
42
 
@@ -67,6 +78,7 @@ function listeningPorts(files) {
67
78
  * @param {string} host the configured CCU host
68
79
  * @param {object} [options]
69
80
  * @param {string[]} [options.files] /proc files to read, for tests
81
+ * @param {string} [options.interfacesList] the CCU's interface list, for tests
70
82
  * @returns {boolean}
71
83
  */
72
84
  function isLocalCcu(host, options) {
@@ -75,7 +87,13 @@ function isLocalCcu(host, options) {
75
87
  }
76
88
 
77
89
  const ports = listeningPorts(options && options.files);
78
- return LOCAL_PROBE_PORTS.some((port) => ports.has(port));
90
+ if (LOCAL_PROBE_PORTS.some((port) => ports.has(port))) {
91
+ return true;
92
+ }
93
+
94
+ // the interface processes are not up yet (a start before them): the
95
+ // firmware's interface list still says this is the CCU
96
+ return fs.existsSync((options && options.interfacesList) || INTERFACES_LIST);
79
97
  }
80
98
 
81
- module.exports = {isLocalCcu, listeningPorts, LOCAL_PROBE_PORTS};
99
+ module.exports = {isLocalCcu, listeningPorts, LOCAL_PROBE_PORTS, INTERFACES_LIST};
@@ -281,38 +281,65 @@ function readBody(res) {
281
281
  });
282
282
  }
283
283
 
284
+ /** lighttpd answers these while the api behind it is not up yet - the box is starting, nothing is decided */
285
+ const INCONCLUSIVE_STATUS = new Set([502, 503, 504]);
286
+
284
287
  /**
285
- * Feature detection: `GET /api/meta/v1/version` answers on openccu-lite and
286
- * needs no credential; a CCU / RaspberryMatic / OpenCCU answers 404 or HTML.
287
- * Never rejects - a box that is simply unreachable is not openccu-lite either.
288
+ * Feature detection with its outcome: `GET /api/meta/v1/version` answers on
289
+ * openccu-lite and needs no credential; a CCU / RaspberryMatic / OpenCCU
290
+ * answers 404 or HTML - that is a conclusive "no". A timeout, a refused
291
+ * connection or a 502/503/504 decides nothing (B-28: a busy or still starting
292
+ * box), so the caller can ask again instead of settling for the ReGa.
293
+ * Never rejects.
288
294
  * @param {object} options host, port, tls, insecure, timeout, logger
289
- * @returns {Promise<object|null>} the version document, or null
295
+ * @returns {Promise<{info: object|null, inconclusive: boolean, reason?: string}>}
290
296
  */
291
- async function detect(options) {
297
+ async function detectOutcome(options) {
292
298
  const {logger = SILENT} = options;
299
+ let status;
300
+ let body;
293
301
  try {
294
- const {status, res} = await request({
302
+ const answer = await request({
295
303
  ...options,
296
304
  path: API_PATH + '/version',
297
305
  token: undefined,
298
306
  timeout: options.timeout || 5000,
299
307
  });
300
- const body = await readBody(res);
301
- if (status !== 200) {
302
- logger.debug('meta api detection: status ' + status);
303
- return null;
304
- }
305
-
306
- const info = JSON.parse(body);
307
- if (!info || info.api !== 'meta' || typeof info.version !== 'number') {
308
- return null;
309
- }
310
-
311
- return info;
308
+ status = answer.status;
309
+ body = await readBody(answer.res);
312
310
  } catch (error) {
313
311
  logger.debug('meta api detection: ' + error.message);
314
- return null;
312
+ return {info: null, inconclusive: true, reason: error.message};
313
+ }
314
+
315
+ if (status !== 200) {
316
+ logger.debug('meta api detection: status ' + status);
317
+ return {info: null, inconclusive: INCONCLUSIVE_STATUS.has(status), reason: 'status ' + status};
315
318
  }
319
+
320
+ let info = null;
321
+ try {
322
+ info = JSON.parse(body);
323
+ } catch {
324
+ info = null;
325
+ }
326
+
327
+ if (!info || info.api !== 'meta' || typeof info.version !== 'number') {
328
+ return {info: null, inconclusive: false, reason: 'not the metadata api'};
329
+ }
330
+
331
+ return {info, inconclusive: false};
332
+ }
333
+
334
+ /**
335
+ * Feature detection: the version document of an openccu-lite, or null for
336
+ * anything else (a CCU, an unreachable box). See detectOutcome() for the
337
+ * difference between "no" and "not now".
338
+ * @param {object} options host, port, tls, insecure, timeout, logger
339
+ * @returns {Promise<object|null>} the version document, or null
340
+ */
341
+ async function detect(options) {
342
+ return (await detectOutcome(options)).info;
316
343
  }
317
344
 
318
345
  /**
@@ -708,6 +735,7 @@ module.exports = {
708
735
  MetaProvider,
709
736
  buildNames,
710
737
  detect,
738
+ detectOutcome,
711
739
  flattenEnum,
712
740
  memberNames,
713
741
  readLocalToken,
@@ -0,0 +1,103 @@
1
+ /* Party mode on HmIP thermostats (task 2, #156, #161).
2
+
3
+ An HmIP HEATING_CLIMATECONTROL_TRANSCEIVER channel carries PARTY_TIME_START,
4
+ PARTY_TIME_END and PARTY_SET_POINT_TEMPERATURE (and the read-only
5
+ PARTY_MODE). The device takes the three only together: a setValue of one
6
+ of them resets the other two - PARTY_TIME_START alone puts PARTY_TIME_END
7
+ back to `1999_11_30 00:00` (#156). The CCU's WebUI writes them as one
8
+ putParamset on VALUES, and so do we: a write to one of the three is merged
9
+ with the current values of the other two into one putParamset.
10
+
11
+ The times are strings in the device's own format, `YYYY_MM_DD HH:MM` in
12
+ local time (minutes in steps of five - the device rounds). A Date, an ISO
13
+ string or epoch milliseconds are formatted for it; a string already in the
14
+ format passes through.
15
+
16
+ The older HM thermostats (PARTY_MODE_SUBMIT, the PARTY_START_ and PARTY_STOP_ fields)
17
+ are another protocol and not covered here. Pure. */
18
+
19
+ const PARTY_DATAPOINTS = ['PARTY_TIME_START', 'PARTY_TIME_END', 'PARTY_SET_POINT_TEMPERATURE'];
20
+ const PARTY_TIMES = ['PARTY_TIME_START', 'PARTY_TIME_END'];
21
+ const TIME_FORMAT = /^\d{4}_\d{2}_\d{2} \d{2}:\d{2}$/;
22
+
23
+ /**
24
+ * Is this a write the device only takes together with the other two?
25
+ * @param {string} datapoint
26
+ * @param {object} [valuesDescription] the channel's VALUES paramset description
27
+ * @returns {boolean}
28
+ */
29
+ function isPartyWrite(datapoint, valuesDescription) {
30
+ return (
31
+ PARTY_DATAPOINTS.includes(datapoint) &&
32
+ Boolean(valuesDescription) &&
33
+ PARTY_DATAPOINTS.every((name) => Boolean(valuesDescription[name]))
34
+ );
35
+ }
36
+
37
+ /**
38
+ * The device's time string for a value: a Date, epoch milliseconds, an ISO
39
+ * (or Date-parsable) string, or a string already in the format.
40
+ * @param {*} value
41
+ * @returns {string} the formatted time, or the value as a string when it cannot be read as a time
42
+ */
43
+ function formatPartyTime(value) {
44
+ if (typeof value === 'string' && TIME_FORMAT.test(value)) {
45
+ return value;
46
+ }
47
+
48
+ let date;
49
+ if (value instanceof Date) {
50
+ date = value;
51
+ } else if (typeof value === 'number') {
52
+ date = new Date(value);
53
+ } else if (typeof value === 'string' && value.trim() !== '') {
54
+ date = new Date(value);
55
+ }
56
+
57
+ if (!date || Number.isNaN(date.getTime())) {
58
+ return String(value);
59
+ }
60
+
61
+ const pad = (n) => String(n).padStart(2, '0');
62
+ return (
63
+ date.getFullYear() +
64
+ '_' +
65
+ pad(date.getMonth() + 1) +
66
+ '_' +
67
+ pad(date.getDate()) +
68
+ ' ' +
69
+ pad(date.getHours()) +
70
+ ':' +
71
+ pad(date.getMinutes())
72
+ );
73
+ }
74
+
75
+ /**
76
+ * The complete VALUES paramset for one party write: the written datapoint
77
+ * plus the current values of the other two.
78
+ * @param {string} datapoint the one being written
79
+ * @param {*} value its new value
80
+ * @param {object} current the other datapoints' current values by name (undefined when unknown)
81
+ * @returns {{values: object, missing: string[]}} the merged three, and the names no current value was known for
82
+ */
83
+ function mergePartyValues(datapoint, value, current) {
84
+ const values = {};
85
+ const missing = [];
86
+ for (const name of PARTY_DATAPOINTS) {
87
+ let v = name === datapoint ? value : current && current[name];
88
+ if (v === undefined || v === null) {
89
+ missing.push(name);
90
+ continue;
91
+ }
92
+
93
+ if (PARTY_TIMES.includes(name)) {
94
+ v = formatPartyTime(v);
95
+ }
96
+
97
+ values[name] = v;
98
+ }
99
+
100
+ return {values, missing};
101
+ }
102
+
103
+ module.exports = {PARTY_DATAPOINTS, isPartyWrite, formatPartyTime, mergePartyValues};
@@ -35,4 +35,25 @@ function topicReplace(topic, message) {
35
35
  return topic;
36
36
  }
37
37
 
38
- module.exports = {topicReplace};
38
+ /**
39
+ * The datapoint address in an incoming msg.topic (task 6, #39). Two shapes:
40
+ * the value node's old input form `iface.channel.datapoint` (dots), and the
41
+ * shape the nodes emit by default, `${CCU}/${Interface}/${channel}/${datapoint}`
42
+ * or without the CCU part - with slashes, the last three segments count, so
43
+ * an event's topic can be fed straight back into a value node.
44
+ * @param {string} topic
45
+ * @returns {{iface: string|undefined, channel: string|undefined, datapoint: string|undefined}}
46
+ */
47
+ function parseTopicAddress(topic) {
48
+ const text = typeof topic === 'string' ? topic : '';
49
+ if (text.includes('/')) {
50
+ const parts = text.split('/').filter((part) => part !== '');
51
+ const [iface, channel, datapoint] = parts.slice(-3);
52
+ return parts.length >= 3 ? {iface, channel, datapoint} : {};
53
+ }
54
+
55
+ const [iface, channel, datapoint] = text.split('.');
56
+ return {iface, channel, datapoint};
57
+ }
58
+
59
+ module.exports = {topicReplace, parseTopicAddress};