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.
- package/CONTROL_PANEL_AI_GUIDE.md +147 -36
- package/README.md +16 -4
- package/control-bound-protocol.js +2 -1
- package/control-panel.html +10 -36
- package/control-panel.js +21 -7
- package/control-policy.js +5 -1
- package/control-type-model.js +8 -6
- package/gateway.cjs +33 -15
- package/native-binding-plan.js +4 -2
- package/npm-shrinkwrap.json +2 -2
- package/package.json +8 -1
- package/panel-binding-editor.js +186 -44
- package/panel-button-press.js +46 -0
- package/panel-meter.js +4 -4
- package/panel-momentary-client.js +91 -0
- package/panel-momentary.js +93 -0
- package/panel-telemetry-send.js +13 -0
- package/panel-template-instrumentation.js +1 -0
- package/panel-visual-bindings.js +27 -9
- package/panel-xy-series.js +16 -7
- package/xy-series-frame.js +1 -1
package/panel-binding-editor.js
CHANGED
|
@@ -6,6 +6,12 @@ function initializeVisualBinding () {
|
|
|
6
6
|
const root = this.$el
|
|
7
7
|
const base = config.editorEndpoint
|
|
8
8
|
const saved = new Map(Object.entries(config.bindings || {}))
|
|
9
|
+
const bindingsByLocator = new Map()
|
|
10
|
+
for (const entry of saved) {
|
|
11
|
+
const locator = entry[1].locator
|
|
12
|
+
if (!bindingsByLocator.has(locator)) bindingsByLocator.set(locator, [])
|
|
13
|
+
bindingsByLocator.get(locator).push(entry)
|
|
14
|
+
}
|
|
9
15
|
const meterEntries = [...saved].filter(([, entry]) => entry.readMeta?.meter)
|
|
10
16
|
const xySeriesEntries = [...saved].filter(([, entry]) => entry.readMeta?.xySeries)
|
|
11
17
|
const meterTargets = new Map()
|
|
@@ -13,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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
239
|
-
|
|
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
|
|
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
|
|
260
|
-
node
|
|
346
|
+
listen(node, 'change', afterModelUpdate, false, stops)
|
|
347
|
+
listen(node, 'focusout', afterModelUpdate, false, stops)
|
|
261
348
|
if (binding.component === 'v-text-field') {
|
|
262
|
-
node
|
|
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
|
|
277
|
-
node
|
|
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
|
|
366
|
+
listen(node, 'click', event => {
|
|
280
367
|
event.preventDefault()
|
|
281
368
|
send(true)
|
|
282
|
-
})
|
|
283
|
-
} else node
|
|
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
|
|
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
|
|
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 () {
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
630
|
-
read.
|
|
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', () => {
|
|
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
|
-
|
|
761
|
-
|
|
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
|
|
944
|
+
listen(root, 'contextmenu', chooseCandidate, true)
|
|
825
945
|
for (const name of ['input', 'change']) {
|
|
826
|
-
root
|
|
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
|
|
833
|
-
root
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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 }
|