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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  # ControlPanel AI 开发契约
2
2
 
3
- 适用节点:`ui-control-panel` 1.8.0。
3
+ 适用节点:`ui-control-panel` 1.8.2。
4
4
 
5
5
  把本文件作为 AI 开发或修改 ControlPanel Template 前的强制上下文。目标是让
6
6
  不同页面使用同一套控制语义,避免滑杆积压、重复队列、反馈回环和跨房间误发。
@@ -151,6 +151,11 @@ Vuetify 滑杆可把相同逻辑分别放在 `@update:model-value` 和 `@end`。
151
151
  连续控件直接发送目标值,不主动查询当前值。匹配目标的被动反馈会自动清除 pending;
152
152
  未匹配的较旧反馈只更新 `deviceState`,`controlDisplayValue()` 仍返回最后目标。
153
153
 
154
+ 离散控件的下一条同 topic 真实反馈会解除 pending,并直接以该反馈值显示实际状态,
155
+ 即使 Automator 反馈携带的是 MUSE provider path、与 Template 的 UI `key` 不同。若同一房间内
156
+ 有多个相同 topic 的控件,反馈必须提供与 Template 一致的 `controlKey`,避免误确认其他控件。
157
+ 连续控件仍只有在反馈值匹配最后目标时才解除 pending。
158
+
154
159
  离散按钮必须显示设备反馈值,不能先改本地状态。标准写法:
155
160
 
156
161
  ```html
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.8.0 uses the same control semantics at the panel, gateway and device
18
+ Version 1.8.2 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
@@ -58,7 +58,10 @@ the selected room. `deviceState` contains reported device state only. Accepted
58
58
  commands update the gateway's private desired state but do not change
59
59
  `deviceState`; matching Script-as-Device feedback is required before a Power,
60
60
  Mute, Source or Preset control appears active. `isControlPending(key)` can be
61
- used for a waiting indicator.
61
+ used for a waiting indicator. A discrete control is released by the next
62
+ authoritative feedback for its topic, even when Automator reports a provider
63
+ path instead of the Template's UI key. Continuous controls still wait for the
64
+ reported value to match their latest target.
62
65
 
63
66
  Non-slider controls must use `:model-value`, `:value`, `:class` or
64
67
  `:aria-pressed` from `deviceState`/`deviceValue()` and must not optimistically
@@ -12,7 +12,7 @@
12
12
  const template = []
13
13
 
14
14
  template.push('<!--')
15
- template.push('AI / DEVELOPER CONTRACT - ui-control-panel 1.8.0')
15
+ template.push('AI / DEVELOPER CONTRACT - ui-control-panel 1.8.2')
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.')
@@ -21,7 +21,7 @@
21
21
  template.push('5. key must be stable and unique per device control, for example rane/1/level/2.')
22
22
  template.push('6. After room-gateway login call setControlSession(session); on logout call clearControlSession().')
23
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; applyControlFeedback() never sends commands.')
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
25
  template.push('9. Sliders directly send and hold the latest target; use controlDisplayValue() so stale feedback cannot pull them back.')
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.')
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',
@@ -271,7 +274,6 @@ const __controlPanelPolicyMixin = {
271
274
  },
272
275
  __acknowledgeControlFeedback (feedback) {
273
276
  for (const [key, request] of this.__controlPanelRequests) {
274
- if (feedback.controlKey && feedback.controlKey !== key) continue
275
277
  let matched = request.topic === 'room/reset'
276
278
  let value
277
279
  if (!matched && feedback.topic === request.topic) {
@@ -284,7 +286,15 @@ const __controlPanelPolicyMixin = {
284
286
  value = feedback.state[field]
285
287
  }
286
288
  }
287
- if (matched && (request.topic === 'room/reset' || Object.is(value, request.value))) {
289
+ if (!matched) continue
290
+
291
+ const sameTopicRequests = [...this.__controlPanelRequests.values()]
292
+ .filter(candidate => candidate.topic === request.topic).length
293
+ const correlated = !feedback.controlKey || feedback.controlKey === key || sameTopicRequests === 1
294
+ if (!correlated) continue
295
+
296
+ const continuous = ['level', 'dimmer', 'position'].includes(request.type)
297
+ if (!continuous || request.topic === 'room/reset' || Object.is(value, request.value)) {
288
298
  this.__clearControlPending(key)
289
299
  }
290
300
  }
@@ -399,17 +409,344 @@ const __controlPanelPolicyMixin = {
399
409
  `
400
410
  }
401
411
 
412
+ // Dashboard 2.0 1.31 parses ui-template scripts itself. It only treats a script
413
+ // as component options when the script contains one `export default { ... }`
414
+ // statement. Keep every helper inside data, methods, or lifecycle hooks so the
415
+ // generated script remains inside that supported subset.
416
+ function buildDashboardPolicyOptionsSource () {
417
+ return `
418
+ export default {
419
+ data () {
420
+ return {
421
+ deviceState: {},
422
+ deviceFeedbackRevision: 0,
423
+ deviceFeedbackConnected: false,
424
+ controlPending: {},
425
+ controlTargets: {}
426
+ }
427
+ },
428
+ methods: {
429
+ __controlPanelCreatePolicy: ${createControlPanelPolicy.toString()},
430
+ __controlPanelNormalizeFeedback: ${normalizeControlFeedback.toString()},
431
+ __controlPanelFeedbackFields () {
432
+ return ${JSON.stringify(FEEDBACK_TOPIC_FIELDS)}
433
+ },
434
+ __initializeControlPanelPolicy () {
435
+ this.__controlPanelSession = null
436
+ this.__controlPanelFeedbackSource = null
437
+ this.__controlPanelRequests = new Map()
438
+ this.__resetControlPolicy()
439
+ },
440
+ __disposeControlPanelPolicy () {
441
+ this.__closeControlFeedback()
442
+ this.__controlPanelPolicy?.dispose()
443
+ this.__controlPanelPolicy = null
444
+ this.__controlPanelRequests?.clear()
445
+ this.controlPending = {}
446
+ this.controlTargets = {}
447
+ this.__controlPanelSession = null
448
+ },
449
+ __resetControlPolicy () {
450
+ this.__controlPanelPolicy?.dispose()
451
+ this.__controlPanelPolicy = this.__controlPanelCreatePolicy({
452
+ intervalMs: 40,
453
+ dispatch: (command) => this.__dispatchControl(command),
454
+ onError: (error, command) => {
455
+ if (command?.controlKey) this.__clearControlPending(command.controlKey)
456
+ if (typeof this.onControlError === 'function') this.onControlError(error, command)
457
+ else console.error('ControlPanel command failed', error)
458
+ }
459
+ })
460
+ },
461
+ __setControlPending (key, pending) {
462
+ const next = { ...this.controlPending }
463
+ if (pending) next[key] = true
464
+ else delete next[key]
465
+ this.controlPending = next
466
+ },
467
+ __setControlTarget (key, value) {
468
+ this.controlTargets = { ...this.controlTargets, [key]: value }
469
+ },
470
+ __clearControlPending (key) {
471
+ this.__controlPanelRequests.delete(String(key))
472
+ this.__setControlPending(String(key), false)
473
+ const targets = { ...this.controlTargets }
474
+ delete targets[String(key)]
475
+ this.controlTargets = targets
476
+ },
477
+ __setFeedbackConnected (connected) {
478
+ const next = connected === true
479
+ if (this.deviceFeedbackConnected === next) return
480
+ this.deviceFeedbackConnected = next
481
+ if (typeof this.onControlFeedbackStatus === 'function') this.onControlFeedbackStatus(next)
482
+ },
483
+ __closeControlFeedback () {
484
+ this.__controlPanelFeedbackSource?.close()
485
+ this.__controlPanelFeedbackSource = null
486
+ this.__setFeedbackConnected(false)
487
+ },
488
+ __connectControlFeedback () {
489
+ this.__closeControlFeedback()
490
+ const session = this.__controlPanelSession
491
+ if (!session || typeof EventSource === 'undefined') return
492
+ const source = new EventSource('/room-panel-api/regions/' + encodeURIComponent(session.room) + '/events')
493
+ this.__controlPanelFeedbackSource = source
494
+ source.onopen = () => {
495
+ if (this.__controlPanelFeedbackSource === source) this.__setFeedbackConnected(true)
496
+ }
497
+ source.onmessage = (event) => {
498
+ if (this.__controlPanelFeedbackSource !== source) return
499
+ try {
500
+ this.applyControlFeedback(JSON.parse(event.data))
501
+ } catch (error) {
502
+ if (typeof this.onControlError === 'function') this.onControlError(error)
503
+ else console.error('ControlPanel feedback failed', error)
504
+ }
505
+ }
506
+ source.onerror = () => {
507
+ if (this.__controlPanelFeedbackSource === source) this.__setFeedbackConnected(false)
508
+ }
509
+ },
510
+ __acknowledgeControlFeedback (feedback) {
511
+ const feedbackFields = this.__controlPanelFeedbackFields()
512
+ for (const [key, request] of this.__controlPanelRequests) {
513
+ let matched = request.topic === 'room/reset'
514
+ let value
515
+ if (!matched && feedback.topic === request.topic) {
516
+ matched = true
517
+ value = feedback.payload
518
+ } else if (!matched && feedback.topic === 'room/state') {
519
+ const field = feedbackFields[request.topic]
520
+ if (field && Object.prototype.hasOwnProperty.call(feedback.state, field)) {
521
+ matched = true
522
+ value = feedback.state[field]
523
+ }
524
+ }
525
+ if (!matched) continue
526
+
527
+ const sameTopicRequests = [...this.__controlPanelRequests.values()]
528
+ .filter(candidate => candidate.topic === request.topic).length
529
+ const correlated = !feedback.controlKey || feedback.controlKey === key || sameTopicRequests === 1
530
+ if (!correlated) continue
531
+
532
+ const continuous = ['level', 'dimmer', 'position'].includes(request.type)
533
+ if (!continuous || request.topic === 'room/reset' || Object.is(value, request.value)) {
534
+ this.__clearControlPending(key)
535
+ }
536
+ }
537
+ },
538
+ setControlSession (session) {
539
+ if (!session || typeof session !== 'object' || !session.room || !session.csrf) {
540
+ throw new Error('ControlPanel session needs room and csrf')
541
+ }
542
+ const next = { room: String(session.room), csrf: String(session.csrf) }
543
+ if (this.__controlPanelSession &&
544
+ (this.__controlPanelSession.room !== next.room || this.__controlPanelSession.csrf !== next.csrf)) {
545
+ this.__resetControlPolicy()
546
+ this.__controlPanelRequests.clear()
547
+ this.controlPending = {}
548
+ this.controlTargets = {}
549
+ this.deviceState = {}
550
+ this.deviceFeedbackRevision = 0
551
+ }
552
+ this.__controlPanelSession = next
553
+ this.__connectControlFeedback()
554
+ },
555
+ clearControlSession () {
556
+ this.__closeControlFeedback()
557
+ this.__controlPanelSession = null
558
+ this.__resetControlPolicy()
559
+ this.__controlPanelRequests.clear()
560
+ this.controlPending = {}
561
+ this.controlTargets = {}
562
+ this.deviceState = {}
563
+ this.deviceFeedbackRevision = 0
564
+ },
565
+ sendControl (command) {
566
+ const key = String(command?.key ?? command?.controlKey ?? '')
567
+ const type = String(command?.type ?? command?.controlType ?? '').toLowerCase()
568
+ const value = Object.prototype.hasOwnProperty.call(command || {}, 'value') ? command.value : command?.payload
569
+ if (key) {
570
+ this.__controlPanelRequests.set(key, {
571
+ topic: String(command.topic ?? ''),
572
+ type,
573
+ value
574
+ })
575
+ this.__setControlPending(key, true)
576
+ if (['level', 'dimmer', 'position'].includes(type)) this.__setControlTarget(key, value)
577
+ }
578
+ try {
579
+ return this.__controlPanelPolicy.send(command)
580
+ } catch (error) {
581
+ if (key) this.__clearControlPending(key)
582
+ throw error
583
+ }
584
+ },
585
+ flushControl (key) {
586
+ return this.__controlPanelPolicy.flush(key)
587
+ },
588
+ applyControlFeedback (message) {
589
+ const room = this.__controlPanelSession?.room || this.authorizedRoom
590
+ const feedback = this.__controlPanelNormalizeFeedback(
591
+ message,
592
+ room,
593
+ this.__controlPanelFeedbackFields()
594
+ )
595
+ this.deviceState = { ...this.deviceState, ...feedback.state }
596
+ if (feedback.revision !== null) this.deviceFeedbackRevision = feedback.revision
597
+ this.__acknowledgeControlFeedback(feedback)
598
+ if (typeof this.onDeviceFeedback === 'function') this.onDeviceFeedback(feedback, this.deviceState)
599
+ return feedback
600
+ },
601
+ hasDeviceState (field) {
602
+ return Object.prototype.hasOwnProperty.call(this.deviceState, String(field))
603
+ },
604
+ deviceValue (field, fallback = null) {
605
+ return this.hasDeviceState(field) ? this.deviceState[String(field)] : fallback
606
+ },
607
+ isControlPending (key) {
608
+ return this.controlPending[String(key)] === true
609
+ },
610
+ controlDisplayValue (key, field, fallback = null) {
611
+ const controlKey = String(key)
612
+ if (this.isControlPending(controlKey) &&
613
+ Object.prototype.hasOwnProperty.call(this.controlTargets, controlKey)) {
614
+ return this.controlTargets[controlKey]
615
+ }
616
+ return this.deviceValue(field, fallback)
617
+ },
618
+ async __dispatchControl (command) {
619
+ if (typeof this.controlTransport === 'function') return this.controlTransport(command)
620
+
621
+ const session = this.__controlPanelSession
622
+ const room = session?.room || this.authorizedRoom
623
+ if (typeof this.postControl === 'function') {
624
+ return this.postControl(room ? { ...command, room } : command, this.sessionGeneration)
625
+ }
626
+ if (!session) throw new Error('Call setControlSession() after login, or define controlTransport(command)')
627
+
628
+ const response = await fetch('/room-panel-api/regions/' + encodeURIComponent(session.room) + '/command', {
629
+ method: 'POST',
630
+ credentials: 'same-origin',
631
+ headers: { 'Content-Type': 'application/json', 'X-Room-CSRF': session.csrf },
632
+ body: JSON.stringify({ room: session.room, ...command }),
633
+ signal: AbortSignal.timeout(8000)
634
+ })
635
+ if (!response.ok) throw new Error('Control command failed (' + response.status + ')')
636
+ return response
637
+ }
638
+ },
639
+ mounted () {
640
+ this.__initializeControlPanelPolicy()
641
+ },
642
+ unmounted () {
643
+ this.__disposeControlPanelPolicy()
644
+ }
645
+ }
646
+ `
647
+ }
648
+
649
+ function parseOptionsObject (source) {
650
+ const tree = acorn.parse(source, { ecmaVersion: 'latest', sourceType: 'module' })
651
+ if (tree.body.length !== 1 || tree.body[0].type !== 'ExportDefaultDeclaration' ||
652
+ tree.body[0].declaration.type !== 'ObjectExpression') return null
653
+ return tree.body[0].declaration
654
+ }
655
+
656
+ function propertyName (property) {
657
+ if (!property || property.type !== 'Property') return null
658
+ if (!property.computed && property.key.type === 'Identifier') return property.key.name
659
+ if (property.key.type === 'Literal') return String(property.key.value)
660
+ return null
661
+ }
662
+
663
+ function findProperty (object, name) {
664
+ return object.properties.find(property => propertyName(property) === name)
665
+ }
666
+
667
+ function parseProperty (source) {
668
+ const object = acorn.parse('({' + source + '})', { ecmaVersion: 'latest' }).body[0].expression
669
+ return object.properties[0]
670
+ }
671
+
672
+ function functionSource (property) {
673
+ return property ? escodegen.generate(property.value) : null
674
+ }
675
+
676
+ function mergeDashboardOptions (userSource) {
677
+ const user = parseOptionsObject(userSource)
678
+ if (!user) return null
679
+
680
+ const policy = parseOptionsObject(buildDashboardPolicyOptionsSource())
681
+ const policyData = findProperty(policy, 'data')
682
+ const policyMethods = findProperty(policy, 'methods')
683
+ const policyMounted = findProperty(policy, 'mounted')
684
+ const policyUnmounted = findProperty(policy, 'unmounted')
685
+ const userData = findProperty(user, 'data')
686
+ const userMethods = findProperty(user, 'methods')
687
+ const userMounted = findProperty(user, 'mounted')
688
+ const userUnmounted = findProperty(user, 'unmounted')
689
+
690
+ if (userMethods && userMethods.value.type !== 'ObjectExpression') return null
691
+
692
+ const data = userData
693
+ ? parseProperty(`data () {
694
+ const userState = (${functionSource(userData)}).call(this) || {}
695
+ const policyState = (${functionSource(policyData)}).call(this) || {}
696
+ return { ...userState, ...policyState }
697
+ }`)
698
+ : policyData
699
+
700
+ const policyMethodNames = new Set(policyMethods.value.properties.map(propertyName))
701
+ const methods = parseProperty('methods: {}')
702
+ methods.value.properties = [
703
+ ...(userMethods ? userMethods.value.properties.filter(property => !policyMethodNames.has(propertyName(property))) : []),
704
+ ...policyMethods.value.properties
705
+ ]
706
+
707
+ const mounted = userMounted
708
+ ? parseProperty(`mounted () {
709
+ (${functionSource(policyMounted)}).call(this)
710
+ return (${functionSource(userMounted)}).call(this)
711
+ }`)
712
+ : policyMounted
713
+
714
+ const unmounted = userUnmounted
715
+ ? parseProperty(`unmounted () {
716
+ try {
717
+ return (${functionSource(userUnmounted)}).call(this)
718
+ } finally {
719
+ (${functionSource(policyUnmounted)}).call(this)
720
+ }
721
+ }`)
722
+ : policyUnmounted
723
+
724
+ const replaced = new Set(['data', 'methods', 'mounted', 'unmounted'])
725
+ const merged = {
726
+ ...user,
727
+ properties: [
728
+ ...user.properties.filter(property => !replaced.has(propertyName(property))),
729
+ data,
730
+ methods,
731
+ mounted,
732
+ unmounted
733
+ ]
734
+ }
735
+
736
+ return CONTROL_POLICY_MARKER + '\nexport default ' + escodegen.generate(merged, {
737
+ format: { indent: { style: ' ' } }
738
+ })
739
+ }
740
+
402
741
  function injectControlPolicy (format) {
403
742
  if (typeof format !== 'string' || format.includes(CONTROL_POLICY_MARKER)) return format
404
743
 
405
744
  const scriptMatch = /<script(?!\s+setup\b)(?:\s[^>]*)?>/i.exec(format)
406
745
  if (!scriptMatch) {
407
746
  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'
747
+ if (setupMatch || !/<template(?:\s|>)/i.test(format)) return format
748
+ return format + '\n<script>\n' + CONTROL_POLICY_MARKER + '\n' +
749
+ buildDashboardPolicyOptionsSource().trim() + '\n</script>\n'
413
750
  }
414
751
 
415
752
  const scriptStart = scriptMatch.index + scriptMatch[0].length
@@ -417,33 +754,23 @@ function injectControlPolicy (format) {
417
754
  if (scriptEnd === -1) return format
418
755
 
419
756
  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)
757
+ let wrapped
758
+ try {
759
+ wrapped = mergeDashboardOptions(source)
760
+ } catch (_) {
761
+ return format
425
762
  }
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)
763
+ if (!wrapped) return format
764
+ return format.slice(0, scriptStart) + '\n' + wrapped + '\n' + format.slice(scriptEnd)
440
765
  }
441
766
 
442
767
  module.exports = {
443
768
  CONTROL_POLICY_MARKER,
444
769
  FEEDBACK_TOPIC_FIELDS,
445
770
  buildControlPolicyMixinSource,
771
+ buildDashboardPolicyOptionsSource,
446
772
  createControlPanelPolicy,
447
773
  injectControlPolicy,
774
+ mergeDashboardOptions,
448
775
  normalizeControlFeedback
449
776
  }
package/package.json CHANGED
@@ -1,9 +1,13 @@
1
1
  {
2
2
  "name": "node-red-contrib-room-tools",
3
- "version": "1.8.0",
3
+ "version": "1.8.2",
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": {