node-red-contrib-room-tools 1.9.3 → 1.9.5
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 +58 -18
- package/README.md +28 -6
- package/control-bound-protocol.js +25 -7
- package/control-catalog.js +26 -3
- package/control-panel.html +10 -36
- package/control-panel.js +37 -9
- package/control-policy.js +34 -5
- package/control-type-model.js +73 -11
- package/device-availability.js +104 -0
- package/gateway.cjs +36 -12
- package/managed-native-bridge.js +3 -1
- package/meter-frame.js +7 -1
- package/native-binding-plan.js +51 -12
- package/npm-shrinkwrap.json +2 -2
- package/package.json +6 -1
- package/panel-binding-editor.js +311 -62
- package/panel-meter.js +4 -4
- package/panel-momentary.js +7 -1
- package/panel-telemetry-send.js +13 -0
- package/panel-template-instrumentation.js +1 -0
- package/panel-visual-apply.js +86 -27
- package/panel-visual-bindings.js +26 -11
- package/panel-xy-series.js +16 -7
- package/xy-series-frame.js +8 -2
package/panel-binding-editor.js
CHANGED
|
@@ -6,6 +6,12 @@ function initializeVisualBinding () {
|
|
|
6
6
|
const root = this.$el
|
|
7
7
|
const base = config.editorEndpoint
|
|
8
8
|
const saved = new Map(Object.entries(config.bindings || {}))
|
|
9
|
+
const bindingsByLocator = new Map()
|
|
10
|
+
for (const entry of saved) {
|
|
11
|
+
const locator = entry[1].locator
|
|
12
|
+
if (!bindingsByLocator.has(locator)) bindingsByLocator.set(locator, [])
|
|
13
|
+
bindingsByLocator.get(locator).push(entry)
|
|
14
|
+
}
|
|
9
15
|
const meterEntries = [...saved].filter(([, entry]) => entry.readMeta?.meter)
|
|
10
16
|
const xySeriesEntries = [...saved].filter(([, entry]) => entry.readMeta?.xySeries)
|
|
11
17
|
const meterTargets = new Map()
|
|
@@ -13,25 +19,100 @@ function initializeVisualBinding () {
|
|
|
13
19
|
let meterClient = null, lastMeterFrame = null
|
|
14
20
|
let xySeriesClient = null, lastXYSeriesFrame = null
|
|
15
21
|
const attached = new WeakSet()
|
|
22
|
+
const controlListeners = new Map()
|
|
16
23
|
const buttonPresses = new Map()
|
|
17
24
|
const momentaryButtons = new Map()
|
|
18
25
|
const momentaryStates = new Map()
|
|
19
26
|
const feedbackModels = new Map()
|
|
20
27
|
const pendingText = new Map()
|
|
28
|
+
const disabledNodes = new Map()
|
|
29
|
+
const blockedTelemetry = new Map()
|
|
21
30
|
const cleanup = []
|
|
22
31
|
let disposed = false
|
|
23
32
|
cleanup.push(() => { disposed = true })
|
|
24
33
|
let editing = new URL(window.location.href).searchParams.get('cp-edit') === config.panelId
|
|
34
|
+
|
|
35
|
+
function deviceAvailable (id) {
|
|
36
|
+
return editing || component.__deviceAvailable?.(id) !== false
|
|
37
|
+
}
|
|
38
|
+
function sourceVersion (frame, binding) {
|
|
39
|
+
const stream = binding.readMeta?.meter || binding.readMeta?.xySeries
|
|
40
|
+
const source = frame?.sources?.find(item => item.device === binding.device && item.streamId === stream?.streamId)
|
|
41
|
+
return source ? `${source.epoch}:${source.seq}` : frame ? `legacy:${frame.seq}` : null
|
|
42
|
+
}
|
|
43
|
+
function setAvailable (node, available) {
|
|
44
|
+
available = editing || available
|
|
45
|
+
if (!available) {
|
|
46
|
+
if (!disabledNodes.has(node)) disabledNodes.set(node, {
|
|
47
|
+
inert: node.hasAttribute('inert'), aria: node.getAttribute('aria-disabled')
|
|
48
|
+
})
|
|
49
|
+
if (node.getAttribute('data-cpv-unavailable') !== 'true') node.setAttribute('data-cpv-unavailable', 'true')
|
|
50
|
+
if (!node.hasAttribute('inert')) node.setAttribute('inert', '')
|
|
51
|
+
if (node.getAttribute('aria-disabled') !== 'true') node.setAttribute('aria-disabled', 'true')
|
|
52
|
+
} else {
|
|
53
|
+
const original = disabledNodes.get(node)
|
|
54
|
+
if (!original) return
|
|
55
|
+
node.removeAttribute('data-cpv-unavailable')
|
|
56
|
+
if (!original.inert) node.removeAttribute('inert')
|
|
57
|
+
if (original.aria === null) node.removeAttribute('aria-disabled')
|
|
58
|
+
else node.setAttribute('aria-disabled', original.aria)
|
|
59
|
+
disabledNodes.delete(node)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function refreshAvailability () {
|
|
63
|
+
for (const [id, binding] of saved) {
|
|
64
|
+
const node = nodeForBinding(binding)
|
|
65
|
+
if (!node) continue
|
|
66
|
+
if (!deviceAvailable(id) && (binding.readMeta?.meter || binding.readMeta?.xySeries)) {
|
|
67
|
+
const latest = binding.readMeta.meter ? lastMeterFrame : lastXYSeriesFrame
|
|
68
|
+
if (!blockedTelemetry.has(id)) blockedTelemetry.set(id, sourceVersion(latest?.frame, binding))
|
|
69
|
+
node.setAttribute(binding.readMeta.meter ? 'data-cpv-meter-valid' : 'data-cpv-xy-series-valid', 'false')
|
|
70
|
+
}
|
|
71
|
+
const telemetryValid = !binding.readMeta?.meter && !binding.readMeta?.xySeries ||
|
|
72
|
+
node.getAttribute(binding.readMeta?.meter ? 'data-cpv-meter-valid' : 'data-cpv-xy-series-valid') === 'true'
|
|
73
|
+
setAvailable(node, deviceAvailable(id) && telemetryValid)
|
|
74
|
+
}
|
|
75
|
+
for (const [node] of disabledNodes) if (!root.contains(node)) setAvailable(node, true)
|
|
76
|
+
for (const button of buttonPresses.values()) button.controller.check()
|
|
77
|
+
for (const button of momentaryButtons.values()) button.controller.check()
|
|
78
|
+
}
|
|
25
79
|
let editSession = null
|
|
26
80
|
let snapshot = null
|
|
27
81
|
let draft = []
|
|
28
82
|
let devices = []
|
|
29
83
|
let selecting = false
|
|
30
84
|
let observer
|
|
85
|
+
let nodeIndex = null
|
|
31
86
|
let toolbar
|
|
32
87
|
let overlay
|
|
88
|
+
let overlayCleanup
|
|
33
89
|
let status
|
|
34
90
|
let loginScreen
|
|
91
|
+
const resumeKey = `cpv-editor-resume:${config.panelId}`
|
|
92
|
+
const resumeDataKey = `cpv-editor-resume-data:${config.panelId}`
|
|
93
|
+
|
|
94
|
+
function resumePending () {
|
|
95
|
+
try { return window.sessionStorage?.getItem(resumeKey) === '1' } catch (_) { return false }
|
|
96
|
+
}
|
|
97
|
+
function markResumePending (pending) {
|
|
98
|
+
try {
|
|
99
|
+
if (pending) window.sessionStorage?.setItem(resumeKey, '1')
|
|
100
|
+
else window.sessionStorage?.removeItem(resumeKey)
|
|
101
|
+
} catch (_) {}
|
|
102
|
+
}
|
|
103
|
+
function readResumeData () {
|
|
104
|
+
if (!resumePending()) return null
|
|
105
|
+
try {
|
|
106
|
+
const value = JSON.parse(window.sessionStorage?.getItem(resumeDataKey) || 'null')
|
|
107
|
+
return value?.editorConfig && Array.isArray(value.devices) ? value : null
|
|
108
|
+
} catch (_) { return null }
|
|
109
|
+
}
|
|
110
|
+
function clearResumeData () {
|
|
111
|
+
try { window.sessionStorage?.removeItem(resumeDataKey) } catch (_) {}
|
|
112
|
+
}
|
|
113
|
+
function wait (milliseconds) {
|
|
114
|
+
return new Promise(resolve => setTimeout(resolve, milliseconds))
|
|
115
|
+
}
|
|
35
116
|
|
|
36
117
|
function element (tag, text, className) {
|
|
37
118
|
const node = document.createElement(tag)
|
|
@@ -52,6 +133,10 @@ function initializeVisualBinding () {
|
|
|
52
133
|
node.addEventListener('click', action)
|
|
53
134
|
return node
|
|
54
135
|
}
|
|
136
|
+
function listen (node, name, action, capture = false, stops = cleanup) {
|
|
137
|
+
node.addEventListener(name, action, capture)
|
|
138
|
+
stops.push(() => node.removeEventListener(name, action, capture))
|
|
139
|
+
}
|
|
55
140
|
function inform (message, error = false) {
|
|
56
141
|
if (status) {
|
|
57
142
|
status.textContent = message || ''
|
|
@@ -67,10 +152,14 @@ function initializeVisualBinding () {
|
|
|
67
152
|
body: method === 'POST' ? JSON.stringify(body || {}) : undefined
|
|
68
153
|
}, path === '/apply' ? 120000 : (timeoutMs ?? 12000),
|
|
69
154
|
async response => ({ response, result: await response.json() }))
|
|
70
|
-
if (!response.ok)
|
|
155
|
+
if (!response.ok) {
|
|
156
|
+
const error = new Error(result.error || `Request failed (${response.status})`)
|
|
157
|
+
error.status = response.status
|
|
158
|
+
throw error
|
|
159
|
+
}
|
|
71
160
|
return result
|
|
72
161
|
}
|
|
73
|
-
|
|
162
|
+
function numericRange (binding, node) {
|
|
74
163
|
if (binding.uiRange) return binding.uiRange
|
|
75
164
|
const tag = node.tagName?.toLowerCase()
|
|
76
165
|
const type = node.type?.toLowerCase()
|
|
@@ -130,7 +219,30 @@ function initializeVisualBinding () {
|
|
|
130
219
|
return repeatMarkerFor(node, binding.repeat) === String(binding.instanceKey)
|
|
131
220
|
}
|
|
132
221
|
function nodesForBinding (binding) {
|
|
133
|
-
|
|
222
|
+
if (!nodeIndex) {
|
|
223
|
+
nodeIndex = new Map()
|
|
224
|
+
for (const node of root.querySelectorAll('[data-cp-auto]')) {
|
|
225
|
+
const locator = node.getAttribute('data-cp-auto')
|
|
226
|
+
if (!nodeIndex.has(locator)) nodeIndex.set(locator, { nodes: [], repeats: new Map() })
|
|
227
|
+
nodeIndex.get(locator).nodes.push(node)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const group = nodeIndex.get(binding.locator)
|
|
231
|
+
if (!group) return []
|
|
232
|
+
let nodes = group.nodes
|
|
233
|
+
if (binding.repeat) {
|
|
234
|
+
if (!group.repeats.has(binding.repeat.locator)) {
|
|
235
|
+
const keys = new Map()
|
|
236
|
+
for (const node of nodes) {
|
|
237
|
+
const key = repeatMarkerFor(node, binding.repeat)
|
|
238
|
+
if (!keys.has(key)) keys.set(key, [])
|
|
239
|
+
keys.get(key).push(node)
|
|
240
|
+
}
|
|
241
|
+
group.repeats.set(binding.repeat.locator, keys)
|
|
242
|
+
}
|
|
243
|
+
nodes = group.repeats.get(binding.repeat.locator).get(String(binding.instanceKey)) || []
|
|
244
|
+
}
|
|
245
|
+
return nodes.filter(node => root.contains(node))
|
|
134
246
|
}
|
|
135
247
|
function nodeForBinding (binding) {
|
|
136
248
|
const matches = nodesForBinding(binding)
|
|
@@ -195,6 +307,7 @@ function initializeVisualBinding () {
|
|
|
195
307
|
}
|
|
196
308
|
function showFeedback () {
|
|
197
309
|
for (const [id, binding] of saved) {
|
|
310
|
+
if (!deviceAvailable(id)) continue
|
|
198
311
|
if (binding.mode === 'write' || binding.readMeta?.meter || binding.readMeta?.xySeries) continue
|
|
199
312
|
const node = nodeForBinding(binding)
|
|
200
313
|
if (!node) continue
|
|
@@ -238,6 +351,17 @@ function initializeVisualBinding () {
|
|
|
238
351
|
}
|
|
239
352
|
}
|
|
240
353
|
function attachControls () {
|
|
354
|
+
if (disposed) return
|
|
355
|
+
nodeIndex = null
|
|
356
|
+
for (const [node, listeners] of controlListeners) {
|
|
357
|
+
const binding = saved.get(listeners.id)
|
|
358
|
+
if (!binding || nodeForBinding(binding) !== node) {
|
|
359
|
+
for (const stop of listeners.stops) stop()
|
|
360
|
+
controlListeners.delete(node)
|
|
361
|
+
pendingText.delete(listeners.id)
|
|
362
|
+
attached.delete(node)
|
|
363
|
+
}
|
|
364
|
+
}
|
|
241
365
|
for (const [node, button] of buttonPresses) {
|
|
242
366
|
const binding = saved.get(button.id)
|
|
243
367
|
if (!binding || nodeForBinding(binding) !== node) {
|
|
@@ -256,16 +380,18 @@ function initializeVisualBinding () {
|
|
|
256
380
|
}
|
|
257
381
|
for (const node of root.querySelectorAll('[data-cp-auto]')) {
|
|
258
382
|
if (attached.has(node)) continue
|
|
259
|
-
const
|
|
260
|
-
|
|
383
|
+
const matched = bindingsByLocator.get(node.getAttribute('data-cp-auto'))
|
|
384
|
+
?.find(([, entry]) => bindingMatchesNode(node, entry))
|
|
261
385
|
if (!matched) continue
|
|
262
386
|
const [id, binding] = matched
|
|
263
387
|
if (nodeForBinding(binding) !== node) continue
|
|
264
388
|
attached.add(node)
|
|
265
389
|
if (binding.mode === 'read') continue
|
|
390
|
+
const stops = []
|
|
391
|
+
controlListeners.set(node, { id, stops })
|
|
266
392
|
if (node.tagName === 'BUTTON' && binding.adapter === 'switch') {
|
|
267
393
|
buttonPresses.set(node, { id, controller: component.__attachButtonPress({ node,
|
|
268
|
-
canPress: () => !disposed && !editing && component.boundAuthenticated && component.boundFeedbackConnected }) })
|
|
394
|
+
canPress: () => !disposed && !editing && component.boundAuthenticated && component.boundFeedbackConnected && deviceAvailable(id) }) })
|
|
269
395
|
}
|
|
270
396
|
if (binding.buttonBehavior === 'momentary' && node.tagName === 'BUTTON') {
|
|
271
397
|
if (!momentaryStates.has(id)) momentaryStates.set(id, { seq: 0 })
|
|
@@ -273,7 +399,7 @@ function initializeVisualBinding () {
|
|
|
273
399
|
state: momentaryStates.get(id),
|
|
274
400
|
endpoint: component.__boundPanelConfig().endpoint,
|
|
275
401
|
getSession: () => component.__boundPanelSession,
|
|
276
|
-
canStart: () => !disposed && !editing && component.boundAuthenticated && component.boundFeedbackConnected,
|
|
402
|
+
canStart: () => !disposed && !editing && component.boundAuthenticated && component.boundFeedbackConnected && deviceAvailable(id),
|
|
277
403
|
request: (...args) => component.__panelFetch(...args),
|
|
278
404
|
onError: error => console.error('ControlPanel momentary command failed', error) })
|
|
279
405
|
momentaryButtons.set(node, { id, controller })
|
|
@@ -282,7 +408,8 @@ function initializeVisualBinding () {
|
|
|
282
408
|
if (binding.component) {
|
|
283
409
|
if (binding.adapter === 'text-input') {
|
|
284
410
|
const commit = () => {
|
|
285
|
-
if (!pendingText.has(id) || editing || !component.boundAuthenticated)
|
|
411
|
+
if (disposed || !pendingText.has(id) || editing || !component.boundAuthenticated || !deviceAvailable(id) ||
|
|
412
|
+
nodeForBinding(binding) !== node) return
|
|
286
413
|
const value = pendingText.get(id)
|
|
287
414
|
pendingText.delete(id)
|
|
288
415
|
try { component.sendBoundControl({ id, value, final: true }) } catch (error) {
|
|
@@ -293,61 +420,71 @@ function initializeVisualBinding () {
|
|
|
293
420
|
if (component.$nextTick) void component.$nextTick(commit)
|
|
294
421
|
else commit()
|
|
295
422
|
}
|
|
296
|
-
node
|
|
297
|
-
node
|
|
423
|
+
listen(node, 'change', afterModelUpdate, false, stops)
|
|
424
|
+
listen(node, 'focusout', afterModelUpdate, false, stops)
|
|
298
425
|
if (binding.component === 'v-text-field') {
|
|
299
|
-
node
|
|
426
|
+
listen(node, 'keydown', event => {
|
|
300
427
|
if (event.key === 'Enter') afterModelUpdate()
|
|
301
|
-
})
|
|
428
|
+
}, false, stops)
|
|
302
429
|
}
|
|
303
430
|
}
|
|
304
431
|
continue
|
|
305
432
|
}
|
|
306
433
|
const send = final => {
|
|
307
|
-
if (editing || !component.boundAuthenticated) return
|
|
434
|
+
if (disposed || editing || !component.boundAuthenticated || !deviceAvailable(id) || nodeForBinding(binding) !== node) return
|
|
308
435
|
try { component.sendBoundControl({ id, value: valueFor(binding, node), final }) } catch (error) {
|
|
309
436
|
console.error('ControlPanel binding command failed', error)
|
|
310
437
|
}
|
|
311
438
|
}
|
|
312
439
|
if (binding.adapter === 'range') {
|
|
313
|
-
node
|
|
314
|
-
node
|
|
440
|
+
listen(node, 'input', () => send(false), true, stops)
|
|
441
|
+
listen(node, 'change', () => send(true), true, stops)
|
|
315
442
|
} else if (binding.adapter === 'switch' && node.tagName === 'BUTTON') {
|
|
316
|
-
node
|
|
443
|
+
listen(node, 'click', event => {
|
|
317
444
|
event.preventDefault()
|
|
318
445
|
send(true)
|
|
319
|
-
})
|
|
320
|
-
} else node
|
|
446
|
+
}, false, stops)
|
|
447
|
+
} else listen(node, 'change', () => send(true), false, stops)
|
|
321
448
|
}
|
|
322
449
|
if (meterEntries.length) {
|
|
450
|
+
const previous = new Map(meterTargets)
|
|
323
451
|
meterTargets.clear()
|
|
324
452
|
for (const [id, binding] of meterEntries) {
|
|
325
453
|
const node = nodeForBinding(binding)
|
|
326
|
-
if (node) meterTargets.set(id, { node, binding })
|
|
454
|
+
if (node) meterTargets.set(id, previous.get(id)?.node === node ? previous.get(id) : { node, binding })
|
|
327
455
|
}
|
|
328
456
|
if (lastMeterFrame) applyMeterFrame(lastMeterFrame.frame, lastMeterFrame.ids)
|
|
329
457
|
else invalidateMeters()
|
|
330
458
|
}
|
|
331
459
|
if (xySeriesEntries.length) {
|
|
460
|
+
const previous = new Map(xySeriesTargets)
|
|
332
461
|
xySeriesTargets.clear()
|
|
333
462
|
for (const [id, binding] of xySeriesEntries) {
|
|
334
463
|
const node = nodeForBinding(binding)
|
|
335
|
-
if (node) xySeriesTargets.set(id, { node, binding })
|
|
464
|
+
if (node) xySeriesTargets.set(id, previous.get(id)?.node === node ? previous.get(id) : { node, binding })
|
|
336
465
|
}
|
|
337
466
|
if (lastXYSeriesFrame) applyXYSeriesFrame(lastXYSeriesFrame.frame, lastXYSeriesFrame.ids)
|
|
338
467
|
else invalidateXYSeries()
|
|
339
468
|
}
|
|
340
469
|
showFeedback()
|
|
470
|
+
refreshAvailability()
|
|
341
471
|
}
|
|
342
472
|
function applyMeterFrame (frame, ids) {
|
|
343
473
|
lastMeterFrame = { frame, ids }
|
|
344
474
|
for (let index = 0; index < ids.length; index++) {
|
|
345
475
|
const target = meterTargets.get(ids[index])
|
|
346
|
-
if (!target) continue
|
|
476
|
+
if (!target || target.frame === frame) continue
|
|
477
|
+
target.frame = frame
|
|
347
478
|
const { node, binding } = target
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
479
|
+
const id = ids[index], version = sourceVersion(frame, binding)
|
|
480
|
+
if (!deviceAvailable(id)) blockedTelemetry.set(id, version)
|
|
481
|
+
const valid = frame.valid[index] && deviceAvailable(id) &&
|
|
482
|
+
(!blockedTelemetry.has(id) || blockedTelemetry.get(id) !== version)
|
|
483
|
+
node.setAttribute('data-cpv-meter-valid', String(valid))
|
|
484
|
+
node.setAttribute('aria-invalid', String(!valid))
|
|
485
|
+
setAvailable(node, valid)
|
|
486
|
+
if (!valid) continue
|
|
487
|
+
blockedTelemetry.delete(id)
|
|
351
488
|
const value = mappedValue(frame.values[index], binding.readMeta, numericRange(binding, node))
|
|
352
489
|
if (binding.model) {
|
|
353
490
|
if (binding.repeat) setRepeatModel(binding, value)
|
|
@@ -360,17 +497,30 @@ function initializeVisualBinding () {
|
|
|
360
497
|
for (const { node } of meterTargets.values()) {
|
|
361
498
|
node.setAttribute('data-cpv-meter-valid', 'false')
|
|
362
499
|
node.setAttribute('aria-invalid', 'true')
|
|
500
|
+
setAvailable(node, false)
|
|
363
501
|
}
|
|
364
502
|
}
|
|
365
503
|
function applyXYSeriesFrame (frame, ids) {
|
|
366
504
|
lastXYSeriesFrame = { frame, ids }
|
|
367
505
|
for (let index = 0; index < ids.length; index++) {
|
|
368
506
|
const target = xySeriesTargets.get(ids[index])
|
|
369
|
-
if (!target) continue
|
|
507
|
+
if (!target || target.frame === frame) continue
|
|
508
|
+
target.frame = frame
|
|
509
|
+
target.invalidated = false
|
|
370
510
|
const { node, binding } = target
|
|
511
|
+
const id = ids[index], version = sourceVersion(frame, binding)
|
|
512
|
+
if (!deviceAvailable(id)) blockedTelemetry.set(id, version)
|
|
513
|
+
if (!deviceAvailable(id) || (blockedTelemetry.has(id) && blockedTelemetry.get(id) === version)) {
|
|
514
|
+
node.setAttribute('data-cpv-xy-series-valid', 'false')
|
|
515
|
+
setAvailable(node, false)
|
|
516
|
+
continue
|
|
517
|
+
}
|
|
518
|
+
if (frame.valid[index]) blockedTelemetry.delete(id)
|
|
371
519
|
node.setAttribute('data-cpv-xy-series-valid', String(frame.valid[index]))
|
|
520
|
+
node.setAttribute('aria-invalid', String(!frame.valid[index]))
|
|
521
|
+
setAvailable(node, frame.valid[index])
|
|
372
522
|
node.setAttribute('data-cpv-xy-series-seq', String(frame.seq))
|
|
373
|
-
const points = frame.valid[index] ? frame.values[index].map(point => point
|
|
523
|
+
const points = frame.valid[index] ? frame.values[index].map(point => [point[0], point[1]]) : []
|
|
374
524
|
if (binding.model) {
|
|
375
525
|
if (binding.repeat) setRepeatModel(binding, points)
|
|
376
526
|
else if (binding.model in component && !component.$options?.computed?.[binding.model]) component[binding.model] = points
|
|
@@ -379,9 +529,15 @@ function initializeVisualBinding () {
|
|
|
379
529
|
}
|
|
380
530
|
function invalidateXYSeries () {
|
|
381
531
|
lastXYSeriesFrame = null
|
|
382
|
-
for (const
|
|
532
|
+
for (const [id, target] of xySeriesTargets) {
|
|
533
|
+
if (target.invalidated) continue
|
|
534
|
+
target.invalidated = true
|
|
535
|
+
target.frame = null
|
|
536
|
+
const { node, binding } = target
|
|
383
537
|
node.setAttribute('data-cpv-xy-series-valid', 'false')
|
|
384
538
|
node.setAttribute('aria-invalid', 'true')
|
|
539
|
+
setAvailable(node, false)
|
|
540
|
+
if (!deviceAvailable(id)) continue
|
|
385
541
|
if (binding.model) {
|
|
386
542
|
if (binding.repeat) setRepeatModel(binding, [])
|
|
387
543
|
else if (binding.model in component && !component.$options?.computed?.[binding.model]) component[binding.model] = []
|
|
@@ -393,7 +549,7 @@ function initializeVisualBinding () {
|
|
|
393
549
|
if (!binding.component || !binding.model || binding.mode === 'read') continue
|
|
394
550
|
let finishTimer
|
|
395
551
|
const send = (value, final) => {
|
|
396
|
-
if (editing || !component.boundAuthenticated) return
|
|
552
|
+
if (disposed || editing || !component.boundAuthenticated || !deviceAvailable(id)) return
|
|
397
553
|
try {
|
|
398
554
|
let commandValue
|
|
399
555
|
if (binding.adapter === 'range') {
|
|
@@ -432,7 +588,7 @@ function initializeVisualBinding () {
|
|
|
432
588
|
feedbackModels.delete(id)
|
|
433
589
|
if (Object.is(value, feedbackValue)) return
|
|
434
590
|
}
|
|
435
|
-
if (editing || !component.boundAuthenticated) return
|
|
591
|
+
if (disposed || editing || !component.boundAuthenticated || !deviceAvailable(id)) return
|
|
436
592
|
if (binding.adapter === 'text-input') {
|
|
437
593
|
if (typeof value === 'string' || value == null) pendingText.set(id, value ?? '')
|
|
438
594
|
return
|
|
@@ -446,7 +602,12 @@ function initializeVisualBinding () {
|
|
|
446
602
|
cleanup.push(() => { clearTimeout(finishTimer); unwatch() })
|
|
447
603
|
}
|
|
448
604
|
}
|
|
449
|
-
function closeOverlay () {
|
|
605
|
+
function closeOverlay () {
|
|
606
|
+
overlayCleanup?.()
|
|
607
|
+
overlayCleanup = null
|
|
608
|
+
overlay?.remove()
|
|
609
|
+
overlay = null
|
|
610
|
+
}
|
|
450
611
|
function sameBindingTarget (entry, candidate) {
|
|
451
612
|
return entry.locator === candidate.locator &&
|
|
452
613
|
(!candidate.repeat || String(entry.instanceKey) === String(candidate.instanceKey))
|
|
@@ -484,14 +645,13 @@ function initializeVisualBinding () {
|
|
|
484
645
|
const outside = event => {
|
|
485
646
|
if (!overlay?.contains(event.target)) {
|
|
486
647
|
closeOverlay()
|
|
487
|
-
document.removeEventListener('pointerdown', outside, true)
|
|
488
648
|
}
|
|
489
649
|
}
|
|
490
650
|
const timer = setTimeout(() => document.addEventListener('pointerdown', outside, true), 0)
|
|
491
|
-
|
|
651
|
+
overlayCleanup = () => {
|
|
492
652
|
clearTimeout(timer)
|
|
493
653
|
document.removeEventListener('pointerdown', outside, true)
|
|
494
|
-
}
|
|
654
|
+
}
|
|
495
655
|
}
|
|
496
656
|
function showDialog (title) {
|
|
497
657
|
closeOverlay()
|
|
@@ -553,9 +713,9 @@ function initializeVisualBinding () {
|
|
|
553
713
|
submit.setAttribute('aria-busy', 'true')
|
|
554
714
|
message.textContent = ''
|
|
555
715
|
try {
|
|
556
|
-
await login(user.value, password.value)
|
|
716
|
+
const loaded = await login(user.value, password.value)
|
|
557
717
|
if (disposed) return
|
|
558
|
-
closeLoginScreen()
|
|
718
|
+
if (loaded !== false) closeLoginScreen()
|
|
559
719
|
} catch (error) {
|
|
560
720
|
message.textContent = error.message
|
|
561
721
|
submit.textContent = 'Log in'
|
|
@@ -576,17 +736,20 @@ function initializeVisualBinding () {
|
|
|
576
736
|
if (!response.ok) throw new Error(result.error || 'Login failed')
|
|
577
737
|
if (disposed) return
|
|
578
738
|
editSession = result
|
|
579
|
-
|
|
739
|
+
markResumePending(false)
|
|
740
|
+
return startEditor()
|
|
580
741
|
}, true)
|
|
581
742
|
}
|
|
582
743
|
function normalLoginView () {
|
|
583
744
|
showLoginForm('Sign in', (username, password) => component.loginBoundPanel(username, password))
|
|
584
745
|
}
|
|
585
746
|
async function loadEditor () {
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
747
|
+
const resumed = readResumeData()
|
|
748
|
+
const [config, response] = resumed
|
|
749
|
+
? [resumed.editorConfig, { devices: resumed.devices }]
|
|
750
|
+
: await Promise.all([request('/config'), request('/devices')])
|
|
589
751
|
if (disposed) return
|
|
752
|
+
snapshot = config
|
|
590
753
|
devices = response.devices
|
|
591
754
|
draft = snapshot.bindings.map(entry => ({ ...entry, onebased: numberingMode(entry) }))
|
|
592
755
|
closeOverlay()
|
|
@@ -595,6 +758,58 @@ function initializeVisualBinding () {
|
|
|
595
758
|
inform('Choose a control on the page')
|
|
596
759
|
updateBindingMarkers()
|
|
597
760
|
meterClient?.setAuth({ endpoint: base, csrf: editSession.csrf, header: 'X-Panel-CSRF' })
|
|
761
|
+
xySeriesClient?.setAuth({ endpoint: base, csrf: editSession.csrf, header: 'X-Panel-CSRF' })
|
|
762
|
+
closeLoginScreen()
|
|
763
|
+
clearResumeData()
|
|
764
|
+
}
|
|
765
|
+
function loadingError (error, editor, retry) {
|
|
766
|
+
if (disposed) return
|
|
767
|
+
const content = coverPage('Unable to load panel', editor ? 'editor' : 'panel', 'error')
|
|
768
|
+
const message = element('p', error.message, 'cpv-message')
|
|
769
|
+
message.setAttribute('role', 'alert')
|
|
770
|
+
content.append(message, button('Retry', () => {
|
|
771
|
+
coverPage('Loading...', editor ? 'editor' : 'panel')
|
|
772
|
+
retry()
|
|
773
|
+
}))
|
|
774
|
+
}
|
|
775
|
+
async function startEditor () {
|
|
776
|
+
try { await loadEditor(); return true } catch (error) {
|
|
777
|
+
if (disposed) return false
|
|
778
|
+
if (error.status === 401) { editSession = null; loginView() }
|
|
779
|
+
else loadingError(error, true, startEditor)
|
|
780
|
+
return false
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
async function restoreEditor () {
|
|
784
|
+
const resumingAfterApply = resumePending()
|
|
785
|
+
const attempts = resumingAfterApply ? 25 : 1
|
|
786
|
+
let lastError
|
|
787
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
788
|
+
try {
|
|
789
|
+
const session = await request('/session')
|
|
790
|
+
if (disposed) return
|
|
791
|
+
editSession = session
|
|
792
|
+
const loaded = await startEditor()
|
|
793
|
+
if (loaded) markResumePending(false)
|
|
794
|
+
return
|
|
795
|
+
} catch (error) {
|
|
796
|
+
lastError = error
|
|
797
|
+
if (disposed) return
|
|
798
|
+
if (!resumingAfterApply || attempt + 1 >= attempts) break
|
|
799
|
+
await wait(Math.min(250 + attempt * 250, 1000))
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
if (disposed) return
|
|
803
|
+
if (lastError?.status === 401) loginView()
|
|
804
|
+
else loadingError(lastError || new Error('Editor session unavailable'), true, restoreEditor)
|
|
805
|
+
}
|
|
806
|
+
async function restorePanel () {
|
|
807
|
+
try {
|
|
808
|
+
const session = await component.restoreBoundPanelSession(12000)
|
|
809
|
+
if (disposed) return
|
|
810
|
+
if (session || component.boundAuthenticated) closeLoginScreen()
|
|
811
|
+
else normalLoginView()
|
|
812
|
+
} catch (error) { loadingError(error, false, restorePanel) }
|
|
598
813
|
}
|
|
599
814
|
function choose (label, options, selected) {
|
|
600
815
|
const select = element('select')
|
|
@@ -627,8 +842,7 @@ function initializeVisualBinding () {
|
|
|
627
842
|
? '.' + String(candidate.instanceKey).replace(/[^A-Za-z0-9._/-]/g, '-').slice(0, 48)
|
|
628
843
|
: ''
|
|
629
844
|
id.value = existing?.id || 'control.' + candidate.locator.slice(-12) + instanceSuffix
|
|
630
|
-
const modeOptions =
|
|
631
|
-
? ['read'] : ['both', 'write', 'read']
|
|
845
|
+
const modeOptions = matcher.modes(candidate.adapter)
|
|
632
846
|
const mode = field(form, 'Mode', choose('Mode', modeOptions.map(value => ({ value, text: value })), existing?.mode || modeOptions[0]))
|
|
633
847
|
const behavior = candidate.button ? field(form, 'Button behavior', choose('Button behavior', [
|
|
634
848
|
{ value: 'toggle', text: 'Click to toggle / 点击切换' },
|
|
@@ -645,6 +859,22 @@ function initializeVisualBinding () {
|
|
|
645
859
|
.map(item => ({ value: item.id, text: item.id })), existing?.device))
|
|
646
860
|
const write = field(form, 'Write point', choose('Write point', [], existing?.writePath))
|
|
647
861
|
const read = field(form, 'Feedback point', choose('Feedback point', [], existing?.readPath || existing?.writePath))
|
|
862
|
+
const reverse = candidate.adapter === 'switch' ? field(form, 'State reverse', element('input')) : null
|
|
863
|
+
if (reverse) {
|
|
864
|
+
reverse.type = 'checkbox'
|
|
865
|
+
reverse.setAttribute('aria-label', 'State reverse')
|
|
866
|
+
reverse.checked = matcher.reverse(existing)
|
|
867
|
+
reverse.closest('label').classList.add('cpv-state-reverse')
|
|
868
|
+
}
|
|
869
|
+
function updateReverse () {
|
|
870
|
+
if (!reverse) return
|
|
871
|
+
const active = [mode.value !== 'read' && write.value, mode.value !== 'write' && read.value].filter(Boolean)
|
|
872
|
+
reverse.disabled = !active.length || active.some(path => {
|
|
873
|
+
const point = targets.find(target => target.path === path)
|
|
874
|
+
return !point || !matcher.states(point)
|
|
875
|
+
})
|
|
876
|
+
reverse.closest('label').dataset.disabled = String(reverse.disabled)
|
|
877
|
+
}
|
|
648
878
|
const numbering = field(form, 'Numbering', choose('Numbering', [
|
|
649
879
|
{ value: 'false', text: 'Zero-based' }, { value: 'true', text: 'One-based' }
|
|
650
880
|
], String(numberingMode(existing))))
|
|
@@ -682,6 +912,7 @@ function initializeVisualBinding () {
|
|
|
682
912
|
: mode.value === 'write' ? writeCount : Math.min(writeCount, readCount)
|
|
683
913
|
message.textContent = targets.length && !available ? 'No compatible points for this control'
|
|
684
914
|
: targets.length ? '' : 'No points available'
|
|
915
|
+
updateReverse()
|
|
685
916
|
}
|
|
686
917
|
device.addEventListener('change', async () => {
|
|
687
918
|
const generation = ++targetGeneration
|
|
@@ -703,7 +934,9 @@ function initializeVisualBinding () {
|
|
|
703
934
|
})
|
|
704
935
|
write.addEventListener('change', () => {
|
|
705
936
|
if (mode.value === 'both') read.value = write.value
|
|
937
|
+
updateReverse()
|
|
706
938
|
})
|
|
939
|
+
read.addEventListener('change', updateReverse)
|
|
707
940
|
mode.addEventListener('change', updateTargets)
|
|
708
941
|
behavior?.addEventListener('change', () => { updateButtonMode(); updateTargets() })
|
|
709
942
|
updateTargets()
|
|
@@ -737,6 +970,7 @@ function initializeVisualBinding () {
|
|
|
737
970
|
label: name.value.trim(), mode: mode.value, device: device.value,
|
|
738
971
|
writePath: mode.value === 'read' ? '' : write.value,
|
|
739
972
|
readPath: mode.value === 'write' ? '' : read.value,
|
|
973
|
+
...(reverse ? { stateReverse: reverse.checked } : {}),
|
|
740
974
|
onebased: numbering.value === 'true'
|
|
741
975
|
}
|
|
742
976
|
draft = draft.filter(item => !sameBindingTarget(item, candidate))
|
|
@@ -767,7 +1001,12 @@ function initializeVisualBinding () {
|
|
|
767
1001
|
try {
|
|
768
1002
|
await request('/apply', 'POST', {
|
|
769
1003
|
rev: snapshot.rev, templateHash: snapshot.templateHash, entries: draft
|
|
1004
|
+
}).then(result => {
|
|
1005
|
+
try {
|
|
1006
|
+
window.sessionStorage?.setItem(resumeDataKey, JSON.stringify(result))
|
|
1007
|
+
} catch (_) {}
|
|
770
1008
|
})
|
|
1009
|
+
markResumePending(true)
|
|
771
1010
|
window.location.reload()
|
|
772
1011
|
} catch (error) {
|
|
773
1012
|
inform(error.message, true)
|
|
@@ -869,7 +1108,7 @@ function initializeVisualBinding () {
|
|
|
869
1108
|
return candidate
|
|
870
1109
|
}
|
|
871
1110
|
function chooseCandidate (event) {
|
|
872
|
-
if (!editing || !editSession) return
|
|
1111
|
+
if (disposed || !editing || !editSession) return
|
|
873
1112
|
const candidate = candidateFromEvent(event)
|
|
874
1113
|
if (!candidate) return
|
|
875
1114
|
event.preventDefault()
|
|
@@ -877,26 +1116,34 @@ function initializeVisualBinding () {
|
|
|
877
1116
|
if (!candidate.supported) { inform(candidate.reason, true); return }
|
|
878
1117
|
contextMenu(candidate, event.clientX || 12, event.clientY || 12)
|
|
879
1118
|
}
|
|
880
|
-
root
|
|
1119
|
+
listen(root, 'contextmenu', chooseCandidate, true)
|
|
881
1120
|
for (const name of ['input', 'change']) {
|
|
882
|
-
root
|
|
1121
|
+
listen(root, name, event => {
|
|
883
1122
|
if (!editing || !event.target?.closest?.('[data-cp-auto]')) return
|
|
884
1123
|
event.preventDefault()
|
|
885
1124
|
event.stopImmediatePropagation()
|
|
886
1125
|
}, true)
|
|
887
1126
|
}
|
|
888
|
-
|
|
889
|
-
|
|
1127
|
+
for (const name of ['click', 'pointerdown', 'keydown', 'input', 'change']) {
|
|
1128
|
+
listen(root, name, event => {
|
|
1129
|
+
if (!editing && event.target?.closest?.('[data-cpv-unavailable="true"]')) {
|
|
1130
|
+
event.preventDefault()
|
|
1131
|
+
event.stopImmediatePropagation()
|
|
1132
|
+
}
|
|
1133
|
+
}, true)
|
|
1134
|
+
}
|
|
1135
|
+
listen(root, 'click', event => { if (selecting) chooseCandidate(event) }, true)
|
|
1136
|
+
listen(root, 'keydown', event => {
|
|
890
1137
|
if (event.key === 'Enter' && selecting) chooseCandidate(event)
|
|
891
1138
|
}, true)
|
|
892
1139
|
let longPress
|
|
893
|
-
root
|
|
1140
|
+
listen(root, 'pointerdown', event => {
|
|
894
1141
|
if (!editing || event.pointerType === 'mouse') return
|
|
895
1142
|
const candidate = candidateFromEvent(event)
|
|
896
1143
|
if (candidate) longPress = setTimeout(() => chooseCandidate(event), 550)
|
|
897
1144
|
}, true)
|
|
898
1145
|
for (const name of ['pointerup', 'pointercancel', 'pointermove']) {
|
|
899
|
-
root
|
|
1146
|
+
listen(root, name, () => clearTimeout(longPress), true)
|
|
900
1147
|
}
|
|
901
1148
|
attachControls()
|
|
902
1149
|
attachModelControls()
|
|
@@ -942,8 +1189,15 @@ function initializeVisualBinding () {
|
|
|
942
1189
|
attachControls()
|
|
943
1190
|
updateBindingMarkers()
|
|
944
1191
|
})
|
|
945
|
-
observer.observe(root, { childList: true, subtree: true, attributes: true,
|
|
1192
|
+
observer.observe(root, { childList: true, subtree: true, attributes: true,
|
|
1193
|
+
attributeFilter: ['disabled', 'data-cp-auto', 'data-cp-repeat', 'data-cp-repeat-key'] })
|
|
946
1194
|
cleanup.push(() => {
|
|
1195
|
+
clearTimeout(longPress)
|
|
1196
|
+
for (const listeners of controlListeners.values()) {
|
|
1197
|
+
for (const stop of listeners.stops) stop()
|
|
1198
|
+
}
|
|
1199
|
+
controlListeners.clear()
|
|
1200
|
+
pendingText.clear()
|
|
947
1201
|
for (const button of buttonPresses.values()) button.controller.dispose()
|
|
948
1202
|
buttonPresses.clear()
|
|
949
1203
|
for (const button of momentaryButtons.values()) button.controller.dispose()
|
|
@@ -955,14 +1209,18 @@ function initializeVisualBinding () {
|
|
|
955
1209
|
for (const button of buttonPresses.values()) button.controller.release()
|
|
956
1210
|
for (const button of momentaryButtons.values()) button.controller.release()
|
|
957
1211
|
}
|
|
1212
|
+
refreshAvailability()
|
|
958
1213
|
}))
|
|
1214
|
+
cleanup.push(this.$watch('boundAvailability', () => { refreshAvailability(); showFeedback() }))
|
|
959
1215
|
const unwatch = this.$watch('boundState', showFeedback)
|
|
960
1216
|
cleanup.push(() => unwatch(), () => observer.disconnect(), () => toolbar?.remove(),
|
|
961
1217
|
() => closeOverlay(), () => closeLoginScreen())
|
|
1218
|
+
cleanup.push(() => { for (const [node] of disabledNodes) setAvailable(node, true) })
|
|
962
1219
|
this.__visualBindingCleanup = cleanup
|
|
963
1220
|
|
|
964
1221
|
const style = element('style')
|
|
965
1222
|
style.textContent = `
|
|
1223
|
+
[data-cpv-unavailable="true"]{filter:grayscale(1) !important;opacity:.45 !important;pointer-events:none !important}
|
|
966
1224
|
button[data-cpv-press="true"]{translate:0 2px !important;box-shadow:inset 0 2px 4px #0003 !important}
|
|
967
1225
|
.cpv-toolbar,.cpv-dialog,.cpv-menu{font:14px/1.4 system-ui,sans-serif;color:#182224;background:#fff}
|
|
968
1226
|
.cpv-toolbar{position:fixed;z-index:2147483000;top:12px;right:12px;display:flex;align-items:center;gap:8px;padding:8px;border:1px solid #9baaad;box-shadow:0 8px 24px #0003;max-width:calc(100vw - 24px)}
|
|
@@ -1013,7 +1271,9 @@ function initializeVisualBinding () {
|
|
|
1013
1271
|
.cpv-scrim{position:fixed;inset:0;z-index:2147483001;background:#0008;display:flex;align-items:center;justify-content:center;padding:16px}
|
|
1014
1272
|
.cpv-dialog{width:min(460px,100%);max-height:min(90vh,720px);overflow:auto;padding:28px 18px 18px;border-radius:6px;box-shadow:0 12px 36px #0005}
|
|
1015
1273
|
.cpv-dialog h2{font-size:18px;margin:0 0 20px}.cpv-field{display:block;margin:10px 0}.cpv-field span{display:block;font-size:12px;margin-bottom:4px}
|
|
1274
|
+
.cpv-field[hidden]{display:none}
|
|
1016
1275
|
.cpv-field input,.cpv-field select{box-sizing:border-box;width:100%;padding:8px;border:1px solid #86999c;border-radius:4px;background:white;color:#182224;font:inherit}
|
|
1276
|
+
.cpv-state-reverse{display:flex;align-items:center;gap:10px}.cpv-state-reverse span{margin:0}.cpv-field input[type=checkbox]{width:18px;height:18px;padding:0;accent-color:#008d35;flex:none}
|
|
1017
1277
|
.cpv-field[data-disabled=true] span{color:#66787c}.cpv-field select:disabled{background:#edf1f2;color:#66787c;border-color:#c5cfd1;cursor:not-allowed;opacity:1}
|
|
1018
1278
|
.cpv-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:16px}.cpv-dialog>button:last-child{margin-top:20px}.cpv-message{min-height:18px;font-size:12px}
|
|
1019
1279
|
.cpv-menu{position:fixed;z-index:2147483002;width:170px;padding:4px;border:1px solid #9baaad;box-shadow:0 8px 24px #0003}
|
|
@@ -1033,25 +1293,14 @@ function initializeVisualBinding () {
|
|
|
1033
1293
|
document.body.classList.add('cpv-editing')
|
|
1034
1294
|
cleanup.push(() => document.body.classList.remove('cpv-editing'))
|
|
1035
1295
|
coverPage('Loading...', 'editor')
|
|
1036
|
-
|
|
1037
|
-
if (disposed) return
|
|
1038
|
-
editSession = session
|
|
1039
|
-
return loadEditor()
|
|
1040
|
-
})
|
|
1041
|
-
.then(closeLoginScreen).catch(() => loginView())
|
|
1296
|
+
restoreEditor()
|
|
1042
1297
|
} else {
|
|
1043
1298
|
coverPage('Loading...', 'panel')
|
|
1044
1299
|
cleanup.push(this.$watch('boundAuthenticated', authenticated => {
|
|
1045
1300
|
if (authenticated) closeLoginScreen()
|
|
1046
1301
|
else if (!loginScreen) normalLoginView()
|
|
1047
1302
|
}))
|
|
1048
|
-
|
|
1049
|
-
if (disposed) return
|
|
1050
|
-
if (session || component.boundAuthenticated) closeLoginScreen()
|
|
1051
|
-
else normalLoginView()
|
|
1052
|
-
}).catch(() => {
|
|
1053
|
-
normalLoginView()
|
|
1054
|
-
})
|
|
1303
|
+
restorePanel()
|
|
1055
1304
|
}
|
|
1056
1305
|
}
|
|
1057
1306
|
|