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.
- package/CONTROL_PANEL_AI_GUIDE.md +172 -29
- package/README.md +8 -0
- package/control-catalog.js +5 -2
- package/control-panel.js +37 -5
- package/control-policy.js +13 -1
- package/control-type-contract.json +1 -0
- package/control-type-model.js +8 -3
- package/gateway.cjs +10 -4
- package/managed-native-bridge.js +2 -1
- package/native-binding-plan.js +61 -7
- package/npm-shrinkwrap.json +2 -2
- package/package.json +8 -1
- package/panel-binding-editor.js +140 -9
- package/panel-button-press.js +46 -0
- package/panel-momentary-client.js +91 -0
- package/panel-momentary.js +93 -0
- package/panel-template-instrumentation.js +21 -1
- package/panel-visual-bindings.js +26 -2
- package/panel-xy-series-client.js +84 -0
- package/panel-xy-series.js +186 -0
- package/xy-series-frame.js +98 -0
- package/xy-series-route.js +11 -0
|
@@ -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
|
-
|
|
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)
|
package/panel-visual-bindings.js
CHANGED
|
@@ -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 }
|