node-red-contrib-room-tools 1.8.15 → 1.9.0
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 +224 -100
- package/README.md +3 -1
- package/control-bound-protocol.js +1 -1
- package/control-catalog.js +5 -2
- package/control-panel.js +33 -2
- 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-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 +8 -2
- package/panel-binding-editor.js +209 -37
- package/panel-editor-auth.js +5 -2
- package/panel-http.js +3 -2
- package/panel-meter-client.js +84 -0
- package/panel-meter.js +213 -0
- package/panel-template-instrumentation.js +181 -31
- package/panel-visual-apply.js +1 -1
- package/panel-visual-bindings.js +43 -6
package/panel-meter.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
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
|
+
const initial = hub.snapshot(panelId)
|
|
192
|
+
emitFrame(socket, initial)
|
|
193
|
+
onSubscribe()
|
|
194
|
+
},
|
|
195
|
+
'control-panel:meter-unsubscribe': (socket, id) => { if (id === panelId) remove(socket.id) }
|
|
196
|
+
},
|
|
197
|
+
accept: message => !closed && hub.accept(message),
|
|
198
|
+
stats: () => ({ tickets: tickets.size, subscribers: subscribers.size, ...hub.stats() }),
|
|
199
|
+
close () {
|
|
200
|
+
closed = true
|
|
201
|
+
stopTick()
|
|
202
|
+
tickets.clear()
|
|
203
|
+
for (const entry of subscribers.values()) {
|
|
204
|
+
entry.socket.emit('control-panel:meter-ended', { panelId })
|
|
205
|
+
entry.socket.off('disconnect', entry.disconnect)
|
|
206
|
+
}
|
|
207
|
+
subscribers.clear()
|
|
208
|
+
hub.detach(panelId)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
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({
|
|
@@ -8,6 +8,14 @@ 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 SIMPLE_IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/
|
|
12
|
+
const SIMPLE_PROPERTY_PATH = /^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*$/
|
|
13
|
+
const UNSAFE_PROPERTY_NAMES = new Set(['__proto__', 'prototype', 'constructor'])
|
|
14
|
+
|
|
15
|
+
function safePropertyPath (value) {
|
|
16
|
+
return SIMPLE_PROPERTY_PATH.test(value) &&
|
|
17
|
+
value.split('.').every(part => !UNSAFE_PROPERTY_NAMES.has(part))
|
|
18
|
+
}
|
|
11
19
|
|
|
12
20
|
function attribute (node, name) {
|
|
13
21
|
const prop = (node.props || []).find(item => item.type === 6 && item.name === name)
|
|
@@ -78,38 +86,139 @@ function staticNumericProp (node, name) {
|
|
|
78
86
|
return { value: numeric, dynamic: !Number.isFinite(numeric) }
|
|
79
87
|
}
|
|
80
88
|
|
|
81
|
-
function
|
|
89
|
+
function repeatModelInfo (expression, repeat) {
|
|
90
|
+
const model = String(expression || '').trim()
|
|
91
|
+
if (SIMPLE_IDENTIFIER.test(model)) return { valid: true, repeatModel: null }
|
|
92
|
+
if (!repeat?.valid) return { valid: false, repeatModel: null }
|
|
93
|
+
const indexedPrefix = repeat.index ? `${repeat.source}[${repeat.index}].` : ''
|
|
94
|
+
if (indexedPrefix && model.startsWith(indexedPrefix)) {
|
|
95
|
+
const field = model.slice(indexedPrefix.length)
|
|
96
|
+
if (safePropertyPath(field)) {
|
|
97
|
+
return { valid: true, repeatModel: { kind: 'source-index-property', field } }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (repeat.kind === 'item') {
|
|
101
|
+
const escaped = repeat.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
102
|
+
const match = model.match(new RegExp('^' + escaped + '\\.' +
|
|
103
|
+
'(' + SIMPLE_PROPERTY_PATH.source.slice(1, -1) + ')$'))
|
|
104
|
+
return match && safePropertyPath(match[1])
|
|
105
|
+
? { valid: true, repeatModel: { kind: 'item-property', field: match[1] } }
|
|
106
|
+
: { valid: false, repeatModel: null }
|
|
107
|
+
}
|
|
108
|
+
return model === `${repeat.source}[${repeat.index}]`
|
|
109
|
+
? { valid: true, repeatModel: { kind: 'array-index' } }
|
|
110
|
+
: { valid: false, repeatModel: null }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function displayModel (node, repeat) {
|
|
82
114
|
const dynamic = (node.children || []).filter(child => child.type === 5)
|
|
83
115
|
if (!dynamic.length) return { model: '', valid: true }
|
|
84
116
|
const expression = dynamic.length === 1 ? dynamic[0].content?.content?.trim() : ''
|
|
85
|
-
return { model: expression,
|
|
117
|
+
return { model: expression, ...repeatModelInfo(expression, repeat) }
|
|
86
118
|
}
|
|
87
119
|
|
|
88
|
-
function inputModel (node, allowNumber = false) {
|
|
120
|
+
function inputModel (node, allowNumber = false, repeat) {
|
|
89
121
|
const directive = (node.props || []).find(prop => prop.type === 7 && prop.name === 'model')
|
|
90
122
|
if (!directive) return { model: '', valid: true }
|
|
91
123
|
const model = directive.exp?.content?.trim() || ''
|
|
92
124
|
const modifiers = (directive.modifiers || []).map(item => typeof item === 'string' ? item : item.content)
|
|
93
|
-
|
|
94
|
-
|
|
125
|
+
const info = repeatModelInfo(model, repeat)
|
|
126
|
+
return { model, repeatModel: info.repeatModel,
|
|
127
|
+
valid: !directive.arg && modifiers.every(item => allowNumber && item === 'number') && info.valid }
|
|
95
128
|
}
|
|
96
129
|
|
|
97
|
-
function progressModel (node) {
|
|
130
|
+
function progressModel (node, repeat) {
|
|
98
131
|
const bound = (node.props || []).find(prop => prop.type === 7 && prop.name === 'bind' &&
|
|
99
132
|
['model-value', 'modelValue'].includes(prop.arg?.content))
|
|
100
|
-
if (!bound) return inputModel(node)
|
|
133
|
+
if (!bound) return inputModel(node, false, repeat)
|
|
101
134
|
const model = bound.exp?.content?.trim() || ''
|
|
102
|
-
|
|
103
|
-
|
|
135
|
+
const info = repeatModelInfo(model, repeat)
|
|
136
|
+
return { model, repeatModel: info.repeatModel,
|
|
137
|
+
valid: !hasDirective(node, ['model']) && !bound.modifiers?.length && info.valid }
|
|
104
138
|
}
|
|
105
139
|
|
|
106
|
-
function
|
|
140
|
+
function nativeMeterModel (node, repeat) {
|
|
141
|
+
const modelDirective = (node.props || []).find(prop => prop.type === 7 && prop.name === 'model')
|
|
142
|
+
if (modelDirective) {
|
|
143
|
+
const model = modelDirective.exp?.content?.trim() || ''
|
|
144
|
+
const info = repeatModelInfo(model, repeat)
|
|
145
|
+
return { model, repeatModel: info.repeatModel,
|
|
146
|
+
valid: !modelDirective.arg && !modelDirective.modifiers?.length && info.valid }
|
|
147
|
+
}
|
|
148
|
+
const bound = (node.props || []).find(prop => prop.type === 7 && prop.name === 'bind' &&
|
|
149
|
+
prop.arg?.content === 'value')
|
|
150
|
+
if (!bound) return { model: '', valid: true }
|
|
151
|
+
const model = bound.exp?.content?.trim() || ''
|
|
152
|
+
const info = repeatModelInfo(model, repeat)
|
|
153
|
+
return { model, repeatModel: info.repeatModel,
|
|
154
|
+
valid: !bound.modifiers?.length && info.valid }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function repeatDescriptor (node) {
|
|
158
|
+
const directive = (node.props || []).find(prop => prop.type === 7 && prop.name === 'for')
|
|
159
|
+
if (!directive) return null
|
|
160
|
+
const parsed = directive.forParseResult
|
|
161
|
+
const source = parsed?.source?.content?.trim() || ''
|
|
162
|
+
const value = parsed?.value?.content?.trim() || ''
|
|
163
|
+
const index = parsed?.key?.content?.trim() || ''
|
|
164
|
+
const keyProp = (node.props || []).find(prop => prop.type === 7 && prop.name === 'bind' &&
|
|
165
|
+
prop.arg?.content === 'key' && !prop.modifiers?.length)
|
|
166
|
+
const key = keyProp?.exp?.content?.trim() || ''
|
|
167
|
+
const locator = `repeat:${hash(`${node.tag}:${node.loc.start.offset}:${directive.exp?.content || ''}`)}`
|
|
168
|
+
let kind = ''
|
|
169
|
+
let reason = ''
|
|
170
|
+
if (!SIMPLE_IDENTIFIER.test(source)) reason = 'v-for source must be one top-level array field'
|
|
171
|
+
else if (!SIMPLE_IDENTIFIER.test(value)) reason = 'v-for item alias must be a simple identifier'
|
|
172
|
+
else if (index && !SIMPLE_IDENTIFIER.test(index)) reason = 'v-for index alias must be a simple identifier'
|
|
173
|
+
else if (!key) reason = 'Repeated controls need a stable :key'
|
|
174
|
+
else if (key === `${value}.id`) kind = 'item'
|
|
175
|
+
else if (index && key === index) kind = 'array'
|
|
176
|
+
else reason = index
|
|
177
|
+
? 'Object lists need :key="item.id" or array lists need :key="index"'
|
|
178
|
+
: 'Item v-for bindings require :key="item.id"'
|
|
179
|
+
return { locator, source, value, ...(index ? { index } : {}), key, kind,
|
|
180
|
+
markerOnControl: node.tag.toLowerCase() === 'template',
|
|
181
|
+
valid: !reason, ...(reason ? { reason } : {}) }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function normalizeMeterVModels (format) {
|
|
185
|
+
const parsed = parse(format, { filename: 'ControlPanel.vue' })
|
|
186
|
+
if (parsed.errors.length || !parsed.descriptor.template?.ast) return format
|
|
187
|
+
const replacements = []
|
|
188
|
+
function visit (node) {
|
|
189
|
+
if (node.type === 1 && ['meter', 'progress'].includes(node.tag.toLowerCase())) {
|
|
190
|
+
const model = (node.props || []).find(prop => prop.type === 7 && prop.name === 'model')
|
|
191
|
+
if (model) {
|
|
192
|
+
const expression = model.exp?.content?.trim() || ''
|
|
193
|
+
if (model.arg || model.modifiers?.length || !/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(expression)) {
|
|
194
|
+
throw new Error('Native meter/progress v-model must be a simple top-level field without modifiers')
|
|
195
|
+
}
|
|
196
|
+
if ((node.props || []).some(prop => prop.type === 7 && prop.name === 'bind' &&
|
|
197
|
+
prop.arg?.content === 'value')) {
|
|
198
|
+
throw new Error('Native meter/progress v-model cannot be combined with :value')
|
|
199
|
+
}
|
|
200
|
+
replacements.push({ start: model.loc.start.offset, end: model.loc.end.offset,
|
|
201
|
+
value: `:value="${expression}"` })
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
for (const child of node.children || []) visit(child)
|
|
205
|
+
}
|
|
206
|
+
visit(parsed.descriptor.template.ast)
|
|
207
|
+
let normalized = format
|
|
208
|
+
for (const replacement of replacements.reverse()) {
|
|
209
|
+
normalized = normalized.slice(0, replacement.start) + replacement.value + normalized.slice(replacement.end)
|
|
210
|
+
}
|
|
211
|
+
return normalized
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function pressedModel (node, repeat) {
|
|
107
215
|
const directive = (node.props || []).find(prop => prop.type === 7 && prop.name === 'bind' &&
|
|
108
216
|
['aria-pressed', 'aria-checked'].includes(prop.arg?.content))
|
|
109
217
|
if (!directive) return { model: '', valid: true }
|
|
110
218
|
const model = directive.exp?.content?.trim() || ''
|
|
219
|
+
const info = repeatModelInfo(model, repeat)
|
|
111
220
|
return { model, valid: !directive.modifiers?.length &&
|
|
112
|
-
|
|
221
|
+
info.valid, repeatModel: info.repeatModel }
|
|
113
222
|
}
|
|
114
223
|
|
|
115
224
|
function openingTagEnd (source, start) {
|
|
@@ -130,15 +239,37 @@ function inspectTemplate (format) {
|
|
|
130
239
|
}
|
|
131
240
|
const { descriptor, errors } = parse(format, { filename: 'ControlPanel.vue' })
|
|
132
241
|
if (errors.length) throw new Error('Invalid ControlPanel template: ' + errors[0].message)
|
|
242
|
+
// Parsing alone accepts directives that Vue cannot render (for example meter v-model).
|
|
243
|
+
if (descriptor.template) {
|
|
244
|
+
const normalized = normalizeMeterVModels(format)
|
|
245
|
+
const normalizedDescriptor = parse(normalized, { filename: 'ControlPanel.vue' }).descriptor
|
|
246
|
+
const compiled = compileTemplate({ source: normalizedDescriptor.template.content,
|
|
247
|
+
filename: 'ControlPanel.vue', id: 'control-panel' })
|
|
248
|
+
if (compiled.errors.length) {
|
|
249
|
+
const error = compiled.errors[0]
|
|
250
|
+
const line = error.loc?.start?.line
|
|
251
|
+
const location = line ? ` at line ${line + descriptor.template.loc.start.line - 1}` : ''
|
|
252
|
+
throw new Error(`Invalid ControlPanel template${location}: ${error.message || error}`)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
133
255
|
const candidates = []
|
|
134
256
|
const counts = new Map()
|
|
257
|
+
const repeatMarkers = []
|
|
135
258
|
|
|
136
|
-
function visit (node,
|
|
259
|
+
function visit (node, repeatContext = null) {
|
|
137
260
|
if (node.type === 1) {
|
|
138
261
|
if (node.tag.toLowerCase().startsWith('v-') && node.isSelfClosing) {
|
|
139
262
|
throw new Error(`Vuetify component <${node.tag}> needs an explicit closing tag in Dashboard templates`)
|
|
140
263
|
}
|
|
141
|
-
const
|
|
264
|
+
const ownRepeat = repeatDescriptor(node)
|
|
265
|
+
const activeRepeat = ownRepeat
|
|
266
|
+
? repeatContext ? { ...ownRepeat, valid: false, reason: 'Nested v-for bindings are not supported' } : ownRepeat
|
|
267
|
+
: repeatContext
|
|
268
|
+
const insideRepeat = Boolean(activeRepeat)
|
|
269
|
+
if (ownRepeat?.valid && !repeatContext && !ownRepeat.markerOnControl) {
|
|
270
|
+
repeatMarkers.push({ offset: openingTagEnd(format, node.loc.start.offset),
|
|
271
|
+
locator: ownRepeat.locator, key: ownRepeat.key })
|
|
272
|
+
}
|
|
142
273
|
const adapter = candidateAdapter(node)
|
|
143
274
|
const customAttribute = (node.props || []).some(prop =>
|
|
144
275
|
(prop.type === 6 && prop.name.startsWith('data-cp-')) ||
|
|
@@ -147,11 +278,12 @@ function inspectTemplate (format) {
|
|
|
147
278
|
if (adapter) {
|
|
148
279
|
const component = COMPONENT_ADAPTERS[node.tag.toLowerCase()] ? node.tag.toLowerCase() : ''
|
|
149
280
|
const display = adapter === 'text-display'
|
|
150
|
-
? displayModel(node)
|
|
151
|
-
: ['v-progress-linear', 'v-progress-circular'].includes(component) ? progressModel(node)
|
|
281
|
+
? displayModel(node, activeRepeat)
|
|
282
|
+
: ['v-progress-linear', 'v-progress-circular'].includes(component) ? progressModel(node, activeRepeat)
|
|
283
|
+
: adapter === 'meter' && !component ? nativeMeterModel(node, activeRepeat)
|
|
152
284
|
: adapter === 'switch' && node.tag.toLowerCase() === 'button'
|
|
153
|
-
? pressedModel(node)
|
|
154
|
-
: inputModel(node, adapter === 'range' || adapter === 'select')
|
|
285
|
+
? pressedModel(node, activeRepeat)
|
|
286
|
+
: inputModel(node, adapter === 'range' || adapter === 'select', activeRepeat)
|
|
155
287
|
const source = format.slice(node.loc.start.offset, openingTagEnd(format, node.loc.start.offset) + 1)
|
|
156
288
|
const stableName = attribute(node, 'id') || attribute(node, 'name')
|
|
157
289
|
const locator = stableName
|
|
@@ -161,6 +293,7 @@ function inspectTemplate (format) {
|
|
|
161
293
|
const explicitLogic = hasDirective(node, ['on']) ||
|
|
162
294
|
(node.props || []).some(prop => prop.type === 7 && prop.name === 'bind' &&
|
|
163
295
|
['value', 'checked', 'model-value', 'modelValue'].includes(prop.arg?.content) &&
|
|
296
|
+
!(adapter === 'meter' && !component && prop.arg?.content === 'value') &&
|
|
164
297
|
!(['v-progress-linear', 'v-progress-circular'].includes(component) &&
|
|
165
298
|
['model-value', 'modelValue'].includes(prop.arg?.content)))
|
|
166
299
|
const nestedLogic = component === 'v-radio-group' && hasNestedHandlers(node)
|
|
@@ -170,7 +303,7 @@ function inspectTemplate (format) {
|
|
|
170
303
|
const max = staticNumericProp(node, 'max')
|
|
171
304
|
const step = staticNumericProp(node, 'step')
|
|
172
305
|
const dynamicBounds = (adapter === 'range' && (min.dynamic || max.dynamic || step.dynamic)) ||
|
|
173
|
-
(
|
|
306
|
+
(adapter === 'meter' && (min.dynamic || max.dynamic))
|
|
174
307
|
const invalidSliderBounds = component === 'v-slider' &&
|
|
175
308
|
((min.value ?? 0) >= (max.value ?? 100) ||
|
|
176
309
|
(step.value !== undefined && step.value <= 0) || attribute(node, 'step') === 'any')
|
|
@@ -194,6 +327,7 @@ function inspectTemplate (format) {
|
|
|
194
327
|
const candidate = {
|
|
195
328
|
locator, adapter,
|
|
196
329
|
...(component ? { component } : {}),
|
|
330
|
+
...(activeRepeat ? { repeat: activeRepeat } : {}),
|
|
197
331
|
label: attribute(node, 'aria-label') || attribute(node, 'label') || attribute(node, 'title') ||
|
|
198
332
|
stableName || display.model || (adapter === 'text-display' ? node.children[0]?.content?.trim() : '') || node.tag,
|
|
199
333
|
...(display.model ? { model: display.model } : {}),
|
|
@@ -209,12 +343,12 @@ function inspectTemplate (format) {
|
|
|
209
343
|
} : {}),
|
|
210
344
|
...(attribute(node, 'data-unit') ? { unit: attribute(node, 'data-unit') } : {}),
|
|
211
345
|
line: node.loc.start.line,
|
|
212
|
-
supported: !insideRepeat && !explicitLogic && display.valid && !dynamicBounds &&
|
|
346
|
+
supported: (!insideRepeat || Boolean(activeRepeat.valid && display.valid)) && !explicitLogic && display.valid && !dynamicBounds &&
|
|
213
347
|
!invalidSliderBounds &&
|
|
214
348
|
!customSwitchValues && !unsupportedSelect && !unsupportedRadio &&
|
|
215
349
|
!unsupportedTextType && !unsupportedProgress && !unsupportedCircular &&
|
|
216
350
|
(!component || Boolean(display.model)),
|
|
217
|
-
reason: insideRepeat
|
|
351
|
+
reason: insideRepeat && !activeRepeat.valid ? activeRepeat.reason :
|
|
218
352
|
explicitLogic ? 'Controls with existing handlers or reactive value bindings need a dedicated adapter' :
|
|
219
353
|
!display.valid ? (adapter === 'text-display' ? 'Dynamic text needs one writable data field' :
|
|
220
354
|
'Only a simple Vue v-model field is supported') :
|
|
@@ -231,11 +365,11 @@ function inspectTemplate (format) {
|
|
|
231
365
|
candidates.push({ ...candidate, offset: openingTagEnd(format, node.loc.start.offset) })
|
|
232
366
|
counts.set(locator, (counts.get(locator) || 0) + 1)
|
|
233
367
|
}
|
|
234
|
-
for (const child of node.children || []) visit(child,
|
|
368
|
+
for (const child of node.children || []) visit(child, activeRepeat)
|
|
235
369
|
return
|
|
236
370
|
}
|
|
237
|
-
for (const child of node.children || []) visit(child,
|
|
238
|
-
for (const branch of node.branches || []) visit(branch,
|
|
371
|
+
for (const child of node.children || []) visit(child, repeatContext)
|
|
372
|
+
for (const branch of node.branches || []) visit(branch, repeatContext)
|
|
239
373
|
}
|
|
240
374
|
|
|
241
375
|
if (descriptor.template?.ast) visit(descriptor.template.ast)
|
|
@@ -245,23 +379,39 @@ function inspectTemplate (format) {
|
|
|
245
379
|
candidate.reason = 'This element is ambiguous; give it a unique HTML id or name'
|
|
246
380
|
}
|
|
247
381
|
}
|
|
248
|
-
return { candidates, templateHash: hash(format) }
|
|
382
|
+
return { candidates, repeatMarkers, templateHash: hash(format) }
|
|
249
383
|
}
|
|
250
384
|
|
|
251
385
|
function instrumentTemplate (format) {
|
|
252
386
|
const inspected = inspectTemplate(format)
|
|
253
387
|
let rendered = format
|
|
254
|
-
|
|
255
|
-
|
|
388
|
+
const insertions = new Map()
|
|
389
|
+
const addInsertion = (offset, value) => {
|
|
390
|
+
const list = insertions.get(offset) || []
|
|
391
|
+
list.push(value)
|
|
392
|
+
insertions.set(offset, list)
|
|
393
|
+
}
|
|
394
|
+
for (const candidate of inspected.candidates) {
|
|
395
|
+
addInsertion(candidate.offset, `data-cp-auto="${candidate.locator}"`)
|
|
396
|
+
if (candidate.repeat?.markerOnControl) {
|
|
397
|
+
addInsertion(candidate.offset,
|
|
398
|
+
`data-cp-repeat="${candidate.repeat.locator}" :data-cp-repeat-key="String(${candidate.repeat.key})"`)
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
for (const marker of inspected.repeatMarkers) {
|
|
402
|
+
addInsertion(marker.offset, `data-cp-repeat="${marker.locator}" :data-cp-repeat-key="String(${marker.key})"`)
|
|
403
|
+
}
|
|
404
|
+
for (const [offset, attributes] of [...insertions.entries()].sort((a, b) => b[0] - a[0])) {
|
|
405
|
+
const before = rendered.slice(0, offset)
|
|
256
406
|
const selfClosing = /\/\s*$/.test(before)
|
|
257
|
-
const insertAt = selfClosing ? before.lastIndexOf('/') :
|
|
258
|
-
rendered = rendered.slice(0, insertAt) +
|
|
407
|
+
const insertAt = selfClosing ? before.lastIndexOf('/') : offset
|
|
408
|
+
rendered = rendered.slice(0, insertAt) + ' ' + attributes.join(' ') + rendered.slice(insertAt)
|
|
259
409
|
}
|
|
260
410
|
return {
|
|
261
|
-
format: rendered,
|
|
411
|
+
format: normalizeMeterVModels(rendered),
|
|
262
412
|
candidates: inspected.candidates.map(({ offset, ...candidate }) => candidate),
|
|
263
413
|
templateHash: inspected.templateHash
|
|
264
414
|
}
|
|
265
415
|
}
|
|
266
416
|
|
|
267
|
-
module.exports = { inspectTemplate, instrumentTemplate }
|
|
417
|
+
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,9 +1,22 @@
|
|
|
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}$/
|
|
6
|
+
const INSTANCE_KEY = /^[A-Za-z0-9._:/-]{1,160}$/
|
|
5
7
|
const MODES = new Set(['read', 'write', 'both'])
|
|
6
8
|
|
|
9
|
+
function targetKey (candidate, entry) {
|
|
10
|
+
if (!candidate.repeat) return candidate.locator
|
|
11
|
+
if (typeof entry.instanceKey !== 'string' || !INSTANCE_KEY.test(entry.instanceKey)) {
|
|
12
|
+
throw new Error('Repeated visual controls need a stable instance key')
|
|
13
|
+
}
|
|
14
|
+
if (candidate.repeat.kind === 'array' && !/^(0|[1-9][0-9]*)$/.test(entry.instanceKey)) {
|
|
15
|
+
throw new Error('Array repeated controls need a non-negative integer instance key')
|
|
16
|
+
}
|
|
17
|
+
return `${candidate.locator}#${entry.instanceKey}`
|
|
18
|
+
}
|
|
19
|
+
|
|
7
20
|
function parseVisualBindings (config) {
|
|
8
21
|
let raw = config.visualBindings || '{}'
|
|
9
22
|
if (typeof raw === 'string') raw = JSON.parse(raw)
|
|
@@ -25,15 +38,24 @@ function visualRoutes (config) {
|
|
|
25
38
|
if (!candidate || candidate.adapter !== entry.adapter) {
|
|
26
39
|
throw new Error(`Visual control moved or became ambiguous: ${id}`)
|
|
27
40
|
}
|
|
28
|
-
|
|
29
|
-
usedLocators.
|
|
41
|
+
const bindingKey = targetKey(candidate, entry)
|
|
42
|
+
if (usedLocators.has(bindingKey)) throw new Error(`Duplicate visual control locator: ${id}`)
|
|
43
|
+
usedLocators.add(bindingKey)
|
|
30
44
|
if (!MODES.has(entry.mode) || !entry.type || !entry.label ||
|
|
31
45
|
(['meter', 'text-display'].includes(entry.adapter) && entry.mode !== 'read')) {
|
|
32
46
|
throw new Error(`Invalid visual binding settings: ${id}`)
|
|
33
47
|
}
|
|
48
|
+
if (entry.readMeta?.meter) {
|
|
49
|
+
meterRoute(entry.readMeta.meter)
|
|
50
|
+
if (entry.adapter !== 'meter' || entry.mode !== 'read' || entry.readMeta.access !== 'ro' ||
|
|
51
|
+
!['integer', 'float'].includes(entry.readMeta.type) ||
|
|
52
|
+
!Number.isFinite(entry.readMeta.min) || !Number.isFinite(entry.readMeta.max) ||
|
|
53
|
+
entry.readMeta.min >= entry.readMeta.max) throw Error(`Invalid meter binding: ${id}`)
|
|
54
|
+
}
|
|
34
55
|
routes.set(id, {
|
|
35
56
|
control: { id, type: entry.type, mode: entry.mode, label: entry.label,
|
|
36
|
-
adapter: entry.adapter, locator: entry.locator
|
|
57
|
+
adapter: entry.adapter, locator: entry.locator,
|
|
58
|
+
...(candidate.repeat ? { instanceKey: entry.instanceKey } : {}) },
|
|
37
59
|
binding: entry
|
|
38
60
|
})
|
|
39
61
|
}
|
|
@@ -41,20 +63,22 @@ function visualRoutes (config) {
|
|
|
41
63
|
}
|
|
42
64
|
|
|
43
65
|
function validatedVisualBindings ({ format, entries, devices, targetsByDevice }) {
|
|
44
|
-
if (!Array.isArray(entries) || entries.length >
|
|
66
|
+
if (!Array.isArray(entries) || entries.length > 256) throw new Error('At most 256 visual bindings are supported')
|
|
45
67
|
const candidates = new Map(inspectTemplate(format).candidates
|
|
46
68
|
.filter(item => item.supported).map(item => [item.locator, item]))
|
|
47
69
|
const knownDevices = new Map(devices.map(device => [device.id, device]))
|
|
48
70
|
const result = Object.create(null)
|
|
49
71
|
const used = new Set()
|
|
50
72
|
const usedLocators = new Set()
|
|
73
|
+
const models = new Map()
|
|
51
74
|
for (const raw of entries) {
|
|
52
75
|
if (!raw || !ID.test(raw.id) || used.has(raw.id)) throw new Error('Control IDs must be unique and valid')
|
|
53
76
|
used.add(raw.id)
|
|
54
77
|
const candidate = candidates.get(raw.locator)
|
|
55
78
|
if (!candidate || candidate.adapter !== raw.adapter) throw new Error(`Control changed: ${raw.id}`)
|
|
56
|
-
|
|
57
|
-
usedLocators.
|
|
79
|
+
const bindingKey = targetKey(candidate, raw)
|
|
80
|
+
if (usedLocators.has(bindingKey)) throw new Error(`One element cannot have two bindings: ${raw.id}`)
|
|
81
|
+
usedLocators.add(bindingKey)
|
|
58
82
|
if (!MODES.has(raw.mode) || (['meter', 'text-display'].includes(raw.adapter) && raw.mode !== 'read')) {
|
|
59
83
|
throw new Error(`Invalid binding mode: ${raw.id}`)
|
|
60
84
|
}
|
|
@@ -77,9 +101,22 @@ function validatedVisualBindings ({ format, entries, devices, targetsByDevice })
|
|
|
77
101
|
}
|
|
78
102
|
const target = write || read
|
|
79
103
|
const type = controlTypeForAdapter(raw.adapter, target)
|
|
104
|
+
if (read?.meter && (!Number.isFinite(read.min) || !Number.isFinite(read.max) || read.min >= read.max)) {
|
|
105
|
+
throw Error(`Meter needs a finite device range: ${raw.id}`)
|
|
106
|
+
}
|
|
107
|
+
if (candidate.model) {
|
|
108
|
+
const identity = JSON.stringify([raw.device, read?.path, write?.path])
|
|
109
|
+
const modelKey = candidate.repeat ? `${candidate.model}#${raw.instanceKey}` : candidate.model
|
|
110
|
+
const previous = models.get(modelKey)
|
|
111
|
+
if (previous && (read?.meter || previous.meter) && previous.identity !== identity) {
|
|
112
|
+
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`)
|
|
113
|
+
}
|
|
114
|
+
models.set(modelKey, { identity, meter: Boolean(read?.meter) })
|
|
115
|
+
}
|
|
80
116
|
result[raw.id] = {
|
|
81
117
|
locator: raw.locator, adapter: raw.adapter, type, label: raw.label.trim(), mode: raw.mode,
|
|
82
118
|
...(candidate.model ? { model: candidate.model } : {}),
|
|
119
|
+
...(candidate.repeat ? { repeat: candidate.repeat, instanceKey: raw.instanceKey } : {}),
|
|
83
120
|
...(candidate.component ? { component: candidate.component } : {}),
|
|
84
121
|
...(['range', 'meter'].includes(raw.adapter)
|
|
85
122
|
? { uiRange: { min: candidate.min, max: candidate.max, step: candidate.step } } : {}),
|