node-red-contrib-room-tools 1.5.0 → 1.7.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.
@@ -0,0 +1,220 @@
1
+ # ControlPanel AI 开发契约
2
+
3
+ 适用节点:`ui-control-panel` 1.7.1。
4
+
5
+ 把本文件作为 AI 开发或修改 ControlPanel Template 前的强制上下文。目标是让
6
+ 不同页面使用同一套控制语义,避免滑杆积压、重复队列、反馈回环和跨房间误发。
7
+
8
+ ## 1. 保持节点边界
9
+
10
+ - 保留节点类型 `ui-control-panel`,不要改回普通 `ui-template`。
11
+ - 页面布局、Vue 状态和交互写在 Template;鉴权和路由交给 `room-gateway`;
12
+ 设备协议、TCP 和 ACK 交给 Script-as-Device 驱动。
13
+ - 禁止在 Template 中重新实现 debounce、throttle、latest-wins `Map`、
14
+ `Promise` 发送队列或设备 TCP 逻辑。
15
+ - 推荐使用普通 `<script>export default {}</script>`。`defineComponent(...)` 和
16
+ `<script setup>` 可以编译,但 Options API 最适合直接调用注入的生命周期方法。
17
+
18
+ ## 2. 公共 API
19
+
20
+ 节点运行时自动注入以下方法,Template 不得重定义这些名字:
21
+
22
+ ```js
23
+ setControlSession(session)
24
+ clearControlSession()
25
+ sendControl({ type, topic, key, value, final })
26
+ flushControl(key)
27
+ applyControlFeedback(message)
28
+ hasDeviceState(field)
29
+ deviceValue(field, fallback)
30
+ isControlPending(key)
31
+ controlDisplayValue(key, field, fallback)
32
+ ```
33
+
34
+ 节点还注入以下响应式状态,Template 不得用自己的 `data()` 覆盖同名字段:
35
+
36
+ ```js
37
+ deviceState // 只保存设备反馈确认的状态
38
+ deviceFeedbackRevision // 最近一次设备反馈版本号
39
+ deviceFeedbackConnected // 实时反馈流是否连接
40
+ controlPending // 等待匹配设备反馈的 controlKey 集合
41
+ controlTargets // 连续控件尚待设备确认的最后目标值
42
+ ```
43
+
44
+ 还保留两个可选扩展点:
45
+
46
+ ```js
47
+ controlTransport(command) // 自定义传输时实现
48
+ onControlError(error, command) // 自定义错误展示时实现
49
+ onDeviceFeedback(feedback, state) // 把反馈同步到滑杆草稿等页面状态
50
+ onControlFeedbackStatus(connected) // 自定义连接状态展示
51
+ ```
52
+
53
+ 成功登录 `room-gateway` 后必须执行:
54
+
55
+ ```js
56
+ this.setControlSession(session)
57
+ ```
58
+
59
+ 该调用会自动连接当前房间的被动反馈流。Template 不得主动查询设备状态,也不得再创建自己的状态轮询器、
60
+ `EventSource` 或 WebSocket。若使用完全自定义的 `controlTransport()`,可由外部代码把
61
+ 标准 `kind: 'state'` 消息交给 `applyControlFeedback(message)`。
62
+
63
+ 退出、锁屏或会话失效时必须执行:
64
+
65
+ ```js
66
+ this.clearControlSession()
67
+ ```
68
+
69
+ 这会同时清除该会话尚未发送的连续值和离散队列。
70
+
71
+ ## 3. 命令格式
72
+
73
+ 每条设备控制命令只声明以下字段:
74
+
75
+ ```js
76
+ this.sendControl({
77
+ type: 'level',
78
+ topic: 'audio/volume',
79
+ key: 'rane/1/level/2',
80
+ value: 65,
81
+ final: false
82
+ })
83
+ ```
84
+
85
+ - `type`:控制语义,决定发送策略。
86
+ - `topic`:业务功能,由 `room-gateway` 验证和路由。
87
+ - `key`:稳定且唯一的设备控制标识。推荐
88
+ `<device>/<slot>/<control>/<channel>`,例如 `rane/1/level/2`。
89
+ - `value`:目标值。
90
+ - `final`:仅连续控制需要;松手或 change 时设为 `true`。
91
+
92
+ 不要把显示文本、Vue 组件 ID 或随机值当作 `key`。同一个物理控制始终使用相同
93
+ `key`;不同设备或不同控制号必须使用不同 `key`。
94
+
95
+ ## 4. 控件策略
96
+
97
+ | `type` | 前端写法 | 公共 API 行为 |
98
+ | --- | --- | --- |
99
+ | `power` | 每次明确点击发送一次 | 顺序发送 |
100
+ | `source` | 用户选择后发送一次 | 顺序发送 |
101
+ | `preset` | 每次明确点击发送一次 | 顺序发送 |
102
+ | `mute` | 每次明确点击发送一次 | 顺序发送 |
103
+ | `level` | 拖动发送,松手再发最终值 | 每个 key 40 ms latest-wins |
104
+ | `dimmer` | 拖动发送,松手再发最终值 | 每个 key 40 ms latest-wins |
105
+ | `position` | 拖动发送,松手再发最终值 | 每个 key 40 ms latest-wins |
106
+
107
+ 滑杆标准写法:
108
+
109
+ ```html
110
+ <input
111
+ :value="controlDisplayValue('rane/1/level/2', 'volume', volume)"
112
+ type="range"
113
+ min="0"
114
+ max="100"
115
+ @input="sendVolume($event, false)"
116
+ @change="sendVolume($event, true)"
117
+ />
118
+ ```
119
+
120
+ ```js
121
+ sendVolume (event, final) {
122
+ this.volume = Number(event.target.value)
123
+ return this.sendControl({
124
+ type: 'level',
125
+ topic: 'audio/volume',
126
+ key: 'rane/1/level/2',
127
+ value: this.volume,
128
+ final
129
+ })
130
+ },
131
+ onDeviceFeedback (feedback, state) {
132
+ // pending 期间保持最后目标位置,不让较旧的中间反馈拉回滑杆。
133
+ if (!this.isControlPending('rane/1/level/2') && Number.isFinite(state.volume)) {
134
+ this.volume = state.volume
135
+ }
136
+ }
137
+ ```
138
+
139
+ Vuetify 滑杆可把相同逻辑分别放在 `@update:model-value` 和 `@end`。
140
+ 连续控件直接发送目标值,不主动查询当前值。匹配目标的被动反馈会自动清除 pending;
141
+ 未匹配的较旧反馈只更新 `deviceState`,`controlDisplayValue()` 仍返回最后目标。
142
+
143
+ 离散按钮必须显示设备反馈值,不能先改本地状态。标准写法:
144
+
145
+ ```html
146
+ <v-btn
147
+ :class="{ active: deviceValue('muted', false) }"
148
+ :loading="isControlPending('rane/1/mute/1')"
149
+ :disabled="!hasDeviceState('muted')"
150
+ @click="requestMute"
151
+ >
152
+ Mute
153
+ </v-btn>
154
+ ```
155
+
156
+ ```js
157
+ requestMute () {
158
+ const next = !this.deviceValue('muted', false)
159
+ return this.sendControl({
160
+ type: 'mute',
161
+ topic: 'audio/mute',
162
+ key: 'rane/1/mute/1',
163
+ value: next
164
+ })
165
+ }
166
+ ```
167
+
168
+ Power、Source 和 Preset 使用相同原则:事件处理函数只计算并发送目标值,不修改
169
+ 代表设备状态的本地变量。按钮的 active、selected、pressed、switch model-value 和
170
+ 下拉框 value 必须来自 `deviceState`/`deviceValue()`。`isControlPending(key)` 只用于
171
+ 显示“等待设备确认”,不能当作成功状态。
172
+
173
+ ## 5. 反馈与显示规则
174
+
175
+ - `room-gateway` 的公开状态只来自设备反馈;接受控制命令不会提前改变公开状态。
176
+ - 登录后的实时反馈由公共 API 自动接收并写入 `deviceState`。
177
+ - Power、Source、Preset、Mute、温度步进按钮等非滑杆控件必须显示
178
+ `deviceState` 中的实际值,不得乐观切换本地状态。
179
+ - Level、Dimmer、Position 滑杆直接发送目标值且不主动查询。拖动和松手后都保持
180
+ 最后目标位置;匹配最终目标的被动反馈只负责确认,不改变滑杆位置。
181
+ - 连续控制 pending 期间收到的旧中间反馈可以更新 `deviceState`,但不能覆盖
182
+ `controlTargets` 或拉回滑杆。只有发送失败,或没有 pending 时发生外部调节,
183
+ 才把滑杆同步到实际反馈值。
184
+ - 反馈没有包含某个字段时,使用 `hasDeviceState(field)` 显示未知或禁用状态,不能
185
+ 用假定默认值冒充设备实际状态。
186
+ - 禁止使用会把反馈值自动发送回设备的 `watch`。
187
+ - 控制发送只能来自明确的用户事件或业务动作。
188
+ - 需要把反馈复制到滑杆草稿或其他页面状态时,实现 `onDeviceFeedback(feedback,
189
+ state)`;先用 `isControlPending(key)` 阻止旧反馈覆盖目标。该方法只赋值,不调用
190
+ `sendControl()`。
191
+ - Template 只清理自己创建的动画和纯 UI timer;公共 API 的发送队列、反馈连接和
192
+ pending 状态由节点在页面卸载时自动清理。
193
+
194
+ ## 6. AI 交付前检查
195
+
196
+ AI 完成页面后必须逐项确认:
197
+
198
+ 1. 所有设备控制都调用 `sendControl()`,没有 `this.send()`、命令 `fetch()` 或 TCP。
199
+ 2. 每条命令都有正确的 `type`、`topic`、稳定 `key` 和 `value`。
200
+ 3. 连续控件在松手/change 时发送相同目标值并带 `final: true`。
201
+ 4. Template 内没有自建限速器、latest-wins Map 或发送队列。
202
+ 5. 登录成功调用 `setControlSession()`,退出或会话失效调用
203
+ `clearControlSession()`。
204
+ 6. 非滑杆控件的 active、selected、pressed、value 等显示只来自设备反馈。
205
+ 7. 非滑杆事件处理函数没有在发送前修改设备状态变量。
206
+ 8. 滑杆使用 `controlDisplayValue()`,松手后保持最后目标,不被旧中间反馈拉回。
207
+ 9. 连续控件直接发送目标值;Template 没有主动状态查询、自建轮询、`EventSource`
208
+ 或 WebSocket。
209
+ 10. 设备反馈和 `onDeviceFeedback()` 不会触发控制命令回发。
210
+ 11. 未收到反馈的字段显示未知/禁用,不用默认值冒充实际状态。
211
+ 12. 桌面和移动尺寸下文字、滑杆、按钮不重叠。
212
+ 13. 页面卸载时清理 Template 自己创建的 timer 和 listener。
213
+
214
+ 给 AI 的推荐提示语:
215
+
216
+ > 先完整阅读 `CONTROL_PANEL_AI_GUIDE.md`、当前 ControlPanel Template 和
217
+ > `control-policy.js`,再修改页面。严格使用注入的 `sendControl()` API,禁止在
218
+ > Template 中重写发送队列、限速、latest-wins 或反馈连接。所有非滑杆控件必须
219
+ > 显示设备反馈确认的状态,不能乐观修改本地状态;滑杆直接发送并保持最后目标,
220
+ > 不主动查询,也不能被旧中间反馈拉回。完成后按照指南第 6 节逐项检查。
package/README.md CHANGED
@@ -6,12 +6,16 @@ One package containing three nodes:
6
6
  - room-message: typed panel feedback message generator.
7
7
  - ui-control-panel: Dashboard 2 template widget without message ports.
8
8
 
9
+ When asking an AI to create or modify a panel, have it read
10
+ `CONTROL_PANEL_AI_GUIDE.md` before editing the Template. New ControlPanel nodes
11
+ also include a shortened copy of that contract as a non-rendered HTML comment.
12
+
9
13
  Every room gateway uses `/room-panel-api/regions/<room-id>` so additional
10
14
  regions follow the same URL structure.
11
15
 
12
16
  ## Control traffic policy
13
17
 
14
- Version 1.5.0 uses the same control semantics at the panel, gateway and device
18
+ Version 1.7.1 uses the same control semantics at the panel, gateway and device
15
19
  driver boundaries:
16
20
 
17
21
  - `power`, `source`, `preset` and `mute` are discrete controls. The panel sends
@@ -26,24 +30,70 @@ driver boundaries:
26
30
  `controlKey` and `final` fields. Authentication, CSRF checks, room isolation
27
31
  and value validation still happen before a command is accepted.
28
32
 
29
- The two deployed `ControlPanel` templates provide these reusable component
30
- methods: `sendDiscrete`, `sendContinuous`, `flushContinuous`,
31
- `scheduleContinuous` and `dispatchContinuous`. New controls should call the
32
- method matching their semantic type and use a stable key that identifies the
33
- room, target device and control number.
33
+ Every `ui-control-panel` Template using Vue's Options API now receives the
34
+ following methods automatically: `sendControl`, `flushControl`,
35
+ `setControlSession`, `clearControlSession`, `applyControlFeedback`,
36
+ `hasDeviceState`, `deviceValue`, `isControlPending` and `controlDisplayValue`.
37
+ The scheduler and passive live
38
+ feedback connection are owned by the node, so new templates do not copy queues,
39
+ timers, polling loops or `EventSource` code.
40
+
41
+ After a successful room-gateway login, pass the returned session to the API:
42
+
43
+ ```js
44
+ this.setControlSession(session)
45
+ ```
46
+
47
+ `sendControl` sends directly to the matching room-gateway route. A Template
48
+ that already implements `postControl(message, generation)` remains compatible:
49
+ the injected API calls that method as its transport. A Template can also define
50
+ `controlTransport(command)` for a fully custom transport.
51
+
52
+ Call `clearControlSession()` on logout. It clears the room session and all
53
+ pending controls so values from an old session cannot be emitted after a new
54
+ login.
55
+
56
+ `setControlSession` also opens an authenticated Server-Sent Events stream for
57
+ the selected room. `deviceState` contains reported device state only. Accepted
58
+ commands update the gateway's private desired state but do not change
59
+ `deviceState`; matching Script-as-Device feedback is required before a Power,
60
+ Mute, Source or Preset control appears active. `isControlPending(key)` can be
61
+ used for a waiting indicator.
62
+
63
+ Non-slider controls must use `:model-value`, `:value`, `:class` or
64
+ `:aria-pressed` from `deviceState`/`deviceValue()` and must not optimistically
65
+ change that state in the click handler. Sliders send their target directly and
66
+ must use `controlDisplayValue(key, field, fallback)` to hold the latest target
67
+ until matching passive feedback arrives. Older intermediate feedback updates
68
+ `deviceState` but does not pull the slider backward. Templates must not query
69
+ device state actively.
34
70
 
35
71
  ```html
36
72
  <input
37
73
  type="range"
38
- @input="sendContinuous('level', 'audio/volume', 'rane/0/level/2', value)"
39
- @change="flushContinuous('rane/0/level/2')"
74
+ @input="sendControl({ type: 'level', topic: 'audio/volume', key: 'rane/0/level/2', value })"
75
+ @change="sendControl({ type: 'level', topic: 'audio/volume', key: 'rane/0/level/2', value, final: true })"
40
76
  />
41
77
  ```
42
78
 
43
79
  ```js
44
- sendDiscrete('mute', 'audio/mute', 'rane/0/mute/1', true)
80
+ sendControl({ type: 'mute', topic: 'audio/mute', key: 'rane/0/mute/1', value: true })
45
81
  ```
46
82
 
83
+ The injected API supports `power`, `source`, `preset`, `mute`, `level`,
84
+ `dimmer` and `position`. It requires a stable `key`; this identifies one
85
+ device control, not just a visible slider. The public names `sendControl`,
86
+ `flushControl`, `setControlSession`, `clearControlSession`,
87
+ `applyControlFeedback`, `hasDeviceState`, `deviceValue`, `isControlPending`,
88
+ `controlDisplayValue`,
89
+ `deviceState`, `deviceFeedbackRevision`, `deviceFeedbackConnected`,
90
+ `controlPending`, `controlTargets`, `controlTransport`, `onControlError`, `onDeviceFeedback` and
91
+ `onControlFeedbackStatus` are reserved by the ControlPanel API.
92
+
93
+ The injector supports normal Options API exports, `defineComponent(...)` and
94
+ `<script setup>`. CSS-only `site:style` and `page:style` ControlPanel nodes are
95
+ left unchanged.
96
+
47
97
  The node type names and configuration fields are unchanged from the individual
48
98
  1.0.0 packages. No external dependencies or installation scripts are required.
49
99
 
@@ -11,6 +11,22 @@
11
11
 
12
12
  const template = []
13
13
 
14
+ template.push('<!--')
15
+ template.push('AI / DEVELOPER CONTRACT - ui-control-panel 1.7.1')
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; applyControlFeedback() never sends commands.')
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('-->')
14
30
  template.push('<template>')
15
31
  template.push(' <div>')
16
32
  template.push(' <h2>Counter</h2>')
@@ -34,14 +50,6 @@
34
50
  template.push(' count: 0')
35
51
  template.push(' }')
36
52
  template.push(' },')
37
- template.push(' watch: {')
38
- template.push(' // watch for any changes of "count"')
39
- template.push(' count: function () {')
40
- template.push(' if (this.count % 5 === 0) {')
41
- template.push(' this.send({payload: \'Multiple of 5\'})')
42
- template.push(' }')
43
- template.push(' }')
44
- template.push(' },')
45
53
  template.push(' computed: {')
46
54
  template.push(' // automatically compute this variable')
47
55
  template.push(' // whenever VueJS deems appropriate')
@@ -50,17 +58,17 @@
50
58
  template.push(' }')
51
59
  template.push(' },')
52
60
  template.push(' methods: {')
53
- template.push(' // expose a method to our <template> and Vue Application')
61
+ template.push(' // UI-only method. Device commands must use this.sendControl({...}).')
54
62
  template.push(' increase: function () {')
55
63
  template.push(' this.count++')
56
64
  template.push(' }')
57
65
  template.push(' },')
58
66
  template.push(' mounted() {')
59
- template.push(' // code here when the component is first loaded')
67
+ template.push(' // After a successful login: this.setControlSession(session)')
60
68
  template.push(' },')
61
69
  template.push(' unmounted() {')
62
- template.push(' // code here when the component is removed from the Dashboard')
63
- template.push(' // i.e. when the user navigates away from the page')
70
+ template.push(' // The injected policy cleans its queues and timers automatically.')
71
+ template.push(' // Clean only timers/listeners created by this Template.')
64
72
  template.push(' }')
65
73
  template.push(' }')
66
74
  // eslint complaingin about unterminated string literal - need to do this oddity to get around it
@@ -370,6 +378,22 @@
370
378
  })()
371
379
  </script>
372
380
 
381
+ <script type="text/html" data-help-name="ui-control-panel">
382
+ <p>This node renders a custom Vue Template and automatically injects control traffic and reported-device-state APIs.</p>
383
+ <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>
384
+ <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>
385
+ <ul>
386
+ <li><code>power</code>, <code>source</code>, <code>preset</code>, <code>mute</code>: ordered discrete commands.</li>
387
+ <li><code>level</code>, <code>dimmer</code>, <code>position</code>: 40 ms latest-wins per stable key.</li>
388
+ <li>Send the same continuous target with <code>final: true</code> on release/change.</li>
389
+ <li>Bind non-slider active/selected values to <code>deviceState</code> or <code>deviceValue()</code>; commands do not change reported state.</li>
390
+ <li>Use <code>isControlPending(key)</code> to show that a command is waiting for matching device feedback.</li>
391
+ <li>Sliders directly send and hold their latest target through <code>controlDisplayValue()</code>; unmatched older feedback must not move them.</li>
392
+ </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>See <code>CONTROL_PANEL_AI_GUIDE.md</code> in the node package for the complete AI authoring contract and examples.</p>
395
+ </script>
396
+
373
397
  <script type="text/html" data-template-name="ui-control-panel">
374
398
  <div class="form-row">
375
399
  <label for="node-input-name"><i class="fa fa-tag"></i> <span data-i18n="node-red:common.label.name"></span></label>
package/control-panel.js CHANGED
@@ -1,5 +1,12 @@
1
+ const { injectControlPolicy } = require('./control-policy')
2
+
1
3
  let statestore
2
4
 
5
+ function injectWidgetPolicy (format, templateScope) {
6
+ if (templateScope === 'site:style' || templateScope === 'page:style') return format
7
+ return injectControlPolicy(format)
8
+ }
9
+
3
10
  function getStateStore (RED) {
4
11
  if (statestore) return statestore
5
12
  try {
@@ -26,6 +33,7 @@ module.exports = function (RED) {
26
33
  if (msg && msg.ui_update && statestore) {
27
34
  const parent = parentNode
28
35
  if (parent && typeof msg.ui_update.format !== 'undefined') {
36
+ msg.ui_update.format = injectWidgetPolicy(msg.ui_update.format, config.templateScope)
29
37
  statestore.set(parent.getBase(), node, msg, 'format', msg.ui_update.format)
30
38
  }
31
39
  }
@@ -50,7 +58,11 @@ module.exports = function (RED) {
50
58
  }
51
59
 
52
60
  // Keep the custom editor type in Dashboard's namespace, but use Dashboard's built-in renderer.
53
- const widgetConfig = { ...config, type: 'ui-template' }
61
+ const widgetConfig = {
62
+ ...config,
63
+ type: 'ui-template',
64
+ format: injectWidgetPolicy(config.format, config.templateScope)
65
+ }
54
66
  parentNode = group || page || ui
55
67
  if (group) group.register(node, widgetConfig, events)
56
68
  else if (page) page.register(null, node, widgetConfig, events)
@@ -0,0 +1,449 @@
1
+ const CONTROL_POLICY_MARKER = '/* ui-control-panel-control-api-v1 */'
2
+ const FEEDBACK_TOPIC_FIELDS = Object.freeze({
3
+ 'display/power': 'display',
4
+ 'display/source': 'source',
5
+ 'audio/volume': 'volume',
6
+ 'audio/mute': 'muted',
7
+ 'lighting/brightness': 'light',
8
+ 'room/curtain': 'curtains',
9
+ 'climate/temperature': 'temperature',
10
+ 'room/scene': 'scene',
11
+ 'room/meeting': 'meeting'
12
+ })
13
+
14
+ function normalizeControlFeedback (message, expectedRoom, topicFields) {
15
+ if (!message || typeof message !== 'object' || Array.isArray(message)) {
16
+ throw new Error('Control feedback must be an object')
17
+ }
18
+ if (message.kind !== 'state') throw new Error('Control feedback must have kind=state')
19
+ if (expectedRoom && message.room !== expectedRoom) throw new Error('Control feedback room mismatch')
20
+ if (typeof message.topic !== 'string' || !message.topic) throw new Error('Control feedback needs a topic')
21
+
22
+ let state = message.state && typeof message.state === 'object' && !Array.isArray(message.state)
23
+ ? { ...message.state }
24
+ : {}
25
+ if (message.topic === 'room/state') {
26
+ if (!message.payload || typeof message.payload !== 'object' || Array.isArray(message.payload)) {
27
+ throw new Error('room/state feedback needs an object payload')
28
+ }
29
+ state = { ...state, ...message.payload }
30
+ } else if (topicFields[message.topic]) {
31
+ state[topicFields[message.topic]] = message.payload
32
+ }
33
+
34
+ return {
35
+ room: message.room,
36
+ kind: 'state',
37
+ topic: message.topic,
38
+ payload: message.payload,
39
+ state,
40
+ revision: Number.isInteger(message.revision) && message.revision >= 0 ? message.revision : null,
41
+ controlKey: typeof message.controlKey === 'string' ? message.controlKey : null
42
+ }
43
+ }
44
+
45
+ function createControlPanelPolicy ({
46
+ dispatch,
47
+ intervalMs = 40,
48
+ now = () => Date.now(),
49
+ setTimer = setTimeout,
50
+ clearTimer = clearTimeout,
51
+ onError = () => {}
52
+ }) {
53
+ if (typeof dispatch !== 'function') throw new Error('ControlPanel dispatch function is required')
54
+ if (!Number.isFinite(intervalMs) || intervalMs < 0) throw new Error('ControlPanel interval must be a non-negative number')
55
+
56
+ const continuousTypes = new Set(['level', 'dimmer', 'position'])
57
+ const discreteTypes = new Set(['power', 'source', 'preset', 'mute'])
58
+ const continuousControls = new Map()
59
+ let discreteQueue = Promise.resolve()
60
+ let disposed = false
61
+
62
+ const reportError = (error, command) => {
63
+ try {
64
+ onError(error, command)
65
+ } catch (_) {}
66
+ }
67
+
68
+ const normalize = (command) => {
69
+ if (!command || typeof command !== 'object' || Array.isArray(command)) {
70
+ throw new Error('Control command must be an object')
71
+ }
72
+ const type = String(command.type ?? command.controlType ?? '').trim().toLowerCase()
73
+ const topic = String(command.topic ?? '').trim()
74
+ const key = String(command.key ?? command.controlKey ?? '').trim()
75
+ const hasValue = Object.prototype.hasOwnProperty.call(command, 'value') ||
76
+ Object.prototype.hasOwnProperty.call(command, 'payload')
77
+
78
+ if (!continuousTypes.has(type) && !discreteTypes.has(type)) {
79
+ throw new Error('Unsupported control type: ' + type)
80
+ }
81
+ if (!topic) throw new Error('Control command needs a topic')
82
+ if (!/^[a-zA-Z0-9._:/-]{1,160}$/.test(key)) throw new Error('Control command needs a valid key')
83
+ if (!hasValue) throw new Error('Control command needs a value')
84
+
85
+ return {
86
+ topic,
87
+ payload: Object.prototype.hasOwnProperty.call(command, 'value') ? command.value : command.payload,
88
+ controlType: type,
89
+ controlKey: key,
90
+ final: command.final === true
91
+ }
92
+ }
93
+
94
+ const continuousControl = (key) => {
95
+ let control = continuousControls.get(key)
96
+ if (!control) {
97
+ control = {
98
+ inFlight: false,
99
+ lastCommand: null,
100
+ lastSentAt: -intervalMs,
101
+ latest: null,
102
+ timer: null
103
+ }
104
+ continuousControls.set(key, control)
105
+ }
106
+ return control
107
+ }
108
+
109
+ const dispatchContinuous = async (key) => {
110
+ const control = continuousControl(key)
111
+ if (control.inFlight || !control.latest) return
112
+
113
+ const command = control.latest
114
+ control.latest = null
115
+ control.inFlight = true
116
+ try {
117
+ await dispatch(command)
118
+ } catch (error) {
119
+ reportError(error, command)
120
+ } finally {
121
+ control.lastSentAt = now()
122
+ control.inFlight = false
123
+ if (!disposed && control.latest) scheduleContinuous(key, control.latest.final)
124
+ }
125
+ }
126
+
127
+ const scheduleContinuous = (key, force) => {
128
+ const control = continuousControl(key)
129
+ if (control.inFlight || !control.latest) return
130
+ if (force && control.timer) {
131
+ clearTimer(control.timer)
132
+ control.timer = null
133
+ }
134
+ if (control.timer) return
135
+
136
+ const delay = force ? 0 : Math.max(0, intervalMs - (now() - control.lastSentAt))
137
+ if (delay === 0) {
138
+ void dispatchContinuous(key)
139
+ return
140
+ }
141
+ control.timer = setTimer(() => {
142
+ control.timer = null
143
+ void dispatchContinuous(key)
144
+ }, delay)
145
+ }
146
+
147
+ const sendDiscrete = (command) => {
148
+ discreteQueue = discreteQueue
149
+ .catch(() => undefined)
150
+ .then(() => disposed ? undefined : dispatch(command))
151
+ .catch((error) => reportError(error, command))
152
+ return discreteQueue
153
+ }
154
+
155
+ return {
156
+ send (command) {
157
+ if (disposed) return Promise.resolve()
158
+ const normalized = normalize(command)
159
+ if (continuousTypes.has(normalized.controlType)) {
160
+ const control = continuousControl(normalized.controlKey)
161
+ control.lastCommand = normalized
162
+ control.latest = normalized
163
+ scheduleContinuous(normalized.controlKey, normalized.final)
164
+ return Promise.resolve()
165
+ }
166
+ return sendDiscrete(normalized)
167
+ },
168
+
169
+ flush (key) {
170
+ const control = continuousControls.get(String(key))
171
+ if (disposed || !control || !control.lastCommand) return Promise.resolve()
172
+ control.latest = { ...control.lastCommand, final: true }
173
+ scheduleContinuous(control.latest.controlKey, true)
174
+ return Promise.resolve()
175
+ },
176
+
177
+ dispose () {
178
+ disposed = true
179
+ for (const control of continuousControls.values()) {
180
+ if (control.timer) clearTimer(control.timer)
181
+ control.latest = null
182
+ }
183
+ continuousControls.clear()
184
+ }
185
+ }
186
+ }
187
+
188
+ function buildControlPolicyMixinSource () {
189
+ return `
190
+ ${CONTROL_POLICY_MARKER}
191
+ const __controlPanelCreatePolicy = ${createControlPanelPolicy.toString()}
192
+ const __controlPanelFeedbackFields = ${JSON.stringify(FEEDBACK_TOPIC_FIELDS)}
193
+ const __controlPanelNormalizeFeedback = ${normalizeControlFeedback.toString()}
194
+ const __controlPanelPolicyMixin = {
195
+ data () {
196
+ return {
197
+ deviceState: {},
198
+ deviceFeedbackRevision: 0,
199
+ deviceFeedbackConnected: false,
200
+ controlPending: {},
201
+ controlTargets: {}
202
+ }
203
+ },
204
+ created () {
205
+ this.__controlPanelSession = null
206
+ this.__controlPanelFeedbackSource = null
207
+ this.__controlPanelRequests = new Map()
208
+ this.__resetControlPolicy()
209
+ },
210
+ methods: {
211
+ __resetControlPolicy () {
212
+ this.__controlPanelPolicy?.dispose()
213
+ this.__controlPanelPolicy = __controlPanelCreatePolicy({
214
+ intervalMs: 40,
215
+ dispatch: (command) => this.__dispatchControl(command),
216
+ onError: (error, command) => {
217
+ if (command?.controlKey) this.__clearControlPending(command.controlKey)
218
+ if (typeof this.onControlError === 'function') this.onControlError(error, command)
219
+ else console.error('ControlPanel command failed', error)
220
+ }
221
+ })
222
+ },
223
+ __setControlPending (key, pending) {
224
+ const next = { ...this.controlPending }
225
+ if (pending) next[key] = true
226
+ else delete next[key]
227
+ this.controlPending = next
228
+ },
229
+ __setControlTarget (key, value) {
230
+ this.controlTargets = { ...this.controlTargets, [key]: value }
231
+ },
232
+ __clearControlPending (key) {
233
+ this.__controlPanelRequests.delete(String(key))
234
+ this.__setControlPending(String(key), false)
235
+ const targets = { ...this.controlTargets }
236
+ delete targets[String(key)]
237
+ this.controlTargets = targets
238
+ },
239
+ __setFeedbackConnected (connected) {
240
+ const next = connected === true
241
+ if (this.deviceFeedbackConnected === next) return
242
+ this.deviceFeedbackConnected = next
243
+ if (typeof this.onControlFeedbackStatus === 'function') this.onControlFeedbackStatus(next)
244
+ },
245
+ __closeControlFeedback () {
246
+ this.__controlPanelFeedbackSource?.close()
247
+ this.__controlPanelFeedbackSource = null
248
+ this.__setFeedbackConnected(false)
249
+ },
250
+ __connectControlFeedback () {
251
+ this.__closeControlFeedback()
252
+ const session = this.__controlPanelSession
253
+ if (!session || typeof EventSource === 'undefined') return
254
+ const source = new EventSource('/room-panel-api/regions/' + encodeURIComponent(session.room) + '/events')
255
+ this.__controlPanelFeedbackSource = source
256
+ source.onopen = () => {
257
+ if (this.__controlPanelFeedbackSource === source) this.__setFeedbackConnected(true)
258
+ }
259
+ source.onmessage = (event) => {
260
+ if (this.__controlPanelFeedbackSource !== source) return
261
+ try {
262
+ this.applyControlFeedback(JSON.parse(event.data))
263
+ } catch (error) {
264
+ if (typeof this.onControlError === 'function') this.onControlError(error)
265
+ else console.error('ControlPanel feedback failed', error)
266
+ }
267
+ }
268
+ source.onerror = () => {
269
+ if (this.__controlPanelFeedbackSource === source) this.__setFeedbackConnected(false)
270
+ }
271
+ },
272
+ __acknowledgeControlFeedback (feedback) {
273
+ for (const [key, request] of this.__controlPanelRequests) {
274
+ if (feedback.controlKey && feedback.controlKey !== key) continue
275
+ let matched = request.topic === 'room/reset'
276
+ let value
277
+ if (!matched && feedback.topic === request.topic) {
278
+ matched = true
279
+ value = feedback.payload
280
+ } else if (!matched && feedback.topic === 'room/state') {
281
+ const field = __controlPanelFeedbackFields[request.topic]
282
+ if (field && Object.prototype.hasOwnProperty.call(feedback.state, field)) {
283
+ matched = true
284
+ value = feedback.state[field]
285
+ }
286
+ }
287
+ if (matched && (request.topic === 'room/reset' || Object.is(value, request.value))) {
288
+ this.__clearControlPending(key)
289
+ }
290
+ }
291
+ },
292
+ setControlSession (session) {
293
+ if (!session || typeof session !== 'object' || !session.room || !session.csrf) {
294
+ throw new Error('ControlPanel session needs room and csrf')
295
+ }
296
+ const next = { room: String(session.room), csrf: String(session.csrf) }
297
+ if (this.__controlPanelSession &&
298
+ (this.__controlPanelSession.room !== next.room || this.__controlPanelSession.csrf !== next.csrf)) {
299
+ this.__resetControlPolicy()
300
+ this.__controlPanelRequests.clear()
301
+ this.controlPending = {}
302
+ this.controlTargets = {}
303
+ this.deviceState = {}
304
+ this.deviceFeedbackRevision = 0
305
+ }
306
+ this.__controlPanelSession = next
307
+ this.__connectControlFeedback()
308
+ },
309
+ clearControlSession () {
310
+ this.__closeControlFeedback()
311
+ this.__controlPanelSession = null
312
+ this.__resetControlPolicy()
313
+ this.__controlPanelRequests.clear()
314
+ this.controlPending = {}
315
+ this.controlTargets = {}
316
+ this.deviceState = {}
317
+ this.deviceFeedbackRevision = 0
318
+ },
319
+ sendControl (command) {
320
+ const key = String(command?.key ?? command?.controlKey ?? '')
321
+ const type = String(command?.type ?? command?.controlType ?? '').toLowerCase()
322
+ const value = Object.prototype.hasOwnProperty.call(command || {}, 'value') ? command.value : command?.payload
323
+ if (key) {
324
+ this.__controlPanelRequests.set(key, {
325
+ topic: String(command.topic ?? ''),
326
+ type,
327
+ value
328
+ })
329
+ this.__setControlPending(key, true)
330
+ if (['level', 'dimmer', 'position'].includes(type)) this.__setControlTarget(key, value)
331
+ }
332
+ try {
333
+ return this.__controlPanelPolicy.send(command)
334
+ } catch (error) {
335
+ if (key) this.__clearControlPending(key)
336
+ throw error
337
+ }
338
+ },
339
+ flushControl (key) {
340
+ return this.__controlPanelPolicy.flush(key)
341
+ },
342
+ applyControlFeedback (message) {
343
+ const room = this.__controlPanelSession?.room || this.authorizedRoom
344
+ const feedback = __controlPanelNormalizeFeedback(message, room, __controlPanelFeedbackFields)
345
+ this.deviceState = { ...this.deviceState, ...feedback.state }
346
+ if (feedback.revision !== null) this.deviceFeedbackRevision = feedback.revision
347
+ this.__acknowledgeControlFeedback(feedback)
348
+ if (typeof this.onDeviceFeedback === 'function') this.onDeviceFeedback(feedback, this.deviceState)
349
+ return feedback
350
+ },
351
+ hasDeviceState (field) {
352
+ return Object.prototype.hasOwnProperty.call(this.deviceState, String(field))
353
+ },
354
+ deviceValue (field, fallback = null) {
355
+ return this.hasDeviceState(field) ? this.deviceState[String(field)] : fallback
356
+ },
357
+ isControlPending (key) {
358
+ return this.controlPending[String(key)] === true
359
+ },
360
+ controlDisplayValue (key, field, fallback = null) {
361
+ const controlKey = String(key)
362
+ if (this.isControlPending(controlKey) &&
363
+ Object.prototype.hasOwnProperty.call(this.controlTargets, controlKey)) {
364
+ return this.controlTargets[controlKey]
365
+ }
366
+ return this.deviceValue(field, fallback)
367
+ },
368
+ async __dispatchControl (command) {
369
+ if (typeof this.controlTransport === 'function') return this.controlTransport(command)
370
+
371
+ const session = this.__controlPanelSession
372
+ const room = session?.room || this.authorizedRoom
373
+ if (typeof this.postControl === 'function') {
374
+ return this.postControl(room ? { ...command, room } : command, this.sessionGeneration)
375
+ }
376
+ if (!session) throw new Error('Call setControlSession() after login, or define controlTransport(command)')
377
+
378
+ const response = await fetch('/room-panel-api/regions/' + encodeURIComponent(session.room) + '/command', {
379
+ method: 'POST',
380
+ credentials: 'same-origin',
381
+ headers: { 'Content-Type': 'application/json', 'X-Room-CSRF': session.csrf },
382
+ body: JSON.stringify({ room: session.room, ...command }),
383
+ signal: AbortSignal.timeout(8000)
384
+ })
385
+ if (!response.ok) throw new Error('Control command failed (' + response.status + ')')
386
+ return response
387
+ }
388
+ },
389
+ unmounted () {
390
+ this.__closeControlFeedback()
391
+ this.__controlPanelPolicy?.dispose()
392
+ this.__controlPanelPolicy = null
393
+ this.__controlPanelRequests?.clear()
394
+ this.controlPending = {}
395
+ this.controlTargets = {}
396
+ this.__controlPanelSession = null
397
+ }
398
+ }
399
+ `
400
+ }
401
+
402
+ function injectControlPolicy (format) {
403
+ if (typeof format !== 'string' || format.includes(CONTROL_POLICY_MARKER)) return format
404
+
405
+ const scriptMatch = /<script(?!\s+setup\b)(?:\s[^>]*)?>/i.exec(format)
406
+ if (!scriptMatch) {
407
+ 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'
413
+ }
414
+
415
+ const scriptStart = scriptMatch.index + scriptMatch[0].length
416
+ const scriptEnd = format.indexOf('</script>', scriptStart)
417
+ if (scriptEnd === -1) return format
418
+
419
+ 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)
425
+ }
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)
440
+ }
441
+
442
+ module.exports = {
443
+ CONTROL_POLICY_MARKER,
444
+ FEEDBACK_TOPIC_FIELDS,
445
+ buildControlPolicyMixinSource,
446
+ createControlPanelPolicy,
447
+ injectControlPolicy,
448
+ normalizeControlFeedback
449
+ }
package/gateway.cjs CHANGED
@@ -2,6 +2,7 @@ const crypto = require('node:crypto')
2
2
 
3
3
  const PREFIX = '/room-panel-api'
4
4
  const CONTINUOUS_INTERVAL_MS = 40
5
+ const FEEDBACK_HEARTBEAT_MS = 20000
5
6
 
6
7
  const defaults = () => ({
7
8
  scene: 'meeting', display: true, source: '无线投屏', volume: 42,
@@ -135,9 +136,69 @@ function createGateway ({
135
136
  onDispatch: onCommand,
136
137
  intervalMs: continuousIntervalMs
137
138
  })
138
- let state = defaults()
139
+ const subscribers = new Set()
140
+ let desiredState = defaults()
141
+ let reportedState = {}
139
142
  let revision = 0
143
+ let commandRevision = 0
140
144
  let closed = false
145
+ let heartbeatTimer = null
146
+
147
+ function closeSubscriber (subscriber) {
148
+ subscribers.delete(subscriber)
149
+ try { subscriber.res.end() } catch (_) {}
150
+ if (!subscribers.size && heartbeatTimer) {
151
+ clearInterval(heartbeatTimer)
152
+ heartbeatTimer = null
153
+ }
154
+ }
155
+
156
+ function ensureHeartbeat () {
157
+ if (heartbeatTimer || !subscribers.size) return
158
+ heartbeatTimer = setInterval(() => {
159
+ const now = Date.now()
160
+ for (const subscriber of [...subscribers]) {
161
+ const session = sessions.get(subscriber.token)
162
+ if (!session || session.expiry < now) {
163
+ sessions.delete(subscriber.token)
164
+ closeSubscriber(subscriber)
165
+ continue
166
+ }
167
+ try { subscriber.res.write(': heartbeat\n\n') } catch (_) { closeSubscriber(subscriber) }
168
+ }
169
+ }, FEEDBACK_HEARTBEAT_MS)
170
+ heartbeatTimer.unref?.()
171
+ }
172
+
173
+ function sendFeedbackEvent (subscriber, message) {
174
+ try {
175
+ subscriber.res.write('data: ' + JSON.stringify(message) + '\n\n')
176
+ } catch (_) {
177
+ closeSubscriber(subscriber)
178
+ }
179
+ }
180
+
181
+ function feedbackMessage (topic = 'room/state', payload = reportedState, controlKey) {
182
+ return {
183
+ room,
184
+ kind: 'state',
185
+ topic,
186
+ payload,
187
+ state: { ...reportedState },
188
+ revision,
189
+ ...(controlKey ? { controlKey } : {})
190
+ }
191
+ }
192
+
193
+ function broadcastFeedback (message) {
194
+ for (const subscriber of [...subscribers]) sendFeedbackEvent(subscriber, message)
195
+ }
196
+
197
+ function closeSubscribersForToken (token) {
198
+ for (const subscriber of [...subscribers]) {
199
+ if (subscriber.token === token) closeSubscriber(subscriber)
200
+ }
201
+ }
141
202
 
142
203
  function feedback (msg) {
143
204
  if (msg.room !== room || msg.kind !== 'state') {
@@ -151,8 +212,15 @@ function createGateway ({
151
212
  Object.entries(values).some(([key, value]) => !valid(key, value))) {
152
213
  throw Error('Invalid feedback')
153
214
  }
154
- state = { ...state, ...values }
215
+ reportedState = { ...reportedState, ...values }
155
216
  revision++
217
+ const message = feedbackMessage(
218
+ msg.topic,
219
+ msg.topic === 'room/state' ? { ...reportedState } : msg.payload,
220
+ typeof msg.controlKey === 'string' ? msg.controlKey : undefined
221
+ )
222
+ broadcastFeedback(message)
223
+ return message
156
224
  }
157
225
 
158
226
  function reply (res, status, data, headers = {}) {
@@ -195,7 +263,12 @@ function createGateway ({
195
263
 
196
264
  try {
197
265
  const now = Date.now()
198
- for (const [id, session] of sessions) if (session.expiry < now) sessions.delete(id)
266
+ for (const [id, session] of sessions) {
267
+ if (session.expiry < now) {
268
+ sessions.delete(id)
269
+ closeSubscribersForToken(id)
270
+ }
271
+ }
199
272
  for (const [ip, attempt] of attempts) if (attempt.until < now) attempts.delete(ip)
200
273
 
201
274
  if (req.method === 'POST') {
@@ -259,16 +332,33 @@ function createGateway ({
259
332
  reply(res, 200, { room, username, csrf: session.csrf })
260
333
  return true
261
334
  }
335
+ if (path === routePrefix + '/events' && req.method === 'GET') {
336
+ res.writeHead(200, {
337
+ 'Content-Type': 'text/event-stream; charset=utf-8',
338
+ 'Cache-Control': 'no-cache, no-transform',
339
+ Connection: 'keep-alive',
340
+ 'X-Accel-Buffering': 'no',
341
+ 'X-Content-Type-Options': 'nosniff'
342
+ })
343
+ res.flushHeaders?.()
344
+ const subscriber = { res, token }
345
+ subscribers.add(subscriber)
346
+ res.on('close', () => closeSubscriber(subscriber))
347
+ sendFeedbackEvent(subscriber, feedbackMessage())
348
+ ensureHeartbeat()
349
+ return true
350
+ }
262
351
  if (path === routePrefix + '/state' && req.method === 'GET') {
263
352
  if (new URL(req.url, 'http://localhost').searchParams.get('room') !== room) {
264
353
  reply(res, 403, { error: 'Region denied' })
265
354
  return true
266
355
  }
267
- reply(res, 200, { room, kind: 'state', topic: 'room/state', payload: state, revision })
356
+ reply(res, 200, feedbackMessage())
268
357
  return true
269
358
  }
270
359
  if (path === routePrefix + '/logout' && req.method === 'POST') {
271
360
  sessions.delete(token)
361
+ closeSubscribersForToken(token)
272
362
  reply(res, 200, { ok: true }, { 'Set-Cookie': cookie('', 0) })
273
363
  return true
274
364
  }
@@ -294,7 +384,7 @@ function createGateway ({
294
384
  return true
295
385
  }
296
386
 
297
- let next = { ...state }
387
+ let next = { ...desiredState }
298
388
  if (data.topic === 'room/reset') next = defaults()
299
389
  else if (key === 'scene') next = { ...next, ...presets[data.payload], scene: data.payload }
300
390
  else {
@@ -321,9 +411,9 @@ function createGateway ({
321
411
  onCommand(command)
322
412
  }
323
413
 
324
- state = next
325
- revision++
326
- reply(res, 200, { ok: true, revision })
414
+ desiredState = next
415
+ commandRevision++
416
+ reply(res, 200, { ok: true, revision, commandRevision })
327
417
  return true
328
418
  }
329
419
 
@@ -340,6 +430,9 @@ function createGateway ({
340
430
  close () {
341
431
  closed = true
342
432
  continuousDispatcher.close()
433
+ for (const subscriber of [...subscribers]) closeSubscriber(subscriber)
434
+ if (heartbeatTimer) clearInterval(heartbeatTimer)
435
+ heartbeatTimer = null
343
436
  sessions.clear()
344
437
  attempts.clear()
345
438
  }
@@ -348,6 +441,7 @@ function createGateway ({
348
441
 
349
442
  module.exports = {
350
443
  CONTINUOUS_INTERVAL_MS,
444
+ FEEDBACK_HEARTBEAT_MS,
351
445
  PREFIX,
352
446
  controlPolicies,
353
447
  createGateway,
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "node-red-contrib-room-tools",
3
- "version": "1.5.0",
3
+ "version": "1.7.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
- "files": ["gateway.cjs", "room-gateway.js", "room-gateway.html", "room-message.js", "room-message.html", "control-panel.js", "control-panel.html", "README.md"],
7
+ "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
8
  "node-red": {
9
9
  "nodes": {
10
10
  "room-gateway": "room-gateway.js",
package/room-gateway.html CHANGED
@@ -14,7 +14,7 @@ RED.nodes.registerType('room-gateway',{
14
14
  <p>密码至少 12 个字符。每个区域 ID 只能部署一个网关,账号只能访问该网关配置的区域。</p>
15
15
  </script>
16
16
  <script type="text/html" data-help-name="room-gateway">
17
- <p>Template 通过同源 /room-panel-api/regions/&lt;区域 ID&gt; 登录、发送控制请求和读取反馈。Template 输出不再用于控制设备。</p>
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>状态包含最近接受的目标值,实际设备反馈可覆盖。Node-RED 编辑器属于管理员入口,部署时应独立保护。生产 iPad 访问请配置 HTTPS 并启用 HTTPS Cookie。</p>
19
+ <p>公开状态只包含设备反馈,不会把刚接受的控制目标冒充实际状态。Node-RED 编辑器属于管理员入口,部署时应独立保护。生产 iPad 访问请配置 HTTPS 并启用 HTTPS Cookie。</p>
20
20
  </script>