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.
- package/CONTROL_PANEL_AI_GUIDE.md +250 -109
- package/README.md +3 -1
- package/control-catalog.js +5 -2
- package/control-panel.js +24 -4
- package/control-policy.js +8 -0
- package/control-type-contract.json +1 -0
- package/control-type-model.js +8 -3
- package/managed-native-bridge.js +2 -1
- package/native-binding-plan.js +61 -7
- package/npm-shrinkwrap.json +2 -2
- package/package.json +8 -2
- package/panel-binding-editor.js +193 -34
- package/panel-meter.js +2 -1
- package/panel-template-instrumentation.js +144 -33
- package/panel-visual-bindings.js +33 -9
- package/panel-xy-series-client.js +84 -0
- package/panel-xy-series.js +186 -0
- package/xy-series-frame.js +98 -0
- package/xy-series-route.js +11 -0
|
@@ -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
|
|
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,
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
:
|
|
208
|
-
:
|
|
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
|
|
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
|
-
|
|
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,
|
|
387
|
+
for (const child of node.children || []) visit(child, activeRepeat)
|
|
293
388
|
return
|
|
294
389
|
}
|
|
295
|
-
for (const child of node.children || []) visit(child,
|
|
296
|
-
for (const branch of node.branches || []) visit(branch,
|
|
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
|
-
|
|
313
|
-
|
|
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('/') :
|
|
316
|
-
rendered = rendered.slice(0, 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),
|
package/panel-visual-bindings.js
CHANGED
|
@@ -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
|
-
|
|
30
|
-
usedLocators.
|
|
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
|
-
|
|
66
|
-
usedLocators.
|
|
67
|
-
|
|
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
|
|
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(
|
|
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 }
|