node-red-contrib-room-tools 1.8.2 → 1.8.3

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.
@@ -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 }
@@ -9,79 +9,12 @@
9
9
  return Object.prototype.hasOwnProperty.call(obj, prop)
10
10
  }
11
11
 
12
- const template = []
13
-
14
- template.push('<!--')
15
- template.push('AI / DEVELOPER CONTRACT - ui-control-panel 1.8.2')
16
- template.push('AI START HERE: 先完整阅读 CONTROL_PANEL_AI_GUIDE.md、当前 ControlPanel Template 和 control-policy.js,再开发或修改页面。严格使用注入的 sendControl() 和设备反馈 API,禁止在 Template 中重写发送队列、限速、debounce、latest-wins、主动状态查询或反馈连接。所有非滑杆控件必须显示设备反馈确认的实际状态;滑杆必须直接发送并保持最后目标,不能被旧中间反馈拉回;设备反馈不能触发命令回发。完成后按照指南第 6 节逐项检查。')
17
- template.push('1. Use the injected sendControl({ type, topic, key, value, final }) API for device commands.')
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
- inputs: 0,
168
- outputs: 0,
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><strong>AI START HERE:</strong> 先完整阅读 <code>CONTROL_PANEL_AI_GUIDE.md</code>、当前 ControlPanel Template 和 <code>control-policy.js</code>,再开发或修改页面。严格使用注入的 <code>sendControl()</code> 和设备反馈 API,禁止在 Template 中重写发送队列、限速、debounce、latest-wins、主动状态查询或反馈连接。所有非滑杆控件必须显示设备反馈确认的实际状态;滑杆必须直接发送并保持最后目标,不能被旧中间反馈拉回;设备反馈不能触发命令回发。完成后按照指南第 6 节逐项检查。</p>
385
- <p>Use <code>sendControl({ type, topic, key, value, final })</code> for device commands. Do not implement throttling, latest-wins maps, queues, or device protocol code in the Template.</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 injectWidgetPolicy (format, templateScope) {
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
- return injectControlPolicy(format)
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: true,
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
- const widgetConfig = {
62
- ...config,
63
- type: 'ui-template',
64
- format: injectWidgetPolicy(config.format, config.templateScope)
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', () => clearTimeout(retryTimer))
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
  }