node-red-contrib-room-tools 1.9.4 → 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 +54 -16
- package/README.md +19 -3
- package/control-bound-protocol.js +23 -6
- package/control-catalog.js +26 -3
- package/control-panel.js +29 -3
- package/control-policy.js +34 -5
- package/control-type-model.js +68 -8
- package/device-availability.js +104 -0
- package/gateway.cjs +13 -1
- package/managed-native-bridge.js +3 -1
- package/meter-frame.js +7 -1
- package/native-binding-plan.js +47 -10
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/panel-binding-editor.js +208 -30
- package/panel-momentary.js +7 -1
- package/panel-template-instrumentation.js +1 -0
- package/panel-visual-apply.js +86 -27
- package/panel-visual-bindings.js +18 -4
- package/xy-series-frame.js +7 -1
package/panel-binding-editor.js
CHANGED
|
@@ -25,10 +25,57 @@ function initializeVisualBinding () {
|
|
|
25
25
|
const momentaryStates = new Map()
|
|
26
26
|
const feedbackModels = new Map()
|
|
27
27
|
const pendingText = new Map()
|
|
28
|
+
const disabledNodes = new Map()
|
|
29
|
+
const blockedTelemetry = new Map()
|
|
28
30
|
const cleanup = []
|
|
29
31
|
let disposed = false
|
|
30
32
|
cleanup.push(() => { disposed = true })
|
|
31
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
|
+
}
|
|
32
79
|
let editSession = null
|
|
33
80
|
let snapshot = null
|
|
34
81
|
let draft = []
|
|
@@ -41,6 +88,31 @@ function initializeVisualBinding () {
|
|
|
41
88
|
let overlayCleanup
|
|
42
89
|
let status
|
|
43
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
|
+
}
|
|
44
116
|
|
|
45
117
|
function element (tag, text, className) {
|
|
46
118
|
const node = document.createElement(tag)
|
|
@@ -80,7 +152,11 @@ function initializeVisualBinding () {
|
|
|
80
152
|
body: method === 'POST' ? JSON.stringify(body || {}) : undefined
|
|
81
153
|
}, path === '/apply' ? 120000 : (timeoutMs ?? 12000),
|
|
82
154
|
async response => ({ response, result: await response.json() }))
|
|
83
|
-
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
|
+
}
|
|
84
160
|
return result
|
|
85
161
|
}
|
|
86
162
|
function numericRange (binding, node) {
|
|
@@ -231,6 +307,7 @@ function initializeVisualBinding () {
|
|
|
231
307
|
}
|
|
232
308
|
function showFeedback () {
|
|
233
309
|
for (const [id, binding] of saved) {
|
|
310
|
+
if (!deviceAvailable(id)) continue
|
|
234
311
|
if (binding.mode === 'write' || binding.readMeta?.meter || binding.readMeta?.xySeries) continue
|
|
235
312
|
const node = nodeForBinding(binding)
|
|
236
313
|
if (!node) continue
|
|
@@ -314,7 +391,7 @@ function initializeVisualBinding () {
|
|
|
314
391
|
controlListeners.set(node, { id, stops })
|
|
315
392
|
if (node.tagName === 'BUTTON' && binding.adapter === 'switch') {
|
|
316
393
|
buttonPresses.set(node, { id, controller: component.__attachButtonPress({ node,
|
|
317
|
-
canPress: () => !disposed && !editing && component.boundAuthenticated && component.boundFeedbackConnected }) })
|
|
394
|
+
canPress: () => !disposed && !editing && component.boundAuthenticated && component.boundFeedbackConnected && deviceAvailable(id) }) })
|
|
318
395
|
}
|
|
319
396
|
if (binding.buttonBehavior === 'momentary' && node.tagName === 'BUTTON') {
|
|
320
397
|
if (!momentaryStates.has(id)) momentaryStates.set(id, { seq: 0 })
|
|
@@ -322,7 +399,7 @@ function initializeVisualBinding () {
|
|
|
322
399
|
state: momentaryStates.get(id),
|
|
323
400
|
endpoint: component.__boundPanelConfig().endpoint,
|
|
324
401
|
getSession: () => component.__boundPanelSession,
|
|
325
|
-
canStart: () => !disposed && !editing && component.boundAuthenticated && component.boundFeedbackConnected,
|
|
402
|
+
canStart: () => !disposed && !editing && component.boundAuthenticated && component.boundFeedbackConnected && deviceAvailable(id),
|
|
326
403
|
request: (...args) => component.__panelFetch(...args),
|
|
327
404
|
onError: error => console.error('ControlPanel momentary command failed', error) })
|
|
328
405
|
momentaryButtons.set(node, { id, controller })
|
|
@@ -331,7 +408,7 @@ function initializeVisualBinding () {
|
|
|
331
408
|
if (binding.component) {
|
|
332
409
|
if (binding.adapter === 'text-input') {
|
|
333
410
|
const commit = () => {
|
|
334
|
-
if (disposed || !pendingText.has(id) || editing || !component.boundAuthenticated ||
|
|
411
|
+
if (disposed || !pendingText.has(id) || editing || !component.boundAuthenticated || !deviceAvailable(id) ||
|
|
335
412
|
nodeForBinding(binding) !== node) return
|
|
336
413
|
const value = pendingText.get(id)
|
|
337
414
|
pendingText.delete(id)
|
|
@@ -354,7 +431,7 @@ function initializeVisualBinding () {
|
|
|
354
431
|
continue
|
|
355
432
|
}
|
|
356
433
|
const send = final => {
|
|
357
|
-
if (disposed || editing || !component.boundAuthenticated || nodeForBinding(binding) !== node) return
|
|
434
|
+
if (disposed || editing || !component.boundAuthenticated || !deviceAvailable(id) || nodeForBinding(binding) !== node) return
|
|
358
435
|
try { component.sendBoundControl({ id, value: valueFor(binding, node), final }) } catch (error) {
|
|
359
436
|
console.error('ControlPanel binding command failed', error)
|
|
360
437
|
}
|
|
@@ -390,6 +467,7 @@ function initializeVisualBinding () {
|
|
|
390
467
|
else invalidateXYSeries()
|
|
391
468
|
}
|
|
392
469
|
showFeedback()
|
|
470
|
+
refreshAvailability()
|
|
393
471
|
}
|
|
394
472
|
function applyMeterFrame (frame, ids) {
|
|
395
473
|
lastMeterFrame = { frame, ids }
|
|
@@ -398,9 +476,15 @@ function initializeVisualBinding () {
|
|
|
398
476
|
if (!target || target.frame === frame) continue
|
|
399
477
|
target.frame = frame
|
|
400
478
|
const { node, binding } = target
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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)
|
|
404
488
|
const value = mappedValue(frame.values[index], binding.readMeta, numericRange(binding, node))
|
|
405
489
|
if (binding.model) {
|
|
406
490
|
if (binding.repeat) setRepeatModel(binding, value)
|
|
@@ -413,6 +497,7 @@ function initializeVisualBinding () {
|
|
|
413
497
|
for (const { node } of meterTargets.values()) {
|
|
414
498
|
node.setAttribute('data-cpv-meter-valid', 'false')
|
|
415
499
|
node.setAttribute('aria-invalid', 'true')
|
|
500
|
+
setAvailable(node, false)
|
|
416
501
|
}
|
|
417
502
|
}
|
|
418
503
|
function applyXYSeriesFrame (frame, ids) {
|
|
@@ -423,8 +508,17 @@ function initializeVisualBinding () {
|
|
|
423
508
|
target.frame = frame
|
|
424
509
|
target.invalidated = false
|
|
425
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)
|
|
426
519
|
node.setAttribute('data-cpv-xy-series-valid', String(frame.valid[index]))
|
|
427
520
|
node.setAttribute('aria-invalid', String(!frame.valid[index]))
|
|
521
|
+
setAvailable(node, frame.valid[index])
|
|
428
522
|
node.setAttribute('data-cpv-xy-series-seq', String(frame.seq))
|
|
429
523
|
const points = frame.valid[index] ? frame.values[index].map(point => [point[0], point[1]]) : []
|
|
430
524
|
if (binding.model) {
|
|
@@ -435,13 +529,15 @@ function initializeVisualBinding () {
|
|
|
435
529
|
}
|
|
436
530
|
function invalidateXYSeries () {
|
|
437
531
|
lastXYSeriesFrame = null
|
|
438
|
-
for (const target of xySeriesTargets
|
|
532
|
+
for (const [id, target] of xySeriesTargets) {
|
|
439
533
|
if (target.invalidated) continue
|
|
440
534
|
target.invalidated = true
|
|
441
535
|
target.frame = null
|
|
442
536
|
const { node, binding } = target
|
|
443
537
|
node.setAttribute('data-cpv-xy-series-valid', 'false')
|
|
444
538
|
node.setAttribute('aria-invalid', 'true')
|
|
539
|
+
setAvailable(node, false)
|
|
540
|
+
if (!deviceAvailable(id)) continue
|
|
445
541
|
if (binding.model) {
|
|
446
542
|
if (binding.repeat) setRepeatModel(binding, [])
|
|
447
543
|
else if (binding.model in component && !component.$options?.computed?.[binding.model]) component[binding.model] = []
|
|
@@ -453,7 +549,7 @@ function initializeVisualBinding () {
|
|
|
453
549
|
if (!binding.component || !binding.model || binding.mode === 'read') continue
|
|
454
550
|
let finishTimer
|
|
455
551
|
const send = (value, final) => {
|
|
456
|
-
if (disposed || editing || !component.boundAuthenticated) return
|
|
552
|
+
if (disposed || editing || !component.boundAuthenticated || !deviceAvailable(id)) return
|
|
457
553
|
try {
|
|
458
554
|
let commandValue
|
|
459
555
|
if (binding.adapter === 'range') {
|
|
@@ -492,7 +588,7 @@ function initializeVisualBinding () {
|
|
|
492
588
|
feedbackModels.delete(id)
|
|
493
589
|
if (Object.is(value, feedbackValue)) return
|
|
494
590
|
}
|
|
495
|
-
if (disposed || editing || !component.boundAuthenticated) return
|
|
591
|
+
if (disposed || editing || !component.boundAuthenticated || !deviceAvailable(id)) return
|
|
496
592
|
if (binding.adapter === 'text-input') {
|
|
497
593
|
if (typeof value === 'string' || value == null) pendingText.set(id, value ?? '')
|
|
498
594
|
return
|
|
@@ -617,9 +713,9 @@ function initializeVisualBinding () {
|
|
|
617
713
|
submit.setAttribute('aria-busy', 'true')
|
|
618
714
|
message.textContent = ''
|
|
619
715
|
try {
|
|
620
|
-
await login(user.value, password.value)
|
|
716
|
+
const loaded = await login(user.value, password.value)
|
|
621
717
|
if (disposed) return
|
|
622
|
-
closeLoginScreen()
|
|
718
|
+
if (loaded !== false) closeLoginScreen()
|
|
623
719
|
} catch (error) {
|
|
624
720
|
message.textContent = error.message
|
|
625
721
|
submit.textContent = 'Log in'
|
|
@@ -640,17 +736,20 @@ function initializeVisualBinding () {
|
|
|
640
736
|
if (!response.ok) throw new Error(result.error || 'Login failed')
|
|
641
737
|
if (disposed) return
|
|
642
738
|
editSession = result
|
|
643
|
-
|
|
739
|
+
markResumePending(false)
|
|
740
|
+
return startEditor()
|
|
644
741
|
}, true)
|
|
645
742
|
}
|
|
646
743
|
function normalLoginView () {
|
|
647
744
|
showLoginForm('Sign in', (username, password) => component.loginBoundPanel(username, password))
|
|
648
745
|
}
|
|
649
746
|
async function loadEditor () {
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
747
|
+
const resumed = readResumeData()
|
|
748
|
+
const [config, response] = resumed
|
|
749
|
+
? [resumed.editorConfig, { devices: resumed.devices }]
|
|
750
|
+
: await Promise.all([request('/config'), request('/devices')])
|
|
653
751
|
if (disposed) return
|
|
752
|
+
snapshot = config
|
|
654
753
|
devices = response.devices
|
|
655
754
|
draft = snapshot.bindings.map(entry => ({ ...entry, onebased: numberingMode(entry) }))
|
|
656
755
|
closeOverlay()
|
|
@@ -660,6 +759,57 @@ function initializeVisualBinding () {
|
|
|
660
759
|
updateBindingMarkers()
|
|
661
760
|
meterClient?.setAuth({ endpoint: base, csrf: editSession.csrf, header: 'X-Panel-CSRF' })
|
|
662
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) }
|
|
663
813
|
}
|
|
664
814
|
function choose (label, options, selected) {
|
|
665
815
|
const select = element('select')
|
|
@@ -709,6 +859,22 @@ function initializeVisualBinding () {
|
|
|
709
859
|
.map(item => ({ value: item.id, text: item.id })), existing?.device))
|
|
710
860
|
const write = field(form, 'Write point', choose('Write point', [], existing?.writePath))
|
|
711
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
|
+
}
|
|
712
878
|
const numbering = field(form, 'Numbering', choose('Numbering', [
|
|
713
879
|
{ value: 'false', text: 'Zero-based' }, { value: 'true', text: 'One-based' }
|
|
714
880
|
], String(numberingMode(existing))))
|
|
@@ -746,6 +912,7 @@ function initializeVisualBinding () {
|
|
|
746
912
|
: mode.value === 'write' ? writeCount : Math.min(writeCount, readCount)
|
|
747
913
|
message.textContent = targets.length && !available ? 'No compatible points for this control'
|
|
748
914
|
: targets.length ? '' : 'No points available'
|
|
915
|
+
updateReverse()
|
|
749
916
|
}
|
|
750
917
|
device.addEventListener('change', async () => {
|
|
751
918
|
const generation = ++targetGeneration
|
|
@@ -767,7 +934,9 @@ function initializeVisualBinding () {
|
|
|
767
934
|
})
|
|
768
935
|
write.addEventListener('change', () => {
|
|
769
936
|
if (mode.value === 'both') read.value = write.value
|
|
937
|
+
updateReverse()
|
|
770
938
|
})
|
|
939
|
+
read.addEventListener('change', updateReverse)
|
|
771
940
|
mode.addEventListener('change', updateTargets)
|
|
772
941
|
behavior?.addEventListener('change', () => { updateButtonMode(); updateTargets() })
|
|
773
942
|
updateTargets()
|
|
@@ -801,6 +970,7 @@ function initializeVisualBinding () {
|
|
|
801
970
|
label: name.value.trim(), mode: mode.value, device: device.value,
|
|
802
971
|
writePath: mode.value === 'read' ? '' : write.value,
|
|
803
972
|
readPath: mode.value === 'write' ? '' : read.value,
|
|
973
|
+
...(reverse ? { stateReverse: reverse.checked } : {}),
|
|
804
974
|
onebased: numbering.value === 'true'
|
|
805
975
|
}
|
|
806
976
|
draft = draft.filter(item => !sameBindingTarget(item, candidate))
|
|
@@ -831,7 +1001,12 @@ function initializeVisualBinding () {
|
|
|
831
1001
|
try {
|
|
832
1002
|
await request('/apply', 'POST', {
|
|
833
1003
|
rev: snapshot.rev, templateHash: snapshot.templateHash, entries: draft
|
|
1004
|
+
}).then(result => {
|
|
1005
|
+
try {
|
|
1006
|
+
window.sessionStorage?.setItem(resumeDataKey, JSON.stringify(result))
|
|
1007
|
+
} catch (_) {}
|
|
834
1008
|
})
|
|
1009
|
+
markResumePending(true)
|
|
835
1010
|
window.location.reload()
|
|
836
1011
|
} catch (error) {
|
|
837
1012
|
inform(error.message, true)
|
|
@@ -949,6 +1124,14 @@ function initializeVisualBinding () {
|
|
|
949
1124
|
event.stopImmediatePropagation()
|
|
950
1125
|
}, true)
|
|
951
1126
|
}
|
|
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
|
+
}
|
|
952
1135
|
listen(root, 'click', event => { if (selecting) chooseCandidate(event) }, true)
|
|
953
1136
|
listen(root, 'keydown', event => {
|
|
954
1137
|
if (event.key === 'Enter' && selecting) chooseCandidate(event)
|
|
@@ -1026,14 +1209,18 @@ function initializeVisualBinding () {
|
|
|
1026
1209
|
for (const button of buttonPresses.values()) button.controller.release()
|
|
1027
1210
|
for (const button of momentaryButtons.values()) button.controller.release()
|
|
1028
1211
|
}
|
|
1212
|
+
refreshAvailability()
|
|
1029
1213
|
}))
|
|
1214
|
+
cleanup.push(this.$watch('boundAvailability', () => { refreshAvailability(); showFeedback() }))
|
|
1030
1215
|
const unwatch = this.$watch('boundState', showFeedback)
|
|
1031
1216
|
cleanup.push(() => unwatch(), () => observer.disconnect(), () => toolbar?.remove(),
|
|
1032
1217
|
() => closeOverlay(), () => closeLoginScreen())
|
|
1218
|
+
cleanup.push(() => { for (const [node] of disabledNodes) setAvailable(node, true) })
|
|
1033
1219
|
this.__visualBindingCleanup = cleanup
|
|
1034
1220
|
|
|
1035
1221
|
const style = element('style')
|
|
1036
1222
|
style.textContent = `
|
|
1223
|
+
[data-cpv-unavailable="true"]{filter:grayscale(1) !important;opacity:.45 !important;pointer-events:none !important}
|
|
1037
1224
|
button[data-cpv-press="true"]{translate:0 2px !important;box-shadow:inset 0 2px 4px #0003 !important}
|
|
1038
1225
|
.cpv-toolbar,.cpv-dialog,.cpv-menu{font:14px/1.4 system-ui,sans-serif;color:#182224;background:#fff}
|
|
1039
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)}
|
|
@@ -1084,7 +1271,9 @@ function initializeVisualBinding () {
|
|
|
1084
1271
|
.cpv-scrim{position:fixed;inset:0;z-index:2147483001;background:#0008;display:flex;align-items:center;justify-content:center;padding:16px}
|
|
1085
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}
|
|
1086
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}
|
|
1087
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}
|
|
1088
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}
|
|
1089
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}
|
|
1090
1279
|
.cpv-menu{position:fixed;z-index:2147483002;width:170px;padding:4px;border:1px solid #9baaad;box-shadow:0 8px 24px #0003}
|
|
@@ -1104,25 +1293,14 @@ function initializeVisualBinding () {
|
|
|
1104
1293
|
document.body.classList.add('cpv-editing')
|
|
1105
1294
|
cleanup.push(() => document.body.classList.remove('cpv-editing'))
|
|
1106
1295
|
coverPage('Loading...', 'editor')
|
|
1107
|
-
|
|
1108
|
-
if (disposed) return
|
|
1109
|
-
editSession = session
|
|
1110
|
-
return loadEditor()
|
|
1111
|
-
})
|
|
1112
|
-
.then(closeLoginScreen).catch(() => loginView())
|
|
1296
|
+
restoreEditor()
|
|
1113
1297
|
} else {
|
|
1114
1298
|
coverPage('Loading...', 'panel')
|
|
1115
1299
|
cleanup.push(this.$watch('boundAuthenticated', authenticated => {
|
|
1116
1300
|
if (authenticated) closeLoginScreen()
|
|
1117
1301
|
else if (!loginScreen) normalLoginView()
|
|
1118
1302
|
}))
|
|
1119
|
-
|
|
1120
|
-
if (disposed) return
|
|
1121
|
-
if (session || component.boundAuthenticated) closeLoginScreen()
|
|
1122
|
-
else normalLoginView()
|
|
1123
|
-
}).catch(() => {
|
|
1124
|
-
normalLoginView()
|
|
1125
|
-
})
|
|
1303
|
+
restorePanel()
|
|
1126
1304
|
}
|
|
1127
1305
|
}
|
|
1128
1306
|
|
package/panel-momentary.js
CHANGED
|
@@ -53,7 +53,7 @@ function createMomentaryControls ({ routes, send, onError = () => {}, leaseMs =
|
|
|
53
53
|
let record = records.get(key)
|
|
54
54
|
if (!record) {
|
|
55
55
|
if (records.size >= 1024) throw Error('Momentary client limit reached')
|
|
56
|
-
record = { seq: 0, active: false, target: destination(command), identity,
|
|
56
|
+
record = { seq: 0, active: false, controlId: request.id, target: destination(command), identity,
|
|
57
57
|
off: resolveBoundCommand(routes, { _controlPanel: { kind: 'command', id: request.id, value: false, final: true } }) }
|
|
58
58
|
records.set(key, record)
|
|
59
59
|
}
|
|
@@ -62,6 +62,8 @@ function createMomentaryControls ({ routes, send, onError = () => {}, leaseMs =
|
|
|
62
62
|
record.seq = hold.seq
|
|
63
63
|
if (hold.action === 'release') release(record)
|
|
64
64
|
else if (hold.action === 'start') {
|
|
65
|
+
if ([...records.values()].some(other => other.active && other.target === record.target &&
|
|
66
|
+
!Object.is(other.off.payload, record.off.payload))) throw Error('Conflicting momentary state mapping')
|
|
65
67
|
const alreadyHeld = occupied(record.target)
|
|
66
68
|
if (!alreadyHeld) send(command, identity)
|
|
67
69
|
record.active = true
|
|
@@ -74,6 +76,10 @@ function createMomentaryControls ({ routes, send, onError = () => {}, leaseMs =
|
|
|
74
76
|
epoch,
|
|
75
77
|
handle,
|
|
76
78
|
isHeld (command) { sweep(); return occupied(destination(command)) },
|
|
79
|
+
releaseControl (id) {
|
|
80
|
+
for (const record of records.values()) if (record.controlId === id) release(record)
|
|
81
|
+
schedule()
|
|
82
|
+
},
|
|
77
83
|
releaseSession (token) {
|
|
78
84
|
for (const [key, record] of records) {
|
|
79
85
|
if (record.identity.token === token) { release(record); records.delete(key) }
|
|
@@ -66,6 +66,7 @@ function candidateAdapter (node) {
|
|
|
66
66
|
if (tag === 'meter' || tag === 'progress') return 'meter'
|
|
67
67
|
if (tag === 'canvas' && hasBoundAttribute(node, ['data-points', 'data-series'])) return 'xy-series'
|
|
68
68
|
if (XY_SERIES_CONTAINER_TAGS.has(tag) && hasBoundAttribute(node, ['data-coordinates'])) return 'xy-series'
|
|
69
|
+
if (attribute(node, 'data-display-only').toLowerCase() === 'true') return null
|
|
69
70
|
if (['span', 'strong', 'b', 'output', 'h1', 'h2', 'h3'].includes(tag) &&
|
|
70
71
|
node.children?.length === 1 && node.children[0].type === 2 &&
|
|
71
72
|
node.children[0].content.trim()) return 'text-display'
|
package/panel-visual-apply.js
CHANGED
|
@@ -6,6 +6,27 @@ const { configuredRoutes } = require('./control-bound-protocol')
|
|
|
6
6
|
const { provisionManagedBridges } = require('./managed-native-bridge')
|
|
7
7
|
const { backupFlow, flowLocation, runtimeDirectory } = require('./managed-bridge-admin')
|
|
8
8
|
|
|
9
|
+
const snapshotCache = new Map()
|
|
10
|
+
|
|
11
|
+
function snapshotCacheKey (localPort, adminRoot) {
|
|
12
|
+
return `${localPort}:${adminRoot}`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function waitForEditorRoute (localPort, panelId, cookie, fetcher = fetch) {
|
|
16
|
+
if (!cookie || !Number.isInteger(localPort) || !panelId) return
|
|
17
|
+
const url = `http://127.0.0.1:${localPort}/control-panel-edit/panels/${panelId}/session`
|
|
18
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
19
|
+
try {
|
|
20
|
+
const response = await fetcher(url, {
|
|
21
|
+
headers: { Cookie: cookie },
|
|
22
|
+
signal: AbortSignal.timeout(1000)
|
|
23
|
+
})
|
|
24
|
+
if (response.ok) return
|
|
25
|
+
} catch (_) {}
|
|
26
|
+
if (attempt < 19) await new Promise(resolve => setTimeout(resolve, 150))
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
9
30
|
function conflict (message) {
|
|
10
31
|
const error = new Error(message)
|
|
11
32
|
error.status = 409
|
|
@@ -24,17 +45,38 @@ function adminUrl (localPort, adminRoot = '/red') {
|
|
|
24
45
|
return url.href
|
|
25
46
|
}
|
|
26
47
|
|
|
27
|
-
async function getSnapshot (localPort, fetcher = fetch, adminRoot = '/red') {
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
if (
|
|
35
|
-
|
|
48
|
+
async function getSnapshot (localPort, fetcher = fetch, adminRoot = '/red', options = {}) {
|
|
49
|
+
const key = snapshotCacheKey(localPort, adminRoot)
|
|
50
|
+
const cached = snapshotCache.get(key)
|
|
51
|
+
const now = Date.now()
|
|
52
|
+
if (cached && typeof options.expectedRev === 'string' && cached.snapshot.rev === options.expectedRev) {
|
|
53
|
+
return cached.snapshot
|
|
54
|
+
}
|
|
55
|
+
if (cached && Number.isFinite(options.maxAgeMs) && now - cached.time <= options.maxAgeMs) {
|
|
56
|
+
return cached.snapshot
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const response = await fetcher(adminUrl(localPort, adminRoot), {
|
|
60
|
+
headers: { 'Node-RED-API-Version': 'v2' },
|
|
61
|
+
signal: AbortSignal.timeout(10000)
|
|
62
|
+
})
|
|
63
|
+
if (!response.ok) throw new Error(`Automator Flow read failed (${response.status})`)
|
|
64
|
+
const snapshot = await response.json()
|
|
65
|
+
if (typeof snapshot.rev !== 'string' || !Array.isArray(snapshot.flows)) {
|
|
66
|
+
throw new Error('Automator did not return a versioned Flow')
|
|
67
|
+
}
|
|
68
|
+
snapshotCache.set(key, { snapshot, time: Date.now() })
|
|
69
|
+
return snapshot
|
|
70
|
+
} catch (error) {
|
|
71
|
+
const isTimeout = error?.name === 'TimeoutError' || error?.code === 'ABORT_ERR' ||
|
|
72
|
+
/aborted.*timeout/i.test(String(error?.message || ''))
|
|
73
|
+
if (isTimeout && cached && Number.isFinite(options.allowStaleMs) &&
|
|
74
|
+
Date.now() - cached.time <= options.allowStaleMs) {
|
|
75
|
+
return cached.snapshot
|
|
76
|
+
}
|
|
77
|
+
if (isTimeout) throw new Error('Automator Flow is still restarting; retry in a moment')
|
|
78
|
+
throw error
|
|
36
79
|
}
|
|
37
|
-
return snapshot
|
|
38
80
|
}
|
|
39
81
|
|
|
40
82
|
function findPanel (snapshot, panelId) {
|
|
@@ -65,12 +107,15 @@ function editorConfig (snapshot, panelId) {
|
|
|
65
107
|
|
|
66
108
|
async function applyVisualBindings ({
|
|
67
109
|
panelId, rev, templateHash, entries, userDir, localPort, adminRoot = '/red',
|
|
68
|
-
catalogBase, fetcher = fetch
|
|
110
|
+
catalogBase, editorCookie, fetcher = fetch
|
|
69
111
|
}) {
|
|
70
112
|
// The editor authenticates the browser; Flow access stays on the runtime's loopback interface.
|
|
71
113
|
const directory = runtimeDirectory(userDir)
|
|
72
114
|
if (!catalogBase) throw conflict('Device catalog is unavailable')
|
|
73
|
-
|
|
115
|
+
if (!Array.isArray(entries) || entries.length > 256) throw conflict('Invalid binding draft')
|
|
116
|
+
const [snapshot, devices] = await Promise.all([
|
|
117
|
+
getSnapshot(localPort, fetcher, adminRoot, { expectedRev: rev }), listCatalogDevices(catalogBase, fetcher)
|
|
118
|
+
])
|
|
74
119
|
if (snapshot.rev !== rev) throw conflict('Flow changed; refresh the visual editor')
|
|
75
120
|
const panel = findPanel(snapshot, panelId)
|
|
76
121
|
if (inspectTemplate(panel.format).templateHash !== templateHash) {
|
|
@@ -81,8 +126,6 @@ async function applyVisualBindings ({
|
|
|
81
126
|
throw conflict('Deployed Flow differs from the active project file')
|
|
82
127
|
}
|
|
83
128
|
|
|
84
|
-
const devices = await listCatalogDevices(catalogBase, fetcher)
|
|
85
|
-
if (!Array.isArray(entries) || entries.length > 256) throw conflict('Invalid binding draft')
|
|
86
129
|
const deviceIds = [...new Set(entries.map(entry => entry?.device))]
|
|
87
130
|
for (const id of deviceIds) {
|
|
88
131
|
if (!devices.some(device => device.id === id && device.online)) {
|
|
@@ -98,11 +141,23 @@ async function applyVisualBindings ({
|
|
|
98
141
|
nextPanel.nativeBridge = configuredRoutes(nextPanel).size > 0
|
|
99
142
|
const provisioned = provisionManagedBridges(next, { panelId })
|
|
100
143
|
|
|
101
|
-
const latest = await getSnapshot(localPort, fetcher, adminRoot)
|
|
102
|
-
if (latest.rev !== snapshot.rev) throw conflict('Flow changed during validation; refresh and retry')
|
|
103
144
|
if (JSON.stringify(JSON.parse(fs.readFileSync(location.flowFile, 'utf8'))) !== JSON.stringify(snapshot.flows)) {
|
|
104
145
|
throw conflict('Active project changed during validation')
|
|
105
146
|
}
|
|
147
|
+
// Node order is not a Flow change. An unchanged draft must not restart the
|
|
148
|
+
// panel and its bridge just because provisioning moved generated nodes.
|
|
149
|
+
const currentNodes = new Map(snapshot.flows.map(node => [node.id, node]))
|
|
150
|
+
if (currentNodes.size === snapshot.flows.length && provisioned.flows.length === snapshot.flows.length &&
|
|
151
|
+
provisioned.flows.every(node => JSON.stringify(node) === JSON.stringify(currentNodes.get(node.id)))) {
|
|
152
|
+
return {
|
|
153
|
+
ok: true,
|
|
154
|
+
rev: snapshot.rev,
|
|
155
|
+
updated: [],
|
|
156
|
+
unchanged: true,
|
|
157
|
+
editorConfig: editorConfig(snapshot, panelId),
|
|
158
|
+
devices
|
|
159
|
+
}
|
|
160
|
+
}
|
|
106
161
|
const backupDir = backupFlow(location, snapshot.rev, snapshot.flows)
|
|
107
162
|
const response = await fetcher(adminUrl(localPort, adminRoot), {
|
|
108
163
|
method: 'POST',
|
|
@@ -116,18 +171,22 @@ async function applyVisualBindings ({
|
|
|
116
171
|
})
|
|
117
172
|
if (!response.ok) throw conflict(`Automator rejected the binding update (${response.status})`)
|
|
118
173
|
const applied = await response.json().catch(() => ({}))
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
174
|
+
// The v2 Flow POST is the runtime acknowledgement. Avoid rereading the
|
|
175
|
+
// complete Flow repeatedly while modified nodes are still restarting.
|
|
176
|
+
const appliedRev = typeof applied.rev === 'string' ? applied.rev : snapshot.rev
|
|
177
|
+
snapshotCache.set(snapshotCacheKey(localPort, adminRoot), {
|
|
178
|
+
snapshot: { rev: appliedRev, flows: provisioned.flows },
|
|
179
|
+
time: Date.now()
|
|
180
|
+
})
|
|
181
|
+
await waitForEditorRoute(localPort, panelId, editorCookie, fetcher)
|
|
182
|
+
return {
|
|
183
|
+
ok: true,
|
|
184
|
+
rev: appliedRev,
|
|
185
|
+
backupDir,
|
|
186
|
+
updated: provisioned.updated,
|
|
187
|
+
editorConfig: editorConfig({ rev: appliedRev, flows: provisioned.flows }, panelId),
|
|
188
|
+
devices
|
|
129
189
|
}
|
|
130
|
-
throw conflict('Automator did not confirm the binding update; refresh before retrying')
|
|
131
190
|
}
|
|
132
191
|
|
|
133
192
|
module.exports = { adminUrl, applyVisualBindings, editorConfig, getSnapshot }
|