node-red-contrib-room-tools 1.8.2 → 1.8.3-visual-binding.17
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/CONTROL_PANEL_AI_GUIDE.md +55 -220
- package/README.md +10 -148
- package/assets/panel-login-bg.png +0 -0
- package/control-bound-protocol.js +97 -0
- package/control-catalog.js +97 -0
- package/control-panel.html +82 -86
- package/control-panel.js +237 -10
- package/control-policy.js +267 -7
- package/control-type-contract.json +17 -0
- package/control-type-model.js +63 -0
- package/gateway.cjs +77 -5
- package/managed-bridge-admin.js +90 -0
- package/managed-bridge-revision.js +32 -0
- package/managed-native-bridge.js +124 -0
- package/native-binding-plan.js +177 -0
- package/package.json +27 -5
- package/panel-binding-editor.js +771 -0
- package/panel-editor-auth.js +151 -0
- package/panel-template-instrumentation.js +267 -0
- package/panel-visual-apply.js +127 -0
- package/panel-visual-bindings.js +95 -0
- package/room-gateway.html +0 -21
- package/room-gateway.js +0 -57
- package/room-message.html +0 -56
- package/room-message.js +0 -43
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
const { schemaVersion } = require('./control-type-contract.json')
|
|
2
|
+
|
|
3
|
+
function catalogBase () {
|
|
4
|
+
const localAutomator = process.platform === 'darwin' &&
|
|
5
|
+
process.execPath.includes('/MUSE Automator.app/Contents/MacOS/MUSE Automator')
|
|
6
|
+
const raw = process.env.CONTROL_PANEL_POC_CATALOG_BASE_URL ||
|
|
7
|
+
(localAutomator ? 'http://127.0.0.1:48080/red/' : '')
|
|
8
|
+
if (!raw) return null
|
|
9
|
+
const url = new URL(raw)
|
|
10
|
+
if (url.protocol !== 'http:' || !['127.0.0.1', 'localhost'].includes(url.hostname) || url.username || url.password) {
|
|
11
|
+
throw new Error('PoC catalog URL must be an unauthenticated localhost HTTP URL')
|
|
12
|
+
}
|
|
13
|
+
return url.href.endsWith('/') ? url.href : `${url.href}/`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function catalogRequest (base, path, device, fetcher = fetch) {
|
|
17
|
+
const options = { signal: AbortSignal.timeout(5000) }
|
|
18
|
+
if (device !== undefined) {
|
|
19
|
+
options.method = 'POST'
|
|
20
|
+
options.headers = { 'content-type': 'application/json' }
|
|
21
|
+
options.body = JSON.stringify([device])
|
|
22
|
+
}
|
|
23
|
+
const response = await fetcher(new URL(path, base), options)
|
|
24
|
+
if (!response.ok) throw new Error(`Automator catalog HTTP ${response.status}`)
|
|
25
|
+
const data = await response.json()
|
|
26
|
+
if (data.code !== 0 || !Array.isArray(data.value)) throw new Error(data.reason || 'Invalid Automator catalog response')
|
|
27
|
+
return data.value
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function listCatalogDevices (base, fetcher = fetch) {
|
|
31
|
+
const values = await catalogRequest(base, 'amx-com/devices', undefined, fetcher)
|
|
32
|
+
return values.filter(item => typeof item.instanceId === 'string').map(item => ({
|
|
33
|
+
id: item.instanceId,
|
|
34
|
+
driver: item.driver?.driver || '',
|
|
35
|
+
online: item.online === true
|
|
36
|
+
}))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function catalogBound (value) {
|
|
40
|
+
return typeof value === 'number' && Number.isFinite(value) &&
|
|
41
|
+
Math.abs(value) < Number.MAX_SAFE_INTEGER ? value : undefined
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function inferredControl (path, properties, type, min, max, enums) {
|
|
45
|
+
if (properties['.kind'] !== 'param' || path.startsWith('//module/')) return { controlType: '' }
|
|
46
|
+
if (type === 'boolean') return { controlType: 'toggle' }
|
|
47
|
+
if (type === 'integer' && min === 0 && max === 1 && !enums?.length) {
|
|
48
|
+
return { controlType: 'toggle', booleanEncoding: 'integer01' }
|
|
49
|
+
}
|
|
50
|
+
if (type === 'enum' || (enums?.length && ['integer', 'string'].includes(type))) {
|
|
51
|
+
return { controlType: 'source' }
|
|
52
|
+
}
|
|
53
|
+
if (type === 'integer' && /\/(preset|source)$/.test(path.toLowerCase())) {
|
|
54
|
+
return { controlType: 'source' }
|
|
55
|
+
}
|
|
56
|
+
if (['integer', 'float'].includes(type)) return { controlType: 'slider' }
|
|
57
|
+
if (['string', 'dstring'].includes(type)) return { controlType: 'text' }
|
|
58
|
+
return { controlType: '' }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function listCatalogTargets (base, device, fetcher = fetch) {
|
|
62
|
+
if (typeof device !== 'string' || !device || device.length > 128) throw new Error('Invalid device name')
|
|
63
|
+
const [parameters, commands] = await Promise.all([
|
|
64
|
+
catalogRequest(base, 'amx-com/device/status', device, fetcher),
|
|
65
|
+
catalogRequest(base, 'amx-com/device/commands', device, fetcher)
|
|
66
|
+
])
|
|
67
|
+
const targets = [...parameters, ...commands].filter(item => typeof item.path === 'string').map(item => {
|
|
68
|
+
const properties = item.properties || {}
|
|
69
|
+
const type = properties['.type'] || item.schema?.type || ''
|
|
70
|
+
const min = catalogBound(properties['.min'])
|
|
71
|
+
const max = catalogBound(properties['.max'])
|
|
72
|
+
const enums = properties['.enums']
|
|
73
|
+
return {
|
|
74
|
+
path: item.path,
|
|
75
|
+
kind: properties['.kind'] || '',
|
|
76
|
+
type,
|
|
77
|
+
access: properties['.access'],
|
|
78
|
+
onebased: typeof properties['.onebased'] === 'boolean' ? properties['.onebased']
|
|
79
|
+
: typeof item.onebased === 'boolean' ? item.onebased : undefined,
|
|
80
|
+
min, max, enums,
|
|
81
|
+
...inferredControl(item.path, properties, type, min, max, enums),
|
|
82
|
+
controlSchemaVersion: schemaVersion,
|
|
83
|
+
unit: properties['.metadata']?.unit || ''
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
const unique = new Map()
|
|
87
|
+
for (const target of targets) {
|
|
88
|
+
const previous = unique.get(target.path)
|
|
89
|
+
if (previous && JSON.stringify(previous) !== JSON.stringify(target)) {
|
|
90
|
+
throw new Error(`Conflicting catalog descriptors for ${target.path}`)
|
|
91
|
+
}
|
|
92
|
+
unique.set(target.path, target)
|
|
93
|
+
}
|
|
94
|
+
return [...unique.values()]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = { catalogBase, listCatalogDevices, listCatalogTargets }
|
package/control-panel.html
CHANGED
|
@@ -9,79 +9,12 @@
|
|
|
9
9
|
return Object.prototype.hasOwnProperty.call(obj, prop)
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
template.push('2. Never add debounce, throttle, latest-wins maps, Promise queues, or raw device TCP code here.')
|
|
19
|
-
template.push('3. Continuous types: level, dimmer, position. Send final:true on release/change.')
|
|
20
|
-
template.push('4. Discrete types: power, source, preset, mute. Send once per deliberate user action.')
|
|
21
|
-
template.push('5. key must be stable and unique per device control, for example rane/1/level/2.')
|
|
22
|
-
template.push('6. After room-gateway login call setControlSession(session); on logout call clearControlSession().')
|
|
23
|
-
template.push('7. Bind discrete control visuals to deviceState/deviceValue(), not to an optimistic local value.')
|
|
24
|
-
template.push('8. Use isControlPending(key) only for a waiting indicator; discrete pending clears on authoritative topic feedback, while continuous pending waits for its target value.')
|
|
25
|
-
template.push('9. Sliders directly send and hold the latest target; use controlDisplayValue() so stale feedback cannot pull them back.')
|
|
26
|
-
template.push('10. Feedback may update UI state, but must never trigger a watcher that sends the value back.')
|
|
27
|
-
template.push('11. Do not redefine any injected ControlPanel API or its feedback state fields.')
|
|
28
|
-
template.push('12. Read CONTROL_PANEL_AI_GUIDE.md in node-red-contrib-room-tools before generating a production panel.')
|
|
29
|
-
template.push('13. Device state is retained by room-gateway and delivered in the feedback stream first snapshot; never query device state from this Template.')
|
|
30
|
-
template.push('-->')
|
|
31
|
-
template.push('<template>')
|
|
32
|
-
template.push(' <div>')
|
|
33
|
-
template.push(' <h2>Counter</h2>')
|
|
34
|
-
template.push(' <!-- Conditional Styling using Attribute Binding (":") -->')
|
|
35
|
-
template.push(' <!-- and rendering content inside <tags></tags> with {{ }} -->')
|
|
36
|
-
template.push(' <p :style="{\'color\' : (count > 5 ? \'red\' : \'green\' )}">Current Count: {{ count }}</p>')
|
|
37
|
-
template.push(' <!-- Computed Rendering using Vue Computed Variables -->')
|
|
38
|
-
template.push(' <p class="my-class">Formatted Count: {{ formattedCount }}</p>')
|
|
39
|
-
template.push(' <!-- Conditional Rendering with "v-if" -->')
|
|
40
|
-
template.push(' <b v-if="count > 5">Too many!</b>')
|
|
41
|
-
template.push(' <v-btn @click="increase()">Increment</v-btn>')
|
|
42
|
-
template.push(' </div>')
|
|
43
|
-
template.push('</template>')
|
|
44
|
-
template.push('')
|
|
45
|
-
template.push('<script>')
|
|
46
|
-
template.push(' export default {')
|
|
47
|
-
template.push(' data() {')
|
|
48
|
-
template.push(' // define variables available component-wide')
|
|
49
|
-
template.push(' // (in <template> and component functions)')
|
|
50
|
-
template.push(' return {')
|
|
51
|
-
template.push(' count: 0')
|
|
52
|
-
template.push(' }')
|
|
53
|
-
template.push(' },')
|
|
54
|
-
template.push(' computed: {')
|
|
55
|
-
template.push(' // automatically compute this variable')
|
|
56
|
-
template.push(' // whenever VueJS deems appropriate')
|
|
57
|
-
template.push(' formattedCount: function () {')
|
|
58
|
-
template.push(' return this.count + \' Apples\'')
|
|
59
|
-
template.push(' }')
|
|
60
|
-
template.push(' },')
|
|
61
|
-
template.push(' methods: {')
|
|
62
|
-
template.push(' // UI-only method. Device commands must use this.sendControl({...}).')
|
|
63
|
-
template.push(' increase: function () {')
|
|
64
|
-
template.push(' this.count++')
|
|
65
|
-
template.push(' }')
|
|
66
|
-
template.push(' },')
|
|
67
|
-
template.push(' mounted() {')
|
|
68
|
-
template.push(' // After a successful login: this.setControlSession(session)')
|
|
69
|
-
template.push(' },')
|
|
70
|
-
template.push(' unmounted() {')
|
|
71
|
-
template.push(' // The injected policy cleans its queues and timers automatically.')
|
|
72
|
-
template.push(' // Clean only timers/listeners created by this Template.')
|
|
73
|
-
template.push(' }')
|
|
74
|
-
template.push(' }')
|
|
75
|
-
// eslint complaingin about unterminated string literal - need to do this oddity to get around it
|
|
76
|
-
template.push('</' + 'script>')
|
|
77
|
-
template.push('<style>')
|
|
78
|
-
template.push(' /* define any styles here - supports raw CSS */')
|
|
79
|
-
template.push(' .my-class {')
|
|
80
|
-
template.push(' color: red;')
|
|
81
|
-
template.push(' }')
|
|
82
|
-
template.push('</style>')
|
|
83
|
-
|
|
84
|
-
const defaultTemplate = template.join('\n')
|
|
12
|
+
const defaultTemplate = [
|
|
13
|
+
'<!-- Read CONTROL_PANEL_AI_GUIDE.md before adding device controls. -->',
|
|
14
|
+
'<template>',
|
|
15
|
+
' <div></div>',
|
|
16
|
+
'</template>'
|
|
17
|
+
].join('\n')
|
|
85
18
|
|
|
86
19
|
RED.nodes.registerType('ui-control-panel', {
|
|
87
20
|
category: RED._('@flowfuse/node-red-dashboard/ui-base:ui-base.label.category'),
|
|
@@ -158,14 +91,25 @@
|
|
|
158
91
|
height: { value: 0 },
|
|
159
92
|
head: { value: '' },
|
|
160
93
|
format: { value: defaultTemplate },
|
|
94
|
+
visualBindings: { value: '{}' },
|
|
95
|
+
boundRoom: { value: '' },
|
|
96
|
+
nativeBridge: { value: false },
|
|
97
|
+
bridgeRevisionApplied: { value: '' },
|
|
98
|
+
secureCookie: { value: false },
|
|
161
99
|
storeOutMessages: { value: true },
|
|
162
100
|
passthru: { value: true },
|
|
163
101
|
resendOnRefresh: { value: true },
|
|
164
102
|
templateScope: { value: 'local' },
|
|
165
103
|
className: { value: '' }
|
|
166
104
|
},
|
|
167
|
-
|
|
168
|
-
|
|
105
|
+
credentials: {
|
|
106
|
+
username: { type: 'text' },
|
|
107
|
+
password: { type: 'password' },
|
|
108
|
+
editorUsername: { type: 'text' },
|
|
109
|
+
editorPassword: { type: 'password' }
|
|
110
|
+
},
|
|
111
|
+
inputs: 1,
|
|
112
|
+
outputs: 1,
|
|
169
113
|
icon: 'font-awesome/fa-code',
|
|
170
114
|
paletteLabel: 'ControlPanel',
|
|
171
115
|
label: function () {
|
|
@@ -284,6 +228,20 @@
|
|
|
284
228
|
value: $('#node-input-format').val()
|
|
285
229
|
})
|
|
286
230
|
|
|
231
|
+
$('#control-binding-auth').show()
|
|
232
|
+
$('#control-visual-open').on('click', function () {
|
|
233
|
+
const group = RED.nodes.node($('#node-input-group').val() || that.group)
|
|
234
|
+
const page = RED.nodes.node($('#node-input-page').val() || that.page || group?.page)
|
|
235
|
+
const base = RED.nodes.node(page?.ui)
|
|
236
|
+
if (!page?.path) {
|
|
237
|
+
RED.notify('Choose a Dashboard page, then save and deploy this node.', { type: 'warning' })
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
const url = new URL((base?.path || '/dashboard').replace(/\/$/, '') + '/' +
|
|
241
|
+
page.path.replace(/^\//, ''), window.location.origin)
|
|
242
|
+
url.searchParams.set('cp-edit', that.id)
|
|
243
|
+
window.open(url.href, '_blank', 'noopener')
|
|
244
|
+
})
|
|
287
245
|
RED.library.create({
|
|
288
246
|
url: 'uitemplates', // where to get the data from
|
|
289
247
|
type: 'ui-template', // the type of object the library is for
|
|
@@ -379,20 +337,20 @@
|
|
|
379
337
|
})()
|
|
380
338
|
</script>
|
|
381
339
|
|
|
340
|
+
<style>
|
|
341
|
+
.control-binding-hint { margin: 4px 0; color: var(--red-ui-secondary-text-color, #666); overflow-wrap: anywhere; }
|
|
342
|
+
.control-binding-bridge-option { display: flex; align-items: flex-start; gap: 8px; }
|
|
343
|
+
.control-binding-bridge-option > label { flex: 0 0 105px; }
|
|
344
|
+
.control-binding-bridge-option > div { flex: 1; min-width: 0; }
|
|
345
|
+
.control-binding-bridge-option small { display: block; margin-top: 3px; }
|
|
346
|
+
</style>
|
|
347
|
+
|
|
382
348
|
<script type="text/html" data-help-name="ui-control-panel">
|
|
383
349
|
<p>This node renders a custom Vue Template and automatically injects control traffic and reported-device-state APIs.</p>
|
|
384
|
-
<p
|
|
385
|
-
<p
|
|
386
|
-
<ul>
|
|
387
|
-
<li><code>power</code>, <code>source</code>, <code>preset</code>, <code>mute</code>: ordered discrete commands.</li>
|
|
388
|
-
<li><code>level</code>, <code>dimmer</code>, <code>position</code>: 40 ms latest-wins per stable key.</li>
|
|
389
|
-
<li>Send the same continuous target with <code>final: true</code> on release/change.</li>
|
|
390
|
-
<li>Bind non-slider active/selected values to <code>deviceState</code> or <code>deviceValue()</code>; commands do not change reported state.</li>
|
|
391
|
-
<li>Use <code>isControlPending(key)</code> to show that a command is waiting for matching device feedback.</li>
|
|
392
|
-
<li>Sliders directly send and hold their latest target through <code>controlDisplayValue()</code>; unmatched older feedback must not move them.</li>
|
|
393
|
-
</ul>
|
|
394
|
-
<p>After login call <code>setControlSession(session)</code>; it opens the authenticated passive room feedback stream. The room-gateway retains device feedback received while the page is offline and sends the complete snapshot as the stream's first event. On logout call <code>clearControlSession()</code>. Feedback is available through <code>deviceState</code>, <code>hasDeviceState()</code>, <code>deviceValue()</code>, <code>deviceFeedbackConnected</code> and the optional <code>onDeviceFeedback(feedback, state)</code> hook. Use <code>controlDisplayValue()</code> for continuous controls. Do not add active state queries in the Template, and never echo feedback back as a command.</p>
|
|
350
|
+
<p>普通 HTML/Vue 控件由页面绑定编辑器自动识别,并通过面板的 authenticated session 发送控制。开发者不需要添加标签、调用自定义节点 API,或在 Template 中实现 throttling、latest-wins、队列和设备协议。</p>
|
|
351
|
+
<p>页面只需按普通 H5/Vue 方式编写控件;编辑模式下在控件上打开绑定菜单,选择设备参数即可。登录、发送、反馈和状态同步由节点负责,Template 不应主动查询设备或把反馈回发成命令。</p>
|
|
395
352
|
<p>See <code>CONTROL_PANEL_AI_GUIDE.md</code> in the node package for the complete AI authoring contract and examples.</p>
|
|
353
|
+
<p>Save and Deploy the panel, then use Open page binding editor. Apply in that page validates and saves bindings, backs up the Flow, regenerates the native bridge, and deploys it. Ordinary Deploy alone does not apply bindings. Check targets and numbering before using real hardware.</p>
|
|
396
354
|
</script>
|
|
397
355
|
|
|
398
356
|
<script type="text/html" data-template-name="ui-control-panel">
|
|
@@ -469,6 +427,44 @@
|
|
|
469
427
|
<div class="form-row node-text-editor-row" style="display: block;">
|
|
470
428
|
<div style="height:250px; min-height:100px" class="node-text-editor" id="node-input-format-editor" ></div>
|
|
471
429
|
</div>
|
|
430
|
+
<div class="form-row">
|
|
431
|
+
<label>Page binding</label>
|
|
432
|
+
<input type="hidden" id="node-input-visualBindings">
|
|
433
|
+
<button type="button" id="control-visual-open" class="red-ui-button">Open page binding editor</button>
|
|
434
|
+
<span class="control-binding-hint">Use ordinary HTML/Vue controls. Binding is configured from the page.</span>
|
|
435
|
+
</div>
|
|
436
|
+
<div class="form-row" style="display:none">
|
|
437
|
+
<input type="hidden" id="node-input-bridgeRevisionApplied">
|
|
438
|
+
</div>
|
|
439
|
+
<div id="control-binding-auth">
|
|
440
|
+
<div class="form-row">
|
|
441
|
+
<label for="node-input-boundRoom">Room ID</label>
|
|
442
|
+
<input type="text" id="node-input-boundRoom" placeholder="room-01">
|
|
443
|
+
</div>
|
|
444
|
+
<div class="form-row">
|
|
445
|
+
<label for="node-input-username">Login user</label>
|
|
446
|
+
<input type="text" id="node-input-username" autocomplete="off">
|
|
447
|
+
</div>
|
|
448
|
+
<div class="form-row">
|
|
449
|
+
<label for="node-input-password">Password</label>
|
|
450
|
+
<input type="password" id="node-input-password" autocomplete="new-password">
|
|
451
|
+
</div>
|
|
452
|
+
<div id="control-visual-credentials">
|
|
453
|
+
<div class="form-row">
|
|
454
|
+
<label for="node-input-editorUsername">Editor user</label>
|
|
455
|
+
<input type="text" id="node-input-editorUsername" autocomplete="off">
|
|
456
|
+
</div>
|
|
457
|
+
<div class="form-row">
|
|
458
|
+
<label for="node-input-editorPassword">Editor password</label>
|
|
459
|
+
<input type="password" id="node-input-editorPassword" autocomplete="new-password">
|
|
460
|
+
</div>
|
|
461
|
+
</div>
|
|
462
|
+
<div class="form-row">
|
|
463
|
+
<label for="node-input-secureCookie">HTTPS cookie</label>
|
|
464
|
+
<input type="checkbox" id="node-input-secureCookie" style="width:auto">
|
|
465
|
+
</div>
|
|
466
|
+
<input type="hidden" id="node-input-nativeBridge">
|
|
467
|
+
</div>
|
|
472
468
|
<div id="template-pass-store">
|
|
473
469
|
<div class="form-row" style="margin-bottom:0px;">
|
|
474
470
|
<input type="checkbox" id="node-input-passthru" style="display:inline-block; margin-left:8px; width:auto; vertical-align:top;">
|
package/control-panel.js
CHANGED
|
@@ -1,10 +1,55 @@
|
|
|
1
|
+
const crypto = require('node:crypto')
|
|
2
|
+
const path = require('node:path')
|
|
1
3
|
const { injectControlPolicy } = require('./control-policy')
|
|
4
|
+
const { configuredRoutes, resolveBoundCommand, resolveBoundFeedback } = require('./control-bound-protocol')
|
|
5
|
+
const { catalogBase, listCatalogDevices, listCatalogTargets } = require('./control-catalog')
|
|
6
|
+
const { contract } = require('./control-type-model')
|
|
7
|
+
const { parseVisualBindings } = require('./panel-visual-bindings')
|
|
8
|
+
const { instrumentTemplate } = require('./panel-template-instrumentation')
|
|
9
|
+
const { createPanelEditor } = require('./panel-editor-auth')
|
|
10
|
+
const { applyVisualBindings, editorConfig, getSnapshot } = require('./panel-visual-apply')
|
|
11
|
+
const { createGateway } = require('./gateway.cjs')
|
|
12
|
+
const { managedBridgeRevision } = require('./managed-bridge-revision')
|
|
2
13
|
|
|
3
14
|
let statestore
|
|
15
|
+
const authHandoffKey = crypto.randomBytes(32)
|
|
16
|
+
const gatewayHandoffs = new Map()
|
|
17
|
+
const editorHandoffs = new Map()
|
|
18
|
+
const panelLoginImage = path.join(__dirname, 'assets', 'panel-login-bg.png')
|
|
4
19
|
|
|
5
|
-
function
|
|
20
|
+
function authSignature (values) {
|
|
21
|
+
return crypto.createHmac('sha256', authHandoffKey).update(JSON.stringify(values)).digest('hex')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function holdSessions (handoffs, id, signature, sessions) {
|
|
25
|
+
const previous = handoffs.get(id)
|
|
26
|
+
if (previous) clearTimeout(previous.timer)
|
|
27
|
+
handoffs.delete(id)
|
|
28
|
+
if (!sessions?.size) return
|
|
29
|
+
const item = { signature, sessions }
|
|
30
|
+
item.timer = setTimeout(() => { if (handoffs.get(id) === item) handoffs.delete(id) }, 60000)
|
|
31
|
+
item.timer.unref?.()
|
|
32
|
+
handoffs.set(id, item)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function takeSessions (handoffs, id, signature) {
|
|
36
|
+
const item = handoffs.get(id)
|
|
37
|
+
if (!item) return null
|
|
38
|
+
clearTimeout(item.timer)
|
|
39
|
+
handoffs.delete(id)
|
|
40
|
+
return item.signature === signature ? item.sessions : null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function injectWidgetPolicy (format, templateScope, boundPanel) {
|
|
6
44
|
if (templateScope === 'site:style' || templateScope === 'page:style') return format
|
|
7
|
-
|
|
45
|
+
const rendered = instrumentTemplate(format).format
|
|
46
|
+
const controls = Object.entries(boundPanel?.visual?.bindings || {})
|
|
47
|
+
.map(([id, entry]) => ({ id, type: entry.type, mode: entry.mode }))
|
|
48
|
+
const injected = injectControlPolicy(rendered, controls, boundPanel)
|
|
49
|
+
if (!injected.includes('/* ui-control-panel-control-api-v1 */')) {
|
|
50
|
+
throw new Error('Visual binding requires a supported Vue Options API template')
|
|
51
|
+
}
|
|
52
|
+
return injected
|
|
8
53
|
}
|
|
9
54
|
|
|
10
55
|
function getStateStore (RED) {
|
|
@@ -21,25 +66,186 @@ function getStateStore (RED) {
|
|
|
21
66
|
}
|
|
22
67
|
|
|
23
68
|
module.exports = function (RED) {
|
|
69
|
+
const base = catalogBase()
|
|
70
|
+
const activePanels = new Map()
|
|
71
|
+
const activeEditors = new Map()
|
|
72
|
+
RED.httpNode.use((req, res, next) => {
|
|
73
|
+
const path = new URL(req.url, 'http://localhost').pathname
|
|
74
|
+
if (path === '/control-panel-assets/panel-login-bg.png') {
|
|
75
|
+
res.setHeader('Cache-Control', 'public, max-age=86400')
|
|
76
|
+
return res.sendFile(panelLoginImage)
|
|
77
|
+
}
|
|
78
|
+
const editorMatch = path.match(/^\/control-panel-edit\/panels\/([a-zA-Z0-9._-]+)\//)
|
|
79
|
+
if (editorMatch) {
|
|
80
|
+
const editor = activeEditors.get(editorMatch[1])
|
|
81
|
+
if (!editor) return res.status(404).json({ error: 'Visual editor unavailable' })
|
|
82
|
+
return editor.handler(req, res).then(handled => {
|
|
83
|
+
if (!handled) res.status(404).json({ error: 'Not found' })
|
|
84
|
+
}).catch(next)
|
|
85
|
+
}
|
|
86
|
+
const match = path
|
|
87
|
+
.match(/^\/control-panel-api\/panels\/([a-zA-Z0-9._-]+)\//)
|
|
88
|
+
if (!match) return next()
|
|
89
|
+
const gateway = activePanels.get(match[1])
|
|
90
|
+
if (!gateway) return res.status(404).json({ error: 'Panel unavailable' })
|
|
91
|
+
gateway.handler(req, res).then(handled => {
|
|
92
|
+
if (!handled) res.status(404).json({ error: 'Not found' })
|
|
93
|
+
}).catch(next)
|
|
94
|
+
})
|
|
95
|
+
RED.httpAdmin.get('/ui-control-panel/catalog/devices', RED.auth.needsPermission('nodes.read'), async (_req, res) => {
|
|
96
|
+
if (!base) return res.json({ enabled: false, devices: [] })
|
|
97
|
+
try {
|
|
98
|
+
res.json({ enabled: true, devices: await listCatalogDevices(base) })
|
|
99
|
+
} catch (error) {
|
|
100
|
+
res.status(502).json({ error: error.message })
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
RED.httpAdmin.post('/ui-control-panel/catalog/targets', RED.auth.needsPermission('nodes.read'), async (req, res) => {
|
|
104
|
+
if (!base) return res.status(404).json({ error: 'Local PoC catalog is disabled' })
|
|
105
|
+
try {
|
|
106
|
+
res.json({ targets: await listCatalogTargets(base, req.body?.device) })
|
|
107
|
+
} catch (error) {
|
|
108
|
+
res.status(502).json({ error: error.message })
|
|
109
|
+
}
|
|
110
|
+
})
|
|
24
111
|
function ControlPanel (config) {
|
|
25
112
|
const node = this
|
|
26
113
|
RED.nodes.createNode(node, config)
|
|
27
114
|
|
|
28
115
|
let parentNode
|
|
116
|
+
const styleOnly = config.templateScope === 'site:style' || config.templateScope === 'page:style'
|
|
117
|
+
const visualBinding = !styleOnly
|
|
118
|
+
let visualBindings = {}
|
|
119
|
+
if (visualBinding) {
|
|
120
|
+
try { visualBindings = parseVisualBindings(config) } catch (error) { node.error(error) }
|
|
121
|
+
}
|
|
122
|
+
const hasVisualBindings = Object.keys(visualBindings).length > 0
|
|
123
|
+
const boundMode = !styleOnly && hasVisualBindings
|
|
124
|
+
const endpoint = `/control-panel-api/panels/${node.id}`
|
|
125
|
+
const boundRoom = config.boundRoom
|
|
126
|
+
let routes
|
|
127
|
+
routes = new Map()
|
|
128
|
+
if (boundMode) {
|
|
129
|
+
try {
|
|
130
|
+
routes = configuredRoutes(config)
|
|
131
|
+
} catch (error) {
|
|
132
|
+
node.error(error)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let bridgeReady = !config.bridgeRevisionApplied
|
|
137
|
+
if (config.nativeBridge === true) {
|
|
138
|
+
try {
|
|
139
|
+
bridgeReady = config.bridgeRevisionApplied === managedBridgeRevision({ ...config, id: node.id })
|
|
140
|
+
} catch (error) {
|
|
141
|
+
bridgeReady = false
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
node.status?.(bridgeReady ? {} : { fill: 'red', shape: 'ring', text: 'bridge out of sync' })
|
|
145
|
+
|
|
146
|
+
let gateway
|
|
147
|
+
let gatewayAuthKey
|
|
148
|
+
if (visualBinding && /^[a-zA-Z0-9_-]+$/.test(boundRoom || '')) {
|
|
149
|
+
try {
|
|
150
|
+
gatewayAuthKey = authSignature([node.id, boundRoom, node.credentials?.username,
|
|
151
|
+
node.credentials?.password, config.secureCookie === true])
|
|
152
|
+
gateway = createGateway({
|
|
153
|
+
username: node.credentials?.username,
|
|
154
|
+
password: node.credentials?.password,
|
|
155
|
+
room: boundRoom,
|
|
156
|
+
prefix: endpoint,
|
|
157
|
+
boundOnly: true,
|
|
158
|
+
secureCookie: config.secureCookie === true,
|
|
159
|
+
restoredSessions: takeSessions(gatewayHandoffs, node.id, gatewayAuthKey),
|
|
160
|
+
onBoundCommand: (message, identity) => {
|
|
161
|
+
if (!bridgeReady) return false
|
|
162
|
+
node.send({ ...resolveBoundCommand(routes, message), room: identity.room, user: identity.user })
|
|
163
|
+
},
|
|
164
|
+
onBoundLogin: identity => {
|
|
165
|
+
if (!boundMode || !bridgeReady) return
|
|
166
|
+
try { node.send({ kind: 'read-all', room: identity.room, user: identity.user }) } catch (error) { node.error(error) }
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
activePanels.set(node.id, gateway)
|
|
170
|
+
} catch (error) {
|
|
171
|
+
node.error(error)
|
|
172
|
+
}
|
|
173
|
+
} else if (visualBinding) {
|
|
174
|
+
node.error('Visual panel requires a valid room')
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let editor
|
|
178
|
+
let editorAuthKey
|
|
179
|
+
if (visualBinding) {
|
|
180
|
+
editorAuthKey = authSignature([node.id, node.credentials?.editorUsername,
|
|
181
|
+
node.credentials?.editorPassword, config.secureCookie === true])
|
|
182
|
+
editor = createPanelEditor({
|
|
183
|
+
panelId: node.id,
|
|
184
|
+
username: node.credentials?.editorUsername,
|
|
185
|
+
password: node.credentials?.editorPassword,
|
|
186
|
+
secureCookie: config.secureCookie === true,
|
|
187
|
+
restoredSessions: takeSessions(editorHandoffs, node.id, editorAuthKey),
|
|
188
|
+
getConfig: async req => editorConfig(await getSnapshot(req.socket.localPort), node.id),
|
|
189
|
+
getDevices: async () => {
|
|
190
|
+
if (!base) throw new Error('Device catalog unavailable')
|
|
191
|
+
return listCatalogDevices(base)
|
|
192
|
+
},
|
|
193
|
+
getTargets: async (_req, device) => {
|
|
194
|
+
if (!base) throw new Error('Device catalog unavailable')
|
|
195
|
+
return listCatalogTargets(base, device)
|
|
196
|
+
},
|
|
197
|
+
apply: async (req, body) => applyVisualBindings({
|
|
198
|
+
panelId: node.id,
|
|
199
|
+
rev: body.rev,
|
|
200
|
+
templateHash: body.templateHash,
|
|
201
|
+
entries: body.entries,
|
|
202
|
+
userDir: RED.settings.userDir,
|
|
203
|
+
remoteAddress: req.socket.remoteAddress,
|
|
204
|
+
localPort: req.socket.localPort,
|
|
205
|
+
catalogBase: base
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
if (editor) activeEditors.set(node.id, editor)
|
|
209
|
+
else node.warn?.('Visual binding editor needs an editor username and password')
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const panelConfig = () => (boundMode || visualBinding) ? {
|
|
213
|
+
endpoint, room: boundRoom,
|
|
214
|
+
...(visualBinding ? { visual: {
|
|
215
|
+
panelId: node.id,
|
|
216
|
+
editorEndpoint: `/control-panel-edit/panels/${node.id}`,
|
|
217
|
+
bindings: visualBindings,
|
|
218
|
+
contract
|
|
219
|
+
} } : {})
|
|
220
|
+
} : null
|
|
29
221
|
|
|
30
222
|
const events = {
|
|
31
|
-
onAction:
|
|
223
|
+
onAction: !boundMode,
|
|
32
224
|
beforeSend (msg) {
|
|
225
|
+
if (msg?._controlPanel?.kind === 'command') throw new Error('Bound commands require authenticated HTTP')
|
|
226
|
+
if (boundMode) {
|
|
227
|
+
if (msg?.kind === 'device-feedback' && gateway) {
|
|
228
|
+
gateway.boundFeedback(resolveBoundFeedback(routes, msg))
|
|
229
|
+
}
|
|
230
|
+
return {}
|
|
231
|
+
}
|
|
232
|
+
if (msg?.kind === 'device-feedback') {
|
|
233
|
+
return { _controlPanel: { kind: 'states', items: resolveBoundFeedback(routes, msg) } }
|
|
234
|
+
}
|
|
33
235
|
if (msg && msg.ui_update && statestore) {
|
|
34
236
|
const parent = parentNode
|
|
35
237
|
if (parent && typeof msg.ui_update.format !== 'undefined') {
|
|
36
|
-
msg.ui_update.format = injectWidgetPolicy(msg.ui_update.format, config.templateScope
|
|
238
|
+
msg.ui_update.format = injectWidgetPolicy(msg.ui_update.format, config.templateScope,
|
|
239
|
+
panelConfig())
|
|
37
240
|
statestore.set(parent.getBase(), node, msg, 'format', msg.ui_update.format)
|
|
38
241
|
}
|
|
39
242
|
}
|
|
40
243
|
return msg
|
|
41
244
|
}
|
|
42
245
|
}
|
|
246
|
+
if (boundMode) {
|
|
247
|
+
events.onSend = () => { throw new Error('Bound commands require authenticated HTTP') }
|
|
248
|
+
}
|
|
43
249
|
|
|
44
250
|
let retryTimer
|
|
45
251
|
let attempts = 0
|
|
@@ -58,10 +264,18 @@ module.exports = function (RED) {
|
|
|
58
264
|
}
|
|
59
265
|
|
|
60
266
|
// Keep the custom editor type in Dashboard's namespace, but use Dashboard's built-in renderer.
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
267
|
+
let widgetConfig
|
|
268
|
+
try {
|
|
269
|
+
widgetConfig = {
|
|
270
|
+
...config,
|
|
271
|
+
type: 'ui-template',
|
|
272
|
+
format: injectWidgetPolicy(config.format, config.templateScope, panelConfig()),
|
|
273
|
+
...(boundMode ? { passthru: false, storeOutMessages: false, resendOnRefresh: false } : {})
|
|
274
|
+
}
|
|
275
|
+
} catch (error) {
|
|
276
|
+
node.error(error)
|
|
277
|
+
node.status?.({ fill: 'red', shape: 'ring', text: 'visual template unsupported' })
|
|
278
|
+
return
|
|
65
279
|
}
|
|
66
280
|
parentNode = group || page || ui
|
|
67
281
|
if (group) group.register(node, widgetConfig, events)
|
|
@@ -71,10 +285,23 @@ module.exports = function (RED) {
|
|
|
71
285
|
}
|
|
72
286
|
|
|
73
287
|
registerWidget()
|
|
74
|
-
node.on('close', () =>
|
|
288
|
+
node.on('close', () => {
|
|
289
|
+
clearTimeout(retryTimer)
|
|
290
|
+
if (gateway) holdSessions(gatewayHandoffs, node.id, gatewayAuthKey, gateway.sessionSnapshot())
|
|
291
|
+
if (editor) holdSessions(editorHandoffs, node.id, editorAuthKey, editor.sessionSnapshot())
|
|
292
|
+
gateway?.close()
|
|
293
|
+
editor?.close()
|
|
294
|
+
if (activePanels.get(node.id) === gateway) activePanels.delete(node.id)
|
|
295
|
+
if (activeEditors.get(node.id) === editor) activeEditors.delete(node.id)
|
|
296
|
+
})
|
|
75
297
|
|
|
76
298
|
}
|
|
77
299
|
|
|
78
300
|
getStateStore(RED)
|
|
79
|
-
RED.nodes.registerType('ui-control-panel', ControlPanel
|
|
301
|
+
RED.nodes.registerType('ui-control-panel', ControlPanel, {
|
|
302
|
+
credentials: {
|
|
303
|
+
username: { type: 'text' }, password: { type: 'password' },
|
|
304
|
+
editorUsername: { type: 'text' }, editorPassword: { type: 'password' }
|
|
305
|
+
}
|
|
306
|
+
})
|
|
80
307
|
}
|