node-red-contrib-room-tools 1.8.15 → 1.8.16
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 +25 -8
- package/README.md +2 -0
- 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 +5 -1
- package/panel-binding-editor.js +84 -11
- package/panel-editor-auth.js +5 -2
- package/panel-http.js +3 -2
- package/panel-meter-client.js +84 -0
- package/panel-meter.js +212 -0
- package/panel-template-instrumentation.js +62 -4
- package/panel-visual-apply.js +1 -1
- package/panel-visual-bindings.js +21 -1
package/native-binding-plan.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const { configuredRoutes } = require('./control-bound-protocol')
|
|
2
|
+
const { meterRoute } = require('./meter-route')
|
|
2
3
|
|
|
3
4
|
const supportedTypes = {
|
|
4
5
|
boolean: { eventType: 'Boolean', eventOptions: { boolParamFilter: 'Any' } },
|
|
@@ -28,6 +29,7 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
|
|
|
28
29
|
const nodes = []
|
|
29
30
|
const readTargets = new Map()
|
|
30
31
|
const writeTargets = new Map()
|
|
32
|
+
const meterStreams = new Map()
|
|
31
33
|
const commandRules = []
|
|
32
34
|
const commandWires = []
|
|
33
35
|
|
|
@@ -51,7 +53,10 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
|
|
|
51
53
|
if (['source', 'preset'].includes(type) && !['enum', 'integer', 'string'].includes(target.type)) {
|
|
52
54
|
throw new Error(`Choice control bound to ${target.type}: ${path}`)
|
|
53
55
|
}
|
|
54
|
-
if (
|
|
56
|
+
if (type === 'meter' && (!target.meter || !['integer', 'float'].includes(target.type) || target.access !== 'ro')) {
|
|
57
|
+
throw new Error(`Meter control must bind to a readonly Meter target: ${path}`)
|
|
58
|
+
}
|
|
59
|
+
if (!['mute', 'power', 'level', 'dimmer', 'position', 'meter', 'text', 'source', 'preset'].includes(type)) {
|
|
55
60
|
throw new Error(`Control type requires a separate command adapter: ${type}`)
|
|
56
61
|
}
|
|
57
62
|
if (!['ro', 'wo', 'rw'].includes(target.access) || !target.access.includes(access)) {
|
|
@@ -88,6 +93,47 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
|
|
|
88
93
|
if (control.mode !== 'write') {
|
|
89
94
|
if (!binding.readPath) throw new Error(`Missing read path: ${id}`)
|
|
90
95
|
const target = metadata(binding.device, binding.readPath, control.type, 'r')
|
|
96
|
+
if (target.meter) {
|
|
97
|
+
const meter = meterRoute(target.meter)
|
|
98
|
+
if (control.mode !== 'read' || control.adapter !== 'meter') throw Error(`Meter is read-only: ${id}`)
|
|
99
|
+
const key = JSON.stringify([target.device, meter.streamId])
|
|
100
|
+
const previous = meterStreams.get(key)
|
|
101
|
+
if (previous) {
|
|
102
|
+
if (JSON.stringify(previous.meter) !== JSON.stringify(meter) || previous.onebased !== target.onebased) {
|
|
103
|
+
throw Error(`Conflicting meter stream: ${meter.streamId}`)
|
|
104
|
+
}
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
const index = meterStreams.size + 1
|
|
108
|
+
const statusId = `${scopeId}-meter-read-${index}`
|
|
109
|
+
const tagIds = []
|
|
110
|
+
meterStreams.set(key, { meter, onebased: target.onebased, statusId, tagIds })
|
|
111
|
+
for (const [role, path] of [['manifest', meter.manifestPath], ['frame', meter.eventPath]]) {
|
|
112
|
+
const eventId = `${scopeId}-meter-${role}-${index}`
|
|
113
|
+
const tagId = `${eventId}-tag`
|
|
114
|
+
tagIds.push(tagId)
|
|
115
|
+
const y = 540 + (index - 1) * 280 + (role === 'frame' ? 140 : 0)
|
|
116
|
+
if (role === 'manifest') nodes.push({
|
|
117
|
+
id: statusId, type: 'Status', z: subflowId, name: `Read meter manifest ${target.device}`,
|
|
118
|
+
x: 440, y: y - 60, mojo: { version: '1.0', device: [target.device],
|
|
119
|
+
parameter: path, onebased: target.onebased }, wires: [[tagId]]
|
|
120
|
+
})
|
|
121
|
+
nodes.push({ id: eventId, type: 'Event', z: subflowId,
|
|
122
|
+
name: `Watch meter ${role} ${target.device} ${meter.streamId}`, x: 440, y,
|
|
123
|
+
mojo: { version: '1.0', device: [target.device], event: path,
|
|
124
|
+
eventKind: role === 'frame' ? 'Event' : 'Parameter', paramType: 'String',
|
|
125
|
+
onebased: target.onebased }, wires: [[tagId]] })
|
|
126
|
+
nodes.push({ id: tagId, type: 'change', z: subflowId,
|
|
127
|
+
name: `Identify meter ${role} ${target.device}`, x: 1000, y,
|
|
128
|
+
rules: [
|
|
129
|
+
{ t: 'set', p: 'kind', pt: 'msg', to: 'device-meter-' + role, tot: 'str' },
|
|
130
|
+
{ t: 'set', p: 'device', pt: 'msg', to: target.device, tot: 'str' },
|
|
131
|
+
{ t: 'set', p: 'path', pt: 'msg', to: path, tot: 'str' },
|
|
132
|
+
{ t: 'set', p: 'streamId', pt: 'msg', to: meter.streamId, tot: 'str' }
|
|
133
|
+
], wires: [[]] })
|
|
134
|
+
}
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
91
137
|
const key = JSON.stringify([target.device, target.path])
|
|
92
138
|
if (readTargets.has(key)) continue
|
|
93
139
|
const index = readTargets.size + 1
|
|
@@ -128,6 +174,8 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
|
|
|
128
174
|
}
|
|
129
175
|
|
|
130
176
|
const commandSwitchId = `${scopeId}-commands`
|
|
177
|
+
// Meter lanes follow ordinary feedback lanes without overlapping their labels.
|
|
178
|
+
for (const node of nodes) if (node.id.startsWith(`${scopeId}-meter-`)) node.y += readTargets.size * 170
|
|
131
179
|
const routerId = `${scopeId}-router`
|
|
132
180
|
if (commandRules.length) {
|
|
133
181
|
nodes.push({
|
|
@@ -148,16 +196,17 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
|
|
|
148
196
|
],
|
|
149
197
|
checkall: 'false', repair: false, outputs: 2,
|
|
150
198
|
wires: [commandRules.length ? [commandSwitchId] : [],
|
|
151
|
-
[...readTargets.values()].map(target => target.statusId)]
|
|
199
|
+
[...readTargets.values(), ...meterStreams.values()].map(target => target.statusId)]
|
|
152
200
|
})
|
|
153
201
|
const subflow = {
|
|
154
202
|
id: subflowId, type: 'subflow', name: `Binding bridge ${scopeId}`,
|
|
155
203
|
info: 'Generated panel binding bridge. Requires a Controller node and authenticated command source.',
|
|
156
204
|
category: 'panel binding',
|
|
157
205
|
in: [{ x: 170, y: 150, wires: [{ id: routerId }] }],
|
|
158
|
-
out: readTargets.size
|
|
159
|
-
? [{ x: 1290, y: Math.round((390 + (readTargets.size + 1) * 85) / 10) * 10,
|
|
160
|
-
wires: [...readTargets.values()].map(target => ({ id: target.tagId, port: 0 }))
|
|
206
|
+
out: readTargets.size || meterStreams.size
|
|
207
|
+
? [{ x: meterStreams.size ? 1600 : 1290, y: Math.round((390 + (readTargets.size + meterStreams.size + 1) * 85) / 10) * 10,
|
|
208
|
+
wires: [...readTargets.values()].map(target => ({ id: target.tagId, port: 0 }))
|
|
209
|
+
.concat([...meterStreams.values()].flatMap(target => target.tagIds.map(id => ({ id, port: 0 })))) }]
|
|
161
210
|
: [],
|
|
162
211
|
env: [], meta: {}, color: '#96bca7'
|
|
163
212
|
}
|
|
@@ -166,7 +215,8 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
|
|
|
166
215
|
manifest: {
|
|
167
216
|
subflowId,
|
|
168
217
|
controls: routes.size,
|
|
169
|
-
nativeReads: readTargets.size,
|
|
218
|
+
nativeReads: readTargets.size + meterStreams.size,
|
|
219
|
+
nativeMeterStreams: meterStreams.size,
|
|
170
220
|
nativeWrites: writeTargets.size,
|
|
171
221
|
requiresController: true,
|
|
172
222
|
initialReadMessage: { kind: 'read-all' }
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-red-contrib-room-tools",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.16",
|
|
4
4
|
"lockfileVersion": 2,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "node-red-contrib-room-tools",
|
|
9
|
-
"version": "1.8.
|
|
9
|
+
"version": "1.8.16",
|
|
10
10
|
"license": "UNLICENSED",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@vue/compiler-sfc": "3.5.42",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-red-contrib-room-tools",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.16",
|
|
4
4
|
"description": "Authenticated visual control panel binding for Node-RED and MUSE Automator",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"engines": { "node": ">=18.16.0" },
|
|
@@ -21,6 +21,10 @@
|
|
|
21
21
|
"control-catalog.js",
|
|
22
22
|
"control-type-contract.json",
|
|
23
23
|
"control-type-model.js",
|
|
24
|
+
"meter-route.js",
|
|
25
|
+
"meter-frame.js",
|
|
26
|
+
"panel-meter.js",
|
|
27
|
+
"panel-meter-client.js",
|
|
24
28
|
"panel-template-instrumentation.js",
|
|
25
29
|
"panel-visual-bindings.js",
|
|
26
30
|
"panel-visual-apply.js",
|
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
|
}
|
|
@@ -104,7 +109,7 @@ function initializeVisualBinding () {
|
|
|
104
109
|
}
|
|
105
110
|
function showFeedback () {
|
|
106
111
|
for (const [id, binding] of saved) {
|
|
107
|
-
if (binding.mode === 'write') continue
|
|
112
|
+
if (binding.mode === 'write' || binding.readMeta?.meter) continue
|
|
108
113
|
const matches = root.querySelectorAll('[data-cp-auto="' + binding.locator + '"]')
|
|
109
114
|
if (matches.length !== 1) continue
|
|
110
115
|
if (!Object.prototype.hasOwnProperty.call(component.boundState, id)) continue
|
|
@@ -192,8 +197,44 @@ function initializeVisualBinding () {
|
|
|
192
197
|
})
|
|
193
198
|
} else node.addEventListener('change', () => send(true))
|
|
194
199
|
}
|
|
200
|
+
if (meterEntries.length) {
|
|
201
|
+
const locators = new Map()
|
|
202
|
+
for (const node of root.querySelectorAll('[data-cp-auto]')) {
|
|
203
|
+
const locator = node.getAttribute('data-cp-auto')
|
|
204
|
+
locators.set(locator, locators.has(locator) ? null : node)
|
|
205
|
+
}
|
|
206
|
+
meterTargets.clear()
|
|
207
|
+
for (const [id, binding] of meterEntries) {
|
|
208
|
+
const node = locators.get(binding.locator)
|
|
209
|
+
if (node) meterTargets.set(id, { node, binding })
|
|
210
|
+
}
|
|
211
|
+
if (lastMeterFrame) applyMeterFrame(lastMeterFrame.frame, lastMeterFrame.ids)
|
|
212
|
+
else invalidateMeters()
|
|
213
|
+
}
|
|
195
214
|
showFeedback()
|
|
196
215
|
}
|
|
216
|
+
function applyMeterFrame (frame, ids) {
|
|
217
|
+
lastMeterFrame = { frame, ids }
|
|
218
|
+
for (let index = 0; index < ids.length; index++) {
|
|
219
|
+
const target = meterTargets.get(ids[index])
|
|
220
|
+
if (!target) continue
|
|
221
|
+
const { node, binding } = target
|
|
222
|
+
node.setAttribute('data-cpv-meter-valid', String(frame.valid[index]))
|
|
223
|
+
node.setAttribute('aria-invalid', String(!frame.valid[index]))
|
|
224
|
+
if (!frame.valid[index]) continue
|
|
225
|
+
const value = mappedValue(frame.values[index], binding.readMeta, numericRange(binding, node))
|
|
226
|
+
if (binding.model) {
|
|
227
|
+
if (binding.model in component && !component.$options?.computed?.[binding.model]) component[binding.model] = value
|
|
228
|
+
} else node.value = value
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
function invalidateMeters () {
|
|
232
|
+
lastMeterFrame = null
|
|
233
|
+
for (const { node } of meterTargets.values()) {
|
|
234
|
+
node.setAttribute('data-cpv-meter-valid', 'false')
|
|
235
|
+
node.setAttribute('aria-invalid', 'true')
|
|
236
|
+
}
|
|
237
|
+
}
|
|
197
238
|
function attachModelControls () {
|
|
198
239
|
for (const [id, binding] of saved) {
|
|
199
240
|
if (!binding.component || !binding.model || binding.mode === 'read') continue
|
|
@@ -330,6 +371,7 @@ function initializeVisualBinding () {
|
|
|
330
371
|
}
|
|
331
372
|
function closeLoginScreen () { loginScreen?.remove(); loginScreen = null }
|
|
332
373
|
function showLoginForm (title, login, editor = false) {
|
|
374
|
+
if (disposed) return
|
|
333
375
|
const content = coverPage(title, editor ? 'editor' : 'panel', 'login')
|
|
334
376
|
const form = element('form')
|
|
335
377
|
const user = field(form, editor ? 'Editor username' : 'Username', element('input'))
|
|
@@ -353,6 +395,7 @@ function initializeVisualBinding () {
|
|
|
353
395
|
message.textContent = ''
|
|
354
396
|
try {
|
|
355
397
|
await login(user.value, password.value)
|
|
398
|
+
if (disposed) return
|
|
356
399
|
closeLoginScreen()
|
|
357
400
|
} catch (error) {
|
|
358
401
|
message.textContent = error.message
|
|
@@ -366,13 +409,13 @@ function initializeVisualBinding () {
|
|
|
366
409
|
}
|
|
367
410
|
function loginView () {
|
|
368
411
|
showLoginForm('ControlPanel editor', async (username, password) => {
|
|
369
|
-
const response = await component.__panelFetch(base + '/login', {
|
|
412
|
+
const { response, result } = await component.__panelFetch(base + '/login', {
|
|
370
413
|
method: 'POST', credentials: 'same-origin',
|
|
371
414
|
headers: { 'Content-Type': 'application/json' },
|
|
372
415
|
body: JSON.stringify({ username, password })
|
|
373
|
-
})
|
|
374
|
-
const result = await response.json()
|
|
416
|
+
}, 12000, async response => ({ response, result: await response.json() }))
|
|
375
417
|
if (!response.ok) throw new Error(result.error || 'Login failed')
|
|
418
|
+
if (disposed) return
|
|
376
419
|
editSession = result
|
|
377
420
|
await loadEditor()
|
|
378
421
|
}, true)
|
|
@@ -382,7 +425,9 @@ function initializeVisualBinding () {
|
|
|
382
425
|
}
|
|
383
426
|
async function loadEditor () {
|
|
384
427
|
snapshot = await request('/config')
|
|
428
|
+
if (disposed) return
|
|
385
429
|
const response = await request('/devices')
|
|
430
|
+
if (disposed) return
|
|
386
431
|
devices = response.devices
|
|
387
432
|
draft = snapshot.bindings.map(entry => ({ ...entry, onebased: numberingMode(entry) }))
|
|
388
433
|
closeOverlay()
|
|
@@ -390,6 +435,7 @@ function initializeVisualBinding () {
|
|
|
390
435
|
renderToolbar()
|
|
391
436
|
inform('Choose a control on the page')
|
|
392
437
|
updateBindingMarkers()
|
|
438
|
+
meterClient?.setAuth({ endpoint: base, csrf: editSession.csrf, header: 'X-Panel-CSRF' })
|
|
393
439
|
}
|
|
394
440
|
function choose (label, options, selected) {
|
|
395
441
|
const select = element('select')
|
|
@@ -658,6 +704,25 @@ function initializeVisualBinding () {
|
|
|
658
704
|
}
|
|
659
705
|
attachControls()
|
|
660
706
|
attachModelControls()
|
|
707
|
+
if (this.__boundPanelConfig().meter && meterEntries.length) {
|
|
708
|
+
try {
|
|
709
|
+
const ids = meterEntries.map(([id]) => id).sort((a, b) => a.localeCompare(b))
|
|
710
|
+
meterClient = this.__createPanelMeterClient({ socket: this.$socket,
|
|
711
|
+
panelId: config.panelId, room: this.__boundPanelConfig().room, ids,
|
|
712
|
+
request: (...args) => component.__panelFetch(...args),
|
|
713
|
+
createReceiver: this.__createRoomMeterReceiver, createRenderer: this.__createMeterRenderer,
|
|
714
|
+
apply: applyMeterFrame, invalidate: invalidateMeters,
|
|
715
|
+
onUnauthorized: () => {
|
|
716
|
+
if (editing) { editSession = null; loginView() }
|
|
717
|
+
else component.clearBoundPanelSession()
|
|
718
|
+
} })
|
|
719
|
+
cleanup.push(() => meterClient.close())
|
|
720
|
+
if (!editing) cleanup.push(this.$watch('boundAuthenticated', authenticated => {
|
|
721
|
+
meterClient.setAuth(authenticated ? { endpoint: component.__boundPanelConfig().endpoint,
|
|
722
|
+
csrf: component.__boundPanelSession.csrf, header: 'X-Room-CSRF' } : null)
|
|
723
|
+
}))
|
|
724
|
+
} catch (error) { console.error('ControlPanel meter initialization failed', error) }
|
|
725
|
+
}
|
|
661
726
|
observer = new MutationObserver(() => {
|
|
662
727
|
attachControls()
|
|
663
728
|
updateBindingMarkers()
|
|
@@ -730,6 +795,7 @@ function initializeVisualBinding () {
|
|
|
730
795
|
.cpv-binding-actions{display:flex;gap:6px;flex-shrink:0}
|
|
731
796
|
body.cpv-editing [data-cpv-bound="true"]{outline:2px solid #b526f2 !important;outline-offset:3px;box-shadow:0 0 0 4px #b526f255 !important}
|
|
732
797
|
body.cpv-editing [data-cp-auto]:hover{outline:2px solid #b526f2;outline-offset:2px}
|
|
798
|
+
[data-cpv-meter-valid="false"]{opacity:.45}
|
|
733
799
|
`
|
|
734
800
|
document.head.append(style)
|
|
735
801
|
cleanup.push(() => style.remove())
|
|
@@ -737,7 +803,11 @@ function initializeVisualBinding () {
|
|
|
737
803
|
document.body.classList.add('cpv-editing')
|
|
738
804
|
cleanup.push(() => document.body.classList.remove('cpv-editing'))
|
|
739
805
|
coverPage('Loading...', 'editor')
|
|
740
|
-
request('/session').then(session => {
|
|
806
|
+
request('/session', 'GET', undefined, 2500).then(session => {
|
|
807
|
+
if (disposed) return
|
|
808
|
+
editSession = session
|
|
809
|
+
return loadEditor()
|
|
810
|
+
})
|
|
741
811
|
.then(closeLoginScreen).catch(() => loginView())
|
|
742
812
|
} else {
|
|
743
813
|
coverPage('Loading...', 'panel')
|
|
@@ -745,10 +815,13 @@ function initializeVisualBinding () {
|
|
|
745
815
|
if (authenticated) closeLoginScreen()
|
|
746
816
|
else if (!loginScreen) normalLoginView()
|
|
747
817
|
}))
|
|
748
|
-
component.restoreBoundPanelSession().then(session => {
|
|
818
|
+
component.restoreBoundPanelSession(2500).then(session => {
|
|
819
|
+
if (disposed) return
|
|
749
820
|
if (session || component.boundAuthenticated) closeLoginScreen()
|
|
750
821
|
else normalLoginView()
|
|
751
|
-
}).catch(() =>
|
|
822
|
+
}).catch(() => {
|
|
823
|
+
normalLoginView()
|
|
824
|
+
})
|
|
752
825
|
}
|
|
753
826
|
}
|
|
754
827
|
|
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 }
|