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.
@@ -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 和文本展示只能用 `read`;当前 XY-series 弹窗仍可能默认显示 `both`,须手动选择 `read`。已绑定控件在编辑模式显示亮紫色边框;这只是编辑提示。
394
- 4. 点 Apply。节点验证当前模板、设备目录和映射,备份当前 Flow,并通过运行时 Flow API 更新当前面板的映射和受管理桥接。它不会导入、清除或追加其他面板的 Flow。
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 repeated controls distinct standard IDs. Simple top-level Vue `v-model` works, including object-list models such as `item.volume`, `item.audio.volume`, or `items[index].volume`, primitive array models such as `values[index]`, and an inner `<template v-for>` for multiple controls per row. During Vue re-rendering, a temporary duplicate list key is ignored until the key becomes unique again, so bindings do not attach twice. Controls with existing send handlers or ambiguous dynamic behavior are rejected until a suitable adapter exists. See `CONTROL_PANEL_AI_GUIDE.md` for examples and limits.
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>`. 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.
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 generates the native bridge for that panel using the original 1.8.9 flow. Apply does not click Deploy for the user. When Automator reports the external Flow change, the editor refreshes its Flow view once; review the refreshed Flow and deploy it yourself. 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.
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 `node --test test-*.cjs`. The isolated integration test uses the executing Node version; `CP_TEST_NODE` and `CP_TEST_PANEL_PACKAGE` can select an alternate runtime and unpacked release package.
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) continue
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
@@ -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 in that page validates and saves bindings, backs up the Flow, and regenerates the native bridge. When Node-RED reports a newer external Flow revision, the Automator editor page reloads once; the panel never imports or deploys Flow data. Ordinary Deploy alone does not apply bindings. Check targets and numbering before using real hardware.</p>
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" style="flex-grow: 1;">
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) => meter?.ticket(req, res, identity,
193
- () => gateway?.sessionActive(identity.token)) || xySeries?.ticket(req, res, identity,
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) => meter?.ticket(req, res, identity,
230
- () => editor?.sessionActive(identity.token)) || xySeries?.ticket(req, res, identity,
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 () => {
@@ -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
- try { subscriber.res.write(': heartbeat\n\n') } catch (_) { closeSubscriber(subscriber) }
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
- try {
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
- for (const subscriber of [...subscribers]) sendFeedbackEvent(subscriber, message)
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()
@@ -222,10 +222,11 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
222
222
  }
223
223
 
224
224
  const commandSwitchId = `${scopeId}-commands`
225
- // Meter lanes follow ordinary feedback lanes without overlapping their labels.
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, y: Math.round((390 + (readTargets.size + meterStreams.size + 1) * 85) / 10) * 10,
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 })))) }]
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "node-red-contrib-room-tools",
3
- "version": "1.9.3",
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.3",
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",
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",
@@ -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
- function numericRange (binding, node) {
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
- return [...root.querySelectorAll('[data-cp-auto]')].filter(node => bindingMatchesNode(node, binding))
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 locator = node.getAttribute('data-cp-auto')
260
- const matched = [...saved].find(([, entry]) => bindingMatchesNode(node, entry))
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) return
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.addEventListener('change', afterModelUpdate)
297
- node.addEventListener('focusout', afterModelUpdate)
346
+ listen(node, 'change', afterModelUpdate, false, stops)
347
+ listen(node, 'focusout', afterModelUpdate, false, stops)
298
348
  if (binding.component === 'v-text-field') {
299
- node.addEventListener('keydown', event => {
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.addEventListener('input', () => send(false), true)
314
- node.addEventListener('change', () => send(true), true)
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.addEventListener('click', event => {
366
+ listen(node, 'click', event => {
317
367
  event.preventDefault()
318
368
  send(true)
319
- })
320
- } else node.addEventListener('change', () => send(true))
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.slice()) : []
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 { node, binding } of xySeriesTargets.values()) {
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 () { overlay?.remove(); overlay = null }
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
- cleanup.push(() => {
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 = ['meter', 'text-display'].includes(candidate.adapter)
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.addEventListener('contextmenu', chooseCandidate, true)
944
+ listen(root, 'contextmenu', chooseCandidate, true)
881
945
  for (const name of ['input', 'change']) {
882
- root.addEventListener(name, event => {
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.addEventListener('click', event => { if (selecting) chooseCandidate(event) }, true)
889
- root.addEventListener('keydown', event => {
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.addEventListener('pointerdown', event => {
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.addEventListener(name, () => clearTimeout(longPress), true)
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, attributeFilter: ['disabled'] })
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.projections.map(({ source }) => `${source.revision}:${source.receivedAt !== null &&
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
- for (const { socket } of subscribers.values()) emitFrame(socket, frame)
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 }
@@ -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 (!MODES.has(entry.mode) || !entry.type || !entry.label ||
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 (!MODES.has(raw.mode) || (['meter', 'xy-series', 'text-display'].includes(raw.adapter) && raw.mode !== 'read')) {
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
- const byPath = new Map(targets.map(target => [target.path, target]))
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
- if (previous && (read?.meter || previous.meter) && previous.identity !== identity) {
135
- throw Error(`Meter model "${candidate.model}" is already used by another binding; use a separate Vue model for the meter. Meter and volume controls must not share a model`)
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, meter: Boolean(read?.meter) })
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,
@@ -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
- const projections = entries.map(([id, binding]) => {
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
- let source = sources.get(key)
19
- if (source && JSON.stringify(source.route) !== JSON.stringify(series)) {
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.slice()) : [])
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.projections.map(({ source }) => `${source.revision}:${source.receivedAt !== null &&
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
- for (const { socket } of subscribers.values()) emitFrame(socket, frame)
145
+ emitTelemetryFrame('control-panel:xy-series-frame', frame, subscribers)
137
146
  })
138
147
  return {
139
148
  async ticket (req, res, identity, active) {
@@ -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.slice())),
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