node-red-contrib-room-tools 1.9.3 → 1.9.5

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/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 {
@@ -53,7 +53,7 @@ function createMomentaryControls ({ routes, send, onError = () => {}, leaseMs =
53
53
  let record = records.get(key)
54
54
  if (!record) {
55
55
  if (records.size >= 1024) throw Error('Momentary client limit reached')
56
- record = { seq: 0, active: false, target: destination(command), identity,
56
+ record = { seq: 0, active: false, controlId: request.id, target: destination(command), identity,
57
57
  off: resolveBoundCommand(routes, { _controlPanel: { kind: 'command', id: request.id, value: false, final: true } }) }
58
58
  records.set(key, record)
59
59
  }
@@ -62,6 +62,8 @@ function createMomentaryControls ({ routes, send, onError = () => {}, leaseMs =
62
62
  record.seq = hold.seq
63
63
  if (hold.action === 'release') release(record)
64
64
  else if (hold.action === 'start') {
65
+ if ([...records.values()].some(other => other.active && other.target === record.target &&
66
+ !Object.is(other.off.payload, record.off.payload))) throw Error('Conflicting momentary state mapping')
65
67
  const alreadyHeld = occupied(record.target)
66
68
  if (!alreadyHeld) send(command, identity)
67
69
  record.active = true
@@ -74,6 +76,10 @@ function createMomentaryControls ({ routes, send, onError = () => {}, leaseMs =
74
76
  epoch,
75
77
  handle,
76
78
  isHeld (command) { sweep(); return occupied(destination(command)) },
79
+ releaseControl (id) {
80
+ for (const record of records.values()) if (record.controlId === id) release(record)
81
+ schedule()
82
+ },
77
83
  releaseSession (token) {
78
84
  for (const [key, record] of records) {
79
85
  if (record.identity.token === token) { release(record); records.delete(key) }
@@ -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 }
@@ -66,6 +66,7 @@ function candidateAdapter (node) {
66
66
  if (tag === 'meter' || tag === 'progress') return 'meter'
67
67
  if (tag === 'canvas' && hasBoundAttribute(node, ['data-points', 'data-series'])) return 'xy-series'
68
68
  if (XY_SERIES_CONTAINER_TAGS.has(tag) && hasBoundAttribute(node, ['data-coordinates'])) return 'xy-series'
69
+ if (attribute(node, 'data-display-only').toLowerCase() === 'true') return null
69
70
  if (['span', 'strong', 'b', 'output', 'h1', 'h2', 'h3'].includes(tag) &&
70
71
  node.children?.length === 1 && node.children[0].type === 2 &&
71
72
  node.children[0].content.trim()) return 'text-display'
@@ -6,6 +6,27 @@ const { configuredRoutes } = require('./control-bound-protocol')
6
6
  const { provisionManagedBridges } = require('./managed-native-bridge')
7
7
  const { backupFlow, flowLocation, runtimeDirectory } = require('./managed-bridge-admin')
8
8
 
9
+ const snapshotCache = new Map()
10
+
11
+ function snapshotCacheKey (localPort, adminRoot) {
12
+ return `${localPort}:${adminRoot}`
13
+ }
14
+
15
+ async function waitForEditorRoute (localPort, panelId, cookie, fetcher = fetch) {
16
+ if (!cookie || !Number.isInteger(localPort) || !panelId) return
17
+ const url = `http://127.0.0.1:${localPort}/control-panel-edit/panels/${panelId}/session`
18
+ for (let attempt = 0; attempt < 20; attempt += 1) {
19
+ try {
20
+ const response = await fetcher(url, {
21
+ headers: { Cookie: cookie },
22
+ signal: AbortSignal.timeout(1000)
23
+ })
24
+ if (response.ok) return
25
+ } catch (_) {}
26
+ if (attempt < 19) await new Promise(resolve => setTimeout(resolve, 150))
27
+ }
28
+ }
29
+
9
30
  function conflict (message) {
10
31
  const error = new Error(message)
11
32
  error.status = 409
@@ -24,17 +45,38 @@ function adminUrl (localPort, adminRoot = '/red') {
24
45
  return url.href
25
46
  }
26
47
 
27
- async function getSnapshot (localPort, fetcher = fetch, adminRoot = '/red') {
28
- const response = await fetcher(adminUrl(localPort, adminRoot), {
29
- headers: { 'Node-RED-API-Version': 'v2' },
30
- signal: AbortSignal.timeout(10000)
31
- })
32
- if (!response.ok) throw new Error(`Automator Flow read failed (${response.status})`)
33
- const snapshot = await response.json()
34
- if (typeof snapshot.rev !== 'string' || !Array.isArray(snapshot.flows)) {
35
- throw new Error('Automator did not return a versioned Flow')
48
+ async function getSnapshot (localPort, fetcher = fetch, adminRoot = '/red', options = {}) {
49
+ const key = snapshotCacheKey(localPort, adminRoot)
50
+ const cached = snapshotCache.get(key)
51
+ const now = Date.now()
52
+ if (cached && typeof options.expectedRev === 'string' && cached.snapshot.rev === options.expectedRev) {
53
+ return cached.snapshot
54
+ }
55
+ if (cached && Number.isFinite(options.maxAgeMs) && now - cached.time <= options.maxAgeMs) {
56
+ return cached.snapshot
57
+ }
58
+ try {
59
+ const response = await fetcher(adminUrl(localPort, adminRoot), {
60
+ headers: { 'Node-RED-API-Version': 'v2' },
61
+ signal: AbortSignal.timeout(10000)
62
+ })
63
+ if (!response.ok) throw new Error(`Automator Flow read failed (${response.status})`)
64
+ const snapshot = await response.json()
65
+ if (typeof snapshot.rev !== 'string' || !Array.isArray(snapshot.flows)) {
66
+ throw new Error('Automator did not return a versioned Flow')
67
+ }
68
+ snapshotCache.set(key, { snapshot, time: Date.now() })
69
+ return snapshot
70
+ } catch (error) {
71
+ const isTimeout = error?.name === 'TimeoutError' || error?.code === 'ABORT_ERR' ||
72
+ /aborted.*timeout/i.test(String(error?.message || ''))
73
+ if (isTimeout && cached && Number.isFinite(options.allowStaleMs) &&
74
+ Date.now() - cached.time <= options.allowStaleMs) {
75
+ return cached.snapshot
76
+ }
77
+ if (isTimeout) throw new Error('Automator Flow is still restarting; retry in a moment')
78
+ throw error
36
79
  }
37
- return snapshot
38
80
  }
39
81
 
40
82
  function findPanel (snapshot, panelId) {
@@ -65,12 +107,15 @@ function editorConfig (snapshot, panelId) {
65
107
 
66
108
  async function applyVisualBindings ({
67
109
  panelId, rev, templateHash, entries, userDir, localPort, adminRoot = '/red',
68
- catalogBase, fetcher = fetch
110
+ catalogBase, editorCookie, fetcher = fetch
69
111
  }) {
70
112
  // The editor authenticates the browser; Flow access stays on the runtime's loopback interface.
71
113
  const directory = runtimeDirectory(userDir)
72
114
  if (!catalogBase) throw conflict('Device catalog is unavailable')
73
- const snapshot = await getSnapshot(localPort, fetcher, adminRoot)
115
+ if (!Array.isArray(entries) || entries.length > 256) throw conflict('Invalid binding draft')
116
+ const [snapshot, devices] = await Promise.all([
117
+ getSnapshot(localPort, fetcher, adminRoot, { expectedRev: rev }), listCatalogDevices(catalogBase, fetcher)
118
+ ])
74
119
  if (snapshot.rev !== rev) throw conflict('Flow changed; refresh the visual editor')
75
120
  const panel = findPanel(snapshot, panelId)
76
121
  if (inspectTemplate(panel.format).templateHash !== templateHash) {
@@ -81,8 +126,6 @@ async function applyVisualBindings ({
81
126
  throw conflict('Deployed Flow differs from the active project file')
82
127
  }
83
128
 
84
- const devices = await listCatalogDevices(catalogBase, fetcher)
85
- if (!Array.isArray(entries) || entries.length > 256) throw conflict('Invalid binding draft')
86
129
  const deviceIds = [...new Set(entries.map(entry => entry?.device))]
87
130
  for (const id of deviceIds) {
88
131
  if (!devices.some(device => device.id === id && device.online)) {
@@ -98,11 +141,23 @@ async function applyVisualBindings ({
98
141
  nextPanel.nativeBridge = configuredRoutes(nextPanel).size > 0
99
142
  const provisioned = provisionManagedBridges(next, { panelId })
100
143
 
101
- const latest = await getSnapshot(localPort, fetcher, adminRoot)
102
- if (latest.rev !== snapshot.rev) throw conflict('Flow changed during validation; refresh and retry')
103
144
  if (JSON.stringify(JSON.parse(fs.readFileSync(location.flowFile, 'utf8'))) !== JSON.stringify(snapshot.flows)) {
104
145
  throw conflict('Active project changed during validation')
105
146
  }
147
+ // Node order is not a Flow change. An unchanged draft must not restart the
148
+ // panel and its bridge just because provisioning moved generated nodes.
149
+ const currentNodes = new Map(snapshot.flows.map(node => [node.id, node]))
150
+ if (currentNodes.size === snapshot.flows.length && provisioned.flows.length === snapshot.flows.length &&
151
+ provisioned.flows.every(node => JSON.stringify(node) === JSON.stringify(currentNodes.get(node.id)))) {
152
+ return {
153
+ ok: true,
154
+ rev: snapshot.rev,
155
+ updated: [],
156
+ unchanged: true,
157
+ editorConfig: editorConfig(snapshot, panelId),
158
+ devices
159
+ }
160
+ }
106
161
  const backupDir = backupFlow(location, snapshot.rev, snapshot.flows)
107
162
  const response = await fetcher(adminUrl(localPort, adminRoot), {
108
163
  method: 'POST',
@@ -116,18 +171,22 @@ async function applyVisualBindings ({
116
171
  })
117
172
  if (!response.ok) throw conflict(`Automator rejected the binding update (${response.status})`)
118
173
  const applied = await response.json().catch(() => ({}))
119
- let confirmed
120
- for (let attempt = 0; attempt < 5; attempt++) {
121
- confirmed = await getSnapshot(localPort, fetcher, adminRoot)
122
- const confirmedPanel = confirmed.flows.find(node => node.id === panelId)
123
- if (confirmedPanel?.visualBindings === nextPanel.visualBindings &&
124
- confirmedPanel.bridgeRevisionApplied === provisioned.flows.find(node => node.id === panelId)?.bridgeRevisionApplied) {
125
- return { ok: true, rev: confirmed.rev, backupDir, updated: provisioned.updated }
126
- }
127
- if (confirmed.rev !== snapshot.rev && confirmed.rev !== applied.rev) break
128
- if (attempt < 4) await new Promise(resolve => setTimeout(resolve, 200))
174
+ // The v2 Flow POST is the runtime acknowledgement. Avoid rereading the
175
+ // complete Flow repeatedly while modified nodes are still restarting.
176
+ const appliedRev = typeof applied.rev === 'string' ? applied.rev : snapshot.rev
177
+ snapshotCache.set(snapshotCacheKey(localPort, adminRoot), {
178
+ snapshot: { rev: appliedRev, flows: provisioned.flows },
179
+ time: Date.now()
180
+ })
181
+ await waitForEditorRoute(localPort, panelId, editorCookie, fetcher)
182
+ return {
183
+ ok: true,
184
+ rev: appliedRev,
185
+ backupDir,
186
+ updated: provisioned.updated,
187
+ editorConfig: editorConfig({ rev: appliedRev, flows: provisioned.flows }, panelId),
188
+ devices
129
189
  }
130
- throw conflict('Automator did not confirm the binding update; refresh before retrying')
131
190
  }
132
191
 
133
192
  module.exports = { adminUrl, applyVisualBindings, editorConfig, getSnapshot }
@@ -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, switchMapping, stateReverse, isMeterTarget } = 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,10 +55,19 @@ 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
  }
61
+ if (entry.adapter === 'meter' && (entry.type !== 'meter' || entry.mode !== 'read' ||
62
+ !entry.readPath || entry.writePath || entry.writeMeta || !entry.readMeta || !isMeterTarget(entry.readMeta))) {
63
+ throw Error(`Invalid readonly meter binding: ${id}`)
64
+ }
65
+ if (entry.adapter === 'range' && [entry.writeMeta, entry.readMeta].some(meta => meta && isMeterTarget(meta))) {
66
+ throw Error(`Numeric controls cannot bind to readonly meter targets: ${id}`)
67
+ }
68
+ for (const meta of [entry.readMeta, entry.writeMeta]) {
69
+ if (meta?.availability) require('./device-availability').availabilityRoute(meta.availability)
70
+ }
63
71
  if (entry.readMeta?.meter) {
64
72
  meterRoute(entry.readMeta.meter)
65
73
  if (entry.adapter !== 'meter' || entry.mode !== 'read' || entry.readMeta.access !== 'ro' ||
@@ -93,6 +101,7 @@ function validatedVisualBindings ({ format, entries, devices, targetsByDevice })
93
101
  const used = new Set()
94
102
  const usedLocators = new Set()
95
103
  const models = new Map()
104
+ const targetIndexes = new Map()
96
105
  for (const raw of entries) {
97
106
  if (!raw || !ID.test(raw.id) || used.has(raw.id)) throw new Error('Control IDs must be unique and valid')
98
107
  used.add(raw.id)
@@ -102,7 +111,7 @@ function validatedVisualBindings ({ format, entries, devices, targetsByDevice })
102
111
  const behavior = buttonBehavior(raw, candidate)
103
112
  if (usedLocators.has(bindingKey)) throw new Error(`One element cannot have two bindings: ${raw.id}`)
104
113
  usedLocators.add(bindingKey)
105
- if (!MODES.has(raw.mode) || (['meter', 'xy-series', 'text-display'].includes(raw.adapter) && raw.mode !== 'read')) {
114
+ if (!bindingModes(raw.adapter).includes(raw.mode)) {
106
115
  throw new Error(`Invalid binding mode: ${raw.id}`)
107
116
  }
108
117
  if (typeof raw.label !== 'string' || !raw.label.trim() || raw.label.length > 80) {
@@ -111,7 +120,8 @@ function validatedVisualBindings ({ format, entries, devices, targetsByDevice })
111
120
  if (!knownDevices.has(raw.device)) throw new Error(`Unknown device: ${raw.id}`)
112
121
  const targets = targetsByDevice.get(raw.device)
113
122
  if (!targets) throw new Error(`Device catalog unavailable: ${raw.device}`)
114
- const byPath = new Map(targets.map(target => [target.path, target]))
123
+ if (!targetIndexes.has(raw.device)) targetIndexes.set(raw.device, new Map(targets.map(target => [target.path, target])))
124
+ const byPath = targetIndexes.get(raw.device)
115
125
  const write = raw.mode === 'read' ? null : byPath.get(raw.writePath)
116
126
  const read = raw.mode === 'write' ? null : byPath.get(raw.readPath)
117
127
  if ((raw.mode !== 'read' && !write) || (raw.mode !== 'write' && !read)) {
@@ -124,6 +134,7 @@ function validatedVisualBindings ({ format, entries, devices, targetsByDevice })
124
134
  }
125
135
  const target = write || read
126
136
  const type = controlTypeForAdapter(raw.adapter, target)
137
+ const reverse = raw.adapter === 'switch' ? stateReverse(raw, write, read) : undefined
127
138
  if (read?.meter && (!Number.isFinite(read.min) || !Number.isFinite(read.max) || read.min >= read.max)) {
128
139
  throw Error(`Meter needs a finite device range: ${raw.id}`)
129
140
  }
@@ -131,14 +142,16 @@ function validatedVisualBindings ({ format, entries, devices, targetsByDevice })
131
142
  const identity = JSON.stringify([raw.device, read?.path, write?.path])
132
143
  const modelKey = candidate.repeat ? `${candidate.model}#${raw.instanceKey}` : candidate.model
133
144
  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`)
145
+ const realtime = raw.adapter === 'meter' || raw.adapter === 'xy-series'
146
+ if (previous && (realtime || previous.realtime) && previous.identity !== identity) {
147
+ throw Error(`Realtime model "${candidate.model}" is already used by another binding; use a separate Vue model for each independent telemetry target`)
136
148
  }
137
- models.set(modelKey, { identity, meter: Boolean(read?.meter) })
149
+ models.set(modelKey, { identity, realtime })
138
150
  }
139
151
  result[raw.id] = {
140
152
  locator: raw.locator, adapter: raw.adapter, type, label: raw.label.trim(), mode: raw.mode,
141
153
  ...(candidate.button ? { buttonBehavior: behavior } : {}),
154
+ ...(raw.adapter === 'switch' ? { stateReverse: reverse } : {}),
142
155
  ...(candidate.model ? { model: candidate.model } : {}),
143
156
  ...(candidate.repeat ? { repeat: candidate.repeat, instanceKey: raw.instanceKey } : {}),
144
157
  ...(candidate.component ? { component: candidate.component } : {}),
@@ -146,8 +159,10 @@ function validatedVisualBindings ({ format, entries, devices, targetsByDevice })
146
159
  ? { uiRange: { min: candidate.min, max: candidate.max, step: candidate.step } } : {}),
147
160
  device: raw.device,
148
161
  writePath: write?.path || '', readPath: read?.path || '',
149
- ...(write ? { writeMeta: targetMeta(write, raw.onebased) } : {}),
150
- ...(read ? { readMeta: targetMeta(read, raw.onebased) } : {})
162
+ ...(write ? { writeMeta: targetMeta(write, raw.onebased,
163
+ raw.adapter === 'switch' ? switchMapping(write, undefined, reverse) : undefined) } : {}),
164
+ ...(read ? { readMeta: targetMeta(read, raw.onebased,
165
+ raw.adapter === 'switch' ? switchMapping(read, undefined, reverse) : undefined) } : {})
151
166
  }
152
167
  }
153
168
  return result
@@ -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,8 +85,14 @@ 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())),
89
- valid: frame.valid.slice(), seq: frame.seq }
88
+ if (frame.sources !== undefined && (!Array.isArray(frame.sources) || frame.sources.length > 256 ||
89
+ frame.sources.some(source => typeof source?.device !== 'string' || source.device.length > 128 ||
90
+ typeof source.streamId !== 'string' || source.streamId.length > 128 ||
91
+ typeof source.epoch !== 'string' || source.epoch.length > 128 ||
92
+ !Number.isSafeInteger(source.seq) || source.seq < 0))) return false
93
+ latest = { values: frame.values.map(points => points.map(point => [point[0], point[1]])),
94
+ valid: frame.valid.slice(), seq: frame.seq,
95
+ ...(frame.sources ? { sources: frame.sources.map(({ device, streamId, epoch, seq }) => ({ device, streamId, epoch, seq })) } : {}) }
90
96
  pending = true
91
97
  return true
92
98
  },