node-red-contrib-room-tools 1.7.1 → 1.8.1
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 +17 -3
- package/README.md +45 -1
- package/control-panel.html +3 -2
- package/control-policy.js +337 -24
- package/package.json +5 -1
- package/room-gateway.html +1 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# ControlPanel AI 开发契约
|
|
2
2
|
|
|
3
|
-
适用节点:`ui-control-panel` 1.
|
|
3
|
+
适用节点:`ui-control-panel` 1.8.0。
|
|
4
4
|
|
|
5
5
|
把本文件作为 AI 开发或修改 ControlPanel Template 前的强制上下文。目标是让
|
|
6
6
|
不同页面使用同一套控制语义,避免滑杆积压、重复队列、反馈回环和跨房间误发。
|
|
@@ -56,10 +56,21 @@ onControlFeedbackStatus(connected) // 自定义连接状态展示
|
|
|
56
56
|
this.setControlSession(session)
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
该调用会自动连接当前房间的被动反馈流。`room-gateway` 会缓存 Dashboard 离线期间收到的
|
|
60
|
+
真实设备状态,并在反馈流建立后的首帧发送完整快照。Template 不得主动查询设备状态,也不得再创建自己的状态轮询器、
|
|
60
61
|
`EventSource` 或 WebSocket。若使用完全自定义的 `controlTransport()`,可由外部代码把
|
|
61
62
|
标准 `kind: 'state'` 消息交给 `applyControlFeedback(message)`。
|
|
62
63
|
|
|
64
|
+
Automator 的状态连线规则:`room-gateway` 输出只接控制命令流程;设备参数反馈接 Gateway 输入。
|
|
65
|
+
以当前 room-01 Rane 映射为例,`//processors/1/channels/1/mute` 参数反馈通过
|
|
66
|
+
`room-message(audio/mute, useInput=true)` 返回 Gateway 输入,
|
|
67
|
+
`//processors/1/channels/2/level` 参数反馈通过
|
|
68
|
+
`room-message(audio/volume, useInput=true)` 返回 Gateway 输入。Template 不负责这些连线。
|
|
69
|
+
由于 Automator Event 只报告参数变化,如果 Automator 可能晚于驱动启动,还需要使用
|
|
70
|
+
`Inject once -> Status stateReady -> Switch true -> Status mute/level -> room-message -> Gateway`
|
|
71
|
+
在流程启动时读取 MUSE 虚拟设备已保存的当前值;同时用 `Event stateReady=true` 触发同一组
|
|
72
|
+
Status 节点,覆盖设备晚于 Automator 上线的情况。Mute/Level Event 节点继续负责后续实时变化。
|
|
73
|
+
|
|
63
74
|
退出、锁屏或会话失效时必须执行:
|
|
64
75
|
|
|
65
76
|
```js
|
|
@@ -173,7 +184,8 @@ Power、Source 和 Preset 使用相同原则:事件处理函数只计算并发
|
|
|
173
184
|
## 5. 反馈与显示规则
|
|
174
185
|
|
|
175
186
|
- `room-gateway` 的公开状态只来自设备反馈;接受控制命令不会提前改变公开状态。
|
|
176
|
-
-
|
|
187
|
+
- 驱动连接设备后负责查询一次真实状态;实时反馈由 Gateway 缓存并由公共 API 自动接收、写入
|
|
188
|
+
`deviceState`。Dashboard 离线时收到的状态会在下次登录后的反馈首帧恢复。
|
|
177
189
|
- Power、Source、Preset、Mute、温度步进按钮等非滑杆控件必须显示
|
|
178
190
|
`deviceState` 中的实际值,不得乐观切换本地状态。
|
|
179
191
|
- Level、Dimmer、Position 滑杆直接发送目标值且不主动查询。拖动和松手后都保持
|
|
@@ -210,6 +222,8 @@ AI 完成页面后必须逐项确认:
|
|
|
210
222
|
11. 未收到反馈的字段显示未知/禁用,不用默认值冒充实际状态。
|
|
211
223
|
12. 桌面和移动尺寸下文字、滑杆、按钮不重叠。
|
|
212
224
|
13. 页面卸载时清理 Template 自己创建的 timer 和 listener。
|
|
225
|
+
14. 设备连接后的首次查询由 Script-as-Device 驱动负责;Gateway 没有查询输出,Template
|
|
226
|
+
没有自行调用 `queryStatus` 或任何查询接口。
|
|
213
227
|
|
|
214
228
|
给 AI 的推荐提示语:
|
|
215
229
|
|
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ regions follow the same URL structure.
|
|
|
15
15
|
|
|
16
16
|
## Control traffic policy
|
|
17
17
|
|
|
18
|
-
Version 1.
|
|
18
|
+
Version 1.8.0 uses the same control semantics at the panel, gateway and device
|
|
19
19
|
driver boundaries:
|
|
20
20
|
|
|
21
21
|
- `power`, `source`, `preset` and `mute` are discrete controls. The panel sends
|
|
@@ -68,6 +68,50 @@ until matching passive feedback arrives. Older intermediate feedback updates
|
|
|
68
68
|
`deviceState` but does not pull the slider backward. Templates must not query
|
|
69
69
|
device state actively.
|
|
70
70
|
|
|
71
|
+
## Retained device state
|
|
72
|
+
|
|
73
|
+
`room-gateway` keeps its original single output for authenticated control
|
|
74
|
+
commands. Actual device feedback enters the gateway input as `kind: 'state'`.
|
|
75
|
+
The gateway stores the latest reported value for every associated control:
|
|
76
|
+
|
|
77
|
+
- If a Dashboard is online, feedback is broadcast immediately over its passive
|
|
78
|
+
Server-Sent Events connection.
|
|
79
|
+
- If no Dashboard is logged in, feedback remains cached in `reportedState`.
|
|
80
|
+
- When a Dashboard later opens the feedback stream, its first event is the
|
|
81
|
+
complete cached state snapshot.
|
|
82
|
+
|
|
83
|
+
The Script-as-Device driver should query once when its device connection is
|
|
84
|
+
established, publish the result through its virtual-device parameters, and then
|
|
85
|
+
rely on device feedback. This is not periodic polling. The Template does not
|
|
86
|
+
issue queries and device feedback never becomes a control command.
|
|
87
|
+
|
|
88
|
+
For the current room-01 Rane mapping, use these Automator feedback connections:
|
|
89
|
+
|
|
90
|
+
- Event `//processors/1/channels/1/mute` -> room-message `audio/mute` with
|
|
91
|
+
`useInput` enabled -> Gateway input.
|
|
92
|
+
- Event `//processors/1/channels/2/level` -> room-message `audio/volume` with
|
|
93
|
+
`useInput` enabled -> Gateway input.
|
|
94
|
+
|
|
95
|
+
Use the virtual-device name currently shown in Automator. Older deployments may
|
|
96
|
+
still display `Precis Driver`; newer descriptors display `RaneProcessor`.
|
|
97
|
+
|
|
98
|
+
Automator Event nodes report parameter changes but do not fetch an already
|
|
99
|
+
retained value. To seed a gateway that starts after the driver, add a flow-start
|
|
100
|
+
initialization branch for each associated control:
|
|
101
|
+
|
|
102
|
+
```text
|
|
103
|
+
Inject once -> Status //processors/1/stateReady -> Switch true
|
|
104
|
+
-> Status //processors/1/channels/1/mute -> room-message -> Gateway
|
|
105
|
+
-> Status //processors/1/channels/2/level -> room-message -> Gateway
|
|
106
|
+
|
|
107
|
+
Event //processors/1/stateReady (true) -> the same two Status nodes
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Keep the Event nodes above in parallel for subsequent live changes. The
|
|
111
|
+
`stateReady` check prevents descriptor defaults or an incomplete reconnect
|
|
112
|
+
snapshot from being presented as actual state. The `stateReady=true` Event
|
|
113
|
+
covers the case where the physical processor connects after Automator starts.
|
|
114
|
+
|
|
71
115
|
```html
|
|
72
116
|
<input
|
|
73
117
|
type="range"
|
package/control-panel.html
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
const template = []
|
|
13
13
|
|
|
14
14
|
template.push('<!--')
|
|
15
|
-
template.push('AI / DEVELOPER CONTRACT - ui-control-panel 1.
|
|
15
|
+
template.push('AI / DEVELOPER CONTRACT - ui-control-panel 1.8.0')
|
|
16
16
|
template.push('AI START HERE: 先完整阅读 CONTROL_PANEL_AI_GUIDE.md、当前 ControlPanel Template 和 control-policy.js,再开发或修改页面。严格使用注入的 sendControl() 和设备反馈 API,禁止在 Template 中重写发送队列、限速、debounce、latest-wins、主动状态查询或反馈连接。所有非滑杆控件必须显示设备反馈确认的实际状态;滑杆必须直接发送并保持最后目标,不能被旧中间反馈拉回;设备反馈不能触发命令回发。完成后按照指南第 6 节逐项检查。')
|
|
17
17
|
template.push('1. Use the injected sendControl({ type, topic, key, value, final }) API for device commands.')
|
|
18
18
|
template.push('2. Never add debounce, throttle, latest-wins maps, Promise queues, or raw device TCP code here.')
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
template.push('10. Feedback may update UI state, but must never trigger a watcher that sends the value back.')
|
|
27
27
|
template.push('11. Do not redefine any injected ControlPanel API or its feedback state fields.')
|
|
28
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.')
|
|
29
30
|
template.push('-->')
|
|
30
31
|
template.push('<template>')
|
|
31
32
|
template.push(' <div>')
|
|
@@ -390,7 +391,7 @@
|
|
|
390
391
|
<li>Use <code>isControlPending(key)</code> to show that a command is waiting for matching device feedback.</li>
|
|
391
392
|
<li>Sliders directly send and hold their latest target through <code>controlDisplayValue()</code>; unmatched older feedback must not move them.</li>
|
|
392
393
|
</ul>
|
|
393
|
-
<p>After login call <code>setControlSession(session)</code>; it opens the authenticated passive room feedback stream. 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, and never echo feedback back as a command.</p>
|
|
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>
|
|
394
395
|
<p>See <code>CONTROL_PANEL_AI_GUIDE.md</code> in the node package for the complete AI authoring contract and examples.</p>
|
|
395
396
|
</script>
|
|
396
397
|
|
package/control-policy.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
const acorn = require('acorn')
|
|
2
|
+
const escodegen = require('escodegen')
|
|
3
|
+
|
|
1
4
|
const CONTROL_POLICY_MARKER = '/* ui-control-panel-control-api-v1 */'
|
|
2
5
|
const FEEDBACK_TOPIC_FIELDS = Object.freeze({
|
|
3
6
|
'display/power': 'display',
|
|
@@ -399,17 +402,337 @@ const __controlPanelPolicyMixin = {
|
|
|
399
402
|
`
|
|
400
403
|
}
|
|
401
404
|
|
|
405
|
+
// Dashboard 2.0 1.31 parses ui-template scripts itself. It only treats a script
|
|
406
|
+
// as component options when the script contains one `export default { ... }`
|
|
407
|
+
// statement. Keep every helper inside data, methods, or lifecycle hooks so the
|
|
408
|
+
// generated script remains inside that supported subset.
|
|
409
|
+
function buildDashboardPolicyOptionsSource () {
|
|
410
|
+
return `
|
|
411
|
+
export default {
|
|
412
|
+
data () {
|
|
413
|
+
return {
|
|
414
|
+
deviceState: {},
|
|
415
|
+
deviceFeedbackRevision: 0,
|
|
416
|
+
deviceFeedbackConnected: false,
|
|
417
|
+
controlPending: {},
|
|
418
|
+
controlTargets: {}
|
|
419
|
+
}
|
|
420
|
+
},
|
|
421
|
+
methods: {
|
|
422
|
+
__controlPanelCreatePolicy: ${createControlPanelPolicy.toString()},
|
|
423
|
+
__controlPanelNormalizeFeedback: ${normalizeControlFeedback.toString()},
|
|
424
|
+
__controlPanelFeedbackFields () {
|
|
425
|
+
return ${JSON.stringify(FEEDBACK_TOPIC_FIELDS)}
|
|
426
|
+
},
|
|
427
|
+
__initializeControlPanelPolicy () {
|
|
428
|
+
this.__controlPanelSession = null
|
|
429
|
+
this.__controlPanelFeedbackSource = null
|
|
430
|
+
this.__controlPanelRequests = new Map()
|
|
431
|
+
this.__resetControlPolicy()
|
|
432
|
+
},
|
|
433
|
+
__disposeControlPanelPolicy () {
|
|
434
|
+
this.__closeControlFeedback()
|
|
435
|
+
this.__controlPanelPolicy?.dispose()
|
|
436
|
+
this.__controlPanelPolicy = null
|
|
437
|
+
this.__controlPanelRequests?.clear()
|
|
438
|
+
this.controlPending = {}
|
|
439
|
+
this.controlTargets = {}
|
|
440
|
+
this.__controlPanelSession = null
|
|
441
|
+
},
|
|
442
|
+
__resetControlPolicy () {
|
|
443
|
+
this.__controlPanelPolicy?.dispose()
|
|
444
|
+
this.__controlPanelPolicy = this.__controlPanelCreatePolicy({
|
|
445
|
+
intervalMs: 40,
|
|
446
|
+
dispatch: (command) => this.__dispatchControl(command),
|
|
447
|
+
onError: (error, command) => {
|
|
448
|
+
if (command?.controlKey) this.__clearControlPending(command.controlKey)
|
|
449
|
+
if (typeof this.onControlError === 'function') this.onControlError(error, command)
|
|
450
|
+
else console.error('ControlPanel command failed', error)
|
|
451
|
+
}
|
|
452
|
+
})
|
|
453
|
+
},
|
|
454
|
+
__setControlPending (key, pending) {
|
|
455
|
+
const next = { ...this.controlPending }
|
|
456
|
+
if (pending) next[key] = true
|
|
457
|
+
else delete next[key]
|
|
458
|
+
this.controlPending = next
|
|
459
|
+
},
|
|
460
|
+
__setControlTarget (key, value) {
|
|
461
|
+
this.controlTargets = { ...this.controlTargets, [key]: value }
|
|
462
|
+
},
|
|
463
|
+
__clearControlPending (key) {
|
|
464
|
+
this.__controlPanelRequests.delete(String(key))
|
|
465
|
+
this.__setControlPending(String(key), false)
|
|
466
|
+
const targets = { ...this.controlTargets }
|
|
467
|
+
delete targets[String(key)]
|
|
468
|
+
this.controlTargets = targets
|
|
469
|
+
},
|
|
470
|
+
__setFeedbackConnected (connected) {
|
|
471
|
+
const next = connected === true
|
|
472
|
+
if (this.deviceFeedbackConnected === next) return
|
|
473
|
+
this.deviceFeedbackConnected = next
|
|
474
|
+
if (typeof this.onControlFeedbackStatus === 'function') this.onControlFeedbackStatus(next)
|
|
475
|
+
},
|
|
476
|
+
__closeControlFeedback () {
|
|
477
|
+
this.__controlPanelFeedbackSource?.close()
|
|
478
|
+
this.__controlPanelFeedbackSource = null
|
|
479
|
+
this.__setFeedbackConnected(false)
|
|
480
|
+
},
|
|
481
|
+
__connectControlFeedback () {
|
|
482
|
+
this.__closeControlFeedback()
|
|
483
|
+
const session = this.__controlPanelSession
|
|
484
|
+
if (!session || typeof EventSource === 'undefined') return
|
|
485
|
+
const source = new EventSource('/room-panel-api/regions/' + encodeURIComponent(session.room) + '/events')
|
|
486
|
+
this.__controlPanelFeedbackSource = source
|
|
487
|
+
source.onopen = () => {
|
|
488
|
+
if (this.__controlPanelFeedbackSource === source) this.__setFeedbackConnected(true)
|
|
489
|
+
}
|
|
490
|
+
source.onmessage = (event) => {
|
|
491
|
+
if (this.__controlPanelFeedbackSource !== source) return
|
|
492
|
+
try {
|
|
493
|
+
this.applyControlFeedback(JSON.parse(event.data))
|
|
494
|
+
} catch (error) {
|
|
495
|
+
if (typeof this.onControlError === 'function') this.onControlError(error)
|
|
496
|
+
else console.error('ControlPanel feedback failed', error)
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
source.onerror = () => {
|
|
500
|
+
if (this.__controlPanelFeedbackSource === source) this.__setFeedbackConnected(false)
|
|
501
|
+
}
|
|
502
|
+
},
|
|
503
|
+
__acknowledgeControlFeedback (feedback) {
|
|
504
|
+
const feedbackFields = this.__controlPanelFeedbackFields()
|
|
505
|
+
for (const [key, request] of this.__controlPanelRequests) {
|
|
506
|
+
if (feedback.controlKey && feedback.controlKey !== key) continue
|
|
507
|
+
let matched = request.topic === 'room/reset'
|
|
508
|
+
let value
|
|
509
|
+
if (!matched && feedback.topic === request.topic) {
|
|
510
|
+
matched = true
|
|
511
|
+
value = feedback.payload
|
|
512
|
+
} else if (!matched && feedback.topic === 'room/state') {
|
|
513
|
+
const field = feedbackFields[request.topic]
|
|
514
|
+
if (field && Object.prototype.hasOwnProperty.call(feedback.state, field)) {
|
|
515
|
+
matched = true
|
|
516
|
+
value = feedback.state[field]
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
if (matched && (request.topic === 'room/reset' || Object.is(value, request.value))) {
|
|
520
|
+
this.__clearControlPending(key)
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
},
|
|
524
|
+
setControlSession (session) {
|
|
525
|
+
if (!session || typeof session !== 'object' || !session.room || !session.csrf) {
|
|
526
|
+
throw new Error('ControlPanel session needs room and csrf')
|
|
527
|
+
}
|
|
528
|
+
const next = { room: String(session.room), csrf: String(session.csrf) }
|
|
529
|
+
if (this.__controlPanelSession &&
|
|
530
|
+
(this.__controlPanelSession.room !== next.room || this.__controlPanelSession.csrf !== next.csrf)) {
|
|
531
|
+
this.__resetControlPolicy()
|
|
532
|
+
this.__controlPanelRequests.clear()
|
|
533
|
+
this.controlPending = {}
|
|
534
|
+
this.controlTargets = {}
|
|
535
|
+
this.deviceState = {}
|
|
536
|
+
this.deviceFeedbackRevision = 0
|
|
537
|
+
}
|
|
538
|
+
this.__controlPanelSession = next
|
|
539
|
+
this.__connectControlFeedback()
|
|
540
|
+
},
|
|
541
|
+
clearControlSession () {
|
|
542
|
+
this.__closeControlFeedback()
|
|
543
|
+
this.__controlPanelSession = null
|
|
544
|
+
this.__resetControlPolicy()
|
|
545
|
+
this.__controlPanelRequests.clear()
|
|
546
|
+
this.controlPending = {}
|
|
547
|
+
this.controlTargets = {}
|
|
548
|
+
this.deviceState = {}
|
|
549
|
+
this.deviceFeedbackRevision = 0
|
|
550
|
+
},
|
|
551
|
+
sendControl (command) {
|
|
552
|
+
const key = String(command?.key ?? command?.controlKey ?? '')
|
|
553
|
+
const type = String(command?.type ?? command?.controlType ?? '').toLowerCase()
|
|
554
|
+
const value = Object.prototype.hasOwnProperty.call(command || {}, 'value') ? command.value : command?.payload
|
|
555
|
+
if (key) {
|
|
556
|
+
this.__controlPanelRequests.set(key, {
|
|
557
|
+
topic: String(command.topic ?? ''),
|
|
558
|
+
type,
|
|
559
|
+
value
|
|
560
|
+
})
|
|
561
|
+
this.__setControlPending(key, true)
|
|
562
|
+
if (['level', 'dimmer', 'position'].includes(type)) this.__setControlTarget(key, value)
|
|
563
|
+
}
|
|
564
|
+
try {
|
|
565
|
+
return this.__controlPanelPolicy.send(command)
|
|
566
|
+
} catch (error) {
|
|
567
|
+
if (key) this.__clearControlPending(key)
|
|
568
|
+
throw error
|
|
569
|
+
}
|
|
570
|
+
},
|
|
571
|
+
flushControl (key) {
|
|
572
|
+
return this.__controlPanelPolicy.flush(key)
|
|
573
|
+
},
|
|
574
|
+
applyControlFeedback (message) {
|
|
575
|
+
const room = this.__controlPanelSession?.room || this.authorizedRoom
|
|
576
|
+
const feedback = this.__controlPanelNormalizeFeedback(
|
|
577
|
+
message,
|
|
578
|
+
room,
|
|
579
|
+
this.__controlPanelFeedbackFields()
|
|
580
|
+
)
|
|
581
|
+
this.deviceState = { ...this.deviceState, ...feedback.state }
|
|
582
|
+
if (feedback.revision !== null) this.deviceFeedbackRevision = feedback.revision
|
|
583
|
+
this.__acknowledgeControlFeedback(feedback)
|
|
584
|
+
if (typeof this.onDeviceFeedback === 'function') this.onDeviceFeedback(feedback, this.deviceState)
|
|
585
|
+
return feedback
|
|
586
|
+
},
|
|
587
|
+
hasDeviceState (field) {
|
|
588
|
+
return Object.prototype.hasOwnProperty.call(this.deviceState, String(field))
|
|
589
|
+
},
|
|
590
|
+
deviceValue (field, fallback = null) {
|
|
591
|
+
return this.hasDeviceState(field) ? this.deviceState[String(field)] : fallback
|
|
592
|
+
},
|
|
593
|
+
isControlPending (key) {
|
|
594
|
+
return this.controlPending[String(key)] === true
|
|
595
|
+
},
|
|
596
|
+
controlDisplayValue (key, field, fallback = null) {
|
|
597
|
+
const controlKey = String(key)
|
|
598
|
+
if (this.isControlPending(controlKey) &&
|
|
599
|
+
Object.prototype.hasOwnProperty.call(this.controlTargets, controlKey)) {
|
|
600
|
+
return this.controlTargets[controlKey]
|
|
601
|
+
}
|
|
602
|
+
return this.deviceValue(field, fallback)
|
|
603
|
+
},
|
|
604
|
+
async __dispatchControl (command) {
|
|
605
|
+
if (typeof this.controlTransport === 'function') return this.controlTransport(command)
|
|
606
|
+
|
|
607
|
+
const session = this.__controlPanelSession
|
|
608
|
+
const room = session?.room || this.authorizedRoom
|
|
609
|
+
if (typeof this.postControl === 'function') {
|
|
610
|
+
return this.postControl(room ? { ...command, room } : command, this.sessionGeneration)
|
|
611
|
+
}
|
|
612
|
+
if (!session) throw new Error('Call setControlSession() after login, or define controlTransport(command)')
|
|
613
|
+
|
|
614
|
+
const response = await fetch('/room-panel-api/regions/' + encodeURIComponent(session.room) + '/command', {
|
|
615
|
+
method: 'POST',
|
|
616
|
+
credentials: 'same-origin',
|
|
617
|
+
headers: { 'Content-Type': 'application/json', 'X-Room-CSRF': session.csrf },
|
|
618
|
+
body: JSON.stringify({ room: session.room, ...command }),
|
|
619
|
+
signal: AbortSignal.timeout(8000)
|
|
620
|
+
})
|
|
621
|
+
if (!response.ok) throw new Error('Control command failed (' + response.status + ')')
|
|
622
|
+
return response
|
|
623
|
+
}
|
|
624
|
+
},
|
|
625
|
+
mounted () {
|
|
626
|
+
this.__initializeControlPanelPolicy()
|
|
627
|
+
},
|
|
628
|
+
unmounted () {
|
|
629
|
+
this.__disposeControlPanelPolicy()
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
`
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function parseOptionsObject (source) {
|
|
636
|
+
const tree = acorn.parse(source, { ecmaVersion: 'latest', sourceType: 'module' })
|
|
637
|
+
if (tree.body.length !== 1 || tree.body[0].type !== 'ExportDefaultDeclaration' ||
|
|
638
|
+
tree.body[0].declaration.type !== 'ObjectExpression') return null
|
|
639
|
+
return tree.body[0].declaration
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function propertyName (property) {
|
|
643
|
+
if (!property || property.type !== 'Property') return null
|
|
644
|
+
if (!property.computed && property.key.type === 'Identifier') return property.key.name
|
|
645
|
+
if (property.key.type === 'Literal') return String(property.key.value)
|
|
646
|
+
return null
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function findProperty (object, name) {
|
|
650
|
+
return object.properties.find(property => propertyName(property) === name)
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function parseProperty (source) {
|
|
654
|
+
const object = acorn.parse('({' + source + '})', { ecmaVersion: 'latest' }).body[0].expression
|
|
655
|
+
return object.properties[0]
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function functionSource (property) {
|
|
659
|
+
return property ? escodegen.generate(property.value) : null
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function mergeDashboardOptions (userSource) {
|
|
663
|
+
const user = parseOptionsObject(userSource)
|
|
664
|
+
if (!user) return null
|
|
665
|
+
|
|
666
|
+
const policy = parseOptionsObject(buildDashboardPolicyOptionsSource())
|
|
667
|
+
const policyData = findProperty(policy, 'data')
|
|
668
|
+
const policyMethods = findProperty(policy, 'methods')
|
|
669
|
+
const policyMounted = findProperty(policy, 'mounted')
|
|
670
|
+
const policyUnmounted = findProperty(policy, 'unmounted')
|
|
671
|
+
const userData = findProperty(user, 'data')
|
|
672
|
+
const userMethods = findProperty(user, 'methods')
|
|
673
|
+
const userMounted = findProperty(user, 'mounted')
|
|
674
|
+
const userUnmounted = findProperty(user, 'unmounted')
|
|
675
|
+
|
|
676
|
+
if (userMethods && userMethods.value.type !== 'ObjectExpression') return null
|
|
677
|
+
|
|
678
|
+
const data = userData
|
|
679
|
+
? parseProperty(`data () {
|
|
680
|
+
const userState = (${functionSource(userData)}).call(this) || {}
|
|
681
|
+
const policyState = (${functionSource(policyData)}).call(this) || {}
|
|
682
|
+
return { ...userState, ...policyState }
|
|
683
|
+
}`)
|
|
684
|
+
: policyData
|
|
685
|
+
|
|
686
|
+
const policyMethodNames = new Set(policyMethods.value.properties.map(propertyName))
|
|
687
|
+
const methods = parseProperty('methods: {}')
|
|
688
|
+
methods.value.properties = [
|
|
689
|
+
...(userMethods ? userMethods.value.properties.filter(property => !policyMethodNames.has(propertyName(property))) : []),
|
|
690
|
+
...policyMethods.value.properties
|
|
691
|
+
]
|
|
692
|
+
|
|
693
|
+
const mounted = userMounted
|
|
694
|
+
? parseProperty(`mounted () {
|
|
695
|
+
(${functionSource(policyMounted)}).call(this)
|
|
696
|
+
return (${functionSource(userMounted)}).call(this)
|
|
697
|
+
}`)
|
|
698
|
+
: policyMounted
|
|
699
|
+
|
|
700
|
+
const unmounted = userUnmounted
|
|
701
|
+
? parseProperty(`unmounted () {
|
|
702
|
+
try {
|
|
703
|
+
return (${functionSource(userUnmounted)}).call(this)
|
|
704
|
+
} finally {
|
|
705
|
+
(${functionSource(policyUnmounted)}).call(this)
|
|
706
|
+
}
|
|
707
|
+
}`)
|
|
708
|
+
: policyUnmounted
|
|
709
|
+
|
|
710
|
+
const replaced = new Set(['data', 'methods', 'mounted', 'unmounted'])
|
|
711
|
+
const merged = {
|
|
712
|
+
...user,
|
|
713
|
+
properties: [
|
|
714
|
+
...user.properties.filter(property => !replaced.has(propertyName(property))),
|
|
715
|
+
data,
|
|
716
|
+
methods,
|
|
717
|
+
mounted,
|
|
718
|
+
unmounted
|
|
719
|
+
]
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
return CONTROL_POLICY_MARKER + '\nexport default ' + escodegen.generate(merged, {
|
|
723
|
+
format: { indent: { style: ' ' } }
|
|
724
|
+
})
|
|
725
|
+
}
|
|
726
|
+
|
|
402
727
|
function injectControlPolicy (format) {
|
|
403
728
|
if (typeof format !== 'string' || format.includes(CONTROL_POLICY_MARKER)) return format
|
|
404
729
|
|
|
405
730
|
const scriptMatch = /<script(?!\s+setup\b)(?:\s[^>]*)?>/i.exec(format)
|
|
406
731
|
if (!scriptMatch) {
|
|
407
732
|
const setupMatch = /<script\s+setup\b([^>]*)>/i.exec(format)
|
|
408
|
-
if (
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
return format + '\n' + scriptOpen + '\n' + buildControlPolicyMixinSource() +
|
|
412
|
-
'\nexport default { mixins: [__controlPanelPolicyMixin] }\n</script>\n'
|
|
733
|
+
if (setupMatch || !/<template(?:\s|>)/i.test(format)) return format
|
|
734
|
+
return format + '\n<script>\n' + CONTROL_POLICY_MARKER + '\n' +
|
|
735
|
+
buildDashboardPolicyOptionsSource().trim() + '\n</script>\n'
|
|
413
736
|
}
|
|
414
737
|
|
|
415
738
|
const scriptStart = scriptMatch.index + scriptMatch[0].length
|
|
@@ -417,33 +740,23 @@ function injectControlPolicy (format) {
|
|
|
417
740
|
if (scriptEnd === -1) return format
|
|
418
741
|
|
|
419
742
|
const source = format.slice(scriptStart, scriptEnd)
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
return format
|
|
743
|
+
let wrapped
|
|
744
|
+
try {
|
|
745
|
+
wrapped = mergeDashboardOptions(source)
|
|
746
|
+
} catch (_) {
|
|
747
|
+
return format
|
|
425
748
|
}
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
const exportEnd = exportMatch.index + exportMatch[0].length
|
|
429
|
-
const userOptions = source.slice(0, exportStart) +
|
|
430
|
-
'const __controlPanelUserOptions =' +
|
|
431
|
-
source.slice(exportEnd)
|
|
432
|
-
const wrapped = userOptions + '\n' + buildControlPolicyMixinSource() + `
|
|
433
|
-
export default {
|
|
434
|
-
...__controlPanelUserOptions,
|
|
435
|
-
mixins: [...(__controlPanelUserOptions.mixins || []), __controlPanelPolicyMixin]
|
|
436
|
-
}
|
|
437
|
-
`
|
|
438
|
-
|
|
439
|
-
return format.slice(0, scriptStart) + wrapped + format.slice(scriptEnd)
|
|
749
|
+
if (!wrapped) return format
|
|
750
|
+
return format.slice(0, scriptStart) + '\n' + wrapped + '\n' + format.slice(scriptEnd)
|
|
440
751
|
}
|
|
441
752
|
|
|
442
753
|
module.exports = {
|
|
443
754
|
CONTROL_POLICY_MARKER,
|
|
444
755
|
FEEDBACK_TOPIC_FIELDS,
|
|
445
756
|
buildControlPolicyMixinSource,
|
|
757
|
+
buildDashboardPolicyOptionsSource,
|
|
446
758
|
createControlPanelPolicy,
|
|
447
759
|
injectControlPolicy,
|
|
760
|
+
mergeDashboardOptions,
|
|
448
761
|
normalizeControlFeedback
|
|
449
762
|
}
|
package/package.json
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-red-contrib-room-tools",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.1",
|
|
4
4
|
"description": "Room panel message generator and authenticated region gateway for Node-RED and MUSE Automator",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"keywords": ["node-red"],
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"acorn": "^8.15.0",
|
|
9
|
+
"escodegen": "^2.1.0"
|
|
10
|
+
},
|
|
7
11
|
"files": ["gateway.cjs", "room-gateway.js", "room-gateway.html", "room-message.js", "room-message.html", "control-panel.js", "control-panel.html", "control-policy.js", "CONTROL_PANEL_AI_GUIDE.md", "README.md"],
|
|
8
12
|
"node-red": {
|
|
9
13
|
"nodes": {
|
package/room-gateway.html
CHANGED
|
@@ -16,5 +16,6 @@ RED.nodes.registerType('room-gateway',{
|
|
|
16
16
|
<script type="text/html" data-help-name="room-gateway">
|
|
17
17
|
<p>Template 通过同源 /room-panel-api/regions/<区域 ID> 登录和发送控制请求;ui-control-panel 登录后自动订阅该区域的实时设备反馈。Template 输出不再用于控制设备。</p>
|
|
18
18
|
<p>本节点输出已授权消息,连接原来的 Switch。设备反馈必须含 room、kind: state、topic 和 payload,接入本节点输入,不接 Template。</p>
|
|
19
|
+
<p>网关会保存每个已关联控件最后收到的真实设备状态。页面在线时通过实时反馈流立即推送;页面未登录时继续缓存,登录后在反馈流首帧发送完整状态快照。</p>
|
|
19
20
|
<p>公开状态只包含设备反馈,不会把刚接受的控制目标冒充实际状态。Node-RED 编辑器属于管理员入口,部署时应独立保护。生产 iPad 访问请配置 HTTPS 并启用 HTTPS Cookie。</p>
|
|
20
21
|
</script>
|