node-red-contrib-room-tools 1.4.2

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/README.md ADDED
@@ -0,0 +1,27 @@
1
+ # Room Tools
2
+
3
+ One package containing three nodes:
4
+
5
+ - room-gateway: authenticated region control and feedback gateway.
6
+ - room-message: typed panel feedback message generator.
7
+ - ui-control-panel: Dashboard 2 template widget without message ports.
8
+
9
+ Every room gateway uses `/room-panel-api/regions/<room-id>` so additional
10
+ regions follow the same URL structure.
11
+
12
+ The node type names and configuration fields are unchanged from the individual
13
+ 1.0.0 packages. No external dependencies or installation scripts are required.
14
+
15
+ ## Migrating from the individual packages
16
+
17
+ Do not install this package alongside node-red-contrib-room-gateway or
18
+ node-red-contrib-room-message: they register the same node types.
19
+
20
+ Back up the project and its credentials, save changes, and stop Automator.
21
+ In its user directory (on this Mac, ~/.node-red), uninstall the two individual
22
+ packages with npm, then install this .tgz package before restarting Automator.
23
+ Keep the existing flow nodes and their IDs; do not delete them from the canvas.
24
+ After restarting, verify the gateway credentials and both nodes before use.
25
+
26
+ This package does not contain accounts, passwords, or flows. Installing it on a
27
+ Mac does not install it on a MUSE controller.
@@ -0,0 +1,462 @@
1
+ <script type="text/javascript">
2
+ (async function () {
3
+ // convert to i18 text
4
+ function c_ (x) {
5
+ return RED._('@flowfuse/node-red-dashboard/ui-template:ui-template.' + x)
6
+ }
7
+ // TODO: move this to util.js for reuse & unit testing
8
+ function hasProperty (obj, prop) {
9
+ return Object.prototype.hasOwnProperty.call(obj, prop)
10
+ }
11
+
12
+ const template = []
13
+
14
+ template.push('<template>')
15
+ template.push(' <div>')
16
+ template.push(' <h2>Counter</h2>')
17
+ template.push(' <!-- Conditional Styling using Attribute Binding (":") -->')
18
+ template.push(' <!-- and rendering content inside <tags></tags> with {{ }} -->')
19
+ template.push(' <p :style="{\'color\' : (count > 5 ? \'red\' : \'green\' )}">Current Count: {{ count }}</p>')
20
+ template.push(' <!-- Computed Rendering using Vue Computed Variables -->')
21
+ template.push(' <p class="my-class">Formatted Count: {{ formattedCount }}</p>')
22
+ template.push(' <!-- Conditional Rendering with "v-if" -->')
23
+ template.push(' <b v-if="count > 5">Too many!</b>')
24
+ template.push(' <v-btn @click="increase()">Increment</v-btn>')
25
+ template.push(' </div>')
26
+ template.push('</template>')
27
+ template.push('')
28
+ template.push('<script>')
29
+ template.push(' export default {')
30
+ template.push(' data() {')
31
+ template.push(' // define variables available component-wide')
32
+ template.push(' // (in <template> and component functions)')
33
+ template.push(' return {')
34
+ template.push(' count: 0')
35
+ template.push(' }')
36
+ template.push(' },')
37
+ template.push(' watch: {')
38
+ template.push(' // watch for any changes of "count"')
39
+ template.push(' count: function () {')
40
+ template.push(' if (this.count % 5 === 0) {')
41
+ template.push(' this.send({payload: \'Multiple of 5\'})')
42
+ template.push(' }')
43
+ template.push(' }')
44
+ template.push(' },')
45
+ template.push(' computed: {')
46
+ template.push(' // automatically compute this variable')
47
+ template.push(' // whenever VueJS deems appropriate')
48
+ template.push(' formattedCount: function () {')
49
+ template.push(' return this.count + \' Apples\'')
50
+ template.push(' }')
51
+ template.push(' },')
52
+ template.push(' methods: {')
53
+ template.push(' // expose a method to our <template> and Vue Application')
54
+ template.push(' increase: function () {')
55
+ template.push(' this.count++')
56
+ template.push(' }')
57
+ template.push(' },')
58
+ template.push(' mounted() {')
59
+ template.push(' // code here when the component is first loaded')
60
+ template.push(' },')
61
+ template.push(' unmounted() {')
62
+ template.push(' // code here when the component is removed from the Dashboard')
63
+ template.push(' // i.e. when the user navigates away from the page')
64
+ template.push(' }')
65
+ template.push(' }')
66
+ // eslint complaingin about unterminated string literal - need to do this oddity to get around it
67
+ template.push('</' + 'script>')
68
+ template.push('<style>')
69
+ template.push(' /* define any styles here - supports raw CSS */')
70
+ template.push(' .my-class {')
71
+ template.push(' color: red;')
72
+ template.push(' }')
73
+ template.push('</style>')
74
+
75
+ const defaultTemplate = template.join('\n')
76
+
77
+ RED.nodes.registerType('ui-control-panel', {
78
+ category: RED._('@flowfuse/node-red-dashboard/ui-base:ui-base.label.category'),
79
+ color: RED._('@flowfuse/node-red-dashboard/ui-base:ui-base.colors.dark'),
80
+ defaults: {
81
+ group: {
82
+ type: 'ui-group',
83
+ validate: function () {
84
+ const tempScope = ($('#node-input-templateScope').val() !== undefined) ? $('#node-input-templateScope').val() : this.templateScope
85
+ const groupVal = ($('#node-input-group').val() !== undefined) ? $('#node-input-group').val() : this.group
86
+
87
+ if (tempScope !== 'local') {
88
+ return true
89
+ }
90
+
91
+ if (tempScope === 'local') {
92
+ if (!!groupVal && groupVal !== '_ADD_') {
93
+ return true
94
+ }
95
+ }
96
+ return false
97
+ }
98
+ }, // for when template is scoped to 'local' (default)
99
+ page: {
100
+ type: 'ui-page',
101
+ validate: function () {
102
+ const tempScope = ($('#node-input-templateScope').val() !== undefined) ? $('#node-input-templateScope').val() : this.templateScope
103
+ const pageVal = ($('#node-input-page').val() !== undefined) ? $('#node-input-page').val() : this.page
104
+
105
+ if (tempScope !== 'widget:page' && tempScope !== 'page:style') {
106
+ return true
107
+ }
108
+ if (tempScope === 'widget:page' || tempScope === 'page:style') {
109
+ if (!!pageVal && pageVal !== '_ADD_') {
110
+ return true
111
+ }
112
+ }
113
+ return false
114
+ }
115
+ }, // for when template is scoped to 'page'
116
+ ui: {
117
+ type: 'ui-base',
118
+ validate: function () {
119
+ const tempScope = ($('#node-input-templateScope').val() !== undefined) ? $('#node-input-templateScope').val() : this.templateScope
120
+ const uiVal = ($('#node-input-ui').val() !== undefined) ? $('#node-input-ui').val() : this.ui
121
+
122
+ if (tempScope !== 'widget:ui' && tempScope !== 'site:style') {
123
+ return true
124
+ }
125
+ if (tempScope === 'widget:ui' || tempScope === 'site:style') {
126
+ if (!!uiVal && uiVal !== '_ADD_') {
127
+ return true
128
+ }
129
+ }
130
+ return false
131
+ }
132
+ }, // for when template is scoped to 'site'
133
+ name: { value: '' },
134
+ order: { value: 0 },
135
+ width: {
136
+ value: 0,
137
+ validate: function (v) {
138
+ if (this.templateScope !== 'global') {
139
+ const width = v || 0
140
+ const currentGroup = $('#node-input-group').val() || this.group
141
+ const groupNode = RED.nodes.node(currentGroup)
142
+ const valid = !groupNode || +width >= 0
143
+ $('#node-input-size').toggleClass('input-error', !valid)
144
+ return valid
145
+ }
146
+ return true
147
+ }
148
+ },
149
+ height: { value: 0 },
150
+ head: { value: '' },
151
+ format: { value: defaultTemplate },
152
+ storeOutMessages: { value: true },
153
+ passthru: { value: true },
154
+ resendOnRefresh: { value: true },
155
+ templateScope: { value: 'local' },
156
+ className: { value: '' }
157
+ },
158
+ inputs: 0,
159
+ outputs: 0,
160
+ icon: 'font-awesome/fa-code',
161
+ paletteLabel: 'ControlPanel',
162
+ label: function () {
163
+ if (this.name) { return this.name }
164
+ const knownLabels = {
165
+ local: 'template', // widget:group - kept as local for backward compatability
166
+ 'widget:page': 'template',
167
+ 'widget:ui': 'template',
168
+ 'site:style': 'site style',
169
+ 'page:style': 'page style'
170
+ }
171
+ return knownLabels[this.templateScope] || 'template'
172
+ },
173
+ labelStyle: function () { return this.name ? 'node_label_italic' : '' },
174
+ oneditprepare: function () {
175
+ if (RED.editor.__debug === true) {
176
+ console.log('ui-template: oneditprepare') // useful for locating this code in the browser debugger
177
+ }
178
+ if (hasProperty(RED.editor, 'editText') && typeof RED.editor.editText === 'function') {
179
+ $('#node-template-expand-editor').show()
180
+ } else {
181
+ $('#node-template-expand-editor').hide()
182
+ }
183
+ const that = this
184
+ const $templateScope = $('#node-input-templateScope')
185
+
186
+ // The native editor translations belong to ui-template. Reuse them for this renamed node.
187
+ const nativeLabel = (key) => c_('label.' + key)
188
+ $('#node-input-templateScope').closest('.form-row').find('label span').text(nativeLabel('scope'))
189
+ $('#node-input-templateScope option[value="local"]').text(nativeLabel('widget-group'))
190
+ $('#node-input-templateScope option[value="widget:page"]').text(nativeLabel('widget-page'))
191
+ $('#node-input-templateScope option[value="widget:ui"]').text(nativeLabel('widget-ui'))
192
+ $('#node-input-templateScope option[value="site:style"]').text(nativeLabel('site-style'))
193
+ $('#node-input-templateScope option[value="page:style"]').text(nativeLabel('page-style'))
194
+ $('#template-row-ui label span').text(nativeLabel('ui'))
195
+ $('#template-row-page label span').text(nativeLabel('page'))
196
+ $('#template-row-group label span').text(nativeLabel('group'))
197
+ $('#node-input-className').attr('placeholder', nativeLabel('classNamePlaceholder'))
198
+ $('#node-input-className').closest('.form-row').find('label span').text(nativeLabel('className'))
199
+ $('#node-input-format').closest('.form-row').find('label span').text(nativeLabel('template'))
200
+ $('#node-input-passthru').closest('.form-row').find('label span').text(nativeLabel('pass-through'))
201
+
202
+ // if this groups parent is a subflow template, set the node-config-input-width and node-config-input-height up
203
+ // as typedInputs and hide the elementSizer (as it doesn't make sense for subflow templates)
204
+ if (RED.nodes.subflow(this.z)) {
205
+ // change inputs from hidden to text & display them
206
+ $('#node-input-width').attr('type', 'text')
207
+ $('#node-input-height').attr('type', 'text')
208
+ $('div.form-row.nr-db-ui-element-hide-in-subflow').hide()
209
+ $('div.form-row.nr-db-ui-element-show-in-subflow').show()
210
+ } else {
211
+ // not in a subflow, use the elementSizer
212
+ $('div.form-row.nr-db-ui-element-hide-in-subflow').show()
213
+ $('div.form-row.nr-db-ui-element-show-in-subflow').hide()
214
+ $('#node-input-size').elementSizer({
215
+ width: '#node-input-width',
216
+ height: '#node-input-height',
217
+ group: '#node-input-group'
218
+ })
219
+ }
220
+
221
+ if (typeof this.storeOutMessages === 'undefined') {
222
+ this.storeOutMessages = true
223
+ $('#node-input-storeOutMessages').prop('checked', true)
224
+ }
225
+
226
+ if (typeof this.passthru === 'undefined') {
227
+ this.passthru = true
228
+ $('#node-input-passthru').prop('checked', true)
229
+ }
230
+
231
+ if (typeof this.templateScope === 'undefined') {
232
+ this.templateScope = 'local'
233
+ $templateScope.val(this.templateScope)
234
+ }
235
+
236
+ $templateScope.on('change', function () {
237
+ $('#template-row-group, #template-row-page, #template-row-ui, #template-row-class').hide()
238
+
239
+ switch ($templateScope.val()) {
240
+ case 'site:style':
241
+ $('#template-row-ui').show()
242
+ that.editor.getSession().setMode('ace/mode/css')
243
+ break
244
+ case 'page:style':
245
+ $('#template-row-page').show()
246
+ that.editor.getSession().setMode('ace/mode/css')
247
+ break
248
+ case 'site:script':
249
+ $('#template-row-ui').show()
250
+ that.editor.getSession().setMode('ace/mode/javascript')
251
+ break
252
+ case 'page:script':
253
+ $('#template-row-page').show()
254
+ that.editor.getSession().setMode('ace/mode/javascript')
255
+ break
256
+ case 'widget:ui':
257
+ $('#template-row-ui').show()
258
+ that.editor.getSession().setMode('ace/mode/html')
259
+ break
260
+ case 'widget:page':
261
+ $('#template-row-page').show()
262
+ that.editor.getSession().setMode('ace/mode/html')
263
+ break
264
+ default:
265
+ $('#template-row-group, #template-row-class').show()
266
+ that.editor.getSession().setMode('ace/mode/html')
267
+ break
268
+ }
269
+ resize.call(that)
270
+ })
271
+
272
+ this.editor = RED.editor.createEditor({
273
+ id: 'node-input-format-editor',
274
+ mode: 'ace/mode/html',
275
+ value: $('#node-input-format').val()
276
+ })
277
+
278
+ RED.library.create({
279
+ url: 'uitemplates', // where to get the data from
280
+ type: 'ui-template', // the type of object the library is for
281
+ editor: this.editor, // the field name the main text body goes to
282
+ mode: 'ace/mode/html',
283
+ fields: ['name']
284
+ })
285
+
286
+ this.editor.focus()
287
+
288
+ RED.popover.tooltip($('#node-template-expand-editor'), c_('label.expand'))
289
+
290
+ $('#node-template-expand-editor').on('click', function (e) {
291
+ e.preventDefault()
292
+ const value = that.editor.getValue()
293
+ RED.editor.editText({
294
+ mode: $templateScope.val() === 'global' ? 'css' : 'html',
295
+ value,
296
+ width: 'Infinity',
297
+ cursor: that.editor.getCursorPosition(),
298
+ complete: function (v, cursor) {
299
+ that.editor.setValue(v, -1)
300
+ that.editor.gotoLine(cursor.row + 1, cursor.column, false)
301
+ setTimeout(function () { that.editor.focus() }, 300)
302
+ }
303
+ })
304
+ })
305
+
306
+ // use jQuery UI tooltip to convert the plain old title attribute to a nice tooltip
307
+ $('.ui-node-popover-title').tooltip({
308
+ show: {
309
+ effect: 'slideDown',
310
+ delay: 150
311
+ }
312
+ })
313
+
314
+ $templateScope.trigger('change') // trigger the change event to hide/show the group/dashboard row
315
+ },
316
+ oneditsave: function () {
317
+ const annot = this.editor.getSession().getAnnotations()
318
+ this.noerr = 0
319
+ $('#node-input-noerr').val(0)
320
+ for (let k = 0; k < annot.length; k++) {
321
+ if (annot[k].type === 'error') {
322
+ $('#node-input-noerr').val(annot.length)
323
+ this.noerr = annot.length
324
+ }
325
+ }
326
+ $('#node-input-format').val(this.editor.getValue())
327
+ this.editor.destroy()
328
+ delete this.editor
329
+
330
+ // ensure we only have one of group/page/dashboard defined
331
+ const scope = $('#node-input-templateScope').val()
332
+ if (scope === 'local') {
333
+ $('#node-input-ui').val('_ADD_')
334
+ $('#node-input-page').val('_ADD_')
335
+ } else if (scope === 'widget:page') {
336
+ $('#node-input-ui').val('_ADD_')
337
+ $('#node-input-group').val('_ADD_')
338
+ } else if (scope === 'widget:ui') {
339
+ $('#node-input-page').val('_ADD_')
340
+ $('#node-input-group').val('_ADD_')
341
+ } else if (scope === 'site:style') {
342
+ $('#node-input-page').val('_ADD_')
343
+ $('#node-input-group').val('_ADD_')
344
+ } else if (scope === 'page:style') {
345
+ $('#node-input-ui').val('_ADD_')
346
+ $('#node-input-group').val('_ADD_')
347
+ }
348
+ },
349
+ oneditcancel: function () {
350
+ this.editor.destroy()
351
+ delete this.editor
352
+ },
353
+ oneditresize: resize.bind(this)
354
+ })
355
+
356
+ function resize (size) {
357
+ const rows = $('#dialog-form>div:not(.node-text-editor-row)')
358
+ let fixRowsHeight = 0
359
+ for (let i = 0; i < rows.size(); i++) {
360
+ fixRowsHeight += $(rows[i]).height()
361
+ }
362
+ const dialogHeight = $('#dialog-form').height()
363
+ const height = dialogHeight - fixRowsHeight
364
+ $('.node-text-editor').css('height', height + 'px')
365
+ if (RED.editor.__debug === true) {
366
+ console.log('dialogHeight', dialogHeight, 'fixRowsHeight', fixRowsHeight, 'height', height, 'editorRow')
367
+ }
368
+ this.editor && this.editor.resize()
369
+ }
370
+ })()
371
+ </script>
372
+
373
+ <script type="text/html" data-template-name="ui-control-panel">
374
+ <div class="form-row">
375
+ <label for="node-input-name"><i class="fa fa-tag"></i> <span data-i18n="node-red:common.label.name"></span></label>
376
+ <div style="display:inline-block; width:calc(100% - 105px)">
377
+ <input type="text" id="node-input-name" data-i18n="[placeholder]node-red:common.label.name">
378
+ </div>
379
+ </div>
380
+ <div class="form-row">
381
+ <label for="node-input-templateScope"><i class="fa fa-dot-circle-o"></i> <span data-i18n="ui-template.label.scope"></span></label>
382
+ <select style="width:76%" id="node-input-templateScope">
383
+ <option value="local" data-i18n="ui-template.label.widget-group"></option>
384
+ <option value="widget:page" data-i18n="ui-template.label.widget-page"></option>
385
+ <option value="widget:ui" data-i18n="ui-template.label.widget-ui"></option>
386
+ <option value="site:style" data-i18n="ui-template.label.site-style"></option>
387
+ <option value="page:style" data-i18n="ui-template.label.page-style"></option>
388
+ <!-- FUTURE? <option value="site:script" data-i18n="ui-template.label.site-script"></option>
389
+ <option value="page:script" data-i18n="ui-template.label.page-script"></option> -->
390
+ </select>
391
+ </div>
392
+ <div id="template-row-ui" class="form-row">
393
+ <label for="node-input-ui"><i class="fa fa-bookmark"></i> <span data-i18n="ui-template.label.ui"></label>
394
+ <input type="text" id="node-input-ui">
395
+ </div>
396
+ <div id="template-row-page" class="form-row">
397
+ <label for="node-input-page"><i class="fa fa-bookmark"></i> <span data-i18n="ui-template.label.page"></label>
398
+ <input type="text" id="node-input-page">
399
+ </div>
400
+ <div id="template-row-group" class="form-row">
401
+ <label for="node-input-group"><i class="fa fa-table"></i> <span data-i18n="ui-template.label.group"></label>
402
+ <input type="text" id="node-input-group">
403
+ </div>
404
+ <div class="form-row nr-db-ui-element-hide-in-subflow">
405
+ <label><i class="fa fa-object-group"></i> <span data-i18n="ui-base.label.size">Size</label>
406
+ <button class="editor-button" id="node-input-size"></button>
407
+ </div>
408
+ <div class="form-row nr-db-ui-element-show-in-subflow">
409
+ <label><i class="fa fa-arrows-h"></i> <span data-i18n="ui-base.label.width">Width</label>
410
+ <input type="hidden" id="node-input-width">
411
+ </div>
412
+ <div class="form-row nr-db-ui-element-show-in-subflow">
413
+ <label><i class="fa fa-arrows-v"></i> <span data-i18n="ui-base.label.height">Height</label>
414
+ <input type="hidden" id="node-input-height">
415
+ </div>
416
+ <div class="form-row nr-db-ui-element-show-in-subflow">
417
+ <label><i class="fa fa-arrows"></i> <span data-i18n="ui-base.label.order">Order</label>
418
+ <input type="text" id="node-input-order">
419
+ </div>
420
+ <!--<div class="form-row" id="template-row-size">
421
+ <label><i class="fa fa-object-group"></i> <span data-i18n="ui-template.label.size"></span></label>
422
+ <input type="hidden" id="node-input-width">
423
+ <input type="hidden" id="node-input-height">
424
+ <button class="editor-button" id="node-input-size"></button>
425
+ </div>-->
426
+ <div class="form-row">
427
+ <label for="node-input-className"><i class="fa fa-code"></i> <span data-i18n="ui-template.label.className"></label>
428
+ <div style="display: inline;">
429
+ <input style="width: 70%;" type="text" id="node-input-className" data-i18n="[placeholder]ui-template.label.classNamePlaceholder" style="flex-grow: 1;">
430
+ <a
431
+ data-html="true"
432
+ title="Dynamic Property: Send msg.class to append new classes to this widget. NOTE: classes set at runtime will be applied in addition to any class(es) set in the nodes class field."
433
+ class="red-ui-button ui-node-popover-title"
434
+ style="margin-left: 4px; cursor: help; font-size: 0.625rem; border-radius: 50%; width: 24px; height: 24px; display: inline-flex; justify-content: center; align-items: center;">
435
+ <i style="font-family: ui-serif;">fx</i>
436
+ </a>
437
+ </div>
438
+ </div>
439
+ <div class="form-row" style="margin-bottom:0px;">
440
+ <label for="node-input-format" style="width:110px;"><i class="fa fa-copy"></i> <span data-i18n="ui-template.label.template"></span></label>
441
+ <input type="hidden" id="node-input-format">
442
+ <button id="node-template-expand-editor" class="red-ui-button red-ui-button-small" style="float:right"><i class="fa fa-expand"></i></button>
443
+ </div>
444
+ <div class="form-row node-text-editor-row" style="display: block;">
445
+ <div style="height:250px; min-height:100px" class="node-text-editor" id="node-input-format-editor" ></div>
446
+ </div>
447
+ <div id="template-pass-store">
448
+ <div class="form-row" style="margin-bottom:0px;">
449
+ <input type="checkbox" id="node-input-passthru" style="display:inline-block; margin-left:8px; width:auto; vertical-align:top;">
450
+ <label for="node-input-passthru" style="width:70%;"> <span data-i18n="ui-template.label.pass-through"></span></label>
451
+ </div>
452
+ </div>
453
+ <!--<div class="form-row" style="margin-bottom:0px;">
454
+ <input type="checkbox" id="node-input-storeOutMessages" style="display:inline-block; margin-left:8px; width:auto; vertical-align:top;">
455
+ <label for="node-input-storeOutMessages" style="width:70%;"> <span data-i18n="ui-template.label.store-state"></span></label>
456
+ </div>
457
+ <div class="form-row" style="margin-bottom:0px;">
458
+ <input type="checkbox" id="node-input-resendOnRefresh" style="display:inline-block; margin-left:8px; width:auto; vertical-align:top;">
459
+ <label for="node-input-resendOnRefresh" style="width:70%;"> <span data-i18n="ui-template.label.resend"></span></label>
460
+ </div>
461
+ </div> -->
462
+ </script>
@@ -0,0 +1,68 @@
1
+ let statestore
2
+
3
+ function getStateStore (RED) {
4
+ if (statestore) return statestore
5
+ try {
6
+ const dashboardPath = require.resolve('@flowfuse/node-red-dashboard/nodes/store/state.js', {
7
+ paths: [RED.settings.userDir || process.cwd()]
8
+ })
9
+ statestore = require(dashboardPath)
10
+ } catch (error) {
11
+ RED.log.warn('ui-control-panel: Dashboard 2 state store is unavailable')
12
+ }
13
+ return statestore
14
+ }
15
+
16
+ module.exports = function (RED) {
17
+ function ControlPanel (config) {
18
+ const node = this
19
+ RED.nodes.createNode(node, config)
20
+
21
+ let parentNode
22
+
23
+ const events = {
24
+ onAction: true,
25
+ beforeSend (msg) {
26
+ if (msg && msg.ui_update && statestore) {
27
+ const parent = parentNode
28
+ if (parent && typeof msg.ui_update.format !== 'undefined') {
29
+ statestore.set(parent.getBase(), node, msg, 'format', msg.ui_update.format)
30
+ }
31
+ }
32
+ return msg
33
+ }
34
+ }
35
+
36
+ let retryTimer
37
+ let attempts = 0
38
+ let registered = false
39
+
40
+ // Config nodes can be created after this node during a full flow deploy.
41
+ // Retry briefly so a valid group/page is not missed during startup.
42
+ const registerWidget = () => {
43
+ if (registered) return
44
+ const group = config.group ? RED.nodes.getNode(config.group) : null
45
+ const page = !group && config.page ? RED.nodes.getNode(config.page) : null
46
+ const ui = !group && !page && config.ui ? RED.nodes.getNode(config.ui) : null
47
+ if (!group && !page && !ui && attempts++ < 40) {
48
+ retryTimer = setTimeout(registerWidget, 25)
49
+ return
50
+ }
51
+
52
+ // Keep the custom editor type in Dashboard's namespace, but use Dashboard's built-in renderer.
53
+ const widgetConfig = { ...config, type: 'ui-template' }
54
+ parentNode = group || page || ui
55
+ if (group) group.register(node, widgetConfig, events)
56
+ else if (page) page.register(null, node, widgetConfig, events)
57
+ else if (ui) ui.register(null, null, node, widgetConfig, events)
58
+ registered = Boolean(parentNode)
59
+ }
60
+
61
+ registerWidget()
62
+ node.on('close', () => clearTimeout(retryTimer))
63
+
64
+ }
65
+
66
+ getStateStore(RED)
67
+ RED.nodes.registerType('ui-control-panel', ControlPanel)
68
+ }
package/gateway.cjs ADDED
@@ -0,0 +1,109 @@
1
+ const crypto = require('node:crypto')
2
+ const PREFIX = '/room-panel-api'
3
+ const defaults = () => ({scene:'meeting',display:true,source:'无线投屏',volume:42,muted:false,light:80,curtains:75,temperature:23,meeting:false})
4
+ const fields = {'display/power':'display','display/source':'source','audio/volume':'volume','audio/mute':'muted','lighting/brightness':'light','room/curtain':'curtains','climate/temperature':'temperature','room/scene':'scene','room/meeting':'meeting'}
5
+ const ranges = {volume:[0,100],light:[0,100],curtains:[0,100],temperature:[16,30]}
6
+ const presets = {
7
+ focus:{light:85,curtains:80,volume:25,display:true},
8
+ cinema:{light:20,curtains:10,volume:65,display:true},
9
+ social:{light:70,curtains:80,volume:40,display:true},
10
+ unwind:{light:35,curtains:80,volume:32,display:true},
11
+ meeting:{light:80,curtains:75,volume:42,source:'无线投屏',display:true},
12
+ present:{light:30,curtains:10,volume:55,source:'HDMI 1',display:true},
13
+ video:{light:65,curtains:35,volume:65,source:'视频会议',display:true},
14
+ relax:{light:45,curtains:100,volume:20,source:'无线投屏',display:false}
15
+ }
16
+ function valid(key, value) {
17
+ if(ranges[key]) return typeof value === 'number' && Number.isFinite(value) && value >= ranges[key][0] && value <= ranges[key][1]
18
+ if(['display','muted','meeting'].includes(key)) return typeof value === 'boolean'
19
+ if(key === 'source') return ['无线投屏','HDMI 1','HDMI 2','视频会议'].includes(value)
20
+ if(key === 'scene') return ['custom',...Object.keys(presets)].includes(value)
21
+ return false
22
+ }
23
+ function createGateway({username,password,room='room-01',onCommand=()=>{},secureCookie=false,prefix}) {
24
+ const routePrefix=prefix||PREFIX+'/regions/'+encodeURIComponent(room)
25
+ if(!username || typeof password !== 'string' || password.length < 12) throw Error('Set a username and password of at least 12 characters')
26
+ const salt=crypto.randomBytes(16), hash=crypto.scryptSync(password,salt,32)
27
+ const sessions=new Map(), attempts=new Map()
28
+ let state=defaults(), revision=0, closed=false
29
+ function feedback(msg) {
30
+ if(msg.room!==room || msg.kind!=='state') throw Error('Feedback requires matching room and kind=state')
31
+ const values=msg.topic==='room/state' ? msg.payload : fields[msg.topic] ? {[fields[msg.topic]]:msg.payload} : null
32
+ if(!values || typeof values!=='object' || Array.isArray(values) || !Object.keys(values).length || Object.entries(values).some(([k,v])=>!valid(k,v))) throw Error('Invalid feedback')
33
+ state={...state,...values};revision++
34
+ }
35
+ function reply(res,status,data,headers={}) {
36
+ res.writeHead(status,{'Content-Type':'application/json; charset=utf-8','Cache-Control':'no-store','X-Content-Type-Options':'nosniff',...headers})
37
+ res.end(JSON.stringify(data))
38
+ }
39
+ async function body(req) {
40
+ if(!String(req.headers['content-type']||'').startsWith('application/json')) throw Error('JSON required')
41
+ if(req.body !== undefined) {
42
+ if(Buffer.isBuffer(req.body)) return JSON.parse(req.body.toString('utf8'))
43
+ if(typeof req.body === 'string') return JSON.parse(req.body)
44
+ if(req.body&&typeof req.body==='object'&&Object.keys(req.body).length)return req.body
45
+ }
46
+ let raw='';for await(const chunk of req){raw+=chunk;if(Buffer.byteLength(raw)>8192)throw Error('Request too large')}
47
+ return JSON.parse(raw)
48
+ }
49
+ const cookieName='room_panel_'+Buffer.from(room).toString('hex')
50
+ const cookie=(token,maxAge)=>`${cookieName}=${token}; Path=${routePrefix}; HttpOnly; SameSite=Strict; Max-Age=${maxAge}${secureCookie?'; Secure':''}`
51
+ async function handler(req,res) {
52
+ const path=new URL(req.url,'http://localhost').pathname
53
+ if(!path.startsWith(routePrefix+'/')) return false
54
+ if(closed){reply(res,503,{error:'Gateway unavailable'});return true}
55
+ try {
56
+ const now=Date.now()
57
+ for(const [id,s]of sessions)if(s.expiry<now)sessions.delete(id)
58
+ for(const [ip,a]of attempts)if(a.until<now)attempts.delete(ip)
59
+ if(req.method==='POST') {
60
+ const origin=req.headers.origin
61
+ if(!origin || new URL(origin).host!==req.headers.host){reply(res,403,{error:'Origin rejected'});return true}
62
+ }
63
+ if(path===routePrefix+'/login' && req.method==='POST') {
64
+ const ip=req.socket.remoteAddress||'unknown', entry=attempts.get(ip)||{count:0,until:now+60000}
65
+ if(entry.count>=10 || attempts.size>1000){reply(res,429,{error:'Too many attempts. Wait one minute.'});return true}
66
+ entry.count++;attempts.set(ip,entry)
67
+ const data=await body(req)
68
+ const supplied=String(data.password||'')
69
+ if(supplied.length>256){reply(res,401,{error:'Invalid credentials'});return true}
70
+ const test=await new Promise((resolve,reject)=>crypto.scrypt(supplied,salt,32,(err,key)=>err?reject(err):resolve(key)))
71
+ const passwordMatch=crypto.timingSafeEqual(test,hash), usernameMatch=data.username===username
72
+ if(!passwordMatch||!usernameMatch){reply(res,401,{error:'Invalid credentials'});return true}
73
+ if(sessions.size>=100){reply(res,503,{error:'Session limit reached'});return true}
74
+ attempts.delete(ip)
75
+ const token=crypto.randomBytes(32).toString('hex'),csrf=crypto.randomBytes(24).toString('hex')
76
+ sessions.set(token,{csrf,expiry:now+8*3600000})
77
+ reply(res,200,{room,username,csrf},{'Set-Cookie':cookie(token,28800)});return true
78
+ }
79
+ const token=String(req.headers.cookie||'').split(';').map(v=>v.trim()).find(v=>v.startsWith(cookieName+'='))?.split('=')[1]
80
+ const session=sessions.get(token)
81
+ if(!session){reply(res,401,{error:'Login required'});return true}
82
+ if(req.method==='POST' && req.headers['x-room-csrf']!==session.csrf){reply(res,403,{error:'CSRF rejected'});return true}
83
+ if(path===routePrefix+'/session' && req.method==='GET'){reply(res,200,{room,username,csrf:session.csrf});return true}
84
+ if(path===routePrefix+'/state' && req.method==='GET') {
85
+ if(new URL(req.url,'http://localhost').searchParams.get('room')!==room){reply(res,403,{error:'Region denied'});return true}
86
+ reply(res,200,{room,kind:'state',topic:'room/state',payload:state,revision});return true
87
+ }
88
+ if(path===routePrefix+'/logout' && req.method==='POST'){sessions.delete(token);reply(res,200,{ok:true},{'Set-Cookie':cookie('',0)});return true}
89
+ if(path===routePrefix+'/command' && req.method==='POST') {
90
+ const data=await body(req)
91
+ if(data.room!==room){reply(res,403,{error:'Region denied'});return true}
92
+ const key=fields[data.topic]
93
+ if(data.topic==='room/reset' ? data.payload!==true : !key || !valid(key,data.payload) || (key==='scene' && !presets[data.payload])){reply(res,400,{error:'Invalid control value'});return true}
94
+ let next={...state}
95
+ if(data.topic==='room/reset')next=defaults()
96
+ else if(key==='scene')next={...next,...presets[data.payload],scene:data.payload}
97
+ else {next[key]=data.payload;if(['light','curtains'].includes(key))next.scene='custom';if(key==='meeting'&&data.payload)next.display=true}
98
+ // Identity and the complete command state are rebuilt on the server.
99
+ onCommand({room,topic:data.topic,payload:data.payload,kind:'command',user:username,state:next})
100
+ state=next;revision++
101
+ reply(res,200,{ok:true,revision});return true
102
+ }
103
+ reply(res,404,{error:'Not found'})
104
+ }catch(err){reply(res,400,{error:'Invalid request'})}
105
+ return true
106
+ }
107
+ return {handler,feedback,close(){closed=true;sessions.clear();attempts.clear()}}
108
+ }
109
+ module.exports={createGateway,PREFIX}
package/package.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "node-red-contrib-room-tools",
3
+ "version": "1.4.2",
4
+ "description": "Room panel message generator and authenticated region gateway for Node-RED and MUSE Automator",
5
+ "license": "UNLICENSED",
6
+ "keywords": ["node-red"],
7
+ "files": ["gateway.cjs", "room-gateway.js", "room-gateway.html", "room-message.js", "room-message.html", "control-panel.js", "control-panel.html", "README.md"],
8
+ "node-red": {
9
+ "nodes": {
10
+ "room-gateway": "room-gateway.js",
11
+ "room-message": "room-message.js",
12
+ "ui-control-panel": "control-panel.js"
13
+ }
14
+ }
15
+ }
@@ -0,0 +1,20 @@
1
+ <script type="text/javascript">
2
+ RED.nodes.registerType('room-gateway',{
3
+ category:'Room Tools',color:'#a8d5c3',defaults:{name:{value:''},room:{value:'room-01',required:true},secureCookie:{value:false}},
4
+ credentials:{username:{type:'text'},password:{type:'password'}},inputs:1,outputs:1,icon:'font-awesome/fa-lock',
5
+ paletteLabel:'区域权限网关',label:function(){return this.name||'区域权限网关'},inputLabels:'设备反馈 kind=state',outputLabels:'已验证的控制指令'
6
+ })
7
+ </script>
8
+ <script type="text/html" data-template-name="room-gateway">
9
+ <div class="form-row"><label for="node-input-name">名称</label><input id="node-input-name" type="text"></div>
10
+ <div class="form-row"><label for="node-input-room">区域 ID</label><input id="node-input-room" type="text"></div>
11
+ <div class="form-row"><label for="node-input-username">登录账号</label><input id="node-input-username" type="text" autocomplete="off"></div>
12
+ <div class="form-row"><label for="node-input-password">登录密码</label><input id="node-input-password" type="password" autocomplete="new-password"></div>
13
+ <div class="form-row"><label for="node-input-secureCookie">HTTPS</label><input id="node-input-secureCookie" type="checkbox" style="width:auto">仅通过 HTTPS 发送会话 Cookie</div>
14
+ <p>密码至少 12 个字符。每个区域 ID 只能部署一个网关,账号只能访问该网关配置的区域。</p>
15
+ </script>
16
+ <script type="text/html" data-help-name="room-gateway">
17
+ <p>Template 通过同源 /room-panel-api/regions/&lt;区域 ID&gt; 登录、发送控制请求和读取反馈。Template 输出不再用于控制设备。</p>
18
+ <p>本节点输出已授权消息,连接原来的 Switch。设备反馈必须含 room、kind: state、topic 和 payload,接入本节点输入,不接 Template。</p>
19
+ <p>状态包含最近接受的目标值,实际设备反馈可覆盖。Node-RED 编辑器属于管理员入口,部署时应独立保护。生产 iPad 访问请配置 HTTPS 并启用 HTTPS Cookie。</p>
20
+ </script>
@@ -0,0 +1,57 @@
1
+ const {createGateway,PREFIX}=require('./gateway.cjs')
2
+ const fs=require('node:fs')
3
+ const path=require('node:path')
4
+
5
+ function storedCredentials(RED,nodeId){
6
+ const files=[]
7
+ const add=file=>{if(file&&!files.includes(file))files.push(file)}
8
+ const flowFile=RED.settings.flowFile
9
+ const credentialsFile=RED.settings.credentialsFile
10
+ if(path.isAbsolute(String(credentialsFile||'')))add(credentialsFile)
11
+ if(path.isAbsolute(String(flowFile||''))){
12
+ add(path.join(path.dirname(flowFile),path.basename(flowFile,path.extname(flowFile))+'_cred.json'))
13
+ if(credentialsFile)add(path.resolve(path.dirname(flowFile),credentialsFile))
14
+ }
15
+ const projects=path.join(RED.settings.userDir||'', 'projects')
16
+ try{
17
+ for(const entry of fs.readdirSync(projects,{withFileTypes:true})){
18
+ if(!entry.isDirectory())continue
19
+ add(path.join(projects,entry.name,'flow_cred.json'))
20
+ }
21
+ }catch{}
22
+ for(const file of files){
23
+ try{
24
+ const credentials=JSON.parse(fs.readFileSync(file,'utf8'))[nodeId]
25
+ if(credentials?.username&&typeof credentials.password==='string')return credentials
26
+ }catch{}
27
+ }
28
+ return null
29
+ }
30
+
31
+ module.exports=function(RED){
32
+ const active=new Map()
33
+ RED.httpNode.use((req,res,next)=>{
34
+ const route=new URL(req.url,'http://localhost').pathname
35
+ const match=route.match(new RegExp('^'+PREFIX+'/regions/([^/]+)/'))
36
+ if(!match)return next()
37
+ const room=decodeURIComponent(match[1])
38
+ const gateway=active.get(room)
39
+ if(!gateway)return res.status(503).json({error:'Region gateway not configured'})
40
+ gateway.handler(req,res).then(handled=>{if(!handled)res.status(404).json({error:'Not found'})}).catch(next)
41
+ })
42
+ function Gateway(config){
43
+ RED.nodes.createNode(this,config)
44
+ const room=config.room||'room-01'
45
+ if(!/^[a-zA-Z0-9_-]+$/.test(room)){this.error('Invalid room identifier');return}
46
+ if(active.has(room)){this.status({fill:'red',shape:'ring',text:'Duplicate room gateway'});return}
47
+ let gateway
48
+ const credentials=this.credentials?.username&&this.credentials?.password?this.credentials:storedCredentials(RED,this.id)||{}
49
+ try{gateway=createGateway({username:credentials.username,password:credentials.password,room,secureCookie:!!config.secureCookie,onCommand:msg=>this.send(msg)})}
50
+ catch(err){this.status({fill:'red',shape:'ring',text:err.message});this.error(err.message);return}
51
+ active.set(room,gateway)
52
+ this.status({fill:'green',shape:'dot',text:'Access protected: '+(config.room||'room-01')})
53
+ this.on('input',(msg,send,done)=>{try{gateway.feedback(msg);if(done)done()}catch(err){if(done)done(err);else this.error(err,msg)}})
54
+ this.on('close',()=>{gateway.close();if(active.get(room)===gateway)active.delete(room)})
55
+ }
56
+ RED.nodes.registerType('room-gateway',Gateway,{credentials:{username:{type:'text'},password:{type:'password'}}})
57
+ }
@@ -0,0 +1,56 @@
1
+ <script type="text/javascript">
2
+ RED.nodes.registerType('room-message', {
3
+ category: 'Room Tools',
4
+ color: '#a8d5c3',
5
+ defaults: {
6
+ name: { value: '' },
7
+ topic: { value: 'audio/volume', required: true },
8
+ value: { value: '65' },
9
+ room: { value: 'room-01', required: true },
10
+ useInput: { value: false }
11
+ },
12
+ inputs: 1,
13
+ outputs: 1,
14
+ icon: 'font-awesome/fa-sliders',
15
+ paletteLabel: '面板消息',
16
+ label: function () { return this.name || '面板消息 · ' + this.topic },
17
+ inputLabels: '触发消息,或使用输入 payload',
18
+ outputLabels: 'Template 状态反馈',
19
+ oneditprepare: function () {
20
+ const hints = {
21
+ 'audio/volume': '0 ~ 100', 'lighting/brightness': '0 ~ 100',
22
+ 'room/curtain': '0 ~ 100', 'climate/temperature': '16 ~ 30',
23
+ 'display/power': 'true / false', 'audio/mute': 'true / false', 'room/meeting': 'true / false',
24
+ 'display/source': '无线投屏 / HDMI 1 / HDMI 2 / 视频会议',
25
+ 'room/scene': 'meeting / present / video / relax / custom'
26
+ }
27
+ function update() {
28
+ $('#room-message-hint').text(hints[$('#node-input-topic').val()] || '')
29
+ $('#node-input-value').prop('disabled', $('#node-input-useInput').prop('checked'))
30
+ }
31
+ $('#node-input-topic, #node-input-useInput').on('change', update)
32
+ update()
33
+ }
34
+ })
35
+ </script>
36
+ <script type="text/html" data-template-name="room-message">
37
+ <div class="form-row"><label for="node-input-name">名称</label><input id="node-input-name" type="text" placeholder="可选" /></div>
38
+ <div class="form-row"><label for="node-input-room">房间 ID</label><input id="node-input-room" type="text" /></div>
39
+ <div class="form-row"><label for="node-input-topic">控件</label><select id="node-input-topic" style="width:70%">
40
+ <option value="audio/volume">音量</option><option value="audio/mute">静音</option>
41
+ <option value="display/power">显示电源</option><option value="display/source">信号源</option>
42
+ <option value="lighting/brightness">灯光亮度</option><option value="room/curtain">窗帘开度</option>
43
+ <option value="climate/temperature">目标温度</option><option value="room/scene">场景状态</option>
44
+ <option value="room/meeting">会议状态</option>
45
+ </select></div>
46
+ <div class="form-row"><label for="node-input-value">设定值</label><input id="node-input-value" type="text" /></div>
47
+ <div class="form-row"><span style="margin-left:100px" id="room-message-hint"></span></div>
48
+ <div class="form-row"><label for="node-input-useInput">输入值</label><input id="node-input-useInput" type="checkbox" style="width:auto" /> 使用收到的 msg.payload</div>
49
+ </script>
50
+ <script type="text/html" data-help-name="room-message">
51
+ <p>收到任意输入后,为会议室 Template 生成状态反馈消息。</p>
52
+ <p>连接:Inject → 面板消息 → Template。默认发送音量 65。</p>
53
+ <p>输出包含 topic、payload、kind: state、room。数值和布尔值自动转换并校验;非法值报错,不输出。</p>
54
+ <p>勾选“使用收到的 msg.payload”后,可从上游动态传入值。其他输入属性保留,旧 state 快照移除。</p>
55
+ <p>场景状态仅回填场景名称,不执行场景联动;本节点不生成重置命令。</p>
56
+ </script>
@@ -0,0 +1,43 @@
1
+ module.exports = function (RED) {
2
+ const types = {
3
+ 'audio/volume': [0, 100], 'lighting/brightness': [0, 100],
4
+ 'room/curtain': [0, 100], 'climate/temperature': [16, 30],
5
+ 'display/power': 'bool', 'audio/mute': 'bool', 'room/meeting': 'bool',
6
+ 'display/source': ['无线投屏', 'HDMI 1', 'HDMI 2', '视频会议'],
7
+ 'room/scene': ['meeting', 'present', 'video', 'relax', 'custom']
8
+ }
9
+ function RoomMessage(config) {
10
+ RED.nodes.createNode(this, config)
11
+ const node = this
12
+ node.on('input', function (msg, send, done) {
13
+ send = send || node.send.bind(node)
14
+ done = done || function (err) { if (err) node.error(err, msg) }
15
+ try {
16
+ const rule = types[config.topic]
17
+ if (!rule) throw new Error('请选择有效的控件')
18
+ const raw = config.useInput ? msg.payload : config.value
19
+ let value = raw
20
+ if (rule === 'bool') {
21
+ if (![true, false, 'true', 'false'].includes(raw)) throw new Error('开关值必须为 true 或 false')
22
+ value = raw === true || raw === 'true'
23
+ } else if (typeof rule[0] === 'number') {
24
+ if (!['number', 'string'].includes(typeof raw) || String(raw).trim() === '') throw new Error('请输入数字')
25
+ value = Number(raw)
26
+ if (!Number.isFinite(value) || value < rule[0] || value > rule[1]) throw new Error('数值范围应为 ' + rule.join(' ~ '))
27
+ } else if (!rule.includes(raw)) throw new Error('可用值: ' + rule.join(', '))
28
+ msg.topic = config.topic
29
+ msg.payload = value
30
+ msg.kind = 'state'
31
+ msg.room = config.room || 'room-01'
32
+ delete msg.state
33
+ node.status({ fill: 'green', shape: 'dot', text: config.topic + ': ' + value })
34
+ send(msg)
35
+ done()
36
+ } catch (err) {
37
+ node.status({ fill: 'red', shape: 'ring', text: err.message })
38
+ done(err)
39
+ }
40
+ })
41
+ }
42
+ RED.nodes.registerType('room-message', RoomMessage)
43
+ }