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/CONTROL_PANEL_AI_GUIDE.md +58 -18
- package/README.md +28 -6
- package/control-bound-protocol.js +25 -7
- package/control-catalog.js +26 -3
- package/control-panel.html +10 -36
- package/control-panel.js +37 -9
- package/control-policy.js +34 -5
- package/control-type-model.js +73 -11
- package/device-availability.js +104 -0
- package/gateway.cjs +36 -12
- package/managed-native-bridge.js +3 -1
- package/meter-frame.js +7 -1
- package/native-binding-plan.js +51 -12
- package/npm-shrinkwrap.json +2 -2
- package/package.json +6 -1
- package/panel-binding-editor.js +311 -62
- package/panel-meter.js +4 -4
- package/panel-momentary.js +7 -1
- package/panel-telemetry-send.js +13 -0
- package/panel-template-instrumentation.js +1 -0
- package/panel-visual-apply.js +86 -27
- package/panel-visual-bindings.js +26 -11
- package/panel-xy-series.js +16 -7
- package/xy-series-frame.js +8 -2
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.
|
|
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
|
-
|
|
154
|
+
emitTelemetryFrame('control-panel:meter-frame', frame, subscribers)
|
|
155
155
|
})
|
|
156
156
|
|
|
157
157
|
return {
|
package/panel-momentary.js
CHANGED
|
@@ -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'
|
package/panel-visual-apply.js
CHANGED
|
@@ -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
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
if (
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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 }
|
package/panel-visual-bindings.js
CHANGED
|
@@ -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 (!
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
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,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
|
-
|
|
89
|
-
|
|
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
|
},
|