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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,13 +8,14 @@
8
8
 
9
9
  页面作者使用普通 HTML、CSS 和 Vue 编写布局、样式、导航、控件及其本地状态。无需知道驱动协议、点位路径、内部 Control ID 或绑定表结构。标准 HTML `id`、Vue `v-model` 和 ARIA 属性不是 ControlPanel 专用标签。
10
10
 
11
- 节点负责登录界面、普通与编辑权限、控件识别、映射保存、桥接生成、命令发送和反馈更新。页面不调用这些内部服务,不实现第二套设备通信。
11
+ 节点负责登录界面、普通与编辑权限、控件识别、映射保存、桥接生成、命令发送和反馈更新。页面不调用这些内部服务,不实现第二套设备通信。即使一个页面同时显示几十或上百个实时 Meter,页面也只声明普通控件和普通 Vue 数据字段,并用 `volume` 与 `meter1` 等不同字段表达不同语义。
12
12
 
13
13
  不得从旧页面复制以下内容:
14
14
 
15
15
  - 手写 `data-cp-*` 属性。这些属性会被拒绝;运行时自动添加的内部标记不属于页面交付代码。
16
16
  - `sendControl`、`postControl`、`setControlSession`、`deviceValue` 等旧页面接口。
17
17
  - 写死设备路径、驱动命令、绑定表,或调用 ControlPanel 的 HTTP、WebSocket、SSE 接口。
18
+ - 声明 stream ID、订阅主题、帧格式、Socket.IO 事件或批量反馈协议。
18
19
  - 为修复反馈而添加设备轮询、强制回写滑杆、重复发送监听器或设备状态定时器。
19
20
 
20
21
  这些限制针对设备绑定,不禁止正常的页面导航、CSS 动画、图片明暗变化等纯前端功能。未绑定控件不会自动控制设备。待绑定控件的模型应独立;不要让两个不同设备控件共用一个 `v-model` 字段。
@@ -39,7 +40,8 @@
39
40
  | 布尔开关 | `input[type=checkbox]`、语义开关按钮 | `v-switch`、`v-checkbox` | 布尔值;切换发送。语义按钮写法见下节。 |
40
41
  | 单选枚举 | `select` | `v-select`、`v-radio-group` | 字符串或数字的单个值,必须与设备枚举值对应。 |
41
42
  | 文本输入 | `input[type=text]`、`textarea` | `v-text-field`、`v-textarea` | 字符串;原生 change 提交,Vuetify 在 change 或失焦时提交,`v-text-field` 还支持 Enter。 |
42
- | 只读数值 | `meter`、`progress` | `v-progress-linear`、`v-progress-circular` | 只绑定 Feedback point,不发送命令。 |
43
+ | 普通只读数值 | `output`、文本展示 | 暂无 | 只绑定普通 Feedback point,不发送命令。使用简单顶层字段;不要把普通数值反馈写成 Meter。 |
44
+ | 实时信号电平 Meter | `meter`、`progress` | `v-progress-linear`、`v-progress-circular` | 只绑定驱动明确声明 Meter 元数据的 Feedback point,不发送命令。使用独立的 `meter`/`meter1` 字段;原生 `meter`/`progress` 可使用简单 `v-model` 作为只读别名。 |
43
45
  | 只读文本 | `span`、`strong`、`b`、`output`、`h1`、`h2`、`h3` | 暂无 | 使用简单字段插值,如 `{{ status }}`,或由节点替换的纯文本;只绑定读点位。 |
44
46
 
45
47
  原生输入可以不写 Vue 模型,也支持简单顶层 `v-model`。Vuetify 交互控件必须写简单 `v-model`;进度组件可以使用简单 `:model-value="progress"`。初始化数值为数字、开关为布尔值、文本为字符串,不用数组、对象或空值占位。
@@ -47,7 +49,7 @@
47
49
  ### 3.1 通用限制
48
50
 
49
51
  - 待绑定控件自身不能有 `@click`、`@input`、`@change`、`@update:model-value` 等任何事件指令。即使事件只做本地操作,也会被当前识别器判为已有逻辑。页面导航等事件可放在独立、不绑定设备的控件上。
50
- - 不额外叠加 `:value`、`:checked`、`:model-value`。只读进度组件的 `:model-value` 是例外;语义开关按钮的简单 ARIA 布尔绑定也是例外。
52
+ - 交互输入控件不额外叠加 `:value`、`:checked`、`:model-value`。只读原生 meter/progress 的 `:value`、Vuetify 进度组件的 `:model-value` 和语义开关按钮的简单 ARIA 布尔绑定是例外。
51
53
  - 只允许 range/select 的 `v-model.number`;不要用 `.lazy`、`.trim`、带参数的 `v-model` 或其他修饰符。
52
54
  - 待绑定元素不能位于 `v-for` 中,即使写了 `:key`。相同模板会生成多个元素,当前不能按列表项分别绑定。
53
55
  - 标签页、`v-if` 等正常布局可以保留。编辑该控件时先让它显示出来,并确保同一定位只出现一个元素。绑定提示会随元素重建恢复。
@@ -57,13 +59,22 @@
57
59
 
58
60
  - 数值:`min`、`max`、`step` 用固定数字,也可用 `:min="0"`、`:max="100"`、`:step="1"`。不能绑定到动态字段或表达式。`v-slider` 要求 min < max、step > 0,不支持 `step="any"`。
59
61
  - 原生 range 默认 0–100;原生 meter/progress 默认 0–1。原生 number 没有缺省完整量程;需要自动缩放时明确写 min/max。不要把原生缺省量程误当成设备量程。
62
+ - 原生 `meter`/`progress` 可使用标准 `:value="meter1"`,也可使用简单 `v-model="meter1"` 作为只读别名;节点会在编译前将后者转换为 `:value`。不支持 `v-model.number`、动态表达式或同时写 `:value`。只有驱动点位带有 Meter 元数据时,`meter`/`progress` 才能建立绑定;它由独立的 Meter 批量帧通道更新,不能接收网页设置。音量滑杆的 `volume` 与 Meter 的 `meter` 必须是两个不同字段,不能把同一个字段绑定到两个不同设备点位。普通 volume/level 反馈不能选择为 Meter。Vuetify `progress` 组件仍使用简单 `:model-value`;未绑定的进度条可以作为普通前端视觉元素。
60
63
  - Vuetify switch/checkbox 不支持 `value`、自定义 `true-value`/`false-value`、`multiple` 或数组模型。
61
64
  - Vuetify select 不支持多选、`return-object`、`clearable` 或对象模型;选项可用标量数组,或普通 `{ title, value }` 选项但模型必须是标量。`v-radio-group` 使用标量模型,子项也不能自带事件处理器。
62
65
  - Vuetify 文本控件只用字符串模型。`v-text-field` 使用文本类型,不用数字、密码等类型;不支持 `.number`。`v-textarea` 的 Enter 是换行,不是提交。
63
66
  - `v-progress-linear` 从 0 到固定正数 max,默认 max=100;不支持 min、step、indeterminate、clickable。`v-progress-circular` 固定 0–100,不声明 min/max/step,也不使用 indeterminate。
64
67
  - 文本展示推荐只放一个简单插值,如 `<span>{{ status }}</span>`,不使用 `status.toFixed()`、拼接表达式或多个插值。复杂展示可用另外的纯前端元素,不把它作为设备反馈绑定目标。
65
68
 
66
- ### 3.3 普通按钮与开关按钮
69
+ ### 3.3 Meter 实时信号与批量反馈
70
+
71
+ 页面作者不编写订阅、socket 或批处理代码,但要用不同字段表达不同语义:音量/增益使用 `volume` 等普通可写字段;实时信号使用 `meter`、`meter1` 等只读字段。部署人员在绑定编辑页选择 Feedback point;只有驱动明确声明 Meter 元数据的点位才会进入 Meter 列表,ControlPanel 会在运行时自动采用批量反馈通道,并把同一帧的数据一次性更新到本页对应的所有表头。
72
+
73
+ 页面不需要也不得声明设备、驱动、stream、socket、帧序号、点位路径或 ControlPanel API。断线、过期帧、重连、同帧合并和页面量程换算都由节点处理。反馈短暂失效时节点保留最后显示值并标记无效;页面作者不添加轮询、定时器或回写逻辑。标签页、`v-if` 等使控件重新创建后,节点会重新关联控件。
74
+
75
+ 多个表头必须使用不同的 `id` 和不同的顶层字段。不要让属于不同设备点位的表头共用同一个字段;同一个滑杆及其纯视觉旁显可以共享字段,但只能绑定其中一个控件。Meter 与 volume 是完全独立的通信类型:volume 负责音量/增益设置和普通真实值反馈,Meter 只负责某音频通道的高频实时信号电平反馈。代码或驱动中的历史 `level` 名称不自动表示 Meter;是否具备 Meter 能力由点位的 Meter 元数据决定。
76
+
77
+ ### 3.4 普通按钮与开关按钮
67
78
 
68
79
  一个只有文字的普通 `<button>` 不会自动成为设备控件。需要布尔设备开关时使用标准语义:
69
80
 
@@ -90,12 +101,14 @@
90
101
  <option value="B">输入 B</option>
91
102
  </select>
92
103
  <span id="room-status">{{ status }}</span>
104
+ <meter id="room-meter" :value="meter" min="0" max="100" aria-label="实时信号电平"></meter>
105
+ <progress id="room-progress" :value="progress" max="100" aria-label="进度"></progress>
93
106
  </section>
94
107
  </template>
95
108
  <script>
96
109
  export default {
97
110
  data () {
98
- return { volume: 0, muted: false, powered: false, source: 'A', status: '待机' }
111
+ return { volume: 0, meter: 0, muted: false, powered: false, source: 'A', status: '待机', progress: 0 }
99
112
  }
100
113
  }
101
114
  </script>
@@ -114,7 +127,7 @@ export default {
114
127
  <v-radio label="模式 B" value="B"></v-radio>
115
128
  </v-radio-group>
116
129
  <v-text-field id="room-title" label="名称" v-model="title"></v-text-field>
117
- <v-progress-linear id="room-level" :model-value="level" :max="100"></v-progress-linear>
130
+ <v-progress-linear id="room-meter" :model-value="meter" :max="100"></v-progress-linear>
118
131
  <span id="room-status">{{ status }}</span>
119
132
  </section>
120
133
  </template>
@@ -123,7 +136,7 @@ export default {
123
136
  data () {
124
137
  return {
125
138
  volume: 0, muted: false, source: 'A', sources: ['A', 'B'],
126
- mode: 'A', title: '', level: 0, status: '待机'
139
+ mode: 'A', title: '', meter: 0, status: '待机'
127
140
  }
128
141
  }
129
142
  }
@@ -155,7 +168,9 @@ Bindings 列表可 Edit 或 Remove;这些操作先改草稿,最后统一 App
155
168
  | 参数描述 | 可匹配控件 |
156
169
  | --- | --- |
157
170
  | boolean,或无枚举且范围恰为 0–1 的 integer | 布尔开关;整数 0/1 由节点转换为前端布尔值。 |
158
- | integer/float 数值参数 | range、数值进度;有枚举或 0/1 布尔语义时优先对应相应类别。 |
171
+ | 可写 integer/float 数值参数 | range;这类参数用于 volume/增益等普通连续设置。 |
172
+ | 只读 integer/float,且没有 Meter 元数据 | 只读文本展示;不能选择为 Meter。 |
173
+ | 只读 integer/float,且带 `.metadata.meter` | Meter、原生 `meter` 或 Vuetify 进度组件;只走实时 Meter 反馈。 |
159
174
  | enum,或带枚举描述的 integer/string | 单选枚举。当前整数路径以 `/source`、`/preset` 结尾也会按选择类处理;仍需确认合法选项。 |
160
175
  | string/dstring | 文本输入。 |
161
176
  | 上述类型的可读参数 | 简单文本展示也可绑定。 |
@@ -173,6 +188,7 @@ Write point 改动时 Feedback point 跟随;单独改 Feedback point 不反向
173
188
  | 控件不能选中 | 是否在支持清单、是否有事件指令/复杂模型/v-for、自闭合 Vuetify、重复 ID;阅读编辑页给出的拒绝原因。 |
174
189
  | 有设备但点位为空 | 在线状态、参数目录、值类型与 ro/wo/rw 权限;不是所有设备命令都属于可绑定参数。 |
175
190
  | 草稿有紫框但普通页无效 | Apply 是否成功、模式和点位是否正确、桥接是否同步、MUSE 是否加载预期模块版本。 |
191
+ | 页面一直 Loading | 先检查节点状态和运行日志中的模板编译错误,例如在原生 meter/progress 上使用带修饰符的 v-model、动态表达式或同时写 v-model 与 :value。简单顶层 v-model 会由节点转换为只读 :value;节点会显示具体模板错误。会话检查包含响应正文的超时保护,设备离线不应阻止普通页进入登录页。登录检查的时限不包含 Dashboard 资源下载和组件编译时间。 |
176
192
  | 滑杆动不了 | 是否仍在 Select 编辑模式、模型类型是否正确、是否存在手写回写逻辑;不要先改驱动。 |
177
193
  | 页面变化但设备不变 | 写映射和驱动收发日志。前端变化不证明命令到达设备。 |
178
194
  | 设备已变但页面旧值 | Feedback point、事件/读取是否有新反馈。节点不能凭写入成功制造真实设备反馈。 |
@@ -186,6 +202,7 @@ Write point 改动时 Feedback point 跟随;单独改 Feedback point 不反向
186
202
  - 所有待绑定控件位于支持清单,使用稳定唯一 ID;数据模型简单、独立且类型正确。
187
203
  - 页面量程明确、数值常量有效;下拉菜单选项与实际设备值对应。
188
204
  - 没有手写 `data-cp-*`、旧发送接口、设备通信、绑定控件事件或复杂模型。
205
+ - 实时表头仍是普通 HTML/Vue/Vuetify;没有 stream、socket、订阅、帧协议或驱动相关代码。
189
206
  - Vuetify 标签成对闭合;不把自动绑定目标放入 v-for。
190
207
  - 保留正常的视觉与纯前端交互;不为了绑定重写驱动或添加第二套控制逻辑。
191
208
  - 不声称任意普通页面都可无条件自动绑定;不支持的组件要明确说明,不能伪造兼容写法。
package/README.md CHANGED
@@ -12,6 +12,8 @@ Bindings are stored in `visualBindings` on the node. The runtime reads that mapp
12
12
 
13
13
  Supported controls include native range, checkbox, semantic pressed button, select, text input, meter, progress, and simple text display. Give repeated controls distinct standard IDs. Simple top-level Vue `v-model` works; controls with existing send handlers or ambiguous dynamic behavior are rejected until a suitable adapter exists. See `CONTROL_PANEL_AI_GUIDE.md` for examples and limits.
14
14
 
15
+ Realtime meter delivery does not change the page-authoring contract. Authors use ordinary `meter`, `progress`, `v-progress-linear`, or `v-progress-circular` controls with stable IDs and simple top-level fields. They do not declare devices, point paths, stream IDs, sockets, subscriptions, or frame formats. A `volume` binding is a writable gain setting with ordinary feedback; a `meter` binding is read-only realtime signal telemetry and requires a target that advertises Meter metadata. A normal numeric `level` target is not automatically a Meter. When the selected feedback point advertises realtime meter delivery, ControlPanel automatically shares the device stream, projects complete frames to the bound controls, applies webpage-side range conversion, and handles stale/reconnected data.
16
+
15
17
  Vuetify adapters cover `v-slider`, `v-switch`, `v-checkbox`, `v-select`, `v-radio-group`, `v-text-field`, `v-textarea`, and read-only `v-progress-linear` and `v-progress-circular`. Interactive components use a simple top-level `v-model`; progress may also use a simple `:model-value` field. Sliders accept fixed numeric `min`, `max`, and `step` props, including numeric Vue bindings such as `:max="100"`. Text fields commit on change, blur, or Enter (`v-text-field` only). Selection values must match the device's enum values; circular progress uses a fixed 0-100 range. `v-model.number` is accepted for numeric sliders and scalar selections, but not for boolean or text controls. Dynamic bounds, array-valued switches, multi-select, object selections, custom true/false values, indeterminate progress, and controls with their own event handlers need a separate adapter.
16
18
 
17
19
  Use paired Vuetify tags in Dashboard templates, such as `<v-slider></v-slider>`; self-closing component tags can absorb the following controls during Dashboard rendering and are rejected before deployment.
@@ -66,7 +66,7 @@ function resolveBoundFeedback (routes, message) {
66
66
  const matches = []
67
67
  for (const [id, route] of routes) {
68
68
  if (route.control.mode === 'write' || route.binding?.device !== message.device ||
69
- route.binding.readPath !== message.path) continue
69
+ route.binding.readPath !== message.path || route.binding.readMeta?.meter) continue
70
70
  const type = route.control.type
71
71
  const metadata = route.binding.readMeta
72
72
  let value = message.payload
@@ -1,5 +1,6 @@
1
1
  const { schemaVersion } = require('./control-type-contract.json')
2
2
  const { runtimeJsonRequest } = require('./panel-http')
3
+ const { meterRoute } = require('./meter-route')
3
4
 
4
5
  function catalogBase (settings) {
5
6
  const port = Number(settings?.uiPort)
@@ -73,6 +74,7 @@ async function listCatalogTargets (base, device, fetcher = fetch) {
73
74
  const min = catalogBound(properties['.min'])
74
75
  const max = catalogBound(properties['.max'])
75
76
  const enums = properties['.enums']
77
+ const meter = properties['.metadata']?.meter ? meterRoute(properties['.metadata'].meter) : null
76
78
  return {
77
79
  path: item.path,
78
80
  kind: properties['.kind'] || '',
@@ -81,9 +83,10 @@ async function listCatalogTargets (base, device, fetcher = fetch) {
81
83
  onebased: typeof properties['.onebased'] === 'boolean' ? properties['.onebased']
82
84
  : typeof item.onebased === 'boolean' ? item.onebased : undefined,
83
85
  min, max, enums,
84
- ...inferredControl(item.path, properties, type, min, max, enums),
86
+ ...(meter ? { controlType: 'meter' } : inferredControl(item.path, properties, type, min, max, enums)),
85
87
  controlSchemaVersion: schemaVersion,
86
- unit: properties['.metadata']?.unit || ''
88
+ unit: properties['.metadata']?.unit || '',
89
+ ...(meter ? { meter } : {})
87
90
  }
88
91
  })
89
92
  const unique = new Map()
package/control-panel.js CHANGED
@@ -10,6 +10,7 @@ const { createPanelEditor } = require('./panel-editor-auth')
10
10
  const { applyVisualBindings, editorConfig, getSnapshot } = require('./panel-visual-apply')
11
11
  const { createGateway } = require('./gateway.cjs')
12
12
  const { managedBridgeRevision } = require('./managed-bridge-revision')
13
+ const { createMeterRoom, createPanelMeter } = require('./panel-meter')
13
14
 
14
15
  let statestore
15
16
  const authHandoffKey = crypto.randomBytes(32)
@@ -69,6 +70,7 @@ module.exports = function (RED) {
69
70
  const base = catalogBase(RED.settings)
70
71
  const activePanels = new Map()
71
72
  const activeEditors = new Map()
73
+ const meterRooms = new Map()
72
74
  RED.httpAdmin.get('/ui-control-panel/editor-sync.js', RED.auth.needsPermission('nodes.read'), (_req, res) => {
73
75
  res.setHeader('Cache-Control', 'no-store')
74
76
  res.sendFile(path.join(__dirname, 'panel-flow-editor-sync.js'))
@@ -147,6 +149,16 @@ module.exports = function (RED) {
147
149
  }
148
150
  node.status?.(bridgeReady ? {} : { fill: 'red', shape: 'ring', text: 'bridge out of sync' })
149
151
 
152
+ let meter
153
+ if ([...routes.values()].some(route => route.binding.readMeta?.meter) && /^[a-zA-Z0-9_-]+$/.test(boundRoom || '')) {
154
+ try {
155
+ let hub = meterRooms.get(boundRoom)
156
+ if (!hub) { hub = createMeterRoom({ room: boundRoom }); meterRooms.set(boundRoom, hub) }
157
+ meter = createPanelMeter({ panelId: node.id, room: boundRoom, bindings: visualBindings, hub,
158
+ onSubscribe: () => { if (bridgeReady) node.send({ kind: 'read-all', room: boundRoom }) } })
159
+ } catch (error) { node.error(error) }
160
+ }
161
+
150
162
  let gateway
151
163
  let gatewayAuthKey
152
164
  if (visualBinding && /^[a-zA-Z0-9_-]+$/.test(boundRoom || '')) {
@@ -160,6 +172,8 @@ module.exports = function (RED) {
160
172
  prefix: endpoint,
161
173
  secureCookie: config.secureCookie === true,
162
174
  restoredSessions: takeSessions(gatewayHandoffs, node.id, gatewayAuthKey),
175
+ onAuthenticatedRequest: (req, res, identity) => meter?.ticket(req, res, identity,
176
+ () => gateway?.sessionActive(identity.token)) || false,
163
177
  onBoundCommand: (message, identity) => {
164
178
  if (!bridgeReady) return false
165
179
  node.send({ ...resolveBoundCommand(routes, message), room: identity.room, user: identity.user })
@@ -188,6 +202,8 @@ module.exports = function (RED) {
188
202
  password: node.credentials?.editorPassword,
189
203
  secureCookie: config.secureCookie === true,
190
204
  restoredSessions: takeSessions(editorHandoffs, node.id, editorAuthKey),
205
+ onAuthenticatedRequest: (req, res, identity) => meter?.ticket(req, res, identity,
206
+ () => editor?.sessionActive(identity.token)) || false,
191
207
  getConfig: async req => editorConfig(await getSnapshot(req.socket.localPort, fetch,
192
208
  RED.settings.httpAdminRoot ?? '/'), node.id),
193
209
  getDevices: async () => {
@@ -214,7 +230,7 @@ module.exports = function (RED) {
214
230
  }
215
231
 
216
232
  const panelConfig = () => (boundMode || visualBinding) ? {
217
- endpoint, room: boundRoom,
233
+ endpoint, room: boundRoom, meter: Boolean(meter),
218
234
  ...(visualBinding ? { visual: {
219
235
  panelId: node.id,
220
236
  editorEndpoint: `/control-panel-edit/panels/${node.id}`,
@@ -225,8 +241,15 @@ module.exports = function (RED) {
225
241
 
226
242
  const events = {
227
243
  onAction: !boundMode,
244
+ ...(meter ? { onSocket: meter.hooks } : {}),
228
245
  beforeSend (msg) {
229
246
  if (msg?._controlPanel?.kind === 'command') throw new Error('Bound commands require authenticated HTTP')
247
+ if (['device-meter-frame', 'device-meter-manifest'].includes(msg?.kind)) {
248
+ if (bridgeReady) meter?.accept(msg)
249
+ // Dashboard 1.31 reads properties before checking whether a message
250
+ // should be emitted. `false` is property-safe and suppresses emit.
251
+ return false
252
+ }
230
253
  if (boundMode) {
231
254
  if (msg?.kind === 'device-feedback' && gateway) {
232
255
  gateway.boundFeedback(resolveBoundFeedback(routes, msg))
@@ -279,7 +302,13 @@ module.exports = function (RED) {
279
302
  } catch (error) {
280
303
  node.error(error)
281
304
  node.status?.({ fill: 'red', shape: 'ring', text: 'visual template unsupported' })
282
- return
305
+ const message = String(error.message).replace(/[&<>]/g,
306
+ char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[char])
307
+ widgetConfig = { ...config, type: 'ui-template',
308
+ format: '<template><section role="alert" style="padding:24px;color:#a52a35;background:#fff">' +
309
+ '<h2>ControlPanel template error</h2><pre v-pre style="white-space:pre-wrap;overflow-wrap:anywhere">' +
310
+ message + '</pre></section></template>',
311
+ passthru: false, storeOutMessages: false, resendOnRefresh: false }
283
312
  }
284
313
  parentNode = group || page || ui
285
314
  if (group) group.register(node, widgetConfig, events)
@@ -295,6 +324,8 @@ module.exports = function (RED) {
295
324
  if (editor) holdSessions(editorHandoffs, node.id, editorAuthKey, editor.sessionSnapshot())
296
325
  gateway?.close()
297
326
  editor?.close()
327
+ meter?.close()
328
+ if (meterRooms.get(boundRoom)?.stats().panels === 0) meterRooms.delete(boundRoom)
298
329
  if (activePanels.get(node.id) === gateway) activePanels.delete(node.id)
299
330
  if (activeEditors.get(node.id) === editor) activeEditors.delete(node.id)
300
331
  })
package/control-policy.js CHANGED
@@ -3,6 +3,8 @@ const escodegen = require('escodegen')
3
3
  const { initializeVisualBinding, disposeVisualBinding } = require('./panel-binding-editor')
4
4
  const { fetchWithTimeout } = require('./panel-http')
5
5
  const { createTargetMatcher } = require('./control-type-model')
6
+ const { createRoomMeterReceiver, createMeterRenderer } = require('./meter-frame')
7
+ const { createPanelMeterClient } = require('./panel-meter-client')
6
8
 
7
9
  const CONTROL_POLICY_MARKER = '/* ui-control-panel-control-api-v1 */'
8
10
 
@@ -157,6 +159,11 @@ function createControlPanelPolicy ({
157
159
  // generated script remains inside that supported subset.
158
160
  function buildDashboardPolicyOptionsSource (controls = [], boundPanel = null) {
159
161
  const boundControls = controls.map(({ id, type, mode }) => ({ id, type, mode }))
162
+ const meterMethods = boundPanel?.meter
163
+ ? `__createRoomMeterReceiver: ${createRoomMeterReceiver.toString()},
164
+ __createMeterRenderer: ${createMeterRenderer.toString()},
165
+ __createPanelMeterClient: ${createPanelMeterClient.toString()},`
166
+ : ''
160
167
  const visualMethods = boundPanel?.visual
161
168
  ? `__createTargetMatcher: ${createTargetMatcher.toString()},
162
169
  __initializeVisualBinding: ${initializeVisualBinding.toString()},
@@ -180,6 +187,7 @@ export default {
180
187
  }
181
188
  },
182
189
  methods: {
190
+ ${meterMethods}
183
191
  ${visualMethods}
184
192
  __panelFetch: ${fetchWithTimeout.toString()},
185
193
  __boundPanelConfig () {
@@ -304,6 +312,7 @@ export default {
304
312
  }
305
313
  },
306
314
  __disposeControlPanelPolicy () {
315
+ this.__boundAuthGeneration++
307
316
  this.__boundUnwatch?.()
308
317
  this.__boundUnwatch = null
309
318
  this.__clearAllBoundSettlements()
@@ -381,28 +390,28 @@ export default {
381
390
  if (!config) throw new Error('This panel has no bound login endpoint')
382
391
  this.clearBoundPanelSession()
383
392
  const generation = this.__boundAuthGeneration
384
- const response = await this.__panelFetch(config.endpoint + '/login', {
393
+ const { response, session } = await this.__panelFetch(config.endpoint + '/login', {
385
394
  method: 'POST', credentials: 'same-origin',
386
395
  headers: { 'Content-Type': 'application/json' },
387
396
  body: JSON.stringify({ username, password })
388
- })
397
+ }, 12000, async response => ({ response, session: response.ok ? await response.json() : null }))
389
398
  if (!response.ok) throw new Error('Panel login failed (' + response.status + ')')
390
- const session = await response.json()
391
399
  if (generation !== this.__boundAuthGeneration) return null
392
400
  this.setBoundPanelSession(session)
393
401
  return session
394
402
  },
395
- async restoreBoundPanelSession () {
403
+ async restoreBoundPanelSession (timeoutMs = 2500) {
396
404
  const config = this.__boundPanelConfig()
397
405
  if (!config) return null
398
406
  const generation = this.__boundAuthGeneration
399
- const response = await this.__panelFetch(config.endpoint + '/session', { credentials: 'same-origin' })
407
+ const { response, session } = await this.__panelFetch(config.endpoint + '/session', {
408
+ credentials: 'same-origin'
409
+ }, timeoutMs, async response => ({ response, session: response.ok ? await response.json() : null }))
400
410
  if (generation !== this.__boundAuthGeneration) return this.__boundPanelSession
401
411
  if (!response.ok) {
402
412
  this.clearBoundPanelSession()
403
413
  return null
404
414
  }
405
- const session = await response.json()
406
415
  if (generation !== this.__boundAuthGeneration) return this.__boundPanelSession
407
416
  this.setBoundPanelSession(session)
408
417
  return session
@@ -456,7 +465,7 @@ export default {
456
465
  },
457
466
  mounted () {
458
467
  this.__initializeControlPanelPolicy()
459
- if (this.__boundPanelConfig()) this.restoreBoundPanelSession().catch(() => undefined)
468
+ ${boundPanel?.visual ? '' : 'if (this.__boundPanelConfig()) this.restoreBoundPanelSession().catch(() => undefined)'}
460
469
  ${visualMounted}
461
470
  },
462
471
  unmounted () {
@@ -4,6 +4,7 @@
4
4
  "slider": { "family": "number", "delivery": "continuous" },
5
5
  "level": { "family": "number", "delivery": "continuous" },
6
6
  "volume": { "family": "number", "delivery": "continuous" },
7
+ "meter": { "family": "number", "delivery": "realtime", "access": "ro" },
7
8
  "dimmer": { "family": "number", "delivery": "continuous" },
8
9
  "position": { "family": "number", "delivery": "continuous" },
9
10
  "toggle": { "family": "boolean", "delivery": "discrete" },
@@ -1,4 +1,5 @@
1
1
  const contract = require('./control-type-contract.json')
2
+ const { meterRoute } = require('./meter-route')
2
3
 
3
4
 
4
5
 
@@ -22,6 +23,8 @@ function createTargetMatcher (typeContract) {
22
23
  return definition.family
23
24
  }
24
25
  function compatible (adapter, target, direction, candidate = {}) {
26
+ if (adapter === 'meter' && !target.meter) return false
27
+ if (target.meter && (adapter !== 'meter' || direction !== 'read' || target.access !== 'ro')) return false
25
28
  if (target.kind !== 'param' || !families[adapter]?.includes(family(target))) return false
26
29
  if (direction === 'write' && ['meter', 'text-display'].includes(adapter)) return false
27
30
  if (!['read', 'write'].includes(direction)) return false
@@ -38,6 +41,7 @@ function controlTypeForAdapter (adapter, target) {
38
41
  if (!compatibleTarget(adapter, target, 'read') && !compatibleTarget(adapter, target, 'write')) {
39
42
  throw new Error('Target is incompatible with this control')
40
43
  }
44
+ if (adapter === 'meter' && target.meter) return 'meter'
41
45
  const family = targetFamily(target)
42
46
  if (family === 'number') return 'level'
43
47
  if (family === 'boolean') return 'mute'
@@ -55,7 +59,8 @@ function targetMeta (target, onebased) {
55
59
  onebased: typeof target.onebased === 'boolean' ? target.onebased : onebased,
56
60
  min: target.min, max: target.max, enums: target.enums,
57
61
  ...(target.booleanEncoding ? { booleanEncoding: target.booleanEncoding } : {}),
58
- controlType: target.controlType, controlSchemaVersion: target.controlSchemaVersion
62
+ controlType: target.controlType, controlSchemaVersion: target.controlSchemaVersion,
63
+ ...(target.meter ? { meter: meterRoute(target.meter), unit: target.unit || '' } : {})
59
64
  }
60
65
  }
61
66
 
package/gateway.cjs CHANGED
@@ -9,6 +9,7 @@ function createGateway ({
9
9
  room = 'room-01',
10
10
  onBoundCommand,
11
11
  onBoundLogin,
12
+ onAuthenticatedRequest,
12
13
  secureCookie = false,
13
14
  prefix,
14
15
  sessionTtlMs = 8 * 3600000,
@@ -193,6 +194,8 @@ function createGateway ({
193
194
  reply(res, 403, { error: 'CSRF rejected' })
194
195
  return true
195
196
  }
197
+ if (onAuthenticatedRequest && await onAuthenticatedRequest(req, res,
198
+ { token, expiry: session.expiry, room, user: username })) return true
196
199
  if (path === routePrefix + '/session' && req.method === 'GET') {
197
200
  reply(res, 200, { room, username, csrf: session.csrf })
198
201
  return true
@@ -255,6 +258,7 @@ function createGateway ({
255
258
  return {
256
259
  handler,
257
260
  boundFeedback,
261
+ sessionActive: token => !closed && (sessions.get(token)?.expiry || 0) > Date.now(),
258
262
  sessionSnapshot () {
259
263
  return new Map([...sessions].filter(([, session]) => session.expiry > Date.now())
260
264
  .map(([token, session]) => [token, { ...session }]))
@@ -24,7 +24,8 @@ function panelTargets (panel) {
24
24
  const target = {
25
25
  device: binding.device, path, kind: meta.kind, type: meta.type,
26
26
  access: meta.access, onebased: meta.onebased,
27
- ...(meta.booleanEncoding ? { booleanEncoding: meta.booleanEncoding } : {})
27
+ ...(meta.booleanEncoding ? { booleanEncoding: meta.booleanEncoding } : {}),
28
+ ...(meta.meter ? { meter: meta.meter } : {})
28
29
  }
29
30
  const key = JSON.stringify([target.device, target.path])
30
31
  const previous = targets.get(key)
package/meter-frame.js ADDED
@@ -0,0 +1,111 @@
1
+ // Shared, non-reactive receivers; usable in Node and injected Vue component methods.
2
+ function createMeterReceiver (manifest) {
3
+ const number = value => typeof value === 'number' && Number.isFinite(value)
4
+ if (manifest?.kind !== 'meter-manifest' || typeof manifest.streamId !== 'string' ||
5
+ !manifest.streamId.trim() || manifest.streamId.length > 255 ||
6
+ typeof manifest.epoch !== 'string' || !manifest.epoch || manifest.epoch.length > 128 ||
7
+ !Number.isInteger(manifest.layoutVersion) || manifest.layoutVersion < 1 ||
8
+ manifest.layoutVersion > 2147483647 || manifest.clock !== 'monotonic-ms' ||
9
+ !number(manifest.publishHz) || manifest.publishHz < 1 || manifest.publishHz > 60 ||
10
+ !Array.isArray(manifest.points) || !manifest.points.length || manifest.points.length > 256) {
11
+ throw Error('Invalid meter manifest')
12
+ }
13
+ const paths = new Set()
14
+ const points = Array.from(manifest.points, point => {
15
+ if (typeof point?.path !== 'string' || !/^\/[^/]/.test(point.path) ||
16
+ point.path.length > 1025 || paths.has(point.path) ||
17
+ !['integer', 'float'].includes(point.type) || !number(point.min) ||
18
+ !number(point.max) || point.min >= point.max ||
19
+ (point.type === 'integer' && (!Number.isSafeInteger(point.min) || !Number.isSafeInteger(point.max))) ||
20
+ typeof point.unit !== 'string' || point.unit.length > 32) throw Error('Invalid meter point')
21
+ paths.add(point.path)
22
+ return Object.freeze({ path: point.path, type: point.type, min: point.min, max: point.max, unit: point.unit })
23
+ })
24
+ const schema = Object.freeze({ ...manifest, points: Object.freeze(points) })
25
+ let latest = null, pending = false, closed = false
26
+ return {
27
+ manifest: schema,
28
+ accept (frame) {
29
+ if (closed || frame?.kind !== 'meter-frame' || frame.streamId !== schema.streamId ||
30
+ frame.epoch !== schema.epoch || frame.layoutVersion !== schema.layoutVersion ||
31
+ !Number.isSafeInteger(frame.seq) || frame.seq < 1 || (latest && frame.seq <= latest.seq) ||
32
+ !number(frame.sampleTime) || frame.sampleTime < 0 ||
33
+ !Array.isArray(frame.values) || frame.values.length !== points.length ||
34
+ !Array.isArray(frame.valid) || frame.valid.length !== points.length) return false
35
+ const values = [], valid = []
36
+ for (let index = 0; index < points.length; index++) {
37
+ const point = points[index], value = frame.values[index], flag = frame.valid[index]
38
+ if (typeof flag !== 'boolean' || (flag && (!number(value) ||
39
+ value < point.min || value > point.max ||
40
+ (point.type === 'integer' && !Number.isSafeInteger(value))))) return false
41
+ values.push(flag ? value : null)
42
+ valid.push(flag)
43
+ }
44
+ latest = Object.freeze({ kind: 'meter-frame', streamId: schema.streamId,
45
+ epoch: schema.epoch, seq: frame.seq, layoutVersion: schema.layoutVersion,
46
+ sampleTime: frame.sampleTime, values: Object.freeze(values), valid: Object.freeze(valid) })
47
+ pending = true
48
+ return true
49
+ },
50
+ snapshot () { return latest },
51
+ take () {
52
+ if (!pending) return null
53
+ pending = false
54
+ return latest
55
+ },
56
+ close () { closed = true; pending = false; latest = null }
57
+ }
58
+ }
59
+
60
+ function createMeterRenderer ({ receiver, requestFrame, cancelFrame, apply }) {
61
+ let handle = null, closed = false
62
+ return {
63
+ receive (frame) {
64
+ if (closed || !receiver.accept(frame)) return false
65
+ if (handle === null) {
66
+ handle = requestFrame(() => {
67
+ handle = null
68
+ const current = receiver.take()
69
+ if (!closed && current) apply(current)
70
+ })
71
+ }
72
+ return true
73
+ },
74
+ close () {
75
+ closed = true
76
+ if (handle !== null) cancelFrame(handle)
77
+ handle = null
78
+ receiver.close()
79
+ }
80
+ }
81
+ }
82
+
83
+ function createRoomMeterReceiver (layout) {
84
+ if (layout?.kind !== 'room-meter-layout' || typeof layout.panelId !== 'string' ||
85
+ typeof layout.room !== 'string' || typeof layout.epoch !== 'string' || !layout.epoch ||
86
+ !Array.isArray(layout.ids) || !layout.ids.length || layout.ids.length > 256 ||
87
+ layout.ids.some(id => typeof id !== 'string' || !id) || new Set(layout.ids).size !== layout.ids.length) {
88
+ throw Error('Invalid Room meter layout')
89
+ }
90
+ let latest = null, pending = false, closed = false
91
+ const count = layout.ids.length
92
+ return {
93
+ accept (frame) {
94
+ if (closed || frame?.kind !== 'room-meter-frame' || frame.panelId !== layout.panelId ||
95
+ frame.room !== layout.room || frame.epoch !== layout.epoch ||
96
+ !Number.isSafeInteger(frame.seq) || frame.seq < 1 || (latest && frame.seq <= latest.seq) ||
97
+ !Array.isArray(frame.values) || frame.values.length !== count ||
98
+ !Array.isArray(frame.valid) || frame.valid.length !== count ||
99
+ frame.valid.some((flag, index) => typeof flag !== 'boolean' || (flag
100
+ ? typeof frame.values[index] !== 'number' || !Number.isFinite(frame.values[index])
101
+ : frame.values[index] !== null))) return false
102
+ latest = { values: frame.values.slice(), valid: frame.valid.slice(), seq: frame.seq }
103
+ pending = true
104
+ return true
105
+ },
106
+ take () { if (!pending) return null; pending = false; return latest },
107
+ close () { closed = true; latest = null; pending = false }
108
+ }
109
+ }
110
+
111
+ module.exports = { createMeterReceiver, createMeterRenderer, createRoomMeterReceiver }
package/meter-route.js ADDED
@@ -0,0 +1,12 @@
1
+ function meterRoute (value) {
2
+ if (value == null) return null
3
+ if (!value || typeof value !== 'object' || Array.isArray(value) ||
4
+ typeof value.streamId !== 'string' || !value.streamId.trim() || value.streamId.length > 255 ||
5
+ ![value.eventPath, value.manifestPath].every(path => typeof path === 'string' &&
6
+ /^\/\/[^/]/.test(path) && path.length <= 1026) || value.eventPath === value.manifestPath) {
7
+ throw Error('Invalid meter delivery metadata')
8
+ }
9
+ return { streamId: value.streamId, eventPath: value.eventPath, manifestPath: value.manifestPath }
10
+ }
11
+
12
+ module.exports = { meterRoute }