node-red-contrib-room-tools 1.8.2 → 1.8.3
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 +55 -220
- package/README.md +10 -148
- package/assets/panel-login-bg.png +0 -0
- package/control-bound-protocol.js +97 -0
- package/control-catalog.js +97 -0
- package/control-panel.html +82 -86
- package/control-panel.js +237 -10
- package/control-policy.js +267 -7
- package/control-type-contract.json +17 -0
- package/control-type-model.js +63 -0
- package/gateway.cjs +77 -5
- package/managed-bridge-admin.js +90 -0
- package/managed-bridge-revision.js +32 -0
- package/managed-native-bridge.js +124 -0
- package/native-binding-plan.js +177 -0
- package/package.json +27 -5
- package/panel-binding-editor.js +771 -0
- package/panel-editor-auth.js +151 -0
- package/panel-template-instrumentation.js +267 -0
- package/panel-visual-apply.js +127 -0
- package/panel-visual-bindings.js +95 -0
- package/room-gateway.html +0 -21
- package/room-gateway.js +0 -57
- package/room-message.html +0 -56
- package/room-message.js +0 -43
|
@@ -1,239 +1,74 @@
|
|
|
1
|
-
# ControlPanel
|
|
1
|
+
# ControlPanel 前端页面编写规范
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
本文适用于当前 `ui-control-panel` 的可视化绑定版本。给 AI 编写页面前先阅读本文:页面作者只负责普通 Dashboard 2 / Vue 页面和控件状态,不编写设备协议、驱动路径、绑定表或 ControlPanel 专用 API。
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
不同页面使用同一套控制语义,避免滑杆积压、重复队列、反馈回环和跨房间误发。
|
|
5
|
+
## 页面交付与绑定流程
|
|
7
6
|
|
|
8
|
-
|
|
7
|
+
1. 在 `ui-control-panel` 的 Template 中粘贴 Vue 单文件组件式页面(`<template>`、可选的 `<script>` 和 `<style>`)。节点选择 Dashboard 页面,填写 `Room ID`、普通页面账号及独立的编辑账号,保存并 Deploy。
|
|
8
|
+
2. 重新打开该节点,点击 `Open page binding editor`。用编辑账号登录;编辑页默认启用 Select,可点击或右键点选可绑定控件,再选择在线设备、读写点位与编号方式。控件的绑定会先保存为草稿。
|
|
9
|
+
3. 在编辑页点 Apply,节点校验并保存隐藏映射、备份 Flow、重新生成该面板的 native bridge 并部署。Apply 后仍留在编辑页;点 Exit 才返回普通页面的登录页。
|
|
10
|
+
4. 普通用户用普通账号登录后才能操作已绑定控件。修改 Template 可能使旧控件定位失效;重新 Deploy 后进入编辑页检查并重新 Apply。普通 Deploy 本身不会替代编辑页的 Apply。
|
|
9
11
|
|
|
10
|
-
|
|
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 最适合直接调用注入的生命周期方法。
|
|
12
|
+
绑定时设备必须在线,且 Automator 目录能返回该设备的参数。已有绑定会保存在 Flow 中;Automator 启动时不靠设备在线才能显示已保存的页面。可绑定点位按目录中的参数类型、枚举、量程和读写权限筛选;不是任意设备命令都可绑定。
|
|
17
13
|
|
|
18
|
-
##
|
|
14
|
+
## 页面代码的基本约定
|
|
19
15
|
|
|
20
|
-
|
|
16
|
+
- 使用普通 HTML、CSS 和 Vue 编写布局、导航、场景展示和页面内部交互。需要设备绑定的控件只维护普通的控件值;发送、反馈、登录、范围换算由节点处理。
|
|
17
|
+
- Vue 绑定字段使用 `data()` 中的顶层简单字段,例如 `volume`、`muted`、`source`。不要用 `state.volume`、数组下标、计算属性或表达式作为待绑定模型。
|
|
18
|
+
- 给控件稳定且唯一的标准 `id` 或 `name`,尤其是重复出现或外观相同的控件。不要把待绑定控件放在 `v-for` 内;绑定时控件应实际显示在页面上。
|
|
19
|
+
- 不要手写 `data-cp-*` 标签、设备路径、HTTP/WebSocket 调用、驱动命令或反馈回写。`data-cp-*` 会被拒绝。不要给待绑定控件叠加 `@click`、`@input`、`@change`、`@update:model-value` 等自行处理设备状态的事件,也不要额外绑定 `:value`、`:checked` 或 `:model-value`(下述只读进度条例外)。
|
|
20
|
+
- Vuetify 标签一律成对闭合,包括只用于装饰的组件:写 `<v-icon></v-icon>`,不要写 `<v-icon/>`。Dashboard 模板会把自闭合自定义标签后的控件错误地吞进去;节点现会直接报错。
|
|
21
|
+
- 普通页面可以使用其他 Vuetify 组件作布局或纯页面交互,但只有下表列出的组件具备自动设备绑定适配器。
|
|
21
22
|
|
|
22
|
-
|
|
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
|
-
```
|
|
23
|
+
## 可绑定写法
|
|
58
24
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
`
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
`//processors/1/channels/2/level` 参数反馈通过
|
|
68
|
-
`room-message(audio/volume, useInput=true)` 返回 Gateway 输入。Template 不负责这些连线。
|
|
69
|
-
由于 Automator Event 只报告参数变化,如果 Automator 可能晚于驱动启动,还需要使用
|
|
70
|
-
`Inject once -> Status stateReady -> Switch true -> Status mute/level -> room-message -> Gateway`
|
|
71
|
-
在流程启动时读取 MUSE 虚拟设备已保存的当前值;同时用 `Event stateReady=true` 触发同一组
|
|
72
|
-
Status 节点,覆盖设备晚于 Automator 上线的情况。Mute/Level Event 节点继续负责后续实时变化。
|
|
73
|
-
|
|
74
|
-
退出、锁屏或会话失效时必须执行:
|
|
75
|
-
|
|
76
|
-
```js
|
|
77
|
-
this.clearControlSession()
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
这会同时清除该会话尚未发送的连续值和离散队列。
|
|
81
|
-
|
|
82
|
-
## 3. 命令格式
|
|
83
|
-
|
|
84
|
-
每条设备控制命令只声明以下字段:
|
|
85
|
-
|
|
86
|
-
```js
|
|
87
|
-
this.sendControl({
|
|
88
|
-
type: 'level',
|
|
89
|
-
topic: 'audio/volume',
|
|
90
|
-
key: 'rane/1/level/2',
|
|
91
|
-
value: 65,
|
|
92
|
-
final: false
|
|
93
|
-
})
|
|
94
|
-
```
|
|
25
|
+
| 用途 | 原生 HTML | Vuetify | 模型与限制 |
|
|
26
|
+
| --- | --- | --- | --- |
|
|
27
|
+
| 连续数值控制 | `<input type="range">`、`<input type="number">` | `v-slider` | 数字;滑杆用简单 `v-model`,允许 `.number`。`min`、`max`、`step` 只写数字常量(包括 `:max="100"`),不要绑定动态字段。 |
|
|
28
|
+
| 布尔控制 | `<input type="checkbox">`、`<button aria-pressed="false">`、`<button role="switch" aria-checked="false">` | `v-switch`、`v-checkbox` | Vuetify 使用布尔 `v-model`;不支持自定义 `true-value` / `false-value`、`multiple` 或值数组。语义按钮由节点更新状态并处理点击。 |
|
|
29
|
+
| 单选枚举 | `<select>` | `v-select`、`v-radio-group` | Vuetify 使用字符串或数字的简单 `v-model`,选项值与驱动枚举值一致;可用 `.number`。不支持多选、`return-object`、`clearable`;单选项不要自带发送事件。 |
|
|
30
|
+
| 文本控制 | `<input type="text">`、`<textarea>` | `v-text-field`、`v-textarea` | Vuetify 使用字符串 `v-model`;在 change 或失焦时提交,`v-text-field` 也支持 Enter。不支持数字型 `v-text-field` 或 `.number`。 |
|
|
31
|
+
| 只读数值反馈 | `<meter>`、`<progress>` | `v-progress-linear`、`v-progress-circular` | 只能绑定读点位。Vuetify 用数字 `:model-value="progress"` 或 `v-model="progress"`;linear 可用固定正数 `max`,circular 固定 0–100;不支持 indeterminate/clickable。 |
|
|
32
|
+
| 只读文本反馈 | `<span>`、`<strong>`、`<b>`、`<output>`、`<h1>`、`<h2>`、`<h3>` | 暂无自动适配组件 | 元素内使用单个顶层字段插值,如 `{{ status }}`;也可用纯文本。只可绑定读点位,不支持拼接或格式化表达式。 |
|
|
95
33
|
|
|
96
|
-
- `
|
|
97
|
-
- `topic`:业务功能,由 `room-gateway` 验证和路由。
|
|
98
|
-
- `key`:稳定且唯一的设备控制标识。推荐
|
|
99
|
-
`<device>/<slot>/<control>/<channel>`,例如 `rane/1/level/2`。
|
|
100
|
-
- `value`:目标值。
|
|
101
|
-
- `final`:仅连续控制需要;松手或 change 时设为 `true`。
|
|
34
|
+
原生控件的简单顶层 `v-model` 也可用。Vuetify 交互控件必须有简单 `v-model`;其模型的初始类型要与表格一致。原生 `meter`/`progress` 默认量程为 0–1;原生 range 默认 0–100。为可读性和准确换算,数值控件尽量显式声明页面量程。
|
|
102
35
|
|
|
103
|
-
|
|
104
|
-
`key`;不同设备或不同控制号必须使用不同 `key`。
|
|
36
|
+
原生滑杆可按常规 Vue 写法使用,例如 `<input id="room-light" type="range" min="0" max="100" step="1" v-model.number="light">`,并在 `data()` 中将 `light` 初始化为数字。拖动产生的发送和设备反馈由 ControlPanel 处理;不要为了绑定再添加手写的 `input`/`change` 发送监听、定时器或强制回写滑杆值。若滑杆能拖动但设备无变化,或设备已变化而页面显示旧值,应分别检查绑定、设备收发和反馈的新鲜度,不要在页面里补第二套设备逻辑。
|
|
105
37
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
| `type` | 前端写法 | 公共 API 行为 |
|
|
109
|
-
| --- | --- | --- |
|
|
110
|
-
| `power` | 每次明确点击发送一次 | 顺序发送 |
|
|
111
|
-
| `source` | 用户选择后发送一次 | 顺序发送 |
|
|
112
|
-
| `preset` | 每次明确点击发送一次 | 顺序发送 |
|
|
113
|
-
| `mute` | 每次明确点击发送一次 | 顺序发送 |
|
|
114
|
-
| `level` | 拖动发送,松手再发最终值 | 每个 key 40 ms latest-wins |
|
|
115
|
-
| `dimmer` | 拖动发送,松手再发最终值 | 每个 key 40 ms latest-wins |
|
|
116
|
-
| `position` | 拖动发送,松手再发最终值 | 每个 key 40 ms latest-wins |
|
|
117
|
-
|
|
118
|
-
滑杆标准写法:
|
|
38
|
+
### 最小示例
|
|
119
39
|
|
|
120
40
|
```html
|
|
121
|
-
<
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
},
|
|
142
|
-
onDeviceFeedback (feedback, state) {
|
|
143
|
-
// pending 期间保持最后目标位置,不让较旧的中间反馈拉回滑杆。
|
|
144
|
-
if (!this.isControlPending('rane/1/level/2') && Number.isFinite(state.volume)) {
|
|
145
|
-
this.volume = state.volume
|
|
41
|
+
<template>
|
|
42
|
+
<section>
|
|
43
|
+
<v-slider id="room-volume" label="音量" v-model="volume" :min="0" :max="100" :step="1"></v-slider>
|
|
44
|
+
<v-switch id="room-mute" label="静音" v-model="muted"></v-switch>
|
|
45
|
+
<v-select id="room-source" label="输入源" v-model="source" :items="sources"></v-select>
|
|
46
|
+
<v-progress-linear id="room-level" :model-value="level" :max="100"></v-progress-linear>
|
|
47
|
+
<span id="room-status">{{ status }}</span>
|
|
48
|
+
</section>
|
|
49
|
+
</template>
|
|
50
|
+
<script>
|
|
51
|
+
export default {
|
|
52
|
+
data () {
|
|
53
|
+
return {
|
|
54
|
+
volume: 0,
|
|
55
|
+
muted: false,
|
|
56
|
+
source: 'A',
|
|
57
|
+
sources: ['A', 'B'],
|
|
58
|
+
level: 0,
|
|
59
|
+
status: '待机'
|
|
60
|
+
}
|
|
146
61
|
}
|
|
147
62
|
}
|
|
63
|
+
</script>
|
|
148
64
|
```
|
|
149
65
|
|
|
150
|
-
|
|
151
|
-
连续控件直接发送目标值,不主动查询当前值。匹配目标的被动反馈会自动清除 pending;
|
|
152
|
-
未匹配的较旧反馈只更新 `deviceState`,`controlDisplayValue()` 仍返回最后目标。
|
|
153
|
-
|
|
154
|
-
离散控件的下一条同 topic 真实反馈会解除 pending,并直接以该反馈值显示实际状态,
|
|
155
|
-
即使 Automator 反馈携带的是 MUSE provider path、与 Template 的 UI `key` 不同。若同一房间内
|
|
156
|
-
有多个相同 topic 的控件,反馈必须提供与 Template 一致的 `controlKey`,避免误确认其他控件。
|
|
157
|
-
连续控件仍只有在反馈值匹配最后目标时才解除 pending。
|
|
158
|
-
|
|
159
|
-
离散按钮必须显示设备反馈值,不能先改本地状态。标准写法:
|
|
160
|
-
|
|
161
|
-
```html
|
|
162
|
-
<v-btn
|
|
163
|
-
:class="{ active: deviceValue('muted', false) }"
|
|
164
|
-
:loading="isControlPending('rane/1/mute/1')"
|
|
165
|
-
:disabled="!hasDeviceState('muted')"
|
|
166
|
-
@click="requestMute"
|
|
167
|
-
>
|
|
168
|
-
Mute
|
|
169
|
-
</v-btn>
|
|
170
|
-
```
|
|
171
|
-
|
|
172
|
-
```js
|
|
173
|
-
requestMute () {
|
|
174
|
-
const next = !this.deviceValue('muted', false)
|
|
175
|
-
return this.sendControl({
|
|
176
|
-
type: 'mute',
|
|
177
|
-
topic: 'audio/mute',
|
|
178
|
-
key: 'rane/1/mute/1',
|
|
179
|
-
value: next
|
|
180
|
-
})
|
|
181
|
-
}
|
|
182
|
-
```
|
|
183
|
-
|
|
184
|
-
Power、Source 和 Preset 使用相同原则:事件处理函数只计算并发送目标值,不修改
|
|
185
|
-
代表设备状态的本地变量。按钮的 active、selected、pressed、switch model-value 和
|
|
186
|
-
下拉框 value 必须来自 `deviceState`/`deviceValue()`。`isControlPending(key)` 只用于
|
|
187
|
-
显示“等待设备确认”,不能当作成功状态。
|
|
188
|
-
|
|
189
|
-
## 5. 反馈与显示规则
|
|
190
|
-
|
|
191
|
-
- `room-gateway` 的公开状态只来自设备反馈;接受控制命令不会提前改变公开状态。
|
|
192
|
-
- 驱动连接设备后负责查询一次真实状态;实时反馈由 Gateway 缓存并由公共 API 自动接收、写入
|
|
193
|
-
`deviceState`。Dashboard 离线时收到的状态会在下次登录后的反馈首帧恢复。
|
|
194
|
-
- Power、Source、Preset、Mute、温度步进按钮等非滑杆控件必须显示
|
|
195
|
-
`deviceState` 中的实际值,不得乐观切换本地状态。
|
|
196
|
-
- Level、Dimmer、Position 滑杆直接发送目标值且不主动查询。拖动和松手后都保持
|
|
197
|
-
最后目标位置;匹配最终目标的被动反馈只负责确认,不改变滑杆位置。
|
|
198
|
-
- 连续控制 pending 期间收到的旧中间反馈可以更新 `deviceState`,但不能覆盖
|
|
199
|
-
`controlTargets` 或拉回滑杆。只有发送失败,或没有 pending 时发生外部调节,
|
|
200
|
-
才把滑杆同步到实际反馈值。
|
|
201
|
-
- 反馈没有包含某个字段时,使用 `hasDeviceState(field)` 显示未知或禁用状态,不能
|
|
202
|
-
用假定默认值冒充设备实际状态。
|
|
203
|
-
- 禁止使用会把反馈值自动发送回设备的 `watch`。
|
|
204
|
-
- 控制发送只能来自明确的用户事件或业务动作。
|
|
205
|
-
- 需要把反馈复制到滑杆草稿或其他页面状态时,实现 `onDeviceFeedback(feedback,
|
|
206
|
-
state)`;先用 `isControlPending(key)` 阻止旧反馈覆盖目标。该方法只赋值,不调用
|
|
207
|
-
`sendControl()`。
|
|
208
|
-
- Template 只清理自己创建的动画和纯 UI timer;公共 API 的发送队列、反馈连接和
|
|
209
|
-
pending 状态由节点在页面卸载时自动清理。
|
|
210
|
-
|
|
211
|
-
## 6. AI 交付前检查
|
|
212
|
-
|
|
213
|
-
AI 完成页面后必须逐项确认:
|
|
214
|
-
|
|
215
|
-
1. 所有设备控制都调用 `sendControl()`,没有 `this.send()`、命令 `fetch()` 或 TCP。
|
|
216
|
-
2. 每条命令都有正确的 `type`、`topic`、稳定 `key` 和 `value`。
|
|
217
|
-
3. 连续控件在松手/change 时发送相同目标值并带 `final: true`。
|
|
218
|
-
4. Template 内没有自建限速器、latest-wins Map 或发送队列。
|
|
219
|
-
5. 登录成功调用 `setControlSession()`,退出或会话失效调用
|
|
220
|
-
`clearControlSession()`。
|
|
221
|
-
6. 非滑杆控件的 active、selected、pressed、value 等显示只来自设备反馈。
|
|
222
|
-
7. 非滑杆事件处理函数没有在发送前修改设备状态变量。
|
|
223
|
-
8. 滑杆使用 `controlDisplayValue()`,松手后保持最后目标,不被旧中间反馈拉回。
|
|
224
|
-
9. 连续控件直接发送目标值;Template 没有主动状态查询、自建轮询、`EventSource`
|
|
225
|
-
或 WebSocket。
|
|
226
|
-
10. 设备反馈和 `onDeviceFeedback()` 不会触发控制命令回发。
|
|
227
|
-
11. 未收到反馈的字段显示未知/禁用,不用默认值冒充实际状态。
|
|
228
|
-
12. 桌面和移动尺寸下文字、滑杆、按钮不重叠。
|
|
229
|
-
13. 页面卸载时清理 Template 自己创建的 timer 和 listener。
|
|
230
|
-
14. 设备连接后的首次查询由 Script-as-Device 驱动负责;Gateway 没有查询输出,Template
|
|
231
|
-
没有自行调用 `queryStatus` 或任何查询接口。
|
|
66
|
+
其中 `sources` 的值仅是示例,实际枚举值要与所选驱动点位一致。控件可以先写好页面,设备与点位留到可视化绑定编辑页选择。
|
|
232
67
|
|
|
233
|
-
|
|
68
|
+
## 映射规则与检查
|
|
234
69
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
70
|
+
- 数值滑杆和进度条由网页侧在页面量程与驱动量程之间换算;不会修改驱动。只有两侧量程元数据有效时才能换算;否则不要假定会自动缩放。整数目标会取整。
|
|
71
|
+
- Write point 改动时,Feedback point 默认跟随;之后可单独指定反馈点位。新绑定默认 Zero-based;必须按实际设备协议确认。只读控件没有 Write point。
|
|
72
|
+
- 编辑页只展示当前控件类型、值类型和读写权限兼容的参数。设备列表为空时,先检查设备在线和 Automator 参数目录,不能靠前端 HTML 使离线设备的点位出现。
|
|
73
|
+
- 一个页面元素只能绑定一个映射。`v-for`、无法唯一定位的重复元素、复杂模型、动态量程、既有发送处理器及不在上表中的组件不会被自动接管。
|
|
74
|
+
- Apply 成功才算映射及桥接部署完成。桥接版本与保存的映射不一致时命令会被拒绝。先验证登录、绑定、页面反馈,再检查实际设备收发;本地模拟测试不等于真实设备闭环。
|
package/README.md
CHANGED
|
@@ -1,159 +1,21 @@
|
|
|
1
1
|
# Room Tools
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
This package provides `ui-control-panel` for Node-RED and MUSE Automator.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
- room-message: typed panel feedback message generator.
|
|
7
|
-
- ui-control-panel: Dashboard 2 template widget without message ports.
|
|
5
|
+
## ControlPanel
|
|
8
6
|
|
|
9
|
-
|
|
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.
|
|
7
|
+
The page author writes ordinary HTML/CSS/Vue and pastes it into the `ui-control-panel` Template. After configuring a Dashboard page, room ID, control login, and separate editor login, save and Deploy. Reopen the node and select **Open page binding editor**. Sign in with the editor account, right-click a supported control, choose its device points, and Apply.
|
|
12
8
|
|
|
13
|
-
|
|
14
|
-
regions follow the same URL structure.
|
|
9
|
+
Bindings are stored in `visualBindings` on the node. The runtime reads that mapping to authenticate commands, route feedback, and generate a native bridge. The page author does not put device paths, `data-cp-*` attributes, or device-send code in the Template. Author-provided `data-cp-*` attributes are rejected.
|
|
15
10
|
|
|
16
|
-
|
|
11
|
+
Supported controls include native range, checkbox, semantic pressed button, select, text input, meter, progress, and simple text display. Give repeated controls distinct standard IDs. Simple top-level Vue `v-model` works; controls with existing send handlers or ambiguous dynamic behavior are rejected until a suitable adapter exists. See `CONTROL_PANEL_AI_GUIDE.md` for examples and limits.
|
|
17
12
|
|
|
18
|
-
|
|
19
|
-
driver boundaries:
|
|
13
|
+
Vuetify adapters cover `v-slider`, `v-switch`, `v-checkbox`, `v-select`, `v-radio-group`, `v-text-field`, `v-textarea`, and read-only `v-progress-linear` and `v-progress-circular`. Interactive components use a simple top-level `v-model`; progress may also use a simple `:model-value` field. Sliders accept fixed numeric `min`, `max`, and `step` props, including numeric Vue bindings such as `:max="100"`. Text fields commit on change, blur, or Enter (`v-text-field` only). Selection values must match the device's enum values; circular progress uses a fixed 0-100 range. `v-model.number` is accepted for numeric sliders and scalar selections, but not for boolean or text controls. Dynamic bounds, array-valued switches, multi-select, object selections, custom true/false values, indeterminate progress, and controls with their own event handlers need a separate adapter.
|
|
20
14
|
|
|
21
|
-
-
|
|
22
|
-
them in order and the gateway never coalesces them.
|
|
23
|
-
- `level`, `dimmer` and `position` are continuous controls. The panel sends at
|
|
24
|
-
most 25 updates per second, overwrites an unsent value for the same
|
|
25
|
-
`controlKey`, and sends the final value again on `change`/release.
|
|
26
|
-
- The gateway independently enforces the same 40 ms latest-wins window. A
|
|
27
|
-
custom panel that produces too many slider events therefore cannot create an
|
|
28
|
-
unbounded downstream queue.
|
|
29
|
-
- Every command emitted by the gateway includes validated `controlType`,
|
|
30
|
-
`controlKey` and `final` fields. Authentication, CSRF checks, room isolation
|
|
31
|
-
and value validation still happen before a command is accepted.
|
|
15
|
+
Use paired Vuetify tags in Dashboard templates, such as `<v-slider></v-slider>`; self-closing component tags can absorb the following controls during Dashboard rendering and are rejected before deployment.
|
|
32
16
|
|
|
33
|
-
|
|
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.
|
|
17
|
+
After clicking Apply in the page binding editor, the package validates the mapping, backs up the active Flow, generates the native bridge for that panel, and deploys the updated Flow in one operation. The bridge revision must match the saved mapping before a command can reach a device. A Node-RED simulation verifies software behavior, while live device operation requires a separate check.
|
|
40
18
|
|
|
41
|
-
|
|
19
|
+
## Installation
|
|
42
20
|
|
|
43
|
-
|
|
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. A discrete control is released by the next
|
|
62
|
-
authoritative feedback for its topic, even when Automator reports a provider
|
|
63
|
-
path instead of the Template's UI key. Continuous controls still wait for the
|
|
64
|
-
reported value to match their latest target.
|
|
65
|
-
|
|
66
|
-
Non-slider controls must use `:model-value`, `:value`, `:class` or
|
|
67
|
-
`:aria-pressed` from `deviceState`/`deviceValue()` and must not optimistically
|
|
68
|
-
change that state in the click handler. Sliders send their target directly and
|
|
69
|
-
must use `controlDisplayValue(key, field, fallback)` to hold the latest target
|
|
70
|
-
until matching passive feedback arrives. Older intermediate feedback updates
|
|
71
|
-
`deviceState` but does not pull the slider backward. Templates must not query
|
|
72
|
-
device state actively.
|
|
73
|
-
|
|
74
|
-
## Retained device state
|
|
75
|
-
|
|
76
|
-
`room-gateway` keeps its original single output for authenticated control
|
|
77
|
-
commands. Actual device feedback enters the gateway input as `kind: 'state'`.
|
|
78
|
-
The gateway stores the latest reported value for every associated control:
|
|
79
|
-
|
|
80
|
-
- If a Dashboard is online, feedback is broadcast immediately over its passive
|
|
81
|
-
Server-Sent Events connection.
|
|
82
|
-
- If no Dashboard is logged in, feedback remains cached in `reportedState`.
|
|
83
|
-
- When a Dashboard later opens the feedback stream, its first event is the
|
|
84
|
-
complete cached state snapshot.
|
|
85
|
-
|
|
86
|
-
The Script-as-Device driver should query once when its device connection is
|
|
87
|
-
established, publish the result through its virtual-device parameters, and then
|
|
88
|
-
rely on device feedback. This is not periodic polling. The Template does not
|
|
89
|
-
issue queries and device feedback never becomes a control command.
|
|
90
|
-
|
|
91
|
-
For the current room-01 Rane mapping, use these Automator feedback connections:
|
|
92
|
-
|
|
93
|
-
- Event `//processors/1/channels/1/mute` -> room-message `audio/mute` with
|
|
94
|
-
`useInput` enabled -> Gateway input.
|
|
95
|
-
- Event `//processors/1/channels/2/level` -> room-message `audio/volume` with
|
|
96
|
-
`useInput` enabled -> Gateway input.
|
|
97
|
-
|
|
98
|
-
Use the virtual-device name currently shown in Automator. Older deployments may
|
|
99
|
-
still display `Precis Driver`; newer descriptors display `RaneProcessor`.
|
|
100
|
-
|
|
101
|
-
Automator Event nodes report parameter changes but do not fetch an already
|
|
102
|
-
retained value. To seed a gateway that starts after the driver, add a flow-start
|
|
103
|
-
initialization branch for each associated control:
|
|
104
|
-
|
|
105
|
-
```text
|
|
106
|
-
Inject once -> Status //processors/1/stateReady -> Switch true
|
|
107
|
-
-> Status //processors/1/channels/1/mute -> room-message -> Gateway
|
|
108
|
-
-> Status //processors/1/channels/2/level -> room-message -> Gateway
|
|
109
|
-
|
|
110
|
-
Event //processors/1/stateReady (true) -> the same two Status nodes
|
|
111
|
-
```
|
|
112
|
-
|
|
113
|
-
Keep the Event nodes above in parallel for subsequent live changes. The
|
|
114
|
-
`stateReady` check prevents descriptor defaults or an incomplete reconnect
|
|
115
|
-
snapshot from being presented as actual state. The `stateReady=true` Event
|
|
116
|
-
covers the case where the physical processor connects after Automator starts.
|
|
117
|
-
|
|
118
|
-
```html
|
|
119
|
-
<input
|
|
120
|
-
type="range"
|
|
121
|
-
@input="sendControl({ type: 'level', topic: 'audio/volume', key: 'rane/0/level/2', value })"
|
|
122
|
-
@change="sendControl({ type: 'level', topic: 'audio/volume', key: 'rane/0/level/2', value, final: true })"
|
|
123
|
-
/>
|
|
124
|
-
```
|
|
125
|
-
|
|
126
|
-
```js
|
|
127
|
-
sendControl({ type: 'mute', topic: 'audio/mute', key: 'rane/0/mute/1', value: true })
|
|
128
|
-
```
|
|
129
|
-
|
|
130
|
-
The injected API supports `power`, `source`, `preset`, `mute`, `level`,
|
|
131
|
-
`dimmer` and `position`. It requires a stable `key`; this identifies one
|
|
132
|
-
device control, not just a visible slider. The public names `sendControl`,
|
|
133
|
-
`flushControl`, `setControlSession`, `clearControlSession`,
|
|
134
|
-
`applyControlFeedback`, `hasDeviceState`, `deviceValue`, `isControlPending`,
|
|
135
|
-
`controlDisplayValue`,
|
|
136
|
-
`deviceState`, `deviceFeedbackRevision`, `deviceFeedbackConnected`,
|
|
137
|
-
`controlPending`, `controlTargets`, `controlTransport`, `onControlError`, `onDeviceFeedback` and
|
|
138
|
-
`onControlFeedbackStatus` are reserved by the ControlPanel API.
|
|
139
|
-
|
|
140
|
-
The injector supports normal Options API exports, `defineComponent(...)` and
|
|
141
|
-
`<script setup>`. CSS-only `site:style` and `page:style` ControlPanel nodes are
|
|
142
|
-
left unchanged.
|
|
143
|
-
|
|
144
|
-
The node type names and configuration fields are unchanged from the individual
|
|
145
|
-
1.0.0 packages. No external dependencies or installation scripts are required.
|
|
146
|
-
|
|
147
|
-
## Migrating from the individual packages
|
|
148
|
-
|
|
149
|
-
Do not install this package alongside node-red-contrib-room-gateway or
|
|
150
|
-
node-red-contrib-room-message: they register the same node types.
|
|
151
|
-
|
|
152
|
-
Back up the project and its credentials, save changes, and stop Automator.
|
|
153
|
-
In its user directory (on this Mac, ~/.node-red), uninstall the two individual
|
|
154
|
-
packages with npm, then install this .tgz package before restarting Automator.
|
|
155
|
-
Keep the existing flow nodes and their IDs; do not delete them from the canvas.
|
|
156
|
-
After restarting, verify the gateway credentials and both nodes before use.
|
|
157
|
-
|
|
158
|
-
This package does not contain accounts, passwords, or flows. Installing it on a
|
|
159
|
-
Mac does not install it on a MUSE controller.
|
|
21
|
+
Install this package in the Node-RED user directory or declare it in the active project `package.json`. Installing on the Mac does not install the package on a MUSE controller.
|
|
Binary file
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
const { visualRoutes } = require('./panel-visual-bindings')
|
|
2
|
+
|
|
3
|
+
function configuredRoutes (config) {
|
|
4
|
+
return visualRoutes(config)
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function resolveBoundCommand (routes, message) {
|
|
8
|
+
const request = message?._controlPanel
|
|
9
|
+
if (request?.kind !== 'command' || typeof request.id !== 'string') {
|
|
10
|
+
throw new Error('Invalid bound command')
|
|
11
|
+
}
|
|
12
|
+
const route = routes.get(request.id)
|
|
13
|
+
if (!route) throw new Error(`Unknown control ID: ${request.id}`)
|
|
14
|
+
if (route.control.mode === 'read') throw new Error(`Read-only control: ${request.id}`)
|
|
15
|
+
if (!route.binding?.device || !route.binding.writePath) {
|
|
16
|
+
throw new Error(`Incomplete command binding: ${request.id}`)
|
|
17
|
+
}
|
|
18
|
+
if (!Object.hasOwn(request, 'value')) throw new Error('Bound command needs a value')
|
|
19
|
+
const value = request.value
|
|
20
|
+
const type = route.control.type
|
|
21
|
+
if (['level', 'dimmer', 'position'].includes(type) &&
|
|
22
|
+
(typeof value !== 'number' || !Number.isFinite(value))) {
|
|
23
|
+
throw new Error(`${type} value must be a finite number`)
|
|
24
|
+
}
|
|
25
|
+
const metadata = route.binding.writeMeta
|
|
26
|
+
if (['level', 'dimmer', 'position'].includes(type) && metadata?.type === 'integer' && !Number.isInteger(value)) {
|
|
27
|
+
throw new Error(`${type} value must be an integer`)
|
|
28
|
+
}
|
|
29
|
+
if (['level', 'dimmer', 'position'].includes(type) &&
|
|
30
|
+
((Number.isFinite(metadata?.min) && value < metadata.min) ||
|
|
31
|
+
(Number.isFinite(metadata?.max) && value > metadata.max))) {
|
|
32
|
+
throw new Error(`${type} value is outside the device range`)
|
|
33
|
+
}
|
|
34
|
+
if (['mute', 'power'].includes(type) && typeof value !== 'boolean') {
|
|
35
|
+
throw new Error(`${type} value must be a boolean`)
|
|
36
|
+
}
|
|
37
|
+
if (['source', 'preset'].includes(type) &&
|
|
38
|
+
!(typeof value === 'string' || (typeof value === 'number' && Number.isFinite(value)))) {
|
|
39
|
+
throw new Error(`${type} value must be a string or number`)
|
|
40
|
+
}
|
|
41
|
+
if (type === 'text' && (typeof value !== 'string' || value.length > 4096)) {
|
|
42
|
+
throw new Error('Text value must be a string under 4096 characters')
|
|
43
|
+
}
|
|
44
|
+
if (Array.isArray(metadata?.enums) && metadata.enums.length &&
|
|
45
|
+
!metadata.enums.some(entry => Object.is(entry, value) || Object.is(entry?.value, value))) {
|
|
46
|
+
throw new Error('Value is not a permitted device option')
|
|
47
|
+
}
|
|
48
|
+
const payload = ['mute', 'power'].includes(type) && metadata?.booleanEncoding === 'integer01'
|
|
49
|
+
? (value ? 1 : 0) : value
|
|
50
|
+
return {
|
|
51
|
+
kind: 'bound-command',
|
|
52
|
+
controlId: request.id,
|
|
53
|
+
controlType: type,
|
|
54
|
+
device: route.binding.device,
|
|
55
|
+
path: route.binding.writePath,
|
|
56
|
+
payload,
|
|
57
|
+
final: request.final === true
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function resolveBoundFeedback (routes, message) {
|
|
62
|
+
if (message?.kind !== 'device-feedback' || typeof message.device !== 'string' ||
|
|
63
|
+
typeof message.path !== 'string' || !Object.hasOwn(message, 'payload')) {
|
|
64
|
+
throw new Error('Invalid device feedback')
|
|
65
|
+
}
|
|
66
|
+
const matches = []
|
|
67
|
+
for (const [id, route] of routes) {
|
|
68
|
+
if (route.control.mode === 'write' || route.binding?.device !== message.device ||
|
|
69
|
+
route.binding.readPath !== message.path) continue
|
|
70
|
+
const type = route.control.type
|
|
71
|
+
const metadata = route.binding.readMeta
|
|
72
|
+
let value = message.payload
|
|
73
|
+
if (['mute', 'power'].includes(type)) {
|
|
74
|
+
if (value === 'true' || value === 1 || value === '1') value = true
|
|
75
|
+
else if (value === 'false' || value === 0 || value === '0') value = false
|
|
76
|
+
if (typeof value !== 'boolean') throw new Error(`Invalid ${type} feedback: ${id}`)
|
|
77
|
+
} else if (['level', 'dimmer', 'position'].includes(type)) {
|
|
78
|
+
if (typeof value === 'string' && value.trim() !== '') value = Number(value)
|
|
79
|
+
if (typeof value !== 'number' || !Number.isFinite(value) ||
|
|
80
|
+
(metadata?.type === 'integer' && !Number.isInteger(value)) ||
|
|
81
|
+
(Number.isFinite(metadata?.min) && value < metadata.min) ||
|
|
82
|
+
(Number.isFinite(metadata?.max) && value > metadata.max)) {
|
|
83
|
+
throw new Error(`Invalid ${type} feedback: ${id}`)
|
|
84
|
+
}
|
|
85
|
+
} else if (['source', 'preset'].includes(type) &&
|
|
86
|
+
!(typeof value === 'string' || (typeof value === 'number' && Number.isFinite(value)))) {
|
|
87
|
+
throw new Error(`Invalid ${type} feedback: ${id}`)
|
|
88
|
+
} else if (type === 'text' && (typeof value !== 'string' || value.length > 4096)) {
|
|
89
|
+
throw new Error(`Invalid text feedback: ${id}`)
|
|
90
|
+
}
|
|
91
|
+
matches.push({ id, value })
|
|
92
|
+
}
|
|
93
|
+
if (!matches.length) throw new Error('Unmapped device feedback')
|
|
94
|
+
return matches
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = { configuredRoutes, resolveBoundCommand, resolveBoundFeedback }
|