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.
@@ -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
- (component === 'v-progress-linear' && max.dynamic)
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 }
@@ -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,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 > 100) throw new Error('At most 100 visual bindings are supported')
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 } : {}),