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/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 displayModel (node) {
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, valid: /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(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
- return { model, valid: !directive.arg && modifiers.every(item => allowNumber && item === 'number') &&
94
- /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(model) }
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
- return { model, valid: !hasDirective(node, ['model']) && !bound.modifiers?.length &&
103
- /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(model) }
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 pressedModel (node) {
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
- /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(model) }
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, repeated = false) {
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 insideRepeat = repeated || hasDirective(node, ['for'])
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
- (component === 'v-progress-linear' && max.dynamic)
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 ? 'Repeated controls need a stable item identity' :
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, insideRepeat)
368
+ for (const child of node.children || []) visit(child, activeRepeat)
235
369
  return
236
370
  }
237
- for (const child of node.children || []) visit(child, repeated)
238
- for (const branch of node.branches || []) visit(branch, repeated)
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
- for (const candidate of [...inspected.candidates].reverse()) {
255
- const before = rendered.slice(0, candidate.offset)
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('/') : candidate.offset
258
- rendered = rendered.slice(0, insertAt) + ` data-cp-auto="${candidate.locator}"` + rendered.slice(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 }
@@ -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 > 100) throw conflict('Invalid binding draft')
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)) {
@@ -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
- if (usedLocators.has(entry.locator)) throw new Error(`Duplicate visual control locator: ${id}`)
29
- usedLocators.add(entry.locator)
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 > 100) throw new Error('At most 100 visual bindings are supported')
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
- if (usedLocators.has(raw.locator)) throw new Error(`One element cannot have two bindings: ${raw.id}`)
57
- usedLocators.add(raw.locator)
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 } } : {}),