node-red-contrib-room-tools 1.9.3 → 1.9.4
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 +4 -2
- package/README.md +10 -4
- package/control-bound-protocol.js +2 -1
- package/control-panel.html +10 -36
- package/control-panel.js +8 -6
- package/control-type-model.js +8 -6
- package/gateway.cjs +23 -11
- package/native-binding-plan.js +4 -2
- package/npm-shrinkwrap.json +2 -2
- package/package.json +5 -1
- package/panel-binding-editor.js +108 -37
- package/panel-meter.js +4 -4
- package/panel-telemetry-send.js +13 -0
- package/panel-visual-bindings.js +10 -9
- package/panel-xy-series.js +16 -7
- package/xy-series-frame.js +1 -1
|
@@ -109,6 +109,8 @@ XY-series 使用数组字段初始化,例如 `spectrum: []`。推荐使用标
|
|
|
109
109
|
|
|
110
110
|
数组以整体替换方式更新,绘图通常使用浅层 `watch` 即可,无需 `deep: true` 遍历每个点。只观察数组来绘制,不改写接收数组;多图表需要时可在页面自己的 `requestAnimationFrame` 中合并绘制,并在卸载时清理绘图资源。这是纯显示逻辑,不是第二套设备通信。
|
|
111
111
|
|
|
112
|
+
无关 DOM 变化不会反复向同一个目标模型写入上一帧数组;容器重新出现时由节点恢复最新快照,不要手动补绑。大量频谱点建议在 Canvas 中批量绘制,避免为每个采样点创建 DOM 元素。多个显示终端会增加实际网络和绘制负载,不能把单帧点数上限理解为所有图表都能同时维持 30 Hz。
|
|
113
|
+
|
|
112
114
|
### 3.1 普通控件写法
|
|
113
115
|
|
|
114
116
|
```html
|
|
@@ -390,8 +392,8 @@ export default {
|
|
|
390
392
|
|
|
391
393
|
1. 将 Template 保存到 `ui-control-panel`,配置 Dashboard 页面、Room ID、普通登录账号和独立编辑账号,然后保存并 Deploy。
|
|
392
394
|
2. 重新打开节点,点击 `Open binding editor`。编辑页登录使用编辑账号;进入后 Select 默认激活。`Open page` 打开普通控制页面,普通页面必须使用普通账号登录。
|
|
393
|
-
3. 在 Select 模式选择控件,选择设备、模式、Write point、Feedback point 和编号方式。Meter、XY-series
|
|
394
|
-
4. 点 Apply。节点验证当前模板、设备目录和映射,备份当前 Flow,并通过运行时 Flow API
|
|
395
|
+
3. 在 Select 模式选择控件,选择设备、模式、Write point、Feedback point 和编号方式。Meter、XY-series 和文本展示的模式选项只有 `read`;无需手动从 `both` 改为 `read`。已绑定控件在编辑模式显示亮紫色边框;这只是编辑提示。
|
|
396
|
+
4. 点 Apply。节点验证当前模板、设备目录和映射,备份当前 Flow,并通过运行时 Flow API 以 `nodes` 部署方式更新当前面板的映射和受管理桥接。更新会使受影响节点重启;浏览器同步脚本不点击 Deploy,也不会向画布导入、清除或追加节点。
|
|
395
397
|
5. Apply 成功后会重新加载当前编辑页,使用仍有效的编辑会话继续进入;会话过期或配置改变时可能要求重新登录。Bindings 列表可以 Edit 或 Remove,从列表进入编辑时取消/保存后回到列表。点 Exit 退出编辑并回到普通页面登录页。
|
|
396
398
|
|
|
397
399
|
Automator 的同步脚本监听 `notification/runtime-deploy` 的新版本通知,并在当前页面最多触发一次浏览器刷新;它不读取、导入、合并、清除或再次提交 Flow,也不额外点击 Deploy。监听不限于本面板 Apply,其他运行时版本变化也可能触发刷新。当前实现不判断画布是否有未 Deploy 改动,也不会合并画布草稿;在 Apply 前应先处理好 Automator 中未保存的画布改动。没有打开 Automator 编辑页或不支持该通知接口时,不能承诺自动刷新。
|
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ Both logins work over HTTP on a trusted local network. HTTPS is recommended for
|
|
|
10
10
|
|
|
11
11
|
Bindings are stored in `visualBindings` on the node. The runtime reads that mapping to authenticate commands, route feedback, and generate a native bridge. The page author does not put device paths, `data-cp-*` attributes, or device-send code in the Template. Author-provided `data-cp-*` attributes are rejected.
|
|
12
12
|
|
|
13
|
-
Supported controls include native range, checkbox, semantic pressed button, select, text input, meter, progress, and simple text display. Give
|
|
13
|
+
Supported controls include native range, checkbox, semantic pressed button, select, text input, meter, progress, and simple text display. Give each template control a stable standard ID or name; repeated instances also need stable unique keys. Simple top-level Vue `v-model` works, including object-list models such as `item.volume`, `item.audio.volume`, or `items[index].volume`, primitive array models such as `values[index]`, and an inner `<template v-for>` for multiple controls per row. During Vue re-rendering, a temporary duplicate list key is ignored until the key becomes unique again, so bindings do not attach twice. Detached or disposed controls release their event listeners and cannot send commands. Controls with existing send handlers or ambiguous dynamic behavior are rejected until a suitable adapter exists. See `CONTROL_PANEL_AI_GUIDE.md` for examples and limits.
|
|
14
14
|
|
|
15
15
|
Native semantic buttons support a binding-editor **Button behavior** setting: **Click to toggle** (the default for existing bindings) or **Hold to activate**. Momentary buttons require `both` mode, send boolean true on press and false on release, and always display real device feedback rather than a predicted local state. Pointer cancellation, keyboard release, focus/visibility loss, DOM removal, and disconnect release the hold. A 3-second server lease attempts release if the browser disappears; lease renewal does not repeat device writes. Concurrent holders of the same Write point within one panel are combined until the last release. Other panels and external controllers are not coordinated. Device or bridge outages can prevent physical release, so feedback remains the authority. Drivers keep the existing boolean Toggle contract; page authors do not implement hold events or lease traffic.
|
|
16
16
|
|
|
@@ -18,7 +18,7 @@ Writable native semantic buttons in either behavior automatically show a separat
|
|
|
18
18
|
|
|
19
19
|
Realtime meter delivery does not change the page-authoring contract. Authors use ordinary `meter`, `progress`, `v-progress-linear`, or `v-progress-circular` controls with stable IDs and simple top-level fields. They do not declare devices, point paths, stream IDs, sockets, subscriptions, or frame formats. A `volume` binding is a writable gain setting with ordinary feedback; a `meter` binding is read-only realtime signal telemetry and requires a target that advertises Meter metadata. A normal numeric `level` target is not automatically a Meter. When the selected feedback point advertises realtime meter delivery, ControlPanel automatically shares the device stream, projects complete frames to the bound controls, applies webpage-side range conversion, and handles stale/reconnected data.
|
|
20
20
|
|
|
21
|
-
Readonly XY-series telemetry follows the same page boundary. Use a standard canvas with one simple Vue array binding, for example `<canvas id="spectrum" :data-points="spectrum"></canvas>`, or a map container with `<div id="room-map" :data-coordinates="locations"></div>`.
|
|
21
|
+
Readonly XY-series telemetry follows the same page boundary. Use a standard canvas with one simple Vue array binding, for example `<canvas id="spectrum" :data-points="spectrum"></canvas>`, or a map container with `<div id="room-map" :data-coordinates="locations"></div>`. Use `[longitude, latitude]` for geographic coordinates or `[x, y]` for local planar coordinates; the page owns the projection. The page receives complete `[[x, y], ...]` snapshots; ControlPanel owns subscription, latest-frame replacement, stale handling, and reconnection. The page does not declare a device, stream, socket, or frame protocol. XY-series is independent from Meter and Volume: it never sends commands and does not represent a single scalar value.
|
|
22
22
|
|
|
23
23
|
Different XY-series streams arrive independently. Room delivery aggregates their latest snapshots, not synchronized samples from a shared acquisition frame. The public framework defaults to 2048 points per stream and allows a configured maximum of 8192; the receiver enforces the manifest's `maxPoints`. A panel supports at most 256 XY-series bindings. These limits do not guarantee throughput at every frame rate or concurrency level; validate performance in the target environment.
|
|
24
24
|
|
|
@@ -26,7 +26,7 @@ Vuetify adapters cover `v-slider`, `v-switch`, `v-checkbox`, `v-select`, `v-radi
|
|
|
26
26
|
|
|
27
27
|
Use paired Vuetify tags in Dashboard templates, such as `<v-slider></v-slider>`; self-closing component tags can absorb the following controls during Dashboard rendering and are rejected before deployment.
|
|
28
28
|
|
|
29
|
-
After clicking Apply in the page binding editor, the package validates the mapping, backs up the active Flow, and
|
|
29
|
+
After clicking Apply in the page binding editor, the package validates the mapping, backs up the active Flow, and updates the panel and generated native bridge through the runtime Flow API with deployment type `nodes`. Affected runtime nodes restart. Separately, when Automator reports the external Flow change, its editor listener reloads the browser page once: that listener does not import, merge, clear or deploy Flow data. It does not preserve undeployed canvas drafts, so handle those before Apply. The bridge revision must match the saved mapping before a command can reach a device. A Node-RED simulation verifies software behavior, while live device operation requires a separate check.
|
|
30
30
|
|
|
31
31
|
The current runtime has one panel-scoped control path. The retired topic-based region gateway, fixed demo scenes, hand-written control tags, and `sendControl` authoring API are not supported. The page editor and server use the same target compatibility rules.
|
|
32
32
|
|
|
@@ -40,4 +40,10 @@ Node.js 18.16.0 or newer is required. Dashboard 2 and the AMX Command/Status/Eve
|
|
|
40
40
|
|
|
41
41
|
The release includes `npm-shrinkwrap.json` to install the tested dependency versions. Deploy/Push must reference the intended registry version or a controller-accessible tarball; a `file:` dependency pointing at a Mac path is not portable to MUSE. Restart the controller runtime after replacing module code.
|
|
42
42
|
|
|
43
|
-
For development, run `
|
|
43
|
+
For development, run `npm ci --ignore-scripts` and `npm test` in this source directory with Node.js >=18.16. Tests need the local pinned dependencies; do not depend on another machine's installed Automator modules. The parent project's `local-validation/test-source.sh` also covers the Python framework, drivers, and offline Flow helpers. Its optional isolated runtime test uses a temporary user directory and random loopback port; it never deploys to the live Automator or MUSE.
|
|
44
|
+
|
|
45
|
+
Ordinary feedback uses authenticated SSE, while Meter and XY-series use Dashboard Socket.IO. SSE feedback is serialized once per broadcast; a temporary backpressured write may drain, while a persistently blocked client is disconnected and reconnects to the latest snapshot rather than buffering history. Each panel allows at most 100 simultaneous SSE clients.
|
|
46
|
+
|
|
47
|
+
Realtime fan-out uses one volatile namespace broadcast to explicit authenticated socket IDs when the Dashboard socket exposes that API; other hosts keep the per-socket fallback. This avoids repeated server encoding without changing frame formats, authoring syntax, authorization or each viewer's network traffic. Shared streams are checked once when deciding whether a panel needs a new frame. Browser control lookup uses a DOM index invalidated on structure or binding-key changes; unrelated DOM mutations do not replay an XY array into the same model. Target recreation still restores the latest snapshot.
|
|
48
|
+
|
|
49
|
+
The parent project's `local-validation/stress-panel.cjs` adds opt-in multi-room, maximum-point, multi-viewer and SSE pressure scenarios. These use real HTTP/Socket.IO with simulated DOM/animation frames, not live Automator, MUSE or actual browser paint; target-device capacity must be verified separately.
|
|
@@ -66,7 +66,8 @@ 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 || route.binding.readMeta?.meter
|
|
69
|
+
route.binding.readPath !== message.path || route.binding.readMeta?.meter ||
|
|
70
|
+
route.binding.readMeta?.xySeries) continue
|
|
70
71
|
const type = route.control.type
|
|
71
72
|
const metadata = route.binding.readMeta
|
|
72
73
|
let value = message.payload
|
package/control-panel.html
CHANGED
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
function c_ (x) {
|
|
5
5
|
return RED._('@flowfuse/node-red-dashboard/ui-template:ui-template.' + x)
|
|
6
6
|
}
|
|
7
|
-
// TODO: move this to util.js for reuse & unit testing
|
|
8
7
|
function hasProperty (obj, prop) {
|
|
9
8
|
return Object.prototype.hasOwnProperty.call(obj, prop)
|
|
10
9
|
}
|
|
@@ -346,20 +345,12 @@
|
|
|
346
345
|
})()
|
|
347
346
|
</script>
|
|
348
347
|
|
|
349
|
-
<style>
|
|
350
|
-
.control-binding-hint { margin: 4px 0; color: var(--red-ui-secondary-text-color, #666); overflow-wrap: anywhere; }
|
|
351
|
-
.control-binding-bridge-option { display: flex; align-items: flex-start; gap: 8px; }
|
|
352
|
-
.control-binding-bridge-option > label { flex: 0 0 105px; }
|
|
353
|
-
.control-binding-bridge-option > div { flex: 1; min-width: 0; }
|
|
354
|
-
.control-binding-bridge-option small { display: block; margin-top: 3px; }
|
|
355
|
-
</style>
|
|
356
|
-
|
|
357
348
|
<script type="text/html" data-help-name="ui-control-panel">
|
|
358
349
|
<p>This node renders a custom Vue Template and automatically injects control traffic and reported-device-state APIs.</p>
|
|
359
350
|
<p>普通 HTML/Vue 控件由页面绑定编辑器自动识别,并通过面板的 authenticated session 发送控制。开发者不需要添加标签、调用自定义节点 API,或在 Template 中实现 throttling、latest-wins、队列和设备协议。</p>
|
|
360
351
|
<p>页面只需按普通 H5/Vue 方式编写控件;编辑模式下在控件上打开绑定菜单,选择设备参数即可。登录、发送、反馈和状态同步由节点负责,Template 不应主动查询设备或把反馈回发成命令。</p>
|
|
361
352
|
<p>See <code>CONTROL_PANEL_AI_GUIDE.md</code> in the node package for the complete AI authoring contract and examples.</p>
|
|
362
|
-
<p>Save and Deploy the panel, then use Open binding editor. Apply
|
|
353
|
+
<p>Save and Deploy the panel, then use Open binding editor. Apply validates bindings, backs up the Flow, and updates the panel and native bridge through the runtime Flow API. When Node-RED reports a newer revision, the Automator editor page reloads once; that editor listener never imports nodes or clicks Deploy. Ordinary Deploy alone does not apply a binding draft. Check targets and numbering before using real hardware.</p>
|
|
363
354
|
</script>
|
|
364
355
|
|
|
365
356
|
<script type="text/html" data-template-name="ui-control-panel">
|
|
@@ -377,48 +368,40 @@
|
|
|
377
368
|
<option value="widget:ui" data-i18n="ui-template.label.widget-ui"></option>
|
|
378
369
|
<option value="site:style" data-i18n="ui-template.label.site-style"></option>
|
|
379
370
|
<option value="page:style" data-i18n="ui-template.label.page-style"></option>
|
|
380
|
-
<!-- FUTURE? <option value="site:script" data-i18n="ui-template.label.site-script"></option>
|
|
381
|
-
<option value="page:script" data-i18n="ui-template.label.page-script"></option> -->
|
|
382
371
|
</select>
|
|
383
372
|
</div>
|
|
384
373
|
<div id="template-row-ui" class="form-row">
|
|
385
|
-
<label for="node-input-ui"><i class="fa fa-bookmark"></i> <span data-i18n="ui-template.label.ui"></label>
|
|
374
|
+
<label for="node-input-ui"><i class="fa fa-bookmark"></i> <span data-i18n="ui-template.label.ui"></span></label>
|
|
386
375
|
<input type="text" id="node-input-ui">
|
|
387
376
|
</div>
|
|
388
377
|
<div id="template-row-page" class="form-row">
|
|
389
|
-
<label for="node-input-page"><i class="fa fa-bookmark"></i> <span data-i18n="ui-template.label.page"></label>
|
|
378
|
+
<label for="node-input-page"><i class="fa fa-bookmark"></i> <span data-i18n="ui-template.label.page"></span></label>
|
|
390
379
|
<input type="text" id="node-input-page">
|
|
391
380
|
</div>
|
|
392
381
|
<div id="template-row-group" class="form-row">
|
|
393
|
-
<label for="node-input-group"><i class="fa fa-table"></i> <span data-i18n="ui-template.label.group"></label>
|
|
382
|
+
<label for="node-input-group"><i class="fa fa-table"></i> <span data-i18n="ui-template.label.group"></span></label>
|
|
394
383
|
<input type="text" id="node-input-group">
|
|
395
384
|
</div>
|
|
396
385
|
<div class="form-row nr-db-ui-element-hide-in-subflow">
|
|
397
|
-
<label><i class="fa fa-object-group"></i> <span data-i18n="ui-base.label.size">Size</label>
|
|
386
|
+
<label><i class="fa fa-object-group"></i> <span data-i18n="ui-base.label.size">Size</span></label>
|
|
398
387
|
<button class="editor-button" id="node-input-size"></button>
|
|
399
388
|
</div>
|
|
400
389
|
<div class="form-row nr-db-ui-element-show-in-subflow">
|
|
401
|
-
<label><i class="fa fa-arrows-h"></i> <span data-i18n="ui-base.label.width">Width</label>
|
|
390
|
+
<label><i class="fa fa-arrows-h"></i> <span data-i18n="ui-base.label.width">Width</span></label>
|
|
402
391
|
<input type="hidden" id="node-input-width">
|
|
403
392
|
</div>
|
|
404
393
|
<div class="form-row nr-db-ui-element-show-in-subflow">
|
|
405
|
-
<label><i class="fa fa-arrows-v"></i> <span data-i18n="ui-base.label.height">Height</label>
|
|
394
|
+
<label><i class="fa fa-arrows-v"></i> <span data-i18n="ui-base.label.height">Height</span></label>
|
|
406
395
|
<input type="hidden" id="node-input-height">
|
|
407
396
|
</div>
|
|
408
397
|
<div class="form-row nr-db-ui-element-show-in-subflow">
|
|
409
|
-
<label><i class="fa fa-arrows"></i> <span data-i18n="ui-base.label.order">Order</label>
|
|
398
|
+
<label><i class="fa fa-arrows"></i> <span data-i18n="ui-base.label.order">Order</span></label>
|
|
410
399
|
<input type="text" id="node-input-order">
|
|
411
400
|
</div>
|
|
412
|
-
<!--<div class="form-row" id="template-row-size">
|
|
413
|
-
<label><i class="fa fa-object-group"></i> <span data-i18n="ui-template.label.size"></span></label>
|
|
414
|
-
<input type="hidden" id="node-input-width">
|
|
415
|
-
<input type="hidden" id="node-input-height">
|
|
416
|
-
<button class="editor-button" id="node-input-size"></button>
|
|
417
|
-
</div>-->
|
|
418
401
|
<div class="form-row">
|
|
419
|
-
<label for="node-input-className"><i class="fa fa-code"></i> <span data-i18n="ui-template.label.className"></label>
|
|
402
|
+
<label for="node-input-className"><i class="fa fa-code"></i> <span data-i18n="ui-template.label.className"></span></label>
|
|
420
403
|
<div style="display: inline;">
|
|
421
|
-
<input style="width: 70%;" type="text" id="node-input-className" data-i18n="[placeholder]ui-template.label.classNamePlaceholder"
|
|
404
|
+
<input style="width: 70%;" type="text" id="node-input-className" data-i18n="[placeholder]ui-template.label.classNamePlaceholder">
|
|
422
405
|
<a
|
|
423
406
|
data-html="true"
|
|
424
407
|
title="Dynamic Property: Send msg.class to append new classes to this widget. NOTE: classes set at runtime will be applied in addition to any class(es) set in the nodes class field."
|
|
@@ -480,13 +463,4 @@
|
|
|
480
463
|
<label for="node-input-passthru" style="width:70%;"> <span data-i18n="ui-template.label.pass-through"></span></label>
|
|
481
464
|
</div>
|
|
482
465
|
</div>
|
|
483
|
-
<!--<div class="form-row" style="margin-bottom:0px;">
|
|
484
|
-
<input type="checkbox" id="node-input-storeOutMessages" style="display:inline-block; margin-left:8px; width:auto; vertical-align:top;">
|
|
485
|
-
<label for="node-input-storeOutMessages" style="width:70%;"> <span data-i18n="ui-template.label.store-state"></span></label>
|
|
486
|
-
</div>
|
|
487
|
-
<div class="form-row" style="margin-bottom:0px;">
|
|
488
|
-
<input type="checkbox" id="node-input-resendOnRefresh" style="display:inline-block; margin-left:8px; width:auto; vertical-align:top;">
|
|
489
|
-
<label for="node-input-resendOnRefresh" style="width:70%;"> <span data-i18n="ui-template.label.resend"></span></label>
|
|
490
|
-
</div>
|
|
491
|
-
</div> -->
|
|
492
466
|
</script>
|
package/control-panel.js
CHANGED
|
@@ -174,6 +174,10 @@ module.exports = function (RED) {
|
|
|
174
174
|
|
|
175
175
|
let gateway
|
|
176
176
|
let gatewayAuthKey
|
|
177
|
+
async function telemetryTicket (req, res, identity, active) {
|
|
178
|
+
if (await meter?.ticket(req, res, identity, active)) return true
|
|
179
|
+
return await xySeries?.ticket(req, res, identity, active) || false
|
|
180
|
+
}
|
|
177
181
|
const momentary = createMomentaryControls({ routes,
|
|
178
182
|
send: (command, identity) => node.send({ ...command, room: identity.room, user: identity.user }),
|
|
179
183
|
onError: error => node.error(error) })
|
|
@@ -189,9 +193,8 @@ module.exports = function (RED) {
|
|
|
189
193
|
holdEpoch: momentary.epoch,
|
|
190
194
|
secureCookie: config.secureCookie === true,
|
|
191
195
|
restoredSessions: takeSessions(gatewayHandoffs, node.id, gatewayAuthKey),
|
|
192
|
-
onAuthenticatedRequest: (req, res, identity) =>
|
|
193
|
-
() => gateway?.sessionActive(identity.token))
|
|
194
|
-
() => gateway?.sessionActive(identity.token)) || false,
|
|
196
|
+
onAuthenticatedRequest: (req, res, identity) => telemetryTicket(req, res, identity,
|
|
197
|
+
() => gateway?.sessionActive(identity.token)),
|
|
195
198
|
onBoundCommand: (message, identity) => {
|
|
196
199
|
if (!bridgeReady) return false
|
|
197
200
|
if (message?._controlPanel?.hold) return momentary.handle(message, identity)
|
|
@@ -226,9 +229,8 @@ module.exports = function (RED) {
|
|
|
226
229
|
password: node.credentials?.editorPassword,
|
|
227
230
|
secureCookie: config.secureCookie === true,
|
|
228
231
|
restoredSessions: takeSessions(editorHandoffs, node.id, editorAuthKey),
|
|
229
|
-
onAuthenticatedRequest: (req, res, identity) =>
|
|
230
|
-
() => editor?.sessionActive(identity.token))
|
|
231
|
-
() => editor?.sessionActive(identity.token)) || false,
|
|
232
|
+
onAuthenticatedRequest: (req, res, identity) => telemetryTicket(req, res, identity,
|
|
233
|
+
() => editor?.sessionActive(identity.token)),
|
|
232
234
|
getConfig: async req => editorConfig(await getSnapshot(req.socket.localPort, fetch,
|
|
233
235
|
RED.settings.httpAdminRoot ?? '/'), node.id),
|
|
234
236
|
getDevices: async () => {
|
package/control-type-model.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
const contract = require('./control-type-contract.json')
|
|
2
2
|
const { meterRoute } = require('./meter-route')
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
3
|
// Used by both server validation and the injected editor; keep it self-contained.
|
|
7
4
|
function createTargetMatcher (typeContract) {
|
|
8
5
|
const families = {
|
|
9
6
|
range: ['number'], switch: ['boolean'], meter: ['number'], 'xy-series': ['xy-series'],
|
|
10
7
|
'text-display': ['text', 'number', 'enum', 'boolean'], 'text-input': ['text'], select: ['enum']
|
|
11
8
|
}
|
|
9
|
+
function modes (adapter) {
|
|
10
|
+
if (!families[adapter]) return []
|
|
11
|
+
return ['meter', 'xy-series', 'text-display'].includes(adapter)
|
|
12
|
+
? ['read'] : ['both', 'write', 'read']
|
|
13
|
+
}
|
|
12
14
|
function family (target) {
|
|
13
15
|
if (target.xySeries) return 'xy-series'
|
|
14
16
|
if (target.controlSchemaVersion !== typeContract.schemaVersion) return null
|
|
@@ -35,10 +37,10 @@ function createTargetMatcher (typeContract) {
|
|
|
35
37
|
if (candidate.unit && candidate.unit !== target.unit) return false
|
|
36
38
|
return true
|
|
37
39
|
}
|
|
38
|
-
return { family, compatible }
|
|
40
|
+
return { family, compatible, modes }
|
|
39
41
|
}
|
|
40
42
|
|
|
41
|
-
const { family: targetFamily, compatible: compatibleTarget } = createTargetMatcher(contract)
|
|
43
|
+
const { family: targetFamily, compatible: compatibleTarget, modes: bindingModes } = createTargetMatcher(contract)
|
|
42
44
|
|
|
43
45
|
function controlTypeForAdapter (adapter, target) {
|
|
44
46
|
if (!compatibleTarget(adapter, target, 'read') && !compatibleTarget(adapter, target, 'write')) {
|
|
@@ -69,4 +71,4 @@ function targetMeta (target, onebased) {
|
|
|
69
71
|
}
|
|
70
72
|
}
|
|
71
73
|
|
|
72
|
-
module.exports = { contract, createTargetMatcher, compatibleTarget, controlTypeForAdapter, targetFamily, targetMeta }
|
|
74
|
+
module.exports = { contract, createTargetMatcher, compatibleTarget, controlTypeForAdapter, targetFamily, targetMeta, bindingModes }
|
package/gateway.cjs
CHANGED
|
@@ -47,15 +47,24 @@ function createGateway ({
|
|
|
47
47
|
let closed = false
|
|
48
48
|
let heartbeatTimer = null
|
|
49
49
|
|
|
50
|
-
function closeSubscriber (subscriber) {
|
|
51
|
-
subscribers.delete(subscriber)
|
|
52
|
-
try { subscriber.res.end() } catch (_) {}
|
|
50
|
+
function closeSubscriber (subscriber, destroy = false) {
|
|
51
|
+
if (!subscribers.delete(subscriber)) return
|
|
52
|
+
try { if (destroy) subscriber.res.destroy(); else subscriber.res.end() } catch (_) {}
|
|
53
53
|
if (!subscribers.size && heartbeatTimer) {
|
|
54
54
|
clearInterval(heartbeatTimer)
|
|
55
55
|
heartbeatTimer = null
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
function writeSubscriber (subscriber, data) {
|
|
60
|
+
try {
|
|
61
|
+
// Allow one queued write to drain, including a large initial snapshot.
|
|
62
|
+
// A persistently blocked client reconnects to the latest snapshot.
|
|
63
|
+
if (subscriber.blocked) return closeSubscriber(subscriber, true)
|
|
64
|
+
if (subscriber.res.write(data) === false) subscriber.blocked = true
|
|
65
|
+
} catch (_) { closeSubscriber(subscriber, true) }
|
|
66
|
+
}
|
|
67
|
+
|
|
59
68
|
function ensureHeartbeat () {
|
|
60
69
|
if (heartbeatTimer || !subscribers.size) return
|
|
61
70
|
heartbeatTimer = setInterval(() => {
|
|
@@ -68,23 +77,21 @@ function createGateway ({
|
|
|
68
77
|
closeSubscriber(subscriber)
|
|
69
78
|
continue
|
|
70
79
|
}
|
|
71
|
-
|
|
80
|
+
writeSubscriber(subscriber, ': heartbeat\n\n')
|
|
72
81
|
}
|
|
73
82
|
}, FEEDBACK_HEARTBEAT_MS)
|
|
74
83
|
heartbeatTimer.unref?.()
|
|
75
84
|
}
|
|
76
85
|
|
|
77
86
|
function sendFeedbackEvent (subscriber, message) {
|
|
78
|
-
|
|
79
|
-
subscriber.res.write('data: ' + JSON.stringify(message) + '\n\n')
|
|
80
|
-
} catch (_) {
|
|
81
|
-
closeSubscriber(subscriber)
|
|
82
|
-
}
|
|
87
|
+
writeSubscriber(subscriber, 'data: ' + JSON.stringify(message) + '\n\n')
|
|
83
88
|
}
|
|
84
89
|
|
|
85
90
|
|
|
86
91
|
function broadcastFeedback (message) {
|
|
87
|
-
|
|
92
|
+
if (!subscribers.size) return
|
|
93
|
+
const data = 'data: ' + JSON.stringify(message) + '\n\n'
|
|
94
|
+
for (const subscriber of [...subscribers]) writeSubscriber(subscriber, data)
|
|
88
95
|
}
|
|
89
96
|
|
|
90
97
|
function closeSubscribersForToken (token) {
|
|
@@ -205,6 +212,10 @@ function createGateway ({
|
|
|
205
212
|
return true
|
|
206
213
|
}
|
|
207
214
|
if (path === routePrefix + '/events' && req.method === 'GET') {
|
|
215
|
+
if (subscribers.size >= 100) {
|
|
216
|
+
reply(res, 503, { error: 'Feedback connection limit reached' })
|
|
217
|
+
return true
|
|
218
|
+
}
|
|
208
219
|
res.writeHead(200, {
|
|
209
220
|
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
210
221
|
'Cache-Control': 'no-cache, no-transform',
|
|
@@ -213,8 +224,9 @@ function createGateway ({
|
|
|
213
224
|
'X-Content-Type-Options': 'nosniff'
|
|
214
225
|
})
|
|
215
226
|
res.flushHeaders?.()
|
|
216
|
-
const subscriber = { res, token }
|
|
227
|
+
const subscriber = { res, token, blocked: false }
|
|
217
228
|
subscribers.add(subscriber)
|
|
229
|
+
res.on('drain', () => { subscriber.blocked = false })
|
|
218
230
|
res.on('close', () => closeSubscriber(subscriber))
|
|
219
231
|
sendFeedbackEvent(subscriber, boundSnapshot())
|
|
220
232
|
ensureHeartbeat()
|
package/native-binding-plan.js
CHANGED
|
@@ -222,10 +222,11 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
|
|
|
222
222
|
}
|
|
223
223
|
|
|
224
224
|
const commandSwitchId = `${scopeId}-commands`
|
|
225
|
-
// Meter
|
|
225
|
+
// Ordinary feedback, Meter, and XY-series each get a separate vertical band.
|
|
226
226
|
for (const node of nodes) {
|
|
227
227
|
if (node.id.startsWith(`${scopeId}-meter-`) || node.id.startsWith(`${scopeId}-xy-series-`)) {
|
|
228
228
|
node.y += readTargets.size * 170
|
|
229
|
+
if (node.id.startsWith(`${scopeId}-xy-series-`)) node.y += meterStreams.size * 280
|
|
229
230
|
}
|
|
230
231
|
}
|
|
231
232
|
const routerId = `${scopeId}-router`
|
|
@@ -256,7 +257,8 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
|
|
|
256
257
|
category: 'panel binding',
|
|
257
258
|
in: [{ x: 170, y: 150, wires: [{ id: routerId }] }],
|
|
258
259
|
out: readTargets.size || meterStreams.size || xySeriesStreams.size
|
|
259
|
-
? [{ x: meterStreams.size ? 1600 : 1290,
|
|
260
|
+
? [{ x: meterStreams.size || xySeriesStreams.size ? 1600 : 1290,
|
|
261
|
+
y: Math.round((390 + (readTargets.size + meterStreams.size + xySeriesStreams.size + 1) * 85) / 10) * 10,
|
|
260
262
|
wires: [...readTargets.values()].map(target => ({ id: target.tagId, port: 0 }))
|
|
261
263
|
.concat([...meterStreams.values()].flatMap(target => target.tagIds.map(id => ({ id, port: 0 }))))
|
|
262
264
|
.concat([...xySeriesStreams.values()].flatMap(target => target.tagIds.map(id => ({ id, port: 0 })))) }]
|
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.4",
|
|
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.4",
|
|
10
10
|
"license": "UNLICENSED",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@vue/compiler-sfc": "3.5.42",
|
package/package.json
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-red-contrib-room-tools",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.4",
|
|
4
4
|
"description": "Authenticated visual control panel binding for Node-RED and MUSE Automator",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "node --test test-*.cjs"
|
|
8
|
+
},
|
|
6
9
|
"engines": {
|
|
7
10
|
"node": ">=18.16.0"
|
|
8
11
|
},
|
|
@@ -26,6 +29,7 @@
|
|
|
26
29
|
"meter-route.js",
|
|
27
30
|
"meter-frame.js",
|
|
28
31
|
"panel-meter.js",
|
|
32
|
+
"panel-telemetry-send.js",
|
|
29
33
|
"panel-meter-client.js",
|
|
30
34
|
"xy-series-route.js",
|
|
31
35
|
"xy-series-frame.js",
|
package/panel-binding-editor.js
CHANGED
|
@@ -6,6 +6,12 @@ function initializeVisualBinding () {
|
|
|
6
6
|
const root = this.$el
|
|
7
7
|
const base = config.editorEndpoint
|
|
8
8
|
const saved = new Map(Object.entries(config.bindings || {}))
|
|
9
|
+
const bindingsByLocator = new Map()
|
|
10
|
+
for (const entry of saved) {
|
|
11
|
+
const locator = entry[1].locator
|
|
12
|
+
if (!bindingsByLocator.has(locator)) bindingsByLocator.set(locator, [])
|
|
13
|
+
bindingsByLocator.get(locator).push(entry)
|
|
14
|
+
}
|
|
9
15
|
const meterEntries = [...saved].filter(([, entry]) => entry.readMeta?.meter)
|
|
10
16
|
const xySeriesEntries = [...saved].filter(([, entry]) => entry.readMeta?.xySeries)
|
|
11
17
|
const meterTargets = new Map()
|
|
@@ -13,6 +19,7 @@ function initializeVisualBinding () {
|
|
|
13
19
|
let meterClient = null, lastMeterFrame = null
|
|
14
20
|
let xySeriesClient = null, lastXYSeriesFrame = null
|
|
15
21
|
const attached = new WeakSet()
|
|
22
|
+
const controlListeners = new Map()
|
|
16
23
|
const buttonPresses = new Map()
|
|
17
24
|
const momentaryButtons = new Map()
|
|
18
25
|
const momentaryStates = new Map()
|
|
@@ -28,8 +35,10 @@ function initializeVisualBinding () {
|
|
|
28
35
|
let devices = []
|
|
29
36
|
let selecting = false
|
|
30
37
|
let observer
|
|
38
|
+
let nodeIndex = null
|
|
31
39
|
let toolbar
|
|
32
40
|
let overlay
|
|
41
|
+
let overlayCleanup
|
|
33
42
|
let status
|
|
34
43
|
let loginScreen
|
|
35
44
|
|
|
@@ -52,6 +61,10 @@ function initializeVisualBinding () {
|
|
|
52
61
|
node.addEventListener('click', action)
|
|
53
62
|
return node
|
|
54
63
|
}
|
|
64
|
+
function listen (node, name, action, capture = false, stops = cleanup) {
|
|
65
|
+
node.addEventListener(name, action, capture)
|
|
66
|
+
stops.push(() => node.removeEventListener(name, action, capture))
|
|
67
|
+
}
|
|
55
68
|
function inform (message, error = false) {
|
|
56
69
|
if (status) {
|
|
57
70
|
status.textContent = message || ''
|
|
@@ -70,7 +83,7 @@ function initializeVisualBinding () {
|
|
|
70
83
|
if (!response.ok) throw new Error(result.error || `Request failed (${response.status})`)
|
|
71
84
|
return result
|
|
72
85
|
}
|
|
73
|
-
|
|
86
|
+
function numericRange (binding, node) {
|
|
74
87
|
if (binding.uiRange) return binding.uiRange
|
|
75
88
|
const tag = node.tagName?.toLowerCase()
|
|
76
89
|
const type = node.type?.toLowerCase()
|
|
@@ -130,7 +143,30 @@ function initializeVisualBinding () {
|
|
|
130
143
|
return repeatMarkerFor(node, binding.repeat) === String(binding.instanceKey)
|
|
131
144
|
}
|
|
132
145
|
function nodesForBinding (binding) {
|
|
133
|
-
|
|
146
|
+
if (!nodeIndex) {
|
|
147
|
+
nodeIndex = new Map()
|
|
148
|
+
for (const node of root.querySelectorAll('[data-cp-auto]')) {
|
|
149
|
+
const locator = node.getAttribute('data-cp-auto')
|
|
150
|
+
if (!nodeIndex.has(locator)) nodeIndex.set(locator, { nodes: [], repeats: new Map() })
|
|
151
|
+
nodeIndex.get(locator).nodes.push(node)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const group = nodeIndex.get(binding.locator)
|
|
155
|
+
if (!group) return []
|
|
156
|
+
let nodes = group.nodes
|
|
157
|
+
if (binding.repeat) {
|
|
158
|
+
if (!group.repeats.has(binding.repeat.locator)) {
|
|
159
|
+
const keys = new Map()
|
|
160
|
+
for (const node of nodes) {
|
|
161
|
+
const key = repeatMarkerFor(node, binding.repeat)
|
|
162
|
+
if (!keys.has(key)) keys.set(key, [])
|
|
163
|
+
keys.get(key).push(node)
|
|
164
|
+
}
|
|
165
|
+
group.repeats.set(binding.repeat.locator, keys)
|
|
166
|
+
}
|
|
167
|
+
nodes = group.repeats.get(binding.repeat.locator).get(String(binding.instanceKey)) || []
|
|
168
|
+
}
|
|
169
|
+
return nodes.filter(node => root.contains(node))
|
|
134
170
|
}
|
|
135
171
|
function nodeForBinding (binding) {
|
|
136
172
|
const matches = nodesForBinding(binding)
|
|
@@ -238,6 +274,17 @@ function initializeVisualBinding () {
|
|
|
238
274
|
}
|
|
239
275
|
}
|
|
240
276
|
function attachControls () {
|
|
277
|
+
if (disposed) return
|
|
278
|
+
nodeIndex = null
|
|
279
|
+
for (const [node, listeners] of controlListeners) {
|
|
280
|
+
const binding = saved.get(listeners.id)
|
|
281
|
+
if (!binding || nodeForBinding(binding) !== node) {
|
|
282
|
+
for (const stop of listeners.stops) stop()
|
|
283
|
+
controlListeners.delete(node)
|
|
284
|
+
pendingText.delete(listeners.id)
|
|
285
|
+
attached.delete(node)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
241
288
|
for (const [node, button] of buttonPresses) {
|
|
242
289
|
const binding = saved.get(button.id)
|
|
243
290
|
if (!binding || nodeForBinding(binding) !== node) {
|
|
@@ -256,13 +303,15 @@ function initializeVisualBinding () {
|
|
|
256
303
|
}
|
|
257
304
|
for (const node of root.querySelectorAll('[data-cp-auto]')) {
|
|
258
305
|
if (attached.has(node)) continue
|
|
259
|
-
const
|
|
260
|
-
|
|
306
|
+
const matched = bindingsByLocator.get(node.getAttribute('data-cp-auto'))
|
|
307
|
+
?.find(([, entry]) => bindingMatchesNode(node, entry))
|
|
261
308
|
if (!matched) continue
|
|
262
309
|
const [id, binding] = matched
|
|
263
310
|
if (nodeForBinding(binding) !== node) continue
|
|
264
311
|
attached.add(node)
|
|
265
312
|
if (binding.mode === 'read') continue
|
|
313
|
+
const stops = []
|
|
314
|
+
controlListeners.set(node, { id, stops })
|
|
266
315
|
if (node.tagName === 'BUTTON' && binding.adapter === 'switch') {
|
|
267
316
|
buttonPresses.set(node, { id, controller: component.__attachButtonPress({ node,
|
|
268
317
|
canPress: () => !disposed && !editing && component.boundAuthenticated && component.boundFeedbackConnected }) })
|
|
@@ -282,7 +331,8 @@ function initializeVisualBinding () {
|
|
|
282
331
|
if (binding.component) {
|
|
283
332
|
if (binding.adapter === 'text-input') {
|
|
284
333
|
const commit = () => {
|
|
285
|
-
if (!pendingText.has(id) || editing || !component.boundAuthenticated
|
|
334
|
+
if (disposed || !pendingText.has(id) || editing || !component.boundAuthenticated ||
|
|
335
|
+
nodeForBinding(binding) !== node) return
|
|
286
336
|
const value = pendingText.get(id)
|
|
287
337
|
pendingText.delete(id)
|
|
288
338
|
try { component.sendBoundControl({ id, value, final: true }) } catch (error) {
|
|
@@ -293,46 +343,48 @@ function initializeVisualBinding () {
|
|
|
293
343
|
if (component.$nextTick) void component.$nextTick(commit)
|
|
294
344
|
else commit()
|
|
295
345
|
}
|
|
296
|
-
node
|
|
297
|
-
node
|
|
346
|
+
listen(node, 'change', afterModelUpdate, false, stops)
|
|
347
|
+
listen(node, 'focusout', afterModelUpdate, false, stops)
|
|
298
348
|
if (binding.component === 'v-text-field') {
|
|
299
|
-
node
|
|
349
|
+
listen(node, 'keydown', event => {
|
|
300
350
|
if (event.key === 'Enter') afterModelUpdate()
|
|
301
|
-
})
|
|
351
|
+
}, false, stops)
|
|
302
352
|
}
|
|
303
353
|
}
|
|
304
354
|
continue
|
|
305
355
|
}
|
|
306
356
|
const send = final => {
|
|
307
|
-
if (editing || !component.boundAuthenticated) return
|
|
357
|
+
if (disposed || editing || !component.boundAuthenticated || nodeForBinding(binding) !== node) return
|
|
308
358
|
try { component.sendBoundControl({ id, value: valueFor(binding, node), final }) } catch (error) {
|
|
309
359
|
console.error('ControlPanel binding command failed', error)
|
|
310
360
|
}
|
|
311
361
|
}
|
|
312
362
|
if (binding.adapter === 'range') {
|
|
313
|
-
node
|
|
314
|
-
node
|
|
363
|
+
listen(node, 'input', () => send(false), true, stops)
|
|
364
|
+
listen(node, 'change', () => send(true), true, stops)
|
|
315
365
|
} else if (binding.adapter === 'switch' && node.tagName === 'BUTTON') {
|
|
316
|
-
node
|
|
366
|
+
listen(node, 'click', event => {
|
|
317
367
|
event.preventDefault()
|
|
318
368
|
send(true)
|
|
319
|
-
})
|
|
320
|
-
} else node
|
|
369
|
+
}, false, stops)
|
|
370
|
+
} else listen(node, 'change', () => send(true), false, stops)
|
|
321
371
|
}
|
|
322
372
|
if (meterEntries.length) {
|
|
373
|
+
const previous = new Map(meterTargets)
|
|
323
374
|
meterTargets.clear()
|
|
324
375
|
for (const [id, binding] of meterEntries) {
|
|
325
376
|
const node = nodeForBinding(binding)
|
|
326
|
-
if (node) meterTargets.set(id, { node, binding })
|
|
377
|
+
if (node) meterTargets.set(id, previous.get(id)?.node === node ? previous.get(id) : { node, binding })
|
|
327
378
|
}
|
|
328
379
|
if (lastMeterFrame) applyMeterFrame(lastMeterFrame.frame, lastMeterFrame.ids)
|
|
329
380
|
else invalidateMeters()
|
|
330
381
|
}
|
|
331
382
|
if (xySeriesEntries.length) {
|
|
383
|
+
const previous = new Map(xySeriesTargets)
|
|
332
384
|
xySeriesTargets.clear()
|
|
333
385
|
for (const [id, binding] of xySeriesEntries) {
|
|
334
386
|
const node = nodeForBinding(binding)
|
|
335
|
-
if (node) xySeriesTargets.set(id, { node, binding })
|
|
387
|
+
if (node) xySeriesTargets.set(id, previous.get(id)?.node === node ? previous.get(id) : { node, binding })
|
|
336
388
|
}
|
|
337
389
|
if (lastXYSeriesFrame) applyXYSeriesFrame(lastXYSeriesFrame.frame, lastXYSeriesFrame.ids)
|
|
338
390
|
else invalidateXYSeries()
|
|
@@ -343,7 +395,8 @@ function initializeVisualBinding () {
|
|
|
343
395
|
lastMeterFrame = { frame, ids }
|
|
344
396
|
for (let index = 0; index < ids.length; index++) {
|
|
345
397
|
const target = meterTargets.get(ids[index])
|
|
346
|
-
if (!target) continue
|
|
398
|
+
if (!target || target.frame === frame) continue
|
|
399
|
+
target.frame = frame
|
|
347
400
|
const { node, binding } = target
|
|
348
401
|
node.setAttribute('data-cpv-meter-valid', String(frame.valid[index]))
|
|
349
402
|
node.setAttribute('aria-invalid', String(!frame.valid[index]))
|
|
@@ -366,11 +419,14 @@ function initializeVisualBinding () {
|
|
|
366
419
|
lastXYSeriesFrame = { frame, ids }
|
|
367
420
|
for (let index = 0; index < ids.length; index++) {
|
|
368
421
|
const target = xySeriesTargets.get(ids[index])
|
|
369
|
-
if (!target) continue
|
|
422
|
+
if (!target || target.frame === frame) continue
|
|
423
|
+
target.frame = frame
|
|
424
|
+
target.invalidated = false
|
|
370
425
|
const { node, binding } = target
|
|
371
426
|
node.setAttribute('data-cpv-xy-series-valid', String(frame.valid[index]))
|
|
427
|
+
node.setAttribute('aria-invalid', String(!frame.valid[index]))
|
|
372
428
|
node.setAttribute('data-cpv-xy-series-seq', String(frame.seq))
|
|
373
|
-
const points = frame.valid[index] ? frame.values[index].map(point => point
|
|
429
|
+
const points = frame.valid[index] ? frame.values[index].map(point => [point[0], point[1]]) : []
|
|
374
430
|
if (binding.model) {
|
|
375
431
|
if (binding.repeat) setRepeatModel(binding, points)
|
|
376
432
|
else if (binding.model in component && !component.$options?.computed?.[binding.model]) component[binding.model] = points
|
|
@@ -379,7 +435,11 @@ function initializeVisualBinding () {
|
|
|
379
435
|
}
|
|
380
436
|
function invalidateXYSeries () {
|
|
381
437
|
lastXYSeriesFrame = null
|
|
382
|
-
for (const
|
|
438
|
+
for (const target of xySeriesTargets.values()) {
|
|
439
|
+
if (target.invalidated) continue
|
|
440
|
+
target.invalidated = true
|
|
441
|
+
target.frame = null
|
|
442
|
+
const { node, binding } = target
|
|
383
443
|
node.setAttribute('data-cpv-xy-series-valid', 'false')
|
|
384
444
|
node.setAttribute('aria-invalid', 'true')
|
|
385
445
|
if (binding.model) {
|
|
@@ -393,7 +453,7 @@ function initializeVisualBinding () {
|
|
|
393
453
|
if (!binding.component || !binding.model || binding.mode === 'read') continue
|
|
394
454
|
let finishTimer
|
|
395
455
|
const send = (value, final) => {
|
|
396
|
-
if (editing || !component.boundAuthenticated) return
|
|
456
|
+
if (disposed || editing || !component.boundAuthenticated) return
|
|
397
457
|
try {
|
|
398
458
|
let commandValue
|
|
399
459
|
if (binding.adapter === 'range') {
|
|
@@ -432,7 +492,7 @@ function initializeVisualBinding () {
|
|
|
432
492
|
feedbackModels.delete(id)
|
|
433
493
|
if (Object.is(value, feedbackValue)) return
|
|
434
494
|
}
|
|
435
|
-
if (editing || !component.boundAuthenticated) return
|
|
495
|
+
if (disposed || editing || !component.boundAuthenticated) return
|
|
436
496
|
if (binding.adapter === 'text-input') {
|
|
437
497
|
if (typeof value === 'string' || value == null) pendingText.set(id, value ?? '')
|
|
438
498
|
return
|
|
@@ -446,7 +506,12 @@ function initializeVisualBinding () {
|
|
|
446
506
|
cleanup.push(() => { clearTimeout(finishTimer); unwatch() })
|
|
447
507
|
}
|
|
448
508
|
}
|
|
449
|
-
function closeOverlay () {
|
|
509
|
+
function closeOverlay () {
|
|
510
|
+
overlayCleanup?.()
|
|
511
|
+
overlayCleanup = null
|
|
512
|
+
overlay?.remove()
|
|
513
|
+
overlay = null
|
|
514
|
+
}
|
|
450
515
|
function sameBindingTarget (entry, candidate) {
|
|
451
516
|
return entry.locator === candidate.locator &&
|
|
452
517
|
(!candidate.repeat || String(entry.instanceKey) === String(candidate.instanceKey))
|
|
@@ -484,14 +549,13 @@ function initializeVisualBinding () {
|
|
|
484
549
|
const outside = event => {
|
|
485
550
|
if (!overlay?.contains(event.target)) {
|
|
486
551
|
closeOverlay()
|
|
487
|
-
document.removeEventListener('pointerdown', outside, true)
|
|
488
552
|
}
|
|
489
553
|
}
|
|
490
554
|
const timer = setTimeout(() => document.addEventListener('pointerdown', outside, true), 0)
|
|
491
|
-
|
|
555
|
+
overlayCleanup = () => {
|
|
492
556
|
clearTimeout(timer)
|
|
493
557
|
document.removeEventListener('pointerdown', outside, true)
|
|
494
|
-
}
|
|
558
|
+
}
|
|
495
559
|
}
|
|
496
560
|
function showDialog (title) {
|
|
497
561
|
closeOverlay()
|
|
@@ -595,6 +659,7 @@ function initializeVisualBinding () {
|
|
|
595
659
|
inform('Choose a control on the page')
|
|
596
660
|
updateBindingMarkers()
|
|
597
661
|
meterClient?.setAuth({ endpoint: base, csrf: editSession.csrf, header: 'X-Panel-CSRF' })
|
|
662
|
+
xySeriesClient?.setAuth({ endpoint: base, csrf: editSession.csrf, header: 'X-Panel-CSRF' })
|
|
598
663
|
}
|
|
599
664
|
function choose (label, options, selected) {
|
|
600
665
|
const select = element('select')
|
|
@@ -627,8 +692,7 @@ function initializeVisualBinding () {
|
|
|
627
692
|
? '.' + String(candidate.instanceKey).replace(/[^A-Za-z0-9._/-]/g, '-').slice(0, 48)
|
|
628
693
|
: ''
|
|
629
694
|
id.value = existing?.id || 'control.' + candidate.locator.slice(-12) + instanceSuffix
|
|
630
|
-
const modeOptions =
|
|
631
|
-
? ['read'] : ['both', 'write', 'read']
|
|
695
|
+
const modeOptions = matcher.modes(candidate.adapter)
|
|
632
696
|
const mode = field(form, 'Mode', choose('Mode', modeOptions.map(value => ({ value, text: value })), existing?.mode || modeOptions[0]))
|
|
633
697
|
const behavior = candidate.button ? field(form, 'Button behavior', choose('Button behavior', [
|
|
634
698
|
{ value: 'toggle', text: 'Click to toggle / 点击切换' },
|
|
@@ -869,7 +933,7 @@ function initializeVisualBinding () {
|
|
|
869
933
|
return candidate
|
|
870
934
|
}
|
|
871
935
|
function chooseCandidate (event) {
|
|
872
|
-
if (!editing || !editSession) return
|
|
936
|
+
if (disposed || !editing || !editSession) return
|
|
873
937
|
const candidate = candidateFromEvent(event)
|
|
874
938
|
if (!candidate) return
|
|
875
939
|
event.preventDefault()
|
|
@@ -877,26 +941,26 @@ function initializeVisualBinding () {
|
|
|
877
941
|
if (!candidate.supported) { inform(candidate.reason, true); return }
|
|
878
942
|
contextMenu(candidate, event.clientX || 12, event.clientY || 12)
|
|
879
943
|
}
|
|
880
|
-
root
|
|
944
|
+
listen(root, 'contextmenu', chooseCandidate, true)
|
|
881
945
|
for (const name of ['input', 'change']) {
|
|
882
|
-
root
|
|
946
|
+
listen(root, name, event => {
|
|
883
947
|
if (!editing || !event.target?.closest?.('[data-cp-auto]')) return
|
|
884
948
|
event.preventDefault()
|
|
885
949
|
event.stopImmediatePropagation()
|
|
886
950
|
}, true)
|
|
887
951
|
}
|
|
888
|
-
root
|
|
889
|
-
root
|
|
952
|
+
listen(root, 'click', event => { if (selecting) chooseCandidate(event) }, true)
|
|
953
|
+
listen(root, 'keydown', event => {
|
|
890
954
|
if (event.key === 'Enter' && selecting) chooseCandidate(event)
|
|
891
955
|
}, true)
|
|
892
956
|
let longPress
|
|
893
|
-
root
|
|
957
|
+
listen(root, 'pointerdown', event => {
|
|
894
958
|
if (!editing || event.pointerType === 'mouse') return
|
|
895
959
|
const candidate = candidateFromEvent(event)
|
|
896
960
|
if (candidate) longPress = setTimeout(() => chooseCandidate(event), 550)
|
|
897
961
|
}, true)
|
|
898
962
|
for (const name of ['pointerup', 'pointercancel', 'pointermove']) {
|
|
899
|
-
root
|
|
963
|
+
listen(root, name, () => clearTimeout(longPress), true)
|
|
900
964
|
}
|
|
901
965
|
attachControls()
|
|
902
966
|
attachModelControls()
|
|
@@ -942,8 +1006,15 @@ function initializeVisualBinding () {
|
|
|
942
1006
|
attachControls()
|
|
943
1007
|
updateBindingMarkers()
|
|
944
1008
|
})
|
|
945
|
-
observer.observe(root, { childList: true, subtree: true, attributes: true,
|
|
1009
|
+
observer.observe(root, { childList: true, subtree: true, attributes: true,
|
|
1010
|
+
attributeFilter: ['disabled', 'data-cp-auto', 'data-cp-repeat', 'data-cp-repeat-key'] })
|
|
946
1011
|
cleanup.push(() => {
|
|
1012
|
+
clearTimeout(longPress)
|
|
1013
|
+
for (const listeners of controlListeners.values()) {
|
|
1014
|
+
for (const stop of listeners.stops) stop()
|
|
1015
|
+
}
|
|
1016
|
+
controlListeners.clear()
|
|
1017
|
+
pendingText.clear()
|
|
947
1018
|
for (const button of buttonPresses.values()) button.controller.dispose()
|
|
948
1019
|
buttonPresses.clear()
|
|
949
1020
|
for (const button of momentaryButtons.values()) button.controller.dispose()
|
package/panel-meter.js
CHANGED
|
@@ -2,6 +2,7 @@ const crypto = require('node:crypto')
|
|
|
2
2
|
const { createMeterReceiver } = require('./meter-frame')
|
|
3
3
|
const { readJsonBody, replyJson } = require('./panel-http')
|
|
4
4
|
const { meterRoute } = require('./meter-route')
|
|
5
|
+
const { emitTelemetryFrame } = require('./panel-telemetry-send')
|
|
5
6
|
|
|
6
7
|
function createMeterRoom ({ room, now = Date.now, staleMs = 1000 }) {
|
|
7
8
|
const sources = new Map(), panels = new Map()
|
|
@@ -26,7 +27,6 @@ function createMeterRoom ({ room, now = Date.now, staleMs = 1000 }) {
|
|
|
26
27
|
const meter = meterRoute(binding.readMeta.meter)
|
|
27
28
|
const key = JSON.stringify([binding.device, meter.streamId])
|
|
28
29
|
let source = sources.get(key)
|
|
29
|
-
if (source && JSON.stringify(source.route) !== JSON.stringify(meter)) throw Error('Conflicting meter stream')
|
|
30
30
|
if (!source) {
|
|
31
31
|
source = { device: binding.device, route: meter, receiver: null, receivedAt: null,
|
|
32
32
|
revision: 0, refs: new Set(), indices: new Map() }
|
|
@@ -37,7 +37,7 @@ function createMeterRoom ({ room, now = Date.now, staleMs = 1000 }) {
|
|
|
37
37
|
})
|
|
38
38
|
const layout = { kind: 'room-meter-layout', panelId, room, epoch: crypto.randomBytes(16).toString('hex'),
|
|
39
39
|
ids: points.map(([id]) => id) }
|
|
40
|
-
const panel = { projections, layout, sequence: 0 }
|
|
40
|
+
const panel = { projections, streams: [...new Set(projections.map(point => point.source))], layout, sequence: 0 }
|
|
41
41
|
panels.set(panelId, panel)
|
|
42
42
|
return layout
|
|
43
43
|
}
|
|
@@ -100,7 +100,7 @@ function createMeterRoom ({ room, now = Date.now, staleMs = 1000 }) {
|
|
|
100
100
|
const panel = panels.get(panelId)
|
|
101
101
|
if (!panel) return ''
|
|
102
102
|
const time = now()
|
|
103
|
-
return panel.
|
|
103
|
+
return panel.streams.map(source => `${source.revision}:${source.receivedAt !== null &&
|
|
104
104
|
time - source.receivedAt <= staleMs}`).join(',')
|
|
105
105
|
}
|
|
106
106
|
|
|
@@ -151,7 +151,7 @@ function createPanelMeter ({ panelId, room, bindings, hub, onSubscribe = () => {
|
|
|
151
151
|
if (revision === lastRevision) return
|
|
152
152
|
lastRevision = revision
|
|
153
153
|
const frame = hub.snapshot(panelId)
|
|
154
|
-
|
|
154
|
+
emitTelemetryFrame('control-panel:meter-frame', frame, subscribers)
|
|
155
155
|
})
|
|
156
156
|
|
|
157
157
|
return {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
function emitTelemetryFrame (event, frame, subscribers) {
|
|
2
|
+
const sockets = [...subscribers.values()].map(entry => entry.socket)
|
|
3
|
+
if (!sockets.length) return
|
|
4
|
+
const namespace = sockets[0].nsp
|
|
5
|
+
// A namespace broadcast encodes once, but targets only authenticated socket IDs.
|
|
6
|
+
if (typeof namespace?.to === 'function' && sockets.every(socket => socket.nsp === namespace)) {
|
|
7
|
+
namespace.to(sockets.map(socket => socket.id)).volatile.emit(event, frame)
|
|
8
|
+
} else {
|
|
9
|
+
for (const socket of sockets) socket.volatile.emit(event, frame)
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
module.exports = { emitTelemetryFrame }
|
package/panel-visual-bindings.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
const { inspectTemplate } = require('./panel-template-instrumentation')
|
|
2
|
-
const { compatibleTarget, controlTypeForAdapter, targetMeta } = require('./control-type-model')
|
|
2
|
+
const { compatibleTarget, controlTypeForAdapter, targetMeta, bindingModes } = require('./control-type-model')
|
|
3
3
|
const { meterRoute } = require('./meter-route')
|
|
4
4
|
const { xySeriesRoute } = require('./xy-series-route')
|
|
5
5
|
|
|
6
6
|
const ID = /^[A-Za-z][A-Za-z0-9._/-]{0,127}$/
|
|
7
7
|
const INSTANCE_KEY = /^[A-Za-z0-9._:/-]{1,160}$/
|
|
8
|
-
const MODES = new Set(['read', 'write', 'both'])
|
|
9
8
|
|
|
10
9
|
function buttonBehavior (entry, candidate) {
|
|
11
10
|
const behavior = entry.buttonBehavior ?? 'toggle'
|
|
@@ -56,8 +55,7 @@ function visualRoutes (config) {
|
|
|
56
55
|
}
|
|
57
56
|
if (usedLocators.has(bindingKey)) throw new Error(`Duplicate visual control locator: ${id}`)
|
|
58
57
|
usedLocators.add(bindingKey)
|
|
59
|
-
if (!
|
|
60
|
-
(['meter', 'xy-series', 'text-display'].includes(entry.adapter) && entry.mode !== 'read')) {
|
|
58
|
+
if (!bindingModes(entry.adapter).includes(entry.mode) || !entry.type || !entry.label) {
|
|
61
59
|
throw new Error(`Invalid visual binding settings: ${id}`)
|
|
62
60
|
}
|
|
63
61
|
if (entry.readMeta?.meter) {
|
|
@@ -93,6 +91,7 @@ function validatedVisualBindings ({ format, entries, devices, targetsByDevice })
|
|
|
93
91
|
const used = new Set()
|
|
94
92
|
const usedLocators = new Set()
|
|
95
93
|
const models = new Map()
|
|
94
|
+
const targetIndexes = new Map()
|
|
96
95
|
for (const raw of entries) {
|
|
97
96
|
if (!raw || !ID.test(raw.id) || used.has(raw.id)) throw new Error('Control IDs must be unique and valid')
|
|
98
97
|
used.add(raw.id)
|
|
@@ -102,7 +101,7 @@ function validatedVisualBindings ({ format, entries, devices, targetsByDevice })
|
|
|
102
101
|
const behavior = buttonBehavior(raw, candidate)
|
|
103
102
|
if (usedLocators.has(bindingKey)) throw new Error(`One element cannot have two bindings: ${raw.id}`)
|
|
104
103
|
usedLocators.add(bindingKey)
|
|
105
|
-
if (!
|
|
104
|
+
if (!bindingModes(raw.adapter).includes(raw.mode)) {
|
|
106
105
|
throw new Error(`Invalid binding mode: ${raw.id}`)
|
|
107
106
|
}
|
|
108
107
|
if (typeof raw.label !== 'string' || !raw.label.trim() || raw.label.length > 80) {
|
|
@@ -111,7 +110,8 @@ function validatedVisualBindings ({ format, entries, devices, targetsByDevice })
|
|
|
111
110
|
if (!knownDevices.has(raw.device)) throw new Error(`Unknown device: ${raw.id}`)
|
|
112
111
|
const targets = targetsByDevice.get(raw.device)
|
|
113
112
|
if (!targets) throw new Error(`Device catalog unavailable: ${raw.device}`)
|
|
114
|
-
|
|
113
|
+
if (!targetIndexes.has(raw.device)) targetIndexes.set(raw.device, new Map(targets.map(target => [target.path, target])))
|
|
114
|
+
const byPath = targetIndexes.get(raw.device)
|
|
115
115
|
const write = raw.mode === 'read' ? null : byPath.get(raw.writePath)
|
|
116
116
|
const read = raw.mode === 'write' ? null : byPath.get(raw.readPath)
|
|
117
117
|
if ((raw.mode !== 'read' && !write) || (raw.mode !== 'write' && !read)) {
|
|
@@ -131,10 +131,11 @@ function validatedVisualBindings ({ format, entries, devices, targetsByDevice })
|
|
|
131
131
|
const identity = JSON.stringify([raw.device, read?.path, write?.path])
|
|
132
132
|
const modelKey = candidate.repeat ? `${candidate.model}#${raw.instanceKey}` : candidate.model
|
|
133
133
|
const previous = models.get(modelKey)
|
|
134
|
-
|
|
135
|
-
|
|
134
|
+
const realtime = Boolean(read?.meter || read?.xySeries)
|
|
135
|
+
if (previous && (realtime || previous.realtime) && previous.identity !== identity) {
|
|
136
|
+
throw Error(`Realtime model "${candidate.model}" is already used by another binding; use a separate Vue model for each independent telemetry target`)
|
|
136
137
|
}
|
|
137
|
-
models.set(modelKey, { identity,
|
|
138
|
+
models.set(modelKey, { identity, realtime })
|
|
138
139
|
}
|
|
139
140
|
result[raw.id] = {
|
|
140
141
|
locator: raw.locator, adapter: raw.adapter, type, label: raw.label.trim(), mode: raw.mode,
|
package/panel-xy-series.js
CHANGED
|
@@ -2,6 +2,7 @@ const crypto = require('node:crypto')
|
|
|
2
2
|
const { createXYSeriesReceiver } = require('./xy-series-frame')
|
|
3
3
|
const { readJsonBody, replyJson } = require('./panel-http')
|
|
4
4
|
const { xySeriesRoute } = require('./xy-series-route')
|
|
5
|
+
const { emitTelemetryFrame } = require('./panel-telemetry-send')
|
|
5
6
|
|
|
6
7
|
function createXYSeriesRoom ({ room, now = Date.now, staleMs = 1000 }) {
|
|
7
8
|
const sources = new Map(), panels = new Map(), ticks = new Set()
|
|
@@ -12,13 +13,21 @@ function createXYSeriesRoom ({ room, now = Date.now, staleMs = 1000 }) {
|
|
|
12
13
|
const entries = Object.entries(bindings).filter(([, entry]) => entry.readMeta?.xySeries)
|
|
13
14
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
14
15
|
if (!entries.length || entries.length > 256) throw Error('Invalid xy-series panel size')
|
|
15
|
-
|
|
16
|
+
// Validate the whole registration before changing shared sources or references.
|
|
17
|
+
const proposed = new Map()
|
|
18
|
+
for (const [, binding] of entries) {
|
|
16
19
|
const series = xySeriesRoute(binding.readMeta.xySeries)
|
|
17
20
|
const key = JSON.stringify([binding.device, series.streamId])
|
|
18
|
-
|
|
19
|
-
if (
|
|
21
|
+
const previous = sources.get(key)?.route || proposed.get(key)
|
|
22
|
+
if (previous && JSON.stringify(previous) !== JSON.stringify(series)) {
|
|
20
23
|
throw Error(`Conflicting xy-series stream: ${series.streamId}`)
|
|
21
24
|
}
|
|
25
|
+
proposed.set(key, series)
|
|
26
|
+
}
|
|
27
|
+
const projections = entries.map(([id, binding]) => {
|
|
28
|
+
const series = xySeriesRoute(binding.readMeta.xySeries)
|
|
29
|
+
const key = JSON.stringify([binding.device, series.streamId])
|
|
30
|
+
let source = sources.get(key)
|
|
22
31
|
if (!source) {
|
|
23
32
|
source = { device: binding.device, route: series, receiver: null,
|
|
24
33
|
receivedAt: null, revision: 0, refs: new Set() }
|
|
@@ -29,7 +38,7 @@ function createXYSeriesRoom ({ room, now = Date.now, staleMs = 1000 }) {
|
|
|
29
38
|
})
|
|
30
39
|
const layout = { kind: 'room-xy-series-layout', panelId, room,
|
|
31
40
|
epoch: crypto.randomBytes(16).toString('hex'), ids: entries.map(([id]) => id) }
|
|
32
|
-
panels.set(panelId, { projections, layout, sequence: 0 })
|
|
41
|
+
panels.set(panelId, { projections, streams: [...new Set(projections.map(point => point.source))], layout, sequence: 0 })
|
|
33
42
|
return layout
|
|
34
43
|
}
|
|
35
44
|
|
|
@@ -69,7 +78,7 @@ function createXYSeriesRoom ({ room, now = Date.now, staleMs = 1000 }) {
|
|
|
69
78
|
const frame = source.receiver?.snapshot()
|
|
70
79
|
const age = source.receivedAt === null ? null : Math.max(0, time - source.receivedAt)
|
|
71
80
|
const ok = Boolean(frame && age !== null && age <= staleMs)
|
|
72
|
-
values.push(ok ? frame.points.map(point => point
|
|
81
|
+
values.push(ok ? frame.points.map(point => [point[0], point[1]]) : [])
|
|
73
82
|
valid.push(ok)
|
|
74
83
|
sourcesByStream.set(key, { device: source.device, streamId: source.route.streamId,
|
|
75
84
|
epoch: source.receiver?.manifest.epoch || '', seq: frame?.seq || 0,
|
|
@@ -83,7 +92,7 @@ function createXYSeriesRoom ({ room, now = Date.now, staleMs = 1000 }) {
|
|
|
83
92
|
const panel = panels.get(panelId)
|
|
84
93
|
if (!panel) return ''
|
|
85
94
|
const time = now()
|
|
86
|
-
return panel.
|
|
95
|
+
return panel.streams.map(source => `${source.revision}:${source.receivedAt !== null &&
|
|
87
96
|
time - source.receivedAt <= staleMs}`).join(',')
|
|
88
97
|
}
|
|
89
98
|
|
|
@@ -133,7 +142,7 @@ function createPanelXYSeries ({ panelId, room, bindings, hub, onSubscribe = () =
|
|
|
133
142
|
if (revision === lastRevision) return
|
|
134
143
|
lastRevision = revision
|
|
135
144
|
const frame = hub.snapshot(panelId)
|
|
136
|
-
|
|
145
|
+
emitTelemetryFrame('control-panel:xy-series-frame', frame, subscribers)
|
|
137
146
|
})
|
|
138
147
|
return {
|
|
139
148
|
async ticket (req, res, identity, active) {
|
package/xy-series-frame.js
CHANGED
|
@@ -85,7 +85,7 @@ function createRoomXYSeriesReceiver (layout) {
|
|
|
85
85
|
(valid && points.some(point => !Array.isArray(point) || point.length !== 2 ||
|
|
86
86
|
!finite(point[0]) || !finite(point[1])))) return false
|
|
87
87
|
}
|
|
88
|
-
latest = { values: frame.values.map(points => points.map(point => point
|
|
88
|
+
latest = { values: frame.values.map(points => points.map(point => [point[0], point[1]])),
|
|
89
89
|
valid: frame.valid.slice(), seq: frame.seq }
|
|
90
90
|
pending = true
|
|
91
91
|
return true
|