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

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.
@@ -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,6 +19,10 @@ 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()
23
+ const buttonPresses = new Map()
24
+ const momentaryButtons = new Map()
25
+ const momentaryStates = new Map()
16
26
  const feedbackModels = new Map()
17
27
  const pendingText = new Map()
18
28
  const cleanup = []
@@ -25,8 +35,10 @@ function initializeVisualBinding () {
25
35
  let devices = []
26
36
  let selecting = false
27
37
  let observer
38
+ let nodeIndex = null
28
39
  let toolbar
29
40
  let overlay
41
+ let overlayCleanup
30
42
  let status
31
43
  let loginScreen
32
44
 
@@ -49,6 +61,10 @@ function initializeVisualBinding () {
49
61
  node.addEventListener('click', action)
50
62
  return node
51
63
  }
64
+ function listen (node, name, action, capture = false, stops = cleanup) {
65
+ node.addEventListener(name, action, capture)
66
+ stops.push(() => node.removeEventListener(name, action, capture))
67
+ }
52
68
  function inform (message, error = false) {
53
69
  if (status) {
54
70
  status.textContent = message || ''
@@ -67,7 +83,7 @@ function initializeVisualBinding () {
67
83
  if (!response.ok) throw new Error(result.error || `Request failed (${response.status})`)
68
84
  return result
69
85
  }
70
- function numericRange (binding, node) {
86
+ function numericRange (binding, node) {
71
87
  if (binding.uiRange) return binding.uiRange
72
88
  const tag = node.tagName?.toLowerCase()
73
89
  const type = node.type?.toLowerCase()
@@ -127,7 +143,30 @@ function initializeVisualBinding () {
127
143
  return repeatMarkerFor(node, binding.repeat) === String(binding.instanceKey)
128
144
  }
129
145
  function nodesForBinding (binding) {
130
- return [...root.querySelectorAll('[data-cp-auto]')].filter(node => bindingMatchesNode(node, binding))
146
+ if (!nodeIndex) {
147
+ nodeIndex = new Map()
148
+ for (const node of root.querySelectorAll('[data-cp-auto]')) {
149
+ const locator = node.getAttribute('data-cp-auto')
150
+ if (!nodeIndex.has(locator)) nodeIndex.set(locator, { nodes: [], repeats: new Map() })
151
+ nodeIndex.get(locator).nodes.push(node)
152
+ }
153
+ }
154
+ const group = nodeIndex.get(binding.locator)
155
+ if (!group) return []
156
+ let nodes = group.nodes
157
+ if (binding.repeat) {
158
+ if (!group.repeats.has(binding.repeat.locator)) {
159
+ const keys = new Map()
160
+ for (const node of nodes) {
161
+ const key = repeatMarkerFor(node, binding.repeat)
162
+ if (!keys.has(key)) keys.set(key, [])
163
+ keys.get(key).push(node)
164
+ }
165
+ group.repeats.set(binding.repeat.locator, keys)
166
+ }
167
+ nodes = group.repeats.get(binding.repeat.locator).get(String(binding.instanceKey)) || []
168
+ }
169
+ return nodes.filter(node => root.contains(node))
131
170
  }
132
171
  function nodeForBinding (binding) {
133
172
  const matches = nodesForBinding(binding)
@@ -196,7 +235,9 @@ function initializeVisualBinding () {
196
235
  const node = nodeForBinding(binding)
197
236
  if (!node) continue
198
237
  if (!Object.prototype.hasOwnProperty.call(component.boundState, id)) continue
199
- const value = component.boundDisplayValue(id, component.boundState[id])
238
+ const value = node.tagName === 'BUTTON' && binding.adapter === 'switch'
239
+ ? component.controlValue(id, component.boundState[id])
240
+ : component.boundDisplayValue(id, component.boundState[id])
200
241
  const displayValue = ['range', 'meter'].includes(binding.adapter)
201
242
  ? mappedValue(Number(value), binding.readMeta || binding.writeMeta,
202
243
  numericRange(binding, node)) : value
@@ -233,19 +274,65 @@ function initializeVisualBinding () {
233
274
  }
234
275
  }
235
276
  function attachControls () {
277
+ if (disposed) return
278
+ nodeIndex = null
279
+ for (const [node, listeners] of controlListeners) {
280
+ const binding = saved.get(listeners.id)
281
+ if (!binding || nodeForBinding(binding) !== node) {
282
+ for (const stop of listeners.stops) stop()
283
+ controlListeners.delete(node)
284
+ pendingText.delete(listeners.id)
285
+ attached.delete(node)
286
+ }
287
+ }
288
+ for (const [node, button] of buttonPresses) {
289
+ const binding = saved.get(button.id)
290
+ if (!binding || nodeForBinding(binding) !== node) {
291
+ button.controller.dispose()
292
+ buttonPresses.delete(node)
293
+ attached.delete(node)
294
+ } else button.controller.check()
295
+ }
296
+ for (const [node, button] of momentaryButtons) {
297
+ const binding = saved.get(button.id)
298
+ if (!binding || nodeForBinding(binding) !== node) {
299
+ button.controller.dispose()
300
+ momentaryButtons.delete(node)
301
+ attached.delete(node)
302
+ } else button.controller.check()
303
+ }
236
304
  for (const node of root.querySelectorAll('[data-cp-auto]')) {
237
305
  if (attached.has(node)) continue
238
- const locator = node.getAttribute('data-cp-auto')
239
- const matched = [...saved].find(([, entry]) => bindingMatchesNode(node, entry))
306
+ const matched = bindingsByLocator.get(node.getAttribute('data-cp-auto'))
307
+ ?.find(([, entry]) => bindingMatchesNode(node, entry))
240
308
  if (!matched) continue
241
309
  const [id, binding] = matched
242
310
  if (nodeForBinding(binding) !== node) continue
243
311
  attached.add(node)
244
312
  if (binding.mode === 'read') continue
313
+ const stops = []
314
+ controlListeners.set(node, { id, stops })
315
+ if (node.tagName === 'BUTTON' && binding.adapter === 'switch') {
316
+ buttonPresses.set(node, { id, controller: component.__attachButtonPress({ node,
317
+ canPress: () => !disposed && !editing && component.boundAuthenticated && component.boundFeedbackConnected }) })
318
+ }
319
+ if (binding.buttonBehavior === 'momentary' && node.tagName === 'BUTTON') {
320
+ if (!momentaryStates.has(id)) momentaryStates.set(id, { seq: 0 })
321
+ const controller = component.__attachMomentaryButton({ node, id,
322
+ state: momentaryStates.get(id),
323
+ endpoint: component.__boundPanelConfig().endpoint,
324
+ getSession: () => component.__boundPanelSession,
325
+ canStart: () => !disposed && !editing && component.boundAuthenticated && component.boundFeedbackConnected,
326
+ request: (...args) => component.__panelFetch(...args),
327
+ onError: error => console.error('ControlPanel momentary command failed', error) })
328
+ momentaryButtons.set(node, { id, controller })
329
+ continue
330
+ }
245
331
  if (binding.component) {
246
332
  if (binding.adapter === 'text-input') {
247
333
  const commit = () => {
248
- if (!pendingText.has(id) || editing || !component.boundAuthenticated) return
334
+ if (disposed || !pendingText.has(id) || editing || !component.boundAuthenticated ||
335
+ nodeForBinding(binding) !== node) return
249
336
  const value = pendingText.get(id)
250
337
  pendingText.delete(id)
251
338
  try { component.sendBoundControl({ id, value, final: true }) } catch (error) {
@@ -256,46 +343,48 @@ function initializeVisualBinding () {
256
343
  if (component.$nextTick) void component.$nextTick(commit)
257
344
  else commit()
258
345
  }
259
- node.addEventListener('change', afterModelUpdate)
260
- node.addEventListener('focusout', afterModelUpdate)
346
+ listen(node, 'change', afterModelUpdate, false, stops)
347
+ listen(node, 'focusout', afterModelUpdate, false, stops)
261
348
  if (binding.component === 'v-text-field') {
262
- node.addEventListener('keydown', event => {
349
+ listen(node, 'keydown', event => {
263
350
  if (event.key === 'Enter') afterModelUpdate()
264
- })
351
+ }, false, stops)
265
352
  }
266
353
  }
267
354
  continue
268
355
  }
269
356
  const send = final => {
270
- if (editing || !component.boundAuthenticated) return
357
+ if (disposed || editing || !component.boundAuthenticated || nodeForBinding(binding) !== node) return
271
358
  try { component.sendBoundControl({ id, value: valueFor(binding, node), final }) } catch (error) {
272
359
  console.error('ControlPanel binding command failed', error)
273
360
  }
274
361
  }
275
362
  if (binding.adapter === 'range') {
276
- node.addEventListener('input', () => send(false), true)
277
- node.addEventListener('change', () => send(true), true)
363
+ listen(node, 'input', () => send(false), true, stops)
364
+ listen(node, 'change', () => send(true), true, stops)
278
365
  } else if (binding.adapter === 'switch' && node.tagName === 'BUTTON') {
279
- node.addEventListener('click', event => {
366
+ listen(node, 'click', event => {
280
367
  event.preventDefault()
281
368
  send(true)
282
- })
283
- } else node.addEventListener('change', () => send(true))
369
+ }, false, stops)
370
+ } else listen(node, 'change', () => send(true), false, stops)
284
371
  }
285
372
  if (meterEntries.length) {
373
+ const previous = new Map(meterTargets)
286
374
  meterTargets.clear()
287
375
  for (const [id, binding] of meterEntries) {
288
376
  const node = nodeForBinding(binding)
289
- if (node) meterTargets.set(id, { node, binding })
377
+ if (node) meterTargets.set(id, previous.get(id)?.node === node ? previous.get(id) : { node, binding })
290
378
  }
291
379
  if (lastMeterFrame) applyMeterFrame(lastMeterFrame.frame, lastMeterFrame.ids)
292
380
  else invalidateMeters()
293
381
  }
294
382
  if (xySeriesEntries.length) {
383
+ const previous = new Map(xySeriesTargets)
295
384
  xySeriesTargets.clear()
296
385
  for (const [id, binding] of xySeriesEntries) {
297
386
  const node = nodeForBinding(binding)
298
- if (node) xySeriesTargets.set(id, { node, binding })
387
+ if (node) xySeriesTargets.set(id, previous.get(id)?.node === node ? previous.get(id) : { node, binding })
299
388
  }
300
389
  if (lastXYSeriesFrame) applyXYSeriesFrame(lastXYSeriesFrame.frame, lastXYSeriesFrame.ids)
301
390
  else invalidateXYSeries()
@@ -306,7 +395,8 @@ function initializeVisualBinding () {
306
395
  lastMeterFrame = { frame, ids }
307
396
  for (let index = 0; index < ids.length; index++) {
308
397
  const target = meterTargets.get(ids[index])
309
- if (!target) continue
398
+ if (!target || target.frame === frame) continue
399
+ target.frame = frame
310
400
  const { node, binding } = target
311
401
  node.setAttribute('data-cpv-meter-valid', String(frame.valid[index]))
312
402
  node.setAttribute('aria-invalid', String(!frame.valid[index]))
@@ -329,11 +419,14 @@ function initializeVisualBinding () {
329
419
  lastXYSeriesFrame = { frame, ids }
330
420
  for (let index = 0; index < ids.length; index++) {
331
421
  const target = xySeriesTargets.get(ids[index])
332
- if (!target) continue
422
+ if (!target || target.frame === frame) continue
423
+ target.frame = frame
424
+ target.invalidated = false
333
425
  const { node, binding } = target
334
426
  node.setAttribute('data-cpv-xy-series-valid', String(frame.valid[index]))
427
+ node.setAttribute('aria-invalid', String(!frame.valid[index]))
335
428
  node.setAttribute('data-cpv-xy-series-seq', String(frame.seq))
336
- const points = frame.valid[index] ? frame.values[index].map(point => point.slice()) : []
429
+ const points = frame.valid[index] ? frame.values[index].map(point => [point[0], point[1]]) : []
337
430
  if (binding.model) {
338
431
  if (binding.repeat) setRepeatModel(binding, points)
339
432
  else if (binding.model in component && !component.$options?.computed?.[binding.model]) component[binding.model] = points
@@ -342,7 +435,11 @@ function initializeVisualBinding () {
342
435
  }
343
436
  function invalidateXYSeries () {
344
437
  lastXYSeriesFrame = null
345
- for (const { node, binding } of xySeriesTargets.values()) {
438
+ for (const target of xySeriesTargets.values()) {
439
+ if (target.invalidated) continue
440
+ target.invalidated = true
441
+ target.frame = null
442
+ const { node, binding } = target
346
443
  node.setAttribute('data-cpv-xy-series-valid', 'false')
347
444
  node.setAttribute('aria-invalid', 'true')
348
445
  if (binding.model) {
@@ -356,7 +453,7 @@ function initializeVisualBinding () {
356
453
  if (!binding.component || !binding.model || binding.mode === 'read') continue
357
454
  let finishTimer
358
455
  const send = (value, final) => {
359
- if (editing || !component.boundAuthenticated) return
456
+ if (disposed || editing || !component.boundAuthenticated) return
360
457
  try {
361
458
  let commandValue
362
459
  if (binding.adapter === 'range') {
@@ -395,7 +492,7 @@ function initializeVisualBinding () {
395
492
  feedbackModels.delete(id)
396
493
  if (Object.is(value, feedbackValue)) return
397
494
  }
398
- if (editing || !component.boundAuthenticated) return
495
+ if (disposed || editing || !component.boundAuthenticated) return
399
496
  if (binding.adapter === 'text-input') {
400
497
  if (typeof value === 'string' || value == null) pendingText.set(id, value ?? '')
401
498
  return
@@ -409,7 +506,12 @@ function initializeVisualBinding () {
409
506
  cleanup.push(() => { clearTimeout(finishTimer); unwatch() })
410
507
  }
411
508
  }
412
- function closeOverlay () { overlay?.remove(); overlay = null }
509
+ function closeOverlay () {
510
+ overlayCleanup?.()
511
+ overlayCleanup = null
512
+ overlay?.remove()
513
+ overlay = null
514
+ }
413
515
  function sameBindingTarget (entry, candidate) {
414
516
  return entry.locator === candidate.locator &&
415
517
  (!candidate.repeat || String(entry.instanceKey) === String(candidate.instanceKey))
@@ -447,14 +549,13 @@ function initializeVisualBinding () {
447
549
  const outside = event => {
448
550
  if (!overlay?.contains(event.target)) {
449
551
  closeOverlay()
450
- document.removeEventListener('pointerdown', outside, true)
451
552
  }
452
553
  }
453
554
  const timer = setTimeout(() => document.addEventListener('pointerdown', outside, true), 0)
454
- cleanup.push(() => {
555
+ overlayCleanup = () => {
455
556
  clearTimeout(timer)
456
557
  document.removeEventListener('pointerdown', outside, true)
457
- })
558
+ }
458
559
  }
459
560
  function showDialog (title) {
460
561
  closeOverlay()
@@ -558,6 +659,7 @@ function initializeVisualBinding () {
558
659
  inform('Choose a control on the page')
559
660
  updateBindingMarkers()
560
661
  meterClient?.setAuth({ endpoint: base, csrf: editSession.csrf, header: 'X-Panel-CSRF' })
662
+ xySeriesClient?.setAuth({ endpoint: base, csrf: editSession.csrf, header: 'X-Panel-CSRF' })
561
663
  }
562
664
  function choose (label, options, selected) {
563
665
  const select = element('select')
@@ -590,9 +692,19 @@ function initializeVisualBinding () {
590
692
  ? '.' + String(candidate.instanceKey).replace(/[^A-Za-z0-9._/-]/g, '-').slice(0, 48)
591
693
  : ''
592
694
  id.value = existing?.id || 'control.' + candidate.locator.slice(-12) + instanceSuffix
593
- const modeOptions = ['meter', 'text-display'].includes(candidate.adapter)
594
- ? ['read'] : ['both', 'write', 'read']
695
+ const modeOptions = matcher.modes(candidate.adapter)
595
696
  const mode = field(form, 'Mode', choose('Mode', modeOptions.map(value => ({ value, text: value })), existing?.mode || modeOptions[0]))
697
+ const behavior = candidate.button ? field(form, 'Button behavior', choose('Button behavior', [
698
+ { value: 'toggle', text: 'Click to toggle / 点击切换' },
699
+ { value: 'momentary', text: 'Hold to activate / 按住生效' }
700
+ ], existing?.buttonBehavior || 'toggle')) : null
701
+ function updateButtonMode () {
702
+ if (!behavior) return
703
+ mode.disabled = behavior.value === 'momentary'
704
+ if (mode.disabled) mode.value = 'both'
705
+ mode.closest('label').dataset.disabled = String(mode.disabled)
706
+ }
707
+ updateButtonMode()
596
708
  const device = field(form, 'Device', choose('Device', devices.filter(item => item.online)
597
709
  .map(item => ({ value: item.id, text: item.id })), existing?.device))
598
710
  const write = field(form, 'Write point', choose('Write point', [], existing?.writePath))
@@ -626,8 +738,10 @@ function initializeVisualBinding () {
626
738
  }
627
739
  write.value = oldWrite || ''
628
740
  read.value = oldRead || ''
629
- write.closest('label').hidden = mode.value === 'read'
630
- read.closest('label').hidden = mode.value === 'write'
741
+ write.disabled = mode.value === 'read'
742
+ read.disabled = mode.value === 'write'
743
+ write.closest('label').dataset.disabled = String(write.disabled)
744
+ read.closest('label').dataset.disabled = String(read.disabled)
631
745
  const available = mode.value === 'read' ? readCount
632
746
  : mode.value === 'write' ? writeCount : Math.min(writeCount, readCount)
633
747
  message.textContent = targets.length && !available ? 'No compatible points for this control'
@@ -651,8 +765,12 @@ function initializeVisualBinding () {
651
765
  targets = []; updateTargets(); message.textContent = error.message
652
766
  }
653
767
  })
654
- write.addEventListener('change', () => { read.value = write.value })
768
+ write.addEventListener('change', () => {
769
+ if (mode.value === 'both') read.value = write.value
770
+ })
655
771
  mode.addEventListener('change', updateTargets)
772
+ behavior?.addEventListener('change', () => { updateButtonMode(); updateTargets() })
773
+ updateTargets()
656
774
  if (device.value) device.dispatchEvent(new Event('change'))
657
775
  const actions = element('div', null, 'cpv-actions')
658
776
  actions.append(button('Cancel', () => finishBindingEdit(returnToBindings)))
@@ -672,11 +790,12 @@ function initializeVisualBinding () {
672
790
  message.textContent = 'Control ID must be unique and valid'; return
673
791
  }
674
792
  if (!name.value.trim() || !device.value || (mode.value !== 'read' && !write.value) ||
675
- (mode.value !== 'write' && !read.value) || !numbering.value) {
793
+ (mode.value !== 'write' && !read.value) || !numbering.value || (behavior && !behavior.value)) {
676
794
  message.textContent = 'Complete the binding fields'; return
677
795
  }
678
796
  const entry = {
679
797
  id: id.value, locator: candidate.locator, adapter: candidate.adapter,
798
+ ...(behavior ? { buttonBehavior: behavior.value } : {}),
680
799
  ...(candidate.model ? { model: candidate.model } : {}),
681
800
  ...(candidate.repeat ? { repeat: candidate.repeat, instanceKey: String(candidate.instanceKey) } : {}),
682
801
  label: name.value.trim(), mode: mode.value, device: device.value,
@@ -757,8 +876,9 @@ function initializeVisualBinding () {
757
876
  const baseCandidate = snapshot.candidates.find(item => item.locator === entry.locator && item.supported)
758
877
  const candidate = baseCandidate?.repeat ? { ...baseCandidate, instanceKey: entry.instanceKey } : baseCandidate
759
878
  const edit = button('Edit', () => bindingDialog(candidate, { returnToBindings: true }))
760
- edit.disabled = !candidate || !nodeForBinding(entry)
761
- if (edit.disabled) edit.title = 'This control is no longer available on the page'
879
+ // Existing mappings belong to the template, not the currently rendered tab.
880
+ edit.disabled = !candidate
881
+ if (edit.disabled) edit.title = 'This control is no longer supported or present in the template'
762
882
  actions.append(edit)
763
883
  actions.append(button('Remove', () => {
764
884
  draft = draft.filter(item => item.id !== entry.id)
@@ -813,7 +933,7 @@ function initializeVisualBinding () {
813
933
  return candidate
814
934
  }
815
935
  function chooseCandidate (event) {
816
- if (!editing || !editSession) return
936
+ if (disposed || !editing || !editSession) return
817
937
  const candidate = candidateFromEvent(event)
818
938
  if (!candidate) return
819
939
  event.preventDefault()
@@ -821,26 +941,26 @@ function initializeVisualBinding () {
821
941
  if (!candidate.supported) { inform(candidate.reason, true); return }
822
942
  contextMenu(candidate, event.clientX || 12, event.clientY || 12)
823
943
  }
824
- root.addEventListener('contextmenu', chooseCandidate, true)
944
+ listen(root, 'contextmenu', chooseCandidate, true)
825
945
  for (const name of ['input', 'change']) {
826
- root.addEventListener(name, event => {
946
+ listen(root, name, event => {
827
947
  if (!editing || !event.target?.closest?.('[data-cp-auto]')) return
828
948
  event.preventDefault()
829
949
  event.stopImmediatePropagation()
830
950
  }, true)
831
951
  }
832
- root.addEventListener('click', event => { if (selecting) chooseCandidate(event) }, true)
833
- root.addEventListener('keydown', event => {
952
+ listen(root, 'click', event => { if (selecting) chooseCandidate(event) }, true)
953
+ listen(root, 'keydown', event => {
834
954
  if (event.key === 'Enter' && selecting) chooseCandidate(event)
835
955
  }, true)
836
956
  let longPress
837
- root.addEventListener('pointerdown', event => {
957
+ listen(root, 'pointerdown', event => {
838
958
  if (!editing || event.pointerType === 'mouse') return
839
959
  const candidate = candidateFromEvent(event)
840
960
  if (candidate) longPress = setTimeout(() => chooseCandidate(event), 550)
841
961
  }, true)
842
962
  for (const name of ['pointerup', 'pointercancel', 'pointermove']) {
843
- root.addEventListener(name, () => clearTimeout(longPress), true)
963
+ listen(root, name, () => clearTimeout(longPress), true)
844
964
  }
845
965
  attachControls()
846
966
  attachModelControls()
@@ -886,7 +1006,27 @@ function initializeVisualBinding () {
886
1006
  attachControls()
887
1007
  updateBindingMarkers()
888
1008
  })
889
- observer.observe(root, { childList: true, subtree: true })
1009
+ observer.observe(root, { childList: true, subtree: true, attributes: true,
1010
+ attributeFilter: ['disabled', 'data-cp-auto', 'data-cp-repeat', 'data-cp-repeat-key'] })
1011
+ cleanup.push(() => {
1012
+ clearTimeout(longPress)
1013
+ for (const listeners of controlListeners.values()) {
1014
+ for (const stop of listeners.stops) stop()
1015
+ }
1016
+ controlListeners.clear()
1017
+ pendingText.clear()
1018
+ for (const button of buttonPresses.values()) button.controller.dispose()
1019
+ buttonPresses.clear()
1020
+ for (const button of momentaryButtons.values()) button.controller.dispose()
1021
+ momentaryButtons.clear()
1022
+ momentaryStates.clear()
1023
+ })
1024
+ cleanup.push(this.$watch(() => component.boundAuthenticated && component.boundFeedbackConnected, connected => {
1025
+ if (!connected) {
1026
+ for (const button of buttonPresses.values()) button.controller.release()
1027
+ for (const button of momentaryButtons.values()) button.controller.release()
1028
+ }
1029
+ }))
890
1030
  const unwatch = this.$watch('boundState', showFeedback)
891
1031
  cleanup.push(() => unwatch(), () => observer.disconnect(), () => toolbar?.remove(),
892
1032
  () => closeOverlay(), () => closeLoginScreen())
@@ -894,6 +1034,7 @@ function initializeVisualBinding () {
894
1034
 
895
1035
  const style = element('style')
896
1036
  style.textContent = `
1037
+ button[data-cpv-press="true"]{translate:0 2px !important;box-shadow:inset 0 2px 4px #0003 !important}
897
1038
  .cpv-toolbar,.cpv-dialog,.cpv-menu{font:14px/1.4 system-ui,sans-serif;color:#182224;background:#fff}
898
1039
  .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)}
899
1040
  .cpv-toolbar button,.cpv-dialog button{padding:7px 11px;border:1px solid #809296;background:#fff;color:#182224;border-radius:4px;cursor:pointer;font:inherit}
@@ -944,6 +1085,7 @@ function initializeVisualBinding () {
944
1085
  .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}
945
1086
  .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}
946
1087
  .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}
1088
+ .cpv-field[data-disabled=true] span{color:#66787c}.cpv-field select:disabled{background:#edf1f2;color:#66787c;border-color:#c5cfd1;cursor:not-allowed;opacity:1}
947
1089
  .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}
948
1090
  .cpv-menu{position:fixed;z-index:2147483002;width:170px;padding:4px;border:1px solid #9baaad;box-shadow:0 8px 24px #0003}
949
1091
  .cpv-menu button{display:block;width:100%;padding:8px;text-align:left;border:0;background:white;color:#182224;font:inherit;cursor:pointer}
@@ -0,0 +1,46 @@
1
+ // Serialized into the Dashboard component; press appearance never changes device state.
2
+ function attachButtonPress ({ node, canPress }) {
3
+ let pointer = null
4
+ const keys = new Set(), listeners = []
5
+ function listen (target, name, callback) {
6
+ target.addEventListener(name, callback)
7
+ listeners.push(() => target.removeEventListener(name, callback))
8
+ }
9
+ function render () {
10
+ if (pointer !== null || keys.size) node.setAttribute('data-cpv-press', 'true')
11
+ else node.removeAttribute('data-cpv-press')
12
+ }
13
+ function release () { pointer = null; keys.clear(); render() }
14
+ function allowed () { return node.isConnected && !node.disabled && !document.hidden && canPress() }
15
+ listen(node, 'pointerdown', event => {
16
+ if (event.button !== 0 || pointer !== null || !allowed()) return
17
+ pointer = event.pointerId
18
+ render()
19
+ })
20
+ const pointerEnd = event => {
21
+ if (pointer !== event.pointerId) return
22
+ pointer = null
23
+ render()
24
+ }
25
+ listen(window, 'pointerup', pointerEnd)
26
+ listen(window, 'pointercancel', pointerEnd)
27
+ listen(node, 'lostpointercapture', pointerEnd)
28
+ listen(node, 'keydown', event => {
29
+ if (![' ', 'Enter'].includes(event.key) || !allowed()) return
30
+ keys.add(event.key)
31
+ render()
32
+ })
33
+ listen(window, 'keyup', event => { if (keys.delete(event.key)) render() })
34
+ listen(node, 'blur', release)
35
+ listen(window, 'blur', release)
36
+ listen(window, 'offline', release)
37
+ listen(window, 'pagehide', release)
38
+ listen(document, 'visibilitychange', () => { if (document.hidden) release() })
39
+ return {
40
+ release,
41
+ check () { if (!allowed()) release() },
42
+ dispose () { release(); for (const remove of listeners) remove() }
43
+ }
44
+ }
45
+
46
+ module.exports = { attachButtonPress }
package/panel-meter.js CHANGED
@@ -2,6 +2,7 @@ const crypto = require('node:crypto')
2
2
  const { createMeterReceiver } = require('./meter-frame')
3
3
  const { readJsonBody, replyJson } = require('./panel-http')
4
4
  const { meterRoute } = require('./meter-route')
5
+ const { emitTelemetryFrame } = require('./panel-telemetry-send')
5
6
 
6
7
  function createMeterRoom ({ room, now = Date.now, staleMs = 1000 }) {
7
8
  const sources = new Map(), panels = new Map()
@@ -26,7 +27,6 @@ function createMeterRoom ({ room, now = Date.now, staleMs = 1000 }) {
26
27
  const meter = meterRoute(binding.readMeta.meter)
27
28
  const key = JSON.stringify([binding.device, meter.streamId])
28
29
  let source = sources.get(key)
29
- if (source && JSON.stringify(source.route) !== JSON.stringify(meter)) throw Error('Conflicting meter stream')
30
30
  if (!source) {
31
31
  source = { device: binding.device, route: meter, receiver: null, receivedAt: null,
32
32
  revision: 0, refs: new Set(), indices: new Map() }
@@ -37,7 +37,7 @@ function createMeterRoom ({ room, now = Date.now, staleMs = 1000 }) {
37
37
  })
38
38
  const layout = { kind: 'room-meter-layout', panelId, room, epoch: crypto.randomBytes(16).toString('hex'),
39
39
  ids: points.map(([id]) => id) }
40
- const panel = { projections, layout, sequence: 0 }
40
+ const panel = { projections, streams: [...new Set(projections.map(point => point.source))], layout, sequence: 0 }
41
41
  panels.set(panelId, panel)
42
42
  return layout
43
43
  }
@@ -100,7 +100,7 @@ function createMeterRoom ({ room, now = Date.now, staleMs = 1000 }) {
100
100
  const panel = panels.get(panelId)
101
101
  if (!panel) return ''
102
102
  const time = now()
103
- return panel.projections.map(({ source }) => `${source.revision}:${source.receivedAt !== null &&
103
+ return panel.streams.map(source => `${source.revision}:${source.receivedAt !== null &&
104
104
  time - source.receivedAt <= staleMs}`).join(',')
105
105
  }
106
106
 
@@ -151,7 +151,7 @@ function createPanelMeter ({ panelId, room, bindings, hub, onSubscribe = () => {
151
151
  if (revision === lastRevision) return
152
152
  lastRevision = revision
153
153
  const frame = hub.snapshot(panelId)
154
- for (const { socket } of subscribers.values()) emitFrame(socket, frame)
154
+ emitTelemetryFrame('control-panel:meter-frame', frame, subscribers)
155
155
  })
156
156
 
157
157
  return {
@@ -0,0 +1,91 @@
1
+ // Serialized into the Dashboard component; only standard browser APIs belong here.
2
+ function attachMomentaryButton ({ node, id, endpoint, getSession, canStart, request, onError, state = { seq: 0 } }) {
3
+ if (!state.client) state.client = Array.from(crypto.getRandomValues(new Uint8Array(16)), byte => byte.toString(16).padStart(2, '0')).join('')
4
+ let generation = 0, active = null, timer = null
5
+ const listeners = []
6
+ function listen (target, name, callback) {
7
+ target.addEventListener(name, callback)
8
+ listeners.push(() => target.removeEventListener(name, callback))
9
+ }
10
+ function transmit (action, session) {
11
+ return request(endpoint + '/bound-command', {
12
+ method: 'POST', credentials: 'same-origin', keepalive: action === 'release',
13
+ headers: { 'Content-Type': 'application/json', 'X-Room-CSRF': session.csrf },
14
+ body: JSON.stringify({ room: session.room, _controlPanel: { kind: 'command', id,
15
+ value: action !== 'release', final: true,
16
+ hold: { client: state.client, seq: ++state.seq, action, epoch: session.holdEpoch } } })
17
+ }, 1800, async response => {
18
+ if (!response.ok) throw Error('Momentary command failed (' + response.status + ')')
19
+ if (typeof response.json === 'function') {
20
+ const result = await response.json()
21
+ if (action !== 'release' && result.held !== true) throw Error('Momentary hold expired; press again')
22
+ }
23
+ })
24
+ }
25
+ function release () {
26
+ if (!active) return
27
+ const previous = active
28
+ active = null
29
+ generation++
30
+ clearTimeout(timer)
31
+ timer = null
32
+ void transmit('release', previous.session).catch(onError)
33
+ if (previous.pointer !== undefined && node.hasPointerCapture?.(previous.pointer)) {
34
+ try { node.releasePointerCapture(previous.pointer) } catch (_) {}
35
+ }
36
+ }
37
+ function renew (version) {
38
+ if (!active || version !== generation) return
39
+ if (!node.isConnected || !canStart()) { release(); return }
40
+ void transmit('renew', active.session).then(() => {
41
+ if (active && version === generation) timer = setTimeout(() => renew(version), 750)
42
+ }).catch(error => {
43
+ if (active && version === generation) release()
44
+ onError(error)
45
+ })
46
+ }
47
+ function start (source) {
48
+ if (active || node.disabled || !canStart()) return
49
+ const session = getSession()
50
+ if (!session?.csrf) return
51
+ active = { ...source, session: { ...session } }
52
+ const version = ++generation
53
+ void transmit('start', active.session).then(() => {
54
+ if (active && version === generation) timer = setTimeout(() => renew(version), 750)
55
+ }).catch(error => {
56
+ if (active && version === generation) release()
57
+ onError(error)
58
+ })
59
+ }
60
+ listen(node, 'pointerdown', event => {
61
+ if (event.button !== 0 || active) return
62
+ event.preventDefault()
63
+ node.focus()
64
+ start({ pointer: event.pointerId })
65
+ if (active) { try { node.setPointerCapture(event.pointerId) } catch (_) {} }
66
+ })
67
+ const pointerEnd = event => { if (active?.pointer === event.pointerId) release() }
68
+ listen(window, 'pointerup', pointerEnd)
69
+ listen(window, 'pointercancel', pointerEnd)
70
+ listen(node, 'lostpointercapture', pointerEnd)
71
+ listen(node, 'keydown', event => {
72
+ if (![' ', 'Enter'].includes(event.key)) return
73
+ event.preventDefault()
74
+ if (!event.repeat) start({ key: event.key })
75
+ })
76
+ listen(window, 'keyup', event => {
77
+ if (active?.key === event.key) { event.preventDefault(); release() }
78
+ })
79
+ listen(node, 'click', event => event.preventDefault())
80
+ listen(node, 'blur', release)
81
+ listen(window, 'blur', release)
82
+ listen(window, 'pagehide', release)
83
+ listen(document, 'visibilitychange', () => { if (document.hidden) release() })
84
+ return {
85
+ release,
86
+ check () { if (!node.isConnected || node.disabled || !canStart()) release() },
87
+ dispose () { release(); for (const remove of listeners) remove() }
88
+ }
89
+ }
90
+
91
+ module.exports = { attachMomentaryButton }