node-red-contrib-room-tools 1.9.1 → 1.9.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTROL_PANEL_AI_GUIDE.md +147 -36
- package/README.md +16 -4
- package/control-bound-protocol.js +2 -1
- package/control-panel.html +10 -36
- package/control-panel.js +21 -7
- package/control-policy.js +5 -1
- package/control-type-model.js +8 -6
- package/gateway.cjs +33 -15
- package/native-binding-plan.js +4 -2
- package/npm-shrinkwrap.json +2 -2
- package/package.json +8 -1
- package/panel-binding-editor.js +186 -44
- package/panel-button-press.js +46 -0
- package/panel-meter.js +4 -4
- package/panel-momentary-client.js +91 -0
- package/panel-momentary.js +93 -0
- package/panel-telemetry-send.js +13 -0
- package/panel-template-instrumentation.js +1 -0
- package/panel-visual-bindings.js +27 -9
- package/panel-xy-series.js +16 -7
- package/xy-series-frame.js +1 -1
|
@@ -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 }
|
|
@@ -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 }
|
|
@@ -341,6 +341,7 @@ function inspectTemplate (format) {
|
|
|
341
341
|
(enabledBooleanProp(node, ['indeterminate']) || hasAttribute(node, ['min', 'max', 'step']))
|
|
342
342
|
const candidate = {
|
|
343
343
|
locator, adapter,
|
|
344
|
+
...(adapter === 'switch' && node.tag.toLowerCase() === 'button' ? { button: true } : {}),
|
|
344
345
|
...(component ? { component } : {}),
|
|
345
346
|
...(activeRepeat ? { repeat: activeRepeat } : {}),
|
|
346
347
|
label: attribute(node, 'aria-label') || attribute(node, 'label') || attribute(node, 'title') ||
|
package/panel-visual-bindings.js
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
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
|
-
|
|
8
|
+
|
|
9
|
+
function buttonBehavior (entry, candidate) {
|
|
10
|
+
const behavior = entry.buttonBehavior ?? 'toggle'
|
|
11
|
+
if (!['toggle', 'momentary'].includes(behavior) ||
|
|
12
|
+
(entry.buttonBehavior !== undefined && !candidate.button) ||
|
|
13
|
+
(behavior === 'momentary' && entry.mode !== 'both')) {
|
|
14
|
+
throw new Error('Button behavior requires a boolean button; momentary buttons require both mode')
|
|
15
|
+
}
|
|
16
|
+
return behavior
|
|
17
|
+
}
|
|
9
18
|
|
|
10
19
|
function targetKey (candidate, entry) {
|
|
11
20
|
if (!candidate.repeat) return candidate.locator
|
|
@@ -40,10 +49,13 @@ function visualRoutes (config) {
|
|
|
40
49
|
throw new Error(`Visual control moved or became ambiguous: ${id}`)
|
|
41
50
|
}
|
|
42
51
|
const bindingKey = targetKey(candidate, entry)
|
|
52
|
+
const behavior = buttonBehavior(entry, candidate)
|
|
53
|
+
if (behavior === 'momentary' && !['mute', 'power'].includes(entry.type)) {
|
|
54
|
+
throw new Error('Momentary buttons require a boolean control type')
|
|
55
|
+
}
|
|
43
56
|
if (usedLocators.has(bindingKey)) throw new Error(`Duplicate visual control locator: ${id}`)
|
|
44
57
|
usedLocators.add(bindingKey)
|
|
45
|
-
if (!
|
|
46
|
-
(['meter', 'xy-series', 'text-display'].includes(entry.adapter) && entry.mode !== 'read')) {
|
|
58
|
+
if (!bindingModes(entry.adapter).includes(entry.mode) || !entry.type || !entry.label) {
|
|
47
59
|
throw new Error(`Invalid visual binding settings: ${id}`)
|
|
48
60
|
}
|
|
49
61
|
if (entry.readMeta?.meter) {
|
|
@@ -62,6 +74,7 @@ function visualRoutes (config) {
|
|
|
62
74
|
routes.set(id, {
|
|
63
75
|
control: { id, type: entry.type, mode: entry.mode, label: entry.label,
|
|
64
76
|
adapter: entry.adapter, locator: entry.locator,
|
|
77
|
+
...(candidate.button ? { buttonBehavior: behavior } : {}),
|
|
65
78
|
...(candidate.repeat ? { instanceKey: entry.instanceKey } : {}) },
|
|
66
79
|
binding: entry
|
|
67
80
|
})
|
|
@@ -78,15 +91,17 @@ function validatedVisualBindings ({ format, entries, devices, targetsByDevice })
|
|
|
78
91
|
const used = new Set()
|
|
79
92
|
const usedLocators = new Set()
|
|
80
93
|
const models = new Map()
|
|
94
|
+
const targetIndexes = new Map()
|
|
81
95
|
for (const raw of entries) {
|
|
82
96
|
if (!raw || !ID.test(raw.id) || used.has(raw.id)) throw new Error('Control IDs must be unique and valid')
|
|
83
97
|
used.add(raw.id)
|
|
84
98
|
const candidate = candidates.get(raw.locator)
|
|
85
99
|
if (!candidate || candidate.adapter !== raw.adapter) throw new Error(`Control changed: ${raw.id}`)
|
|
86
100
|
const bindingKey = targetKey(candidate, raw)
|
|
101
|
+
const behavior = buttonBehavior(raw, candidate)
|
|
87
102
|
if (usedLocators.has(bindingKey)) throw new Error(`One element cannot have two bindings: ${raw.id}`)
|
|
88
103
|
usedLocators.add(bindingKey)
|
|
89
|
-
if (!
|
|
104
|
+
if (!bindingModes(raw.adapter).includes(raw.mode)) {
|
|
90
105
|
throw new Error(`Invalid binding mode: ${raw.id}`)
|
|
91
106
|
}
|
|
92
107
|
if (typeof raw.label !== 'string' || !raw.label.trim() || raw.label.length > 80) {
|
|
@@ -95,7 +110,8 @@ function validatedVisualBindings ({ format, entries, devices, targetsByDevice })
|
|
|
95
110
|
if (!knownDevices.has(raw.device)) throw new Error(`Unknown device: ${raw.id}`)
|
|
96
111
|
const targets = targetsByDevice.get(raw.device)
|
|
97
112
|
if (!targets) throw new Error(`Device catalog unavailable: ${raw.device}`)
|
|
98
|
-
|
|
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)
|
|
99
115
|
const write = raw.mode === 'read' ? null : byPath.get(raw.writePath)
|
|
100
116
|
const read = raw.mode === 'write' ? null : byPath.get(raw.readPath)
|
|
101
117
|
if ((raw.mode !== 'read' && !write) || (raw.mode !== 'write' && !read)) {
|
|
@@ -115,13 +131,15 @@ function validatedVisualBindings ({ format, entries, devices, targetsByDevice })
|
|
|
115
131
|
const identity = JSON.stringify([raw.device, read?.path, write?.path])
|
|
116
132
|
const modelKey = candidate.repeat ? `${candidate.model}#${raw.instanceKey}` : candidate.model
|
|
117
133
|
const previous = models.get(modelKey)
|
|
118
|
-
|
|
119
|
-
|
|
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`)
|
|
120
137
|
}
|
|
121
|
-
models.set(modelKey, { identity,
|
|
138
|
+
models.set(modelKey, { identity, realtime })
|
|
122
139
|
}
|
|
123
140
|
result[raw.id] = {
|
|
124
141
|
locator: raw.locator, adapter: raw.adapter, type, label: raw.label.trim(), mode: raw.mode,
|
|
142
|
+
...(candidate.button ? { buttonBehavior: behavior } : {}),
|
|
125
143
|
...(candidate.model ? { model: candidate.model } : {}),
|
|
126
144
|
...(candidate.repeat ? { repeat: candidate.repeat, instanceKey: raw.instanceKey } : {}),
|
|
127
145
|
...(candidate.component ? { component: candidate.component } : {}),
|
package/panel-xy-series.js
CHANGED
|
@@ -2,6 +2,7 @@ const crypto = require('node:crypto')
|
|
|
2
2
|
const { createXYSeriesReceiver } = require('./xy-series-frame')
|
|
3
3
|
const { readJsonBody, replyJson } = require('./panel-http')
|
|
4
4
|
const { xySeriesRoute } = require('./xy-series-route')
|
|
5
|
+
const { emitTelemetryFrame } = require('./panel-telemetry-send')
|
|
5
6
|
|
|
6
7
|
function createXYSeriesRoom ({ room, now = Date.now, staleMs = 1000 }) {
|
|
7
8
|
const sources = new Map(), panels = new Map(), ticks = new Set()
|
|
@@ -12,13 +13,21 @@ function createXYSeriesRoom ({ room, now = Date.now, staleMs = 1000 }) {
|
|
|
12
13
|
const entries = Object.entries(bindings).filter(([, entry]) => entry.readMeta?.xySeries)
|
|
13
14
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
14
15
|
if (!entries.length || entries.length > 256) throw Error('Invalid xy-series panel size')
|
|
15
|
-
|
|
16
|
+
// Validate the whole registration before changing shared sources or references.
|
|
17
|
+
const proposed = new Map()
|
|
18
|
+
for (const [, binding] of entries) {
|
|
16
19
|
const series = xySeriesRoute(binding.readMeta.xySeries)
|
|
17
20
|
const key = JSON.stringify([binding.device, series.streamId])
|
|
18
|
-
|
|
19
|
-
if (
|
|
21
|
+
const previous = sources.get(key)?.route || proposed.get(key)
|
|
22
|
+
if (previous && JSON.stringify(previous) !== JSON.stringify(series)) {
|
|
20
23
|
throw Error(`Conflicting xy-series stream: ${series.streamId}`)
|
|
21
24
|
}
|
|
25
|
+
proposed.set(key, series)
|
|
26
|
+
}
|
|
27
|
+
const projections = entries.map(([id, binding]) => {
|
|
28
|
+
const series = xySeriesRoute(binding.readMeta.xySeries)
|
|
29
|
+
const key = JSON.stringify([binding.device, series.streamId])
|
|
30
|
+
let source = sources.get(key)
|
|
22
31
|
if (!source) {
|
|
23
32
|
source = { device: binding.device, route: series, receiver: null,
|
|
24
33
|
receivedAt: null, revision: 0, refs: new Set() }
|
|
@@ -29,7 +38,7 @@ function createXYSeriesRoom ({ room, now = Date.now, staleMs = 1000 }) {
|
|
|
29
38
|
})
|
|
30
39
|
const layout = { kind: 'room-xy-series-layout', panelId, room,
|
|
31
40
|
epoch: crypto.randomBytes(16).toString('hex'), ids: entries.map(([id]) => id) }
|
|
32
|
-
panels.set(panelId, { projections, layout, sequence: 0 })
|
|
41
|
+
panels.set(panelId, { projections, streams: [...new Set(projections.map(point => point.source))], layout, sequence: 0 })
|
|
33
42
|
return layout
|
|
34
43
|
}
|
|
35
44
|
|
|
@@ -69,7 +78,7 @@ function createXYSeriesRoom ({ room, now = Date.now, staleMs = 1000 }) {
|
|
|
69
78
|
const frame = source.receiver?.snapshot()
|
|
70
79
|
const age = source.receivedAt === null ? null : Math.max(0, time - source.receivedAt)
|
|
71
80
|
const ok = Boolean(frame && age !== null && age <= staleMs)
|
|
72
|
-
values.push(ok ? frame.points.map(point => point
|
|
81
|
+
values.push(ok ? frame.points.map(point => [point[0], point[1]]) : [])
|
|
73
82
|
valid.push(ok)
|
|
74
83
|
sourcesByStream.set(key, { device: source.device, streamId: source.route.streamId,
|
|
75
84
|
epoch: source.receiver?.manifest.epoch || '', seq: frame?.seq || 0,
|
|
@@ -83,7 +92,7 @@ function createXYSeriesRoom ({ room, now = Date.now, staleMs = 1000 }) {
|
|
|
83
92
|
const panel = panels.get(panelId)
|
|
84
93
|
if (!panel) return ''
|
|
85
94
|
const time = now()
|
|
86
|
-
return panel.
|
|
95
|
+
return panel.streams.map(source => `${source.revision}:${source.receivedAt !== null &&
|
|
87
96
|
time - source.receivedAt <= staleMs}`).join(',')
|
|
88
97
|
}
|
|
89
98
|
|
|
@@ -133,7 +142,7 @@ function createPanelXYSeries ({ panelId, room, bindings, hub, onSubscribe = () =
|
|
|
133
142
|
if (revision === lastRevision) return
|
|
134
143
|
lastRevision = revision
|
|
135
144
|
const frame = hub.snapshot(panelId)
|
|
136
|
-
|
|
145
|
+
emitTelemetryFrame('control-panel:xy-series-frame', frame, subscribers)
|
|
137
146
|
})
|
|
138
147
|
return {
|
|
139
148
|
async ticket (req, res, identity, active) {
|
package/xy-series-frame.js
CHANGED
|
@@ -85,7 +85,7 @@ function createRoomXYSeriesReceiver (layout) {
|
|
|
85
85
|
(valid && points.some(point => !Array.isArray(point) || point.length !== 2 ||
|
|
86
86
|
!finite(point[0]) || !finite(point[1])))) return false
|
|
87
87
|
}
|
|
88
|
-
latest = { values: frame.values.map(points => points.map(point => point
|
|
88
|
+
latest = { values: frame.values.map(points => points.map(point => [point[0], point[1]])),
|
|
89
89
|
valid: frame.valid.slice(), seq: frame.seq }
|
|
90
90
|
pending = true
|
|
91
91
|
return true
|