node-red-contrib-room-tools 1.9.0 → 1.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTROL_PANEL_AI_GUIDE.md +35 -1
- package/README.md +2 -0
- package/control-catalog.js +5 -2
- package/control-panel.js +24 -4
- package/control-policy.js +8 -0
- package/control-type-contract.json +1 -0
- package/control-type-model.js +8 -3
- package/managed-native-bridge.js +2 -1
- package/native-binding-plan.js +61 -7
- package/npm-shrinkwrap.json +2 -2
- package/package.json +5 -1
- package/panel-binding-editor.js +61 -1
- package/panel-template-instrumentation.js +20 -1
- package/panel-visual-bindings.js +9 -2
- package/panel-xy-series-client.js +84 -0
- package/panel-xy-series.js +186 -0
- package/xy-series-frame.js +98 -0
- package/xy-series-route.js +11 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# ControlPanel 前端页面编写指南
|
|
2
2
|
|
|
3
|
-
适用基线:当前统一工程中的 `node-red-contrib-room-tools@1.
|
|
3
|
+
适用基线:当前统一工程中的 `node-red-contrib-room-tools@1.9.1`、Dashboard 2 和 Vue Options API。
|
|
4
4
|
|
|
5
5
|
本文件是给页面编写 AI 的实现契约,不是完整的 Vue 或 Vuetify 手册。目标是:页面作者只写普通 HTML、CSS 和 Vue 页面;设备、驱动、映射表、反馈订阅和通信细节全部由 `ControlPanel` 节点处理。
|
|
6
6
|
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
|
|
27
27
|
- `volume` 等普通数值字段用于音量/增益等可写连续控制,并可接收普通真实值反馈。
|
|
28
28
|
- `meter` 等字段用于只读实时信号电平,只能接收驱动声明了 Meter 元数据的点位。
|
|
29
|
+
- `series`、`spectrum` 等数组字段用于只读实时二维点列,只能接收驱动声明了 XY-series 元数据的点位。
|
|
29
30
|
|
|
30
31
|
不要把同一个模型字段同时用于不同设备点位,尤其不要让 Meter 和 Volume 共用一个模型。
|
|
31
32
|
|
|
@@ -71,11 +72,29 @@ export default {
|
|
|
71
72
|
| 文本控制 | `input[type=text]`、`textarea` | `v-text-field`、`v-textarea` | 字符串;原生控件在 change 时提交,Vuetify 文本框在 change、失焦时提交,`v-text-field` 也支持 Enter 提交。 |
|
|
72
73
|
| 普通只读反馈 | `span`、`output` 等简单文本展示 | 暂无专用组件 | 只读普通反馈,不发送命令。 |
|
|
73
74
|
| 实时信号电平 Meter | `meter`、`progress` | `v-progress-linear`、`v-progress-circular` | 只读数值;必须绑定驱动的 Meter 点位,不发送命令。 |
|
|
75
|
+
| 实时二维点列 XY-series | `canvas` + `:data-points` 或 `:data-series`;地图容器 + `:data-coordinates` | 第三方图表暂不自动识别 | 只读 `[[x, y], ...]` 数组;不发送命令。 |
|
|
74
76
|
|
|
75
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`。
|
|
76
78
|
|
|
77
79
|
初始化值应匹配控件类型:数值为数字,开关为布尔值,文本和枚举为字符串。不要用数组、对象或 `null` 作为普通绑定控件的占位值。
|
|
78
80
|
|
|
81
|
+
XY-series 使用数组字段初始化,例如 `spectrum: []`。推荐使用标准 Canvas:
|
|
82
|
+
|
|
83
|
+
```html
|
|
84
|
+
<canvas id="room-spectrum" :data-points="spectrum" aria-label="实时频谱"></canvas>
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
地图坐标使用普通 HTML 容器和标准地理坐标顺序 `[经度, 纬度]`:
|
|
88
|
+
|
|
89
|
+
```html
|
|
90
|
+
<div id="room-map" class="room-map"
|
|
91
|
+
:data-coordinates="locations" aria-label="实时位置"></div>
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`locations` 的值也是完整快照,例如 `[[116.397, 39.908], [121.473, 31.230]]`。节点只负责把驱动的二维数组更新到这个普通 Vue 数组;地图底图、投影、标记和缩放属于页面自己的显示逻辑。当前自动识别支持 `div`、`section`、`article`、`main`、`aside`、`svg` 和 `g` 上的 `:data-coordinates`,不要求页面引入地图 SDK,也不要求页面知道驱动或订阅机制。
|
|
95
|
+
|
|
96
|
+
节点会把每个完整帧更新到 `spectrum` 或 `locations`,页面自己的绘图逻辑可以观察这个普通 Vue 数组。`data-points`、`data-series` 和 `data-coordinates` 都是普通 HTML `data-*` 属性,不是 ControlPanel 私有属性。Canvas 的频谱点列与地图坐标只是页面呈现语义不同,底层使用同一个只读 XY-series 通道。当前自动识别只支持顶层简单字段,不支持 `v-for` 内的二维点列模型、函数调用、表达式、多个数据源合并或第三方图表组件的专用数据格式。
|
|
97
|
+
|
|
79
98
|
### 3.1 普通控件写法
|
|
80
99
|
|
|
81
100
|
```html
|
|
@@ -240,6 +259,17 @@ Meter 是驱动端的特殊只读数据类型,不是“另一个滑杆”。
|
|
|
240
259
|
<output id="room-value">{{ outputValue }}</output>
|
|
241
260
|
```
|
|
242
261
|
|
|
262
|
+
## 4.1 XY-series 与普通实时数组反馈
|
|
263
|
+
|
|
264
|
+
XY-series 是 Meter 的独立数组版本,不是多个 Meter 的替代写法:
|
|
265
|
+
|
|
266
|
+
- 每个驱动帧是一个完整的 `[[x, y], ...]` 快照,第一层长度可以变化,也可以是空数组。
|
|
267
|
+
- 每个点必须恰好有两个有限数字;节点保留点的顺序,不自动排序、不自动缩放、不自动丢弃旧点。
|
|
268
|
+
- 数据只读,绑定只能使用 `read` 模式,没有 Write point;它与 Volume、Level 和 Meter 不能共用同一个模型字段。
|
|
269
|
+
- 多个页面绑定相同 stream 时只建立一个源订阅,页面帧使用 latest-wins;网络拥塞时允许丢弃旧帧,不补发历史帧。
|
|
270
|
+
- 当前每个 stream 默认最多 8192 个点,每个面板最多 256 个 XY-series 绑定;真实 MUSE 测试前仍需按点数和帧大小验证吞吐。
|
|
271
|
+
- 页面作者不写 socket、订阅、帧解析或重连代码,只维护普通数组。频谱坐标轴、Canvas 绘制、坐标变换和图表样式属于页面显示逻辑。
|
|
272
|
+
|
|
243
273
|
文本展示只使用一个简单字段插值,例如 `{{ status }}` 或 `{{ outputValue }}`。不要使用 `toFixed()`、拼接表达式或多个插值作为绑定模型。
|
|
244
274
|
|
|
245
275
|
## 5. 语义开关按钮
|
|
@@ -277,6 +307,7 @@ Template 修改可能使已有映射失效。重新 Deploy 后,应重新打开
|
|
|
277
307
|
| 可写 integer/float | range 或 `v-slider`;用于音量/增益等普通连续控制。 |
|
|
278
308
|
| 只读 integer/float,没有 Meter 元数据 | 文本展示等普通只读反馈,不能选择为 Meter。 |
|
|
279
309
|
| 只读 integer/float,带有效 `.metadata.meter` | Meter、原生 `meter`/`progress` 或 Vuetify 进度组件;只能读。 |
|
|
310
|
+
| 只读参数,带有效 `.metadata.xySeries` | XY-series;使用标准 Canvas 的 `:data-points`/`:data-series`,或地图容器的 `:data-coordinates` 数组字段;只能读。 |
|
|
280
311
|
| enum,或带枚举描述的 integer/string | 单选枚举。 |
|
|
281
312
|
| 没有枚举但路径以 `/source` 或 `/preset` 结尾的 integer | 选择类参数,仍需确认合法选项。 |
|
|
282
313
|
| string/dstring | 文本输入或文本展示。 |
|
|
@@ -293,6 +324,7 @@ Write point 改动时 Feedback point 会跟随;单独改 Feedback point 不会
|
|
|
293
324
|
| `v-for` 实例没有出现 | 来源是否是顶层数组;key 是否严格符合对象/数组规则;列表项是否实际渲染且 key 唯一。 |
|
|
294
325
|
| 设备有了但点位为空 | 设备在线状态、AMX 参数目录、`kind/type/access` 和读写权限;不是所有命令都属于可绑定参数。 |
|
|
295
326
|
| Meter 可以选但无值 | 点位是否带 `.metadata.meter`;驱动是否实际发布 manifest 和 frame;页面字段是否与普通 Volume 分开。 |
|
|
327
|
+
| XY-series 可以选但无值 | 点位是否带 `.metadata.xySeries`;驱动是否发布对应 manifest/frame;数组字段是否为顶层数组;检查点数、帧大小和过期状态。 |
|
|
296
328
|
| 页面一直 Loading | 查看模板编译错误,重点检查原生 Meter/Progress 的 v-model 写法、自闭合 Vuetify、动态表达式和不支持的组件。设备离线不应被页面作者通过轮询“修复”。 |
|
|
297
329
|
| 滑杆动不了 | 是否仍处于 Select 编辑模式;模型是否为数字;是否给绑定控件添加了自己的事件或回写逻辑。 |
|
|
298
330
|
| 页面变化但设备不变 | 检查映射的 Write point、设备权限和运行日志。前端模型变化不等于设备已确认执行。 |
|
|
@@ -307,6 +339,8 @@ Write point 改动时 Feedback point 会跟随;单独改 Feedback point 不会
|
|
|
307
339
|
- 所有待绑定控件位于支持清单,模型字段简单、独立、可写且类型正确。
|
|
308
340
|
- 列表只使用本指南规定的 `v-for`、`:key` 和模型路径;对象 key 稳定唯一,数组 key 使用 index。
|
|
309
341
|
- 列表内的 Meter、Volume 和其他控件使用不同模型字段;重复实例依靠稳定 key 区分,不生成随机 ID。
|
|
342
|
+
- XY-series 使用独立的顶层数组字段,例如 `spectrum: []`,不要与 Meter、Volume 或普通反馈字段共用。
|
|
343
|
+
- 地图坐标使用独立的顶层数组字段,例如 `locations: []`,点顺序固定为 `[经度, 纬度]`;不要与 Meter、Volume 或普通反馈字段共用。
|
|
310
344
|
- 页面量程明确、数值常量有效;枚举选项与实际设备值对应。
|
|
311
345
|
- 没有手写 `data-cp-*`、设备通信、轮询、反馈回写、绑定控件事件或第二套发送逻辑。
|
|
312
346
|
- 实时表头仍是普通 HTML/Vue/Vuetify 控件;没有 stream、socket、订阅、帧协议或驱动代码。
|
package/README.md
CHANGED
|
@@ -14,6 +14,8 @@ Supported controls include native range, checkbox, semantic pressed button, sele
|
|
|
14
14
|
|
|
15
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
16
|
|
|
17
|
+
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
|
+
|
|
17
19
|
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.
|
|
18
20
|
|
|
19
21
|
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-catalog.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const { schemaVersion } = require('./control-type-contract.json')
|
|
2
2
|
const { runtimeJsonRequest } = require('./panel-http')
|
|
3
3
|
const { meterRoute } = require('./meter-route')
|
|
4
|
+
const { xySeriesRoute } = require('./xy-series-route')
|
|
4
5
|
|
|
5
6
|
function catalogBase (settings) {
|
|
6
7
|
const port = Number(settings?.uiPort)
|
|
@@ -75,6 +76,7 @@ async function listCatalogTargets (base, device, fetcher = fetch) {
|
|
|
75
76
|
const max = catalogBound(properties['.max'])
|
|
76
77
|
const enums = properties['.enums']
|
|
77
78
|
const meter = properties['.metadata']?.meter ? meterRoute(properties['.metadata'].meter) : null
|
|
79
|
+
const xySeries = properties['.metadata']?.xySeries ? xySeriesRoute(properties['.metadata'].xySeries) : null
|
|
78
80
|
return {
|
|
79
81
|
path: item.path,
|
|
80
82
|
kind: properties['.kind'] || '',
|
|
@@ -83,10 +85,11 @@ async function listCatalogTargets (base, device, fetcher = fetch) {
|
|
|
83
85
|
onebased: typeof properties['.onebased'] === 'boolean' ? properties['.onebased']
|
|
84
86
|
: typeof item.onebased === 'boolean' ? item.onebased : undefined,
|
|
85
87
|
min, max, enums,
|
|
86
|
-
...(meter ? { controlType: 'meter' } : inferredControl(item.path, properties, type, min, max, enums)),
|
|
88
|
+
...(meter ? { controlType: 'meter' } : xySeries ? { controlType: 'xy-series' } : inferredControl(item.path, properties, type, min, max, enums)),
|
|
87
89
|
controlSchemaVersion: schemaVersion,
|
|
88
90
|
unit: properties['.metadata']?.unit || '',
|
|
89
|
-
...(meter ? { meter } : {})
|
|
91
|
+
...(meter ? { meter } : {}),
|
|
92
|
+
...(xySeries ? { xySeries } : {})
|
|
90
93
|
}
|
|
91
94
|
})
|
|
92
95
|
const unique = new Map()
|
package/control-panel.js
CHANGED
|
@@ -11,6 +11,7 @@ const { applyVisualBindings, editorConfig, getSnapshot } = require('./panel-visu
|
|
|
11
11
|
const { createGateway } = require('./gateway.cjs')
|
|
12
12
|
const { managedBridgeRevision } = require('./managed-bridge-revision')
|
|
13
13
|
const { createMeterRoom, createPanelMeter } = require('./panel-meter')
|
|
14
|
+
const { createXYSeriesRoom, createPanelXYSeries } = require('./panel-xy-series')
|
|
14
15
|
|
|
15
16
|
let statestore
|
|
16
17
|
const authHandoffKey = crypto.randomBytes(32)
|
|
@@ -71,6 +72,7 @@ module.exports = function (RED) {
|
|
|
71
72
|
const activePanels = new Map()
|
|
72
73
|
const activeEditors = new Map()
|
|
73
74
|
const meterRooms = new Map()
|
|
75
|
+
const xySeriesRooms = new Map()
|
|
74
76
|
RED.httpAdmin.get('/ui-control-panel/editor-sync.js', RED.auth.needsPermission('nodes.read'), (_req, res) => {
|
|
75
77
|
res.setHeader('Cache-Control', 'no-store')
|
|
76
78
|
res.sendFile(path.join(__dirname, 'panel-flow-editor-sync.js'))
|
|
@@ -159,6 +161,16 @@ module.exports = function (RED) {
|
|
|
159
161
|
} catch (error) { node.error(error) }
|
|
160
162
|
}
|
|
161
163
|
|
|
164
|
+
let xySeries
|
|
165
|
+
if ([...routes.values()].some(route => route.binding.readMeta?.xySeries) && /^[a-zA-Z0-9_-]+$/.test(boundRoom || '')) {
|
|
166
|
+
try {
|
|
167
|
+
let hub = xySeriesRooms.get(boundRoom)
|
|
168
|
+
if (!hub) { hub = createXYSeriesRoom({ room: boundRoom }); xySeriesRooms.set(boundRoom, hub) }
|
|
169
|
+
xySeries = createPanelXYSeries({ panelId: node.id, room: boundRoom, bindings: visualBindings, hub,
|
|
170
|
+
onSubscribe: () => { if (bridgeReady) node.send({ kind: 'read-all', room: boundRoom }) } })
|
|
171
|
+
} catch (error) { node.error(error) }
|
|
172
|
+
}
|
|
173
|
+
|
|
162
174
|
let gateway
|
|
163
175
|
let gatewayAuthKey
|
|
164
176
|
if (visualBinding && /^[a-zA-Z0-9_-]+$/.test(boundRoom || '')) {
|
|
@@ -173,7 +185,8 @@ module.exports = function (RED) {
|
|
|
173
185
|
secureCookie: config.secureCookie === true,
|
|
174
186
|
restoredSessions: takeSessions(gatewayHandoffs, node.id, gatewayAuthKey),
|
|
175
187
|
onAuthenticatedRequest: (req, res, identity) => meter?.ticket(req, res, identity,
|
|
176
|
-
() => gateway?.sessionActive(identity.token)) ||
|
|
188
|
+
() => gateway?.sessionActive(identity.token)) || xySeries?.ticket(req, res, identity,
|
|
189
|
+
() => gateway?.sessionActive(identity.token)) || false,
|
|
177
190
|
onBoundCommand: (message, identity) => {
|
|
178
191
|
if (!bridgeReady) return false
|
|
179
192
|
node.send({ ...resolveBoundCommand(routes, message), room: identity.room, user: identity.user })
|
|
@@ -203,7 +216,8 @@ module.exports = function (RED) {
|
|
|
203
216
|
secureCookie: config.secureCookie === true,
|
|
204
217
|
restoredSessions: takeSessions(editorHandoffs, node.id, editorAuthKey),
|
|
205
218
|
onAuthenticatedRequest: (req, res, identity) => meter?.ticket(req, res, identity,
|
|
206
|
-
() => editor?.sessionActive(identity.token)) ||
|
|
219
|
+
() => editor?.sessionActive(identity.token)) || xySeries?.ticket(req, res, identity,
|
|
220
|
+
() => editor?.sessionActive(identity.token)) || false,
|
|
207
221
|
getConfig: async req => editorConfig(await getSnapshot(req.socket.localPort, fetch,
|
|
208
222
|
RED.settings.httpAdminRoot ?? '/'), node.id),
|
|
209
223
|
getDevices: async () => {
|
|
@@ -230,7 +244,7 @@ module.exports = function (RED) {
|
|
|
230
244
|
}
|
|
231
245
|
|
|
232
246
|
const panelConfig = () => (boundMode || visualBinding) ? {
|
|
233
|
-
endpoint, room: boundRoom, meter: Boolean(meter),
|
|
247
|
+
endpoint, room: boundRoom, meter: Boolean(meter), xySeries: Boolean(xySeries),
|
|
234
248
|
...(visualBinding ? { visual: {
|
|
235
249
|
panelId: node.id,
|
|
236
250
|
editorEndpoint: `/control-panel-edit/panels/${node.id}`,
|
|
@@ -241,7 +255,7 @@ module.exports = function (RED) {
|
|
|
241
255
|
|
|
242
256
|
const events = {
|
|
243
257
|
onAction: !boundMode,
|
|
244
|
-
...(meter ? { onSocket: meter
|
|
258
|
+
...((meter || xySeries) ? { onSocket: { ...(meter?.hooks || {}), ...(xySeries?.hooks || {}) } } : {}),
|
|
245
259
|
beforeSend (msg) {
|
|
246
260
|
if (msg?._controlPanel?.kind === 'command') throw new Error('Bound commands require authenticated HTTP')
|
|
247
261
|
if (['device-meter-frame', 'device-meter-manifest'].includes(msg?.kind)) {
|
|
@@ -250,6 +264,10 @@ module.exports = function (RED) {
|
|
|
250
264
|
// should be emitted. `false` is property-safe and suppresses emit.
|
|
251
265
|
return false
|
|
252
266
|
}
|
|
267
|
+
if (['device-xy-series-frame', 'device-xy-series-manifest'].includes(msg?.kind)) {
|
|
268
|
+
if (bridgeReady) xySeries?.accept(msg)
|
|
269
|
+
return false
|
|
270
|
+
}
|
|
253
271
|
if (boundMode) {
|
|
254
272
|
if (msg?.kind === 'device-feedback' && gateway) {
|
|
255
273
|
gateway.boundFeedback(resolveBoundFeedback(routes, msg))
|
|
@@ -325,7 +343,9 @@ module.exports = function (RED) {
|
|
|
325
343
|
gateway?.close()
|
|
326
344
|
editor?.close()
|
|
327
345
|
meter?.close()
|
|
346
|
+
xySeries?.close()
|
|
328
347
|
if (meterRooms.get(boundRoom)?.stats().panels === 0) meterRooms.delete(boundRoom)
|
|
348
|
+
if (xySeriesRooms.get(boundRoom)?.stats().panels === 0) xySeriesRooms.delete(boundRoom)
|
|
329
349
|
if (activePanels.get(node.id) === gateway) activePanels.delete(node.id)
|
|
330
350
|
if (activeEditors.get(node.id) === editor) activeEditors.delete(node.id)
|
|
331
351
|
})
|
package/control-policy.js
CHANGED
|
@@ -5,6 +5,8 @@ const { fetchWithTimeout } = require('./panel-http')
|
|
|
5
5
|
const { createTargetMatcher } = require('./control-type-model')
|
|
6
6
|
const { createRoomMeterReceiver, createMeterRenderer } = require('./meter-frame')
|
|
7
7
|
const { createPanelMeterClient } = require('./panel-meter-client')
|
|
8
|
+
const { createRoomXYSeriesReceiver, createXYSeriesRenderer } = require('./xy-series-frame')
|
|
9
|
+
const { createPanelXYSeriesClient } = require('./panel-xy-series-client')
|
|
8
10
|
|
|
9
11
|
const CONTROL_POLICY_MARKER = '/* ui-control-panel-control-api-v1 */'
|
|
10
12
|
|
|
@@ -164,6 +166,11 @@ function buildDashboardPolicyOptionsSource (controls = [], boundPanel = null) {
|
|
|
164
166
|
__createMeterRenderer: ${createMeterRenderer.toString()},
|
|
165
167
|
__createPanelMeterClient: ${createPanelMeterClient.toString()},`
|
|
166
168
|
: ''
|
|
169
|
+
const xySeriesMethods = boundPanel?.xySeries
|
|
170
|
+
? `__createXYSeriesReceiver: ${createRoomXYSeriesReceiver.toString()},
|
|
171
|
+
__createXYSeriesRenderer: ${createXYSeriesRenderer.toString()},
|
|
172
|
+
__createPanelXYSeriesClient: ${createPanelXYSeriesClient.toString()},`
|
|
173
|
+
: ''
|
|
167
174
|
const visualMethods = boundPanel?.visual
|
|
168
175
|
? `__createTargetMatcher: ${createTargetMatcher.toString()},
|
|
169
176
|
__initializeVisualBinding: ${initializeVisualBinding.toString()},
|
|
@@ -188,6 +195,7 @@ export default {
|
|
|
188
195
|
},
|
|
189
196
|
methods: {
|
|
190
197
|
${meterMethods}
|
|
198
|
+
${xySeriesMethods}
|
|
191
199
|
${visualMethods}
|
|
192
200
|
__panelFetch: ${fetchWithTimeout.toString()},
|
|
193
201
|
__boundPanelConfig () {
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
"level": { "family": "number", "delivery": "continuous" },
|
|
6
6
|
"volume": { "family": "number", "delivery": "continuous" },
|
|
7
7
|
"meter": { "family": "number", "delivery": "realtime", "access": "ro" },
|
|
8
|
+
"xy-series": { "family": "xy-series", "delivery": "realtime", "access": "ro" },
|
|
8
9
|
"dimmer": { "family": "number", "delivery": "continuous" },
|
|
9
10
|
"position": { "family": "number", "delivery": "continuous" },
|
|
10
11
|
"toggle": { "family": "boolean", "delivery": "discrete" },
|
package/control-type-model.js
CHANGED
|
@@ -6,10 +6,11 @@ const { meterRoute } = require('./meter-route')
|
|
|
6
6
|
// Used by both server validation and the injected editor; keep it self-contained.
|
|
7
7
|
function createTargetMatcher (typeContract) {
|
|
8
8
|
const families = {
|
|
9
|
-
range: ['number'], switch: ['boolean'], meter: ['number'],
|
|
9
|
+
range: ['number'], switch: ['boolean'], meter: ['number'], 'xy-series': ['xy-series'],
|
|
10
10
|
'text-display': ['text', 'number', 'enum', 'boolean'], 'text-input': ['text'], select: ['enum']
|
|
11
11
|
}
|
|
12
12
|
function family (target) {
|
|
13
|
+
if (target.xySeries) return 'xy-series'
|
|
13
14
|
if (target.controlSchemaVersion !== typeContract.schemaVersion) return null
|
|
14
15
|
const definition = typeContract.types[String(target.controlType || '').toLowerCase()]
|
|
15
16
|
if (!definition) return null
|
|
@@ -24,9 +25,11 @@ function createTargetMatcher (typeContract) {
|
|
|
24
25
|
}
|
|
25
26
|
function compatible (adapter, target, direction, candidate = {}) {
|
|
26
27
|
if (adapter === 'meter' && !target.meter) return false
|
|
28
|
+
if (adapter === 'xy-series' && !target.xySeries) return false
|
|
27
29
|
if (target.meter && (adapter !== 'meter' || direction !== 'read' || target.access !== 'ro')) return false
|
|
30
|
+
if (target.xySeries && (adapter !== 'xy-series' || direction !== 'read' || target.access !== 'ro')) return false
|
|
28
31
|
if (target.kind !== 'param' || !families[adapter]?.includes(family(target))) return false
|
|
29
|
-
if (direction === 'write' && ['meter', 'text-display'].includes(adapter)) return false
|
|
32
|
+
if (direction === 'write' && ['meter', 'xy-series', 'text-display'].includes(adapter)) return false
|
|
30
33
|
if (!['read', 'write'].includes(direction)) return false
|
|
31
34
|
if (!(direction === 'write' ? ['wo', 'rw'] : ['ro', 'rw']).includes(target.access)) return false
|
|
32
35
|
if (candidate.unit && candidate.unit !== target.unit) return false
|
|
@@ -42,6 +45,7 @@ function controlTypeForAdapter (adapter, target) {
|
|
|
42
45
|
throw new Error('Target is incompatible with this control')
|
|
43
46
|
}
|
|
44
47
|
if (adapter === 'meter' && target.meter) return 'meter'
|
|
48
|
+
if (adapter === 'xy-series' && target.xySeries) return 'xy-series'
|
|
45
49
|
const family = targetFamily(target)
|
|
46
50
|
if (family === 'number') return 'level'
|
|
47
51
|
if (family === 'boolean') return 'mute'
|
|
@@ -60,7 +64,8 @@ function targetMeta (target, onebased) {
|
|
|
60
64
|
min: target.min, max: target.max, enums: target.enums,
|
|
61
65
|
...(target.booleanEncoding ? { booleanEncoding: target.booleanEncoding } : {}),
|
|
62
66
|
controlType: target.controlType, controlSchemaVersion: target.controlSchemaVersion,
|
|
63
|
-
...(target.meter ? { meter: meterRoute(target.meter), unit: target.unit || '' } : {})
|
|
67
|
+
...(target.meter ? { meter: meterRoute(target.meter), unit: target.unit || '' } : {}),
|
|
68
|
+
...(target.xySeries ? { xySeries: require('./xy-series-route').xySeriesRoute(target.xySeries) } : {})
|
|
64
69
|
}
|
|
65
70
|
}
|
|
66
71
|
|
package/managed-native-bridge.js
CHANGED
|
@@ -25,7 +25,8 @@ function panelTargets (panel) {
|
|
|
25
25
|
device: binding.device, path, kind: meta.kind, type: meta.type,
|
|
26
26
|
access: meta.access, onebased: meta.onebased,
|
|
27
27
|
...(meta.booleanEncoding ? { booleanEncoding: meta.booleanEncoding } : {}),
|
|
28
|
-
...(meta.meter ? { meter: meta.meter } : {})
|
|
28
|
+
...(meta.meter ? { meter: meta.meter } : {}),
|
|
29
|
+
...(meta.xySeries ? { xySeries: meta.xySeries } : {})
|
|
29
30
|
}
|
|
30
31
|
const key = JSON.stringify([target.device, target.path])
|
|
31
32
|
const previous = targets.get(key)
|
package/native-binding-plan.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const { configuredRoutes } = require('./control-bound-protocol')
|
|
2
2
|
const { meterRoute } = require('./meter-route')
|
|
3
|
+
const { xySeriesRoute } = require('./xy-series-route')
|
|
3
4
|
|
|
4
5
|
const supportedTypes = {
|
|
5
6
|
boolean: { eventType: 'Boolean', eventOptions: { boolParamFilter: 'Any' } },
|
|
@@ -30,6 +31,7 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
|
|
|
30
31
|
const readTargets = new Map()
|
|
31
32
|
const writeTargets = new Map()
|
|
32
33
|
const meterStreams = new Map()
|
|
34
|
+
const xySeriesStreams = new Map()
|
|
33
35
|
const commandRules = []
|
|
34
36
|
const commandWires = []
|
|
35
37
|
|
|
@@ -37,7 +39,7 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
|
|
|
37
39
|
const target = targetIndex.get(JSON.stringify([device, path]))
|
|
38
40
|
if (!target) throw new Error(`Missing target descriptor: ${device} ${path}`)
|
|
39
41
|
if (target.kind !== 'param') throw new Error(`Only parameter targets are supported: ${path}`)
|
|
40
|
-
if (!Object.hasOwn(supportedTypes, target.type)) {
|
|
42
|
+
if (!target.xySeries && !Object.hasOwn(supportedTypes, target.type)) {
|
|
41
43
|
throw new Error(`Unsupported native parameter type: ${path} (${target.type})`)
|
|
42
44
|
}
|
|
43
45
|
if (['mute', 'power'].includes(type) && target.type !== 'boolean' &&
|
|
@@ -56,7 +58,10 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
|
|
|
56
58
|
if (type === 'meter' && (!target.meter || !['integer', 'float'].includes(target.type) || target.access !== 'ro')) {
|
|
57
59
|
throw new Error(`Meter control must bind to a readonly Meter target: ${path}`)
|
|
58
60
|
}
|
|
59
|
-
if (
|
|
61
|
+
if (type === 'xy-series' && (!target.xySeries || target.access !== 'ro')) {
|
|
62
|
+
throw new Error(`XY-series control must bind to a readonly XY-series target: ${path}`)
|
|
63
|
+
}
|
|
64
|
+
if (!['mute', 'power', 'level', 'dimmer', 'position', 'meter', 'xy-series', 'text', 'source', 'preset'].includes(type)) {
|
|
60
65
|
throw new Error(`Control type requires a separate command adapter: ${type}`)
|
|
61
66
|
}
|
|
62
67
|
if (!['ro', 'wo', 'rw'].includes(target.access) || !target.access.includes(access)) {
|
|
@@ -134,6 +139,49 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
|
|
|
134
139
|
}
|
|
135
140
|
continue
|
|
136
141
|
}
|
|
142
|
+
if (target.xySeries) {
|
|
143
|
+
const series = xySeriesRoute(target.xySeries)
|
|
144
|
+
if (control.mode !== 'read' || control.adapter !== 'xy-series') {
|
|
145
|
+
throw Error(`XY-series is read-only: ${id}`)
|
|
146
|
+
}
|
|
147
|
+
const key = JSON.stringify([target.device, series.streamId])
|
|
148
|
+
const previous = xySeriesStreams.get(key)
|
|
149
|
+
if (previous) {
|
|
150
|
+
if (JSON.stringify(previous.series) !== JSON.stringify(series) || previous.onebased !== target.onebased) {
|
|
151
|
+
throw Error(`Conflicting xy-series stream: ${series.streamId}`)
|
|
152
|
+
}
|
|
153
|
+
continue
|
|
154
|
+
}
|
|
155
|
+
const index = xySeriesStreams.size + 1
|
|
156
|
+
const statusId = `${scopeId}-xy-series-read-${index}`
|
|
157
|
+
const tagIds = []
|
|
158
|
+
xySeriesStreams.set(key, { series, onebased: target.onebased, statusId, tagIds })
|
|
159
|
+
for (const [role, path] of [['manifest', series.manifestPath], ['frame', series.eventPath]]) {
|
|
160
|
+
const eventId = `${scopeId}-xy-series-${role}-${index}`
|
|
161
|
+
const tagId = `${eventId}-tag`
|
|
162
|
+
tagIds.push(tagId)
|
|
163
|
+
const y = 540 + (index - 1) * 280 + (role === 'frame' ? 140 : 0)
|
|
164
|
+
if (role === 'manifest') nodes.push({
|
|
165
|
+
id: statusId, type: 'Status', z: subflowId, name: `Read xy-series manifest ${target.device}`,
|
|
166
|
+
x: 440, y: y - 60, mojo: { version: '1.0', device: [target.device],
|
|
167
|
+
parameter: path, onebased: target.onebased }, wires: [[tagId]]
|
|
168
|
+
})
|
|
169
|
+
nodes.push({ id: eventId, type: 'Event', z: subflowId,
|
|
170
|
+
name: `Watch xy-series ${role} ${target.device} ${series.streamId}`, x: 440, y,
|
|
171
|
+
mojo: { version: '1.0', device: [target.device], event: path,
|
|
172
|
+
eventKind: role === 'frame' ? 'Event' : 'Parameter', paramType: 'String',
|
|
173
|
+
onebased: target.onebased }, wires: [[tagId]] })
|
|
174
|
+
nodes.push({ id: tagId, type: 'change', z: subflowId,
|
|
175
|
+
name: `Identify xy-series ${role} ${target.device}`, x: 1000, y,
|
|
176
|
+
rules: [
|
|
177
|
+
{ t: 'set', p: 'kind', pt: 'msg', to: 'device-xy-series-' + role, tot: 'str' },
|
|
178
|
+
{ t: 'set', p: 'device', pt: 'msg', to: target.device, tot: 'str' },
|
|
179
|
+
{ t: 'set', p: 'path', pt: 'msg', to: path, tot: 'str' },
|
|
180
|
+
{ t: 'set', p: 'streamId', pt: 'msg', to: series.streamId, tot: 'str' }
|
|
181
|
+
], wires: [[]] })
|
|
182
|
+
}
|
|
183
|
+
continue
|
|
184
|
+
}
|
|
137
185
|
const key = JSON.stringify([target.device, target.path])
|
|
138
186
|
if (readTargets.has(key)) continue
|
|
139
187
|
const index = readTargets.size + 1
|
|
@@ -175,7 +223,11 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
|
|
|
175
223
|
|
|
176
224
|
const commandSwitchId = `${scopeId}-commands`
|
|
177
225
|
// Meter lanes follow ordinary feedback lanes without overlapping their labels.
|
|
178
|
-
for (const node of nodes)
|
|
226
|
+
for (const node of nodes) {
|
|
227
|
+
if (node.id.startsWith(`${scopeId}-meter-`) || node.id.startsWith(`${scopeId}-xy-series-`)) {
|
|
228
|
+
node.y += readTargets.size * 170
|
|
229
|
+
}
|
|
230
|
+
}
|
|
179
231
|
const routerId = `${scopeId}-router`
|
|
180
232
|
if (commandRules.length) {
|
|
181
233
|
nodes.push({
|
|
@@ -196,17 +248,18 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
|
|
|
196
248
|
],
|
|
197
249
|
checkall: 'false', repair: false, outputs: 2,
|
|
198
250
|
wires: [commandRules.length ? [commandSwitchId] : [],
|
|
199
|
-
[...readTargets.values(), ...meterStreams.values()].map(target => target.statusId)]
|
|
251
|
+
[...readTargets.values(), ...meterStreams.values(), ...xySeriesStreams.values()].map(target => target.statusId)]
|
|
200
252
|
})
|
|
201
253
|
const subflow = {
|
|
202
254
|
id: subflowId, type: 'subflow', name: `Binding bridge ${scopeId}`,
|
|
203
255
|
info: 'Generated panel binding bridge. Requires a Controller node and authenticated command source.',
|
|
204
256
|
category: 'panel binding',
|
|
205
257
|
in: [{ x: 170, y: 150, wires: [{ id: routerId }] }],
|
|
206
|
-
out: readTargets.size || meterStreams.size
|
|
258
|
+
out: readTargets.size || meterStreams.size || xySeriesStreams.size
|
|
207
259
|
? [{ x: meterStreams.size ? 1600 : 1290, y: Math.round((390 + (readTargets.size + meterStreams.size + 1) * 85) / 10) * 10,
|
|
208
260
|
wires: [...readTargets.values()].map(target => ({ id: target.tagId, port: 0 }))
|
|
209
|
-
.concat([...meterStreams.values()].flatMap(target => target.tagIds.map(id => ({ id, port: 0 }))))
|
|
261
|
+
.concat([...meterStreams.values()].flatMap(target => target.tagIds.map(id => ({ id, port: 0 }))))
|
|
262
|
+
.concat([...xySeriesStreams.values()].flatMap(target => target.tagIds.map(id => ({ id, port: 0 })))) }]
|
|
210
263
|
: [],
|
|
211
264
|
env: [], meta: {}, color: '#96bca7'
|
|
212
265
|
}
|
|
@@ -215,8 +268,9 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
|
|
|
215
268
|
manifest: {
|
|
216
269
|
subflowId,
|
|
217
270
|
controls: routes.size,
|
|
218
|
-
nativeReads: readTargets.size + meterStreams.size,
|
|
271
|
+
nativeReads: readTargets.size + meterStreams.size + xySeriesStreams.size,
|
|
219
272
|
nativeMeterStreams: meterStreams.size,
|
|
273
|
+
nativeXYSeriesStreams: xySeriesStreams.size,
|
|
220
274
|
nativeWrites: writeTargets.size,
|
|
221
275
|
requiresController: true,
|
|
222
276
|
initialReadMessage: { kind: 'read-all' }
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-red-contrib-room-tools",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.1",
|
|
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.
|
|
9
|
+
"version": "1.9.1",
|
|
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.
|
|
3
|
+
"version": "1.9.1",
|
|
4
4
|
"description": "Authenticated visual control panel binding for Node-RED and MUSE Automator",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"engines": {
|
|
@@ -27,6 +27,10 @@
|
|
|
27
27
|
"meter-frame.js",
|
|
28
28
|
"panel-meter.js",
|
|
29
29
|
"panel-meter-client.js",
|
|
30
|
+
"xy-series-route.js",
|
|
31
|
+
"xy-series-frame.js",
|
|
32
|
+
"panel-xy-series.js",
|
|
33
|
+
"panel-xy-series-client.js",
|
|
30
34
|
"panel-template-instrumentation.js",
|
|
31
35
|
"panel-visual-bindings.js",
|
|
32
36
|
"panel-visual-apply.js",
|
package/panel-binding-editor.js
CHANGED
|
@@ -7,8 +7,11 @@ function initializeVisualBinding () {
|
|
|
7
7
|
const base = config.editorEndpoint
|
|
8
8
|
const saved = new Map(Object.entries(config.bindings || {}))
|
|
9
9
|
const meterEntries = [...saved].filter(([, entry]) => entry.readMeta?.meter)
|
|
10
|
+
const xySeriesEntries = [...saved].filter(([, entry]) => entry.readMeta?.xySeries)
|
|
10
11
|
const meterTargets = new Map()
|
|
12
|
+
const xySeriesTargets = new Map()
|
|
11
13
|
let meterClient = null, lastMeterFrame = null
|
|
14
|
+
let xySeriesClient = null, lastXYSeriesFrame = null
|
|
12
15
|
const attached = new WeakSet()
|
|
13
16
|
const feedbackModels = new Map()
|
|
14
17
|
const pendingText = new Map()
|
|
@@ -189,7 +192,7 @@ function initializeVisualBinding () {
|
|
|
189
192
|
}
|
|
190
193
|
function showFeedback () {
|
|
191
194
|
for (const [id, binding] of saved) {
|
|
192
|
-
if (binding.mode === 'write' || binding.readMeta?.meter) continue
|
|
195
|
+
if (binding.mode === 'write' || binding.readMeta?.meter || binding.readMeta?.xySeries) continue
|
|
193
196
|
const node = nodeForBinding(binding)
|
|
194
197
|
if (!node) continue
|
|
195
198
|
if (!Object.prototype.hasOwnProperty.call(component.boundState, id)) continue
|
|
@@ -288,6 +291,15 @@ function initializeVisualBinding () {
|
|
|
288
291
|
if (lastMeterFrame) applyMeterFrame(lastMeterFrame.frame, lastMeterFrame.ids)
|
|
289
292
|
else invalidateMeters()
|
|
290
293
|
}
|
|
294
|
+
if (xySeriesEntries.length) {
|
|
295
|
+
xySeriesTargets.clear()
|
|
296
|
+
for (const [id, binding] of xySeriesEntries) {
|
|
297
|
+
const node = nodeForBinding(binding)
|
|
298
|
+
if (node) xySeriesTargets.set(id, { node, binding })
|
|
299
|
+
}
|
|
300
|
+
if (lastXYSeriesFrame) applyXYSeriesFrame(lastXYSeriesFrame.frame, lastXYSeriesFrame.ids)
|
|
301
|
+
else invalidateXYSeries()
|
|
302
|
+
}
|
|
291
303
|
showFeedback()
|
|
292
304
|
}
|
|
293
305
|
function applyMeterFrame (frame, ids) {
|
|
@@ -313,6 +325,32 @@ function initializeVisualBinding () {
|
|
|
313
325
|
node.setAttribute('aria-invalid', 'true')
|
|
314
326
|
}
|
|
315
327
|
}
|
|
328
|
+
function applyXYSeriesFrame (frame, ids) {
|
|
329
|
+
lastXYSeriesFrame = { frame, ids }
|
|
330
|
+
for (let index = 0; index < ids.length; index++) {
|
|
331
|
+
const target = xySeriesTargets.get(ids[index])
|
|
332
|
+
if (!target) continue
|
|
333
|
+
const { node, binding } = target
|
|
334
|
+
node.setAttribute('data-cpv-xy-series-valid', String(frame.valid[index]))
|
|
335
|
+
node.setAttribute('data-cpv-xy-series-seq', String(frame.seq))
|
|
336
|
+
const points = frame.valid[index] ? frame.values[index].map(point => point.slice()) : []
|
|
337
|
+
if (binding.model) {
|
|
338
|
+
if (binding.repeat) setRepeatModel(binding, points)
|
|
339
|
+
else if (binding.model in component && !component.$options?.computed?.[binding.model]) component[binding.model] = points
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
function invalidateXYSeries () {
|
|
344
|
+
lastXYSeriesFrame = null
|
|
345
|
+
for (const { node, binding } of xySeriesTargets.values()) {
|
|
346
|
+
node.setAttribute('data-cpv-xy-series-valid', 'false')
|
|
347
|
+
node.setAttribute('aria-invalid', 'true')
|
|
348
|
+
if (binding.model) {
|
|
349
|
+
if (binding.repeat) setRepeatModel(binding, [])
|
|
350
|
+
else if (binding.model in component && !component.$options?.computed?.[binding.model]) component[binding.model] = []
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
316
354
|
function attachModelControls () {
|
|
317
355
|
for (const [id, binding] of saved) {
|
|
318
356
|
if (!binding.component || !binding.model || binding.mode === 'read') continue
|
|
@@ -762,6 +800,9 @@ function initializeVisualBinding () {
|
|
|
762
800
|
if (candidate.adapter === 'meter' && (typeof value !== 'number' || !Number.isFinite(value))) {
|
|
763
801
|
return { ...candidate, supported: false, reason: 'This Vuetify progress needs a numeric model field' }
|
|
764
802
|
}
|
|
803
|
+
if (candidate.adapter === 'xy-series' && !Array.isArray(value)) {
|
|
804
|
+
return { ...candidate, supported: false, reason: 'This realtime array control needs an array model field' }
|
|
805
|
+
}
|
|
765
806
|
if (candidate.adapter === 'select' && typeof value !== 'string' &&
|
|
766
807
|
!(typeof value === 'number' && Number.isFinite(value))) {
|
|
767
808
|
return { ...candidate, supported: false, reason: 'This Vuetify selection needs one scalar model field' }
|
|
@@ -822,6 +863,25 @@ function initializeVisualBinding () {
|
|
|
822
863
|
}))
|
|
823
864
|
} catch (error) { console.error('ControlPanel meter initialization failed', error) }
|
|
824
865
|
}
|
|
866
|
+
if (this.__boundPanelConfig().xySeries && xySeriesEntries.length) {
|
|
867
|
+
try {
|
|
868
|
+
const ids = xySeriesEntries.map(([id]) => id).sort((a, b) => a.localeCompare(b))
|
|
869
|
+
xySeriesClient = this.__createPanelXYSeriesClient({ socket: this.$socket,
|
|
870
|
+
panelId: config.panelId, room: this.__boundPanelConfig().room, ids,
|
|
871
|
+
request: (...args) => component.__panelFetch(...args),
|
|
872
|
+
createReceiver: this.__createXYSeriesReceiver, createRenderer: this.__createXYSeriesRenderer,
|
|
873
|
+
apply: applyXYSeriesFrame, invalidate: invalidateXYSeries,
|
|
874
|
+
onUnauthorized: () => {
|
|
875
|
+
if (editing) { editSession = null; loginView() }
|
|
876
|
+
else component.clearBoundPanelSession()
|
|
877
|
+
} })
|
|
878
|
+
cleanup.push(() => xySeriesClient.close())
|
|
879
|
+
if (!editing) cleanup.push(this.$watch('boundAuthenticated', authenticated => {
|
|
880
|
+
xySeriesClient.setAuth(authenticated ? { endpoint: component.__boundPanelConfig().endpoint,
|
|
881
|
+
csrf: component.__boundPanelSession.csrf, header: 'X-Room-CSRF' } : null)
|
|
882
|
+
}))
|
|
883
|
+
} catch (error) { console.error('ControlPanel xy-series initialization failed', error) }
|
|
884
|
+
}
|
|
825
885
|
observer = new MutationObserver(() => {
|
|
826
886
|
attachControls()
|
|
827
887
|
updateBindingMarkers()
|
|
@@ -8,6 +8,7 @@ const COMPONENT_ADAPTERS = Object.freeze({
|
|
|
8
8
|
'v-text-field': 'text-input', 'v-textarea': 'text-input',
|
|
9
9
|
'v-progress-linear': 'meter', 'v-progress-circular': 'meter'
|
|
10
10
|
})
|
|
11
|
+
const XY_SERIES_CONTAINER_TAGS = new Set(['div', 'section', 'article', 'main', 'aside', 'svg', 'g'])
|
|
11
12
|
const SIMPLE_IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/
|
|
12
13
|
const SIMPLE_PROPERTY_PATH = /^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*$/
|
|
13
14
|
const UNSAFE_PROPERTY_NAMES = new Set(['__proto__', 'prototype', 'constructor'])
|
|
@@ -63,6 +64,8 @@ function candidateAdapter (node) {
|
|
|
63
64
|
if (tag === 'button' && (attribute(node, 'aria-pressed') !== '' || attribute(node, 'role') === 'switch' ||
|
|
64
65
|
hasBoundAttribute(node, ['aria-pressed', 'aria-checked']))) return 'switch'
|
|
65
66
|
if (tag === 'meter' || tag === 'progress') return 'meter'
|
|
67
|
+
if (tag === 'canvas' && hasBoundAttribute(node, ['data-points', 'data-series'])) return 'xy-series'
|
|
68
|
+
if (XY_SERIES_CONTAINER_TAGS.has(tag) && hasBoundAttribute(node, ['data-coordinates'])) return 'xy-series'
|
|
66
69
|
if (['span', 'strong', 'b', 'output', 'h1', 'h2', 'h3'].includes(tag) &&
|
|
67
70
|
node.children?.length === 1 && node.children[0].type === 2 &&
|
|
68
71
|
node.children[0].content.trim()) return 'text-display'
|
|
@@ -71,6 +74,17 @@ function candidateAdapter (node) {
|
|
|
71
74
|
return null
|
|
72
75
|
}
|
|
73
76
|
|
|
77
|
+
function xySeriesModel (node) {
|
|
78
|
+
const tag = node.tag.toLowerCase()
|
|
79
|
+
const names = tag === 'canvas' ? ['data-points', 'data-series'] : ['data-coordinates']
|
|
80
|
+
const bound = (node.props || []).find(prop => prop.type === 7 && prop.name === 'bind' &&
|
|
81
|
+
names.includes(prop.arg?.content))
|
|
82
|
+
if (!bound || bound.modifiers?.length) return { model: '', valid: false, attribute: names[0] }
|
|
83
|
+
const model = bound.exp?.content?.trim() || ''
|
|
84
|
+
return { model, valid: SIMPLE_IDENTIFIER.test(model), attribute: bound.arg.content,
|
|
85
|
+
presentation: tag === 'canvas' ? 'points' : 'coordinates' }
|
|
86
|
+
}
|
|
87
|
+
|
|
74
88
|
function staticNumericProp (node, name) {
|
|
75
89
|
const bound = (node.props || []).find(prop => prop.type === 7 && prop.name === 'bind' &&
|
|
76
90
|
prop.arg?.content === name)
|
|
@@ -279,6 +293,7 @@ function inspectTemplate (format) {
|
|
|
279
293
|
const component = COMPONENT_ADAPTERS[node.tag.toLowerCase()] ? node.tag.toLowerCase() : ''
|
|
280
294
|
const display = adapter === 'text-display'
|
|
281
295
|
? displayModel(node, activeRepeat)
|
|
296
|
+
: adapter === 'xy-series' ? xySeriesModel(node)
|
|
282
297
|
: ['v-progress-linear', 'v-progress-circular'].includes(component) ? progressModel(node, activeRepeat)
|
|
283
298
|
: adapter === 'meter' && !component ? nativeMeterModel(node, activeRepeat)
|
|
284
299
|
: adapter === 'switch' && node.tag.toLowerCase() === 'button'
|
|
@@ -360,7 +375,11 @@ function inspectTemplate (format) {
|
|
|
360
375
|
unsupportedTextType ? 'Only text Vuetify fields are supported' :
|
|
361
376
|
unsupportedProgress ? 'Progress needs a determinate positive range' :
|
|
362
377
|
unsupportedCircular ? 'Circular progress uses a determinate 0-100 range' :
|
|
363
|
-
|
|
378
|
+
adapter === 'xy-series' && !display.model
|
|
379
|
+
? display.attribute === 'data-coordinates'
|
|
380
|
+
? 'Map coordinate containers need :data-coordinates="coordinates"'
|
|
381
|
+
: 'Canvas needs :data-points="series" or :data-series="series"'
|
|
382
|
+
: component && !display.model ? 'Vuetify controls need a simple Vue v-model field' : ''
|
|
364
383
|
}
|
|
365
384
|
candidates.push({ ...candidate, offset: openingTagEnd(format, node.loc.start.offset) })
|
|
366
385
|
counts.set(locator, (counts.get(locator) || 0) + 1)
|
package/panel-visual-bindings.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const { inspectTemplate } = require('./panel-template-instrumentation')
|
|
2
2
|
const { compatibleTarget, controlTypeForAdapter, targetMeta } = require('./control-type-model')
|
|
3
3
|
const { meterRoute } = require('./meter-route')
|
|
4
|
+
const { xySeriesRoute } = require('./xy-series-route')
|
|
4
5
|
|
|
5
6
|
const ID = /^[A-Za-z][A-Za-z0-9._/-]{0,127}$/
|
|
6
7
|
const INSTANCE_KEY = /^[A-Za-z0-9._:/-]{1,160}$/
|
|
@@ -42,7 +43,7 @@ function visualRoutes (config) {
|
|
|
42
43
|
if (usedLocators.has(bindingKey)) throw new Error(`Duplicate visual control locator: ${id}`)
|
|
43
44
|
usedLocators.add(bindingKey)
|
|
44
45
|
if (!MODES.has(entry.mode) || !entry.type || !entry.label ||
|
|
45
|
-
(['meter', 'text-display'].includes(entry.adapter) && entry.mode !== 'read')) {
|
|
46
|
+
(['meter', 'xy-series', 'text-display'].includes(entry.adapter) && entry.mode !== 'read')) {
|
|
46
47
|
throw new Error(`Invalid visual binding settings: ${id}`)
|
|
47
48
|
}
|
|
48
49
|
if (entry.readMeta?.meter) {
|
|
@@ -52,6 +53,12 @@ function visualRoutes (config) {
|
|
|
52
53
|
!Number.isFinite(entry.readMeta.min) || !Number.isFinite(entry.readMeta.max) ||
|
|
53
54
|
entry.readMeta.min >= entry.readMeta.max) throw Error(`Invalid meter binding: ${id}`)
|
|
54
55
|
}
|
|
56
|
+
if (entry.readMeta?.xySeries) {
|
|
57
|
+
xySeriesRoute(entry.readMeta.xySeries)
|
|
58
|
+
if (entry.adapter !== 'xy-series' || entry.mode !== 'read' || entry.readMeta.access !== 'ro') {
|
|
59
|
+
throw Error(`Invalid xy-series binding: ${id}`)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
55
62
|
routes.set(id, {
|
|
56
63
|
control: { id, type: entry.type, mode: entry.mode, label: entry.label,
|
|
57
64
|
adapter: entry.adapter, locator: entry.locator,
|
|
@@ -79,7 +86,7 @@ function validatedVisualBindings ({ format, entries, devices, targetsByDevice })
|
|
|
79
86
|
const bindingKey = targetKey(candidate, raw)
|
|
80
87
|
if (usedLocators.has(bindingKey)) throw new Error(`One element cannot have two bindings: ${raw.id}`)
|
|
81
88
|
usedLocators.add(bindingKey)
|
|
82
|
-
if (!MODES.has(raw.mode) || (['meter', 'text-display'].includes(raw.adapter) && raw.mode !== 'read')) {
|
|
89
|
+
if (!MODES.has(raw.mode) || (['meter', 'xy-series', 'text-display'].includes(raw.adapter) && raw.mode !== 'read')) {
|
|
83
90
|
throw new Error(`Invalid binding mode: ${raw.id}`)
|
|
84
91
|
}
|
|
85
92
|
if (typeof raw.label !== 'string' || !raw.label.trim() || raw.label.length > 80) {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Self-contained client for readonly XY-series telemetry.
|
|
2
|
+
function createPanelXYSeriesClient ({ socket, panelId, room, ids, request,
|
|
3
|
+
createReceiver, createRenderer, apply, invalidate, onUnauthorized }) {
|
|
4
|
+
if (!socket) throw Error('Dashboard socket is unavailable for xy-series telemetry')
|
|
5
|
+
let auth = null, renderer = null, generation = 0, disposed = false
|
|
6
|
+
let retry = null, stale = null, connecting = false, delay = 500
|
|
7
|
+
const stopRendering = () => {
|
|
8
|
+
renderer?.close(); renderer = null
|
|
9
|
+
clearTimeout(stale); stale = null
|
|
10
|
+
invalidate()
|
|
11
|
+
}
|
|
12
|
+
const pause = () => {
|
|
13
|
+
generation++
|
|
14
|
+
connecting = false
|
|
15
|
+
clearTimeout(retry); retry = null
|
|
16
|
+
if (socket.connected) socket.emit('control-panel:xy-series-unsubscribe', panelId)
|
|
17
|
+
stopRendering()
|
|
18
|
+
}
|
|
19
|
+
const connect = async () => {
|
|
20
|
+
if (disposed || !auth || document.hidden || !socket.connected || connecting) return
|
|
21
|
+
clearTimeout(retry); retry = null
|
|
22
|
+
connecting = true
|
|
23
|
+
const expected = generation
|
|
24
|
+
try {
|
|
25
|
+
const { response, result } = await request(auth.endpoint + '/xy-series-ticket', {
|
|
26
|
+
method: 'POST', credentials: 'same-origin',
|
|
27
|
+
headers: { 'Content-Type': 'application/json', [auth.header]: auth.csrf },
|
|
28
|
+
body: JSON.stringify({ room, socketId: socket.id })
|
|
29
|
+
}, 5000, async response => ({ response, result: await response.json() }))
|
|
30
|
+
if (disposed || expected !== generation || document.hidden) return
|
|
31
|
+
if (response.status === 401) { pause(); auth = null; onUnauthorized(); return }
|
|
32
|
+
if (!response.ok) throw Error(result.error || 'XY-series subscription failed')
|
|
33
|
+
delay = 500
|
|
34
|
+
socket.emit('control-panel:xy-series-subscribe', panelId, result.ticket)
|
|
35
|
+
} catch (_) {
|
|
36
|
+
if (!disposed && expected === generation && auth) {
|
|
37
|
+
retry = setTimeout(connect, delay)
|
|
38
|
+
delay = Math.min(delay * 2, 5000)
|
|
39
|
+
}
|
|
40
|
+
} finally { if (expected === generation) connecting = false }
|
|
41
|
+
}
|
|
42
|
+
const ready = layout => {
|
|
43
|
+
if (disposed || !auth || document.hidden || layout?.panelId !== panelId || layout.room !== room ||
|
|
44
|
+
!Array.isArray(layout.ids) || JSON.stringify(layout.ids) !== JSON.stringify(ids)) return
|
|
45
|
+
stopRendering()
|
|
46
|
+
try {
|
|
47
|
+
renderer = createRenderer({ receiver: createReceiver(layout),
|
|
48
|
+
requestFrame: callback => requestAnimationFrame(callback), cancelFrame: cancelAnimationFrame,
|
|
49
|
+
apply: frame => apply(frame, ids) })
|
|
50
|
+
} catch (_) { stopRendering() }
|
|
51
|
+
}
|
|
52
|
+
const frame = message => {
|
|
53
|
+
if (message?.panelId !== panelId || !auth || document.hidden || !renderer?.receive(message)) return
|
|
54
|
+
clearTimeout(stale)
|
|
55
|
+
stale = setTimeout(invalidate, 1500)
|
|
56
|
+
}
|
|
57
|
+
const ended = message => {
|
|
58
|
+
if (message?.panelId !== panelId) return
|
|
59
|
+
stopRendering()
|
|
60
|
+
if (auth && !disposed && !retry) retry = setTimeout(connect, 500)
|
|
61
|
+
}
|
|
62
|
+
const disconnected = () => { generation++; connecting = false; stopRendering() }
|
|
63
|
+
const visibility = () => { if (document.hidden) pause(); else void connect() }
|
|
64
|
+
socket.on('control-panel:xy-series-ready', ready)
|
|
65
|
+
socket.on('control-panel:xy-series-frame', frame)
|
|
66
|
+
socket.on('control-panel:xy-series-ended', ended)
|
|
67
|
+
socket.on('connect', connect)
|
|
68
|
+
socket.on('disconnect', disconnected)
|
|
69
|
+
document.addEventListener('visibilitychange', visibility)
|
|
70
|
+
return {
|
|
71
|
+
setAuth (next) { pause(); auth = next; delay = 500; if (auth) void connect() },
|
|
72
|
+
close () {
|
|
73
|
+
disposed = true; pause(); auth = null
|
|
74
|
+
socket.off('control-panel:xy-series-ready', ready)
|
|
75
|
+
socket.off('control-panel:xy-series-frame', frame)
|
|
76
|
+
socket.off('control-panel:xy-series-ended', ended)
|
|
77
|
+
socket.off('connect', connect)
|
|
78
|
+
socket.off('disconnect', disconnected)
|
|
79
|
+
document.removeEventListener('visibilitychange', visibility)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = { createPanelXYSeriesClient }
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
const crypto = require('node:crypto')
|
|
2
|
+
const { createXYSeriesReceiver } = require('./xy-series-frame')
|
|
3
|
+
const { readJsonBody, replyJson } = require('./panel-http')
|
|
4
|
+
const { xySeriesRoute } = require('./xy-series-route')
|
|
5
|
+
|
|
6
|
+
function createXYSeriesRoom ({ room, now = Date.now, staleMs = 1000 }) {
|
|
7
|
+
const sources = new Map(), panels = new Map(), ticks = new Set()
|
|
8
|
+
let timer = null, generation = 0
|
|
9
|
+
|
|
10
|
+
function attach (panelId, bindings) {
|
|
11
|
+
if (panels.has(panelId)) throw Error('XY-series panel already registered')
|
|
12
|
+
const entries = Object.entries(bindings).filter(([, entry]) => entry.readMeta?.xySeries)
|
|
13
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
14
|
+
if (!entries.length || entries.length > 256) throw Error('Invalid xy-series panel size')
|
|
15
|
+
const projections = entries.map(([id, binding]) => {
|
|
16
|
+
const series = xySeriesRoute(binding.readMeta.xySeries)
|
|
17
|
+
const key = JSON.stringify([binding.device, series.streamId])
|
|
18
|
+
let source = sources.get(key)
|
|
19
|
+
if (source && JSON.stringify(source.route) !== JSON.stringify(series)) {
|
|
20
|
+
throw Error(`Conflicting xy-series stream: ${series.streamId}`)
|
|
21
|
+
}
|
|
22
|
+
if (!source) {
|
|
23
|
+
source = { device: binding.device, route: series, receiver: null,
|
|
24
|
+
receivedAt: null, revision: 0, refs: new Set() }
|
|
25
|
+
sources.set(key, source)
|
|
26
|
+
}
|
|
27
|
+
source.refs.add(panelId)
|
|
28
|
+
return { id, binding, source, key }
|
|
29
|
+
})
|
|
30
|
+
const layout = { kind: 'room-xy-series-layout', panelId, room,
|
|
31
|
+
epoch: crypto.randomBytes(16).toString('hex'), ids: entries.map(([id]) => id) }
|
|
32
|
+
panels.set(panelId, { projections, layout, sequence: 0 })
|
|
33
|
+
return layout
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function accept (message) {
|
|
37
|
+
if (!['device-xy-series-frame', 'device-xy-series-manifest'].includes(message?.kind)) return false
|
|
38
|
+
const source = sources.get(JSON.stringify([message.device, message.streamId]))
|
|
39
|
+
if (!source || message.path !== (message.kind === 'device-xy-series-frame'
|
|
40
|
+
? source.route.eventPath : source.route.manifestPath)) return false
|
|
41
|
+
let data = message.payload
|
|
42
|
+
if (data && typeof data === 'object' && Object.hasOwn(data, 'data')) data = data.data
|
|
43
|
+
if (typeof data === 'string') {
|
|
44
|
+
if (Buffer.byteLength(data) > 524288) return false
|
|
45
|
+
try { data = JSON.parse(data) } catch (_) { return false }
|
|
46
|
+
}
|
|
47
|
+
if (message.kind === 'device-xy-series-manifest') {
|
|
48
|
+
let receiver
|
|
49
|
+
try { receiver = createXYSeriesReceiver(data) } catch (_) { return false }
|
|
50
|
+
if (receiver.manifest.streamId !== source.route.streamId) return false
|
|
51
|
+
const previous = source.receiver?.manifest
|
|
52
|
+
if (previous && JSON.stringify(previous) === JSON.stringify(receiver.manifest)) return true
|
|
53
|
+
if (previous?.epoch === receiver.manifest.epoch &&
|
|
54
|
+
receiver.manifest.layoutVersion <= previous.layoutVersion) return false
|
|
55
|
+
source.receiver?.close(); source.receiver = receiver; source.receivedAt = null
|
|
56
|
+
} else {
|
|
57
|
+
if (!source.receiver?.accept(data)) return false
|
|
58
|
+
source.receivedAt = now()
|
|
59
|
+
}
|
|
60
|
+
source.revision = ++generation
|
|
61
|
+
return true
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function snapshot (panelId) {
|
|
65
|
+
const panel = panels.get(panelId)
|
|
66
|
+
if (!panel) return null
|
|
67
|
+
const time = now(), values = [], valid = [], sourcesByStream = new Map()
|
|
68
|
+
for (const { source, key } of panel.projections) {
|
|
69
|
+
const frame = source.receiver?.snapshot()
|
|
70
|
+
const age = source.receivedAt === null ? null : Math.max(0, time - source.receivedAt)
|
|
71
|
+
const ok = Boolean(frame && age !== null && age <= staleMs)
|
|
72
|
+
values.push(ok ? frame.points.map(point => point.slice()) : [])
|
|
73
|
+
valid.push(ok)
|
|
74
|
+
sourcesByStream.set(key, { device: source.device, streamId: source.route.streamId,
|
|
75
|
+
epoch: source.receiver?.manifest.epoch || '', seq: frame?.seq || 0,
|
|
76
|
+
sampleTime: frame?.sampleTime ?? null, ageMs: age })
|
|
77
|
+
}
|
|
78
|
+
return { kind: 'room-xy-series-frame', panelId, room, epoch: panel.layout.epoch,
|
|
79
|
+
seq: ++panel.sequence, values, valid, sources: [...sourcesByStream.values()] }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function revision (panelId) {
|
|
83
|
+
const panel = panels.get(panelId)
|
|
84
|
+
if (!panel) return ''
|
|
85
|
+
const time = now()
|
|
86
|
+
return panel.projections.map(({ source }) => `${source.revision}:${source.receivedAt !== null &&
|
|
87
|
+
time - source.receivedAt <= staleMs}`).join(',')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function detach (panelId) {
|
|
91
|
+
const panel = panels.get(panelId)
|
|
92
|
+
if (!panel) return
|
|
93
|
+
panels.delete(panelId)
|
|
94
|
+
for (const { source, key } of panel.projections) {
|
|
95
|
+
source.refs.delete(panelId)
|
|
96
|
+
if (!source.refs.size) { source.receiver?.close(); sources.delete(key) }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return { attach, accept, snapshot, revision, detach,
|
|
101
|
+
onTick (callback) {
|
|
102
|
+
ticks.add(callback)
|
|
103
|
+
if (!timer) { timer = setInterval(() => { for (const tick of ticks) tick() }, 1000 / 30); timer.unref?.() }
|
|
104
|
+
return () => {
|
|
105
|
+
ticks.delete(callback)
|
|
106
|
+
if (!ticks.size) { clearInterval(timer); timer = null }
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
stats: () => ({ panels: panels.size, sources: sources.size }) }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function createPanelXYSeries ({ panelId, room, bindings, hub, onSubscribe = () => {} }) {
|
|
113
|
+
const layout = hub.attach(panelId, bindings)
|
|
114
|
+
const tickets = new Map(), subscribers = new Map()
|
|
115
|
+
let closed = false, lastRevision = ''
|
|
116
|
+
const remove = socketId => {
|
|
117
|
+
const entry = subscribers.get(socketId)
|
|
118
|
+
if (entry) entry.socket.off('disconnect', entry.disconnect)
|
|
119
|
+
subscribers.delete(socketId)
|
|
120
|
+
}
|
|
121
|
+
const live = entry => entry.active() && entry.socket.connected
|
|
122
|
+
const emitFrame = (socket, frame) => socket.volatile.emit('control-panel:xy-series-frame', frame)
|
|
123
|
+
const prune = () => {
|
|
124
|
+
for (const [key, entry] of tickets) if (entry.expiry <= Date.now() || !entry.active()) tickets.delete(key)
|
|
125
|
+
for (const [key, entry] of subscribers) if (!live(entry)) {
|
|
126
|
+
entry.socket.emit('control-panel:xy-series-ended', { panelId }); remove(key)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const stopTick = hub.onTick(() => {
|
|
130
|
+
prune()
|
|
131
|
+
if (closed || !subscribers.size) return
|
|
132
|
+
const revision = hub.revision(panelId)
|
|
133
|
+
if (revision === lastRevision) return
|
|
134
|
+
lastRevision = revision
|
|
135
|
+
const frame = hub.snapshot(panelId)
|
|
136
|
+
for (const { socket } of subscribers.values()) emitFrame(socket, frame)
|
|
137
|
+
})
|
|
138
|
+
return {
|
|
139
|
+
async ticket (req, res, identity, active) {
|
|
140
|
+
if (!new URL(req.url, 'http://localhost').pathname.endsWith('/xy-series-ticket') || req.method !== 'POST') return false
|
|
141
|
+
if (closed) { replyJson(res, 503, { error: 'XY-series channel unavailable' }); return true }
|
|
142
|
+
prune()
|
|
143
|
+
const body = await readJsonBody(req, 4096)
|
|
144
|
+
if (body.room !== room || typeof body.socketId !== 'string' || !body.socketId || body.socketId.length > 128) {
|
|
145
|
+
replyJson(res, 403, { error: 'XY-series subscription rejected' }); return true
|
|
146
|
+
}
|
|
147
|
+
if (!req.headers.origin) { replyJson(res, 403, { error: 'XY-series subscription needs Origin' }); return true }
|
|
148
|
+
for (const [key, entry] of tickets) if (entry.token === identity.token && entry.socketId === body.socketId) tickets.delete(key)
|
|
149
|
+
if (tickets.size >= 100) { replyJson(res, 429, { error: 'XY-series ticket limit reached' }); return true }
|
|
150
|
+
const ticket = crypto.randomBytes(32).toString('hex')
|
|
151
|
+
tickets.set(ticket, { token: identity.token, socketId: body.socketId,
|
|
152
|
+
expiry: Math.min(identity.expiry, Date.now() + 10000), active })
|
|
153
|
+
replyJson(res, 200, { ticket }); return true
|
|
154
|
+
},
|
|
155
|
+
hooks: {
|
|
156
|
+
'control-panel:xy-series-subscribe': (socket, id, ticket) => {
|
|
157
|
+
if (id !== panelId || closed) return
|
|
158
|
+
prune()
|
|
159
|
+
const entry = tickets.get(ticket); tickets.delete(ticket)
|
|
160
|
+
if (!entry || !entry.active() || entry.expiry <= Date.now() || entry.socketId !== socket.id ||
|
|
161
|
+
!socket.connected || (!subscribers.has(socket.id) && subscribers.size >= 100)) {
|
|
162
|
+
socket.emit('control-panel:xy-series-ended', { panelId }); return
|
|
163
|
+
}
|
|
164
|
+
remove(socket.id)
|
|
165
|
+
const disconnect = () => remove(socket.id)
|
|
166
|
+
subscribers.set(socket.id, { socket, active: entry.active, disconnect })
|
|
167
|
+
socket.on('disconnect', disconnect)
|
|
168
|
+
socket.emit('control-panel:xy-series-ready', layout)
|
|
169
|
+
emitFrame(socket, hub.snapshot(panelId)); onSubscribe()
|
|
170
|
+
},
|
|
171
|
+
'control-panel:xy-series-unsubscribe': (socket, id) => { if (id === panelId) remove(socket.id) }
|
|
172
|
+
},
|
|
173
|
+
accept: message => !closed && hub.accept(message),
|
|
174
|
+
stats: () => ({ tickets: tickets.size, subscribers: subscribers.size, ...hub.stats() }),
|
|
175
|
+
close () {
|
|
176
|
+
closed = true; stopTick(); tickets.clear()
|
|
177
|
+
for (const entry of subscribers.values()) {
|
|
178
|
+
entry.socket.emit('control-panel:xy-series-ended', { panelId })
|
|
179
|
+
entry.socket.off('disconnect', entry.disconnect)
|
|
180
|
+
}
|
|
181
|
+
subscribers.clear(); hub.detach(panelId)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
module.exports = { createXYSeriesRoom, createPanelXYSeries }
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
function finite (value) {
|
|
2
|
+
return typeof value === 'number' && Number.isFinite(value)
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function createXYSeriesReceiver (manifest) {
|
|
6
|
+
if (manifest?.kind !== 'xy-series-manifest' || typeof manifest.streamId !== 'string' ||
|
|
7
|
+
!manifest.streamId.trim() || manifest.streamId.length > 255 ||
|
|
8
|
+
typeof manifest.epoch !== 'string' || !manifest.epoch || manifest.epoch.length > 128 ||
|
|
9
|
+
!Number.isInteger(manifest.layoutVersion) || manifest.layoutVersion < 1 ||
|
|
10
|
+
manifest.layoutVersion > 2147483647 || manifest.clock !== 'monotonic-ms' ||
|
|
11
|
+
!finite(manifest.publishHz) || manifest.publishHz < 1 || manifest.publishHz > 60 ||
|
|
12
|
+
!Number.isSafeInteger(manifest.maxPoints) || manifest.maxPoints < 1 || manifest.maxPoints > 8192 ||
|
|
13
|
+
typeof manifest.xUnit !== 'string' || manifest.xUnit.length > 32 ||
|
|
14
|
+
typeof manifest.yUnit !== 'string' || manifest.yUnit.length > 32) {
|
|
15
|
+
throw Error('Invalid xy-series manifest')
|
|
16
|
+
}
|
|
17
|
+
const schema = Object.freeze({ ...manifest })
|
|
18
|
+
let latest = null, pending = false, closed = false
|
|
19
|
+
return {
|
|
20
|
+
manifest: schema,
|
|
21
|
+
accept (frame) {
|
|
22
|
+
if (closed || frame?.kind !== 'xy-series-frame' || frame.streamId !== schema.streamId ||
|
|
23
|
+
frame.epoch !== schema.epoch || frame.layoutVersion !== schema.layoutVersion ||
|
|
24
|
+
!Number.isSafeInteger(frame.seq) || frame.seq < 1 || (latest && frame.seq <= latest.seq) ||
|
|
25
|
+
!finite(frame.sampleTime) || frame.sampleTime < 0 || !Array.isArray(frame.points) ||
|
|
26
|
+
frame.points.length > schema.maxPoints || frame.points.some(point =>
|
|
27
|
+
!Array.isArray(point) || point.length !== 2 || !finite(point[0]) || !finite(point[1]))) return false
|
|
28
|
+
latest = Object.freeze({ kind: 'xy-series-frame', streamId: schema.streamId,
|
|
29
|
+
epoch: schema.epoch, seq: frame.seq, layoutVersion: schema.layoutVersion,
|
|
30
|
+
sampleTime: frame.sampleTime,
|
|
31
|
+
points: Object.freeze(frame.points.map(point => Object.freeze([point[0], point[1]]))) })
|
|
32
|
+
pending = true
|
|
33
|
+
return true
|
|
34
|
+
},
|
|
35
|
+
snapshot () { return latest },
|
|
36
|
+
take () { if (!pending) return null; pending = false; return latest },
|
|
37
|
+
close () { closed = true; pending = false; latest = null }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function createXYSeriesRenderer ({ receiver, requestFrame, cancelFrame, apply }) {
|
|
42
|
+
let handle = null, closed = false
|
|
43
|
+
return {
|
|
44
|
+
receive (frame) {
|
|
45
|
+
if (closed || !receiver.accept(frame)) return false
|
|
46
|
+
if (handle === null) {
|
|
47
|
+
handle = requestFrame(() => {
|
|
48
|
+
handle = null
|
|
49
|
+
const current = receiver.take()
|
|
50
|
+
if (!closed && current) apply(current)
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
return true
|
|
54
|
+
},
|
|
55
|
+
close () {
|
|
56
|
+
closed = true
|
|
57
|
+
if (handle !== null) cancelFrame(handle)
|
|
58
|
+
handle = null
|
|
59
|
+
receiver.close()
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function createRoomXYSeriesReceiver (layout) {
|
|
65
|
+
const finite = value => typeof value === 'number' && Number.isFinite(value)
|
|
66
|
+
if (layout?.kind !== 'room-xy-series-layout' || typeof layout.panelId !== 'string' ||
|
|
67
|
+
typeof layout.room !== 'string' || typeof layout.epoch !== 'string' || !layout.epoch ||
|
|
68
|
+
!Array.isArray(layout.ids) || !layout.ids.length || layout.ids.length > 256 ||
|
|
69
|
+
layout.ids.some(id => typeof id !== 'string' || !id) || new Set(layout.ids).size !== layout.ids.length) {
|
|
70
|
+
throw Error('Invalid Room XY-series layout')
|
|
71
|
+
}
|
|
72
|
+
let latest = null, pending = false, closed = false
|
|
73
|
+
const count = layout.ids.length
|
|
74
|
+
return {
|
|
75
|
+
accept (frame) {
|
|
76
|
+
if (closed || frame?.kind !== 'room-xy-series-frame' || frame.panelId !== layout.panelId ||
|
|
77
|
+
frame.room !== layout.room || frame.epoch !== layout.epoch ||
|
|
78
|
+
!Number.isSafeInteger(frame.seq) || frame.seq < 1 || (latest && frame.seq <= latest.seq) ||
|
|
79
|
+
!Array.isArray(frame.values) || frame.values.length !== count ||
|
|
80
|
+
!Array.isArray(frame.valid) || frame.valid.length !== count) return false
|
|
81
|
+
for (let index = 0; index < count; index++) {
|
|
82
|
+
const points = frame.values[index], valid = frame.valid[index]
|
|
83
|
+
if (typeof valid !== 'boolean' || !Array.isArray(points) || points.length > 8192 ||
|
|
84
|
+
(!valid && points.length !== 0) ||
|
|
85
|
+
(valid && points.some(point => !Array.isArray(point) || point.length !== 2 ||
|
|
86
|
+
!finite(point[0]) || !finite(point[1])))) return false
|
|
87
|
+
}
|
|
88
|
+
latest = { values: frame.values.map(points => points.map(point => point.slice())),
|
|
89
|
+
valid: frame.valid.slice(), seq: frame.seq }
|
|
90
|
+
pending = true
|
|
91
|
+
return true
|
|
92
|
+
},
|
|
93
|
+
take () { if (!pending) return null; pending = false; return latest },
|
|
94
|
+
close () { closed = true; latest = null; pending = false }
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = { createXYSeriesReceiver, createXYSeriesRenderer, createRoomXYSeriesReceiver }
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
function xySeriesRoute (value) {
|
|
2
|
+
if (!value || typeof value !== 'object' ||
|
|
3
|
+
typeof value.streamId !== 'string' || !value.streamId.trim() || value.streamId.length > 255 ||
|
|
4
|
+
![value.eventPath, value.manifestPath].every(path => typeof path === 'string' &&
|
|
5
|
+
/^\/\/[^/]/.test(path) && path.length <= 1026) || value.eventPath === value.manifestPath) {
|
|
6
|
+
throw Error('Invalid xy-series delivery metadata')
|
|
7
|
+
}
|
|
8
|
+
return { streamId: value.streamId, eventPath: value.eventPath, manifestPath: value.manifestPath }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
module.exports = { xySeriesRoute }
|