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

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.
@@ -0,0 +1,91 @@
1
+ // Serialized into the Dashboard component; only standard browser APIs belong here.
2
+ function attachMomentaryButton ({ node, id, endpoint, getSession, canStart, request, onError, state = { seq: 0 } }) {
3
+ if (!state.client) state.client = Array.from(crypto.getRandomValues(new Uint8Array(16)), byte => byte.toString(16).padStart(2, '0')).join('')
4
+ let generation = 0, active = null, timer = null
5
+ const listeners = []
6
+ function listen (target, name, callback) {
7
+ target.addEventListener(name, callback)
8
+ listeners.push(() => target.removeEventListener(name, callback))
9
+ }
10
+ function transmit (action, session) {
11
+ return request(endpoint + '/bound-command', {
12
+ method: 'POST', credentials: 'same-origin', keepalive: action === 'release',
13
+ headers: { 'Content-Type': 'application/json', 'X-Room-CSRF': session.csrf },
14
+ body: JSON.stringify({ room: session.room, _controlPanel: { kind: 'command', id,
15
+ value: action !== 'release', final: true,
16
+ hold: { client: state.client, seq: ++state.seq, action, epoch: session.holdEpoch } } })
17
+ }, 1800, async response => {
18
+ if (!response.ok) throw Error('Momentary command failed (' + response.status + ')')
19
+ if (typeof response.json === 'function') {
20
+ const result = await response.json()
21
+ if (action !== 'release' && result.held !== true) throw Error('Momentary hold expired; press again')
22
+ }
23
+ })
24
+ }
25
+ function release () {
26
+ if (!active) return
27
+ const previous = active
28
+ active = null
29
+ generation++
30
+ clearTimeout(timer)
31
+ timer = null
32
+ void transmit('release', previous.session).catch(onError)
33
+ if (previous.pointer !== undefined && node.hasPointerCapture?.(previous.pointer)) {
34
+ try { node.releasePointerCapture(previous.pointer) } catch (_) {}
35
+ }
36
+ }
37
+ function renew (version) {
38
+ if (!active || version !== generation) return
39
+ if (!node.isConnected || !canStart()) { release(); return }
40
+ void transmit('renew', active.session).then(() => {
41
+ if (active && version === generation) timer = setTimeout(() => renew(version), 750)
42
+ }).catch(error => {
43
+ if (active && version === generation) release()
44
+ onError(error)
45
+ })
46
+ }
47
+ function start (source) {
48
+ if (active || node.disabled || !canStart()) return
49
+ const session = getSession()
50
+ if (!session?.csrf) return
51
+ active = { ...source, session: { ...session } }
52
+ const version = ++generation
53
+ void transmit('start', active.session).then(() => {
54
+ if (active && version === generation) timer = setTimeout(() => renew(version), 750)
55
+ }).catch(error => {
56
+ if (active && version === generation) release()
57
+ onError(error)
58
+ })
59
+ }
60
+ listen(node, 'pointerdown', event => {
61
+ if (event.button !== 0 || active) return
62
+ event.preventDefault()
63
+ node.focus()
64
+ start({ pointer: event.pointerId })
65
+ if (active) { try { node.setPointerCapture(event.pointerId) } catch (_) {} }
66
+ })
67
+ const pointerEnd = event => { if (active?.pointer === event.pointerId) release() }
68
+ listen(window, 'pointerup', pointerEnd)
69
+ listen(window, 'pointercancel', pointerEnd)
70
+ listen(node, 'lostpointercapture', pointerEnd)
71
+ listen(node, 'keydown', event => {
72
+ if (![' ', 'Enter'].includes(event.key)) return
73
+ event.preventDefault()
74
+ if (!event.repeat) start({ key: event.key })
75
+ })
76
+ listen(window, 'keyup', event => {
77
+ if (active?.key === event.key) { event.preventDefault(); release() }
78
+ })
79
+ listen(node, 'click', event => event.preventDefault())
80
+ listen(node, 'blur', release)
81
+ listen(window, 'blur', release)
82
+ listen(window, 'pagehide', release)
83
+ listen(document, 'visibilitychange', () => { if (document.hidden) release() })
84
+ return {
85
+ release,
86
+ check () { if (!node.isConnected || node.disabled || !canStart()) release() },
87
+ dispose () { release(); for (const remove of listeners) remove() }
88
+ }
89
+ }
90
+
91
+ module.exports = { attachMomentaryButton }
@@ -0,0 +1,93 @@
1
+ const { resolveBoundCommand } = require('./control-bound-protocol')
2
+ const crypto = require('node:crypto')
3
+
4
+ function createMomentaryControls ({ routes, send, onError = () => {}, leaseMs = 3000,
5
+ now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout }) {
6
+ const records = new Map()
7
+ const epoch = crypto.randomBytes(16).toString('hex')
8
+ let timer = null
9
+ let closed = false
10
+ const destination = command => JSON.stringify([command.device, command.path])
11
+ const occupied = key => [...records.values()].some(record => record.active && record.target === key)
12
+
13
+ function release (record) {
14
+ if (!record.active) return
15
+ record.active = false
16
+ if (!occupied(record.target)) {
17
+ try { send(record.off, record.identity) } catch (error) { onError(error) }
18
+ }
19
+ }
20
+ function sweep () {
21
+ const time = now()
22
+ for (const [key, record] of records) {
23
+ if (record.active && record.until <= time) release(record)
24
+ if (record.identity.expiry <= time) { release(record); records.delete(key) }
25
+ }
26
+ }
27
+ function schedule () {
28
+ if (timer) clearTimer(timer)
29
+ timer = null
30
+ const deadlines = [...records.values()].filter(record => record.active).map(record => record.until)
31
+ if (closed || !deadlines.length) return
32
+ timer = setTimer(() => { timer = null; sweep(); schedule() }, Math.max(1, Math.min(...deadlines) - now()))
33
+ timer.unref?.()
34
+ }
35
+ function handle (message, identity) {
36
+ if (closed) throw Error('Momentary controls unavailable')
37
+ const request = message?._controlPanel
38
+ const hold = request?.hold
39
+ const route = routes.get(request?.id)
40
+ if (route?.control.buttonBehavior !== 'momentary' || route.control.mode !== 'both') {
41
+ throw Error('This control is not a momentary button')
42
+ }
43
+ if (!hold || hold.epoch !== epoch || !/^[A-Za-z0-9_-]{1,64}$/.test(hold.client || '') ||
44
+ !Number.isSafeInteger(hold.seq) || hold.seq < 1 ||
45
+ !['start', 'renew', 'release'].includes(hold.action) ||
46
+ request.value !== (hold.action !== 'release') ||
47
+ typeof identity?.token !== 'string' || !Number.isFinite(identity.expiry) || identity.expiry <= now()) {
48
+ throw Error('Invalid momentary command')
49
+ }
50
+ const command = resolveBoundCommand(routes, message)
51
+ sweep()
52
+ const key = JSON.stringify([identity.token, request.id, hold.client])
53
+ let record = records.get(key)
54
+ if (!record) {
55
+ if (records.size >= 1024) throw Error('Momentary client limit reached')
56
+ record = { seq: 0, active: false, target: destination(command), identity,
57
+ off: resolveBoundCommand(routes, { _controlPanel: { kind: 'command', id: request.id, value: false, final: true } }) }
58
+ records.set(key, record)
59
+ }
60
+ // Keep the sequence watermark after release: a delayed press must not re-enable the device.
61
+ if (hold.seq <= record.seq) return { held: record.active }
62
+ record.seq = hold.seq
63
+ if (hold.action === 'release') release(record)
64
+ else if (hold.action === 'start') {
65
+ const alreadyHeld = occupied(record.target)
66
+ if (!alreadyHeld) send(command, identity)
67
+ record.active = true
68
+ record.until = Math.min(now() + leaseMs, identity.expiry)
69
+ } else if (record.active) record.until = Math.min(now() + leaseMs, identity.expiry)
70
+ schedule()
71
+ return { held: record.active }
72
+ }
73
+ return {
74
+ epoch,
75
+ handle,
76
+ isHeld (command) { sweep(); return occupied(destination(command)) },
77
+ releaseSession (token) {
78
+ for (const [key, record] of records) {
79
+ if (record.identity.token === token) { release(record); records.delete(key) }
80
+ }
81
+ schedule()
82
+ },
83
+ close () {
84
+ closed = true
85
+ if (timer) clearTimer(timer)
86
+ timer = null
87
+ for (const record of records.values()) release(record)
88
+ records.clear()
89
+ }
90
+ }
91
+ }
92
+
93
+ module.exports = { createMomentaryControls }
@@ -8,6 +8,7 @@ const COMPONENT_ADAPTERS = Object.freeze({
8
8
  'v-text-field': 'text-input', 'v-textarea': 'text-input',
9
9
  'v-progress-linear': 'meter', 'v-progress-circular': 'meter'
10
10
  })
11
+ const XY_SERIES_CONTAINER_TAGS = new Set(['div', 'section', 'article', 'main', 'aside', 'svg', 'g'])
11
12
  const SIMPLE_IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/
12
13
  const SIMPLE_PROPERTY_PATH = /^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*$/
13
14
  const UNSAFE_PROPERTY_NAMES = new Set(['__proto__', 'prototype', 'constructor'])
@@ -63,6 +64,8 @@ function candidateAdapter (node) {
63
64
  if (tag === 'button' && (attribute(node, 'aria-pressed') !== '' || attribute(node, 'role') === 'switch' ||
64
65
  hasBoundAttribute(node, ['aria-pressed', 'aria-checked']))) return 'switch'
65
66
  if (tag === 'meter' || tag === 'progress') return 'meter'
67
+ if (tag === 'canvas' && hasBoundAttribute(node, ['data-points', 'data-series'])) return 'xy-series'
68
+ if (XY_SERIES_CONTAINER_TAGS.has(tag) && hasBoundAttribute(node, ['data-coordinates'])) return 'xy-series'
66
69
  if (['span', 'strong', 'b', 'output', 'h1', 'h2', 'h3'].includes(tag) &&
67
70
  node.children?.length === 1 && node.children[0].type === 2 &&
68
71
  node.children[0].content.trim()) return 'text-display'
@@ -71,6 +74,17 @@ function candidateAdapter (node) {
71
74
  return null
72
75
  }
73
76
 
77
+ function xySeriesModel (node) {
78
+ const tag = node.tag.toLowerCase()
79
+ const names = tag === 'canvas' ? ['data-points', 'data-series'] : ['data-coordinates']
80
+ const bound = (node.props || []).find(prop => prop.type === 7 && prop.name === 'bind' &&
81
+ names.includes(prop.arg?.content))
82
+ if (!bound || bound.modifiers?.length) return { model: '', valid: false, attribute: names[0] }
83
+ const model = bound.exp?.content?.trim() || ''
84
+ return { model, valid: SIMPLE_IDENTIFIER.test(model), attribute: bound.arg.content,
85
+ presentation: tag === 'canvas' ? 'points' : 'coordinates' }
86
+ }
87
+
74
88
  function staticNumericProp (node, name) {
75
89
  const bound = (node.props || []).find(prop => prop.type === 7 && prop.name === 'bind' &&
76
90
  prop.arg?.content === name)
@@ -279,6 +293,7 @@ function inspectTemplate (format) {
279
293
  const component = COMPONENT_ADAPTERS[node.tag.toLowerCase()] ? node.tag.toLowerCase() : ''
280
294
  const display = adapter === 'text-display'
281
295
  ? displayModel(node, activeRepeat)
296
+ : adapter === 'xy-series' ? xySeriesModel(node)
282
297
  : ['v-progress-linear', 'v-progress-circular'].includes(component) ? progressModel(node, activeRepeat)
283
298
  : adapter === 'meter' && !component ? nativeMeterModel(node, activeRepeat)
284
299
  : adapter === 'switch' && node.tag.toLowerCase() === 'button'
@@ -326,6 +341,7 @@ function inspectTemplate (format) {
326
341
  (enabledBooleanProp(node, ['indeterminate']) || hasAttribute(node, ['min', 'max', 'step']))
327
342
  const candidate = {
328
343
  locator, adapter,
344
+ ...(adapter === 'switch' && node.tag.toLowerCase() === 'button' ? { button: true } : {}),
329
345
  ...(component ? { component } : {}),
330
346
  ...(activeRepeat ? { repeat: activeRepeat } : {}),
331
347
  label: attribute(node, 'aria-label') || attribute(node, 'label') || attribute(node, 'title') ||
@@ -360,7 +376,11 @@ function inspectTemplate (format) {
360
376
  unsupportedTextType ? 'Only text Vuetify fields are supported' :
361
377
  unsupportedProgress ? 'Progress needs a determinate positive range' :
362
378
  unsupportedCircular ? 'Circular progress uses a determinate 0-100 range' :
363
- component && !display.model ? 'Vuetify controls need a simple Vue v-model field' : ''
379
+ adapter === 'xy-series' && !display.model
380
+ ? display.attribute === 'data-coordinates'
381
+ ? 'Map coordinate containers need :data-coordinates="coordinates"'
382
+ : 'Canvas needs :data-points="series" or :data-series="series"'
383
+ : component && !display.model ? 'Vuetify controls need a simple Vue v-model field' : ''
364
384
  }
365
385
  candidates.push({ ...candidate, offset: openingTagEnd(format, node.loc.start.offset) })
366
386
  counts.set(locator, (counts.get(locator) || 0) + 1)
@@ -1,11 +1,22 @@
1
1
  const { inspectTemplate } = require('./panel-template-instrumentation')
2
2
  const { compatibleTarget, controlTypeForAdapter, targetMeta } = require('./control-type-model')
3
3
  const { meterRoute } = require('./meter-route')
4
+ const { xySeriesRoute } = require('./xy-series-route')
4
5
 
5
6
  const ID = /^[A-Za-z][A-Za-z0-9._/-]{0,127}$/
6
7
  const INSTANCE_KEY = /^[A-Za-z0-9._:/-]{1,160}$/
7
8
  const MODES = new Set(['read', 'write', 'both'])
8
9
 
10
+ function buttonBehavior (entry, candidate) {
11
+ const behavior = entry.buttonBehavior ?? 'toggle'
12
+ if (!['toggle', 'momentary'].includes(behavior) ||
13
+ (entry.buttonBehavior !== undefined && !candidate.button) ||
14
+ (behavior === 'momentary' && entry.mode !== 'both')) {
15
+ throw new Error('Button behavior requires a boolean button; momentary buttons require both mode')
16
+ }
17
+ return behavior
18
+ }
19
+
9
20
  function targetKey (candidate, entry) {
10
21
  if (!candidate.repeat) return candidate.locator
11
22
  if (typeof entry.instanceKey !== 'string' || !INSTANCE_KEY.test(entry.instanceKey)) {
@@ -39,10 +50,14 @@ function visualRoutes (config) {
39
50
  throw new Error(`Visual control moved or became ambiguous: ${id}`)
40
51
  }
41
52
  const bindingKey = targetKey(candidate, entry)
53
+ const behavior = buttonBehavior(entry, candidate)
54
+ if (behavior === 'momentary' && !['mute', 'power'].includes(entry.type)) {
55
+ throw new Error('Momentary buttons require a boolean control type')
56
+ }
42
57
  if (usedLocators.has(bindingKey)) throw new Error(`Duplicate visual control locator: ${id}`)
43
58
  usedLocators.add(bindingKey)
44
59
  if (!MODES.has(entry.mode) || !entry.type || !entry.label ||
45
- (['meter', 'text-display'].includes(entry.adapter) && entry.mode !== 'read')) {
60
+ (['meter', 'xy-series', 'text-display'].includes(entry.adapter) && entry.mode !== 'read')) {
46
61
  throw new Error(`Invalid visual binding settings: ${id}`)
47
62
  }
48
63
  if (entry.readMeta?.meter) {
@@ -52,9 +67,16 @@ function visualRoutes (config) {
52
67
  !Number.isFinite(entry.readMeta.min) || !Number.isFinite(entry.readMeta.max) ||
53
68
  entry.readMeta.min >= entry.readMeta.max) throw Error(`Invalid meter binding: ${id}`)
54
69
  }
70
+ if (entry.readMeta?.xySeries) {
71
+ xySeriesRoute(entry.readMeta.xySeries)
72
+ if (entry.adapter !== 'xy-series' || entry.mode !== 'read' || entry.readMeta.access !== 'ro') {
73
+ throw Error(`Invalid xy-series binding: ${id}`)
74
+ }
75
+ }
55
76
  routes.set(id, {
56
77
  control: { id, type: entry.type, mode: entry.mode, label: entry.label,
57
78
  adapter: entry.adapter, locator: entry.locator,
79
+ ...(candidate.button ? { buttonBehavior: behavior } : {}),
58
80
  ...(candidate.repeat ? { instanceKey: entry.instanceKey } : {}) },
59
81
  binding: entry
60
82
  })
@@ -77,9 +99,10 @@ function validatedVisualBindings ({ format, entries, devices, targetsByDevice })
77
99
  const candidate = candidates.get(raw.locator)
78
100
  if (!candidate || candidate.adapter !== raw.adapter) throw new Error(`Control changed: ${raw.id}`)
79
101
  const bindingKey = targetKey(candidate, raw)
102
+ const behavior = buttonBehavior(raw, candidate)
80
103
  if (usedLocators.has(bindingKey)) throw new Error(`One element cannot have two bindings: ${raw.id}`)
81
104
  usedLocators.add(bindingKey)
82
- if (!MODES.has(raw.mode) || (['meter', 'text-display'].includes(raw.adapter) && raw.mode !== 'read')) {
105
+ if (!MODES.has(raw.mode) || (['meter', 'xy-series', 'text-display'].includes(raw.adapter) && raw.mode !== 'read')) {
83
106
  throw new Error(`Invalid binding mode: ${raw.id}`)
84
107
  }
85
108
  if (typeof raw.label !== 'string' || !raw.label.trim() || raw.label.length > 80) {
@@ -115,6 +138,7 @@ function validatedVisualBindings ({ format, entries, devices, targetsByDevice })
115
138
  }
116
139
  result[raw.id] = {
117
140
  locator: raw.locator, adapter: raw.adapter, type, label: raw.label.trim(), mode: raw.mode,
141
+ ...(candidate.button ? { buttonBehavior: behavior } : {}),
118
142
  ...(candidate.model ? { model: candidate.model } : {}),
119
143
  ...(candidate.repeat ? { repeat: candidate.repeat, instanceKey: raw.instanceKey } : {}),
120
144
  ...(candidate.component ? { component: candidate.component } : {}),
@@ -0,0 +1,84 @@
1
+ // Self-contained client for readonly XY-series telemetry.
2
+ function createPanelXYSeriesClient ({ socket, panelId, room, ids, request,
3
+ createReceiver, createRenderer, apply, invalidate, onUnauthorized }) {
4
+ if (!socket) throw Error('Dashboard socket is unavailable for xy-series telemetry')
5
+ let auth = null, renderer = null, generation = 0, disposed = false
6
+ let retry = null, stale = null, connecting = false, delay = 500
7
+ const stopRendering = () => {
8
+ renderer?.close(); renderer = null
9
+ clearTimeout(stale); stale = null
10
+ invalidate()
11
+ }
12
+ const pause = () => {
13
+ generation++
14
+ connecting = false
15
+ clearTimeout(retry); retry = null
16
+ if (socket.connected) socket.emit('control-panel:xy-series-unsubscribe', panelId)
17
+ stopRendering()
18
+ }
19
+ const connect = async () => {
20
+ if (disposed || !auth || document.hidden || !socket.connected || connecting) return
21
+ clearTimeout(retry); retry = null
22
+ connecting = true
23
+ const expected = generation
24
+ try {
25
+ const { response, result } = await request(auth.endpoint + '/xy-series-ticket', {
26
+ method: 'POST', credentials: 'same-origin',
27
+ headers: { 'Content-Type': 'application/json', [auth.header]: auth.csrf },
28
+ body: JSON.stringify({ room, socketId: socket.id })
29
+ }, 5000, async response => ({ response, result: await response.json() }))
30
+ if (disposed || expected !== generation || document.hidden) return
31
+ if (response.status === 401) { pause(); auth = null; onUnauthorized(); return }
32
+ if (!response.ok) throw Error(result.error || 'XY-series subscription failed')
33
+ delay = 500
34
+ socket.emit('control-panel:xy-series-subscribe', panelId, result.ticket)
35
+ } catch (_) {
36
+ if (!disposed && expected === generation && auth) {
37
+ retry = setTimeout(connect, delay)
38
+ delay = Math.min(delay * 2, 5000)
39
+ }
40
+ } finally { if (expected === generation) connecting = false }
41
+ }
42
+ const ready = layout => {
43
+ if (disposed || !auth || document.hidden || layout?.panelId !== panelId || layout.room !== room ||
44
+ !Array.isArray(layout.ids) || JSON.stringify(layout.ids) !== JSON.stringify(ids)) return
45
+ stopRendering()
46
+ try {
47
+ renderer = createRenderer({ receiver: createReceiver(layout),
48
+ requestFrame: callback => requestAnimationFrame(callback), cancelFrame: cancelAnimationFrame,
49
+ apply: frame => apply(frame, ids) })
50
+ } catch (_) { stopRendering() }
51
+ }
52
+ const frame = message => {
53
+ if (message?.panelId !== panelId || !auth || document.hidden || !renderer?.receive(message)) return
54
+ clearTimeout(stale)
55
+ stale = setTimeout(invalidate, 1500)
56
+ }
57
+ const ended = message => {
58
+ if (message?.panelId !== panelId) return
59
+ stopRendering()
60
+ if (auth && !disposed && !retry) retry = setTimeout(connect, 500)
61
+ }
62
+ const disconnected = () => { generation++; connecting = false; stopRendering() }
63
+ const visibility = () => { if (document.hidden) pause(); else void connect() }
64
+ socket.on('control-panel:xy-series-ready', ready)
65
+ socket.on('control-panel:xy-series-frame', frame)
66
+ socket.on('control-panel:xy-series-ended', ended)
67
+ socket.on('connect', connect)
68
+ socket.on('disconnect', disconnected)
69
+ document.addEventListener('visibilitychange', visibility)
70
+ return {
71
+ setAuth (next) { pause(); auth = next; delay = 500; if (auth) void connect() },
72
+ close () {
73
+ disposed = true; pause(); auth = null
74
+ socket.off('control-panel:xy-series-ready', ready)
75
+ socket.off('control-panel:xy-series-frame', frame)
76
+ socket.off('control-panel:xy-series-ended', ended)
77
+ socket.off('connect', connect)
78
+ socket.off('disconnect', disconnected)
79
+ document.removeEventListener('visibilitychange', visibility)
80
+ }
81
+ }
82
+ }
83
+
84
+ module.exports = { createPanelXYSeriesClient }
@@ -0,0 +1,186 @@
1
+ const crypto = require('node:crypto')
2
+ const { createXYSeriesReceiver } = require('./xy-series-frame')
3
+ const { readJsonBody, replyJson } = require('./panel-http')
4
+ const { xySeriesRoute } = require('./xy-series-route')
5
+
6
+ function createXYSeriesRoom ({ room, now = Date.now, staleMs = 1000 }) {
7
+ const sources = new Map(), panels = new Map(), ticks = new Set()
8
+ let timer = null, generation = 0
9
+
10
+ function attach (panelId, bindings) {
11
+ if (panels.has(panelId)) throw Error('XY-series panel already registered')
12
+ const entries = Object.entries(bindings).filter(([, entry]) => entry.readMeta?.xySeries)
13
+ .sort(([a], [b]) => a.localeCompare(b))
14
+ if (!entries.length || entries.length > 256) throw Error('Invalid xy-series panel size')
15
+ const projections = entries.map(([id, binding]) => {
16
+ const series = xySeriesRoute(binding.readMeta.xySeries)
17
+ const key = JSON.stringify([binding.device, series.streamId])
18
+ let source = sources.get(key)
19
+ if (source && JSON.stringify(source.route) !== JSON.stringify(series)) {
20
+ throw Error(`Conflicting xy-series stream: ${series.streamId}`)
21
+ }
22
+ if (!source) {
23
+ source = { device: binding.device, route: series, receiver: null,
24
+ receivedAt: null, revision: 0, refs: new Set() }
25
+ sources.set(key, source)
26
+ }
27
+ source.refs.add(panelId)
28
+ return { id, binding, source, key }
29
+ })
30
+ const layout = { kind: 'room-xy-series-layout', panelId, room,
31
+ epoch: crypto.randomBytes(16).toString('hex'), ids: entries.map(([id]) => id) }
32
+ panels.set(panelId, { projections, layout, sequence: 0 })
33
+ return layout
34
+ }
35
+
36
+ function accept (message) {
37
+ if (!['device-xy-series-frame', 'device-xy-series-manifest'].includes(message?.kind)) return false
38
+ const source = sources.get(JSON.stringify([message.device, message.streamId]))
39
+ if (!source || message.path !== (message.kind === 'device-xy-series-frame'
40
+ ? source.route.eventPath : source.route.manifestPath)) return false
41
+ let data = message.payload
42
+ if (data && typeof data === 'object' && Object.hasOwn(data, 'data')) data = data.data
43
+ if (typeof data === 'string') {
44
+ if (Buffer.byteLength(data) > 524288) return false
45
+ try { data = JSON.parse(data) } catch (_) { return false }
46
+ }
47
+ if (message.kind === 'device-xy-series-manifest') {
48
+ let receiver
49
+ try { receiver = createXYSeriesReceiver(data) } catch (_) { return false }
50
+ if (receiver.manifest.streamId !== source.route.streamId) return false
51
+ const previous = source.receiver?.manifest
52
+ if (previous && JSON.stringify(previous) === JSON.stringify(receiver.manifest)) return true
53
+ if (previous?.epoch === receiver.manifest.epoch &&
54
+ receiver.manifest.layoutVersion <= previous.layoutVersion) return false
55
+ source.receiver?.close(); source.receiver = receiver; source.receivedAt = null
56
+ } else {
57
+ if (!source.receiver?.accept(data)) return false
58
+ source.receivedAt = now()
59
+ }
60
+ source.revision = ++generation
61
+ return true
62
+ }
63
+
64
+ function snapshot (panelId) {
65
+ const panel = panels.get(panelId)
66
+ if (!panel) return null
67
+ const time = now(), values = [], valid = [], sourcesByStream = new Map()
68
+ for (const { source, key } of panel.projections) {
69
+ const frame = source.receiver?.snapshot()
70
+ const age = source.receivedAt === null ? null : Math.max(0, time - source.receivedAt)
71
+ const ok = Boolean(frame && age !== null && age <= staleMs)
72
+ values.push(ok ? frame.points.map(point => point.slice()) : [])
73
+ valid.push(ok)
74
+ sourcesByStream.set(key, { device: source.device, streamId: source.route.streamId,
75
+ epoch: source.receiver?.manifest.epoch || '', seq: frame?.seq || 0,
76
+ sampleTime: frame?.sampleTime ?? null, ageMs: age })
77
+ }
78
+ return { kind: 'room-xy-series-frame', panelId, room, epoch: panel.layout.epoch,
79
+ seq: ++panel.sequence, values, valid, sources: [...sourcesByStream.values()] }
80
+ }
81
+
82
+ function revision (panelId) {
83
+ const panel = panels.get(panelId)
84
+ if (!panel) return ''
85
+ const time = now()
86
+ return panel.projections.map(({ source }) => `${source.revision}:${source.receivedAt !== null &&
87
+ time - source.receivedAt <= staleMs}`).join(',')
88
+ }
89
+
90
+ function detach (panelId) {
91
+ const panel = panels.get(panelId)
92
+ if (!panel) return
93
+ panels.delete(panelId)
94
+ for (const { source, key } of panel.projections) {
95
+ source.refs.delete(panelId)
96
+ if (!source.refs.size) { source.receiver?.close(); sources.delete(key) }
97
+ }
98
+ }
99
+
100
+ return { attach, accept, snapshot, revision, detach,
101
+ onTick (callback) {
102
+ ticks.add(callback)
103
+ if (!timer) { timer = setInterval(() => { for (const tick of ticks) tick() }, 1000 / 30); timer.unref?.() }
104
+ return () => {
105
+ ticks.delete(callback)
106
+ if (!ticks.size) { clearInterval(timer); timer = null }
107
+ }
108
+ },
109
+ stats: () => ({ panels: panels.size, sources: sources.size }) }
110
+ }
111
+
112
+ function createPanelXYSeries ({ panelId, room, bindings, hub, onSubscribe = () => {} }) {
113
+ const layout = hub.attach(panelId, bindings)
114
+ const tickets = new Map(), subscribers = new Map()
115
+ let closed = false, lastRevision = ''
116
+ const remove = socketId => {
117
+ const entry = subscribers.get(socketId)
118
+ if (entry) entry.socket.off('disconnect', entry.disconnect)
119
+ subscribers.delete(socketId)
120
+ }
121
+ const live = entry => entry.active() && entry.socket.connected
122
+ const emitFrame = (socket, frame) => socket.volatile.emit('control-panel:xy-series-frame', frame)
123
+ const prune = () => {
124
+ for (const [key, entry] of tickets) if (entry.expiry <= Date.now() || !entry.active()) tickets.delete(key)
125
+ for (const [key, entry] of subscribers) if (!live(entry)) {
126
+ entry.socket.emit('control-panel:xy-series-ended', { panelId }); remove(key)
127
+ }
128
+ }
129
+ const stopTick = hub.onTick(() => {
130
+ prune()
131
+ if (closed || !subscribers.size) return
132
+ const revision = hub.revision(panelId)
133
+ if (revision === lastRevision) return
134
+ lastRevision = revision
135
+ const frame = hub.snapshot(panelId)
136
+ for (const { socket } of subscribers.values()) emitFrame(socket, frame)
137
+ })
138
+ return {
139
+ async ticket (req, res, identity, active) {
140
+ if (!new URL(req.url, 'http://localhost').pathname.endsWith('/xy-series-ticket') || req.method !== 'POST') return false
141
+ if (closed) { replyJson(res, 503, { error: 'XY-series channel unavailable' }); return true }
142
+ prune()
143
+ const body = await readJsonBody(req, 4096)
144
+ if (body.room !== room || typeof body.socketId !== 'string' || !body.socketId || body.socketId.length > 128) {
145
+ replyJson(res, 403, { error: 'XY-series subscription rejected' }); return true
146
+ }
147
+ if (!req.headers.origin) { replyJson(res, 403, { error: 'XY-series subscription needs Origin' }); return true }
148
+ for (const [key, entry] of tickets) if (entry.token === identity.token && entry.socketId === body.socketId) tickets.delete(key)
149
+ if (tickets.size >= 100) { replyJson(res, 429, { error: 'XY-series ticket limit reached' }); return true }
150
+ const ticket = crypto.randomBytes(32).toString('hex')
151
+ tickets.set(ticket, { token: identity.token, socketId: body.socketId,
152
+ expiry: Math.min(identity.expiry, Date.now() + 10000), active })
153
+ replyJson(res, 200, { ticket }); return true
154
+ },
155
+ hooks: {
156
+ 'control-panel:xy-series-subscribe': (socket, id, ticket) => {
157
+ if (id !== panelId || closed) return
158
+ prune()
159
+ const entry = tickets.get(ticket); tickets.delete(ticket)
160
+ if (!entry || !entry.active() || entry.expiry <= Date.now() || entry.socketId !== socket.id ||
161
+ !socket.connected || (!subscribers.has(socket.id) && subscribers.size >= 100)) {
162
+ socket.emit('control-panel:xy-series-ended', { panelId }); return
163
+ }
164
+ remove(socket.id)
165
+ const disconnect = () => remove(socket.id)
166
+ subscribers.set(socket.id, { socket, active: entry.active, disconnect })
167
+ socket.on('disconnect', disconnect)
168
+ socket.emit('control-panel:xy-series-ready', layout)
169
+ emitFrame(socket, hub.snapshot(panelId)); onSubscribe()
170
+ },
171
+ 'control-panel:xy-series-unsubscribe': (socket, id) => { if (id === panelId) remove(socket.id) }
172
+ },
173
+ accept: message => !closed && hub.accept(message),
174
+ stats: () => ({ tickets: tickets.size, subscribers: subscribers.size, ...hub.stats() }),
175
+ close () {
176
+ closed = true; stopTick(); tickets.clear()
177
+ for (const entry of subscribers.values()) {
178
+ entry.socket.emit('control-panel:xy-series-ended', { panelId })
179
+ entry.socket.off('disconnect', entry.disconnect)
180
+ }
181
+ subscribers.clear(); hub.detach(panelId)
182
+ }
183
+ }
184
+ }
185
+
186
+ module.exports = { createXYSeriesRoom, createPanelXYSeries }