node-red-contrib-room-tools 1.8.9 → 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 +28 -11
- package/README.md +3 -1
- package/control-bound-protocol.js +1 -1
- package/control-catalog.js +10 -5
- package/control-panel.html +6 -1
- package/control-panel.js +47 -12
- 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-bridge-revision.js +1 -1
- 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 +6 -1
- package/panel-binding-editor.js +84 -11
- package/panel-editor-auth.js +5 -2
- package/panel-flow-editor-sync.js +53 -0
- package/panel-http.js +27 -4
- 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/meter-frame.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Shared, non-reactive receivers; usable in Node and injected Vue component methods.
|
|
2
|
+
function createMeterReceiver (manifest) {
|
|
3
|
+
const number = value => typeof value === 'number' && Number.isFinite(value)
|
|
4
|
+
if (manifest?.kind !== 'meter-manifest' || typeof manifest.streamId !== 'string' ||
|
|
5
|
+
!manifest.streamId.trim() || manifest.streamId.length > 255 ||
|
|
6
|
+
typeof manifest.epoch !== 'string' || !manifest.epoch || manifest.epoch.length > 128 ||
|
|
7
|
+
!Number.isInteger(manifest.layoutVersion) || manifest.layoutVersion < 1 ||
|
|
8
|
+
manifest.layoutVersion > 2147483647 || manifest.clock !== 'monotonic-ms' ||
|
|
9
|
+
!number(manifest.publishHz) || manifest.publishHz < 1 || manifest.publishHz > 60 ||
|
|
10
|
+
!Array.isArray(manifest.points) || !manifest.points.length || manifest.points.length > 256) {
|
|
11
|
+
throw Error('Invalid meter manifest')
|
|
12
|
+
}
|
|
13
|
+
const paths = new Set()
|
|
14
|
+
const points = Array.from(manifest.points, point => {
|
|
15
|
+
if (typeof point?.path !== 'string' || !/^\/[^/]/.test(point.path) ||
|
|
16
|
+
point.path.length > 1025 || paths.has(point.path) ||
|
|
17
|
+
!['integer', 'float'].includes(point.type) || !number(point.min) ||
|
|
18
|
+
!number(point.max) || point.min >= point.max ||
|
|
19
|
+
(point.type === 'integer' && (!Number.isSafeInteger(point.min) || !Number.isSafeInteger(point.max))) ||
|
|
20
|
+
typeof point.unit !== 'string' || point.unit.length > 32) throw Error('Invalid meter point')
|
|
21
|
+
paths.add(point.path)
|
|
22
|
+
return Object.freeze({ path: point.path, type: point.type, min: point.min, max: point.max, unit: point.unit })
|
|
23
|
+
})
|
|
24
|
+
const schema = Object.freeze({ ...manifest, points: Object.freeze(points) })
|
|
25
|
+
let latest = null, pending = false, closed = false
|
|
26
|
+
return {
|
|
27
|
+
manifest: schema,
|
|
28
|
+
accept (frame) {
|
|
29
|
+
if (closed || frame?.kind !== 'meter-frame' || frame.streamId !== schema.streamId ||
|
|
30
|
+
frame.epoch !== schema.epoch || frame.layoutVersion !== schema.layoutVersion ||
|
|
31
|
+
!Number.isSafeInteger(frame.seq) || frame.seq < 1 || (latest && frame.seq <= latest.seq) ||
|
|
32
|
+
!number(frame.sampleTime) || frame.sampleTime < 0 ||
|
|
33
|
+
!Array.isArray(frame.values) || frame.values.length !== points.length ||
|
|
34
|
+
!Array.isArray(frame.valid) || frame.valid.length !== points.length) return false
|
|
35
|
+
const values = [], valid = []
|
|
36
|
+
for (let index = 0; index < points.length; index++) {
|
|
37
|
+
const point = points[index], value = frame.values[index], flag = frame.valid[index]
|
|
38
|
+
if (typeof flag !== 'boolean' || (flag && (!number(value) ||
|
|
39
|
+
value < point.min || value > point.max ||
|
|
40
|
+
(point.type === 'integer' && !Number.isSafeInteger(value))))) return false
|
|
41
|
+
values.push(flag ? value : null)
|
|
42
|
+
valid.push(flag)
|
|
43
|
+
}
|
|
44
|
+
latest = Object.freeze({ kind: 'meter-frame', streamId: schema.streamId,
|
|
45
|
+
epoch: schema.epoch, seq: frame.seq, layoutVersion: schema.layoutVersion,
|
|
46
|
+
sampleTime: frame.sampleTime, values: Object.freeze(values), valid: Object.freeze(valid) })
|
|
47
|
+
pending = true
|
|
48
|
+
return true
|
|
49
|
+
},
|
|
50
|
+
snapshot () { return latest },
|
|
51
|
+
take () {
|
|
52
|
+
if (!pending) return null
|
|
53
|
+
pending = false
|
|
54
|
+
return latest
|
|
55
|
+
},
|
|
56
|
+
close () { closed = true; pending = false; latest = null }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function createMeterRenderer ({ receiver, requestFrame, cancelFrame, apply }) {
|
|
61
|
+
let handle = null, closed = false
|
|
62
|
+
return {
|
|
63
|
+
receive (frame) {
|
|
64
|
+
if (closed || !receiver.accept(frame)) return false
|
|
65
|
+
if (handle === null) {
|
|
66
|
+
handle = requestFrame(() => {
|
|
67
|
+
handle = null
|
|
68
|
+
const current = receiver.take()
|
|
69
|
+
if (!closed && current) apply(current)
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
return true
|
|
73
|
+
},
|
|
74
|
+
close () {
|
|
75
|
+
closed = true
|
|
76
|
+
if (handle !== null) cancelFrame(handle)
|
|
77
|
+
handle = null
|
|
78
|
+
receiver.close()
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function createRoomMeterReceiver (layout) {
|
|
84
|
+
if (layout?.kind !== 'room-meter-layout' || typeof layout.panelId !== 'string' ||
|
|
85
|
+
typeof layout.room !== 'string' || typeof layout.epoch !== 'string' || !layout.epoch ||
|
|
86
|
+
!Array.isArray(layout.ids) || !layout.ids.length || layout.ids.length > 256 ||
|
|
87
|
+
layout.ids.some(id => typeof id !== 'string' || !id) || new Set(layout.ids).size !== layout.ids.length) {
|
|
88
|
+
throw Error('Invalid Room meter layout')
|
|
89
|
+
}
|
|
90
|
+
let latest = null, pending = false, closed = false
|
|
91
|
+
const count = layout.ids.length
|
|
92
|
+
return {
|
|
93
|
+
accept (frame) {
|
|
94
|
+
if (closed || frame?.kind !== 'room-meter-frame' || frame.panelId !== layout.panelId ||
|
|
95
|
+
frame.room !== layout.room || frame.epoch !== layout.epoch ||
|
|
96
|
+
!Number.isSafeInteger(frame.seq) || frame.seq < 1 || (latest && frame.seq <= latest.seq) ||
|
|
97
|
+
!Array.isArray(frame.values) || frame.values.length !== count ||
|
|
98
|
+
!Array.isArray(frame.valid) || frame.valid.length !== count ||
|
|
99
|
+
frame.valid.some((flag, index) => typeof flag !== 'boolean' || (flag
|
|
100
|
+
? typeof frame.values[index] !== 'number' || !Number.isFinite(frame.values[index])
|
|
101
|
+
: frame.values[index] !== null))) return false
|
|
102
|
+
latest = { values: frame.values.slice(), valid: frame.valid.slice(), seq: frame.seq }
|
|
103
|
+
pending = true
|
|
104
|
+
return true
|
|
105
|
+
},
|
|
106
|
+
take () { if (!pending) return null; pending = false; return latest },
|
|
107
|
+
close () { closed = true; latest = null; pending = false }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = { createMeterReceiver, createMeterRenderer, createRoomMeterReceiver }
|
package/meter-route.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
function meterRoute (value) {
|
|
2
|
+
if (value == null) return null
|
|
3
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) ||
|
|
4
|
+
typeof value.streamId !== 'string' || !value.streamId.trim() || value.streamId.length > 255 ||
|
|
5
|
+
![value.eventPath, value.manifestPath].every(path => typeof path === 'string' &&
|
|
6
|
+
/^\/\/[^/]/.test(path) && path.length <= 1026) || value.eventPath === value.manifestPath) {
|
|
7
|
+
throw Error('Invalid meter delivery metadata')
|
|
8
|
+
}
|
|
9
|
+
return { streamId: value.streamId, eventPath: value.eventPath, manifestPath: value.manifestPath }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
module.exports = { meterRoute }
|
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,11 +21,16 @@
|
|
|
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",
|
|
27
31
|
"panel-editor-auth.js",
|
|
28
32
|
"panel-http.js",
|
|
33
|
+
"panel-flow-editor-sync.js",
|
|
29
34
|
"panel-binding-editor.js",
|
|
30
35
|
"native-binding-plan.js",
|
|
31
36
|
"managed-native-bridge.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 }]))
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
(function (root, factory) {
|
|
2
|
+
if (typeof window === 'object') root.ControlPanelFlowEditorSync = factory()
|
|
3
|
+
else if (typeof module === 'object' && module.exports) module.exports = factory()
|
|
4
|
+
})(typeof window === 'object' ? window : this, function () {
|
|
5
|
+
const topic = 'notification/runtime-deploy'
|
|
6
|
+
|
|
7
|
+
// Node-RED already owns the Flow-changed notification. This listener only
|
|
8
|
+
// reloads the editor page after a newer runtime revision is announced. It
|
|
9
|
+
// must never read, clear, import, or deploy Flow data.
|
|
10
|
+
function createController (hooks) {
|
|
11
|
+
let stopped = false
|
|
12
|
+
let reloading = false
|
|
13
|
+
|
|
14
|
+
function request (message) {
|
|
15
|
+
if (stopped || reloading || !message || typeof message.revision !== 'string') return
|
|
16
|
+
const current = hooks.version()
|
|
17
|
+
// A retained notification received during page startup describes the
|
|
18
|
+
// Flow that is already loaded. Ignore it instead of causing a reload.
|
|
19
|
+
if (typeof current !== 'string' || current === message.revision) return
|
|
20
|
+
reloading = true
|
|
21
|
+
hooks.reload()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return { request, stop: () => { stopped = true } }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function install (RED, $, host = window) {
|
|
28
|
+
if (RED.__controlPanelFlowSync) return RED.__controlPanelFlowSync
|
|
29
|
+
if (!RED.comms?.subscribe || !RED.nodes?.version || !host.location?.reload) {
|
|
30
|
+
RED.notify('ControlPanel Flow change listener is unavailable in this editor.', 'warning')
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const controller = createController({
|
|
35
|
+
version: () => RED.nodes.version(),
|
|
36
|
+
reload: () => host.location.reload()
|
|
37
|
+
})
|
|
38
|
+
const changed = (_topic, message) => controller.request(message)
|
|
39
|
+
RED.comms.subscribe(topic, changed)
|
|
40
|
+
const instance = {
|
|
41
|
+
request: controller.request,
|
|
42
|
+
dispose () {
|
|
43
|
+
controller.stop()
|
|
44
|
+
RED.comms.unsubscribe(topic, changed)
|
|
45
|
+
delete RED.__controlPanelFlowSync
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
RED.__controlPanelFlowSync = instance
|
|
49
|
+
return instance
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { topic, createController, install }
|
|
53
|
+
})
|
package/panel-http.js
CHANGED
|
@@ -56,14 +56,37 @@ 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
|
+
const timer = setTimeout(() => { timedOut = true; controller.abort() }, timeoutMs)
|
|
62
63
|
try {
|
|
63
|
-
|
|
64
|
+
const response = await fetch(url, { ...options, signal: controller.signal })
|
|
65
|
+
return readResponse ? await readResponse(response) : response
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (timedOut) throw new Error('Request timed out; your binding draft is still available')
|
|
68
|
+
throw error
|
|
64
69
|
} finally {
|
|
65
70
|
clearTimeout(timer)
|
|
66
71
|
}
|
|
67
72
|
}
|
|
68
73
|
|
|
69
|
-
|
|
74
|
+
// Keep the deadline active through JSON decoding, not just response headers.
|
|
75
|
+
async function runtimeJsonRequest (url, options, { fetcher = fetch, timeoutMs, operation }) {
|
|
76
|
+
const controller = new AbortController()
|
|
77
|
+
let timedOut = false
|
|
78
|
+
const timer = setTimeout(() => { timedOut = true; controller.abort() }, timeoutMs)
|
|
79
|
+
try {
|
|
80
|
+
const response = await fetcher(url, { ...options, signal: controller.signal })
|
|
81
|
+
if (!response.ok) return { response, data: null }
|
|
82
|
+
return { response, data: await response.json() }
|
|
83
|
+
} catch (cause) {
|
|
84
|
+
const error = new Error(`${operation} ${timedOut ? 'timed out' : 'failed'}; ${timedOut ? 'the runtime did not respond in time' : cause.message}`, { cause })
|
|
85
|
+
error.status = 503
|
|
86
|
+
throw error
|
|
87
|
+
} finally {
|
|
88
|
+
clearTimeout(timer)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = { sameOrigin, replyJson, readJsonBody, fetchWithTimeout, runtimeJsonRequest }
|