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.
@@ -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) throw new Error(result.error || `Request failed (${response.status})`)
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
- node.setAttribute('data-cpv-meter-valid', String(frame.valid[index]))
402
- node.setAttribute('aria-invalid', String(!frame.valid[index]))
403
- if (!frame.valid[index]) continue
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.values()) {
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
- await loadEditor()
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
- snapshot = await request('/config')
651
- if (disposed) return
652
- const response = await request('/devices')
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
- request('/session', 'GET', undefined, 2500).then(session => {
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
- component.restoreBoundPanelSession(2500).then(session => {
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
 
@@ -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'
@@ -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 response = await fetcher(adminUrl(localPort, adminRoot), {
29
- headers: { 'Node-RED-API-Version': 'v2' },
30
- signal: AbortSignal.timeout(10000)
31
- })
32
- if (!response.ok) throw new Error(`Automator Flow read failed (${response.status})`)
33
- const snapshot = await response.json()
34
- if (typeof snapshot.rev !== 'string' || !Array.isArray(snapshot.flows)) {
35
- throw new Error('Automator did not return a versioned Flow')
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
- const snapshot = await getSnapshot(localPort, fetcher, adminRoot)
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
- let confirmed
120
- for (let attempt = 0; attempt < 5; attempt++) {
121
- confirmed = await getSnapshot(localPort, fetcher, adminRoot)
122
- const confirmedPanel = confirmed.flows.find(node => node.id === panelId)
123
- if (confirmedPanel?.visualBindings === nextPanel.visualBindings &&
124
- confirmedPanel.bridgeRevisionApplied === provisioned.flows.find(node => node.id === panelId)?.bridgeRevisionApplied) {
125
- return { ok: true, rev: confirmed.rev, backupDir, updated: provisioned.updated }
126
- }
127
- if (confirmed.rev !== snapshot.rev && confirmed.rev !== applied.rev) break
128
- if (attempt < 4) await new Promise(resolve => setTimeout(resolve, 200))
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 }