node-red-contrib-room-tools 1.8.9 → 1.8.16
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 +28 -11
- package/README.md +3 -1
- package/control-bound-protocol.js +1 -1
- package/control-catalog.js +10 -5
- package/control-panel.html +6 -1
- package/control-panel.js +47 -12
- package/control-policy.js +16 -7
- package/control-type-contract.json +1 -0
- package/control-type-model.js +6 -1
- package/gateway.cjs +4 -0
- package/managed-bridge-revision.js +1 -1
- package/managed-native-bridge.js +2 -1
- package/meter-frame.js +111 -0
- package/meter-route.js +12 -0
- package/native-binding-plan.js +56 -6
- package/npm-shrinkwrap.json +2 -2
- package/package.json +6 -1
- package/panel-binding-editor.js +84 -11
- package/panel-editor-auth.js +5 -2
- package/panel-flow-editor-sync.js +53 -0
- package/panel-http.js +27 -4
- package/panel-meter-client.js +84 -0
- package/panel-meter.js +212 -0
- package/panel-template-instrumentation.js +62 -4
- package/panel-visual-apply.js +1 -1
- package/panel-visual-bindings.js +21 -1
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Self-contained: serialized as a Vue method, with factories supplied by the node.
|
|
2
|
+
function createPanelMeterClient ({ socket, panelId, room, ids, request, createReceiver,
|
|
3
|
+
createRenderer, apply, invalidate, onUnauthorized }) {
|
|
4
|
+
if (!socket) throw Error('Dashboard socket is unavailable for meter 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:meter-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 + '/meter-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 || 'Meter subscription failed')
|
|
33
|
+
delay = 500
|
|
34
|
+
socket.emit('control-panel:meter-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:meter-ready', ready)
|
|
65
|
+
socket.on('control-panel:meter-frame', frame)
|
|
66
|
+
socket.on('control-panel:meter-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:meter-ready', ready)
|
|
75
|
+
socket.off('control-panel:meter-frame', frame)
|
|
76
|
+
socket.off('control-panel:meter-ended', ended)
|
|
77
|
+
socket.off('connect', connect)
|
|
78
|
+
socket.off('disconnect', disconnected)
|
|
79
|
+
document.removeEventListener('visibilitychange', visibility)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = { createPanelMeterClient }
|
package/panel-meter.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
const crypto = require('node:crypto')
|
|
2
|
+
const { createMeterReceiver } = require('./meter-frame')
|
|
3
|
+
const { readJsonBody, replyJson } = require('./panel-http')
|
|
4
|
+
const { meterRoute } = require('./meter-route')
|
|
5
|
+
|
|
6
|
+
function createMeterRoom ({ room, now = Date.now, staleMs = 1000 }) {
|
|
7
|
+
const sources = new Map(), panels = new Map()
|
|
8
|
+
const ticks = new Set()
|
|
9
|
+
let timer = null
|
|
10
|
+
let generation = 0
|
|
11
|
+
|
|
12
|
+
function attach (panelId, bindings) {
|
|
13
|
+
if (panels.has(panelId)) throw Error('Meter panel already registered')
|
|
14
|
+
const points = Object.entries(bindings).filter(([, entry]) => entry.readMeta?.meter)
|
|
15
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
16
|
+
if (!points.length || points.length > 256) throw Error('Invalid meter panel size')
|
|
17
|
+
const proposed = new Map()
|
|
18
|
+
for (const [, binding] of points) {
|
|
19
|
+
const meter = meterRoute(binding.readMeta.meter)
|
|
20
|
+
const key = JSON.stringify([binding.device, meter.streamId])
|
|
21
|
+
const previous = sources.get(key)?.route || proposed.get(key)
|
|
22
|
+
if (previous && JSON.stringify(previous) !== JSON.stringify(meter)) throw Error('Conflicting meter stream')
|
|
23
|
+
proposed.set(key, meter)
|
|
24
|
+
}
|
|
25
|
+
const projections = points.map(([id, binding]) => {
|
|
26
|
+
const meter = meterRoute(binding.readMeta.meter)
|
|
27
|
+
const key = JSON.stringify([binding.device, meter.streamId])
|
|
28
|
+
let source = sources.get(key)
|
|
29
|
+
if (source && JSON.stringify(source.route) !== JSON.stringify(meter)) throw Error('Conflicting meter stream')
|
|
30
|
+
if (!source) {
|
|
31
|
+
source = { device: binding.device, route: meter, receiver: null, receivedAt: null,
|
|
32
|
+
revision: 0, refs: new Set(), indices: new Map() }
|
|
33
|
+
sources.set(key, source)
|
|
34
|
+
}
|
|
35
|
+
source.refs.add(panelId)
|
|
36
|
+
return { id, binding, source, key, pointPath: binding.readPath.replace(/^\/\//, '/') }
|
|
37
|
+
})
|
|
38
|
+
const layout = { kind: 'room-meter-layout', panelId, room, epoch: crypto.randomBytes(16).toString('hex'),
|
|
39
|
+
ids: points.map(([id]) => id) }
|
|
40
|
+
const panel = { projections, layout, sequence: 0 }
|
|
41
|
+
panels.set(panelId, panel)
|
|
42
|
+
return layout
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function accept (message) {
|
|
46
|
+
if (!['device-meter-frame', 'device-meter-manifest'].includes(message?.kind)) return false
|
|
47
|
+
const source = sources.get(JSON.stringify([message.device, message.streamId]))
|
|
48
|
+
if (!source || message.path !== (message.kind === 'device-meter-frame'
|
|
49
|
+
? source.route.eventPath : source.route.manifestPath)) return false
|
|
50
|
+
let data = message.payload
|
|
51
|
+
// AMX Event emits its arguments object; Status emits the string directly.
|
|
52
|
+
if (data && typeof data === 'object' && Object.hasOwn(data, 'data')) data = data.data
|
|
53
|
+
if (typeof data === 'string') {
|
|
54
|
+
if (Buffer.byteLength(data) > 131072) return false
|
|
55
|
+
try { data = JSON.parse(data) } catch (_) { return false }
|
|
56
|
+
}
|
|
57
|
+
if (message.kind === 'device-meter-manifest') {
|
|
58
|
+
let receiver
|
|
59
|
+
try { receiver = createMeterReceiver(data) } catch (_) { return false }
|
|
60
|
+
if (receiver.manifest.streamId !== source.route.streamId) return false
|
|
61
|
+
const previous = source.receiver?.manifest
|
|
62
|
+
if (previous && JSON.stringify(previous) === JSON.stringify(receiver.manifest)) return true
|
|
63
|
+
if (previous?.epoch === receiver.manifest.epoch &&
|
|
64
|
+
receiver.manifest.layoutVersion <= previous.layoutVersion) return false
|
|
65
|
+
source.receiver?.close()
|
|
66
|
+
source.receiver = receiver
|
|
67
|
+
source.indices = new Map(receiver.manifest.points.map((point, index) => [point.path, index]))
|
|
68
|
+
source.receivedAt = null
|
|
69
|
+
} else {
|
|
70
|
+
if (!source.receiver?.accept(data)) return false
|
|
71
|
+
source.receivedAt = now()
|
|
72
|
+
}
|
|
73
|
+
source.revision = ++generation
|
|
74
|
+
return true
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function snapshot (panelId) {
|
|
78
|
+
const panel = panels.get(panelId)
|
|
79
|
+
if (!panel) return null
|
|
80
|
+
const time = now(), values = [], valid = [], versions = new Map()
|
|
81
|
+
for (const { binding, source, key, pointPath } of panel.projections) {
|
|
82
|
+
const manifest = source.receiver?.manifest
|
|
83
|
+
const frame = source.receiver?.snapshot()
|
|
84
|
+
const index = source.indices.get(pointPath)
|
|
85
|
+
const point = manifest?.points[index]
|
|
86
|
+
const age = source.receivedAt === null ? null : Math.max(0, time - source.receivedAt)
|
|
87
|
+
const matches = point && point.type === binding.readMeta.type && point.min === binding.readMeta.min &&
|
|
88
|
+
point.max === binding.readMeta.max && point.unit === (binding.readMeta.unit || '')
|
|
89
|
+
const flag = Boolean(matches && frame?.valid[index] && age !== null && age <= staleMs)
|
|
90
|
+
values.push(flag ? frame.values[index] : null)
|
|
91
|
+
valid.push(flag)
|
|
92
|
+
versions.set(key, { device: source.device, streamId: source.route.streamId,
|
|
93
|
+
epoch: manifest?.epoch || '', seq: frame?.seq || 0, sampleTime: frame?.sampleTime ?? null, ageMs: age })
|
|
94
|
+
}
|
|
95
|
+
return { kind: 'room-meter-frame', panelId, room, epoch: panel.layout.epoch,
|
|
96
|
+
seq: ++panel.sequence, values, valid, sources: [...versions.values()] }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function revision (panelId) {
|
|
100
|
+
const panel = panels.get(panelId)
|
|
101
|
+
if (!panel) return ''
|
|
102
|
+
const time = now()
|
|
103
|
+
return panel.projections.map(({ source }) => `${source.revision}:${source.receivedAt !== null &&
|
|
104
|
+
time - source.receivedAt <= staleMs}`).join(',')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function detach (panelId) {
|
|
108
|
+
const panel = panels.get(panelId)
|
|
109
|
+
if (!panel) return
|
|
110
|
+
panels.delete(panelId)
|
|
111
|
+
for (const { source, key } of panel.projections) {
|
|
112
|
+
source.refs.delete(panelId)
|
|
113
|
+
if (!source.refs.size) { source.receiver?.close(); sources.delete(key) }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return { attach, accept, snapshot, revision, detach,
|
|
118
|
+
onTick (callback) {
|
|
119
|
+
ticks.add(callback)
|
|
120
|
+
if (!timer) { timer = setInterval(() => { for (const tick of ticks) tick() }, 1000 / 30); timer.unref?.() }
|
|
121
|
+
return () => {
|
|
122
|
+
ticks.delete(callback)
|
|
123
|
+
if (!ticks.size) { clearInterval(timer); timer = null }
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
stats: () => ({ panels: panels.size, sources: sources.size }) }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function createPanelMeter ({ panelId, room, bindings, hub, onSubscribe = () => {} }) {
|
|
130
|
+
const layout = hub.attach(panelId, bindings)
|
|
131
|
+
const tickets = new Map(), subscribers = new Map()
|
|
132
|
+
let closed = false, lastRevision = ''
|
|
133
|
+
const remove = socketId => {
|
|
134
|
+
const entry = subscribers.get(socketId)
|
|
135
|
+
if (entry) entry.socket.off('disconnect', entry.disconnect)
|
|
136
|
+
subscribers.delete(socketId)
|
|
137
|
+
}
|
|
138
|
+
const live = entry => entry.active() && entry.socket.connected
|
|
139
|
+
const emitFrame = (socket, frame) => socket.volatile.emit('control-panel:meter-frame', frame)
|
|
140
|
+
const prune = () => {
|
|
141
|
+
for (const [key, entry] of tickets) if (entry.expiry <= Date.now() || !entry.active()) tickets.delete(key)
|
|
142
|
+
for (const [key, entry] of subscribers) if (!live(entry)) {
|
|
143
|
+
entry.socket.emit('control-panel:meter-ended', { panelId })
|
|
144
|
+
remove(key)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const stopTick = hub.onTick(() => {
|
|
148
|
+
prune()
|
|
149
|
+
if (closed || !subscribers.size) return
|
|
150
|
+
const revision = hub.revision(panelId)
|
|
151
|
+
if (revision === lastRevision) return
|
|
152
|
+
lastRevision = revision
|
|
153
|
+
const frame = hub.snapshot(panelId)
|
|
154
|
+
for (const { socket } of subscribers.values()) emitFrame(socket, frame)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
async ticket (req, res, identity, active) {
|
|
159
|
+
if (!new URL(req.url, 'http://localhost').pathname.endsWith('/meter-ticket') || req.method !== 'POST') return false
|
|
160
|
+
if (closed) { replyJson(res, 503, { error: 'Meter channel unavailable' }); return true }
|
|
161
|
+
prune()
|
|
162
|
+
const body = await readJsonBody(req, 4096)
|
|
163
|
+
if (body.room !== room || typeof body.socketId !== 'string' || !body.socketId || body.socketId.length > 128) {
|
|
164
|
+
replyJson(res, 403, { error: 'Meter subscription rejected' }); return true
|
|
165
|
+
}
|
|
166
|
+
if (!req.headers.origin) { replyJson(res, 403, { error: 'Meter subscription needs Origin' }); return true }
|
|
167
|
+
// One outstanding ticket per session and socket keeps retrying clients bounded.
|
|
168
|
+
for (const [key, entry] of tickets) if (entry.token === identity.token && entry.socketId === body.socketId) tickets.delete(key)
|
|
169
|
+
if (tickets.size >= 100) { replyJson(res, 429, { error: 'Meter ticket limit reached' }); return true }
|
|
170
|
+
const ticket = crypto.randomBytes(32).toString('hex')
|
|
171
|
+
tickets.set(ticket, { token: identity.token, socketId: body.socketId,
|
|
172
|
+
expiry: Math.min(identity.expiry, Date.now() + 10000), active })
|
|
173
|
+
replyJson(res, 200, { ticket })
|
|
174
|
+
return true
|
|
175
|
+
},
|
|
176
|
+
hooks: {
|
|
177
|
+
'control-panel:meter-subscribe': (socket, id, ticket) => {
|
|
178
|
+
if (id !== panelId || closed) return
|
|
179
|
+
prune()
|
|
180
|
+
const entry = tickets.get(ticket)
|
|
181
|
+
tickets.delete(ticket)
|
|
182
|
+
if (!entry || !entry.active() || entry.expiry <= Date.now() || entry.socketId !== socket.id || !socket.connected ||
|
|
183
|
+
(!subscribers.has(socket.id) && subscribers.size >= 100)) {
|
|
184
|
+
socket.emit('control-panel:meter-ended', { panelId }); return
|
|
185
|
+
}
|
|
186
|
+
remove(socket.id)
|
|
187
|
+
const disconnect = () => remove(socket.id)
|
|
188
|
+
subscribers.set(socket.id, { socket, active: entry.active, disconnect })
|
|
189
|
+
socket.on('disconnect', disconnect)
|
|
190
|
+
socket.emit('control-panel:meter-ready', layout)
|
|
191
|
+
emitFrame(socket, hub.snapshot(panelId))
|
|
192
|
+
onSubscribe()
|
|
193
|
+
},
|
|
194
|
+
'control-panel:meter-unsubscribe': (socket, id) => { if (id === panelId) remove(socket.id) }
|
|
195
|
+
},
|
|
196
|
+
accept: message => !closed && hub.accept(message),
|
|
197
|
+
stats: () => ({ tickets: tickets.size, subscribers: subscribers.size, ...hub.stats() }),
|
|
198
|
+
close () {
|
|
199
|
+
closed = true
|
|
200
|
+
stopTick()
|
|
201
|
+
tickets.clear()
|
|
202
|
+
for (const entry of subscribers.values()) {
|
|
203
|
+
entry.socket.emit('control-panel:meter-ended', { panelId })
|
|
204
|
+
entry.socket.off('disconnect', entry.disconnect)
|
|
205
|
+
}
|
|
206
|
+
subscribers.clear()
|
|
207
|
+
hub.detach(panelId)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
module.exports = { createMeterRoom, createPanelMeter }
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
const crypto = require('node:crypto')
|
|
2
|
-
const { parse } = require('@vue/compiler-sfc')
|
|
2
|
+
const { parse, compileTemplate } = require('@vue/compiler-sfc')
|
|
3
3
|
|
|
4
4
|
const hash = value => crypto.createHash('sha256').update(value).digest('hex').slice(0, 20)
|
|
5
5
|
const COMPONENT_ADAPTERS = Object.freeze({
|
|
@@ -103,6 +103,49 @@ function progressModel (node) {
|
|
|
103
103
|
/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(model) }
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
function nativeMeterModel (node) {
|
|
107
|
+
const modelDirective = (node.props || []).find(prop => prop.type === 7 && prop.name === 'model')
|
|
108
|
+
if (modelDirective) {
|
|
109
|
+
return { model: modelDirective.exp?.content?.trim() || '', valid: !modelDirective.arg && !modelDirective.modifiers?.length &&
|
|
110
|
+
/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(modelDirective.exp?.content?.trim() || '') }
|
|
111
|
+
}
|
|
112
|
+
const bound = (node.props || []).find(prop => prop.type === 7 && prop.name === 'bind' &&
|
|
113
|
+
prop.arg?.content === 'value')
|
|
114
|
+
if (!bound) return { model: '', valid: true }
|
|
115
|
+
const model = bound.exp?.content?.trim() || ''
|
|
116
|
+
return { model, valid: !bound.modifiers?.length && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(model) }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function normalizeMeterVModels (format) {
|
|
120
|
+
const parsed = parse(format, { filename: 'ControlPanel.vue' })
|
|
121
|
+
if (parsed.errors.length || !parsed.descriptor.template?.ast) return format
|
|
122
|
+
const replacements = []
|
|
123
|
+
function visit (node) {
|
|
124
|
+
if (node.type === 1 && ['meter', 'progress'].includes(node.tag.toLowerCase())) {
|
|
125
|
+
const model = (node.props || []).find(prop => prop.type === 7 && prop.name === 'model')
|
|
126
|
+
if (model) {
|
|
127
|
+
const expression = model.exp?.content?.trim() || ''
|
|
128
|
+
if (model.arg || model.modifiers?.length || !/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(expression)) {
|
|
129
|
+
throw new Error('Native meter/progress v-model must be a simple top-level field without modifiers')
|
|
130
|
+
}
|
|
131
|
+
if ((node.props || []).some(prop => prop.type === 7 && prop.name === 'bind' &&
|
|
132
|
+
prop.arg?.content === 'value')) {
|
|
133
|
+
throw new Error('Native meter/progress v-model cannot be combined with :value')
|
|
134
|
+
}
|
|
135
|
+
replacements.push({ start: model.loc.start.offset, end: model.loc.end.offset,
|
|
136
|
+
value: `:value="${expression}"` })
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
for (const child of node.children || []) visit(child)
|
|
140
|
+
}
|
|
141
|
+
visit(parsed.descriptor.template.ast)
|
|
142
|
+
let normalized = format
|
|
143
|
+
for (const replacement of replacements.reverse()) {
|
|
144
|
+
normalized = normalized.slice(0, replacement.start) + replacement.value + normalized.slice(replacement.end)
|
|
145
|
+
}
|
|
146
|
+
return normalized
|
|
147
|
+
}
|
|
148
|
+
|
|
106
149
|
function pressedModel (node) {
|
|
107
150
|
const directive = (node.props || []).find(prop => prop.type === 7 && prop.name === 'bind' &&
|
|
108
151
|
['aria-pressed', 'aria-checked'].includes(prop.arg?.content))
|
|
@@ -130,6 +173,19 @@ function inspectTemplate (format) {
|
|
|
130
173
|
}
|
|
131
174
|
const { descriptor, errors } = parse(format, { filename: 'ControlPanel.vue' })
|
|
132
175
|
if (errors.length) throw new Error('Invalid ControlPanel template: ' + errors[0].message)
|
|
176
|
+
// Parsing alone accepts directives that Vue cannot render (for example meter v-model).
|
|
177
|
+
if (descriptor.template) {
|
|
178
|
+
const normalized = normalizeMeterVModels(format)
|
|
179
|
+
const normalizedDescriptor = parse(normalized, { filename: 'ControlPanel.vue' }).descriptor
|
|
180
|
+
const compiled = compileTemplate({ source: normalizedDescriptor.template.content,
|
|
181
|
+
filename: 'ControlPanel.vue', id: 'control-panel' })
|
|
182
|
+
if (compiled.errors.length) {
|
|
183
|
+
const error = compiled.errors[0]
|
|
184
|
+
const line = error.loc?.start?.line
|
|
185
|
+
const location = line ? ` at line ${line + descriptor.template.loc.start.line - 1}` : ''
|
|
186
|
+
throw new Error(`Invalid ControlPanel template${location}: ${error.message || error}`)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
133
189
|
const candidates = []
|
|
134
190
|
const counts = new Map()
|
|
135
191
|
|
|
@@ -149,6 +205,7 @@ function inspectTemplate (format) {
|
|
|
149
205
|
const display = adapter === 'text-display'
|
|
150
206
|
? displayModel(node)
|
|
151
207
|
: ['v-progress-linear', 'v-progress-circular'].includes(component) ? progressModel(node)
|
|
208
|
+
: adapter === 'meter' && !component ? nativeMeterModel(node)
|
|
152
209
|
: adapter === 'switch' && node.tag.toLowerCase() === 'button'
|
|
153
210
|
? pressedModel(node)
|
|
154
211
|
: inputModel(node, adapter === 'range' || adapter === 'select')
|
|
@@ -161,6 +218,7 @@ function inspectTemplate (format) {
|
|
|
161
218
|
const explicitLogic = hasDirective(node, ['on']) ||
|
|
162
219
|
(node.props || []).some(prop => prop.type === 7 && prop.name === 'bind' &&
|
|
163
220
|
['value', 'checked', 'model-value', 'modelValue'].includes(prop.arg?.content) &&
|
|
221
|
+
!(adapter === 'meter' && !component && prop.arg?.content === 'value') &&
|
|
164
222
|
!(['v-progress-linear', 'v-progress-circular'].includes(component) &&
|
|
165
223
|
['model-value', 'modelValue'].includes(prop.arg?.content)))
|
|
166
224
|
const nestedLogic = component === 'v-radio-group' && hasNestedHandlers(node)
|
|
@@ -170,7 +228,7 @@ function inspectTemplate (format) {
|
|
|
170
228
|
const max = staticNumericProp(node, 'max')
|
|
171
229
|
const step = staticNumericProp(node, 'step')
|
|
172
230
|
const dynamicBounds = (adapter === 'range' && (min.dynamic || max.dynamic || step.dynamic)) ||
|
|
173
|
-
(
|
|
231
|
+
(adapter === 'meter' && (min.dynamic || max.dynamic))
|
|
174
232
|
const invalidSliderBounds = component === 'v-slider' &&
|
|
175
233
|
((min.value ?? 0) >= (max.value ?? 100) ||
|
|
176
234
|
(step.value !== undefined && step.value <= 0) || attribute(node, 'step') === 'any')
|
|
@@ -258,10 +316,10 @@ function instrumentTemplate (format) {
|
|
|
258
316
|
rendered = rendered.slice(0, insertAt) + ` data-cp-auto="${candidate.locator}"` + rendered.slice(insertAt)
|
|
259
317
|
}
|
|
260
318
|
return {
|
|
261
|
-
format: rendered,
|
|
319
|
+
format: normalizeMeterVModels(rendered),
|
|
262
320
|
candidates: inspected.candidates.map(({ offset, ...candidate }) => candidate),
|
|
263
321
|
templateHash: inspected.templateHash
|
|
264
322
|
}
|
|
265
323
|
}
|
|
266
324
|
|
|
267
|
-
module.exports = { inspectTemplate, instrumentTemplate }
|
|
325
|
+
module.exports = { inspectTemplate, instrumentTemplate, normalizeMeterVModels }
|
package/panel-visual-apply.js
CHANGED
|
@@ -82,7 +82,7 @@ async function applyVisualBindings ({
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
const devices = await listCatalogDevices(catalogBase, fetcher)
|
|
85
|
-
if (!Array.isArray(entries) || entries.length >
|
|
85
|
+
if (!Array.isArray(entries) || entries.length > 256) throw conflict('Invalid binding draft')
|
|
86
86
|
const deviceIds = [...new Set(entries.map(entry => entry?.device))]
|
|
87
87
|
for (const id of deviceIds) {
|
|
88
88
|
if (!devices.some(device => device.id === id && device.online)) {
|
package/panel-visual-bindings.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const { inspectTemplate } = require('./panel-template-instrumentation')
|
|
2
2
|
const { compatibleTarget, controlTypeForAdapter, targetMeta } = require('./control-type-model')
|
|
3
|
+
const { meterRoute } = require('./meter-route')
|
|
3
4
|
|
|
4
5
|
const ID = /^[A-Za-z][A-Za-z0-9._/-]{0,127}$/
|
|
5
6
|
const MODES = new Set(['read', 'write', 'both'])
|
|
@@ -31,6 +32,13 @@ function visualRoutes (config) {
|
|
|
31
32
|
(['meter', 'text-display'].includes(entry.adapter) && entry.mode !== 'read')) {
|
|
32
33
|
throw new Error(`Invalid visual binding settings: ${id}`)
|
|
33
34
|
}
|
|
35
|
+
if (entry.readMeta?.meter) {
|
|
36
|
+
meterRoute(entry.readMeta.meter)
|
|
37
|
+
if (entry.adapter !== 'meter' || entry.mode !== 'read' || entry.readMeta.access !== 'ro' ||
|
|
38
|
+
!['integer', 'float'].includes(entry.readMeta.type) ||
|
|
39
|
+
!Number.isFinite(entry.readMeta.min) || !Number.isFinite(entry.readMeta.max) ||
|
|
40
|
+
entry.readMeta.min >= entry.readMeta.max) throw Error(`Invalid meter binding: ${id}`)
|
|
41
|
+
}
|
|
34
42
|
routes.set(id, {
|
|
35
43
|
control: { id, type: entry.type, mode: entry.mode, label: entry.label,
|
|
36
44
|
adapter: entry.adapter, locator: entry.locator },
|
|
@@ -41,13 +49,14 @@ function visualRoutes (config) {
|
|
|
41
49
|
}
|
|
42
50
|
|
|
43
51
|
function validatedVisualBindings ({ format, entries, devices, targetsByDevice }) {
|
|
44
|
-
if (!Array.isArray(entries) || entries.length >
|
|
52
|
+
if (!Array.isArray(entries) || entries.length > 256) throw new Error('At most 256 visual bindings are supported')
|
|
45
53
|
const candidates = new Map(inspectTemplate(format).candidates
|
|
46
54
|
.filter(item => item.supported).map(item => [item.locator, item]))
|
|
47
55
|
const knownDevices = new Map(devices.map(device => [device.id, device]))
|
|
48
56
|
const result = Object.create(null)
|
|
49
57
|
const used = new Set()
|
|
50
58
|
const usedLocators = new Set()
|
|
59
|
+
const models = new Map()
|
|
51
60
|
for (const raw of entries) {
|
|
52
61
|
if (!raw || !ID.test(raw.id) || used.has(raw.id)) throw new Error('Control IDs must be unique and valid')
|
|
53
62
|
used.add(raw.id)
|
|
@@ -77,6 +86,17 @@ function validatedVisualBindings ({ format, entries, devices, targetsByDevice })
|
|
|
77
86
|
}
|
|
78
87
|
const target = write || read
|
|
79
88
|
const type = controlTypeForAdapter(raw.adapter, target)
|
|
89
|
+
if (read?.meter && (!Number.isFinite(read.min) || !Number.isFinite(read.max) || read.min >= read.max)) {
|
|
90
|
+
throw Error(`Meter needs a finite device range: ${raw.id}`)
|
|
91
|
+
}
|
|
92
|
+
if (candidate.model) {
|
|
93
|
+
const identity = JSON.stringify([raw.device, read?.path, write?.path])
|
|
94
|
+
const previous = models.get(candidate.model)
|
|
95
|
+
if (previous && (read?.meter || previous.meter) && previous.identity !== identity) {
|
|
96
|
+
throw Error(`Meter model "${candidate.model}" is already used by another binding; use a separate Vue model for the meter. Meter and volume controls must not share a model`)
|
|
97
|
+
}
|
|
98
|
+
models.set(candidate.model, { identity, meter: Boolean(read?.meter) })
|
|
99
|
+
}
|
|
80
100
|
result[raw.id] = {
|
|
81
101
|
locator: raw.locator, adapter: raw.adapter, type, label: raw.label.trim(), mode: raw.mode,
|
|
82
102
|
...(candidate.model ? { model: candidate.model } : {}),
|