node-red-contrib-room-tools 1.9.1 → 1.9.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.
@@ -1,6 +1,6 @@
1
1
  # ControlPanel 前端页面编写指南
2
2
 
3
- 适用基线:当前统一工程中的 `node-red-contrib-room-tools@1.9.1`、Dashboard 2 和 Vue Options API。
3
+ 适用基线:当前统一工程中的 `node-red-contrib-room-tools@1.9.3` 源码、Dashboard 2(已核对 `1.31.0`)和 Vue Options API。文档基线不代表该源码已发布或已安装到每台主机。支持范围以当前识别器和运行时代码为准,不代表完整 Vue/Vuetify 语法都可自动绑定。
4
4
 
5
5
  本文件是给页面编写 AI 的实现契约,不是完整的 Vue 或 Vuetify 手册。目标是:页面作者只写普通 HTML、CSS 和 Vue 页面;设备、驱动、映射表、反馈订阅和通信细节全部由 `ControlPanel` 节点处理。
6
6
 
@@ -12,6 +12,7 @@
12
12
  - 为需要绑定的控件准备简单、可写、类型正确的 Vue 数据字段。
13
13
  - 使用稳定的普通 HTML `id` 或 `name`,并为重复列表使用稳定的 `:key`。
14
14
  - 用 `volume`、`muted`、`source`、`meter` 等字段表达页面语义。字段名只是页面模型名,不需要使用任何内部 Control ID。
15
+ - 所有设备布尔按钮都按第 5 节的双状态模式编写:原生语义 `button` 的下陷只表示正在按;颜色、状态指示和图标只表示设备真实反馈。不要把按下、点击或请求成功当成设备已执行。
15
16
 
16
17
  页面作者不需要知道:
17
18
 
@@ -30,6 +31,8 @@
30
31
 
31
32
  不要把同一个模型字段同时用于不同设备点位,尤其不要让 Meter 和 Volume 共用一个模型。
32
33
 
34
+ `volume`、`level`、`slider`、`dimmer` 和 `position` 都属于普通连续数值机制;名称不决定设备匹配。布尔开关、单选枚举和文本也有独立适配。Meter 和 XY-series 才使用专门的只读实时帧机制。当前没有自动绑定任意 Command 动作或 Mixer 矩阵的适配器。
35
+
33
36
  ## 2. 页面格式与 Vue 约束
34
37
 
35
38
  交付可以直接粘贴到节点 Template 的组件片段:一个外层 `<template>`,可选的 `<script>` 和 `<style>`。不要交付带 `<html>`、`<head>`、`<body>` 的完整文档,也不要交付需要外部构建才能运行的项目。
@@ -46,17 +49,24 @@ export default {
46
49
  }
47
50
  ```
48
51
 
49
- 不要使用 `<script setup>`、组件导入、自行创建 Vue 应用或外部运行时依赖。普通绑定模型应是 `data()` 中的顶层字段,或本指南 3.2 节规定的列表模型。计算属性、函数调用、取反、条件表达式和动态索引不能作为绑定模型。
52
+ `<script>` 内只能有一个 `export default { ... }` 语句。常量、辅助函数和绘图逻辑放进 `data()`、`methods` 或生命周期方法,不要在导出对象外另写 `const`、函数、导入或其他语句;否则节点不能正确合并控制逻辑。
53
+
54
+ 不要使用 `<script setup>`、TypeScript、组件导入、自行创建 Vue 应用或依赖外部构建的运行时。普通绑定模型应是 `data()` 中的顶层字段,或本指南 3.2 节规定的列表模型。计算属性、函数调用、取反、条件表达式和任意动态索引不能作为绑定模型;它们仍可用于不绑定设备的显示逻辑。
55
+
56
+ 不要把节点注入的 `boundState`、`boundPending`、`boundTargets`、`boundUnconfirmed`、`boundAuthenticated`、`boundFeedbackConnected` 等内部字段,或 `__bound*`、`__panel*`、`__attachButtonPress`、`__attachMomentaryButton`、`sendBoundControl` 等方法用于页面模型或自定义方法。使用自己命名的普通字段,避免合并时冲突。
50
57
 
51
58
  不要手写任何 `data-cp-*` 属性。节点会在渲染副本中自动加入内部标记,页面源码不应依赖这些标记;手写此类属性会被拒绝。
52
59
 
53
- 不要给绑定控件添加 `@click`、`@input`、`@change`、`@update:model-value` 等事件指令,也不要额外绑定 `:value`、`:checked` 或 `:model-value`。节点需要独占这些事件和模型更新。页面导航、标签切换、纯视觉交互等事件可以放在不绑定设备的独立控件上。
60
+ 不要给绑定控件添加 `@click`、`@pointerdown`、`@pointerup`、`@keydown`、`@input`、`@change`、`@update:model-value` 等事件指令,也不要额外绑定 `:value`、`:checked` 或 `:model-value`。节点需要独占这些事件和模型更新。页面导航、标签切换、纯视觉交互等事件可以放在不绑定设备的独立控件上。
54
61
 
55
- 以下是两个例外:
62
+ 以下是允许的只读或语义绑定写法:
56
63
 
57
64
  - 只读原生 `<meter>`/`<progress>` 可以使用 `:value`。
58
65
  - 只读 `v-progress-linear`/`v-progress-circular` 可以使用简单 `v-model` 或 `:model-value`。
59
66
  - 语义开关按钮可以使用简单的 `:aria-pressed` 或 `:aria-checked`。
67
+ - XY-series 容器可以使用简单的 `:data-points`、`:data-series` 或 `:data-coordinates`。
68
+
69
+ 上述模型绑定只选一种,不把 `v-model` 和对应的 `:value`/`:model-value` 等同时写在同一控件上。
60
70
 
61
71
  所有 Vuetify 组件标签都成对闭合,例如 `<v-icon></v-icon>`。Dashboard 的模板渲染可能吞掉自闭合组件后面的内容,节点会在部署前拒绝 `<v-slider />`、`<v-progress-linear />` 等写法。原生 HTML void 元素,如 `<input>` 和 `<img>`,不受此限制。
62
72
 
@@ -66,17 +76,17 @@ export default {
66
76
 
67
77
  | 用途 | 原生 HTML | Vuetify | 模型与行为 |
68
78
  | --- | --- | --- | --- |
69
- | 连续数值控制 | `input[type=range]`、`input[type=number]` | `v-slider` | 数字;range 在拖动时发送中间值,结束时发送最终值。 |
70
- | 布尔控制 | `input[type=checkbox]`、带语义属性的 `button` | `v-switch`、`v-checkbox` | 布尔值;切换发送。 |
79
+ | 连续数值控制 | `input[type=range]`、`input[type=number]` | `v-slider` | 数字;原生控件在 input 时发送中间值、change 时提交最终值;`v-slider` 根据模型变化发送,停止变化后补交最终值。 |
80
+ | 布尔控制 | `input[type=checkbox]`、带语义属性的 `button` | `v-switch`、`v-checkbox` | 布尔值;默认切换发送。原生语义按钮还可在绑定窗口选择“按住生效”。 |
71
81
  | 单选枚举 | `select` | `v-select`、`v-radio-group` | 单个字符串或数字,必须是设备允许的枚举值。 |
72
82
  | 文本控制 | `input[type=text]`、`textarea` | `v-text-field`、`v-textarea` | 字符串;原生控件在 change 时提交,Vuetify 文本框在 change、失焦时提交,`v-text-field` 也支持 Enter 提交。 |
73
- | 普通只读反馈 | `span`、`output` 等简单文本展示 | 暂无专用组件 | 只读普通反馈,不发送命令。 |
83
+ | 普通只读反馈 | `span`、`strong`、`b`、`output`、`h1`、`h2`、`h3` | 暂无专用组件 | 单个简单字段插值,或静态文本由节点替换;不发送命令。普通 `div`/`p` 文本不属于此适配器。 |
74
84
  | 实时信号电平 Meter | `meter`、`progress` | `v-progress-linear`、`v-progress-circular` | 只读数值;必须绑定驱动的 Meter 点位,不发送命令。 |
75
85
  | 实时二维点列 XY-series | `canvas` + `:data-points` 或 `:data-series`;地图容器 + `:data-coordinates` | 第三方图表暂不自动识别 | 只读 `[[x, y], ...]` 数组;不发送命令。 |
76
86
 
77
- 交互控件建议写简单顶层 `v-model`。原生 range/select 可以使用 `.number`;不要给布尔、文本或 Meter 加 `.number`。Vuetify 进度组件可以使用简单 `v-model`,也可以使用简单 `:model-value`。原生 Meter/Progress 可以使用简单顶层 `v-model`,节点会把它转换成只读 `:value`;在 `v-for` 列表中应直接写 `:value="item.meter"`,不要写嵌套字段的原生 `v-model`。
87
+ 交互控件建议写简单顶层 `v-model`。数值输入和单选枚举可以使用 `.number`,仅用于数字模型;不要给布尔、文本或 Meter 加 `.number`。当前不支持 `.lazy`、`.trim` 或带参数的 `v-model`。Vuetify 进度组件可以使用简单 `v-model`,也可以使用简单 `:model-value`。原生 Meter/Progress 可以使用简单顶层 `v-model`,节点会把它转换成只读 `:value`;在 `v-for` 列表中应直接写 `:value="item.meter"`,不要写嵌套字段的原生 `v-model`。
78
88
 
79
- 初始化值应匹配控件类型:数值为数字,开关为布尔值,文本和枚举为字符串。不要用数组、对象或 `null` 作为普通绑定控件的占位值。
89
+ 初始化值应匹配控件类型:数值为数字,开关为布尔值,文本为字符串,枚举为与设备选项一致的单个字符串或数字。不要用数组、对象或 `null` 作为普通标量绑定控件的占位值。
80
90
 
81
91
  XY-series 使用数组字段初始化,例如 `spectrum: []`。推荐使用标准 Canvas:
82
92
 
@@ -84,16 +94,20 @@ XY-series 使用数组字段初始化,例如 `spectrum: []`。推荐使用标
84
94
  <canvas id="room-spectrum" :data-points="spectrum" aria-label="实时频谱"></canvas>
85
95
  ```
86
96
 
87
- 地图坐标使用普通 HTML 容器和标准地理坐标顺序 `[经度, 纬度]`:
97
+ 地图或平面定位使用普通 HTML 容器;地理坐标按 `[经度, 纬度]`,本地平面坐标按 `[x, y]`:
88
98
 
89
99
  ```html
90
100
  <div id="room-map" class="room-map"
91
101
  :data-coordinates="locations" aria-label="实时位置"></div>
92
102
  ```
93
103
 
94
- `locations` 的值也是完整快照,例如 `[[116.397, 39.908], [121.473, 31.230]]`。节点只负责把驱动的二维数组更新到这个普通 Vue 数组;地图底图、投影、标记和缩放属于页面自己的显示逻辑。当前自动识别支持 `div`、`section`、`article`、`main`、`aside`、`svg` 和 `g` 上的 `:data-coordinates`,不要求页面引入地图 SDK,也不要求页面知道驱动或订阅机制。
104
+ `locations` 的值也是完整快照,例如地理位置 `[[116.397, 39.908], [121.473, 31.230]]`,或园区平面位置 `[[20, 66], [230, 656]]`。节点不检查是否为经纬度,也不做投影或单位转换,只把二维数组更新到普通 Vue 数组。页面按约定的坐标系、单位、轴方向和底图范围绘制;同一模型不能混合两种坐标系。地图底图、投影、标记和缩放属于页面自己的显示逻辑。当前自动识别支持 `div`、`section`、`article`、`main`、`aside`、`svg` 和 `g` 上的 `:data-coordinates`,不要求页面引入地图 SDK,也不要求页面知道驱动或订阅机制。
105
+
106
+ 节点通过最新帧缓存和动画帧调度整体替换 `spectrum` 或 `locations`,拥塞时不保证每个驱动帧都进入页面。页面自己的绘图逻辑可以观察这个普通 Vue 数组。`data-points`、`data-series` 和 `data-coordinates` 都是普通 HTML `data-*` 属性,不是 ControlPanel 私有属性;但这些属性本身不会自动画出频谱或地图。
95
107
 
96
- 节点会把每个完整帧更新到 `spectrum` 或 `locations`,页面自己的绘图逻辑可以观察这个普通 Vue 数组。`data-points`、`data-series` 和 `data-coordinates` 都是普通 HTML `data-*` 属性,不是 ControlPanel 私有属性。Canvas 的频谱点列与地图坐标只是页面呈现语义不同,底层使用同一个只读 XY-series 通道。当前自动识别只支持顶层简单字段,不支持 `v-for` 内的二维点列模型、函数调用、表达式、多个数据源合并或第三方图表组件的专用数据格式。
108
+ 每个容器只选一个数组属性和一个独立顶层数组字段。Canvas 的频谱点列与地图坐标只是呈现语义不同,底层使用同一种只读 XY-series 通道。当前不支持绑定 `v-for` 内逐项数组模型、函数调用、表达式、多个数据源合并或第三方图表组件的专用数据格式。地图容器内部仍可用普通 `v-for` 绘制标记,这些标记不单独绑定设备。
109
+
110
+ 数组以整体替换方式更新,绘图通常使用浅层 `watch` 即可,无需 `deep: true` 遍历每个点。只观察数组来绘制,不改写接收数组;多图表需要时可在页面自己的 `requestAnimationFrame` 中合并绘制,并在卸载时清理绘图资源。这是纯显示逻辑,不是第二套设备通信。
97
111
 
98
112
  ### 3.1 普通控件写法
99
113
 
@@ -181,13 +195,18 @@ export default {
181
195
  列表规则:
182
196
 
183
197
  - 对象列表必须使用精确的 `:key="item.id"`;数组列表必须使用精确的 `:key="index"`。
184
- - `item.id` 必须稳定且在当前列表中唯一。重复 key/ID 的实例不会被安全绑定;Vue 重渲染期间短暂出现的重复 DOM 会被节点暂时忽略,key 恢复唯一后自动重新关联,但页面不应依赖这种容错。
185
- - 列表外的每个绑定控件都应有唯一的普通 `id` 或 `name`。列表内的重复实例可以使用同一个静态 `id`,节点通过列表 key 区分实例;不要为每一项拼接依赖运行时随机值的 ID。
198
+ - `item.id` 必须稳定且在当前列表中唯一。绑定实例 key 转为字符串后须为 1 到 160 个字符,仅含英文字母、数字、`.`、`_`、`:`、`/`、`-`;推荐短字符串 ID,不用中文、空格或对象作为 key。数字 `1` 和字符串 `'1'` 不能同时作为不同项的 ID。
199
+ - 重复 key/ID 的实例不会被安全绑定;Vue 重渲染期间短暂出现的重复 DOM 会被节点暂时忽略,key 恢复唯一后自动重新关联,但页面不应依赖这种容错。
200
+ - 对象列表重排时映射跟随稳定 `item.id`;数组列表映射跟随索引位置,不跟随原来的值。数组头部插入、删除或重排后,应检查所有受影响索引的映射,不能只给新增项补绑。
201
+ - 列表外的每个绑定控件都应有唯一的普通 `id` 或 `name`。列表内推荐使用示例中的动态唯一 HTML ID;识别器也接受重复实例使用同一个静态 `id`,但不同源码控件仍须使用不同 ID,避免候选冲突。不要使用运行时随机 ID。
202
+ - 列表内各实例的绑定模型应引用自己的项目字段或 `values[index]`,不要把所有实例绑定到同一个顶层字段。静态嵌套字段的中间对象须在 `data()` 中初始化,节点不负责创建缺失对象。
186
203
  - 列表控件在编辑页面中按实际渲染出的实例逐项选择。列表增加或删除后,应重新进入编辑器、补齐或移除对应映射,再 Apply。
187
204
  - `v-for` 可以写在实际容器上,也可以写在内层 `<template>` 上。不要把 `v-for` 直接写在唯一的外层节点 Template 上。
188
205
  - 不支持嵌套 `v-for`、动态列表来源、过滤或计算表达式、复杂 key、slot scope、自定义列表项事件和任意动态索引。
189
206
 
190
- `v-if`、标签页切换、普通 DOM 重建和过渡造成的重新渲染可以使用。节点会在元素重新出现后恢复绑定;不要通过 `mounted`、`watch` 或定时器手动补绑或回写模型。
207
+ `v-if`、标签页切换和普通 DOM 重建可以使用。模型、候选标识和列表 key 不变时,节点会在元素重新出现后恢复绑定;控件隐藏期间不保证每个中间帧都写入其模型。过渡期间出现重复实例时暂不关联,恢复唯一后重新关联。不要通过 `mounted`、`watch` 或定时器手动补绑或回写模型。
208
+
209
+ Bindings 列表中的已有映射按 Template 中的候选控件编辑,不要求该控件当前显示。切换标签页、`v-if` 隐藏或列表实例暂时不在 DOM 中时,仍可 Edit / Remove 已有映射;这不会切换页面标签或创建控件。新增列表实例仍需在实际显示时逐项选择,已删除列表项的旧映射应手动移除。只有候选控件从 Template 中删除或改为不支持的写法时,Edit 才禁用,Remove 仍可用。
191
210
 
192
211
  ### 3.3 Vuetify 控件
193
212
 
@@ -231,13 +250,16 @@ export default {
231
250
  以下写法当前不支持:
232
251
 
233
252
  - `v-btn` 作为自动设备按钮;需要布尔设备控制时使用语义按钮写法。
234
- - `v-range-slider`、多选、`return-object`、对象模型、数组模型、自定义 `true-value`/`false-value`。
253
+ - `v-range-slider`、多选、`return-object`、启用的 `v-select clearable`、对象模型、数组模型、自定义 `true-value`/`false-value`。
235
254
  - `v-autocomplete`、`v-combobox`、原生 radio 和带独立事件处理器的子控件。
255
+ - `v-text-field` 的非 text 类型或动态 `:type`;数值输入使用原生 `input[type=number]`。
236
256
  - 动态量程;range 的 `min`、`max`、`step` 必须是数字字面量或数字字面量绑定。`v-slider` 必须满足 min < max、step > 0。
237
257
  - `v-progress-linear` 的 `min`/`step`、`indeterminate`、`clickable`;`v-progress-circular` 的 `min`/`max`/`step`、`indeterminate`。前者按 0 到 max 工作,后者固定按 0 到 100 工作。
238
258
 
239
259
  下拉选项可以是标量数组或普通 `{ title, value }` 选项,但模型必须是单个标量,且值必须与设备枚举值一致。示例中的 A/B 只是页面占位数据,节点不会自动生成或修改下拉选项。
240
260
 
261
+ 原生 `input[type=number]` 可以不写范围,此时不进行页面侧量程换算。需要换算时明确写固定 `min`/`max`;原生 range 建议明确写量程。原生 `step="any"` 可以使用,`v-slider` 则必须使用正数 step。
262
+
241
263
  ## 4. Meter 与普通数值反馈
242
264
 
243
265
  Meter 是驱动端的特殊只读数据类型,不是“另一个滑杆”。页面作者只声明普通进度/电平控件;编辑人员把它绑定到带 Meter 元数据的 Feedback point 后,ControlPanel 自动完成批量订阅、同帧投影、页面侧量程换算、过期判断和重连。
@@ -245,12 +267,12 @@ Meter 是驱动端的特殊只读数据类型,不是“另一个滑杆”。
245
267
  - 一个 Meter 绑定只能使用 `read` 模式,不能配置 Write point。
246
268
  - 只有驱动点位带有有效 `.metadata.meter`,并且是只读整数/浮点参数时,才会出现在 Meter 可选目标中。
247
269
  - 普通 numeric `level`、`volume` 或其他连续参数不会因为名字像电平就自动变成 Meter。
248
- - 一个面板最多支持 256 个 Meter 绑定。多个 Meter 会按流聚合,前端每帧一次性更新本页所有对应表头;页面不编写任何批处理或 socket 代码。
270
+ - 同设备、同 stream 的多个 Meter 取自同一多通道帧;不同设备或不同 stream 独立接收。Room 聚合各流的最新值,不保证不同流采样同步。前端每个渲染批次更新本页对应表头,页面不编写任何批处理或 socket 代码。
249
271
  - Meter 需要独立字段,例如 `meter1`、`meter2`。不要与音量滑杆的 `volume` 共用字段,也不要让不同点位共用一个 Meter 模型。
250
- - 断线、无效帧和过期帧由节点处理;页面看到的是最后值并带无效状态,页面作者不添加轮询或回写逻辑。
272
+ - 未收到有效采样时模型保持初值;断线、无效采样和过期后保留最后值,控件的标准 `aria-invalid` 标为 `true`。数值模型本身不会变成特殊的“无效值”,不能把保留值当成新采样;需要时可用标准属性选择器显示无效样式,不依赖私有标记,也不添加轮询或回写。
251
273
  - 原生 `<meter>`/`<progress>` 默认页面范围是 0 到 1;Vuetify 进度组件分别使用固定的 0 到 max 或 0 到 100。应按页面显示范围声明固定量程。
252
274
 
253
- 量程适配发生在网页侧:页面量程与驱动量程之间做线性换算,反馈按相反方向换算,不修改驱动或协议。两端都必须是有限且 max > min 的数字;否则按原值传递,不能猜测量程。整数驱动值会取整,反馈会按页面 step 对齐。
275
+ 量程适配发生在网页侧:普通数值设置从页面范围换算到驱动范围,数值反馈和 Meter 从驱动范围换算到页面范围,不修改驱动或协议。两端都必须是有限且 max > min 的数字;否则按原值传递,不能猜测量程。换算会钳制到范围内,整数驱动值会取整,反馈会按有效的页面 step 对齐。XY-series 不使用这种标量量程换算。
254
276
 
255
277
  普通只读反馈使用文本展示即可:
256
278
 
@@ -259,6 +281,8 @@ Meter 是驱动端的特殊只读数据类型,不是“另一个滑杆”。
259
281
  <output id="room-value">{{ outputValue }}</output>
260
282
  ```
261
283
 
284
+ 文本展示只使用一个简单字段插值,例如 `{{ status }}` 或 `{{ outputValue }}`。不要使用 `toFixed()`、拼接表达式或多个插值作为绑定模型。格式化结果可显示在另外一个不绑定设备的元素中。
285
+
262
286
  ## 4.1 XY-series 与普通实时数组反馈
263
287
 
264
288
  XY-series 是 Meter 的独立数组版本,不是多个 Meter 的替代写法:
@@ -266,13 +290,22 @@ XY-series 是 Meter 的独立数组版本,不是多个 Meter 的替代写法
266
290
  - 每个驱动帧是一个完整的 `[[x, y], ...]` 快照,第一层长度可以变化,也可以是空数组。
267
291
  - 每个点必须恰好有两个有限数字;节点保留点的顺序,不自动排序、不自动缩放、不自动丢弃旧点。
268
292
  - 数据只读,绑定只能使用 `read` 模式,没有 Write point;它与 Volume、Level 和 Meter 不能共用同一个模型字段。
269
- - 多个页面绑定相同 stream 时只建立一个源订阅,页面帧使用 latest-wins;网络拥塞时允许丢弃旧帧,不补发历史帧。
270
- - 当前每个 stream 默认最多 8192 个点,每个面板最多 256 个 XY-series 绑定;真实 MUSE 测试前仍需按点数和帧大小验证吞吐。
293
+ - 同一面板内,同设备、同 stream 的多个绑定共用桥接订阅,页面帧使用 latest-wins;网络拥塞时允许丢弃旧帧,不补发历史帧。
294
+ - 不同 stream 独立接收。Room 消息聚合各 stream 的最新快照,不保证它们来自同一采样时刻;页面作者无需处理这一内部通信机制。
295
+ - 公共框架的 `XYSeriesStream` 默认容量为 2048 点,可配置上限为 8192 点;接收端按 manifest 声明的 `maxPoints` 校验整帧,每个面板最多 256 个 XY-series 绑定。这些是容量限制,不是吞吐保证;仍需在目标环境按点数、帧率和并发数量验证性能。
271
296
  - 页面作者不写 socket、订阅、帧解析或重连代码,只维护普通数组。频谱坐标轴、Canvas 绘制、坐标变换和图表样式属于页面显示逻辑。
297
+ - 断线、无效采样、过期或暂停订阅时,页面数组被替换为 `[]`,与 Meter 保留最后数值的行为不同。合法的空快照也为 `[]`,不能仅凭数组为空断言设备断线。
272
298
 
273
- 文本展示只使用一个简单字段插值,例如 `{{ status }}` 或 `{{ outputValue }}`。不要使用 `toFixed()`、拼接表达式或多个插值作为绑定模型。
299
+ ## 5. 按钮必须分开表达按压与设备状态
274
300
 
275
- ## 5. 语义开关按钮
301
+ 这是页面编写 AI 的必选模式,不是可选装饰。凡是控制静音、电源、启停等布尔设备状态的按钮,都使用本节写法;默认点击切换和按住生效只改变命令行为,不改变状态显示规则。
302
+
303
+ | 状态 | 表达方式 | 不能表示什么 |
304
+ | --- | --- | --- |
305
+ | 正在按压 | ControlPanel 自动提供下移 2px 和内阴影,松开恢复。 | 不代表命令成功、设备静音或电源已开启。 |
306
+ | 设备状态 | 由真实反馈更新普通布尔字段和标准 ARIA 属性;页面用该字段或属性控制状态颜色、指示灯、图标和状态文字。 | 不由按下、点击、松开、发送成功或本地定时器推测。 |
307
+
308
+ 按钮按压时可以仍显示“未静音”,直到设备反馈变为静音;按钮松开后也可以仍显示“已静音”,直到收到新的设备反馈。`aria-pressed` 在这里表示切换按钮的反馈状态,不是鼠标是否正在按压;不要用它驱动下陷效果。字段初值只是页面占位,首次反馈前或反馈断线后的保留值不能当成新收到的设备确认。
276
309
 
277
310
  普通文字 `<button>` 不会自动成为设备控件。需要布尔设备控制时使用标准语义,不添加事件:
278
311
 
@@ -281,7 +314,75 @@ XY-series 是 Meter 的独立数组版本,不是多个 Meter 的替代写法
281
314
  <button id="room-mute" type="button" role="switch" :aria-checked="muted">静音</button>
282
315
  ```
283
316
 
284
- 在 `data()` 中把 `powered` 和 `muted` 初始化为布尔值。也可以使用静态 `aria-pressed="false"` 或 `aria-checked="false"`,由节点更新状态。动态 ARIA 表达式只能是顶层字段,不能写取反、条件或函数。
317
+ 在 `data()` 中把 `powered` 和 `muted` 初始化为布尔值。也可以使用静态 `aria-pressed="false"` 或 `role="switch" aria-checked="false"`,由节点更新状态。动态 ARIA 表达式使用顶层字段或 3.2 节的列表项目字段,不能写取反、条件或函数。此类按钮控制布尔状态,不是一次性 Command 动作。
318
+
319
+ 推荐交付下面这种完整的设备按钮。下陷由节点提供,页面只定义基本样式和真实反馈样式:
320
+
321
+ ```html
322
+ <template>
323
+ <button id="room-mute" class="device-toggle" type="button" :aria-pressed="muted">
324
+ 静音
325
+ <small>{{ muted ? '已静音' : '未静音' }}</small>
326
+ </button>
327
+ </template>
328
+
329
+ <script>
330
+ export default {
331
+ data () { return { muted: false } }
332
+ }
333
+ </script>
334
+
335
+ <style>
336
+ .device-toggle {
337
+ display: inline-flex;
338
+ align-items: center;
339
+ gap: 10px;
340
+ min-height: 44px;
341
+ padding: 10px 14px;
342
+ border: 1px solid #70817a;
343
+ border-radius: 4px;
344
+ background: #fff;
345
+ color: #25332d;
346
+ font: inherit;
347
+ cursor: pointer;
348
+ }
349
+ .device-toggle::before {
350
+ content: '';
351
+ width: 8px;
352
+ height: 8px;
353
+ flex: 0 0 8px;
354
+ border-radius: 50%;
355
+ background: #89948e;
356
+ }
357
+ .device-toggle[aria-pressed="true"] {
358
+ border-color: #147d70;
359
+ background: #e5f4ee;
360
+ color: #0b5148;
361
+ }
362
+ .device-toggle[aria-pressed="true"]::before { background: #147d70; }
363
+ .device-toggle:focus-visible { outline: 2px solid #147d70; outline-offset: 3px; }
364
+ .device-toggle:disabled { cursor: not-allowed; opacity: .55; }
365
+ </style>
366
+ ```
367
+
368
+ 按钮名称“静音”保持稳定,状态用独立指示灯和小字表达。`small` 内的条件表达式只是显示逻辑;真正的绑定模型仍是简单的 `:aria-pressed="muted"`。可替换成页面已有的图标或视觉风格,但不得把反馈样式改成由本地按压触发。
369
+
370
+ 不要添加 `@click="muted = !muted"`、按压时修改 `muted`、模拟反馈的定时器、`v-model="muted"` 或额外的设备事件。也不要手写/查询内部按压标记,或用 `:active` 改变代表设备状态的颜色、指示灯、图标或文字;已绑定的设备按钮不需要自己再实现一套下陷效果。
371
+
372
+ 若按钮要显示设备真实状态,使用 `both` 模式并配置有效 Feedback point;`write` 模式只有发送,没有真实状态反馈,不能把请求成功或保留的初值显示成“设备已确认”。`read` 语义按钮只显示反馈,不提供可写按压行为。普通导航、弹窗、页面内操作不绑定设备,可以使用正常前端事件和本地 `:active` 下陷样式,但不能冒充设备状态。`v-btn` 当前不能作为自动设备按钮,不能承诺其具有节点提供的按压或反馈功能。
373
+
374
+ 按钮的 `Button behavior` 在绑定窗口选择,不写进页面事件或私有属性:
375
+
376
+ - `Click to toggle / 点击切换`:默认行为;旧绑定没有这个字段时仍按点击切换处理。
377
+ - `Hold to activate / 按住生效`:仅支持上述原生语义 `button`,自动要求 `both` 模式和完整 Write/Feedback point。鼠标、触摸或空格/回车按下发送 `true`,松开发送 `false`,不是“松开恢复之前的值”。Checkbox、Vuetify Switch 和只读按钮不使用这个行为。
378
+ - 两种行为的可写原生语义按钮都自动提供独立的按压下陷效果:按压时下移 2px 并显示内阴影,松开恢复;鼠标、触摸、Space/Enter 均支持。它只表示“正在按”,不表示命令成功或设备已经静音。页面作者不添加按压事件,也不读写节点内部的按压属性。
379
+ - 设备状态样式仍只使用真实反馈。例如用 `button[aria-checked="true"]`、`button[aria-pressed="true"]` 或 `muted` 控制颜色和图标;不要在按下时自行修改 `muted`,也不要用 `:active` 改变代表设备状态的颜色、图标或文字。下陷使用独立 CSS `translate`,不改页面原有 `transform`,也不改变布局尺寸;只读、禁用、编辑模式或未登录/反馈断线时不启用这个效果。
380
+ - 按住生效时,指针移出按钮后再松开仍会释放;仅移出但仍按住不会自动发送 `false`。节点还统一处理触摸取消、失焦、页面隐藏/退出、控件卸载/禁用和反馈连接中断。正常连接下释放立即提交;未收到释放时,服务端最长约 3 秒租约到期后尝试发送 `false`。设备或桥接离线、运行进程崩溃或主机断电时无法保证物理设备完成释放,页面仍须以真实反馈为准。
381
+ - 同一面板中,多个页面/按钮同时按住同一设备 Write point 时,最后一个释放才发送 `false`;租约续期不会反复发送设备 `true`。按住期间拒绝同一面板其他普通按钮对这个 Write point 的写入,避免相互覆盖。这不协调其他面板、外部控制系统或直接设备操作。
382
+ - 请求携带内部运行代际和序号,拒绝迟到的旧按下、旧续期和上一次运行的请求;页面作者不生成或操作这些内部字段。
383
+ - 编辑模式不发送按钮命令;普通控制页需要有效登录和已连接的反馈通道。中断后不会自动重新按下,用户需要再次操作。
384
+
385
+ 这些按压和租约规则由 ControlPanel 实现,驱动仍是现有布尔 Toggle;页面作者不实现心跳、租约、发送、异常释放或设备状态推测。
285
386
 
286
387
  ## 6. 绑定与编辑流程
287
388
 
@@ -289,32 +390,34 @@ XY-series 是 Meter 的独立数组版本,不是多个 Meter 的替代写法
289
390
 
290
391
  1. 将 Template 保存到 `ui-control-panel`,配置 Dashboard 页面、Room ID、普通登录账号和独立编辑账号,然后保存并 Deploy。
291
392
  2. 重新打开节点,点击 `Open binding editor`。编辑页登录使用编辑账号;进入后 Select 默认激活。`Open page` 打开普通控制页面,普通页面必须使用普通账号登录。
292
- 3. 在 Select 模式选择控件,选择设备、模式、Write point、Feedback point 和编号方式。已绑定控件在编辑模式显示亮紫色边框;这只是编辑提示。
393
+ 3. 在 Select 模式选择控件,选择设备、模式、Write point、Feedback point 和编号方式。Meter、XY-series 和文本展示只能用 `read`;当前 XY-series 弹窗仍可能默认显示 `both`,须手动选择 `read`。已绑定控件在编辑模式显示亮紫色边框;这只是编辑提示。
293
394
  4. 点 Apply。节点验证当前模板、设备目录和映射,备份当前 Flow,并通过运行时 Flow API 更新当前面板的映射和受管理桥接。它不会导入、清除或追加其他面板的 Flow。
294
- 5. Apply 成功后仍停留在编辑页;Bindings 列表可以 Edit 或 Remove,取消/保存后回到列表。Apply 后不应要求再次登录;点 Exit 才回到普通页面登录页。
395
+ 5. Apply 成功后会重新加载当前编辑页,使用仍有效的编辑会话继续进入;会话过期或配置改变时可能要求重新登录。Bindings 列表可以 Edit 或 Remove,从列表进入编辑时取消/保存后回到列表。点 Exit 退出编辑并回到普通页面登录页。
295
396
 
296
- Automator 的同步脚本只监听更新后的运行时 Flow 版本,并最多刷新一次 Flow 编辑器页面;它不读取、导入、合并、清除或再次提交 Flow。当前实现不判断画布是否有未 Deploy 改动,也不会合并画布草稿;在 Apply 前应先处理好 Automator 中未保存的画布改动。
397
+ Automator 的同步脚本监听 `notification/runtime-deploy` 的新版本通知,并在当前页面最多触发一次浏览器刷新;它不读取、导入、合并、清除或再次提交 Flow,也不额外点击 Deploy。监听不限于本面板 Apply,其他运行时版本变化也可能触发刷新。当前实现不判断画布是否有未 Deploy 改动,也不会合并画布草稿;在 Apply 前应先处理好 Automator 中未保存的画布改动。没有打开 Automator 编辑页或不支持该通知接口时,不能承诺自动刷新。
297
398
 
298
399
  Template 修改可能使已有映射失效。重新 Deploy 后,应重新打开绑定编辑器检查候选控件;列表增删、控件 ID/标签类型/模型写法改变后,也应重新检查并 Apply。普通页面登录账号不能进入编辑模式,未登录不能操作设备。
299
400
 
300
401
  ## 7. 点位匹配与量程
301
402
 
302
- 编辑器根据 AMX 参数描述推导可匹配类型,不根据页面文字,也不把驱动作者写入的旧式显式 `controlType` 当作页面契约。主要依据是 `.kind`、`.type`、`.access`、`.min`、`.max`、`.enums` 和 `.metadata`。Meter 的 `.metadata.meter` 是实时通道所需的特殊元数据,不能省略。
403
+ 本节供绑定使用者和排查人员参考,不要求页面编写 AI 在前端填写这些描述。编辑器根据 AMX 参数描述推导可匹配类型,不根据页面文字,也不把驱动作者写入的旧式显式 `controlType` 当作页面契约。主要依据是 `.kind`、`.type`、`.access`、`.min`、`.max`、`.enums` 和 `.metadata`。Meter 的 `.metadata.meter` 和 XY-series 的 `.metadata.xySeries` 是各自实时通道所需的特殊元数据,不能省略。
303
404
 
304
405
  | 参数描述 | 可匹配控件 |
305
406
  | --- | --- |
306
407
  | boolean,或没有枚举且范围恰为 0 到 1 的 integer | 布尔开关;整数 0/1 会转换为前端布尔值。 |
307
- | 可写 integer/float | range 或 `v-slider`;用于音量/增益等普通连续控制。 |
308
- | 只读 integer/float,没有 Meter 元数据 | 文本展示等普通只读反馈,不能选择为 Meter。 |
408
+ | 普通可写 integer/float,未被识别为布尔或枚举 | 数值 input 或 `v-slider`;用于音量/增益等普通连续控制。 |
409
+ | 普通只读 integer/float,没有 Meter 元数据 | 文本展示,或普通数值控件的 `read` 绑定;不能选择为 Meter。 |
309
410
  | 只读 integer/float,带有效 `.metadata.meter` | Meter、原生 `meter`/`progress` 或 Vuetify 进度组件;只能读。 |
310
411
  | 只读参数,带有效 `.metadata.xySeries` | XY-series;使用标准 Canvas 的 `:data-points`/`:data-series`,或地图容器的 `:data-coordinates` 数组字段;只能读。 |
311
412
  | enum,或带枚举描述的 integer/string | 单选枚举。 |
312
413
  | 没有枚举但路径以 `/source` 或 `/preset` 结尾的 integer | 选择类参数,仍需确认合法选项。 |
313
414
  | string/dstring | 文本输入或文本展示。 |
314
415
 
315
- 只有 `kind=param` 且具备相应读/写权限的点位参与绑定。普通命令、数组和未知描述不保证可绑定。
416
+ 只有 `kind=param` 且具备相应读/写权限的点位参与绑定。普通 Command 动作、数组容器和未知描述不能直接绑定。普通数值、布尔、枚举或文本控件也可以只读绑定;若需要阻止用户本地操作,页面另用标准 `disabled` 等属性表达只读交互。
417
+
418
+ 当前一个面板的所有可视绑定合计最多 256 项,包含普通控件、Meter 和 XY-series,并非每种类型各有 256 个独立名额。某些不推荐的字段共享写法未必被校验器全部拦截,不能把 Apply 成功当成模型设计无冲突的证明。
316
419
 
317
- Write point 改动时 Feedback point 会跟随;单独改 Feedback point 不会反向改 Write point。Meter 没有 Write point。编号方式默认 Zero-based;如果目录点位带有 `.onebased`,保存时以目录描述为准,否则使用编辑页选择。编号方式只影响驱动点位编号,不会给网页值加减 1。
420
+ 编辑弹窗中,`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。
318
421
 
319
422
  ## 8. 常见排查
320
423
 
@@ -328,8 +431,9 @@ Write point 改动时 Feedback point 会跟随;单独改 Feedback point 不会
328
431
  | 页面一直 Loading | 查看模板编译错误,重点检查原生 Meter/Progress 的 v-model 写法、自闭合 Vuetify、动态表达式和不支持的组件。设备离线不应被页面作者通过轮询“修复”。 |
329
432
  | 滑杆动不了 | 是否仍处于 Select 编辑模式;模型是否为数字;是否给绑定控件添加了自己的事件或回写逻辑。 |
330
433
  | 页面变化但设备不变 | 检查映射的 Write point、设备权限和运行日志。前端模型变化不等于设备已确认执行。 |
331
- | 设备值变了但页面旧 | 检查 Feedback point、普通反馈事件或 Meter frame 是否到达;节点不会凭写入成功伪造真实反馈。 |
332
- | Apply 失败并提示 Flow changed | 先处理 Automator 中的未 Deploy 改动,再刷新编辑器并重新打开绑定页;不要手工复制或追加生成桥接。 |
434
+ | 设备值变了但页面旧 | 检查 Feedback point、普通反馈事件或 Meter/XY-series frame 是否到达;节点不会凭写入成功伪造真实反馈。 |
435
+ | 按钮下陷了,但静音颜色未变 | 下陷只确认正在按;检查是否配置 both 和真实 Feedback point、设备是否执行、反馈是否到达。不要用前端强改布尔字段掩盖反馈问题。 |
436
+ | Apply 失败并提示 Flow changed | 运行时版本已不同于进入编辑页时的版本,或验证期间又有更新;重新加载绑定编辑页取得新版本并检查映射。重载前自行保留尚未 Apply 的草稿,也先处理 Automator 的未 Deploy 改动;不要复制或追加生成桥接。 |
333
437
 
334
438
  隔离测试只能证明模板识别、映射和软件通信逻辑,不能证明真实设备、MUSE 网络或驱动版本已经完成闭环测试。
335
439
 
@@ -337,10 +441,15 @@ Write point 改动时 Feedback point 会跟随;单独改 Feedback point 不会
337
441
 
338
442
  - 输出可粘贴的 Template 片段,不输出 Node-RED Flow、设备路径、绑定 JSON、账号密码或 ControlPanel API 调用。
339
443
  - 所有待绑定控件位于支持清单,模型字段简单、独立、可写且类型正确。
444
+ - 所有设备布尔按钮使用原生语义 `button` 和简单 `:aria-pressed` 或 `role="switch"` + `:aria-checked`;不使用 `v-btn` 或按钮 `v-model`。
445
+ - 设备按钮下陷由节点独立提供;状态颜色、指示灯、图标和文字来自反馈。没有按压时改布尔值、乐观切换、以请求成功代替反馈或用 `:active` 表示设备状态。
446
+ - 要显示真实设备状态的可写按钮使用 both;点击切换/按住生效在绑定窗口选择,页面不写按压发送或释放逻辑。纯本地按钮与设备按钮分开处理。
340
447
  - 列表只使用本指南规定的 `v-for`、`:key` 和模型路径;对象 key 稳定唯一,数组 key 使用 index。
341
448
  - 列表内的 Meter、Volume 和其他控件使用不同模型字段;重复实例依靠稳定 key 区分,不生成随机 ID。
342
449
  - XY-series 使用独立的顶层数组字段,例如 `spectrum: []`,不要与 Meter、Volume 或普通反馈字段共用。
343
- - 地图坐标使用独立的顶层数组字段,例如 `locations: []`,点顺序固定为 `[经度, 纬度]`;不要与 Meter、Volume 或普通反馈字段共用。
450
+ - 脚本只有一个 `export default { ... }`,辅助代码在组件选项内部,不占用节点内部字段或方法名。
451
+ - Canvas/地图具有实际绘图或标记逻辑,不把 `data-*` 数组属性当成现成图表组件;高频数组避免不必要的深层监听。
452
+ - 地图坐标使用独立的顶层数组字段,例如 `locations: []`;地理位置按 `[经度, 纬度]`,平面位置按约定的 `[x, y]`,页面绘制与坐标系/单位/轴方向一致。不要与 Meter、Volume 或普通反馈字段共用。
344
453
  - 页面量程明确、数值常量有效;枚举选项与实际设备值对应。
345
454
  - 没有手写 `data-cp-*`、设备通信、轮询、反馈回写、绑定控件事件或第二套发送逻辑。
346
455
  - 实时表头仍是普通 HTML/Vue/Vuetify 控件;没有 stream、socket、订阅、帧协议或驱动代码。
package/README.md CHANGED
@@ -12,10 +12,16 @@ 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, 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. 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
+ 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 boolean true on press and false 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. Other panels and external controllers are not coordinated. Device or bridge outages can prevent physical release, so feedback remains the authority. Drivers keep the existing boolean Toggle contract; page authors do not implement hold events or lease traffic.
16
+
17
+ 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
+
15
19
  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
20
 
17
21
  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>`. Map coordinates use the standard `[longitude, latitude]` order. 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.
18
22
 
23
+ Different XY-series streams arrive independently. Room delivery aggregates their latest snapshots, not synchronized samples from a shared acquisition frame. The public framework defaults to 2048 points per stream and allows a configured maximum of 8192; the receiver enforces the manifest's `maxPoints`. A panel supports at most 256 XY-series bindings. These limits do not guarantee throughput at every frame rate or concurrency level; validate performance in the target environment.
24
+
19
25
  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
26
 
21
27
  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.
package/control-panel.js CHANGED
@@ -12,6 +12,7 @@ const { createGateway } = require('./gateway.cjs')
12
12
  const { managedBridgeRevision } = require('./managed-bridge-revision')
13
13
  const { createMeterRoom, createPanelMeter } = require('./panel-meter')
14
14
  const { createXYSeriesRoom, createPanelXYSeries } = require('./panel-xy-series')
15
+ const { createMomentaryControls } = require('./panel-momentary')
15
16
 
16
17
  let statestore
17
18
  const authHandoffKey = crypto.randomBytes(32)
@@ -173,6 +174,9 @@ module.exports = function (RED) {
173
174
 
174
175
  let gateway
175
176
  let gatewayAuthKey
177
+ const momentary = createMomentaryControls({ routes,
178
+ send: (command, identity) => node.send({ ...command, room: identity.room, user: identity.user }),
179
+ onError: error => node.error(error) })
176
180
  if (visualBinding && /^[a-zA-Z0-9_-]+$/.test(boundRoom || '')) {
177
181
  try {
178
182
  gatewayAuthKey = authSignature([node.id, boundRoom, node.credentials?.username,
@@ -182,6 +186,7 @@ module.exports = function (RED) {
182
186
  password: node.credentials?.password,
183
187
  room: boundRoom,
184
188
  prefix: endpoint,
189
+ holdEpoch: momentary.epoch,
185
190
  secureCookie: config.secureCookie === true,
186
191
  restoredSessions: takeSessions(gatewayHandoffs, node.id, gatewayAuthKey),
187
192
  onAuthenticatedRequest: (req, res, identity) => meter?.ticket(req, res, identity,
@@ -189,8 +194,14 @@ module.exports = function (RED) {
189
194
  () => gateway?.sessionActive(identity.token)) || false,
190
195
  onBoundCommand: (message, identity) => {
191
196
  if (!bridgeReady) return false
192
- node.send({ ...resolveBoundCommand(routes, message), room: identity.room, user: identity.user })
197
+ if (message?._controlPanel?.hold) return momentary.handle(message, identity)
198
+ const command = resolveBoundCommand(routes, message)
199
+ if (routes.get(command.controlId)?.control.buttonBehavior === 'momentary' || momentary.isHeld(command)) {
200
+ throw Error('A held button requires the momentary protocol')
201
+ }
202
+ node.send({ ...command, room: identity.room, user: identity.user })
193
203
  },
204
+ onSessionClosed: token => momentary.releaseSession(token),
194
205
  onBoundLogin: identity => {
195
206
  if (!boundMode || !bridgeReady) return
196
207
  try { node.send({ kind: 'read-all', room: identity.room, user: identity.user }) } catch (error) { node.error(error) }
@@ -338,6 +349,7 @@ module.exports = function (RED) {
338
349
  registerWidget()
339
350
  node.on('close', () => {
340
351
  clearTimeout(retryTimer)
352
+ momentary.close()
341
353
  if (gateway) holdSessions(gatewayHandoffs, node.id, gatewayAuthKey, gateway.sessionSnapshot())
342
354
  if (editor) holdSessions(editorHandoffs, node.id, editorAuthKey, editor.sessionSnapshot())
343
355
  gateway?.close()
package/control-policy.js CHANGED
@@ -7,6 +7,8 @@ const { createRoomMeterReceiver, createMeterRenderer } = require('./meter-frame'
7
7
  const { createPanelMeterClient } = require('./panel-meter-client')
8
8
  const { createRoomXYSeriesReceiver, createXYSeriesRenderer } = require('./xy-series-frame')
9
9
  const { createPanelXYSeriesClient } = require('./panel-xy-series-client')
10
+ const { attachMomentaryButton } = require('./panel-momentary-client')
11
+ const { attachButtonPress } = require('./panel-button-press')
10
12
 
11
13
  const CONTROL_POLICY_MARKER = '/* ui-control-panel-control-api-v1 */'
12
14
 
@@ -173,6 +175,8 @@ function buildDashboardPolicyOptionsSource (controls = [], boundPanel = null) {
173
175
  : ''
174
176
  const visualMethods = boundPanel?.visual
175
177
  ? `__createTargetMatcher: ${createTargetMatcher.toString()},
178
+ __attachMomentaryButton: ${attachMomentaryButton.toString()},
179
+ __attachButtonPress: ${attachButtonPress.toString()},
176
180
  __initializeVisualBinding: ${initializeVisualBinding.toString()},
177
181
  __disposeVisualBinding: ${disposeVisualBinding.toString()},`
178
182
  : ''
@@ -379,7 +383,7 @@ export default {
379
383
  this.boundState = {}
380
384
  this.boundFeedbackRevision = 0
381
385
  }
382
- this.__boundPanelSession = { room: session.room, csrf: session.csrf }
386
+ this.__boundPanelSession = { room: session.room, csrf: session.csrf, holdEpoch: session.holdEpoch }
383
387
  this.boundAuthenticated = true
384
388
  this.__connectBoundFeedback()
385
389
  },
package/gateway.cjs CHANGED
@@ -10,6 +10,8 @@ function createGateway ({
10
10
  onBoundCommand,
11
11
  onBoundLogin,
12
12
  onAuthenticatedRequest,
13
+ onSessionClosed,
14
+ holdEpoch,
13
15
  secureCookie = false,
14
16
  prefix,
15
17
  sessionTtlMs = 8 * 3600000,
@@ -62,6 +64,7 @@ function createGateway ({
62
64
  const session = sessions.get(subscriber.token)
63
65
  if (!session || session.expiry < now) {
64
66
  sessions.delete(subscriber.token)
67
+ onSessionClosed?.(subscriber.token)
65
68
  closeSubscriber(subscriber)
66
69
  continue
67
70
  }
@@ -128,6 +131,7 @@ function createGateway ({
128
131
  for (const [id, session] of sessions) {
129
132
  if (session.expiry < now) {
130
133
  sessions.delete(id)
134
+ onSessionClosed?.(id)
131
135
  closeSubscribersForToken(id)
132
136
  }
133
137
  }
@@ -172,7 +176,7 @@ function createGateway ({
172
176
  const token = crypto.randomBytes(32).toString('hex')
173
177
  const csrf = crypto.randomBytes(24).toString('hex')
174
178
  sessions.set(token, { csrf, expiry: now + sessionTtlMs })
175
- reply(res, 200, { room, username, csrf }, {
179
+ reply(res, 200, { room, username, csrf, ...(holdEpoch ? { holdEpoch } : {}) }, {
176
180
  'Set-Cookie': cookie(token, Math.ceil(sessionTtlMs / 1000), req)
177
181
  })
178
182
  if (typeof onBoundLogin === 'function') {
@@ -197,7 +201,7 @@ function createGateway ({
197
201
  if (onAuthenticatedRequest && await onAuthenticatedRequest(req, res,
198
202
  { token, expiry: session.expiry, room, user: username })) return true
199
203
  if (path === routePrefix + '/session' && req.method === 'GET') {
200
- reply(res, 200, { room, username, csrf: session.csrf })
204
+ reply(res, 200, { room, username, csrf: session.csrf, ...(holdEpoch ? { holdEpoch } : {}) })
201
205
  return true
202
206
  }
203
207
  if (path === routePrefix + '/events' && req.method === 'GET') {
@@ -226,6 +230,7 @@ function createGateway ({
226
230
  }
227
231
  if (path === routePrefix + '/logout' && req.method === 'POST') {
228
232
  sessions.delete(token)
233
+ onSessionClosed?.(token)
229
234
  closeSubscribersForToken(token)
230
235
  reply(res, 200, { ok: true }, { 'Set-Cookie': cookie('', 0, req) })
231
236
  return true
@@ -240,11 +245,12 @@ function createGateway ({
240
245
  reply(res, 403, { error: 'Region denied' })
241
246
  return true
242
247
  }
243
- if (await onBoundCommand(data, { room, user: username }) === false) {
248
+ const result = await onBoundCommand(data, { room, user: username, token, expiry: session.expiry })
249
+ if (result === false) {
244
250
  reply(res, 409, { error: 'Managed bridge is out of sync' })
245
251
  return true
246
252
  }
247
- reply(res, 200, { ok: true })
253
+ reply(res, 200, { ok: true, ...(typeof result?.held === 'boolean' ? { held: result.held } : {}) })
248
254
  return true
249
255
  }
250
256
 
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "node-red-contrib-room-tools",
3
- "version": "1.9.1",
3
+ "version": "1.9.3",
4
4
  "lockfileVersion": 2,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "node-red-contrib-room-tools",
9
- "version": "1.9.1",
9
+ "version": "1.9.3",
10
10
  "license": "UNLICENSED",
11
11
  "dependencies": {
12
12
  "@vue/compiler-sfc": "3.5.42",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-room-tools",
3
- "version": "1.9.1",
3
+ "version": "1.9.3",
4
4
  "description": "Authenticated visual control panel binding for Node-RED and MUSE Automator",
5
5
  "license": "UNLICENSED",
6
6
  "engines": {
@@ -38,6 +38,9 @@
38
38
  "panel-http.js",
39
39
  "panel-flow-editor-sync.js",
40
40
  "panel-binding-editor.js",
41
+ "panel-momentary.js",
42
+ "panel-momentary-client.js",
43
+ "panel-button-press.js",
41
44
  "native-binding-plan.js",
42
45
  "managed-native-bridge.js",
43
46
  "managed-bridge-revision.js",
@@ -13,6 +13,9 @@ function initializeVisualBinding () {
13
13
  let meterClient = null, lastMeterFrame = null
14
14
  let xySeriesClient = null, lastXYSeriesFrame = null
15
15
  const attached = new WeakSet()
16
+ const buttonPresses = new Map()
17
+ const momentaryButtons = new Map()
18
+ const momentaryStates = new Map()
16
19
  const feedbackModels = new Map()
17
20
  const pendingText = new Map()
18
21
  const cleanup = []
@@ -196,7 +199,9 @@ function initializeVisualBinding () {
196
199
  const node = nodeForBinding(binding)
197
200
  if (!node) continue
198
201
  if (!Object.prototype.hasOwnProperty.call(component.boundState, id)) continue
199
- const value = component.boundDisplayValue(id, component.boundState[id])
202
+ const value = node.tagName === 'BUTTON' && binding.adapter === 'switch'
203
+ ? component.controlValue(id, component.boundState[id])
204
+ : component.boundDisplayValue(id, component.boundState[id])
200
205
  const displayValue = ['range', 'meter'].includes(binding.adapter)
201
206
  ? mappedValue(Number(value), binding.readMeta || binding.writeMeta,
202
207
  numericRange(binding, node)) : value
@@ -233,6 +238,22 @@ function initializeVisualBinding () {
233
238
  }
234
239
  }
235
240
  function attachControls () {
241
+ for (const [node, button] of buttonPresses) {
242
+ const binding = saved.get(button.id)
243
+ if (!binding || nodeForBinding(binding) !== node) {
244
+ button.controller.dispose()
245
+ buttonPresses.delete(node)
246
+ attached.delete(node)
247
+ } else button.controller.check()
248
+ }
249
+ for (const [node, button] of momentaryButtons) {
250
+ const binding = saved.get(button.id)
251
+ if (!binding || nodeForBinding(binding) !== node) {
252
+ button.controller.dispose()
253
+ momentaryButtons.delete(node)
254
+ attached.delete(node)
255
+ } else button.controller.check()
256
+ }
236
257
  for (const node of root.querySelectorAll('[data-cp-auto]')) {
237
258
  if (attached.has(node)) continue
238
259
  const locator = node.getAttribute('data-cp-auto')
@@ -242,6 +263,22 @@ function initializeVisualBinding () {
242
263
  if (nodeForBinding(binding) !== node) continue
243
264
  attached.add(node)
244
265
  if (binding.mode === 'read') continue
266
+ if (node.tagName === 'BUTTON' && binding.adapter === 'switch') {
267
+ buttonPresses.set(node, { id, controller: component.__attachButtonPress({ node,
268
+ canPress: () => !disposed && !editing && component.boundAuthenticated && component.boundFeedbackConnected }) })
269
+ }
270
+ if (binding.buttonBehavior === 'momentary' && node.tagName === 'BUTTON') {
271
+ if (!momentaryStates.has(id)) momentaryStates.set(id, { seq: 0 })
272
+ const controller = component.__attachMomentaryButton({ node, id,
273
+ state: momentaryStates.get(id),
274
+ endpoint: component.__boundPanelConfig().endpoint,
275
+ getSession: () => component.__boundPanelSession,
276
+ canStart: () => !disposed && !editing && component.boundAuthenticated && component.boundFeedbackConnected,
277
+ request: (...args) => component.__panelFetch(...args),
278
+ onError: error => console.error('ControlPanel momentary command failed', error) })
279
+ momentaryButtons.set(node, { id, controller })
280
+ continue
281
+ }
245
282
  if (binding.component) {
246
283
  if (binding.adapter === 'text-input') {
247
284
  const commit = () => {
@@ -593,6 +630,17 @@ function initializeVisualBinding () {
593
630
  const modeOptions = ['meter', 'text-display'].includes(candidate.adapter)
594
631
  ? ['read'] : ['both', 'write', 'read']
595
632
  const mode = field(form, 'Mode', choose('Mode', modeOptions.map(value => ({ value, text: value })), existing?.mode || modeOptions[0]))
633
+ const behavior = candidate.button ? field(form, 'Button behavior', choose('Button behavior', [
634
+ { value: 'toggle', text: 'Click to toggle / 点击切换' },
635
+ { value: 'momentary', text: 'Hold to activate / 按住生效' }
636
+ ], existing?.buttonBehavior || 'toggle')) : null
637
+ function updateButtonMode () {
638
+ if (!behavior) return
639
+ mode.disabled = behavior.value === 'momentary'
640
+ if (mode.disabled) mode.value = 'both'
641
+ mode.closest('label').dataset.disabled = String(mode.disabled)
642
+ }
643
+ updateButtonMode()
596
644
  const device = field(form, 'Device', choose('Device', devices.filter(item => item.online)
597
645
  .map(item => ({ value: item.id, text: item.id })), existing?.device))
598
646
  const write = field(form, 'Write point', choose('Write point', [], existing?.writePath))
@@ -626,8 +674,10 @@ function initializeVisualBinding () {
626
674
  }
627
675
  write.value = oldWrite || ''
628
676
  read.value = oldRead || ''
629
- write.closest('label').hidden = mode.value === 'read'
630
- read.closest('label').hidden = mode.value === 'write'
677
+ write.disabled = mode.value === 'read'
678
+ read.disabled = mode.value === 'write'
679
+ write.closest('label').dataset.disabled = String(write.disabled)
680
+ read.closest('label').dataset.disabled = String(read.disabled)
631
681
  const available = mode.value === 'read' ? readCount
632
682
  : mode.value === 'write' ? writeCount : Math.min(writeCount, readCount)
633
683
  message.textContent = targets.length && !available ? 'No compatible points for this control'
@@ -651,8 +701,12 @@ function initializeVisualBinding () {
651
701
  targets = []; updateTargets(); message.textContent = error.message
652
702
  }
653
703
  })
654
- write.addEventListener('change', () => { read.value = write.value })
704
+ write.addEventListener('change', () => {
705
+ if (mode.value === 'both') read.value = write.value
706
+ })
655
707
  mode.addEventListener('change', updateTargets)
708
+ behavior?.addEventListener('change', () => { updateButtonMode(); updateTargets() })
709
+ updateTargets()
656
710
  if (device.value) device.dispatchEvent(new Event('change'))
657
711
  const actions = element('div', null, 'cpv-actions')
658
712
  actions.append(button('Cancel', () => finishBindingEdit(returnToBindings)))
@@ -672,11 +726,12 @@ function initializeVisualBinding () {
672
726
  message.textContent = 'Control ID must be unique and valid'; return
673
727
  }
674
728
  if (!name.value.trim() || !device.value || (mode.value !== 'read' && !write.value) ||
675
- (mode.value !== 'write' && !read.value) || !numbering.value) {
729
+ (mode.value !== 'write' && !read.value) || !numbering.value || (behavior && !behavior.value)) {
676
730
  message.textContent = 'Complete the binding fields'; return
677
731
  }
678
732
  const entry = {
679
733
  id: id.value, locator: candidate.locator, adapter: candidate.adapter,
734
+ ...(behavior ? { buttonBehavior: behavior.value } : {}),
680
735
  ...(candidate.model ? { model: candidate.model } : {}),
681
736
  ...(candidate.repeat ? { repeat: candidate.repeat, instanceKey: String(candidate.instanceKey) } : {}),
682
737
  label: name.value.trim(), mode: mode.value, device: device.value,
@@ -757,8 +812,9 @@ function initializeVisualBinding () {
757
812
  const baseCandidate = snapshot.candidates.find(item => item.locator === entry.locator && item.supported)
758
813
  const candidate = baseCandidate?.repeat ? { ...baseCandidate, instanceKey: entry.instanceKey } : baseCandidate
759
814
  const edit = button('Edit', () => bindingDialog(candidate, { returnToBindings: true }))
760
- edit.disabled = !candidate || !nodeForBinding(entry)
761
- if (edit.disabled) edit.title = 'This control is no longer available on the page'
815
+ // Existing mappings belong to the template, not the currently rendered tab.
816
+ edit.disabled = !candidate
817
+ if (edit.disabled) edit.title = 'This control is no longer supported or present in the template'
762
818
  actions.append(edit)
763
819
  actions.append(button('Remove', () => {
764
820
  draft = draft.filter(item => item.id !== entry.id)
@@ -886,7 +942,20 @@ function initializeVisualBinding () {
886
942
  attachControls()
887
943
  updateBindingMarkers()
888
944
  })
889
- observer.observe(root, { childList: true, subtree: true })
945
+ observer.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ['disabled'] })
946
+ cleanup.push(() => {
947
+ for (const button of buttonPresses.values()) button.controller.dispose()
948
+ buttonPresses.clear()
949
+ for (const button of momentaryButtons.values()) button.controller.dispose()
950
+ momentaryButtons.clear()
951
+ momentaryStates.clear()
952
+ })
953
+ cleanup.push(this.$watch(() => component.boundAuthenticated && component.boundFeedbackConnected, connected => {
954
+ if (!connected) {
955
+ for (const button of buttonPresses.values()) button.controller.release()
956
+ for (const button of momentaryButtons.values()) button.controller.release()
957
+ }
958
+ }))
890
959
  const unwatch = this.$watch('boundState', showFeedback)
891
960
  cleanup.push(() => unwatch(), () => observer.disconnect(), () => toolbar?.remove(),
892
961
  () => closeOverlay(), () => closeLoginScreen())
@@ -894,6 +963,7 @@ function initializeVisualBinding () {
894
963
 
895
964
  const style = element('style')
896
965
  style.textContent = `
966
+ button[data-cpv-press="true"]{translate:0 2px !important;box-shadow:inset 0 2px 4px #0003 !important}
897
967
  .cpv-toolbar,.cpv-dialog,.cpv-menu{font:14px/1.4 system-ui,sans-serif;color:#182224;background:#fff}
898
968
  .cpv-toolbar{position:fixed;z-index:2147483000;top:12px;right:12px;display:flex;align-items:center;gap:8px;padding:8px;border:1px solid #9baaad;box-shadow:0 8px 24px #0003;max-width:calc(100vw - 24px)}
899
969
  .cpv-toolbar button,.cpv-dialog button{padding:7px 11px;border:1px solid #809296;background:#fff;color:#182224;border-radius:4px;cursor:pointer;font:inherit}
@@ -944,6 +1014,7 @@ function initializeVisualBinding () {
944
1014
  .cpv-dialog{width:min(460px,100%);max-height:min(90vh,720px);overflow:auto;padding:28px 18px 18px;border-radius:6px;box-shadow:0 12px 36px #0005}
945
1015
  .cpv-dialog h2{font-size:18px;margin:0 0 20px}.cpv-field{display:block;margin:10px 0}.cpv-field span{display:block;font-size:12px;margin-bottom:4px}
946
1016
  .cpv-field input,.cpv-field select{box-sizing:border-box;width:100%;padding:8px;border:1px solid #86999c;border-radius:4px;background:white;color:#182224;font:inherit}
1017
+ .cpv-field[data-disabled=true] span{color:#66787c}.cpv-field select:disabled{background:#edf1f2;color:#66787c;border-color:#c5cfd1;cursor:not-allowed;opacity:1}
947
1018
  .cpv-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:16px}.cpv-dialog>button:last-child{margin-top:20px}.cpv-message{min-height:18px;font-size:12px}
948
1019
  .cpv-menu{position:fixed;z-index:2147483002;width:170px;padding:4px;border:1px solid #9baaad;box-shadow:0 8px 24px #0003}
949
1020
  .cpv-menu button{display:block;width:100%;padding:8px;text-align:left;border:0;background:white;color:#182224;font:inherit;cursor:pointer}
@@ -0,0 +1,46 @@
1
+ // Serialized into the Dashboard component; press appearance never changes device state.
2
+ function attachButtonPress ({ node, canPress }) {
3
+ let pointer = null
4
+ const keys = new Set(), listeners = []
5
+ function listen (target, name, callback) {
6
+ target.addEventListener(name, callback)
7
+ listeners.push(() => target.removeEventListener(name, callback))
8
+ }
9
+ function render () {
10
+ if (pointer !== null || keys.size) node.setAttribute('data-cpv-press', 'true')
11
+ else node.removeAttribute('data-cpv-press')
12
+ }
13
+ function release () { pointer = null; keys.clear(); render() }
14
+ function allowed () { return node.isConnected && !node.disabled && !document.hidden && canPress() }
15
+ listen(node, 'pointerdown', event => {
16
+ if (event.button !== 0 || pointer !== null || !allowed()) return
17
+ pointer = event.pointerId
18
+ render()
19
+ })
20
+ const pointerEnd = event => {
21
+ if (pointer !== event.pointerId) return
22
+ pointer = null
23
+ render()
24
+ }
25
+ listen(window, 'pointerup', pointerEnd)
26
+ listen(window, 'pointercancel', pointerEnd)
27
+ listen(node, 'lostpointercapture', pointerEnd)
28
+ listen(node, 'keydown', event => {
29
+ if (![' ', 'Enter'].includes(event.key) || !allowed()) return
30
+ keys.add(event.key)
31
+ render()
32
+ })
33
+ listen(window, 'keyup', event => { if (keys.delete(event.key)) render() })
34
+ listen(node, 'blur', release)
35
+ listen(window, 'blur', release)
36
+ listen(window, 'offline', release)
37
+ listen(window, 'pagehide', release)
38
+ listen(document, 'visibilitychange', () => { if (document.hidden) release() })
39
+ return {
40
+ release,
41
+ check () { if (!allowed()) release() },
42
+ dispose () { release(); for (const remove of listeners) remove() }
43
+ }
44
+ }
45
+
46
+ module.exports = { attachButtonPress }
@@ -0,0 +1,91 @@
1
+ // Serialized into the Dashboard component; only standard browser APIs belong here.
2
+ function attachMomentaryButton ({ node, id, endpoint, getSession, canStart, request, onError, state = { seq: 0 } }) {
3
+ if (!state.client) state.client = Array.from(crypto.getRandomValues(new Uint8Array(16)), byte => byte.toString(16).padStart(2, '0')).join('')
4
+ let generation = 0, active = null, timer = null
5
+ const listeners = []
6
+ function listen (target, name, callback) {
7
+ target.addEventListener(name, callback)
8
+ listeners.push(() => target.removeEventListener(name, callback))
9
+ }
10
+ function transmit (action, session) {
11
+ return request(endpoint + '/bound-command', {
12
+ method: 'POST', credentials: 'same-origin', keepalive: action === 'release',
13
+ headers: { 'Content-Type': 'application/json', 'X-Room-CSRF': session.csrf },
14
+ body: JSON.stringify({ room: session.room, _controlPanel: { kind: 'command', id,
15
+ value: action !== 'release', final: true,
16
+ hold: { client: state.client, seq: ++state.seq, action, epoch: session.holdEpoch } } })
17
+ }, 1800, async response => {
18
+ if (!response.ok) throw Error('Momentary command failed (' + response.status + ')')
19
+ if (typeof response.json === 'function') {
20
+ const result = await response.json()
21
+ if (action !== 'release' && result.held !== true) throw Error('Momentary hold expired; press again')
22
+ }
23
+ })
24
+ }
25
+ function release () {
26
+ if (!active) return
27
+ const previous = active
28
+ active = null
29
+ generation++
30
+ clearTimeout(timer)
31
+ timer = null
32
+ void transmit('release', previous.session).catch(onError)
33
+ if (previous.pointer !== undefined && node.hasPointerCapture?.(previous.pointer)) {
34
+ try { node.releasePointerCapture(previous.pointer) } catch (_) {}
35
+ }
36
+ }
37
+ function renew (version) {
38
+ if (!active || version !== generation) return
39
+ if (!node.isConnected || !canStart()) { release(); return }
40
+ void transmit('renew', active.session).then(() => {
41
+ if (active && version === generation) timer = setTimeout(() => renew(version), 750)
42
+ }).catch(error => {
43
+ if (active && version === generation) release()
44
+ onError(error)
45
+ })
46
+ }
47
+ function start (source) {
48
+ if (active || node.disabled || !canStart()) return
49
+ const session = getSession()
50
+ if (!session?.csrf) return
51
+ active = { ...source, session: { ...session } }
52
+ const version = ++generation
53
+ void transmit('start', active.session).then(() => {
54
+ if (active && version === generation) timer = setTimeout(() => renew(version), 750)
55
+ }).catch(error => {
56
+ if (active && version === generation) release()
57
+ onError(error)
58
+ })
59
+ }
60
+ listen(node, 'pointerdown', event => {
61
+ if (event.button !== 0 || active) return
62
+ event.preventDefault()
63
+ node.focus()
64
+ start({ pointer: event.pointerId })
65
+ if (active) { try { node.setPointerCapture(event.pointerId) } catch (_) {} }
66
+ })
67
+ const pointerEnd = event => { if (active?.pointer === event.pointerId) release() }
68
+ listen(window, 'pointerup', pointerEnd)
69
+ listen(window, 'pointercancel', pointerEnd)
70
+ listen(node, 'lostpointercapture', pointerEnd)
71
+ listen(node, 'keydown', event => {
72
+ if (![' ', 'Enter'].includes(event.key)) return
73
+ event.preventDefault()
74
+ if (!event.repeat) start({ key: event.key })
75
+ })
76
+ listen(window, 'keyup', event => {
77
+ if (active?.key === event.key) { event.preventDefault(); release() }
78
+ })
79
+ listen(node, 'click', event => event.preventDefault())
80
+ listen(node, 'blur', release)
81
+ listen(window, 'blur', release)
82
+ listen(window, 'pagehide', release)
83
+ listen(document, 'visibilitychange', () => { if (document.hidden) release() })
84
+ return {
85
+ release,
86
+ check () { if (!node.isConnected || node.disabled || !canStart()) release() },
87
+ dispose () { release(); for (const remove of listeners) remove() }
88
+ }
89
+ }
90
+
91
+ module.exports = { attachMomentaryButton }
@@ -0,0 +1,93 @@
1
+ const { resolveBoundCommand } = require('./control-bound-protocol')
2
+ const crypto = require('node:crypto')
3
+
4
+ function createMomentaryControls ({ routes, send, onError = () => {}, leaseMs = 3000,
5
+ now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout }) {
6
+ const records = new Map()
7
+ const epoch = crypto.randomBytes(16).toString('hex')
8
+ let timer = null
9
+ let closed = false
10
+ const destination = command => JSON.stringify([command.device, command.path])
11
+ const occupied = key => [...records.values()].some(record => record.active && record.target === key)
12
+
13
+ function release (record) {
14
+ if (!record.active) return
15
+ record.active = false
16
+ if (!occupied(record.target)) {
17
+ try { send(record.off, record.identity) } catch (error) { onError(error) }
18
+ }
19
+ }
20
+ function sweep () {
21
+ const time = now()
22
+ for (const [key, record] of records) {
23
+ if (record.active && record.until <= time) release(record)
24
+ if (record.identity.expiry <= time) { release(record); records.delete(key) }
25
+ }
26
+ }
27
+ function schedule () {
28
+ if (timer) clearTimer(timer)
29
+ timer = null
30
+ const deadlines = [...records.values()].filter(record => record.active).map(record => record.until)
31
+ if (closed || !deadlines.length) return
32
+ timer = setTimer(() => { timer = null; sweep(); schedule() }, Math.max(1, Math.min(...deadlines) - now()))
33
+ timer.unref?.()
34
+ }
35
+ function handle (message, identity) {
36
+ if (closed) throw Error('Momentary controls unavailable')
37
+ const request = message?._controlPanel
38
+ const hold = request?.hold
39
+ const route = routes.get(request?.id)
40
+ if (route?.control.buttonBehavior !== 'momentary' || route.control.mode !== 'both') {
41
+ throw Error('This control is not a momentary button')
42
+ }
43
+ if (!hold || hold.epoch !== epoch || !/^[A-Za-z0-9_-]{1,64}$/.test(hold.client || '') ||
44
+ !Number.isSafeInteger(hold.seq) || hold.seq < 1 ||
45
+ !['start', 'renew', 'release'].includes(hold.action) ||
46
+ request.value !== (hold.action !== 'release') ||
47
+ typeof identity?.token !== 'string' || !Number.isFinite(identity.expiry) || identity.expiry <= now()) {
48
+ throw Error('Invalid momentary command')
49
+ }
50
+ const command = resolveBoundCommand(routes, message)
51
+ sweep()
52
+ const key = JSON.stringify([identity.token, request.id, hold.client])
53
+ let record = records.get(key)
54
+ if (!record) {
55
+ if (records.size >= 1024) throw Error('Momentary client limit reached')
56
+ record = { seq: 0, active: false, target: destination(command), identity,
57
+ off: resolveBoundCommand(routes, { _controlPanel: { kind: 'command', id: request.id, value: false, final: true } }) }
58
+ records.set(key, record)
59
+ }
60
+ // Keep the sequence watermark after release: a delayed press must not re-enable the device.
61
+ if (hold.seq <= record.seq) return { held: record.active }
62
+ record.seq = hold.seq
63
+ if (hold.action === 'release') release(record)
64
+ else if (hold.action === 'start') {
65
+ const alreadyHeld = occupied(record.target)
66
+ if (!alreadyHeld) send(command, identity)
67
+ record.active = true
68
+ record.until = Math.min(now() + leaseMs, identity.expiry)
69
+ } else if (record.active) record.until = Math.min(now() + leaseMs, identity.expiry)
70
+ schedule()
71
+ return { held: record.active }
72
+ }
73
+ return {
74
+ epoch,
75
+ handle,
76
+ isHeld (command) { sweep(); return occupied(destination(command)) },
77
+ releaseSession (token) {
78
+ for (const [key, record] of records) {
79
+ if (record.identity.token === token) { release(record); records.delete(key) }
80
+ }
81
+ schedule()
82
+ },
83
+ close () {
84
+ closed = true
85
+ if (timer) clearTimer(timer)
86
+ timer = null
87
+ for (const record of records.values()) release(record)
88
+ records.clear()
89
+ }
90
+ }
91
+ }
92
+
93
+ module.exports = { createMomentaryControls }
@@ -341,6 +341,7 @@ function inspectTemplate (format) {
341
341
  (enabledBooleanProp(node, ['indeterminate']) || hasAttribute(node, ['min', 'max', 'step']))
342
342
  const candidate = {
343
343
  locator, adapter,
344
+ ...(adapter === 'switch' && node.tag.toLowerCase() === 'button' ? { button: true } : {}),
344
345
  ...(component ? { component } : {}),
345
346
  ...(activeRepeat ? { repeat: activeRepeat } : {}),
346
347
  label: attribute(node, 'aria-label') || attribute(node, 'label') || attribute(node, 'title') ||
@@ -7,6 +7,16 @@ const ID = /^[A-Za-z][A-Za-z0-9._/-]{0,127}$/
7
7
  const INSTANCE_KEY = /^[A-Za-z0-9._:/-]{1,160}$/
8
8
  const MODES = new Set(['read', 'write', 'both'])
9
9
 
10
+ function buttonBehavior (entry, candidate) {
11
+ const behavior = entry.buttonBehavior ?? 'toggle'
12
+ if (!['toggle', 'momentary'].includes(behavior) ||
13
+ (entry.buttonBehavior !== undefined && !candidate.button) ||
14
+ (behavior === 'momentary' && entry.mode !== 'both')) {
15
+ throw new Error('Button behavior requires a boolean button; momentary buttons require both mode')
16
+ }
17
+ return behavior
18
+ }
19
+
10
20
  function targetKey (candidate, entry) {
11
21
  if (!candidate.repeat) return candidate.locator
12
22
  if (typeof entry.instanceKey !== 'string' || !INSTANCE_KEY.test(entry.instanceKey)) {
@@ -40,6 +50,10 @@ function visualRoutes (config) {
40
50
  throw new Error(`Visual control moved or became ambiguous: ${id}`)
41
51
  }
42
52
  const bindingKey = targetKey(candidate, entry)
53
+ const behavior = buttonBehavior(entry, candidate)
54
+ if (behavior === 'momentary' && !['mute', 'power'].includes(entry.type)) {
55
+ throw new Error('Momentary buttons require a boolean control type')
56
+ }
43
57
  if (usedLocators.has(bindingKey)) throw new Error(`Duplicate visual control locator: ${id}`)
44
58
  usedLocators.add(bindingKey)
45
59
  if (!MODES.has(entry.mode) || !entry.type || !entry.label ||
@@ -62,6 +76,7 @@ function visualRoutes (config) {
62
76
  routes.set(id, {
63
77
  control: { id, type: entry.type, mode: entry.mode, label: entry.label,
64
78
  adapter: entry.adapter, locator: entry.locator,
79
+ ...(candidate.button ? { buttonBehavior: behavior } : {}),
65
80
  ...(candidate.repeat ? { instanceKey: entry.instanceKey } : {}) },
66
81
  binding: entry
67
82
  })
@@ -84,6 +99,7 @@ function validatedVisualBindings ({ format, entries, devices, targetsByDevice })
84
99
  const candidate = candidates.get(raw.locator)
85
100
  if (!candidate || candidate.adapter !== raw.adapter) throw new Error(`Control changed: ${raw.id}`)
86
101
  const bindingKey = targetKey(candidate, raw)
102
+ const behavior = buttonBehavior(raw, candidate)
87
103
  if (usedLocators.has(bindingKey)) throw new Error(`One element cannot have two bindings: ${raw.id}`)
88
104
  usedLocators.add(bindingKey)
89
105
  if (!MODES.has(raw.mode) || (['meter', 'xy-series', 'text-display'].includes(raw.adapter) && raw.mode !== 'read')) {
@@ -122,6 +138,7 @@ function validatedVisualBindings ({ format, entries, devices, targetsByDevice })
122
138
  }
123
139
  result[raw.id] = {
124
140
  locator: raw.locator, adapter: raw.adapter, type, label: raw.label.trim(), mode: raw.mode,
141
+ ...(candidate.button ? { buttonBehavior: behavior } : {}),
125
142
  ...(candidate.model ? { model: candidate.model } : {}),
126
143
  ...(candidate.repeat ? { repeat: candidate.repeat, instanceKey: raw.instanceKey } : {}),
127
144
  ...(candidate.component ? { component: candidate.component } : {}),