node-red-contrib-room-tools 1.8.9 → 1.8.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  # ControlPanel 前端页面编写指南
2
2
 
3
- 适用基线:`node-red-contrib-room-tools@1.8.9` 的 `ui-control-panel`,Dashboard 2,Vue Options API。本文按当前代码审核,不表示支持任意 Vue、Vuetify 或第三方组件。
3
+ 适用基线:`node-red-contrib-room-tools@1.8.9` 的 `ui-control-panel`,当前本地修订为 `node-red-contrib-room-tools@1.8.15`,Dashboard 2,Vue Options API。本文按当前代码审核,不表示支持任意 Vue、Vuetify 或第三方组件。
4
4
 
5
5
  给 AI 编写页面前阅读本文。目标是:页面作者只写普通前端页面;部署人员在网页编辑模式选择设备与点位;ControlPanel 保存映射并负责设备命令、反馈和网页侧量程换算。
6
6
 
@@ -8,13 +8,14 @@
8
8
 
9
9
  页面作者使用普通 HTML、CSS 和 Vue 编写布局、样式、导航、控件及其本地状态。无需知道驱动协议、点位路径、内部 Control ID 或绑定表结构。标准 HTML `id`、Vue `v-model` 和 ARIA 属性不是 ControlPanel 专用标签。
10
10
 
11
- 节点负责登录界面、普通与编辑权限、控件识别、映射保存、桥接生成、命令发送和反馈更新。页面不调用这些内部服务,不实现第二套设备通信。
11
+ 节点负责登录界面、普通与编辑权限、控件识别、映射保存、桥接生成、命令发送和反馈更新。页面不调用这些内部服务,不实现第二套设备通信。即使一个页面同时显示几十或上百个实时 Meter,页面也只声明普通控件和普通 Vue 数据字段,并用 `volume` 与 `meter1` 等不同字段表达不同语义。
12
12
 
13
13
  不得从旧页面复制以下内容:
14
14
 
15
15
  - 手写 `data-cp-*` 属性。这些属性会被拒绝;运行时自动添加的内部标记不属于页面交付代码。
16
16
  - `sendControl`、`postControl`、`setControlSession`、`deviceValue` 等旧页面接口。
17
17
  - 写死设备路径、驱动命令、绑定表,或调用 ControlPanel 的 HTTP、WebSocket、SSE 接口。
18
+ - 声明 stream ID、订阅主题、帧格式、Socket.IO 事件或批量反馈协议。
18
19
  - 为修复反馈而添加设备轮询、强制回写滑杆、重复发送监听器或设备状态定时器。
19
20
 
20
21
  这些限制针对设备绑定,不禁止正常的页面导航、CSS 动画、图片明暗变化等纯前端功能。未绑定控件不会自动控制设备。待绑定控件的模型应独立;不要让两个不同设备控件共用一个 `v-model` 字段。
@@ -39,7 +40,8 @@
39
40
  | 布尔开关 | `input[type=checkbox]`、语义开关按钮 | `v-switch`、`v-checkbox` | 布尔值;切换发送。语义按钮写法见下节。 |
40
41
  | 单选枚举 | `select` | `v-select`、`v-radio-group` | 字符串或数字的单个值,必须与设备枚举值对应。 |
41
42
  | 文本输入 | `input[type=text]`、`textarea` | `v-text-field`、`v-textarea` | 字符串;原生 change 提交,Vuetify 在 change 或失焦时提交,`v-text-field` 还支持 Enter。 |
42
- | 只读数值 | `meter`、`progress` | `v-progress-linear`、`v-progress-circular` | 只绑定 Feedback point,不发送命令。 |
43
+ | 普通只读数值 | `output`、文本展示 | 暂无 | 只绑定普通 Feedback point,不发送命令。使用简单顶层字段;不要把普通数值反馈写成 Meter。 |
44
+ | 实时信号电平 Meter | `meter`、`progress` | `v-progress-linear`、`v-progress-circular` | 只绑定驱动明确声明 Meter 元数据的 Feedback point,不发送命令。使用独立的 `meter`/`meter1` 字段;原生 `meter`/`progress` 可使用简单 `v-model` 作为只读别名。 |
43
45
  | 只读文本 | `span`、`strong`、`b`、`output`、`h1`、`h2`、`h3` | 暂无 | 使用简单字段插值,如 `{{ status }}`,或由节点替换的纯文本;只绑定读点位。 |
44
46
 
45
47
  原生输入可以不写 Vue 模型,也支持简单顶层 `v-model`。Vuetify 交互控件必须写简单 `v-model`;进度组件可以使用简单 `:model-value="progress"`。初始化数值为数字、开关为布尔值、文本为字符串,不用数组、对象或空值占位。
@@ -47,7 +49,7 @@
47
49
  ### 3.1 通用限制
48
50
 
49
51
  - 待绑定控件自身不能有 `@click`、`@input`、`@change`、`@update:model-value` 等任何事件指令。即使事件只做本地操作,也会被当前识别器判为已有逻辑。页面导航等事件可放在独立、不绑定设备的控件上。
50
- - 不额外叠加 `:value`、`:checked`、`:model-value`。只读进度组件的 `:model-value` 是例外;语义开关按钮的简单 ARIA 布尔绑定也是例外。
52
+ - 交互输入控件不额外叠加 `:value`、`:checked`、`:model-value`。只读原生 meter/progress 的 `:value`、Vuetify 进度组件的 `:model-value` 和语义开关按钮的简单 ARIA 布尔绑定是例外。
51
53
  - 只允许 range/select 的 `v-model.number`;不要用 `.lazy`、`.trim`、带参数的 `v-model` 或其他修饰符。
52
54
  - 待绑定元素不能位于 `v-for` 中,即使写了 `:key`。相同模板会生成多个元素,当前不能按列表项分别绑定。
53
55
  - 标签页、`v-if` 等正常布局可以保留。编辑该控件时先让它显示出来,并确保同一定位只出现一个元素。绑定提示会随元素重建恢复。
@@ -57,13 +59,22 @@
57
59
 
58
60
  - 数值:`min`、`max`、`step` 用固定数字,也可用 `:min="0"`、`:max="100"`、`:step="1"`。不能绑定到动态字段或表达式。`v-slider` 要求 min < max、step > 0,不支持 `step="any"`。
59
61
  - 原生 range 默认 0–100;原生 meter/progress 默认 0–1。原生 number 没有缺省完整量程;需要自动缩放时明确写 min/max。不要把原生缺省量程误当成设备量程。
62
+ - 原生 `meter`/`progress` 可使用标准 `:value="meter1"`,也可使用简单 `v-model="meter1"` 作为只读别名;节点会在编译前将后者转换为 `:value`。不支持 `v-model.number`、动态表达式或同时写 `:value`。只有驱动点位带有 Meter 元数据时,`meter`/`progress` 才能建立绑定;它由独立的 Meter 批量帧通道更新,不能接收网页设置。音量滑杆的 `volume` 与 Meter 的 `meter` 必须是两个不同字段,不能把同一个字段绑定到两个不同设备点位。普通 volume/level 反馈不能选择为 Meter。Vuetify `progress` 组件仍使用简单 `:model-value`;未绑定的进度条可以作为普通前端视觉元素。
60
63
  - Vuetify switch/checkbox 不支持 `value`、自定义 `true-value`/`false-value`、`multiple` 或数组模型。
61
64
  - Vuetify select 不支持多选、`return-object`、`clearable` 或对象模型;选项可用标量数组,或普通 `{ title, value }` 选项但模型必须是标量。`v-radio-group` 使用标量模型,子项也不能自带事件处理器。
62
65
  - Vuetify 文本控件只用字符串模型。`v-text-field` 使用文本类型,不用数字、密码等类型;不支持 `.number`。`v-textarea` 的 Enter 是换行,不是提交。
63
66
  - `v-progress-linear` 从 0 到固定正数 max,默认 max=100;不支持 min、step、indeterminate、clickable。`v-progress-circular` 固定 0–100,不声明 min/max/step,也不使用 indeterminate。
64
67
  - 文本展示推荐只放一个简单插值,如 `<span>{{ status }}</span>`,不使用 `status.toFixed()`、拼接表达式或多个插值。复杂展示可用另外的纯前端元素,不把它作为设备反馈绑定目标。
65
68
 
66
- ### 3.3 普通按钮与开关按钮
69
+ ### 3.3 Meter 实时信号与批量反馈
70
+
71
+ 页面作者不编写订阅、socket 或批处理代码,但要用不同字段表达不同语义:音量/增益使用 `volume` 等普通可写字段;实时信号使用 `meter`、`meter1` 等只读字段。部署人员在绑定编辑页选择 Feedback point;只有驱动明确声明 Meter 元数据的点位才会进入 Meter 列表,ControlPanel 会在运行时自动采用批量反馈通道,并把同一帧的数据一次性更新到本页对应的所有表头。
72
+
73
+ 页面不需要也不得声明设备、驱动、stream、socket、帧序号、点位路径或 ControlPanel API。断线、过期帧、重连、同帧合并和页面量程换算都由节点处理。反馈短暂失效时节点保留最后显示值并标记无效;页面作者不添加轮询、定时器或回写逻辑。标签页、`v-if` 等使控件重新创建后,节点会重新关联控件。
74
+
75
+ 多个表头必须使用不同的 `id` 和不同的顶层字段。不要让属于不同设备点位的表头共用同一个字段;同一个滑杆及其纯视觉旁显可以共享字段,但只能绑定其中一个控件。Meter 与 volume 是完全独立的通信类型:volume 负责音量/增益设置和普通真实值反馈,Meter 只负责某音频通道的高频实时信号电平反馈。代码或驱动中的历史 `level` 名称不自动表示 Meter;是否具备 Meter 能力由点位的 Meter 元数据决定。
76
+
77
+ ### 3.4 普通按钮与开关按钮
67
78
 
68
79
  一个只有文字的普通 `<button>` 不会自动成为设备控件。需要布尔设备开关时使用标准语义:
69
80
 
@@ -90,12 +101,14 @@
90
101
  <option value="B">输入 B</option>
91
102
  </select>
92
103
  <span id="room-status">{{ status }}</span>
104
+ <meter id="room-meter" :value="meter" min="0" max="100" aria-label="实时信号电平"></meter>
105
+ <progress id="room-progress" :value="progress" max="100" aria-label="进度"></progress>
93
106
  </section>
94
107
  </template>
95
108
  <script>
96
109
  export default {
97
110
  data () {
98
- return { volume: 0, muted: false, powered: false, source: 'A', status: '待机' }
111
+ return { volume: 0, meter: 0, muted: false, powered: false, source: 'A', status: '待机', progress: 0 }
99
112
  }
100
113
  }
101
114
  </script>
@@ -114,7 +127,7 @@ export default {
114
127
  <v-radio label="模式 B" value="B"></v-radio>
115
128
  </v-radio-group>
116
129
  <v-text-field id="room-title" label="名称" v-model="title"></v-text-field>
117
- <v-progress-linear id="room-level" :model-value="level" :max="100"></v-progress-linear>
130
+ <v-progress-linear id="room-meter" :model-value="meter" :max="100"></v-progress-linear>
118
131
  <span id="room-status">{{ status }}</span>
119
132
  </section>
120
133
  </template>
@@ -123,7 +136,7 @@ export default {
123
136
  data () {
124
137
  return {
125
138
  volume: 0, muted: false, source: 'A', sources: ['A', 'B'],
126
- mode: 'A', title: '', level: 0, status: '待机'
139
+ mode: 'A', title: '', meter: 0, status: '待机'
127
140
  }
128
141
  }
129
142
  }
@@ -139,12 +152,12 @@ export default {
139
152
  1. 将页面粘贴到 `ui-control-panel` Template,选择 Dashboard 页面,设置 Room ID、普通账号及独立的编辑账号。普通账号密码至少 12 个字符;账号密码由节点配置,不能写入 H5。保存并 Deploy。
140
153
  2. 重新打开节点,点击 `Open binding editor`,用编辑账号登录。Select 默认激活,可以点击、右键或触屏长按可绑定控件。`Open page` 打开普通控制页面,使用普通用户账号登录。
141
154
  3. 选择在线设备、模式、Write point、Feedback point、编号方式,保存草稿。草稿和亮紫色边框不表示已部署;边框只在编辑模式显示。
142
- 4. 点 Apply。节点检查当前模板、目录参数和 Flow 版本,备份后替换该面板此前生成的桥接并部署。不会无限追加旧桥接,也不会清除其他面板的绑定。Apply 成功后仍在编辑页,无需再次登录。
155
+ 4. 点 Apply。节点检查当前模板、目录参数和 Flow 版本,备份后替换该面板此前生成的桥接并保存。Apply 不替用户 Deploy;当 Automator 报告外部 Flow 变化时,编辑器只刷新一次 Flow 页面,之后由用户检查并手动 Deploy。不会无限追加旧桥接,也不会清除其他面板的绑定。Apply 成功后仍在编辑页,无需再次登录。
143
156
  5. 点 Exit,回到普通页面登录页,再输入普通账号密码。普通账号不能进入编辑模式;不登录不能操作设备。进程重启或凭据变化可能要求重新登录。
144
157
 
145
158
  Bindings 列表可 Edit 或 Remove;这些操作先改草稿,最后统一 Apply。从列表进入 Edit,保存或取消后返回列表。刷新、离开或重启前先 Apply,避免草稿丢失。
146
159
 
147
- 普通 Deploy 不能替代网页 Apply。Template 改动可能使已保存控件失效;重新 Deploy 后检查绑定。Apply 提示 Flow/Template changed 时重新载入编辑页后再检查,不能靠重复点击覆盖他人的部署。
160
+ 普通 Deploy 不能替代网页 Apply。Template 改动可能使已保存控件失效;重新 Deploy 后检查绑定。Apply 的原有模板、目录、Flow 版本和输入校验保持不变;Flow 页面刷新不自动合并画布改动,也不自动 Deploy。
148
161
 
149
162
  设备必须在线且当前运行环境的 AMX 目录可返回参数,才能查询或重新 Apply 相关绑定。已保存映射在 Flow 中,启动和显示页面不要求驱动在线;但断线时不能保证设备控制或新反馈。Mac 安装模块不等于 MUSE 已安装,Push 也不等于复制本地 node_modules。部署模块属于运行环境操作,不属于前端开发职责。
150
163
 
@@ -155,7 +168,9 @@ Bindings 列表可 Edit 或 Remove;这些操作先改草稿,最后统一 App
155
168
  | 参数描述 | 可匹配控件 |
156
169
  | --- | --- |
157
170
  | boolean,或无枚举且范围恰为 0–1 的 integer | 布尔开关;整数 0/1 由节点转换为前端布尔值。 |
158
- | integer/float 数值参数 | range、数值进度;有枚举或 0/1 布尔语义时优先对应相应类别。 |
171
+ | 可写 integer/float 数值参数 | range;这类参数用于 volume/增益等普通连续设置。 |
172
+ | 只读 integer/float,且没有 Meter 元数据 | 只读文本展示;不能选择为 Meter。 |
173
+ | 只读 integer/float,且带 `.metadata.meter` | Meter、原生 `meter` 或 Vuetify 进度组件;只走实时 Meter 反馈。 |
159
174
  | enum,或带枚举描述的 integer/string | 单选枚举。当前整数路径以 `/source`、`/preset` 结尾也会按选择类处理;仍需确认合法选项。 |
160
175
  | string/dstring | 文本输入。 |
161
176
  | 上述类型的可读参数 | 简单文本展示也可绑定。 |
@@ -173,6 +188,7 @@ Write point 改动时 Feedback point 跟随;单独改 Feedback point 不反向
173
188
  | 控件不能选中 | 是否在支持清单、是否有事件指令/复杂模型/v-for、自闭合 Vuetify、重复 ID;阅读编辑页给出的拒绝原因。 |
174
189
  | 有设备但点位为空 | 在线状态、参数目录、值类型与 ro/wo/rw 权限;不是所有设备命令都属于可绑定参数。 |
175
190
  | 草稿有紫框但普通页无效 | Apply 是否成功、模式和点位是否正确、桥接是否同步、MUSE 是否加载预期模块版本。 |
191
+ | 页面一直 Loading | 先检查节点状态和运行日志中的模板编译错误,例如在原生 meter/progress 上使用带修饰符的 v-model、动态表达式或同时写 v-model 与 :value。简单顶层 v-model 会由节点转换为只读 :value;节点会显示具体模板错误。会话检查包含响应正文的超时保护,设备离线不应阻止普通页进入登录页。登录检查的时限不包含 Dashboard 资源下载和组件编译时间。 |
176
192
  | 滑杆动不了 | 是否仍在 Select 编辑模式、模型类型是否正确、是否存在手写回写逻辑;不要先改驱动。 |
177
193
  | 页面变化但设备不变 | 写映射和驱动收发日志。前端变化不证明命令到达设备。 |
178
194
  | 设备已变但页面旧值 | Feedback point、事件/读取是否有新反馈。节点不能凭写入成功制造真实设备反馈。 |
@@ -186,6 +202,7 @@ Write point 改动时 Feedback point 跟随;单独改 Feedback point 不反向
186
202
  - 所有待绑定控件位于支持清单,使用稳定唯一 ID;数据模型简单、独立且类型正确。
187
203
  - 页面量程明确、数值常量有效;下拉菜单选项与实际设备值对应。
188
204
  - 没有手写 `data-cp-*`、旧发送接口、设备通信、绑定控件事件或复杂模型。
205
+ - 实时表头仍是普通 HTML/Vue/Vuetify;没有 stream、socket、订阅、帧协议或驱动相关代码。
189
206
  - Vuetify 标签成对闭合;不把自动绑定目标放入 v-for。
190
207
  - 保留正常的视觉与纯前端交互;不为了绑定重写驱动或添加第二套控制逻辑。
191
208
  - 不声称任意普通页面都可无条件自动绑定;不支持的组件要明确说明,不能伪造兼容写法。
package/README.md CHANGED
@@ -12,11 +12,13 @@ Bindings are stored in `visualBindings` on the node. The runtime reads that mapp
12
12
 
13
13
  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.
14
14
 
15
+ Realtime meter delivery does not change the page-authoring contract. Authors use ordinary `meter`, `progress`, `v-progress-linear`, or `v-progress-circular` controls with stable IDs and simple top-level fields. They do not declare devices, point paths, stream IDs, sockets, subscriptions, or frame formats. A `volume` binding is a writable gain setting with ordinary feedback; a `meter` binding is read-only realtime signal telemetry and requires a target that advertises Meter metadata. A normal numeric `level` target is not automatically a Meter. When the selected feedback point advertises realtime meter delivery, ControlPanel automatically shares the device stream, projects complete frames to the bound controls, applies webpage-side range conversion, and handles stale/reconnected data.
16
+
15
17
  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.
16
18
 
17
19
  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.
18
20
 
19
- 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.
21
+ After clicking Apply in the page binding editor, the package validates the mapping, backs up the active Flow, and generates the native bridge for that panel using the original 1.8.9 flow. Apply does not click Deploy for the user. When Automator reports the external Flow change, the editor refreshes its Flow view once; review the refreshed Flow and deploy it yourself. 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.
20
22
 
21
23
  The current runtime has one panel-scoped control path. The retired topic-based region gateway, fixed demo scenes, hand-written control tags, and `sendControl` authoring API are not supported. The page editor and server use the same target compatibility rules.
22
24
 
@@ -66,7 +66,7 @@ function resolveBoundFeedback (routes, message) {
66
66
  const matches = []
67
67
  for (const [id, route] of routes) {
68
68
  if (route.control.mode === 'write' || route.binding?.device !== message.device ||
69
- route.binding.readPath !== message.path) continue
69
+ route.binding.readPath !== message.path || route.binding.readMeta?.meter) continue
70
70
  const type = route.control.type
71
71
  const metadata = route.binding.readMeta
72
72
  let value = message.payload
@@ -1,4 +1,6 @@
1
1
  const { schemaVersion } = require('./control-type-contract.json')
2
+ const { runtimeJsonRequest } = require('./panel-http')
3
+ const { meterRoute } = require('./meter-route')
2
4
 
3
5
  function catalogBase (settings) {
4
6
  const port = Number(settings?.uiPort)
@@ -15,15 +17,16 @@ function catalogBase (settings) {
15
17
  }
16
18
 
17
19
  async function catalogRequest (base, path, device, fetcher = fetch) {
18
- const options = { signal: AbortSignal.timeout(5000) }
20
+ const options = {}
19
21
  if (device !== undefined) {
20
22
  options.method = 'POST'
21
23
  options.headers = { 'content-type': 'application/json' }
22
24
  options.body = JSON.stringify([device])
23
25
  }
24
- const response = await fetcher(new URL(path, base), options)
26
+ const { response, data } = await runtimeJsonRequest(new URL(path, base), options, {
27
+ fetcher, timeoutMs: 15000, operation: `Device catalog ${path}${device ? ' (' + device + ')' : ''}`
28
+ })
25
29
  if (!response.ok) throw new Error(`Automator catalog HTTP ${response.status}`)
26
- const data = await response.json()
27
30
  if (data.code !== 0 || !Array.isArray(data.value)) throw new Error(data.reason || 'Invalid Automator catalog response')
28
31
  return data.value
29
32
  }
@@ -71,6 +74,7 @@ async function listCatalogTargets (base, device, fetcher = fetch) {
71
74
  const min = catalogBound(properties['.min'])
72
75
  const max = catalogBound(properties['.max'])
73
76
  const enums = properties['.enums']
77
+ const meter = properties['.metadata']?.meter ? meterRoute(properties['.metadata'].meter) : null
74
78
  return {
75
79
  path: item.path,
76
80
  kind: properties['.kind'] || '',
@@ -79,9 +83,10 @@ async function listCatalogTargets (base, device, fetcher = fetch) {
79
83
  onebased: typeof properties['.onebased'] === 'boolean' ? properties['.onebased']
80
84
  : typeof item.onebased === 'boolean' ? item.onebased : undefined,
81
85
  min, max, enums,
82
- ...inferredControl(item.path, properties, type, min, max, enums),
86
+ ...(meter ? { controlType: 'meter' } : inferredControl(item.path, properties, type, min, max, enums)),
83
87
  controlSchemaVersion: schemaVersion,
84
- unit: properties['.metadata']?.unit || ''
88
+ unit: properties['.metadata']?.unit || '',
89
+ ...(meter ? { meter } : {})
85
90
  }
86
91
  })
87
92
  const unique = new Map()
@@ -338,6 +338,11 @@
338
338
  }
339
339
  this.editor && this.editor.resize()
340
340
  }
341
+ if (RED.comms?.subscribe) {
342
+ $.ajax({ url: 'ui-control-panel/editor-sync.js', dataType: 'script', cache: false })
343
+ .done(() => window.ControlPanelFlowEditorSync.install(RED, $, window))
344
+ .fail(() => RED.notify('ControlPanel automatic Flow sync could not be loaded.', 'warning'))
345
+ }
341
346
  })()
342
347
  </script>
343
348
 
@@ -354,7 +359,7 @@
354
359
  <p>普通 HTML/Vue 控件由页面绑定编辑器自动识别,并通过面板的 authenticated session 发送控制。开发者不需要添加标签、调用自定义节点 API,或在 Template 中实现 throttling、latest-wins、队列和设备协议。</p>
355
360
  <p>页面只需按普通 H5/Vue 方式编写控件;编辑模式下在控件上打开绑定菜单,选择设备参数即可。登录、发送、反馈和状态同步由节点负责,Template 不应主动查询设备或把反馈回发成命令。</p>
356
361
  <p>See <code>CONTROL_PANEL_AI_GUIDE.md</code> in the node package for the complete AI authoring contract and examples.</p>
357
- <p>Save and Deploy the panel, then use Open binding editor. Apply in that page validates and saves bindings, backs up the Flow, regenerates the native bridge, and deploys it. Ordinary Deploy alone does not apply bindings. Check targets and numbering before using real hardware.</p>
362
+ <p>Save and Deploy the panel, then use Open binding editor. Apply in that page validates and saves bindings, backs up the Flow, and regenerates the native bridge. When Node-RED reports a newer external Flow revision, the Automator editor page reloads once; the panel never imports or deploys Flow data. Ordinary Deploy alone does not apply bindings. Check targets and numbering before using real hardware.</p>
358
363
  </script>
359
364
 
360
365
  <script type="text/html" data-template-name="ui-control-panel">
package/control-panel.js CHANGED
@@ -10,6 +10,7 @@ const { createPanelEditor } = require('./panel-editor-auth')
10
10
  const { applyVisualBindings, editorConfig, getSnapshot } = require('./panel-visual-apply')
11
11
  const { createGateway } = require('./gateway.cjs')
12
12
  const { managedBridgeRevision } = require('./managed-bridge-revision')
13
+ const { createMeterRoom, createPanelMeter } = require('./panel-meter')
13
14
 
14
15
  let statestore
15
16
  const authHandoffKey = crypto.randomBytes(32)
@@ -69,6 +70,11 @@ module.exports = function (RED) {
69
70
  const base = catalogBase(RED.settings)
70
71
  const activePanels = new Map()
71
72
  const activeEditors = new Map()
73
+ const meterRooms = new Map()
74
+ RED.httpAdmin.get('/ui-control-panel/editor-sync.js', RED.auth.needsPermission('nodes.read'), (_req, res) => {
75
+ res.setHeader('Cache-Control', 'no-store')
76
+ res.sendFile(path.join(__dirname, 'panel-flow-editor-sync.js'))
77
+ })
72
78
  RED.httpNode.use((req, res, next) => {
73
79
  const path = new URL(req.url, 'http://localhost').pathname
74
80
  if (path === '/control-panel-assets/panel-login-bg.png') {
@@ -143,6 +149,16 @@ module.exports = function (RED) {
143
149
  }
144
150
  node.status?.(bridgeReady ? {} : { fill: 'red', shape: 'ring', text: 'bridge out of sync' })
145
151
 
152
+ let meter
153
+ if ([...routes.values()].some(route => route.binding.readMeta?.meter) && /^[a-zA-Z0-9_-]+$/.test(boundRoom || '')) {
154
+ try {
155
+ let hub = meterRooms.get(boundRoom)
156
+ if (!hub) { hub = createMeterRoom({ room: boundRoom }); meterRooms.set(boundRoom, hub) }
157
+ meter = createPanelMeter({ panelId: node.id, room: boundRoom, bindings: visualBindings, hub,
158
+ onSubscribe: () => { if (bridgeReady) node.send({ kind: 'read-all', room: boundRoom }) } })
159
+ } catch (error) { node.error(error) }
160
+ }
161
+
146
162
  let gateway
147
163
  let gatewayAuthKey
148
164
  if (visualBinding && /^[a-zA-Z0-9_-]+$/.test(boundRoom || '')) {
@@ -156,6 +172,8 @@ module.exports = function (RED) {
156
172
  prefix: endpoint,
157
173
  secureCookie: config.secureCookie === true,
158
174
  restoredSessions: takeSessions(gatewayHandoffs, node.id, gatewayAuthKey),
175
+ onAuthenticatedRequest: (req, res, identity) => meter?.ticket(req, res, identity,
176
+ () => gateway?.sessionActive(identity.token)) || false,
159
177
  onBoundCommand: (message, identity) => {
160
178
  if (!bridgeReady) return false
161
179
  node.send({ ...resolveBoundCommand(routes, message), room: identity.room, user: identity.user })
@@ -184,6 +202,8 @@ module.exports = function (RED) {
184
202
  password: node.credentials?.editorPassword,
185
203
  secureCookie: config.secureCookie === true,
186
204
  restoredSessions: takeSessions(editorHandoffs, node.id, editorAuthKey),
205
+ onAuthenticatedRequest: (req, res, identity) => meter?.ticket(req, res, identity,
206
+ () => editor?.sessionActive(identity.token)) || false,
187
207
  getConfig: async req => editorConfig(await getSnapshot(req.socket.localPort, fetch,
188
208
  RED.settings.httpAdminRoot ?? '/'), node.id),
189
209
  getDevices: async () => {
@@ -194,23 +214,23 @@ module.exports = function (RED) {
194
214
  if (!base) throw new Error('Device catalog unavailable')
195
215
  return listCatalogTargets(base, device)
196
216
  },
197
- apply: async (req, body) => applyVisualBindings({
198
- panelId: node.id,
199
- rev: body.rev,
200
- templateHash: body.templateHash,
201
- entries: body.entries,
202
- userDir: RED.settings.userDir,
203
- localPort: req.socket.localPort,
204
- adminRoot: RED.settings.httpAdminRoot ?? '/',
205
- catalogBase: base
206
- })
217
+ apply: (req, body) => applyVisualBindings({
218
+ panelId: node.id,
219
+ rev: body.rev,
220
+ templateHash: body.templateHash,
221
+ entries: body.entries,
222
+ userDir: RED.settings.userDir,
223
+ localPort: req.socket.localPort,
224
+ adminRoot: RED.settings.httpAdminRoot ?? '/',
225
+ catalogBase: base
226
+ })
207
227
  })
208
228
  if (editor) activeEditors.set(node.id, editor)
209
229
  else node.warn?.('Visual binding editor needs an editor username and password')
210
230
  }
211
231
 
212
232
  const panelConfig = () => (boundMode || visualBinding) ? {
213
- endpoint, room: boundRoom,
233
+ endpoint, room: boundRoom, meter: Boolean(meter),
214
234
  ...(visualBinding ? { visual: {
215
235
  panelId: node.id,
216
236
  editorEndpoint: `/control-panel-edit/panels/${node.id}`,
@@ -221,8 +241,15 @@ module.exports = function (RED) {
221
241
 
222
242
  const events = {
223
243
  onAction: !boundMode,
244
+ ...(meter ? { onSocket: meter.hooks } : {}),
224
245
  beforeSend (msg) {
225
246
  if (msg?._controlPanel?.kind === 'command') throw new Error('Bound commands require authenticated HTTP')
247
+ if (['device-meter-frame', 'device-meter-manifest'].includes(msg?.kind)) {
248
+ if (bridgeReady) meter?.accept(msg)
249
+ // Dashboard 1.31 reads properties before checking whether a message
250
+ // should be emitted. `false` is property-safe and suppresses emit.
251
+ return false
252
+ }
226
253
  if (boundMode) {
227
254
  if (msg?.kind === 'device-feedback' && gateway) {
228
255
  gateway.boundFeedback(resolveBoundFeedback(routes, msg))
@@ -275,7 +302,13 @@ module.exports = function (RED) {
275
302
  } catch (error) {
276
303
  node.error(error)
277
304
  node.status?.({ fill: 'red', shape: 'ring', text: 'visual template unsupported' })
278
- return
305
+ const message = String(error.message).replace(/[&<>]/g,
306
+ char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[char])
307
+ widgetConfig = { ...config, type: 'ui-template',
308
+ format: '<template><section role="alert" style="padding:24px;color:#a52a35;background:#fff">' +
309
+ '<h2>ControlPanel template error</h2><pre v-pre style="white-space:pre-wrap;overflow-wrap:anywhere">' +
310
+ message + '</pre></section></template>',
311
+ passthru: false, storeOutMessages: false, resendOnRefresh: false }
279
312
  }
280
313
  parentNode = group || page || ui
281
314
  if (group) group.register(node, widgetConfig, events)
@@ -291,6 +324,8 @@ module.exports = function (RED) {
291
324
  if (editor) holdSessions(editorHandoffs, node.id, editorAuthKey, editor.sessionSnapshot())
292
325
  gateway?.close()
293
326
  editor?.close()
327
+ meter?.close()
328
+ if (meterRooms.get(boundRoom)?.stats().panels === 0) meterRooms.delete(boundRoom)
294
329
  if (activePanels.get(node.id) === gateway) activePanels.delete(node.id)
295
330
  if (activeEditors.get(node.id) === editor) activeEditors.delete(node.id)
296
331
  })
package/control-policy.js CHANGED
@@ -3,6 +3,8 @@ const escodegen = require('escodegen')
3
3
  const { initializeVisualBinding, disposeVisualBinding } = require('./panel-binding-editor')
4
4
  const { fetchWithTimeout } = require('./panel-http')
5
5
  const { createTargetMatcher } = require('./control-type-model')
6
+ const { createRoomMeterReceiver, createMeterRenderer } = require('./meter-frame')
7
+ const { createPanelMeterClient } = require('./panel-meter-client')
6
8
 
7
9
  const CONTROL_POLICY_MARKER = '/* ui-control-panel-control-api-v1 */'
8
10
 
@@ -157,6 +159,11 @@ function createControlPanelPolicy ({
157
159
  // generated script remains inside that supported subset.
158
160
  function buildDashboardPolicyOptionsSource (controls = [], boundPanel = null) {
159
161
  const boundControls = controls.map(({ id, type, mode }) => ({ id, type, mode }))
162
+ const meterMethods = boundPanel?.meter
163
+ ? `__createRoomMeterReceiver: ${createRoomMeterReceiver.toString()},
164
+ __createMeterRenderer: ${createMeterRenderer.toString()},
165
+ __createPanelMeterClient: ${createPanelMeterClient.toString()},`
166
+ : ''
160
167
  const visualMethods = boundPanel?.visual
161
168
  ? `__createTargetMatcher: ${createTargetMatcher.toString()},
162
169
  __initializeVisualBinding: ${initializeVisualBinding.toString()},
@@ -180,6 +187,7 @@ export default {
180
187
  }
181
188
  },
182
189
  methods: {
190
+ ${meterMethods}
183
191
  ${visualMethods}
184
192
  __panelFetch: ${fetchWithTimeout.toString()},
185
193
  __boundPanelConfig () {
@@ -304,6 +312,7 @@ export default {
304
312
  }
305
313
  },
306
314
  __disposeControlPanelPolicy () {
315
+ this.__boundAuthGeneration++
307
316
  this.__boundUnwatch?.()
308
317
  this.__boundUnwatch = null
309
318
  this.__clearAllBoundSettlements()
@@ -381,28 +390,28 @@ export default {
381
390
  if (!config) throw new Error('This panel has no bound login endpoint')
382
391
  this.clearBoundPanelSession()
383
392
  const generation = this.__boundAuthGeneration
384
- const response = await this.__panelFetch(config.endpoint + '/login', {
393
+ const { response, session } = await this.__panelFetch(config.endpoint + '/login', {
385
394
  method: 'POST', credentials: 'same-origin',
386
395
  headers: { 'Content-Type': 'application/json' },
387
396
  body: JSON.stringify({ username, password })
388
- })
397
+ }, 12000, async response => ({ response, session: response.ok ? await response.json() : null }))
389
398
  if (!response.ok) throw new Error('Panel login failed (' + response.status + ')')
390
- const session = await response.json()
391
399
  if (generation !== this.__boundAuthGeneration) return null
392
400
  this.setBoundPanelSession(session)
393
401
  return session
394
402
  },
395
- async restoreBoundPanelSession () {
403
+ async restoreBoundPanelSession (timeoutMs = 2500) {
396
404
  const config = this.__boundPanelConfig()
397
405
  if (!config) return null
398
406
  const generation = this.__boundAuthGeneration
399
- const response = await this.__panelFetch(config.endpoint + '/session', { credentials: 'same-origin' })
407
+ const { response, session } = await this.__panelFetch(config.endpoint + '/session', {
408
+ credentials: 'same-origin'
409
+ }, timeoutMs, async response => ({ response, session: response.ok ? await response.json() : null }))
400
410
  if (generation !== this.__boundAuthGeneration) return this.__boundPanelSession
401
411
  if (!response.ok) {
402
412
  this.clearBoundPanelSession()
403
413
  return null
404
414
  }
405
- const session = await response.json()
406
415
  if (generation !== this.__boundAuthGeneration) return this.__boundPanelSession
407
416
  this.setBoundPanelSession(session)
408
417
  return session
@@ -456,7 +465,7 @@ export default {
456
465
  },
457
466
  mounted () {
458
467
  this.__initializeControlPanelPolicy()
459
- if (this.__boundPanelConfig()) this.restoreBoundPanelSession().catch(() => undefined)
468
+ ${boundPanel?.visual ? '' : 'if (this.__boundPanelConfig()) this.restoreBoundPanelSession().catch(() => undefined)'}
460
469
  ${visualMounted}
461
470
  },
462
471
  unmounted () {
@@ -4,6 +4,7 @@
4
4
  "slider": { "family": "number", "delivery": "continuous" },
5
5
  "level": { "family": "number", "delivery": "continuous" },
6
6
  "volume": { "family": "number", "delivery": "continuous" },
7
+ "meter": { "family": "number", "delivery": "realtime", "access": "ro" },
7
8
  "dimmer": { "family": "number", "delivery": "continuous" },
8
9
  "position": { "family": "number", "delivery": "continuous" },
9
10
  "toggle": { "family": "boolean", "delivery": "discrete" },
@@ -1,4 +1,5 @@
1
1
  const contract = require('./control-type-contract.json')
2
+ const { meterRoute } = require('./meter-route')
2
3
 
3
4
 
4
5
 
@@ -22,6 +23,8 @@ function createTargetMatcher (typeContract) {
22
23
  return definition.family
23
24
  }
24
25
  function compatible (adapter, target, direction, candidate = {}) {
26
+ if (adapter === 'meter' && !target.meter) return false
27
+ if (target.meter && (adapter !== 'meter' || direction !== 'read' || target.access !== 'ro')) return false
25
28
  if (target.kind !== 'param' || !families[adapter]?.includes(family(target))) return false
26
29
  if (direction === 'write' && ['meter', 'text-display'].includes(adapter)) return false
27
30
  if (!['read', 'write'].includes(direction)) return false
@@ -38,6 +41,7 @@ function controlTypeForAdapter (adapter, target) {
38
41
  if (!compatibleTarget(adapter, target, 'read') && !compatibleTarget(adapter, target, 'write')) {
39
42
  throw new Error('Target is incompatible with this control')
40
43
  }
44
+ if (adapter === 'meter' && target.meter) return 'meter'
41
45
  const family = targetFamily(target)
42
46
  if (family === 'number') return 'level'
43
47
  if (family === 'boolean') return 'mute'
@@ -55,7 +59,8 @@ function targetMeta (target, onebased) {
55
59
  onebased: typeof target.onebased === 'boolean' ? target.onebased : onebased,
56
60
  min: target.min, max: target.max, enums: target.enums,
57
61
  ...(target.booleanEncoding ? { booleanEncoding: target.booleanEncoding } : {}),
58
- controlType: target.controlType, controlSchemaVersion: target.controlSchemaVersion
62
+ controlType: target.controlType, controlSchemaVersion: target.controlSchemaVersion,
63
+ ...(target.meter ? { meter: meterRoute(target.meter), unit: target.unit || '' } : {})
59
64
  }
60
65
  }
61
66
 
package/gateway.cjs CHANGED
@@ -9,6 +9,7 @@ function createGateway ({
9
9
  room = 'room-01',
10
10
  onBoundCommand,
11
11
  onBoundLogin,
12
+ onAuthenticatedRequest,
12
13
  secureCookie = false,
13
14
  prefix,
14
15
  sessionTtlMs = 8 * 3600000,
@@ -193,6 +194,8 @@ function createGateway ({
193
194
  reply(res, 403, { error: 'CSRF rejected' })
194
195
  return true
195
196
  }
197
+ if (onAuthenticatedRequest && await onAuthenticatedRequest(req, res,
198
+ { token, expiry: session.expiry, room, user: username })) return true
196
199
  if (path === routePrefix + '/session' && req.method === 'GET') {
197
200
  reply(res, 200, { room, username, csrf: session.csrf })
198
201
  return true
@@ -255,6 +258,7 @@ function createGateway ({
255
258
  return {
256
259
  handler,
257
260
  boundFeedback,
261
+ sessionActive: token => !closed && (sessions.get(token)?.expiry || 0) > Date.now(),
258
262
  sessionSnapshot () {
259
263
  return new Map([...sessions].filter(([, session]) => session.expiry > Date.now())
260
264
  .map(([token, session]) => [token, { ...session }]))
@@ -29,4 +29,4 @@ function managedBridgeRevision (panel) {
29
29
  return `v1:${crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex')}`
30
30
  }
31
31
 
32
- module.exports = { managedBridgeRevision }
32
+ module.exports = { managedBridgeRevision, canonical }
@@ -24,7 +24,8 @@ function panelTargets (panel) {
24
24
  const target = {
25
25
  device: binding.device, path, kind: meta.kind, type: meta.type,
26
26
  access: meta.access, onebased: meta.onebased,
27
- ...(meta.booleanEncoding ? { booleanEncoding: meta.booleanEncoding } : {})
27
+ ...(meta.booleanEncoding ? { booleanEncoding: meta.booleanEncoding } : {}),
28
+ ...(meta.meter ? { meter: meta.meter } : {})
28
29
  }
29
30
  const key = JSON.stringify([target.device, target.path])
30
31
  const previous = targets.get(key)