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.
- package/CONTROL_PANEL_AI_GUIDE.md +220 -0
- package/README.md +59 -9
- package/control-panel.html +36 -12
- package/control-panel.js +13 -1
- package/control-policy.js +449 -0
- package/gateway.cjs +102 -8
- package/package.json +2 -2
- package/room-gateway.html +2 -2
|
@@ -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.
|
|
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
|
-
|
|
30
|
-
methods: `
|
|
31
|
-
`
|
|
32
|
-
|
|
33
|
-
|
|
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="
|
|
39
|
-
@change="
|
|
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
|
-
|
|
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
|
|
package/control-panel.html
CHANGED
|
@@ -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(' //
|
|
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(' //
|
|
67
|
+
template.push(' // After a successful login: this.setControlSession(session)')
|
|
60
68
|
template.push(' },')
|
|
61
69
|
template.push(' unmounted() {')
|
|
62
|
-
template.push(' //
|
|
63
|
-
template.push(' //
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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,
|
|
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 = { ...
|
|
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
|
-
|
|
325
|
-
|
|
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.
|
|
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/<区域 ID>
|
|
17
|
+
<p>Template 通过同源 /room-panel-api/regions/<区域 ID> 登录和发送控制请求;ui-control-panel 登录后自动订阅该区域的实时设备反馈。Template 输出不再用于控制设备。</p>
|
|
18
18
|
<p>本节点输出已授权消息,连接原来的 Switch。设备反馈必须含 room、kind: state、topic 和 payload,接入本节点输入,不接 Template。</p>
|
|
19
|
-
<p
|
|
19
|
+
<p>公开状态只包含设备反馈,不会把刚接受的控制目标冒充实际状态。Node-RED 编辑器属于管理员入口,部署时应独立保护。生产 iPad 访问请配置 HTTPS 并启用 HTTPS Cookie。</p>
|
|
20
20
|
</script>
|