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.
@@ -1,6 +1,6 @@
1
1
  # ControlPanel AI 开发契约
2
2
 
3
- 适用节点:`ui-control-panel` 1.7.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
- 该调用会自动连接当前房间的被动反馈流。Template 不得主动查询设备状态,也不得再创建自己的状态轮询器、
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
- - 登录后的实时反馈由公共 API 自动接收并写入 `deviceState`。
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.7.1 uses the same control semantics at the panel, gateway and device
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"
@@ -12,7 +12,7 @@
12
12
  const template = []
13
13
 
14
14
  template.push('<!--')
15
- template.push('AI / DEVELOPER CONTRACT - ui-control-panel 1.7.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 (!setupMatch && !/<template(?:\s|>)/i.test(format)) return format
409
- const langMatch = setupMatch?.[1]?.match(/\blang\s*=\s*(["'])([^"']+)\1/i)
410
- const scriptOpen = langMatch ? '<script lang="' + langMatch[2] + '">' : '<script>'
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
- const exportMatch = /(^|\n)([ \t]*)export\s+default\b/.exec(source)
421
- if (!exportMatch) {
422
- const wrapped = source + '\n' + buildControlPolicyMixinSource() +
423
- '\nexport default { mixins: [__controlPanelPolicyMixin] }\n'
424
- return format.slice(0, scriptStart) + wrapped + format.slice(scriptEnd)
743
+ let wrapped
744
+ try {
745
+ wrapped = mergeDashboardOptions(source)
746
+ } catch (_) {
747
+ return format
425
748
  }
426
-
427
- const exportStart = exportMatch.index + exportMatch[1].length + exportMatch[2].length
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.7.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/&lt;区域 ID&gt; 登录和发送控制请求;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>