node-red-contrib-room-tools 1.8.16 → 1.9.1

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.
@@ -8,6 +8,15 @@ 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 XY_SERIES_CONTAINER_TAGS = new Set(['div', 'section', 'article', 'main', 'aside', 'svg', 'g'])
12
+ const SIMPLE_IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/
13
+ const SIMPLE_PROPERTY_PATH = /^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*$/
14
+ const UNSAFE_PROPERTY_NAMES = new Set(['__proto__', 'prototype', 'constructor'])
15
+
16
+ function safePropertyPath (value) {
17
+ return SIMPLE_PROPERTY_PATH.test(value) &&
18
+ value.split('.').every(part => !UNSAFE_PROPERTY_NAMES.has(part))
19
+ }
11
20
 
12
21
  function attribute (node, name) {
13
22
  const prop = (node.props || []).find(item => item.type === 6 && item.name === name)
@@ -55,6 +64,8 @@ function candidateAdapter (node) {
55
64
  if (tag === 'button' && (attribute(node, 'aria-pressed') !== '' || attribute(node, 'role') === 'switch' ||
56
65
  hasBoundAttribute(node, ['aria-pressed', 'aria-checked']))) return 'switch'
57
66
  if (tag === 'meter' || tag === 'progress') return 'meter'
67
+ if (tag === 'canvas' && hasBoundAttribute(node, ['data-points', 'data-series'])) return 'xy-series'
68
+ if (XY_SERIES_CONTAINER_TAGS.has(tag) && hasBoundAttribute(node, ['data-coordinates'])) return 'xy-series'
58
69
  if (['span', 'strong', 'b', 'output', 'h1', 'h2', 'h3'].includes(tag) &&
59
70
  node.children?.length === 1 && node.children[0].type === 2 &&
60
71
  node.children[0].content.trim()) return 'text-display'
@@ -63,6 +74,17 @@ function candidateAdapter (node) {
63
74
  return null
64
75
  }
65
76
 
77
+ function xySeriesModel (node) {
78
+ const tag = node.tag.toLowerCase()
79
+ const names = tag === 'canvas' ? ['data-points', 'data-series'] : ['data-coordinates']
80
+ const bound = (node.props || []).find(prop => prop.type === 7 && prop.name === 'bind' &&
81
+ names.includes(prop.arg?.content))
82
+ if (!bound || bound.modifiers?.length) return { model: '', valid: false, attribute: names[0] }
83
+ const model = bound.exp?.content?.trim() || ''
84
+ return { model, valid: SIMPLE_IDENTIFIER.test(model), attribute: bound.arg.content,
85
+ presentation: tag === 'canvas' ? 'points' : 'coordinates' }
86
+ }
87
+
66
88
  function staticNumericProp (node, name) {
67
89
  const bound = (node.props || []).find(prop => prop.type === 7 && prop.name === 'bind' &&
68
90
  prop.arg?.content === name)
@@ -78,42 +100,99 @@ function staticNumericProp (node, name) {
78
100
  return { value: numeric, dynamic: !Number.isFinite(numeric) }
79
101
  }
80
102
 
81
- function displayModel (node) {
103
+ function repeatModelInfo (expression, repeat) {
104
+ const model = String(expression || '').trim()
105
+ if (SIMPLE_IDENTIFIER.test(model)) return { valid: true, repeatModel: null }
106
+ if (!repeat?.valid) return { valid: false, repeatModel: null }
107
+ const indexedPrefix = repeat.index ? `${repeat.source}[${repeat.index}].` : ''
108
+ if (indexedPrefix && model.startsWith(indexedPrefix)) {
109
+ const field = model.slice(indexedPrefix.length)
110
+ if (safePropertyPath(field)) {
111
+ return { valid: true, repeatModel: { kind: 'source-index-property', field } }
112
+ }
113
+ }
114
+ if (repeat.kind === 'item') {
115
+ const escaped = repeat.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
116
+ const match = model.match(new RegExp('^' + escaped + '\\.' +
117
+ '(' + SIMPLE_PROPERTY_PATH.source.slice(1, -1) + ')$'))
118
+ return match && safePropertyPath(match[1])
119
+ ? { valid: true, repeatModel: { kind: 'item-property', field: match[1] } }
120
+ : { valid: false, repeatModel: null }
121
+ }
122
+ return model === `${repeat.source}[${repeat.index}]`
123
+ ? { valid: true, repeatModel: { kind: 'array-index' } }
124
+ : { valid: false, repeatModel: null }
125
+ }
126
+
127
+ function displayModel (node, repeat) {
82
128
  const dynamic = (node.children || []).filter(child => child.type === 5)
83
129
  if (!dynamic.length) return { model: '', valid: true }
84
130
  const expression = dynamic.length === 1 ? dynamic[0].content?.content?.trim() : ''
85
- return { model: expression, valid: /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(expression) }
131
+ return { model: expression, ...repeatModelInfo(expression, repeat) }
86
132
  }
87
133
 
88
- function inputModel (node, allowNumber = false) {
134
+ function inputModel (node, allowNumber = false, repeat) {
89
135
  const directive = (node.props || []).find(prop => prop.type === 7 && prop.name === 'model')
90
136
  if (!directive) return { model: '', valid: true }
91
137
  const model = directive.exp?.content?.trim() || ''
92
138
  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) }
139
+ const info = repeatModelInfo(model, repeat)
140
+ return { model, repeatModel: info.repeatModel,
141
+ valid: !directive.arg && modifiers.every(item => allowNumber && item === 'number') && info.valid }
95
142
  }
96
143
 
97
- function progressModel (node) {
144
+ function progressModel (node, repeat) {
98
145
  const bound = (node.props || []).find(prop => prop.type === 7 && prop.name === 'bind' &&
99
146
  ['model-value', 'modelValue'].includes(prop.arg?.content))
100
- if (!bound) return inputModel(node)
147
+ if (!bound) return inputModel(node, false, repeat)
101
148
  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) }
149
+ const info = repeatModelInfo(model, repeat)
150
+ return { model, repeatModel: info.repeatModel,
151
+ valid: !hasDirective(node, ['model']) && !bound.modifiers?.length && info.valid }
104
152
  }
105
153
 
106
- function nativeMeterModel (node) {
154
+ function nativeMeterModel (node, repeat) {
107
155
  const modelDirective = (node.props || []).find(prop => prop.type === 7 && prop.name === 'model')
108
156
  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() || '') }
157
+ const model = modelDirective.exp?.content?.trim() || ''
158
+ const info = repeatModelInfo(model, repeat)
159
+ return { model, repeatModel: info.repeatModel,
160
+ valid: !modelDirective.arg && !modelDirective.modifiers?.length && info.valid }
111
161
  }
112
162
  const bound = (node.props || []).find(prop => prop.type === 7 && prop.name === 'bind' &&
113
163
  prop.arg?.content === 'value')
114
164
  if (!bound) return { model: '', valid: true }
115
165
  const model = bound.exp?.content?.trim() || ''
116
- return { model, valid: !bound.modifiers?.length && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(model) }
166
+ const info = repeatModelInfo(model, repeat)
167
+ return { model, repeatModel: info.repeatModel,
168
+ valid: !bound.modifiers?.length && info.valid }
169
+ }
170
+
171
+ function repeatDescriptor (node) {
172
+ const directive = (node.props || []).find(prop => prop.type === 7 && prop.name === 'for')
173
+ if (!directive) return null
174
+ const parsed = directive.forParseResult
175
+ const source = parsed?.source?.content?.trim() || ''
176
+ const value = parsed?.value?.content?.trim() || ''
177
+ const index = parsed?.key?.content?.trim() || ''
178
+ const keyProp = (node.props || []).find(prop => prop.type === 7 && prop.name === 'bind' &&
179
+ prop.arg?.content === 'key' && !prop.modifiers?.length)
180
+ const key = keyProp?.exp?.content?.trim() || ''
181
+ const locator = `repeat:${hash(`${node.tag}:${node.loc.start.offset}:${directive.exp?.content || ''}`)}`
182
+ let kind = ''
183
+ let reason = ''
184
+ if (!SIMPLE_IDENTIFIER.test(source)) reason = 'v-for source must be one top-level array field'
185
+ else if (!SIMPLE_IDENTIFIER.test(value)) reason = 'v-for item alias must be a simple identifier'
186
+ else if (index && !SIMPLE_IDENTIFIER.test(index)) reason = 'v-for index alias must be a simple identifier'
187
+ else if (!key) reason = 'Repeated controls need a stable :key'
188
+ else if (key === `${value}.id`) kind = 'item'
189
+ else if (index && key === index) kind = 'array'
190
+ else reason = index
191
+ ? 'Object lists need :key="item.id" or array lists need :key="index"'
192
+ : 'Item v-for bindings require :key="item.id"'
193
+ return { locator, source, value, ...(index ? { index } : {}), key, kind,
194
+ markerOnControl: node.tag.toLowerCase() === 'template',
195
+ valid: !reason, ...(reason ? { reason } : {}) }
117
196
  }
118
197
 
119
198
  function normalizeMeterVModels (format) {
@@ -146,13 +225,14 @@ function normalizeMeterVModels (format) {
146
225
  return normalized
147
226
  }
148
227
 
149
- function pressedModel (node) {
228
+ function pressedModel (node, repeat) {
150
229
  const directive = (node.props || []).find(prop => prop.type === 7 && prop.name === 'bind' &&
151
230
  ['aria-pressed', 'aria-checked'].includes(prop.arg?.content))
152
231
  if (!directive) return { model: '', valid: true }
153
232
  const model = directive.exp?.content?.trim() || ''
233
+ const info = repeatModelInfo(model, repeat)
154
234
  return { model, valid: !directive.modifiers?.length &&
155
- /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(model) }
235
+ info.valid, repeatModel: info.repeatModel }
156
236
  }
157
237
 
158
238
  function openingTagEnd (source, start) {
@@ -188,13 +268,22 @@ function inspectTemplate (format) {
188
268
  }
189
269
  const candidates = []
190
270
  const counts = new Map()
271
+ const repeatMarkers = []
191
272
 
192
- function visit (node, repeated = false) {
273
+ function visit (node, repeatContext = null) {
193
274
  if (node.type === 1) {
194
275
  if (node.tag.toLowerCase().startsWith('v-') && node.isSelfClosing) {
195
276
  throw new Error(`Vuetify component <${node.tag}> needs an explicit closing tag in Dashboard templates`)
196
277
  }
197
- const insideRepeat = repeated || hasDirective(node, ['for'])
278
+ const ownRepeat = repeatDescriptor(node)
279
+ const activeRepeat = ownRepeat
280
+ ? repeatContext ? { ...ownRepeat, valid: false, reason: 'Nested v-for bindings are not supported' } : ownRepeat
281
+ : repeatContext
282
+ const insideRepeat = Boolean(activeRepeat)
283
+ if (ownRepeat?.valid && !repeatContext && !ownRepeat.markerOnControl) {
284
+ repeatMarkers.push({ offset: openingTagEnd(format, node.loc.start.offset),
285
+ locator: ownRepeat.locator, key: ownRepeat.key })
286
+ }
198
287
  const adapter = candidateAdapter(node)
199
288
  const customAttribute = (node.props || []).some(prop =>
200
289
  (prop.type === 6 && prop.name.startsWith('data-cp-')) ||
@@ -203,12 +292,13 @@ function inspectTemplate (format) {
203
292
  if (adapter) {
204
293
  const component = COMPONENT_ADAPTERS[node.tag.toLowerCase()] ? node.tag.toLowerCase() : ''
205
294
  const display = adapter === 'text-display'
206
- ? displayModel(node)
207
- : ['v-progress-linear', 'v-progress-circular'].includes(component) ? progressModel(node)
208
- : adapter === 'meter' && !component ? nativeMeterModel(node)
295
+ ? displayModel(node, activeRepeat)
296
+ : adapter === 'xy-series' ? xySeriesModel(node)
297
+ : ['v-progress-linear', 'v-progress-circular'].includes(component) ? progressModel(node, activeRepeat)
298
+ : adapter === 'meter' && !component ? nativeMeterModel(node, activeRepeat)
209
299
  : adapter === 'switch' && node.tag.toLowerCase() === 'button'
210
- ? pressedModel(node)
211
- : inputModel(node, adapter === 'range' || adapter === 'select')
300
+ ? pressedModel(node, activeRepeat)
301
+ : inputModel(node, adapter === 'range' || adapter === 'select', activeRepeat)
212
302
  const source = format.slice(node.loc.start.offset, openingTagEnd(format, node.loc.start.offset) + 1)
213
303
  const stableName = attribute(node, 'id') || attribute(node, 'name')
214
304
  const locator = stableName
@@ -252,6 +342,7 @@ function inspectTemplate (format) {
252
342
  const candidate = {
253
343
  locator, adapter,
254
344
  ...(component ? { component } : {}),
345
+ ...(activeRepeat ? { repeat: activeRepeat } : {}),
255
346
  label: attribute(node, 'aria-label') || attribute(node, 'label') || attribute(node, 'title') ||
256
347
  stableName || display.model || (adapter === 'text-display' ? node.children[0]?.content?.trim() : '') || node.tag,
257
348
  ...(display.model ? { model: display.model } : {}),
@@ -267,12 +358,12 @@ function inspectTemplate (format) {
267
358
  } : {}),
268
359
  ...(attribute(node, 'data-unit') ? { unit: attribute(node, 'data-unit') } : {}),
269
360
  line: node.loc.start.line,
270
- supported: !insideRepeat && !explicitLogic && display.valid && !dynamicBounds &&
361
+ supported: (!insideRepeat || Boolean(activeRepeat.valid && display.valid)) && !explicitLogic && display.valid && !dynamicBounds &&
271
362
  !invalidSliderBounds &&
272
363
  !customSwitchValues && !unsupportedSelect && !unsupportedRadio &&
273
364
  !unsupportedTextType && !unsupportedProgress && !unsupportedCircular &&
274
365
  (!component || Boolean(display.model)),
275
- reason: insideRepeat ? 'Repeated controls need a stable item identity' :
366
+ reason: insideRepeat && !activeRepeat.valid ? activeRepeat.reason :
276
367
  explicitLogic ? 'Controls with existing handlers or reactive value bindings need a dedicated adapter' :
277
368
  !display.valid ? (adapter === 'text-display' ? 'Dynamic text needs one writable data field' :
278
369
  'Only a simple Vue v-model field is supported') :
@@ -284,16 +375,20 @@ function inspectTemplate (format) {
284
375
  unsupportedTextType ? 'Only text Vuetify fields are supported' :
285
376
  unsupportedProgress ? 'Progress needs a determinate positive range' :
286
377
  unsupportedCircular ? 'Circular progress uses a determinate 0-100 range' :
287
- component && !display.model ? 'Vuetify controls need a simple Vue v-model field' : ''
378
+ adapter === 'xy-series' && !display.model
379
+ ? display.attribute === 'data-coordinates'
380
+ ? 'Map coordinate containers need :data-coordinates="coordinates"'
381
+ : 'Canvas needs :data-points="series" or :data-series="series"'
382
+ : component && !display.model ? 'Vuetify controls need a simple Vue v-model field' : ''
288
383
  }
289
384
  candidates.push({ ...candidate, offset: openingTagEnd(format, node.loc.start.offset) })
290
385
  counts.set(locator, (counts.get(locator) || 0) + 1)
291
386
  }
292
- for (const child of node.children || []) visit(child, insideRepeat)
387
+ for (const child of node.children || []) visit(child, activeRepeat)
293
388
  return
294
389
  }
295
- for (const child of node.children || []) visit(child, repeated)
296
- for (const branch of node.branches || []) visit(branch, repeated)
390
+ for (const child of node.children || []) visit(child, repeatContext)
391
+ for (const branch of node.branches || []) visit(branch, repeatContext)
297
392
  }
298
393
 
299
394
  if (descriptor.template?.ast) visit(descriptor.template.ast)
@@ -303,17 +398,33 @@ function inspectTemplate (format) {
303
398
  candidate.reason = 'This element is ambiguous; give it a unique HTML id or name'
304
399
  }
305
400
  }
306
- return { candidates, templateHash: hash(format) }
401
+ return { candidates, repeatMarkers, templateHash: hash(format) }
307
402
  }
308
403
 
309
404
  function instrumentTemplate (format) {
310
405
  const inspected = inspectTemplate(format)
311
406
  let rendered = format
312
- for (const candidate of [...inspected.candidates].reverse()) {
313
- const before = rendered.slice(0, candidate.offset)
407
+ const insertions = new Map()
408
+ const addInsertion = (offset, value) => {
409
+ const list = insertions.get(offset) || []
410
+ list.push(value)
411
+ insertions.set(offset, list)
412
+ }
413
+ for (const candidate of inspected.candidates) {
414
+ addInsertion(candidate.offset, `data-cp-auto="${candidate.locator}"`)
415
+ if (candidate.repeat?.markerOnControl) {
416
+ addInsertion(candidate.offset,
417
+ `data-cp-repeat="${candidate.repeat.locator}" :data-cp-repeat-key="String(${candidate.repeat.key})"`)
418
+ }
419
+ }
420
+ for (const marker of inspected.repeatMarkers) {
421
+ addInsertion(marker.offset, `data-cp-repeat="${marker.locator}" :data-cp-repeat-key="String(${marker.key})"`)
422
+ }
423
+ for (const [offset, attributes] of [...insertions.entries()].sort((a, b) => b[0] - a[0])) {
424
+ const before = rendered.slice(0, offset)
314
425
  const selfClosing = /\/\s*$/.test(before)
315
- const insertAt = selfClosing ? before.lastIndexOf('/') : candidate.offset
316
- rendered = rendered.slice(0, insertAt) + ` data-cp-auto="${candidate.locator}"` + rendered.slice(insertAt)
426
+ const insertAt = selfClosing ? before.lastIndexOf('/') : offset
427
+ rendered = rendered.slice(0, insertAt) + ' ' + attributes.join(' ') + rendered.slice(insertAt)
317
428
  }
318
429
  return {
319
430
  format: normalizeMeterVModels(rendered),
@@ -1,10 +1,23 @@
1
1
  const { inspectTemplate } = require('./panel-template-instrumentation')
2
2
  const { compatibleTarget, controlTypeForAdapter, targetMeta } = require('./control-type-model')
3
3
  const { meterRoute } = require('./meter-route')
4
+ const { xySeriesRoute } = require('./xy-series-route')
4
5
 
5
6
  const ID = /^[A-Za-z][A-Za-z0-9._/-]{0,127}$/
7
+ const INSTANCE_KEY = /^[A-Za-z0-9._:/-]{1,160}$/
6
8
  const MODES = new Set(['read', 'write', 'both'])
7
9
 
10
+ function targetKey (candidate, entry) {
11
+ if (!candidate.repeat) return candidate.locator
12
+ if (typeof entry.instanceKey !== 'string' || !INSTANCE_KEY.test(entry.instanceKey)) {
13
+ throw new Error('Repeated visual controls need a stable instance key')
14
+ }
15
+ if (candidate.repeat.kind === 'array' && !/^(0|[1-9][0-9]*)$/.test(entry.instanceKey)) {
16
+ throw new Error('Array repeated controls need a non-negative integer instance key')
17
+ }
18
+ return `${candidate.locator}#${entry.instanceKey}`
19
+ }
20
+
8
21
  function parseVisualBindings (config) {
9
22
  let raw = config.visualBindings || '{}'
10
23
  if (typeof raw === 'string') raw = JSON.parse(raw)
@@ -26,10 +39,11 @@ function visualRoutes (config) {
26
39
  if (!candidate || candidate.adapter !== entry.adapter) {
27
40
  throw new Error(`Visual control moved or became ambiguous: ${id}`)
28
41
  }
29
- if (usedLocators.has(entry.locator)) throw new Error(`Duplicate visual control locator: ${id}`)
30
- usedLocators.add(entry.locator)
42
+ const bindingKey = targetKey(candidate, entry)
43
+ if (usedLocators.has(bindingKey)) throw new Error(`Duplicate visual control locator: ${id}`)
44
+ usedLocators.add(bindingKey)
31
45
  if (!MODES.has(entry.mode) || !entry.type || !entry.label ||
32
- (['meter', 'text-display'].includes(entry.adapter) && entry.mode !== 'read')) {
46
+ (['meter', 'xy-series', 'text-display'].includes(entry.adapter) && entry.mode !== 'read')) {
33
47
  throw new Error(`Invalid visual binding settings: ${id}`)
34
48
  }
35
49
  if (entry.readMeta?.meter) {
@@ -39,9 +53,16 @@ function visualRoutes (config) {
39
53
  !Number.isFinite(entry.readMeta.min) || !Number.isFinite(entry.readMeta.max) ||
40
54
  entry.readMeta.min >= entry.readMeta.max) throw Error(`Invalid meter binding: ${id}`)
41
55
  }
56
+ if (entry.readMeta?.xySeries) {
57
+ xySeriesRoute(entry.readMeta.xySeries)
58
+ if (entry.adapter !== 'xy-series' || entry.mode !== 'read' || entry.readMeta.access !== 'ro') {
59
+ throw Error(`Invalid xy-series binding: ${id}`)
60
+ }
61
+ }
42
62
  routes.set(id, {
43
63
  control: { id, type: entry.type, mode: entry.mode, label: entry.label,
44
- adapter: entry.adapter, locator: entry.locator },
64
+ adapter: entry.adapter, locator: entry.locator,
65
+ ...(candidate.repeat ? { instanceKey: entry.instanceKey } : {}) },
45
66
  binding: entry
46
67
  })
47
68
  }
@@ -62,9 +83,10 @@ function validatedVisualBindings ({ format, entries, devices, targetsByDevice })
62
83
  used.add(raw.id)
63
84
  const candidate = candidates.get(raw.locator)
64
85
  if (!candidate || candidate.adapter !== raw.adapter) throw new Error(`Control changed: ${raw.id}`)
65
- if (usedLocators.has(raw.locator)) throw new Error(`One element cannot have two bindings: ${raw.id}`)
66
- usedLocators.add(raw.locator)
67
- if (!MODES.has(raw.mode) || (['meter', 'text-display'].includes(raw.adapter) && raw.mode !== 'read')) {
86
+ const bindingKey = targetKey(candidate, raw)
87
+ if (usedLocators.has(bindingKey)) throw new Error(`One element cannot have two bindings: ${raw.id}`)
88
+ usedLocators.add(bindingKey)
89
+ if (!MODES.has(raw.mode) || (['meter', 'xy-series', 'text-display'].includes(raw.adapter) && raw.mode !== 'read')) {
68
90
  throw new Error(`Invalid binding mode: ${raw.id}`)
69
91
  }
70
92
  if (typeof raw.label !== 'string' || !raw.label.trim() || raw.label.length > 80) {
@@ -91,15 +113,17 @@ function validatedVisualBindings ({ format, entries, devices, targetsByDevice })
91
113
  }
92
114
  if (candidate.model) {
93
115
  const identity = JSON.stringify([raw.device, read?.path, write?.path])
94
- const previous = models.get(candidate.model)
116
+ const modelKey = candidate.repeat ? `${candidate.model}#${raw.instanceKey}` : candidate.model
117
+ const previous = models.get(modelKey)
95
118
  if (previous && (read?.meter || previous.meter) && previous.identity !== identity) {
96
119
  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
120
  }
98
- models.set(candidate.model, { identity, meter: Boolean(read?.meter) })
121
+ models.set(modelKey, { identity, meter: Boolean(read?.meter) })
99
122
  }
100
123
  result[raw.id] = {
101
124
  locator: raw.locator, adapter: raw.adapter, type, label: raw.label.trim(), mode: raw.mode,
102
125
  ...(candidate.model ? { model: candidate.model } : {}),
126
+ ...(candidate.repeat ? { repeat: candidate.repeat, instanceKey: raw.instanceKey } : {}),
103
127
  ...(candidate.component ? { component: candidate.component } : {}),
104
128
  ...(['range', 'meter'].includes(raw.adapter)
105
129
  ? { uiRange: { min: candidate.min, max: candidate.max, step: candidate.step } } : {}),
@@ -0,0 +1,84 @@
1
+ // Self-contained client for readonly XY-series telemetry.
2
+ function createPanelXYSeriesClient ({ socket, panelId, room, ids, request,
3
+ createReceiver, createRenderer, apply, invalidate, onUnauthorized }) {
4
+ if (!socket) throw Error('Dashboard socket is unavailable for xy-series 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:xy-series-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 + '/xy-series-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 || 'XY-series subscription failed')
33
+ delay = 500
34
+ socket.emit('control-panel:xy-series-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:xy-series-ready', ready)
65
+ socket.on('control-panel:xy-series-frame', frame)
66
+ socket.on('control-panel:xy-series-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:xy-series-ready', ready)
75
+ socket.off('control-panel:xy-series-frame', frame)
76
+ socket.off('control-panel:xy-series-ended', ended)
77
+ socket.off('connect', connect)
78
+ socket.off('disconnect', disconnected)
79
+ document.removeEventListener('visibilitychange', visibility)
80
+ }
81
+ }
82
+ }
83
+
84
+ module.exports = { createPanelXYSeriesClient }
@@ -0,0 +1,186 @@
1
+ const crypto = require('node:crypto')
2
+ const { createXYSeriesReceiver } = require('./xy-series-frame')
3
+ const { readJsonBody, replyJson } = require('./panel-http')
4
+ const { xySeriesRoute } = require('./xy-series-route')
5
+
6
+ function createXYSeriesRoom ({ room, now = Date.now, staleMs = 1000 }) {
7
+ const sources = new Map(), panels = new Map(), ticks = new Set()
8
+ let timer = null, generation = 0
9
+
10
+ function attach (panelId, bindings) {
11
+ if (panels.has(panelId)) throw Error('XY-series panel already registered')
12
+ const entries = Object.entries(bindings).filter(([, entry]) => entry.readMeta?.xySeries)
13
+ .sort(([a], [b]) => a.localeCompare(b))
14
+ if (!entries.length || entries.length > 256) throw Error('Invalid xy-series panel size')
15
+ const projections = entries.map(([id, binding]) => {
16
+ const series = xySeriesRoute(binding.readMeta.xySeries)
17
+ const key = JSON.stringify([binding.device, series.streamId])
18
+ let source = sources.get(key)
19
+ if (source && JSON.stringify(source.route) !== JSON.stringify(series)) {
20
+ throw Error(`Conflicting xy-series stream: ${series.streamId}`)
21
+ }
22
+ if (!source) {
23
+ source = { device: binding.device, route: series, receiver: null,
24
+ receivedAt: null, revision: 0, refs: new Set() }
25
+ sources.set(key, source)
26
+ }
27
+ source.refs.add(panelId)
28
+ return { id, binding, source, key }
29
+ })
30
+ const layout = { kind: 'room-xy-series-layout', panelId, room,
31
+ epoch: crypto.randomBytes(16).toString('hex'), ids: entries.map(([id]) => id) }
32
+ panels.set(panelId, { projections, layout, sequence: 0 })
33
+ return layout
34
+ }
35
+
36
+ function accept (message) {
37
+ if (!['device-xy-series-frame', 'device-xy-series-manifest'].includes(message?.kind)) return false
38
+ const source = sources.get(JSON.stringify([message.device, message.streamId]))
39
+ if (!source || message.path !== (message.kind === 'device-xy-series-frame'
40
+ ? source.route.eventPath : source.route.manifestPath)) return false
41
+ let data = message.payload
42
+ if (data && typeof data === 'object' && Object.hasOwn(data, 'data')) data = data.data
43
+ if (typeof data === 'string') {
44
+ if (Buffer.byteLength(data) > 524288) return false
45
+ try { data = JSON.parse(data) } catch (_) { return false }
46
+ }
47
+ if (message.kind === 'device-xy-series-manifest') {
48
+ let receiver
49
+ try { receiver = createXYSeriesReceiver(data) } catch (_) { return false }
50
+ if (receiver.manifest.streamId !== source.route.streamId) return false
51
+ const previous = source.receiver?.manifest
52
+ if (previous && JSON.stringify(previous) === JSON.stringify(receiver.manifest)) return true
53
+ if (previous?.epoch === receiver.manifest.epoch &&
54
+ receiver.manifest.layoutVersion <= previous.layoutVersion) return false
55
+ source.receiver?.close(); source.receiver = receiver; source.receivedAt = null
56
+ } else {
57
+ if (!source.receiver?.accept(data)) return false
58
+ source.receivedAt = now()
59
+ }
60
+ source.revision = ++generation
61
+ return true
62
+ }
63
+
64
+ function snapshot (panelId) {
65
+ const panel = panels.get(panelId)
66
+ if (!panel) return null
67
+ const time = now(), values = [], valid = [], sourcesByStream = new Map()
68
+ for (const { source, key } of panel.projections) {
69
+ const frame = source.receiver?.snapshot()
70
+ const age = source.receivedAt === null ? null : Math.max(0, time - source.receivedAt)
71
+ const ok = Boolean(frame && age !== null && age <= staleMs)
72
+ values.push(ok ? frame.points.map(point => point.slice()) : [])
73
+ valid.push(ok)
74
+ sourcesByStream.set(key, { device: source.device, streamId: source.route.streamId,
75
+ epoch: source.receiver?.manifest.epoch || '', seq: frame?.seq || 0,
76
+ sampleTime: frame?.sampleTime ?? null, ageMs: age })
77
+ }
78
+ return { kind: 'room-xy-series-frame', panelId, room, epoch: panel.layout.epoch,
79
+ seq: ++panel.sequence, values, valid, sources: [...sourcesByStream.values()] }
80
+ }
81
+
82
+ function revision (panelId) {
83
+ const panel = panels.get(panelId)
84
+ if (!panel) return ''
85
+ const time = now()
86
+ return panel.projections.map(({ source }) => `${source.revision}:${source.receivedAt !== null &&
87
+ time - source.receivedAt <= staleMs}`).join(',')
88
+ }
89
+
90
+ function detach (panelId) {
91
+ const panel = panels.get(panelId)
92
+ if (!panel) return
93
+ panels.delete(panelId)
94
+ for (const { source, key } of panel.projections) {
95
+ source.refs.delete(panelId)
96
+ if (!source.refs.size) { source.receiver?.close(); sources.delete(key) }
97
+ }
98
+ }
99
+
100
+ return { attach, accept, snapshot, revision, detach,
101
+ onTick (callback) {
102
+ ticks.add(callback)
103
+ if (!timer) { timer = setInterval(() => { for (const tick of ticks) tick() }, 1000 / 30); timer.unref?.() }
104
+ return () => {
105
+ ticks.delete(callback)
106
+ if (!ticks.size) { clearInterval(timer); timer = null }
107
+ }
108
+ },
109
+ stats: () => ({ panels: panels.size, sources: sources.size }) }
110
+ }
111
+
112
+ function createPanelXYSeries ({ panelId, room, bindings, hub, onSubscribe = () => {} }) {
113
+ const layout = hub.attach(panelId, bindings)
114
+ const tickets = new Map(), subscribers = new Map()
115
+ let closed = false, lastRevision = ''
116
+ const remove = socketId => {
117
+ const entry = subscribers.get(socketId)
118
+ if (entry) entry.socket.off('disconnect', entry.disconnect)
119
+ subscribers.delete(socketId)
120
+ }
121
+ const live = entry => entry.active() && entry.socket.connected
122
+ const emitFrame = (socket, frame) => socket.volatile.emit('control-panel:xy-series-frame', frame)
123
+ const prune = () => {
124
+ for (const [key, entry] of tickets) if (entry.expiry <= Date.now() || !entry.active()) tickets.delete(key)
125
+ for (const [key, entry] of subscribers) if (!live(entry)) {
126
+ entry.socket.emit('control-panel:xy-series-ended', { panelId }); remove(key)
127
+ }
128
+ }
129
+ const stopTick = hub.onTick(() => {
130
+ prune()
131
+ if (closed || !subscribers.size) return
132
+ const revision = hub.revision(panelId)
133
+ if (revision === lastRevision) return
134
+ lastRevision = revision
135
+ const frame = hub.snapshot(panelId)
136
+ for (const { socket } of subscribers.values()) emitFrame(socket, frame)
137
+ })
138
+ return {
139
+ async ticket (req, res, identity, active) {
140
+ if (!new URL(req.url, 'http://localhost').pathname.endsWith('/xy-series-ticket') || req.method !== 'POST') return false
141
+ if (closed) { replyJson(res, 503, { error: 'XY-series channel unavailable' }); return true }
142
+ prune()
143
+ const body = await readJsonBody(req, 4096)
144
+ if (body.room !== room || typeof body.socketId !== 'string' || !body.socketId || body.socketId.length > 128) {
145
+ replyJson(res, 403, { error: 'XY-series subscription rejected' }); return true
146
+ }
147
+ if (!req.headers.origin) { replyJson(res, 403, { error: 'XY-series subscription needs Origin' }); return true }
148
+ for (const [key, entry] of tickets) if (entry.token === identity.token && entry.socketId === body.socketId) tickets.delete(key)
149
+ if (tickets.size >= 100) { replyJson(res, 429, { error: 'XY-series ticket limit reached' }); return true }
150
+ const ticket = crypto.randomBytes(32).toString('hex')
151
+ tickets.set(ticket, { token: identity.token, socketId: body.socketId,
152
+ expiry: Math.min(identity.expiry, Date.now() + 10000), active })
153
+ replyJson(res, 200, { ticket }); return true
154
+ },
155
+ hooks: {
156
+ 'control-panel:xy-series-subscribe': (socket, id, ticket) => {
157
+ if (id !== panelId || closed) return
158
+ prune()
159
+ const entry = tickets.get(ticket); tickets.delete(ticket)
160
+ if (!entry || !entry.active() || entry.expiry <= Date.now() || entry.socketId !== socket.id ||
161
+ !socket.connected || (!subscribers.has(socket.id) && subscribers.size >= 100)) {
162
+ socket.emit('control-panel:xy-series-ended', { panelId }); return
163
+ }
164
+ remove(socket.id)
165
+ const disconnect = () => remove(socket.id)
166
+ subscribers.set(socket.id, { socket, active: entry.active, disconnect })
167
+ socket.on('disconnect', disconnect)
168
+ socket.emit('control-panel:xy-series-ready', layout)
169
+ emitFrame(socket, hub.snapshot(panelId)); onSubscribe()
170
+ },
171
+ 'control-panel:xy-series-unsubscribe': (socket, id) => { if (id === panelId) remove(socket.id) }
172
+ },
173
+ accept: message => !closed && hub.accept(message),
174
+ stats: () => ({ tickets: tickets.size, subscribers: subscribers.size, ...hub.stats() }),
175
+ close () {
176
+ closed = true; stopTick(); tickets.clear()
177
+ for (const entry of subscribers.values()) {
178
+ entry.socket.emit('control-panel:xy-series-ended', { panelId })
179
+ entry.socket.off('disconnect', entry.disconnect)
180
+ }
181
+ subscribers.clear(); hub.detach(panelId)
182
+ }
183
+ }
184
+ }
185
+
186
+ module.exports = { createXYSeriesRoom, createPanelXYSeries }