node-red-contrib-room-tools 1.8.15 → 1.9.0
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 +224 -100
- package/README.md +3 -1
- package/control-bound-protocol.js +1 -1
- package/control-catalog.js +5 -2
- package/control-panel.js +33 -2
- package/control-policy.js +16 -7
- package/control-type-contract.json +1 -0
- package/control-type-model.js +6 -1
- package/gateway.cjs +4 -0
- package/managed-native-bridge.js +2 -1
- package/meter-frame.js +111 -0
- package/meter-route.js +12 -0
- package/native-binding-plan.js +56 -6
- package/npm-shrinkwrap.json +2 -2
- package/package.json +8 -2
- package/panel-binding-editor.js +209 -37
- package/panel-editor-auth.js +5 -2
- package/panel-http.js +3 -2
- package/panel-meter-client.js +84 -0
- package/panel-meter.js +213 -0
- package/panel-template-instrumentation.js +181 -31
- package/panel-visual-apply.js +1 -1
- package/panel-visual-bindings.js +43 -6
package/panel-binding-editor.js
CHANGED
|
@@ -6,10 +6,15 @@ 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 meterEntries = [...saved].filter(([, entry]) => entry.readMeta?.meter)
|
|
10
|
+
const meterTargets = new Map()
|
|
11
|
+
let meterClient = null, lastMeterFrame = null
|
|
9
12
|
const attached = new WeakSet()
|
|
10
13
|
const feedbackModels = new Map()
|
|
11
14
|
const pendingText = new Map()
|
|
12
15
|
const cleanup = []
|
|
16
|
+
let disposed = false
|
|
17
|
+
cleanup.push(() => { disposed = true })
|
|
13
18
|
let editing = new URL(window.location.href).searchParams.get('cp-edit') === config.panelId
|
|
14
19
|
let editSession = null
|
|
15
20
|
let snapshot = null
|
|
@@ -47,15 +52,15 @@ function initializeVisualBinding () {
|
|
|
47
52
|
status.dataset.error = error ? 'true' : 'false'
|
|
48
53
|
}
|
|
49
54
|
}
|
|
50
|
-
async function request (path, method = 'GET', body) {
|
|
51
|
-
const response = await component.__panelFetch(base + path, {
|
|
55
|
+
async function request (path, method = 'GET', body, timeoutMs) {
|
|
56
|
+
const { response, result } = await component.__panelFetch(base + path, {
|
|
52
57
|
method, credentials: 'same-origin',
|
|
53
58
|
headers: method === 'POST'
|
|
54
59
|
? { 'Content-Type': 'application/json', 'X-Panel-CSRF': editSession?.csrf || '' }
|
|
55
60
|
: undefined,
|
|
56
61
|
body: method === 'POST' ? JSON.stringify(body || {}) : undefined
|
|
57
|
-
}, path === '/apply' ? 120000 : 12000)
|
|
58
|
-
|
|
62
|
+
}, path === '/apply' ? 120000 : (timeoutMs ?? 12000),
|
|
63
|
+
async response => ({ response, result: await response.json() }))
|
|
59
64
|
if (!response.ok) throw new Error(result.error || `Request failed (${response.status})`)
|
|
60
65
|
return result
|
|
61
66
|
}
|
|
@@ -102,25 +107,106 @@ function initializeVisualBinding () {
|
|
|
102
107
|
}
|
|
103
108
|
return String(node.value)
|
|
104
109
|
}
|
|
110
|
+
function repeatMarkerFor (node, repeat) {
|
|
111
|
+
let current = node
|
|
112
|
+
while (current && current !== root) {
|
|
113
|
+
if (current.getAttribute?.('data-cp-repeat') === repeat?.locator) {
|
|
114
|
+
const key = current.getAttribute('data-cp-repeat-key')
|
|
115
|
+
return key == null ? null : String(key)
|
|
116
|
+
}
|
|
117
|
+
current = current.parentElement
|
|
118
|
+
}
|
|
119
|
+
return null
|
|
120
|
+
}
|
|
121
|
+
function bindingMatchesNode (node, binding) {
|
|
122
|
+
if (node.getAttribute('data-cp-auto') !== binding.locator) return false
|
|
123
|
+
if (!binding.repeat) return true
|
|
124
|
+
return repeatMarkerFor(node, binding.repeat) === String(binding.instanceKey)
|
|
125
|
+
}
|
|
126
|
+
function nodesForBinding (binding) {
|
|
127
|
+
return [...root.querySelectorAll('[data-cp-auto]')].filter(node => bindingMatchesNode(node, binding))
|
|
128
|
+
}
|
|
129
|
+
function nodeForBinding (binding) {
|
|
130
|
+
const matches = nodesForBinding(binding)
|
|
131
|
+
return matches.length === 1 ? matches[0] : null
|
|
132
|
+
}
|
|
133
|
+
function repeatItem (binding) {
|
|
134
|
+
const repeat = binding.repeat
|
|
135
|
+
const source = component[repeat.source]
|
|
136
|
+
if (!Array.isArray(source)) return undefined
|
|
137
|
+
if (repeat.kind === 'array') return source[Number(binding.instanceKey)]
|
|
138
|
+
const keyField = repeat.key.slice(repeat.value.length + 1)
|
|
139
|
+
return source.find(value => value && String(value[keyField]) === String(binding.instanceKey))
|
|
140
|
+
}
|
|
141
|
+
function readRepeatField (item, field) {
|
|
142
|
+
return field.split('.').reduce((value, name) => value == null ? undefined : value[name], item)
|
|
143
|
+
}
|
|
144
|
+
function writeRepeatField (item, field, value) {
|
|
145
|
+
const names = field.split('.')
|
|
146
|
+
const last = names.pop()
|
|
147
|
+
let target = item
|
|
148
|
+
for (const name of names) {
|
|
149
|
+
if (!target || typeof target !== 'object') return false
|
|
150
|
+
target = target[name]
|
|
151
|
+
}
|
|
152
|
+
if (!target || typeof target !== 'object') return false
|
|
153
|
+
if (!Object.is(target[last], value)) target[last] = value
|
|
154
|
+
return true
|
|
155
|
+
}
|
|
156
|
+
function repeatModelValue (binding) {
|
|
157
|
+
if (!binding.repeat || !binding.model) return binding.model ? component[binding.model] : undefined
|
|
158
|
+
const repeat = binding.repeat
|
|
159
|
+
const source = component[repeat.source]
|
|
160
|
+
if (!Array.isArray(source)) return undefined
|
|
161
|
+
const item = repeatItem(binding)
|
|
162
|
+
if (repeat.kind === 'array' && binding.model === `${repeat.source}[${repeat.index}]`) return item
|
|
163
|
+
const prefix = repeat.value + '.'
|
|
164
|
+
const indexedPrefix = repeat.index ? `${repeat.source}[${repeat.index}].` : ''
|
|
165
|
+
const field = binding.model.startsWith(prefix) ? binding.model.slice(prefix.length)
|
|
166
|
+
: indexedPrefix && binding.model.startsWith(indexedPrefix)
|
|
167
|
+
? binding.model.slice(indexedPrefix.length) : ''
|
|
168
|
+
return item && field ? readRepeatField(item, field) : undefined
|
|
169
|
+
}
|
|
170
|
+
function setRepeatModel (binding, value) {
|
|
171
|
+
if (!binding.repeat || !binding.model) return false
|
|
172
|
+
const repeat = binding.repeat
|
|
173
|
+
const source = component[repeat.source]
|
|
174
|
+
if (!Array.isArray(source)) return false
|
|
175
|
+
const item = repeatItem(binding)
|
|
176
|
+
const prefix = repeat.value + '.'
|
|
177
|
+
const indexedPrefix = repeat.index ? `${repeat.source}[${repeat.index}].` : ''
|
|
178
|
+
const field = binding.model.startsWith(prefix) ? binding.model.slice(prefix.length)
|
|
179
|
+
: indexedPrefix && binding.model.startsWith(indexedPrefix)
|
|
180
|
+
? binding.model.slice(indexedPrefix.length) : ''
|
|
181
|
+
if (field) {
|
|
182
|
+
return writeRepeatField(item, field, value)
|
|
183
|
+
}
|
|
184
|
+
if (repeat.kind !== 'array' || binding.model !== `${repeat.source}[${repeat.index}]`) return false
|
|
185
|
+
const index = Number(binding.instanceKey)
|
|
186
|
+
if (!Number.isInteger(index) || index < 0 || index >= source.length) return false
|
|
187
|
+
if (!Object.is(source[index], value)) source[index] = value
|
|
188
|
+
return true
|
|
189
|
+
}
|
|
105
190
|
function showFeedback () {
|
|
106
191
|
for (const [id, binding] of saved) {
|
|
107
|
-
if (binding.mode === 'write') continue
|
|
108
|
-
const
|
|
109
|
-
if (
|
|
192
|
+
if (binding.mode === 'write' || binding.readMeta?.meter) continue
|
|
193
|
+
const node = nodeForBinding(binding)
|
|
194
|
+
if (!node) continue
|
|
110
195
|
if (!Object.prototype.hasOwnProperty.call(component.boundState, id)) continue
|
|
111
196
|
const value = component.boundDisplayValue(id, component.boundState[id])
|
|
112
|
-
const node = matches[0]
|
|
113
197
|
const displayValue = ['range', 'meter'].includes(binding.adapter)
|
|
114
198
|
? mappedValue(Number(value), binding.readMeta || binding.writeMeta,
|
|
115
199
|
numericRange(binding, node)) : value
|
|
116
200
|
if (binding.model) {
|
|
117
201
|
if (binding.component && binding.adapter === 'text-input' &&
|
|
118
202
|
(pendingText.has(id) || node.contains?.(document.activeElement))) continue
|
|
119
|
-
|
|
120
|
-
|
|
203
|
+
const current = repeatModelValue(binding)
|
|
204
|
+
if (binding.repeat || (binding.model in component && !component.$options?.computed?.[binding.model])) {
|
|
205
|
+
if (!Object.is(current, displayValue)) {
|
|
121
206
|
pendingText.delete(id)
|
|
122
207
|
if (binding.component) feedbackModels.set(id, displayValue)
|
|
123
|
-
|
|
208
|
+
if (binding.repeat) setRepeatModel(binding, displayValue)
|
|
209
|
+
else component[binding.model] = displayValue
|
|
124
210
|
if (binding.component && component.$nextTick) {
|
|
125
211
|
void component.$nextTick(() => {
|
|
126
212
|
if (Object.is(feedbackModels.get(id), displayValue)) feedbackModels.delete(id)
|
|
@@ -146,11 +232,12 @@ function initializeVisualBinding () {
|
|
|
146
232
|
function attachControls () {
|
|
147
233
|
for (const node of root.querySelectorAll('[data-cp-auto]')) {
|
|
148
234
|
if (attached.has(node)) continue
|
|
149
|
-
attached.add(node)
|
|
150
235
|
const locator = node.getAttribute('data-cp-auto')
|
|
151
|
-
const matched = [...saved].find(([, entry]) => entry
|
|
236
|
+
const matched = [...saved].find(([, entry]) => bindingMatchesNode(node, entry))
|
|
152
237
|
if (!matched) continue
|
|
153
238
|
const [id, binding] = matched
|
|
239
|
+
if (nodeForBinding(binding) !== node) continue
|
|
240
|
+
attached.add(node)
|
|
154
241
|
if (binding.mode === 'read') continue
|
|
155
242
|
if (binding.component) {
|
|
156
243
|
if (binding.adapter === 'text-input') {
|
|
@@ -192,8 +279,40 @@ function initializeVisualBinding () {
|
|
|
192
279
|
})
|
|
193
280
|
} else node.addEventListener('change', () => send(true))
|
|
194
281
|
}
|
|
282
|
+
if (meterEntries.length) {
|
|
283
|
+
meterTargets.clear()
|
|
284
|
+
for (const [id, binding] of meterEntries) {
|
|
285
|
+
const node = nodeForBinding(binding)
|
|
286
|
+
if (node) meterTargets.set(id, { node, binding })
|
|
287
|
+
}
|
|
288
|
+
if (lastMeterFrame) applyMeterFrame(lastMeterFrame.frame, lastMeterFrame.ids)
|
|
289
|
+
else invalidateMeters()
|
|
290
|
+
}
|
|
195
291
|
showFeedback()
|
|
196
292
|
}
|
|
293
|
+
function applyMeterFrame (frame, ids) {
|
|
294
|
+
lastMeterFrame = { frame, ids }
|
|
295
|
+
for (let index = 0; index < ids.length; index++) {
|
|
296
|
+
const target = meterTargets.get(ids[index])
|
|
297
|
+
if (!target) continue
|
|
298
|
+
const { node, binding } = target
|
|
299
|
+
node.setAttribute('data-cpv-meter-valid', String(frame.valid[index]))
|
|
300
|
+
node.setAttribute('aria-invalid', String(!frame.valid[index]))
|
|
301
|
+
if (!frame.valid[index]) continue
|
|
302
|
+
const value = mappedValue(frame.values[index], binding.readMeta, numericRange(binding, node))
|
|
303
|
+
if (binding.model) {
|
|
304
|
+
if (binding.repeat) setRepeatModel(binding, value)
|
|
305
|
+
else if (binding.model in component && !component.$options?.computed?.[binding.model]) component[binding.model] = value
|
|
306
|
+
} else node.value = value
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
function invalidateMeters () {
|
|
310
|
+
lastMeterFrame = null
|
|
311
|
+
for (const { node } of meterTargets.values()) {
|
|
312
|
+
node.setAttribute('data-cpv-meter-valid', 'false')
|
|
313
|
+
node.setAttribute('aria-invalid', 'true')
|
|
314
|
+
}
|
|
315
|
+
}
|
|
197
316
|
function attachModelControls () {
|
|
198
317
|
for (const [id, binding] of saved) {
|
|
199
318
|
if (!binding.component || !binding.model || binding.mode === 'read') continue
|
|
@@ -230,7 +349,9 @@ function initializeVisualBinding () {
|
|
|
230
349
|
console.error('ControlPanel binding command failed', error)
|
|
231
350
|
}
|
|
232
351
|
}
|
|
233
|
-
const unwatch = component.$watch(
|
|
352
|
+
const unwatch = component.$watch(
|
|
353
|
+
binding.repeat ? () => repeatModelValue(binding) : binding.model,
|
|
354
|
+
value => {
|
|
234
355
|
if (feedbackModels.has(id)) {
|
|
235
356
|
const feedbackValue = feedbackModels.get(id)
|
|
236
357
|
feedbackModels.delete(id)
|
|
@@ -246,21 +367,25 @@ function initializeVisualBinding () {
|
|
|
246
367
|
clearTimeout(finishTimer)
|
|
247
368
|
finishTimer = setTimeout(() => send(value, true), 160)
|
|
248
369
|
} else send(value, true)
|
|
249
|
-
|
|
370
|
+
})
|
|
250
371
|
cleanup.push(() => { clearTimeout(finishTimer); unwatch() })
|
|
251
372
|
}
|
|
252
373
|
}
|
|
253
374
|
function closeOverlay () { overlay?.remove(); overlay = null }
|
|
375
|
+
function sameBindingTarget (entry, candidate) {
|
|
376
|
+
return entry.locator === candidate.locator &&
|
|
377
|
+
(!candidate.repeat || String(entry.instanceKey) === String(candidate.instanceKey))
|
|
378
|
+
}
|
|
254
379
|
function updateBindingMarkers () {
|
|
255
380
|
for (const node of root.querySelectorAll('[data-cpv-bound="true"]')) {
|
|
256
381
|
node.removeAttribute('data-cpv-bound')
|
|
257
382
|
node.removeAttribute('data-cpv-binding-id')
|
|
258
383
|
}
|
|
259
384
|
if (!editing || !editSession) return
|
|
260
|
-
const bindings = new Map(draft.map(entry => [entry.locator, entry]))
|
|
261
385
|
for (const node of root.querySelectorAll('[data-cp-auto]')) {
|
|
262
|
-
const entry =
|
|
386
|
+
const entry = draft.find(item => bindingMatchesNode(node, item))
|
|
263
387
|
if (!entry) continue
|
|
388
|
+
if (nodeForBinding(entry) !== node) continue
|
|
264
389
|
node.setAttribute('data-cpv-bound', 'true')
|
|
265
390
|
node.setAttribute('data-cpv-binding-id', entry.id)
|
|
266
391
|
}
|
|
@@ -269,10 +394,10 @@ function initializeVisualBinding () {
|
|
|
269
394
|
closeOverlay()
|
|
270
395
|
overlay = element('div', null, 'cpv-menu')
|
|
271
396
|
overlay.setAttribute('role', 'menu')
|
|
272
|
-
const existing = draft.find(entry => entry
|
|
397
|
+
const existing = draft.find(entry => sameBindingTarget(entry, candidate))
|
|
273
398
|
overlay.append(button(existing ? 'Edit binding' : 'Bind control', () => bindingDialog(candidate)))
|
|
274
399
|
if (existing) overlay.append(button('Remove binding', () => {
|
|
275
|
-
draft = draft.filter(entry => entry
|
|
400
|
+
draft = draft.filter(entry => !sameBindingTarget(entry, candidate))
|
|
276
401
|
updateBindingMarkers()
|
|
277
402
|
closeOverlay()
|
|
278
403
|
inform('Binding removed from draft')
|
|
@@ -330,6 +455,7 @@ function initializeVisualBinding () {
|
|
|
330
455
|
}
|
|
331
456
|
function closeLoginScreen () { loginScreen?.remove(); loginScreen = null }
|
|
332
457
|
function showLoginForm (title, login, editor = false) {
|
|
458
|
+
if (disposed) return
|
|
333
459
|
const content = coverPage(title, editor ? 'editor' : 'panel', 'login')
|
|
334
460
|
const form = element('form')
|
|
335
461
|
const user = field(form, editor ? 'Editor username' : 'Username', element('input'))
|
|
@@ -353,6 +479,7 @@ function initializeVisualBinding () {
|
|
|
353
479
|
message.textContent = ''
|
|
354
480
|
try {
|
|
355
481
|
await login(user.value, password.value)
|
|
482
|
+
if (disposed) return
|
|
356
483
|
closeLoginScreen()
|
|
357
484
|
} catch (error) {
|
|
358
485
|
message.textContent = error.message
|
|
@@ -366,13 +493,13 @@ function initializeVisualBinding () {
|
|
|
366
493
|
}
|
|
367
494
|
function loginView () {
|
|
368
495
|
showLoginForm('ControlPanel editor', async (username, password) => {
|
|
369
|
-
const response = await component.__panelFetch(base + '/login', {
|
|
496
|
+
const { response, result } = await component.__panelFetch(base + '/login', {
|
|
370
497
|
method: 'POST', credentials: 'same-origin',
|
|
371
498
|
headers: { 'Content-Type': 'application/json' },
|
|
372
499
|
body: JSON.stringify({ username, password })
|
|
373
|
-
})
|
|
374
|
-
const result = await response.json()
|
|
500
|
+
}, 12000, async response => ({ response, result: await response.json() }))
|
|
375
501
|
if (!response.ok) throw new Error(result.error || 'Login failed')
|
|
502
|
+
if (disposed) return
|
|
376
503
|
editSession = result
|
|
377
504
|
await loadEditor()
|
|
378
505
|
}, true)
|
|
@@ -382,7 +509,9 @@ function initializeVisualBinding () {
|
|
|
382
509
|
}
|
|
383
510
|
async function loadEditor () {
|
|
384
511
|
snapshot = await request('/config')
|
|
512
|
+
if (disposed) return
|
|
385
513
|
const response = await request('/devices')
|
|
514
|
+
if (disposed) return
|
|
386
515
|
devices = response.devices
|
|
387
516
|
draft = snapshot.bindings.map(entry => ({ ...entry, onebased: numberingMode(entry) }))
|
|
388
517
|
closeOverlay()
|
|
@@ -390,6 +519,7 @@ function initializeVisualBinding () {
|
|
|
390
519
|
renderToolbar()
|
|
391
520
|
inform('Choose a control on the page')
|
|
392
521
|
updateBindingMarkers()
|
|
522
|
+
meterClient?.setAuth({ endpoint: base, csrf: editSession.csrf, header: 'X-Panel-CSRF' })
|
|
393
523
|
}
|
|
394
524
|
function choose (label, options, selected) {
|
|
395
525
|
const select = element('select')
|
|
@@ -411,14 +541,17 @@ function initializeVisualBinding () {
|
|
|
411
541
|
}
|
|
412
542
|
async function bindingDialog (candidate, options = {}) {
|
|
413
543
|
const returnToBindings = options.returnToBindings === true
|
|
414
|
-
const existing = draft.find(entry => entry
|
|
544
|
+
const existing = draft.find(entry => sameBindingTarget(entry, candidate))
|
|
415
545
|
const dialog = showDialog(existing ? 'Edit binding' : 'Bind control')
|
|
416
546
|
const form = element('form')
|
|
417
547
|
const name = field(form, 'Name', element('input'))
|
|
418
|
-
name.value = existing?.label || candidate.label
|
|
548
|
+
name.value = existing?.label || (candidate.repeat ? `${candidate.label} [${candidate.instanceKey}]` : candidate.label)
|
|
419
549
|
name.maxLength = 80
|
|
420
550
|
const id = field(form, 'Control ID', element('input'))
|
|
421
|
-
|
|
551
|
+
const instanceSuffix = candidate.repeat
|
|
552
|
+
? '.' + String(candidate.instanceKey).replace(/[^A-Za-z0-9._/-]/g, '-').slice(0, 48)
|
|
553
|
+
: ''
|
|
554
|
+
id.value = existing?.id || 'control.' + candidate.locator.slice(-12) + instanceSuffix
|
|
422
555
|
const modeOptions = ['meter', 'text-display'].includes(candidate.adapter)
|
|
423
556
|
? ['read'] : ['both', 'write', 'read']
|
|
424
557
|
const mode = field(form, 'Mode', choose('Mode', modeOptions.map(value => ({ value, text: value })), existing?.mode || modeOptions[0]))
|
|
@@ -486,7 +619,7 @@ function initializeVisualBinding () {
|
|
|
486
619
|
const actions = element('div', null, 'cpv-actions')
|
|
487
620
|
actions.append(button('Cancel', () => finishBindingEdit(returnToBindings)))
|
|
488
621
|
if (existing) actions.append(button('Remove', () => {
|
|
489
|
-
draft = draft.filter(entry => entry
|
|
622
|
+
draft = draft.filter(entry => !sameBindingTarget(entry, candidate))
|
|
490
623
|
finishBindingEdit(returnToBindings)
|
|
491
624
|
inform('Binding removed from draft')
|
|
492
625
|
}))
|
|
@@ -497,7 +630,7 @@ function initializeVisualBinding () {
|
|
|
497
630
|
form.addEventListener('submit', event => {
|
|
498
631
|
event.preventDefault()
|
|
499
632
|
if (!/^[A-Za-z][A-Za-z0-9._/-]{0,127}$/.test(id.value) ||
|
|
500
|
-
draft.some(entry => entry.id === id.value && entry
|
|
633
|
+
draft.some(entry => entry.id === id.value && !sameBindingTarget(entry, candidate))) {
|
|
501
634
|
message.textContent = 'Control ID must be unique and valid'; return
|
|
502
635
|
}
|
|
503
636
|
if (!name.value.trim() || !device.value || (mode.value !== 'read' && !write.value) ||
|
|
@@ -507,12 +640,13 @@ function initializeVisualBinding () {
|
|
|
507
640
|
const entry = {
|
|
508
641
|
id: id.value, locator: candidate.locator, adapter: candidate.adapter,
|
|
509
642
|
...(candidate.model ? { model: candidate.model } : {}),
|
|
643
|
+
...(candidate.repeat ? { repeat: candidate.repeat, instanceKey: String(candidate.instanceKey) } : {}),
|
|
510
644
|
label: name.value.trim(), mode: mode.value, device: device.value,
|
|
511
645
|
writePath: mode.value === 'read' ? '' : write.value,
|
|
512
646
|
readPath: mode.value === 'write' ? '' : read.value,
|
|
513
647
|
onebased: numbering.value === 'true'
|
|
514
648
|
}
|
|
515
|
-
draft = draft.filter(item => item
|
|
649
|
+
draft = draft.filter(item => !sameBindingTarget(item, candidate))
|
|
516
650
|
draft.push(entry)
|
|
517
651
|
updateBindingMarkers()
|
|
518
652
|
finishBindingEdit(returnToBindings)
|
|
@@ -566,6 +700,8 @@ function initializeVisualBinding () {
|
|
|
566
700
|
const position = (order.get(a.locator) ?? Number.MAX_SAFE_INTEGER) -
|
|
567
701
|
(order.get(b.locator) ?? Number.MAX_SAFE_INTEGER)
|
|
568
702
|
if (position) return position
|
|
703
|
+
const instancePosition = String(a.instanceKey || '').localeCompare(String(b.instanceKey || ''))
|
|
704
|
+
if (instancePosition) return instancePosition
|
|
569
705
|
return String(a.id).localeCompare(String(b.id))
|
|
570
706
|
})
|
|
571
707
|
}
|
|
@@ -580,10 +716,11 @@ function initializeVisualBinding () {
|
|
|
580
716
|
details.append(element('span', path, 'cpv-binding-path'))
|
|
581
717
|
row.append(details)
|
|
582
718
|
const actions = element('div', null, 'cpv-binding-actions')
|
|
583
|
-
const
|
|
719
|
+
const baseCandidate = snapshot.candidates.find(item => item.locator === entry.locator && item.supported)
|
|
720
|
+
const candidate = baseCandidate?.repeat ? { ...baseCandidate, instanceKey: entry.instanceKey } : baseCandidate
|
|
584
721
|
const edit = button('Edit', () => bindingDialog(candidate, { returnToBindings: true }))
|
|
585
|
-
edit.disabled = !candidate
|
|
586
|
-
if (
|
|
722
|
+
edit.disabled = !candidate || !nodeForBinding(entry)
|
|
723
|
+
if (edit.disabled) edit.title = 'This control is no longer available on the page'
|
|
587
724
|
actions.append(edit)
|
|
588
725
|
actions.append(button('Remove', () => {
|
|
589
726
|
draft = draft.filter(item => item.id !== entry.id)
|
|
@@ -604,9 +741,17 @@ function initializeVisualBinding () {
|
|
|
604
741
|
function candidateFromEvent (event) {
|
|
605
742
|
const node = event.target?.closest?.('[data-cp-auto]')
|
|
606
743
|
if (!node || !root.contains(node) || !snapshot) return null
|
|
607
|
-
const
|
|
608
|
-
if (!
|
|
609
|
-
const
|
|
744
|
+
const baseCandidate = snapshot.candidates.find(item => item.locator === node.getAttribute('data-cp-auto'))
|
|
745
|
+
if (!baseCandidate) return null
|
|
746
|
+
const instanceKey = baseCandidate.repeat ? repeatMarkerFor(node, baseCandidate.repeat) : undefined
|
|
747
|
+
const candidate = baseCandidate.repeat
|
|
748
|
+
? { ...baseCandidate, ...(instanceKey == null ? {} : { instanceKey }) }
|
|
749
|
+
: baseCandidate
|
|
750
|
+
if (candidate.repeat && instanceKey == null) {
|
|
751
|
+
return { ...candidate, supported: false, reason: 'Repeated control instance key is unavailable' }
|
|
752
|
+
}
|
|
753
|
+
if (!candidate.component || !candidate.model) return candidate
|
|
754
|
+
const value = candidate.repeat ? repeatModelValue(candidate) : component[candidate.model]
|
|
610
755
|
if (value == null) return candidate
|
|
611
756
|
if (candidate.adapter === 'switch' && typeof value !== 'boolean') {
|
|
612
757
|
return { ...candidate, supported: false, reason: 'This Vuetify switch needs a boolean v-model field' }
|
|
@@ -658,6 +803,25 @@ function initializeVisualBinding () {
|
|
|
658
803
|
}
|
|
659
804
|
attachControls()
|
|
660
805
|
attachModelControls()
|
|
806
|
+
if (this.__boundPanelConfig().meter && meterEntries.length) {
|
|
807
|
+
try {
|
|
808
|
+
const ids = meterEntries.map(([id]) => id).sort((a, b) => a.localeCompare(b))
|
|
809
|
+
meterClient = this.__createPanelMeterClient({ socket: this.$socket,
|
|
810
|
+
panelId: config.panelId, room: this.__boundPanelConfig().room, ids,
|
|
811
|
+
request: (...args) => component.__panelFetch(...args),
|
|
812
|
+
createReceiver: this.__createRoomMeterReceiver, createRenderer: this.__createMeterRenderer,
|
|
813
|
+
apply: applyMeterFrame, invalidate: invalidateMeters,
|
|
814
|
+
onUnauthorized: () => {
|
|
815
|
+
if (editing) { editSession = null; loginView() }
|
|
816
|
+
else component.clearBoundPanelSession()
|
|
817
|
+
} })
|
|
818
|
+
cleanup.push(() => meterClient.close())
|
|
819
|
+
if (!editing) cleanup.push(this.$watch('boundAuthenticated', authenticated => {
|
|
820
|
+
meterClient.setAuth(authenticated ? { endpoint: component.__boundPanelConfig().endpoint,
|
|
821
|
+
csrf: component.__boundPanelSession.csrf, header: 'X-Room-CSRF' } : null)
|
|
822
|
+
}))
|
|
823
|
+
} catch (error) { console.error('ControlPanel meter initialization failed', error) }
|
|
824
|
+
}
|
|
661
825
|
observer = new MutationObserver(() => {
|
|
662
826
|
attachControls()
|
|
663
827
|
updateBindingMarkers()
|
|
@@ -730,6 +894,7 @@ function initializeVisualBinding () {
|
|
|
730
894
|
.cpv-binding-actions{display:flex;gap:6px;flex-shrink:0}
|
|
731
895
|
body.cpv-editing [data-cpv-bound="true"]{outline:2px solid #b526f2 !important;outline-offset:3px;box-shadow:0 0 0 4px #b526f255 !important}
|
|
732
896
|
body.cpv-editing [data-cp-auto]:hover{outline:2px solid #b526f2;outline-offset:2px}
|
|
897
|
+
[data-cpv-meter-valid="false"]{opacity:.45}
|
|
733
898
|
`
|
|
734
899
|
document.head.append(style)
|
|
735
900
|
cleanup.push(() => style.remove())
|
|
@@ -737,7 +902,11 @@ function initializeVisualBinding () {
|
|
|
737
902
|
document.body.classList.add('cpv-editing')
|
|
738
903
|
cleanup.push(() => document.body.classList.remove('cpv-editing'))
|
|
739
904
|
coverPage('Loading...', 'editor')
|
|
740
|
-
request('/session').then(session => {
|
|
905
|
+
request('/session', 'GET', undefined, 2500).then(session => {
|
|
906
|
+
if (disposed) return
|
|
907
|
+
editSession = session
|
|
908
|
+
return loadEditor()
|
|
909
|
+
})
|
|
741
910
|
.then(closeLoginScreen).catch(() => loginView())
|
|
742
911
|
} else {
|
|
743
912
|
coverPage('Loading...', 'panel')
|
|
@@ -745,10 +914,13 @@ function initializeVisualBinding () {
|
|
|
745
914
|
if (authenticated) closeLoginScreen()
|
|
746
915
|
else if (!loginScreen) normalLoginView()
|
|
747
916
|
}))
|
|
748
|
-
component.restoreBoundPanelSession().then(session => {
|
|
917
|
+
component.restoreBoundPanelSession(2500).then(session => {
|
|
918
|
+
if (disposed) return
|
|
749
919
|
if (session || component.boundAuthenticated) closeLoginScreen()
|
|
750
920
|
else normalLoginView()
|
|
751
|
-
}).catch(() =>
|
|
921
|
+
}).catch(() => {
|
|
922
|
+
normalLoginView()
|
|
923
|
+
})
|
|
752
924
|
}
|
|
753
925
|
}
|
|
754
926
|
|
package/panel-editor-auth.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const crypto = require('node:crypto')
|
|
2
2
|
const { readJsonBody, replyJson: reply, sameOrigin } = require('./panel-http')
|
|
3
3
|
|
|
4
|
-
function createPanelEditor ({ panelId, username, password, secureCookie, getConfig, getDevices, getTargets, apply, restoredSessions }) {
|
|
4
|
+
function createPanelEditor ({ panelId, username, password, secureCookie, getConfig, getDevices, getTargets, apply, restoredSessions, onAuthenticatedRequest }) {
|
|
5
5
|
if (!username || !password) return null
|
|
6
6
|
const prefix = `/control-panel-edit/panels/${panelId}`
|
|
7
7
|
const cookieName = `cp_edit_${panelId.replace(/[^a-zA-Z0-9]/g, '_')}`
|
|
@@ -71,6 +71,8 @@ function createPanelEditor ({ panelId, username, password, secureCookie, getConf
|
|
|
71
71
|
reply(res, 403, { error: 'CSRF rejected' })
|
|
72
72
|
return true
|
|
73
73
|
}
|
|
74
|
+
if (onAuthenticatedRequest && await onAuthenticatedRequest(req, res,
|
|
75
|
+
{ token, expiry: session.expiry })) return true
|
|
74
76
|
if (path === prefix + '/session' && req.method === 'GET') {
|
|
75
77
|
reply(res, 200, { csrf: session.csrf })
|
|
76
78
|
} else if (path === prefix + '/logout' && req.method === 'POST') {
|
|
@@ -84,7 +86,7 @@ function createPanelEditor ({ panelId, username, password, secureCookie, getConf
|
|
|
84
86
|
const device = new URL(req.url, 'http://localhost').searchParams.get('device')
|
|
85
87
|
reply(res, 200, { targets: await getTargets(req, device) })
|
|
86
88
|
} else if (path === prefix + '/apply' && req.method === 'POST') {
|
|
87
|
-
reply(res, 200, await apply(req, await readJsonBody(req,
|
|
89
|
+
reply(res, 200, await apply(req, await readJsonBody(req, 524288)))
|
|
88
90
|
} else {
|
|
89
91
|
reply(res, 404, { error: 'Not found' })
|
|
90
92
|
}
|
|
@@ -97,6 +99,7 @@ function createPanelEditor ({ panelId, username, password, secureCookie, getConf
|
|
|
97
99
|
|
|
98
100
|
return {
|
|
99
101
|
handler,
|
|
102
|
+
sessionActive: token => !closed && (sessions.get(token)?.expiry || 0) > Date.now(),
|
|
100
103
|
sessionSnapshot () {
|
|
101
104
|
return new Map([...sessions].filter(([, session]) => session.expiry > Date.now())
|
|
102
105
|
.map(([token, session]) => [token, { ...session }]))
|
package/panel-http.js
CHANGED
|
@@ -56,12 +56,13 @@ async function readJsonBody (req, maxBytes) {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
// Serialized into the Vue component, so keep this function browser-only and self-contained.
|
|
59
|
-
async function fetchWithTimeout (url, options = {}, timeoutMs = 12000) {
|
|
59
|
+
async function fetchWithTimeout (url, options = {}, timeoutMs = 12000, readResponse) {
|
|
60
60
|
const controller = new AbortController()
|
|
61
61
|
let timedOut = false
|
|
62
62
|
const timer = setTimeout(() => { timedOut = true; controller.abort() }, timeoutMs)
|
|
63
63
|
try {
|
|
64
|
-
|
|
64
|
+
const response = await fetch(url, { ...options, signal: controller.signal })
|
|
65
|
+
return readResponse ? await readResponse(response) : response
|
|
65
66
|
} catch (error) {
|
|
66
67
|
if (timedOut) throw new Error('Request timed out; your binding draft is still available')
|
|
67
68
|
throw error
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Self-contained: serialized as a Vue method, with factories supplied by the node.
|
|
2
|
+
function createPanelMeterClient ({ socket, panelId, room, ids, request, createReceiver,
|
|
3
|
+
createRenderer, apply, invalidate, onUnauthorized }) {
|
|
4
|
+
if (!socket) throw Error('Dashboard socket is unavailable for meter telemetry')
|
|
5
|
+
let auth = null, renderer = null, generation = 0, disposed = false
|
|
6
|
+
let retry = null, stale = null, connecting = false, delay = 500
|
|
7
|
+
const stopRendering = () => {
|
|
8
|
+
renderer?.close(); renderer = null
|
|
9
|
+
clearTimeout(stale); stale = null
|
|
10
|
+
invalidate()
|
|
11
|
+
}
|
|
12
|
+
const pause = () => {
|
|
13
|
+
generation++
|
|
14
|
+
connecting = false
|
|
15
|
+
clearTimeout(retry); retry = null
|
|
16
|
+
if (socket.connected) socket.emit('control-panel:meter-unsubscribe', panelId)
|
|
17
|
+
stopRendering()
|
|
18
|
+
}
|
|
19
|
+
const connect = async () => {
|
|
20
|
+
if (disposed || !auth || document.hidden || !socket.connected || connecting) return
|
|
21
|
+
clearTimeout(retry); retry = null
|
|
22
|
+
connecting = true
|
|
23
|
+
const expected = generation
|
|
24
|
+
try {
|
|
25
|
+
const { response, result } = await request(auth.endpoint + '/meter-ticket', {
|
|
26
|
+
method: 'POST', credentials: 'same-origin',
|
|
27
|
+
headers: { 'Content-Type': 'application/json', [auth.header]: auth.csrf },
|
|
28
|
+
body: JSON.stringify({ room, socketId: socket.id })
|
|
29
|
+
}, 5000, async response => ({ response, result: await response.json() }))
|
|
30
|
+
if (disposed || expected !== generation || document.hidden) return
|
|
31
|
+
if (response.status === 401) { pause(); auth = null; onUnauthorized(); return }
|
|
32
|
+
if (!response.ok) throw Error(result.error || 'Meter subscription failed')
|
|
33
|
+
delay = 500
|
|
34
|
+
socket.emit('control-panel:meter-subscribe', panelId, result.ticket)
|
|
35
|
+
} catch (_) {
|
|
36
|
+
if (!disposed && expected === generation && auth) {
|
|
37
|
+
retry = setTimeout(connect, delay)
|
|
38
|
+
delay = Math.min(delay * 2, 5000)
|
|
39
|
+
}
|
|
40
|
+
} finally { if (expected === generation) connecting = false }
|
|
41
|
+
}
|
|
42
|
+
const ready = layout => {
|
|
43
|
+
if (disposed || !auth || document.hidden || layout?.panelId !== panelId || layout.room !== room ||
|
|
44
|
+
!Array.isArray(layout.ids) || JSON.stringify(layout.ids) !== JSON.stringify(ids)) return
|
|
45
|
+
stopRendering()
|
|
46
|
+
try {
|
|
47
|
+
renderer = createRenderer({ receiver: createReceiver(layout),
|
|
48
|
+
requestFrame: callback => requestAnimationFrame(callback), cancelFrame: cancelAnimationFrame,
|
|
49
|
+
apply: frame => { apply(frame, ids) } })
|
|
50
|
+
} catch (_) { stopRendering() }
|
|
51
|
+
}
|
|
52
|
+
const frame = message => {
|
|
53
|
+
if (message?.panelId !== panelId || !auth || document.hidden || !renderer?.receive(message)) return
|
|
54
|
+
clearTimeout(stale)
|
|
55
|
+
stale = setTimeout(invalidate, 1500)
|
|
56
|
+
}
|
|
57
|
+
const ended = message => {
|
|
58
|
+
if (message?.panelId !== panelId) return
|
|
59
|
+
stopRendering()
|
|
60
|
+
if (auth && !disposed && !retry) retry = setTimeout(connect, 500)
|
|
61
|
+
}
|
|
62
|
+
const disconnected = () => { generation++; connecting = false; stopRendering() }
|
|
63
|
+
const visibility = () => { if (document.hidden) pause(); else void connect() }
|
|
64
|
+
socket.on('control-panel:meter-ready', ready)
|
|
65
|
+
socket.on('control-panel:meter-frame', frame)
|
|
66
|
+
socket.on('control-panel:meter-ended', ended)
|
|
67
|
+
socket.on('connect', connect)
|
|
68
|
+
socket.on('disconnect', disconnected)
|
|
69
|
+
document.addEventListener('visibilitychange', visibility)
|
|
70
|
+
return {
|
|
71
|
+
setAuth (next) { pause(); auth = next; delay = 500; if (auth) void connect() },
|
|
72
|
+
close () {
|
|
73
|
+
disposed = true; pause(); auth = null
|
|
74
|
+
socket.off('control-panel:meter-ready', ready)
|
|
75
|
+
socket.off('control-panel:meter-frame', frame)
|
|
76
|
+
socket.off('control-panel:meter-ended', ended)
|
|
77
|
+
socket.off('connect', connect)
|
|
78
|
+
socket.off('disconnect', disconnected)
|
|
79
|
+
document.removeEventListener('visibilitychange', visibility)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = { createPanelMeterClient }
|