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
package/nodes/ccu-mqtt.js CHANGED
@@ -2,6 +2,19 @@ const path = require('path');
2
2
  const mw = require('mqtt-wildcard');
3
3
 
4
4
  const statusHelper = require(path.join(__dirname, '/lib/status.js'));
5
+ const {castValue} = require(path.join(__dirname, '/lib/cast.js'));
6
+
7
+ /** HM thermostats: CONTROL_MODE is read-only, the modes are set through actions */
8
+ const HM_MODES = {
9
+ 'AUTO-MODE': () => ['AUTO_MODE', true],
10
+ 'MANU-MODE': (setpoint) => ['MANU_MODE', setpoint],
11
+ 'BOOST-MODE': () => ['BOOST_MODE', true],
12
+ 'COMFORT-MODE': () => ['COMFORT_MODE', true],
13
+ 'LOWERING-MODE': () => ['LOWERING_MODE', true],
14
+ };
15
+
16
+ /** Words Home Assistant sends to LEVEL; 1.005 is the actuators' "old level" value */
17
+ const LEVEL_WORDS = {OPEN: 1, CLOSE: 0, ON: 1.005, OFF: 0};
5
18
 
6
19
  module.exports = function (RED) {
7
20
  class CcuMqttNode {
@@ -23,7 +36,10 @@ module.exports = function (RED) {
23
36
  this.topicInputPutParam = config.topicInputPutParam;
24
37
  this.topicInputPutParamset = config.topicInputPutParamset;
25
38
 
39
+ this.topicInputGet = config.topicInputGet;
40
+
26
41
  this.topicInputRpc = config.topicInputRpc;
42
+ this.topicOutputRpc = config.topicOutputRpc;
27
43
 
28
44
  this.topicCounters = config.topicCounters;
29
45
  this.rxCounters = {};
@@ -98,12 +114,15 @@ module.exports = function (RED) {
98
114
  }
99
115
 
100
116
  event(message) {
101
- const topic = this.ccu.topicReplace(this.topicOutputEvent, message);
117
+ // channels without a ReGa name used to produce hm/status//STATE-style
118
+ // topics — fall back to the address (B-3)
119
+ const topicMessage = message.channelName ? message : {...message, channelName: message.channel};
120
+ const topic = this.ccu.topicReplace(this.topicOutputEvent, topicMessage);
102
121
  const retain = !(message.datapoint && message.datapoint.startsWith('PRESS_'));
103
122
  this.send({topic, payload: this.output(message), retain});
104
123
 
105
124
  if (['LEVEL', 'STATE'].includes(message.datapoint) && message.working === false) {
106
- const messageNotWorking = RED.util.cloneMessage(message);
125
+ const messageNotWorking = RED.util.cloneMessage(topicMessage);
107
126
  messageNotWorking.datapoint += '_NOTWORKING';
108
127
  messageNotWorking.datapointName += '_NOTWORKING';
109
128
  this.send({
@@ -164,6 +183,7 @@ module.exports = function (RED) {
164
183
  this.debug('input ' + topic + ' ' + JSON.stringify(payload).slice(0, 40));
165
184
 
166
185
  const topicList = {
186
+ get: this.topicInputGet,
167
187
  setValue: this.topicInputSetValue,
168
188
  sysvar: this.topicInputSysvar,
169
189
  putParam: this.topicInputPutParam,
@@ -174,7 +194,8 @@ module.exports = function (RED) {
174
194
  let command;
175
195
  let filter;
176
196
  Object.keys(topicList).forEach((key) => {
177
- if (!command) {
197
+ // a topic left empty in the editor disables that command
198
+ if (!command && topicList[key]) {
178
199
  const parts = topicList[key].split('/');
179
200
  const patternArray = [];
180
201
  const placeholders = [];
@@ -205,33 +226,177 @@ module.exports = function (RED) {
205
226
  }
206
227
  }
207
228
 
208
- setValue(filter, payload) {
229
+ /**
230
+ * Resolve `channelNameOrAddress` to a channel address and its
231
+ * interface, reporting the same errors the commands used to report
232
+ * inline.
233
+ * @param {object} filter parsed from the topic; `channel` is filled in
234
+ * @param {boolean} [exact] pass through to findChannel
235
+ * @returns {string|null} the interface, or null when unresolvable
236
+ */
237
+ resolveChannel(filter, exact) {
209
238
  if (filter.channelNameOrAddress) {
210
239
  if (this.ccu.channelNames[filter.channelNameOrAddress]) {
211
240
  filter.channel = filter.channelNameOrAddress;
212
241
  } else {
213
- filter.channel = this.ccu.findChannel(filter.channelNameOrAddress, true);
242
+ filter.channel = this.ccu.findChannel(filter.channelNameOrAddress, exact);
214
243
  }
215
244
 
216
245
  if (!filter.channel) {
217
246
  this.error('channel ' + filter.channelNameOrAddress + ' not found');
218
- return;
247
+ return null;
219
248
  }
220
249
  }
221
250
 
222
251
  if (!filter.channel) {
223
252
  this.error('channel undefined');
224
- return;
253
+ return null;
225
254
  }
226
255
 
227
256
  const iface = this.ccu.findIface(filter.channel);
228
257
 
229
258
  if (!iface) {
230
259
  this.error('no interface found for channel ' + filter.channel);
260
+ return null;
261
+ }
262
+
263
+ return iface;
264
+ }
265
+
266
+ /**
267
+ * Republish the last known value of a datapoint on its status topic,
268
+ * so a flow can ask for a refresh over MQTT instead of waiting for the
269
+ * next event (#115). An empty datapoint republishes the whole channel.
270
+ * @param {object} filter
271
+ */
272
+ get(filter) {
273
+ const iface = this.resolveChannel(filter, true);
274
+ if (!iface) {
275
+ return;
276
+ }
277
+
278
+ const prefix = iface + '.' + filter.channel + '.';
279
+ const names = filter.datapoint
280
+ ? [prefix + filter.datapoint]
281
+ : Object.keys(this.ccu.values).filter((name) => name.startsWith(prefix));
282
+
283
+ if (names.length === 0) {
284
+ this.error('unknown datapoint ' + prefix + (filter.datapoint || '#'));
285
+ return;
286
+ }
287
+
288
+ names.forEach((name) => {
289
+ const value = this.ccu.values[name];
290
+ if (value) {
291
+ this.event({...value});
292
+ } else {
293
+ this.error('unknown datapoint ' + name);
294
+ }
295
+ });
296
+ }
297
+
298
+ /**
299
+ * Call an arbitrary RPC method on an interface and publish the result
300
+ * (#22). The topic supplies interface, method and a caller-chosen id
301
+ * that the response topic echoes; the payload is the parameter array
302
+ * (a single value is accepted and wrapped).
303
+ * @param {object} filter
304
+ * @param {*} payload
305
+ */
306
+ rpc(filter, payload) {
307
+ if (!filter.iface) {
308
+ this.error('interface undefined');
309
+ return;
310
+ }
311
+
312
+ if (!filter.method) {
313
+ this.error('method undefined');
314
+ return;
315
+ }
316
+
317
+ let parameters = payload;
318
+ if (parameters === undefined || parameters === null || parameters === '') {
319
+ parameters = [];
320
+ } else if (!Array.isArray(parameters)) {
321
+ parameters = [parameters];
322
+ }
323
+
324
+ const respond = (result, error) => {
325
+ if (!this.topicOutputRpc) {
326
+ return;
327
+ }
328
+
329
+ this.send({
330
+ topic: this.ccu.topicReplace(this.topicOutputRpc, {
331
+ iface: filter.iface,
332
+ method: filter.method,
333
+ callid: filter.callid,
334
+ }),
335
+ payload: error ? {error: error.message} : {result},
336
+ retain: false,
337
+ });
338
+ };
339
+
340
+ this.ccu
341
+ .methodCall(filter.iface, filter.method, parameters)
342
+ .then((result) => respond(result))
343
+ .catch((error) => {
344
+ this.error(error.message);
345
+ respond(undefined, error);
346
+ });
347
+ }
348
+
349
+ setValue(filter, payload) {
350
+ const iface = this.resolveChannel(filter, true);
351
+ if (!iface) {
231
352
  return;
232
353
  }
233
354
 
234
- this.ccu.setValue(iface, filter.channel, filter.datapoint, payload).catch(() => {});
355
+ const command = this.translateCommand(iface, filter.channel, filter.datapoint, payload);
356
+ this.ccu.setValue(iface, filter.channel, command.datapoint, command.payload).catch(() => {});
357
+ }
358
+
359
+ /**
360
+ * Words Home Assistant's single-topic conventions send (see the
361
+ * ccu-homeassistant node): LEVEL accepts OPEN, CLOSE, STOP, ON (restore the
362
+ * last level) and OFF; HM thermostats accept the CONTROL_MODE names
363
+ * (AUTO-MODE, MANU-MODE, BOOST-MODE, COMFORT-MODE, LOWERING-MODE), which
364
+ * are translated to the *_MODE actions because CONTROL_MODE itself is
365
+ * read-only there. Everything else passes through unchanged.
366
+ * @returns {{datapoint: string, payload: *}}
367
+ */
368
+ translateCommand(iface, channel, datapoint, payload) {
369
+ if (typeof payload !== 'string') {
370
+ return {datapoint, payload};
371
+ }
372
+
373
+ const word = payload.trim().toUpperCase();
374
+ const device = this.ccu.metadata.devices[iface] && this.ccu.metadata.devices[iface][channel];
375
+ const description = (device && this.ccu.getParamsetDescription(iface, device, 'VALUES')) || {};
376
+
377
+ if (datapoint === 'LEVEL') {
378
+ if (word === 'STOP' && description.STOP) {
379
+ return {datapoint: 'STOP', payload: true};
380
+ }
381
+
382
+ if (word in LEVEL_WORDS) {
383
+ return {datapoint, payload: LEVEL_WORDS[word]};
384
+ }
385
+ }
386
+
387
+ if (
388
+ datapoint === 'CONTROL_MODE' &&
389
+ HM_MODES[word] &&
390
+ description.CONTROL_MODE &&
391
+ !(description.CONTROL_MODE.OPERATIONS & 2)
392
+ ) {
393
+ const current = this.ccu.values[iface + '.' + channel + '.SET_TEMPERATURE'];
394
+ const setpoint = current && typeof current.value === 'number' ? current.value : 20;
395
+ const [dp, value] = HM_MODES[word](setpoint);
396
+ return {datapoint: dp, payload: value};
397
+ }
398
+
399
+ return {datapoint, payload};
235
400
  }
236
401
 
237
402
  sysvar(filter, payload) {
@@ -254,28 +419,8 @@ module.exports = function (RED) {
254
419
  }
255
420
 
256
421
  putParam(filter, payload) {
257
- if (filter.channelNameOrAddress) {
258
- if (this.ccu.channelNames[filter.channelNameOrAddress]) {
259
- filter.channel = filter.channelNameOrAddress;
260
- } else {
261
- filter.channel = this.ccu.findChannel(filter.channelNameOrAddress);
262
- }
263
-
264
- if (!filter.channel) {
265
- this.error('channel ' + filter.channelNameOrAddress + ' not found');
266
- return;
267
- }
268
- }
269
-
270
- if (!filter.channel) {
271
- this.error('channel undefined');
272
- return;
273
- }
274
-
275
- const iface = this.ccu.findIface(filter.channel);
276
-
422
+ const iface = this.resolveChannel(filter);
277
423
  if (!iface) {
278
- this.error('no interface found for channel ' + filter.channel);
279
424
  return;
280
425
  }
281
426
 
@@ -286,11 +431,14 @@ module.exports = function (RED) {
286
431
  );
287
432
  const paramsetDescription = this.ccu.paramsetDescriptions[psName];
288
433
  if (paramsetDescription && paramsetDescription[filter.param]) {
289
- if (!paramsetDescription[filter.param].OPERATIONS && 2) {
434
+ // was `!(OPERATIONS) && 2`, which is always false - the check
435
+ // never fired and the write went out regardless (B-3)
436
+ if (!(paramsetDescription[filter.param].OPERATIONS & 2)) {
290
437
  this.error('param ' + filter.param + ' not writeable');
438
+ return;
291
439
  }
292
440
 
293
- payload = this.paramCast(payload, paramsetDescription[filter.param]);
441
+ payload = castValue(payload, paramsetDescription[filter.param], {clamp: true});
294
442
  } else {
295
443
  this.warn('unknown paramset/param ' + filter.paramset + ' ' + filter.param);
296
444
  }
@@ -309,28 +457,8 @@ module.exports = function (RED) {
309
457
  return;
310
458
  }
311
459
 
312
- if (filter.channelNameOrAddress) {
313
- if (this.ccu.channelNames[filter.channelNameOrAddress]) {
314
- filter.channel = filter.channelNameOrAddress;
315
- } else {
316
- filter.channel = this.ccu.findChannel(filter.channelNameOrAddress);
317
- }
318
-
319
- if (!filter.channel) {
320
- this.error('channel ' + filter.channelNameOrAddress + ' not found');
321
- return;
322
- }
323
- }
324
-
325
- if (!filter.channel) {
326
- this.error('channel undefined');
327
- return;
328
- }
329
-
330
- const iface = this.ccu.findIface(filter.channel);
331
-
460
+ const iface = this.resolveChannel(filter);
332
461
  if (!iface) {
333
- this.error('no interface found for channel ' + filter.channel);
334
462
  return;
335
463
  }
336
464
 
@@ -345,11 +473,17 @@ module.exports = function (RED) {
345
473
 
346
474
  Object.keys(payload).forEach((parameter) => {
347
475
  if (paramsetDescription && paramsetDescription[parameter]) {
348
- if (!paramsetDescription[parameter].OPERATIONS && 2) {
476
+ // was `!(OPERATIONS) && 2` (always false), and the cast
477
+ // used paramsetDescription[filter.param], which is
478
+ // undefined here - so every value went out uncast (B-3)
479
+ if (!(paramsetDescription[parameter].OPERATIONS & 2)) {
349
480
  this.error('param ' + parameter + ' not writeable');
481
+ return;
350
482
  }
351
483
 
352
- paramset[parameter] = this.paramCast(payload[parameter], paramsetDescription[filter.param]);
484
+ paramset[parameter] = castValue(payload[parameter], paramsetDescription[parameter], {
485
+ clamp: true,
486
+ });
353
487
  } else {
354
488
  this.warn('unknown paramset/param ' + filter.paramset + ' ' + parameter);
355
489
  paramset[parameter] = payload[parameter];
@@ -360,57 +494,6 @@ module.exports = function (RED) {
360
494
  .methodCall(iface, 'putParamset', [filter.channel, filter.paramset, paramset])
361
495
  .catch((error) => this.error(error.message));
362
496
  }
363
-
364
- paramCast(value, paramset) {
365
- switch (paramset && paramset.TYPE) {
366
- case 'BOOL':
367
- // Fallthrough by intention
368
- case 'ACTION':
369
- // OMG this is so ugly...
370
- if (value === 'false') {
371
- value = false;
372
- } else if (!isNaN(value)) {
373
- // Make sure that the string "0" gets casted to boolean false
374
- value = Number(value);
375
- }
376
-
377
- value = Boolean(value);
378
- break;
379
- case 'FLOAT':
380
- value = Number.parseFloat(value);
381
- if (value < paramset.MIN) {
382
- value = paramset.MIN;
383
- } else if (value > paramset.MAX) {
384
- value = paramset.MAX;
385
- }
386
-
387
- value = {explicitDouble: value};
388
- break;
389
- case 'ENUM':
390
- if (typeof value === 'string') {
391
- if (paramset.ENUM && paramset.ENUM.includes(value)) {
392
- value = paramset.ENUM.indexOf(value);
393
- }
394
- }
395
-
396
- // Fallthrough by intention
397
- case 'INTEGER':
398
- value = Number.parseInt(value, 10);
399
- if (value < paramset.MIN) {
400
- value = paramset.MIN;
401
- } else if (value > paramset.MAX) {
402
- value = paramset.MAX;
403
- }
404
-
405
- break;
406
- case 'STRING':
407
- value = String(value);
408
- break;
409
- default:
410
- }
411
-
412
- return value;
413
- }
414
497
  }
415
498
 
416
499
  RED.nodes.registerType('ccu-mqtt', CcuMqttNode);
@@ -29,6 +29,7 @@
29
29
  change: {value: false},
30
30
  working: {value: false},
31
31
  cache: {value: false},
32
+ certainOnly: {value: false},
32
33
  topic: {value: '${CCU}/${Interface}/${channelName}/${datapoint}'}
33
34
  },
34
35
  inputs: 0,
@@ -596,6 +597,11 @@
596
597
  <input type="checkbox" id="node-input-cache">
597
598
  <span data-i18n="ccu-rpc-event.cache"></span>
598
599
  </label>
600
+
601
+ <label class="ccu-checkbox">
602
+ <input type="checkbox" id="node-input-certainOnly">
603
+ <span data-i18n="ccu-rpc-event.certainOnly"></span>
604
+ </label>
599
605
  </div>
600
606
  </div>
601
607
  <div class="form-row">
@@ -1,6 +1,20 @@
1
1
  const path = require('path');
2
2
 
3
3
  const statusHelper = require(path.join(__dirname, '/lib/status.js'));
4
+ const {valueStatus} = require(path.join(__dirname, '/lib/valuestatus.js'));
5
+
6
+ const FILTER_ATTRIBUTES = [
7
+ 'rooms',
8
+ 'functions',
9
+ 'device',
10
+ 'deviceName',
11
+ 'deviceType',
12
+ 'channel',
13
+ 'channelName',
14
+ 'channelType',
15
+ 'channelIndex',
16
+ 'datapoint',
17
+ ];
4
18
 
5
19
  module.exports = function (RED) {
6
20
  class CcuRpcEventNode {
@@ -21,20 +35,15 @@ module.exports = function (RED) {
21
35
  iface: config.iface,
22
36
  };
23
37
 
38
+ // opt-in: only present when the option is enabled, so existing
39
+ // subscriptions keep receiving uncertain values (#96)
40
+ if (config.certainOnly) {
41
+ filter.uncertain = false;
42
+ }
43
+
24
44
  this.ccu.register(this);
25
45
 
26
- [
27
- 'rooms',
28
- 'functions',
29
- 'device',
30
- 'deviceName',
31
- 'deviceType',
32
- 'channel',
33
- 'channelName',
34
- 'channelType',
35
- 'channelIndex',
36
- 'datapoint',
37
- ].forEach((attr) => {
46
+ FILTER_ATTRIBUTES.forEach((attr) => {
38
47
  if (!config[attr]) {
39
48
  return;
40
49
  }
@@ -45,8 +54,14 @@ module.exports = function (RED) {
45
54
  filter[attr] = config[attr];
46
55
  }
47
56
  });
57
+
58
+ // The node used to show nothing at all; show what came through
59
+ // last and how long ago (#52)
60
+ this.valueStatus = valueStatus(this);
61
+
48
62
  this.idSubscription = this.ccu.subscribe(filter, (message) => {
49
63
  message.topic = this.ccu.topicReplace(config.topic, message);
64
+ this.valueStatus.set(message.topic + ' = ' + message.payload, message.ts);
50
65
  this.send(message);
51
66
  });
52
67
  this.on('close', this._destructor);
@@ -54,6 +69,10 @@ module.exports = function (RED) {
54
69
 
55
70
  _destructor(done) {
56
71
  this.debug('ccu-rpc-event close');
72
+ if (this.valueStatus) {
73
+ this.valueStatus.stop();
74
+ }
75
+
57
76
  this.ccu.unsubscribe(this.idSubscription);
58
77
  this.ccu.deregister(this);
59
78
  done();
@@ -624,8 +624,46 @@
624
624
 
625
625
  <h3>Input</h3>
626
626
 
627
+
628
+ <h3>Dynamische Konfiguration</h3>
629
+ <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>
630
+ <ul>
631
+ <li><code>iface</code></li>
632
+ <li><code>rooms</code></li>
633
+ <li><code>functions</code></li>
634
+ <li><code>device</code></li>
635
+ <li><code>deviceType</code></li>
636
+ <li><code>deviceName</code></li>
637
+ <li><code>channel</code></li>
638
+ <li><code>channelType</code></li>
639
+ <li><code>channelIndex</code></li>
640
+ <li><code>channelName</code></li>
641
+ <li><code>datapoint</code></li>
642
+ <li><code>force</code></li>
643
+ <li><code>sowie die zugehörigen <code>...Rx</code>-Selektoren</code></li>
644
+ </ul>
645
+ <p>Zusätzlich füllen gleichnamige Eigenschaften der Nachricht selbst (<code>msg.rooms</code> usw.) leer gelassene Felder — so war es schon immer.</p>
627
646
  </script>
628
647
 
629
648
  <script type="text/x-red" data-help-name="ccu-set-value" lang="en-US">
630
649
  <p>Control multiple devices at once.</p>
650
+
651
+ <h3>Dynamic configuration</h3>
652
+ <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>
653
+ <ul>
654
+ <li><code>iface</code></li>
655
+ <li><code>rooms</code></li>
656
+ <li><code>functions</code></li>
657
+ <li><code>device</code></li>
658
+ <li><code>deviceType</code></li>
659
+ <li><code>deviceName</code></li>
660
+ <li><code>channel</code></li>
661
+ <li><code>channelType</code></li>
662
+ <li><code>channelIndex</code></li>
663
+ <li><code>channelName</code></li>
664
+ <li><code>datapoint</code></li>
665
+ <li><code>force</code></li>
666
+ <li><code>and the matching <code>...Rx</code> selectors</code></li>
667
+ </ul>
668
+ <p>Properties of the message itself (<code>msg.rooms</code> and so on) also fill in fields left empty in the dialog, as they always did.</p>
631
669
  </script>