node-red-contrib-room-tools 1.9.4 → 1.9.5
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 +54 -16
- package/README.md +19 -3
- package/control-bound-protocol.js +23 -6
- package/control-catalog.js +26 -3
- package/control-panel.js +29 -3
- package/control-policy.js +34 -5
- package/control-type-model.js +68 -8
- package/device-availability.js +104 -0
- package/gateway.cjs +13 -1
- package/managed-native-bridge.js +3 -1
- package/meter-frame.js +7 -1
- package/native-binding-plan.js +47 -10
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/panel-binding-editor.js +208 -30
- package/panel-momentary.js +7 -1
- package/panel-template-instrumentation.js +1 -0
- package/panel-visual-apply.js +86 -27
- package/panel-visual-bindings.js +18 -4
- package/xy-series-frame.js +7 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# ControlPanel 前端页面编写指南
|
|
2
2
|
|
|
3
|
-
适用基线:当前统一工程中的 `node-red-contrib-room-tools@1.9.
|
|
3
|
+
适用基线:当前统一工程中的 `node-red-contrib-room-tools@1.9.4` 源码、Dashboard 2(已核对 `1.31.0`)和 Vue Options API。文档基线不代表该源码已发布或已安装到每台主机;本轮设备不可用处理尚未测试、发布。支持范围以当前识别器和运行时代码为准,不代表完整 Vue/Vuetify 语法都可自动绑定。
|
|
4
4
|
|
|
5
5
|
本文件是给页面编写 AI 的实现契约,不是完整的 Vue 或 Vuetify 手册。目标是:页面作者只写普通 HTML、CSS 和 Vue 页面;设备、驱动、映射表、反馈订阅和通信细节全部由 `ControlPanel` 节点处理。
|
|
6
6
|
|
|
@@ -23,15 +23,34 @@
|
|
|
23
23
|
|
|
24
24
|
不要在页面里实现第二套设备通信。不要写设备轮询、定时回写滑杆、反馈回写命令、重复发送监听器或自定义发送函数。未绑定的控件不会自动控制设备。
|
|
25
25
|
|
|
26
|
+
### 设备不可用时的页面行为
|
|
27
|
+
|
|
28
|
+
设备连接、有效回复超时、重新连接和新反馈就绪由公共驱动框架与节点处理,
|
|
29
|
+
页面 AI 不写掉线判断、轮询、禁用绑定或恢复逻辑。驱动已声明物理设备健康
|
|
30
|
+
语义、绑定已重新 Apply 后,节点自动把对应设备的绑定控件灰掉并禁止操作,
|
|
31
|
+
冻结最后值,不弹出提示;重新上线、就绪并收到新反馈后自动恢复。
|
|
32
|
+
同一页面其他正常设备不受影响。绑定编辑模式仍能选中不可用控件。
|
|
33
|
+
|
|
34
|
+
按钮灰掉期间仍保留最后的真实反馈状态;灰色不等于“关闭”或“取消静音”,
|
|
35
|
+
不能改写布尔模型。节点同时在服务端拒绝不可用设备的操作,不只是改变 CSS。
|
|
36
|
+
页面自身的业务 disabled 状态在设备恢复后仍保留;不要自行去掉节点施加的
|
|
37
|
+
禁用状态。没有健康声明的旧驱动/旧绑定不会凭名称猜测掉线;框架升级后由
|
|
38
|
+
驱动与绑定维护者完成接入,这不是页面作者的额外任务。
|
|
39
|
+
|
|
40
|
+
实时 Meter/XY-series 还检查有效采样与过期,不把连上 socket 当作新采样。
|
|
41
|
+
有健康声明且设备不可用时保留最后显示值;普通 XY-series 采样无效/过期但
|
|
42
|
+
设备仍可用时,继续沿用空数组语义。掉线识别存在协议相关的检测窗口,
|
|
43
|
+
不是拔电即刻必然通知网页。纯页面导航或未绑定按钮不因设备掉线而禁用。
|
|
44
|
+
|
|
26
45
|
页面里的 `volume` 和 `meter` 是两个不同语义:
|
|
27
46
|
|
|
28
47
|
- `volume` 等普通数值字段用于音量/增益等可写连续控制,并可接收普通真实值反馈。
|
|
29
|
-
- `meter`
|
|
48
|
+
- `meter` 等字段用于只读数值指示,可接收原生只读连续数值参数,或带 Meter 流元数据的实时点位;不能用来设置音量。
|
|
30
49
|
- `series`、`spectrum` 等数组字段用于只读实时二维点列,只能接收驱动声明了 XY-series 元数据的点位。
|
|
31
50
|
|
|
32
51
|
不要把同一个模型字段同时用于不同设备点位,尤其不要让 Meter 和 Volume 共用一个模型。
|
|
33
52
|
|
|
34
|
-
`volume`、`level`、`slider`、`dimmer` 和 `position`
|
|
53
|
+
`volume`、`level`、`slider`、`dimmer` 和 `position` 都属于普通连续数值控制机制;名称不决定设备匹配。布尔开关、单选枚举和文本也有独立适配。带流元数据的 Meter 和 XY-series 使用专门的只读实时帧机制;原生只读数值指示复用设备参数反馈。当前没有自动绑定任意 Command 动作或 Mixer 矩阵的适配器。
|
|
35
54
|
|
|
36
55
|
## 2. 页面格式与 Vue 约束
|
|
37
56
|
|
|
@@ -53,7 +72,7 @@ export default {
|
|
|
53
72
|
|
|
54
73
|
不要使用 `<script setup>`、TypeScript、组件导入、自行创建 Vue 应用或依赖外部构建的运行时。普通绑定模型应是 `data()` 中的顶层字段,或本指南 3.2 节规定的列表模型。计算属性、函数调用、取反、条件表达式和任意动态索引不能作为绑定模型;它们仍可用于不绑定设备的显示逻辑。
|
|
55
74
|
|
|
56
|
-
不要把节点注入的 `boundState`、`boundPending`、`boundTargets`、`boundUnconfirmed`、`boundAuthenticated`、`boundFeedbackConnected` 等内部字段,或 `__bound*`、`__panel*`、`__attachButtonPress`、`__attachMomentaryButton`、`sendBoundControl` 等方法用于页面模型或自定义方法。使用自己命名的普通字段,避免合并时冲突。
|
|
75
|
+
不要把节点注入的 `boundState`、`boundPending`、`boundTargets`、`boundUnconfirmed`、`boundAuthenticated`、`boundFeedbackConnected`、`boundAvailability` 等内部字段,或 `__bound*`、`__panel*`、`__deviceAvailable`、`__attachButtonPress`、`__attachMomentaryButton`、`sendBoundControl` 等方法用于页面模型或自定义方法。使用自己命名的普通字段,避免合并时冲突。
|
|
57
76
|
|
|
58
77
|
不要手写任何 `data-cp-*` 属性。节点会在渲染副本中自动加入内部标记,页面源码不应依赖这些标记;手写此类属性会被拒绝。
|
|
59
78
|
|
|
@@ -77,7 +96,7 @@ export default {
|
|
|
77
96
|
| 用途 | 原生 HTML | Vuetify | 模型与行为 |
|
|
78
97
|
| --- | --- | --- | --- |
|
|
79
98
|
| 连续数值控制 | `input[type=range]`、`input[type=number]` | `v-slider` | 数字;原生控件在 input 时发送中间值、change 时提交最终值;`v-slider` 根据模型变化发送,停止变化后补交最终值。 |
|
|
80
|
-
|
|
|
99
|
+
| 两态控制 | `input[type=checkbox]`、带语义属性的 `button` | `v-switch`、`v-checkbox` | 页面字段始终为布尔值;驱动原始两态值由绑定映射转换。原生语义按钮还可在绑定窗口选择“按住生效”。 |
|
|
81
100
|
| 单选枚举 | `select` | `v-select`、`v-radio-group` | 单个字符串或数字,必须是设备允许的枚举值。 |
|
|
82
101
|
| 文本控制 | `input[type=text]`、`textarea` | `v-text-field`、`v-textarea` | 字符串;原生控件在 change 时提交,Vuetify 文本框在 change、失焦时提交,`v-text-field` 也支持 Enter 提交。 |
|
|
83
102
|
| 普通只读反馈 | `span`、`strong`、`b`、`output`、`h1`、`h2`、`h3` | 暂无专用组件 | 单个简单字段插值,或静态文本由节点替换;不发送命令。普通 `div`/`p` 文本不属于此适配器。 |
|
|
@@ -264,16 +283,27 @@ export default {
|
|
|
264
283
|
|
|
265
284
|
## 4. Meter 与普通数值反馈
|
|
266
285
|
|
|
267
|
-
Meter
|
|
286
|
+
Meter 是只读数值指示,不是“另一个滑杆”。页面作者只声明普通进度/电平控件,原生参数反馈和批量实时流使用相同的前端写法,不需要为不同设备改页面。
|
|
268
287
|
|
|
269
288
|
- 一个 Meter 绑定只能使用 `read` 模式,不能配置 Write point。
|
|
270
|
-
-
|
|
271
|
-
-
|
|
272
|
-
-
|
|
289
|
+
- 原生 `kind=param`、`access=ro` 的 integer/float 参数,没有枚举或两态域时,可绑定 Meter。既不要求名称包含 Meter,也不要求厂商提供自定义流元数据。它走原生 Status/Parameter Event 反馈,不生成虚构的 manifest/frame 路径;相同设备和路径只订阅一次。
|
|
290
|
+
- 原生只读数字可能是电平、温度或其他测量值。通用识别器根据权限和数值结构判断“只读指示”,不能仅凭这些描述确定物理含义。绑定人员仍须选择正确点位;字段名不会把一个可写音量参数变成 Meter。
|
|
291
|
+
- 可写的连续参数供音量滑杆等普通数值控件使用;只读数值指示和带 `.metadata.meter` 的点位不能作为滑杆的 Write 或 Feedback point。原生只读数字仍可绑定文本展示。若设备把写入增益和只读增益反馈拆为两个点,当前不会猜测其关联;需要明确的后续适配,不能把只读电平误当作增益反馈。
|
|
292
|
+
- 带有效 `.metadata.meter` 的只读整数/浮点点位继续使用批量实时通道:自动订阅 manifest/frame、同帧投影、过期判断和重连。同设备、同 stream 的多个 Meter 取自同一多通道帧;不同设备或不同 stream 独立接收。Room 聚合各流的最新值,不保证不同流采样同步。页面不编写任何批处理或 socket 代码。
|
|
293
|
+
- 原生参数反馈没有 stream 的 epoch/seq、采样时间戳或多通道原子帧保证,更新速度取决于设备。不能把原生参数兼容性当成批量高频流性能或采样过期检测的承诺;断线后沿用普通反馈重连和最新快照机制。
|
|
273
294
|
- Meter 需要独立字段,例如 `meter1`、`meter2`。不要与音量滑杆的 `volume` 共用字段,也不要让不同点位共用一个 Meter 模型。
|
|
274
295
|
- 未收到有效采样时模型保持初值;断线、无效采样和过期后保留最后值,控件的标准 `aria-invalid` 标为 `true`。数值模型本身不会变成特殊的“无效值”,不能把保留值当成新采样;需要时可用标准属性选择器显示无效样式,不依赖私有标记,也不添加轮询或回写。
|
|
275
296
|
- 原生 `<meter>`/`<progress>` 默认页面范围是 0 到 1;Vuetify 进度组件分别使用固定的 0 到 max 或 0 到 100。应按页面显示范围声明固定量程。
|
|
276
297
|
|
|
298
|
+
以百分比显示的 Meter,旁边的数值标签统一显示整数百分比,不直接输出实时反馈的小数。绑定控件仍使用简单的原始模型字段,只在独立的展示标签中使用普通 Vue 表达式取整,不能为显示格式改写反馈模型或降低采样精度:
|
|
299
|
+
|
|
300
|
+
```html
|
|
301
|
+
<meter id="signal-meter" :value="meter" min="0" max="100" aria-label="实时信号电平"></meter>
|
|
302
|
+
<span class="meter-percent" data-display-only="true">{{ Math.round(meter) }}%</span>
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
示例的 `meter` 在 `data()` 中初始化为 `0`;控件量程为 0–100,`66.7` 显示为 `67%`。`data-display-only="true"` 是普通 HTML 数据属性,明确告诉识别器这个元素只是展示格式,不是第二个设备绑定;所有取整、单位和拼接文本都放在这种展示元素中。若页面量程为 0–1,标签使用 `{{ Math.round(meter * 100) }}%`。为标签预留 `100%` 的宽度,可使用 `font-variant-numeric: tabular-nums` 和 `white-space: nowrap`,避免数字更新挤占相邻控件。温度、分贝等物理测量不要误标为百分比,按其实际单位格式化。
|
|
306
|
+
|
|
277
307
|
量程适配发生在网页侧:普通数值设置从页面范围换算到驱动范围,数值反馈和 Meter 从驱动范围换算到页面范围,不修改驱动或协议。两端都必须是有限且 max > min 的数字;否则按原值传递,不能猜测量程。换算会钳制到范围内,整数驱动值会取整,反馈会按有效的页面 step 对齐。XY-series 不使用这种标量量程换算。
|
|
278
308
|
|
|
279
309
|
普通只读反馈使用文本展示即可:
|
|
@@ -300,7 +330,7 @@ XY-series 是 Meter 的独立数组版本,不是多个 Meter 的替代写法
|
|
|
300
330
|
|
|
301
331
|
## 5. 按钮必须分开表达按压与设备状态
|
|
302
332
|
|
|
303
|
-
这是页面编写 AI
|
|
333
|
+
这是页面编写 AI 的必选模式,不是可选装饰。凡是控制静音、电源、启停等两态设备状态的按钮,都使用本节写法;默认点击切换和按住生效只改变命令行为,不改变状态显示规则。页面字段始终是布尔值,无需知道设备使用 true/false、0/1、Off/On、A/B 或其他两个值;转换在绑定配置和节点内部完成,不在前端编写。
|
|
304
334
|
|
|
305
335
|
| 状态 | 表达方式 | 不能表示什么 |
|
|
306
336
|
| --- | --- | --- |
|
|
@@ -394,7 +424,9 @@ export default {
|
|
|
394
424
|
2. 重新打开节点,点击 `Open binding editor`。编辑页登录使用编辑账号;进入后 Select 默认激活。`Open page` 打开普通控制页面,普通页面必须使用普通账号登录。
|
|
395
425
|
3. 在 Select 模式选择控件,选择设备、模式、Write point、Feedback point 和编号方式。Meter、XY-series 和文本展示的模式选项只有 `read`;无需手动从 `both` 改为 `read`。已绑定控件在编辑模式显示亮紫色边框;这只是编辑提示。
|
|
396
426
|
4. 点 Apply。节点验证当前模板、设备目录和映射,备份当前 Flow,并通过运行时 Flow API 以 `nodes` 部署方式更新当前面板的映射和受管理桥接。更新会使受影响节点重启;浏览器同步脚本不点击 Deploy,也不会向画布导入、清除或追加节点。
|
|
397
|
-
5. Apply
|
|
427
|
+
5. Apply 成功后会重新加载当前编辑页,使用仍有效的编辑会话继续进入;会话过期或登录配置改变时可能要求重新登录。设备目录或运行时加载失败不代表会话失效,此时显示错误与 Retry,重试不需要再次输入密码。配置和设备目录并行加载。Bindings 列表可以 Edit 或 Remove,从列表进入编辑时取消/保存后回到列表。点 Exit 退出编辑并回到普通页面登录页。
|
|
428
|
+
|
|
429
|
+
未改变绑定、生成桥接或其他节点配置的 Apply 仍执行模板、目录、映射和版本校验,但不重复备份、写入或重启 Flow;编辑页仍重新加载。真实绑定变化保留原有 `nodes` 部署方式,因此耗时还取决于当前运行环境的目录查询、Flow 存储和受影响节点启动速度。不能承诺本地 Automator 与 MUSE 耗时相同。
|
|
398
430
|
|
|
399
431
|
Automator 的同步脚本监听 `notification/runtime-deploy` 的新版本通知,并在当前页面最多触发一次浏览器刷新;它不读取、导入、合并、清除或再次提交 Flow,也不额外点击 Deploy。监听不限于本面板 Apply,其他运行时版本变化也可能触发刷新。当前实现不判断画布是否有未 Deploy 改动,也不会合并画布草稿;在 Apply 前应先处理好 Automator 中未保存的画布改动。没有打开 Automator 编辑页或不支持该通知接口时,不能承诺自动刷新。
|
|
400
432
|
|
|
@@ -402,21 +434,27 @@ Template 修改可能使已有映射失效。重新 Deploy 后,应重新打开
|
|
|
402
434
|
|
|
403
435
|
## 7. 点位匹配与量程
|
|
404
436
|
|
|
405
|
-
本节供绑定使用者和排查人员参考,不要求页面编写 AI 在前端填写这些描述。编辑器根据 AMX 参数描述推导可匹配类型,不根据页面文字,也不把驱动作者写入的旧式显式 `controlType` 当作页面契约。主要依据是 `.kind`、`.type`、`.access`、`.min`、`.max`、`.enums` 和 `.metadata
|
|
437
|
+
本节供绑定使用者和排查人员参考,不要求页面编写 AI 在前端填写这些描述。编辑器根据 AMX 参数描述推导可匹配类型,不根据页面文字,也不把驱动作者写入的旧式显式 `controlType` 当作页面契约。主要依据是 `.kind`、`.type`、`.access`、`.min`、`.max`、`.enums` 和 `.metadata`。批量 Meter 的 `.metadata.meter` 和 XY-series 的 `.metadata.xySeries` 是各自实时流通道所需的特殊元数据;原生只读数值指示不需要这些元数据。
|
|
406
438
|
|
|
407
439
|
| 参数描述 | 可匹配控件 |
|
|
408
440
|
| --- | --- |
|
|
409
|
-
| boolean
|
|
441
|
+
| boolean | 布尔开关;默认 false/true,可反转对应关系。 |
|
|
442
|
+
| 描述明确列出两个不同、合法的标量枚举值 | 两态开关,不依赖文字含义;enum、带枚举的 integer/float/string/dstring/boolean 保留原始值。enum 也仍可绑定选择控件。 |
|
|
443
|
+
| 没有枚举、合法整数范围恰好只有两个相邻整数的 integer | 两态开关,例如 0/1、1/2、-2/-1;发送与反馈转换为绑定所选对应关系。不能依据当前值猜测合法状态。 |
|
|
410
444
|
| 普通可写 integer/float,未被识别为布尔或枚举 | 数值 input 或 `v-slider`;用于音量/增益等普通连续控制。 |
|
|
411
|
-
| 普通只读 integer/float
|
|
445
|
+
| 普通只读 integer/float,没有枚举或两态域,也没有 Meter 流元数据 | 原生 `meter`/`progress`、Vuetify 进度组件或文本展示;走普通参数反馈,不能映射到音量滑杆。 |
|
|
412
446
|
| 只读 integer/float,带有效 `.metadata.meter` | Meter、原生 `meter`/`progress` 或 Vuetify 进度组件;只能读。 |
|
|
413
447
|
| 只读参数,带有效 `.metadata.xySeries` | XY-series;使用标准 Canvas 的 `:data-points`/`:data-series`,或地图容器的 `:data-coordinates` 数组字段;只能读。 |
|
|
414
|
-
| enum,或带枚举描述的 integer/string |
|
|
448
|
+
| enum,或带枚举描述的 integer/string | 单选枚举;恰为两个合法选项时也支持两态开关。 |
|
|
415
449
|
| 没有枚举但路径以 `/source` 或 `/preset` 结尾的 integer | 选择类参数,仍需确认合法选项。 |
|
|
416
450
|
| string/dstring | 文本输入或文本展示。 |
|
|
417
451
|
|
|
418
452
|
只有 `kind=param` 且具备相应读/写权限的点位参与绑定。普通 Command 动作、数组容器和未知描述不能直接绑定。普通数值、布尔、枚举或文本控件也可以只读绑定;若需要阻止用户本地操作,页面另用标准 `disabled` 等属性表达只读交互。
|
|
419
453
|
|
|
454
|
+
两态绑定只提供一个 `State reverse` 复选框,统一作用于写入和反馈。默认不勾选:驱动状态列表第一项对应页面 false,第二项对应 true;勾选后两个方向一起反转,不再提供独立的写入/反馈映射选项。设置会在切换点位、模式、保存草稿、重新打开和 Apply 后保留。读写点可以具有不同的原始类型和值,但必须采用同一个默认/反转方向,不能复制另一点的原始值。旧绑定在下次 Apply 时迁移:可写绑定以 Write 映射为准,只读绑定以 Feedback 映射为准;明确的布尔 `stateReverse` 优先。仅安装新版不会重写现有 Flow。前端作者仍只维护普通布尔字段,不需要处理反转或原始驱动值。已有 boolean 和整数 0/1 绑定保持兼容。普通 float 范围 0..1 仍然有无限多个合法值,不视为两态;不完整枚举、重复值、非有限数、三项以上枚举也不会被强行识别为两态。未知反馈值会报错,不会被猜成 true/false。枚举对象使用其明确的 `value`,而非 label 或数组索引;只支持原生参数通道能够传输的 boolean、有限 number 和 string 值。
|
|
455
|
+
|
|
456
|
+
按住生效也使用该映射:按下发送 true 对应值,松开或租约释放发送 false 对应值。映射不代表设备已执行,激活样式仍来自真实反馈。同一面板若同时按住同一点的两个按钮,不能配置相反的激活方向;冲突按压会被拒绝。设备节点名称不是控制语义证明,例如矩阵交叉点 On_Off 不应未经确认就当成输出静音。
|
|
457
|
+
|
|
420
458
|
当前一个面板的所有可视绑定合计最多 256 项,包含普通控件、Meter 和 XY-series,并非每种类型各有 256 个独立名额。某些不推荐的字段共享写法未必被校验器全部拦截,不能把 Apply 成功当成模型设计无冲突的证明。
|
|
421
459
|
|
|
422
460
|
编辑弹窗中,`read` 禁用并灰显 Write point,`write` 禁用并灰显 Feedback point,`both` 两者均可选择。切换模式不清空弹窗内已有选择;保存草稿时只保留当前模式需要的点位。在 `both` 模式下,Write point 改动时 Feedback point 会跟随;单独改 Feedback point 不会反向改 Write point。Meter 和 XY-series 只能使用 Feedback point。编号方式默认 Zero-based;如果目录点位带有 `.onebased`,保存时以目录描述为准,否则使用编辑页选择。编号方式只影响驱动点位编号,不会给网页值加减 1。
|
|
@@ -428,7 +466,7 @@ Template 修改可能使已有映射失效。重新 Deploy 后,应重新打开
|
|
|
428
466
|
| 控件不能选择 | 是否在支持清单;是否有事件指令、复杂模型、动态量程、重复/不唯一 key、重复 ID 或自闭合 Vuetify 标签。 |
|
|
429
467
|
| `v-for` 实例没有出现 | 来源是否是顶层数组;key 是否严格符合对象/数组规则;列表项是否实际渲染且 key 唯一。 |
|
|
430
468
|
| 设备有了但点位为空 | 设备在线状态、AMX 参数目录、`kind/type/access` 和读写权限;不是所有命令都属于可绑定参数。 |
|
|
431
|
-
| Meter 可以选但无值 |
|
|
469
|
+
| Meter 可以选但无值 | 原生点位检查 Status/Parameter Event 是否实际反馈;带 `.metadata.meter` 的点位检查 manifest/frame;两者都检查是否与 Volume 使用独立模型,以及设备量程与网页范围。 |
|
|
432
470
|
| XY-series 可以选但无值 | 点位是否带 `.metadata.xySeries`;驱动是否发布对应 manifest/frame;数组字段是否为顶层数组;检查点数、帧大小和过期状态。 |
|
|
433
471
|
| 页面一直 Loading | 查看模板编译错误,重点检查原生 Meter/Progress 的 v-model 写法、自闭合 Vuetify、动态表达式和不支持的组件。设备离线不应被页面作者通过轮询“修复”。 |
|
|
434
472
|
| 滑杆动不了 | 是否仍处于 Select 编辑模式;模型是否为数字;是否给绑定控件添加了自己的事件或回写逻辑。 |
|
package/README.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
This package provides `ui-control-panel` for Node-RED and MUSE Automator.
|
|
4
4
|
|
|
5
|
+
Current source includes unpublished, untested physical-device availability
|
|
6
|
+
support. Drivers annotate readonly boolean health parameters with
|
|
7
|
+
`.metadata.availabilityRole: "online"` / `"ready"` at the physical-device scope.
|
|
8
|
+
Re-Apply bindings after updating the driver descriptor to add deduplicated
|
|
9
|
+
Status/Event health subscriptions. Without this metadata old bindings retain
|
|
10
|
+
their existing behavior; this cannot infer physical health from a provider's
|
|
11
|
+
virtual-device online status. Managed controls gray out, become inert/aria-disabled
|
|
12
|
+
and freeze while unavailable. Commands are rejected server-side too. Ordinary
|
|
13
|
+
controls resume only after readiness and fresh point feedback; streams also
|
|
14
|
+
require valid fresh frames. Binding editor selection remains available. The
|
|
15
|
+
page author supplies no MUSE-specific health code, and no alert is displayed.
|
|
16
|
+
|
|
5
17
|
## ControlPanel
|
|
6
18
|
|
|
7
19
|
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 binding editor**. Sign in with the editor account, right-click a supported control, choose its device points, and Apply. **Open page** opens the normal control page with its separate user login.
|
|
@@ -12,11 +24,13 @@ Bindings are stored in `visualBindings` on the node. The runtime reads that mapp
|
|
|
12
24
|
|
|
13
25
|
Supported controls include native range, checkbox, semantic pressed button, select, text input, meter, progress, and simple text display. Give each template control a stable standard ID or name; repeated instances also need stable unique keys. Simple top-level Vue `v-model` works, including object-list models such as `item.volume`, `item.audio.volume`, or `items[index].volume`, primitive array models such as `values[index]`, and an inner `<template v-for>` for multiple controls per row. During Vue re-rendering, a temporary duplicate list key is ignored until the key becomes unique again, so bindings do not attach twice. Detached or disposed controls release their event listeners and cannot send commands. 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
26
|
|
|
15
|
-
Native semantic buttons support a binding-editor **Button behavior** setting: **Click to toggle** (the default for existing bindings) or **Hold to activate**. Momentary buttons require `both` mode, send
|
|
27
|
+
Native semantic buttons support a binding-editor **Button behavior** setting: **Click to toggle** (the default for existing bindings) or **Hold to activate**. Momentary buttons require `both` mode, send the mapped active value on press and inactive value on release, and always display real device feedback rather than a predicted local state. Pointer cancellation, keyboard release, focus/visibility loss, DOM removal, and disconnect release the hold. A 3-second server lease attempts release if the browser disappears; lease renewal does not repeat device writes. Concurrent holders of the same Write point within one panel are combined until the last release; opposite state mappings are rejected while held. Other panels and external controllers are not coordinated. Device or bridge outages can prevent physical release, so feedback remains the authority. Drivers keep their original parameter values; page authors do not implement hold events or lease traffic.
|
|
28
|
+
|
|
29
|
+
Two-state controls do not depend on device labels or a legacy explicit controlType. A boolean, an integer domain containing exactly two adjacent values, or a descriptor listing two distinct valid scalar options can match a switch. The editor offers one **State reverse** checkbox for both command and feedback conversion. Unchecked means the first value is false and the second is true; checked reverses both directions. This setting persists across point changes, mode changes, draft reopening and Apply. Read and write points may use different raw types/values but share the same normal/reversed order. On the next Apply, legacy separate mappings are migrated using the Write mapping when writable, otherwise the Feedback mapping; an explicit boolean stateReverse takes precedence. Existing running bindings are not rewritten merely by installing an update. Original string/number/boolean payloads are preserved, including enum Off/On, arbitrary A/B labels and integer 1/2, while the ordinary Vue model stays boolean. Enum targets remain available to select controls. Continuous float ranges, unknown domains, duplicate options and nonfinite values are not guessed into switches; unexpected feedback is rejected. Existing boolean/integer01 bindings keep their old behavior.
|
|
16
30
|
|
|
17
31
|
Writable native semantic buttons in either behavior automatically show a separate physical press effect (2px downward translation and inset shadow) for pointer/touch and Space/Enter. Release, cancellation, focus/visibility loss, disablement, DOM removal and loss of the authenticated feedback connection clear it. This effect changes neither the Vue model nor device-feedback ARIA, colors or icons; device feedback remains the sole source of the indicated mute/power state. Read-only buttons and editor mode do not show this effect. Page authors need no extra event handlers or internal press attributes.
|
|
18
32
|
|
|
19
|
-
|
|
33
|
+
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 Vue 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. Readonly numeric parameters without enums or a two-state domain can bind to an indicator through native Status/Parameter Event feedback, without custom metadata or vendor/name heuristics. These can represent signal levels or other measurements; numeric structure alone cannot establish their physical meaning. They cannot bind to a volume slider, even as its feedback. Writable numeric controls cannot bind to a Meter. Split write-gain/read-gain parameters are not inferred as a pair. Native readonly values can still bind to text displays. When a target advertises Meter stream metadata, ControlPanel continues to share that stream, project complete frames, apply webpage-side range conversion, and handle stale/reconnected data. Native scalar events do not provide stream sequencing, sampling timestamps, atomic multichannel frames, or the same high-rate performance guarantees.
|
|
20
34
|
|
|
21
35
|
Readonly XY-series telemetry follows the same page boundary. Use a standard canvas with one simple Vue array binding, for example `<canvas id="spectrum" :data-points="spectrum"></canvas>`, or a map container with `<div id="room-map" :data-coordinates="locations"></div>`. Use `[longitude, latitude]` for geographic coordinates or `[x, y]` for local planar coordinates; the page owns the projection. The page receives complete `[[x, y], ...]` snapshots; ControlPanel owns subscription, latest-frame replacement, stale handling, and reconnection. The page does not declare a device, stream, socket, or frame protocol. XY-series is independent from Meter and Volume: it never sends commands and does not represent a single scalar value.
|
|
22
36
|
|
|
@@ -28,6 +42,8 @@ Use paired Vuetify tags in Dashboard templates, such as `<v-slider></v-slider>`;
|
|
|
28
42
|
|
|
29
43
|
After clicking Apply in the page binding editor, the package validates the mapping, backs up the active Flow, and updates the panel and generated native bridge through the runtime Flow API with deployment type `nodes`. Affected runtime nodes restart. Separately, when Automator reports the external Flow change, its editor listener reloads the browser page once: that listener does not import, merge, clear or deploy Flow data. It does not preserve undeployed canvas drafts, so handle those before Apply. 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.
|
|
30
44
|
|
|
45
|
+
An unchanged Apply still validates the draft and current revision, but skips the backup and Flow write when the resulting nodes are identical, regardless of their array order. The binding page still reloads and restores its valid session. Editor configuration and device discovery load concurrently. Transient runtime/catalog failures show an error with Retry instead of treating valid authentication as expired; only a 401 response requests credentials again. Ordinary user-page session restoration follows the same distinction. Real updates still depend on the host's catalog, storage and node restart performance.
|
|
46
|
+
|
|
31
47
|
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.
|
|
32
48
|
|
|
33
49
|
## Installation
|
|
@@ -42,7 +58,7 @@ The release includes `npm-shrinkwrap.json` to install the tested dependency vers
|
|
|
42
58
|
|
|
43
59
|
For development, run `npm ci --ignore-scripts` and `npm test` in this source directory with Node.js >=18.16. Tests need the local pinned dependencies; do not depend on another machine's installed Automator modules. The parent project's `local-validation/test-source.sh` also covers the Python framework, drivers, and offline Flow helpers. Its optional isolated runtime test uses a temporary user directory and random loopback port; it never deploys to the live Automator or MUSE.
|
|
44
60
|
|
|
45
|
-
Ordinary feedback uses authenticated SSE
|
|
61
|
+
Ordinary feedback, including native readonly numeric indicators, uses authenticated SSE; stream-backed Meter and XY-series use Dashboard Socket.IO. SSE feedback is serialized once per broadcast; a temporary backpressured write may drain, while a persistently blocked client is disconnected and reconnects to the latest snapshot rather than buffering history. Each panel allows at most 100 simultaneous SSE clients.
|
|
46
62
|
|
|
47
63
|
Realtime fan-out uses one volatile namespace broadcast to explicit authenticated socket IDs when the Dashboard socket exposes that API; other hosts keep the per-socket fallback. This avoids repeated server encoding without changing frame formats, authoring syntax, authorization or each viewer's network traffic. Shared streams are checked once when deciding whether a panel needs a new frame. Browser control lookup uses a DOM index invalidated on structure or binding-key changes; unrelated DOM mutations do not replay an XY array into the same model. Target recreation still restores the latest snapshot.
|
|
48
64
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const { visualRoutes } = require('./panel-visual-bindings')
|
|
2
|
+
const { switchMapping } = require('./control-type-model')
|
|
2
3
|
|
|
3
4
|
function configuredRoutes (config) {
|
|
4
5
|
return visualRoutes(config)
|
|
@@ -11,7 +12,9 @@ function resolveBoundCommand (routes, message) {
|
|
|
11
12
|
}
|
|
12
13
|
const route = routes.get(request.id)
|
|
13
14
|
if (!route) throw new Error(`Unknown control ID: ${request.id}`)
|
|
14
|
-
if (route.control.mode === 'read'
|
|
15
|
+
if (route.control.mode === 'read' || ['meter', 'xy-series'].includes(route.control.type)) {
|
|
16
|
+
throw new Error(`Read-only control: ${request.id}`)
|
|
17
|
+
}
|
|
15
18
|
if (!route.binding?.device || !route.binding.writePath) {
|
|
16
19
|
throw new Error(`Incomplete command binding: ${request.id}`)
|
|
17
20
|
}
|
|
@@ -41,12 +44,15 @@ function resolveBoundCommand (routes, message) {
|
|
|
41
44
|
if (type === 'text' && (typeof value !== 'string' || value.length > 4096)) {
|
|
42
45
|
throw new Error('Text value must be a string under 4096 characters')
|
|
43
46
|
}
|
|
47
|
+
const mapping = ['mute', 'power'].includes(type) && metadata?.booleanMapping
|
|
48
|
+
? switchMapping(metadata, metadata.booleanMapping) : null
|
|
49
|
+
const payload = mapping ? (value ? mapping.trueValue : mapping.falseValue)
|
|
50
|
+
: ['mute', 'power'].includes(type) && metadata?.booleanEncoding === 'integer01'
|
|
51
|
+
? (value ? 1 : 0) : value
|
|
44
52
|
if (Array.isArray(metadata?.enums) && metadata.enums.length &&
|
|
45
|
-
!metadata.enums.some(entry => Object.is(entry,
|
|
53
|
+
!metadata.enums.some(entry => Object.is(entry, payload) || Object.is(entry?.value, payload))) {
|
|
46
54
|
throw new Error('Value is not a permitted device option')
|
|
47
55
|
}
|
|
48
|
-
const payload = ['mute', 'power'].includes(type) && metadata?.booleanEncoding === 'integer01'
|
|
49
|
-
? (value ? 1 : 0) : value
|
|
50
56
|
return {
|
|
51
57
|
kind: 'bound-command',
|
|
52
58
|
controlId: request.id,
|
|
@@ -72,10 +78,21 @@ function resolveBoundFeedback (routes, message) {
|
|
|
72
78
|
const metadata = route.binding.readMeta
|
|
73
79
|
let value = message.payload
|
|
74
80
|
if (['mute', 'power'].includes(type)) {
|
|
75
|
-
if (
|
|
81
|
+
if (metadata?.booleanMapping) {
|
|
82
|
+
const mapping = switchMapping(metadata, metadata.booleanMapping)
|
|
83
|
+
// Integer/float Event nodes can deliver numeric values as strings.
|
|
84
|
+
if (['integer', 'float'].includes(metadata.type) && typeof value === 'string' && value.trim()) value = Number(value)
|
|
85
|
+
if (metadata.type === 'boolean') {
|
|
86
|
+
if (value === 'true' || value === 1 || value === '1') value = true
|
|
87
|
+
else if (value === 'false' || value === 0 || value === '0') value = false
|
|
88
|
+
}
|
|
89
|
+
if (Object.is(value, mapping.trueValue)) value = true
|
|
90
|
+
else if (Object.is(value, mapping.falseValue)) value = false
|
|
91
|
+
else throw new Error(`Invalid ${type} feedback: ${id}`)
|
|
92
|
+
} else if (value === 'true' || value === 1 || value === '1') value = true
|
|
76
93
|
else if (value === 'false' || value === 0 || value === '0') value = false
|
|
77
94
|
if (typeof value !== 'boolean') throw new Error(`Invalid ${type} feedback: ${id}`)
|
|
78
|
-
} else if (['level', 'dimmer', 'position'].includes(type)) {
|
|
95
|
+
} else if (['level', 'dimmer', 'position', 'meter'].includes(type)) {
|
|
79
96
|
if (typeof value === 'string' && value.trim() !== '') value = Number(value)
|
|
80
97
|
if (typeof value !== 'number' || !Number.isFinite(value) ||
|
|
81
98
|
(metadata?.type === 'integer' && !Number.isInteger(value)) ||
|
package/control-catalog.js
CHANGED
|
@@ -2,6 +2,7 @@ const { schemaVersion } = require('./control-type-contract.json')
|
|
|
2
2
|
const { runtimeJsonRequest } = require('./panel-http')
|
|
3
3
|
const { meterRoute } = require('./meter-route')
|
|
4
4
|
const { xySeriesRoute } = require('./xy-series-route')
|
|
5
|
+
const { isMeterTarget } = require('./control-type-model')
|
|
5
6
|
|
|
6
7
|
function catalogBase (settings) {
|
|
7
8
|
const port = Number(settings?.uiPort)
|
|
@@ -58,7 +59,10 @@ function inferredControl (path, properties, type, min, max, enums) {
|
|
|
58
59
|
if (type === 'integer' && /\/(preset|source)$/.test(path.toLowerCase())) {
|
|
59
60
|
return { controlType: 'source' }
|
|
60
61
|
}
|
|
61
|
-
if (['integer', 'float'].includes(type))
|
|
62
|
+
if (['integer', 'float'].includes(type)) {
|
|
63
|
+
return { controlType: isMeterTarget({ type, access: properties['.access'], min, max, enums })
|
|
64
|
+
? 'meter' : 'slider' }
|
|
65
|
+
}
|
|
62
66
|
if (['string', 'dstring'].includes(type)) return { controlType: 'text' }
|
|
63
67
|
return { controlType: '' }
|
|
64
68
|
}
|
|
@@ -77,6 +81,7 @@ async function listCatalogTargets (base, device, fetcher = fetch) {
|
|
|
77
81
|
const enums = properties['.enums']
|
|
78
82
|
const meter = properties['.metadata']?.meter ? meterRoute(properties['.metadata'].meter) : null
|
|
79
83
|
const xySeries = properties['.metadata']?.xySeries ? xySeriesRoute(properties['.metadata'].xySeries) : null
|
|
84
|
+
const availabilityRole = properties['.metadata']?.availabilityRole
|
|
80
85
|
return {
|
|
81
86
|
path: item.path,
|
|
82
87
|
kind: properties['.kind'] || '',
|
|
@@ -89,7 +94,9 @@ async function listCatalogTargets (base, device, fetcher = fetch) {
|
|
|
89
94
|
controlSchemaVersion: schemaVersion,
|
|
90
95
|
unit: properties['.metadata']?.unit || '',
|
|
91
96
|
...(meter ? { meter } : {}),
|
|
92
|
-
...(xySeries ? { xySeries } : {})
|
|
97
|
+
...(xySeries ? { xySeries } : {}),
|
|
98
|
+
...(['online', 'ready'].includes(availabilityRole) && type === 'boolean' &&
|
|
99
|
+
properties['.access'] === 'ro' ? { availabilityRole } : {})
|
|
93
100
|
}
|
|
94
101
|
})
|
|
95
102
|
const unique = new Map()
|
|
@@ -100,7 +107,23 @@ async function listCatalogTargets (base, device, fetcher = fetch) {
|
|
|
100
107
|
}
|
|
101
108
|
unique.set(target.path, target)
|
|
102
109
|
}
|
|
103
|
-
|
|
110
|
+
const result = [...unique.values()]
|
|
111
|
+
// Explicit semantic metadata, never brand names or guesses such as "online".
|
|
112
|
+
// The nearest physical-device ancestor wins over any aggregate/root alias.
|
|
113
|
+
const scopes = new Map()
|
|
114
|
+
for (const point of result.filter(target => target.availabilityRole)) {
|
|
115
|
+
const scope = point.path.slice(0, point.path.lastIndexOf('/') + 1)
|
|
116
|
+
if (!scopes.has(scope)) scopes.set(scope, {})
|
|
117
|
+
const roles = scopes.get(scope)
|
|
118
|
+
if (roles[point.availabilityRole]) throw Error('Ambiguous device availability scope: ' + scope)
|
|
119
|
+
roles[point.availabilityRole] = { path: point.path, onebased: point.onebased }
|
|
120
|
+
}
|
|
121
|
+
for (const target of result) {
|
|
122
|
+
const scope = [...scopes.keys()].filter(prefix => target.path.startsWith(prefix) && scopes.get(prefix).online)
|
|
123
|
+
.sort((a, b) => b.length - a.length)[0]
|
|
124
|
+
if (scope) target.availability = scopes.get(scope)
|
|
125
|
+
}
|
|
126
|
+
return result
|
|
104
127
|
}
|
|
105
128
|
|
|
106
129
|
module.exports = { catalogBase, listCatalogDevices, listCatalogTargets }
|
package/control-panel.js
CHANGED
|
@@ -2,6 +2,7 @@ const crypto = require('node:crypto')
|
|
|
2
2
|
const path = require('node:path')
|
|
3
3
|
const { injectControlPolicy } = require('./control-policy')
|
|
4
4
|
const { configuredRoutes, resolveBoundCommand, resolveBoundFeedback } = require('./control-bound-protocol')
|
|
5
|
+
const { createDeviceAvailability } = require('./device-availability')
|
|
5
6
|
const { catalogBase, listCatalogDevices, listCatalogTargets } = require('./control-catalog')
|
|
6
7
|
const { contract } = require('./control-type-model')
|
|
7
8
|
const { parseVisualBindings } = require('./panel-visual-bindings')
|
|
@@ -142,6 +143,7 @@ module.exports = function (RED) {
|
|
|
142
143
|
}
|
|
143
144
|
}
|
|
144
145
|
|
|
146
|
+
const availability = createDeviceAvailability(routes)
|
|
145
147
|
let bridgeReady = !config.bridgeRevisionApplied
|
|
146
148
|
if (config.nativeBridge === true) {
|
|
147
149
|
try {
|
|
@@ -197,6 +199,9 @@ module.exports = function (RED) {
|
|
|
197
199
|
() => gateway?.sessionActive(identity.token)),
|
|
198
200
|
onBoundCommand: (message, identity) => {
|
|
199
201
|
if (!bridgeReady) return false
|
|
202
|
+
if (message?._controlPanel?.hold?.action !== 'release' && !availability.allowed(message?._controlPanel?.id)) {
|
|
203
|
+
throw Error('Device is unavailable')
|
|
204
|
+
}
|
|
200
205
|
if (message?._controlPanel?.hold) return momentary.handle(message, identity)
|
|
201
206
|
const command = resolveBoundCommand(routes, message)
|
|
202
207
|
if (routes.get(command.controlId)?.control.buttonBehavior === 'momentary' || momentary.isHeld(command)) {
|
|
@@ -210,6 +215,7 @@ module.exports = function (RED) {
|
|
|
210
215
|
try { node.send({ kind: 'read-all', room: identity.room, user: identity.user }) } catch (error) { node.error(error) }
|
|
211
216
|
}
|
|
212
217
|
})
|
|
218
|
+
gateway.boundAvailability(availability.snapshot())
|
|
213
219
|
activePanels.set(node.id, gateway)
|
|
214
220
|
} catch (error) {
|
|
215
221
|
node.error(error)
|
|
@@ -232,7 +238,7 @@ module.exports = function (RED) {
|
|
|
232
238
|
onAuthenticatedRequest: (req, res, identity) => telemetryTicket(req, res, identity,
|
|
233
239
|
() => editor?.sessionActive(identity.token)),
|
|
234
240
|
getConfig: async req => editorConfig(await getSnapshot(req.socket.localPort, fetch,
|
|
235
|
-
RED.settings.httpAdminRoot ?? '/'), node.id),
|
|
241
|
+
RED.settings.httpAdminRoot ?? '/', { maxAgeMs: 5000, allowStaleMs: 60000 }), node.id),
|
|
236
242
|
getDevices: async () => {
|
|
237
243
|
if (!base) throw new Error('Device catalog unavailable')
|
|
238
244
|
return listCatalogDevices(base)
|
|
@@ -249,7 +255,8 @@ module.exports = function (RED) {
|
|
|
249
255
|
userDir: RED.settings.userDir,
|
|
250
256
|
localPort: req.socket.localPort,
|
|
251
257
|
adminRoot: RED.settings.httpAdminRoot ?? '/',
|
|
252
|
-
catalogBase: base
|
|
258
|
+
catalogBase: base,
|
|
259
|
+
editorCookie: req.headers.cookie
|
|
253
260
|
})
|
|
254
261
|
})
|
|
255
262
|
if (editor) activeEditors.set(node.id, editor)
|
|
@@ -271,6 +278,22 @@ module.exports = function (RED) {
|
|
|
271
278
|
...((meter || xySeries) ? { onSocket: { ...(meter?.hooks || {}), ...(xySeries?.hooks || {}) } } : {}),
|
|
272
279
|
beforeSend (msg) {
|
|
273
280
|
if (msg?._controlPanel?.kind === 'command') throw new Error('Bound commands require authenticated HTTP')
|
|
281
|
+
if (msg?.kind === 'device-availability') {
|
|
282
|
+
if (bridgeReady && gateway) {
|
|
283
|
+
const items = availability.update(msg)
|
|
284
|
+
if (items) {
|
|
285
|
+
for (const item of items) if (!item.available) momentary.releaseControl(item.id)
|
|
286
|
+
const pending = availability.takePending()
|
|
287
|
+
if (pending.length) gateway.boundFeedback(pending)
|
|
288
|
+
const changed = availability.changes()
|
|
289
|
+
if (changed) gateway.boundAvailability(changed)
|
|
290
|
+
// Re-read exported real state after readiness; no browser refresh,
|
|
291
|
+
// no Deploy and no periodic catalog requests.
|
|
292
|
+
if (msg.payload === true || msg.payload === 'true') node.send({ kind: 'read-all', room: boundRoom })
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return false
|
|
296
|
+
}
|
|
274
297
|
if (['device-meter-frame', 'device-meter-manifest'].includes(msg?.kind)) {
|
|
275
298
|
if (bridgeReady) meter?.accept(msg)
|
|
276
299
|
// Dashboard 1.31 reads properties before checking whether a message
|
|
@@ -283,7 +306,10 @@ module.exports = function (RED) {
|
|
|
283
306
|
}
|
|
284
307
|
if (boundMode) {
|
|
285
308
|
if (msg?.kind === 'device-feedback' && gateway) {
|
|
286
|
-
|
|
309
|
+
const items = availability.feedback(resolveBoundFeedback(routes, msg))
|
|
310
|
+
if (items.length) gateway.boundFeedback(items)
|
|
311
|
+
const changed = availability.changes()
|
|
312
|
+
if (changed) gateway.boundAvailability(changed)
|
|
287
313
|
}
|
|
288
314
|
return {}
|
|
289
315
|
}
|
package/control-policy.js
CHANGED
|
@@ -194,6 +194,7 @@ export default {
|
|
|
194
194
|
boundUnconfirmed: {},
|
|
195
195
|
boundFeedbackRevision: 0,
|
|
196
196
|
boundFeedbackConnected: false,
|
|
197
|
+
boundAvailability: {},
|
|
197
198
|
boundAuthenticated: false,
|
|
198
199
|
}
|
|
199
200
|
},
|
|
@@ -211,11 +212,28 @@ export default {
|
|
|
211
212
|
__boundControl (id) {
|
|
212
213
|
return this.__boundControls().find(control => control.id === id)
|
|
213
214
|
},
|
|
215
|
+
__deviceAvailable (id) {
|
|
216
|
+
const binding = this.__boundPanelConfig()?.visual?.bindings?.[id]
|
|
217
|
+
if (!binding?.readMeta?.availability && !binding?.writeMeta?.availability) return true
|
|
218
|
+
return this.boundFeedbackConnected && this.boundAvailability[id] === true
|
|
219
|
+
},
|
|
214
220
|
applyBoundFeedback (message) {
|
|
221
|
+
if (message?.kind === 'bound-availability') {
|
|
222
|
+
if (message.room !== this.__boundPanelConfig()?.room || !Array.isArray(message.availability) ||
|
|
223
|
+
!Number.isInteger(message.revision) || message.revision < this.boundFeedbackRevision) return false
|
|
224
|
+
this.boundFeedbackRevision = message.revision
|
|
225
|
+
this.boundAvailability = Object.fromEntries(message.availability.filter(item =>
|
|
226
|
+
typeof item?.id === 'string' && typeof item.available === 'boolean').map(item => [item.id, item.available]))
|
|
227
|
+
for (const [id, available] of Object.entries(this.boundAvailability)) if (!available) this.__clearBoundPending(id)
|
|
228
|
+
return true
|
|
229
|
+
}
|
|
215
230
|
if (['bound-snapshot', 'bound-feedback'].includes(message?.kind)) {
|
|
216
231
|
if (message.room !== this.__boundPanelConfig()?.room || !Array.isArray(message.items) ||
|
|
217
232
|
!Number.isInteger(message.revision) || message.revision < this.boundFeedbackRevision) return false
|
|
218
|
-
if (message.kind === 'bound-snapshot')
|
|
233
|
+
if (message.kind === 'bound-snapshot') {
|
|
234
|
+
this.boundState = {}
|
|
235
|
+
this.boundAvailability = Object.fromEntries((message.availability || []).map(item => [item.id, item.available === true]))
|
|
236
|
+
}
|
|
219
237
|
this.boundFeedbackRevision = message.revision
|
|
220
238
|
return message.items.map(item => this.applyBoundFeedback({
|
|
221
239
|
__boundRevision: message.revision,
|
|
@@ -279,6 +297,7 @@ export default {
|
|
|
279
297
|
const control = this.__boundControl(id)
|
|
280
298
|
if (!control) throw new Error('Unknown control ID: ' + id)
|
|
281
299
|
if (control.mode === 'read') throw new Error('Read-only control: ' + id)
|
|
300
|
+
if (!this.__deviceAvailable(id)) throw new Error('Device is unavailable')
|
|
282
301
|
if (!Object.prototype.hasOwnProperty.call(command, 'value')) throw new Error('Bound control needs a value')
|
|
283
302
|
if (!this.__boundPanelSession?.csrf) throw new Error('Bound panel login required')
|
|
284
303
|
const previous = this.__boundSettlements.get(id)
|
|
@@ -351,6 +370,7 @@ export default {
|
|
|
351
370
|
this.__boundPanelSource?.close()
|
|
352
371
|
this.__boundPanelSource = null
|
|
353
372
|
this.boundFeedbackConnected = false
|
|
373
|
+
this.boundAvailability = {}
|
|
354
374
|
},
|
|
355
375
|
__connectBoundFeedback () {
|
|
356
376
|
this.__closeBoundFeedback()
|
|
@@ -369,7 +389,12 @@ export default {
|
|
|
369
389
|
}
|
|
370
390
|
}
|
|
371
391
|
source.onerror = () => {
|
|
372
|
-
if (this.__boundPanelSource === source)
|
|
392
|
+
if (this.__boundPanelSource === source) {
|
|
393
|
+
this.boundFeedbackConnected = false
|
|
394
|
+
this.boundAvailability = {}
|
|
395
|
+
this.__resetControlPolicy()
|
|
396
|
+
this.__clearAllBoundSettlements()
|
|
397
|
+
}
|
|
373
398
|
}
|
|
374
399
|
},
|
|
375
400
|
setBoundPanelSession (session) {
|
|
@@ -412,7 +437,7 @@ export default {
|
|
|
412
437
|
this.setBoundPanelSession(session)
|
|
413
438
|
return session
|
|
414
439
|
},
|
|
415
|
-
async restoreBoundPanelSession (timeoutMs =
|
|
440
|
+
async restoreBoundPanelSession (timeoutMs = 12000) {
|
|
416
441
|
const config = this.__boundPanelConfig()
|
|
417
442
|
if (!config) return null
|
|
418
443
|
const generation = this.__boundAuthGeneration
|
|
@@ -421,8 +446,11 @@ export default {
|
|
|
421
446
|
}, timeoutMs, async response => ({ response, session: response.ok ? await response.json() : null }))
|
|
422
447
|
if (generation !== this.__boundAuthGeneration) return this.__boundPanelSession
|
|
423
448
|
if (!response.ok) {
|
|
424
|
-
|
|
425
|
-
|
|
449
|
+
if (response.status === 401) {
|
|
450
|
+
this.clearBoundPanelSession()
|
|
451
|
+
return null
|
|
452
|
+
}
|
|
453
|
+
throw new Error('Panel session unavailable (' + response.status + ')')
|
|
426
454
|
}
|
|
427
455
|
if (generation !== this.__boundAuthGeneration) return this.__boundPanelSession
|
|
428
456
|
this.setBoundPanelSession(session)
|
|
@@ -450,6 +478,7 @@ export default {
|
|
|
450
478
|
const session = this.__boundPanelSession;
|
|
451
479
|
if (!config || !session)
|
|
452
480
|
throw new Error('Bound panel login required');
|
|
481
|
+
if (!this.__deviceAvailable(command.controlKey)) throw new Error('Device is unavailable');
|
|
453
482
|
const response = await this.__panelFetch(config.endpoint + '/bound-command', {
|
|
454
483
|
method: 'POST',
|
|
455
484
|
credentials: 'same-origin',
|