node-red-contrib-room-tools 1.9.0 → 1.9.3
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 +172 -29
- package/README.md +8 -0
- package/control-catalog.js +5 -2
- package/control-panel.js +37 -5
- package/control-policy.js +13 -1
- package/control-type-contract.json +1 -0
- package/control-type-model.js +8 -3
- package/gateway.cjs +10 -4
- package/managed-native-bridge.js +2 -1
- package/native-binding-plan.js +61 -7
- package/npm-shrinkwrap.json +2 -2
- package/package.json +8 -1
- package/panel-binding-editor.js +140 -9
- package/panel-button-press.js +46 -0
- package/panel-momentary-client.js +91 -0
- package/panel-momentary.js +93 -0
- package/panel-template-instrumentation.js +21 -1
- package/panel-visual-bindings.js +26 -2
- package/panel-xy-series-client.js +84 -0
- package/panel-xy-series.js +186 -0
- package/xy-series-frame.js +98 -0
- package/xy-series-route.js +11 -0
package/control-type-model.js
CHANGED
|
@@ -6,10 +6,11 @@ const { meterRoute } = require('./meter-route')
|
|
|
6
6
|
// Used by both server validation and the injected editor; keep it self-contained.
|
|
7
7
|
function createTargetMatcher (typeContract) {
|
|
8
8
|
const families = {
|
|
9
|
-
range: ['number'], switch: ['boolean'], meter: ['number'],
|
|
9
|
+
range: ['number'], switch: ['boolean'], meter: ['number'], 'xy-series': ['xy-series'],
|
|
10
10
|
'text-display': ['text', 'number', 'enum', 'boolean'], 'text-input': ['text'], select: ['enum']
|
|
11
11
|
}
|
|
12
12
|
function family (target) {
|
|
13
|
+
if (target.xySeries) return 'xy-series'
|
|
13
14
|
if (target.controlSchemaVersion !== typeContract.schemaVersion) return null
|
|
14
15
|
const definition = typeContract.types[String(target.controlType || '').toLowerCase()]
|
|
15
16
|
if (!definition) return null
|
|
@@ -24,9 +25,11 @@ function createTargetMatcher (typeContract) {
|
|
|
24
25
|
}
|
|
25
26
|
function compatible (adapter, target, direction, candidate = {}) {
|
|
26
27
|
if (adapter === 'meter' && !target.meter) return false
|
|
28
|
+
if (adapter === 'xy-series' && !target.xySeries) return false
|
|
27
29
|
if (target.meter && (adapter !== 'meter' || direction !== 'read' || target.access !== 'ro')) return false
|
|
30
|
+
if (target.xySeries && (adapter !== 'xy-series' || direction !== 'read' || target.access !== 'ro')) return false
|
|
28
31
|
if (target.kind !== 'param' || !families[adapter]?.includes(family(target))) return false
|
|
29
|
-
if (direction === 'write' && ['meter', 'text-display'].includes(adapter)) return false
|
|
32
|
+
if (direction === 'write' && ['meter', 'xy-series', 'text-display'].includes(adapter)) return false
|
|
30
33
|
if (!['read', 'write'].includes(direction)) return false
|
|
31
34
|
if (!(direction === 'write' ? ['wo', 'rw'] : ['ro', 'rw']).includes(target.access)) return false
|
|
32
35
|
if (candidate.unit && candidate.unit !== target.unit) return false
|
|
@@ -42,6 +45,7 @@ function controlTypeForAdapter (adapter, target) {
|
|
|
42
45
|
throw new Error('Target is incompatible with this control')
|
|
43
46
|
}
|
|
44
47
|
if (adapter === 'meter' && target.meter) return 'meter'
|
|
48
|
+
if (adapter === 'xy-series' && target.xySeries) return 'xy-series'
|
|
45
49
|
const family = targetFamily(target)
|
|
46
50
|
if (family === 'number') return 'level'
|
|
47
51
|
if (family === 'boolean') return 'mute'
|
|
@@ -60,7 +64,8 @@ function targetMeta (target, onebased) {
|
|
|
60
64
|
min: target.min, max: target.max, enums: target.enums,
|
|
61
65
|
...(target.booleanEncoding ? { booleanEncoding: target.booleanEncoding } : {}),
|
|
62
66
|
controlType: target.controlType, controlSchemaVersion: target.controlSchemaVersion,
|
|
63
|
-
...(target.meter ? { meter: meterRoute(target.meter), unit: target.unit || '' } : {})
|
|
67
|
+
...(target.meter ? { meter: meterRoute(target.meter), unit: target.unit || '' } : {}),
|
|
68
|
+
...(target.xySeries ? { xySeries: require('./xy-series-route').xySeriesRoute(target.xySeries) } : {})
|
|
64
69
|
}
|
|
65
70
|
}
|
|
66
71
|
|
package/gateway.cjs
CHANGED
|
@@ -10,6 +10,8 @@ function createGateway ({
|
|
|
10
10
|
onBoundCommand,
|
|
11
11
|
onBoundLogin,
|
|
12
12
|
onAuthenticatedRequest,
|
|
13
|
+
onSessionClosed,
|
|
14
|
+
holdEpoch,
|
|
13
15
|
secureCookie = false,
|
|
14
16
|
prefix,
|
|
15
17
|
sessionTtlMs = 8 * 3600000,
|
|
@@ -62,6 +64,7 @@ function createGateway ({
|
|
|
62
64
|
const session = sessions.get(subscriber.token)
|
|
63
65
|
if (!session || session.expiry < now) {
|
|
64
66
|
sessions.delete(subscriber.token)
|
|
67
|
+
onSessionClosed?.(subscriber.token)
|
|
65
68
|
closeSubscriber(subscriber)
|
|
66
69
|
continue
|
|
67
70
|
}
|
|
@@ -128,6 +131,7 @@ function createGateway ({
|
|
|
128
131
|
for (const [id, session] of sessions) {
|
|
129
132
|
if (session.expiry < now) {
|
|
130
133
|
sessions.delete(id)
|
|
134
|
+
onSessionClosed?.(id)
|
|
131
135
|
closeSubscribersForToken(id)
|
|
132
136
|
}
|
|
133
137
|
}
|
|
@@ -172,7 +176,7 @@ function createGateway ({
|
|
|
172
176
|
const token = crypto.randomBytes(32).toString('hex')
|
|
173
177
|
const csrf = crypto.randomBytes(24).toString('hex')
|
|
174
178
|
sessions.set(token, { csrf, expiry: now + sessionTtlMs })
|
|
175
|
-
reply(res, 200, { room, username, csrf }, {
|
|
179
|
+
reply(res, 200, { room, username, csrf, ...(holdEpoch ? { holdEpoch } : {}) }, {
|
|
176
180
|
'Set-Cookie': cookie(token, Math.ceil(sessionTtlMs / 1000), req)
|
|
177
181
|
})
|
|
178
182
|
if (typeof onBoundLogin === 'function') {
|
|
@@ -197,7 +201,7 @@ function createGateway ({
|
|
|
197
201
|
if (onAuthenticatedRequest && await onAuthenticatedRequest(req, res,
|
|
198
202
|
{ token, expiry: session.expiry, room, user: username })) return true
|
|
199
203
|
if (path === routePrefix + '/session' && req.method === 'GET') {
|
|
200
|
-
reply(res, 200, { room, username, csrf: session.csrf })
|
|
204
|
+
reply(res, 200, { room, username, csrf: session.csrf, ...(holdEpoch ? { holdEpoch } : {}) })
|
|
201
205
|
return true
|
|
202
206
|
}
|
|
203
207
|
if (path === routePrefix + '/events' && req.method === 'GET') {
|
|
@@ -226,6 +230,7 @@ function createGateway ({
|
|
|
226
230
|
}
|
|
227
231
|
if (path === routePrefix + '/logout' && req.method === 'POST') {
|
|
228
232
|
sessions.delete(token)
|
|
233
|
+
onSessionClosed?.(token)
|
|
229
234
|
closeSubscribersForToken(token)
|
|
230
235
|
reply(res, 200, { ok: true }, { 'Set-Cookie': cookie('', 0, req) })
|
|
231
236
|
return true
|
|
@@ -240,11 +245,12 @@ function createGateway ({
|
|
|
240
245
|
reply(res, 403, { error: 'Region denied' })
|
|
241
246
|
return true
|
|
242
247
|
}
|
|
243
|
-
|
|
248
|
+
const result = await onBoundCommand(data, { room, user: username, token, expiry: session.expiry })
|
|
249
|
+
if (result === false) {
|
|
244
250
|
reply(res, 409, { error: 'Managed bridge is out of sync' })
|
|
245
251
|
return true
|
|
246
252
|
}
|
|
247
|
-
reply(res, 200, { ok: true })
|
|
253
|
+
reply(res, 200, { ok: true, ...(typeof result?.held === 'boolean' ? { held: result.held } : {}) })
|
|
248
254
|
return true
|
|
249
255
|
}
|
|
250
256
|
|
package/managed-native-bridge.js
CHANGED
|
@@ -25,7 +25,8 @@ function panelTargets (panel) {
|
|
|
25
25
|
device: binding.device, path, kind: meta.kind, type: meta.type,
|
|
26
26
|
access: meta.access, onebased: meta.onebased,
|
|
27
27
|
...(meta.booleanEncoding ? { booleanEncoding: meta.booleanEncoding } : {}),
|
|
28
|
-
...(meta.meter ? { meter: meta.meter } : {})
|
|
28
|
+
...(meta.meter ? { meter: meta.meter } : {}),
|
|
29
|
+
...(meta.xySeries ? { xySeries: meta.xySeries } : {})
|
|
29
30
|
}
|
|
30
31
|
const key = JSON.stringify([target.device, target.path])
|
|
31
32
|
const previous = targets.get(key)
|
package/native-binding-plan.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const { configuredRoutes } = require('./control-bound-protocol')
|
|
2
2
|
const { meterRoute } = require('./meter-route')
|
|
3
|
+
const { xySeriesRoute } = require('./xy-series-route')
|
|
3
4
|
|
|
4
5
|
const supportedTypes = {
|
|
5
6
|
boolean: { eventType: 'Boolean', eventOptions: { boolParamFilter: 'Any' } },
|
|
@@ -30,6 +31,7 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
|
|
|
30
31
|
const readTargets = new Map()
|
|
31
32
|
const writeTargets = new Map()
|
|
32
33
|
const meterStreams = new Map()
|
|
34
|
+
const xySeriesStreams = new Map()
|
|
33
35
|
const commandRules = []
|
|
34
36
|
const commandWires = []
|
|
35
37
|
|
|
@@ -37,7 +39,7 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
|
|
|
37
39
|
const target = targetIndex.get(JSON.stringify([device, path]))
|
|
38
40
|
if (!target) throw new Error(`Missing target descriptor: ${device} ${path}`)
|
|
39
41
|
if (target.kind !== 'param') throw new Error(`Only parameter targets are supported: ${path}`)
|
|
40
|
-
if (!Object.hasOwn(supportedTypes, target.type)) {
|
|
42
|
+
if (!target.xySeries && !Object.hasOwn(supportedTypes, target.type)) {
|
|
41
43
|
throw new Error(`Unsupported native parameter type: ${path} (${target.type})`)
|
|
42
44
|
}
|
|
43
45
|
if (['mute', 'power'].includes(type) && target.type !== 'boolean' &&
|
|
@@ -56,7 +58,10 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
|
|
|
56
58
|
if (type === 'meter' && (!target.meter || !['integer', 'float'].includes(target.type) || target.access !== 'ro')) {
|
|
57
59
|
throw new Error(`Meter control must bind to a readonly Meter target: ${path}`)
|
|
58
60
|
}
|
|
59
|
-
if (
|
|
61
|
+
if (type === 'xy-series' && (!target.xySeries || target.access !== 'ro')) {
|
|
62
|
+
throw new Error(`XY-series control must bind to a readonly XY-series target: ${path}`)
|
|
63
|
+
}
|
|
64
|
+
if (!['mute', 'power', 'level', 'dimmer', 'position', 'meter', 'xy-series', 'text', 'source', 'preset'].includes(type)) {
|
|
60
65
|
throw new Error(`Control type requires a separate command adapter: ${type}`)
|
|
61
66
|
}
|
|
62
67
|
if (!['ro', 'wo', 'rw'].includes(target.access) || !target.access.includes(access)) {
|
|
@@ -134,6 +139,49 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
|
|
|
134
139
|
}
|
|
135
140
|
continue
|
|
136
141
|
}
|
|
142
|
+
if (target.xySeries) {
|
|
143
|
+
const series = xySeriesRoute(target.xySeries)
|
|
144
|
+
if (control.mode !== 'read' || control.adapter !== 'xy-series') {
|
|
145
|
+
throw Error(`XY-series is read-only: ${id}`)
|
|
146
|
+
}
|
|
147
|
+
const key = JSON.stringify([target.device, series.streamId])
|
|
148
|
+
const previous = xySeriesStreams.get(key)
|
|
149
|
+
if (previous) {
|
|
150
|
+
if (JSON.stringify(previous.series) !== JSON.stringify(series) || previous.onebased !== target.onebased) {
|
|
151
|
+
throw Error(`Conflicting xy-series stream: ${series.streamId}`)
|
|
152
|
+
}
|
|
153
|
+
continue
|
|
154
|
+
}
|
|
155
|
+
const index = xySeriesStreams.size + 1
|
|
156
|
+
const statusId = `${scopeId}-xy-series-read-${index}`
|
|
157
|
+
const tagIds = []
|
|
158
|
+
xySeriesStreams.set(key, { series, onebased: target.onebased, statusId, tagIds })
|
|
159
|
+
for (const [role, path] of [['manifest', series.manifestPath], ['frame', series.eventPath]]) {
|
|
160
|
+
const eventId = `${scopeId}-xy-series-${role}-${index}`
|
|
161
|
+
const tagId = `${eventId}-tag`
|
|
162
|
+
tagIds.push(tagId)
|
|
163
|
+
const y = 540 + (index - 1) * 280 + (role === 'frame' ? 140 : 0)
|
|
164
|
+
if (role === 'manifest') nodes.push({
|
|
165
|
+
id: statusId, type: 'Status', z: subflowId, name: `Read xy-series manifest ${target.device}`,
|
|
166
|
+
x: 440, y: y - 60, mojo: { version: '1.0', device: [target.device],
|
|
167
|
+
parameter: path, onebased: target.onebased }, wires: [[tagId]]
|
|
168
|
+
})
|
|
169
|
+
nodes.push({ id: eventId, type: 'Event', z: subflowId,
|
|
170
|
+
name: `Watch xy-series ${role} ${target.device} ${series.streamId}`, x: 440, y,
|
|
171
|
+
mojo: { version: '1.0', device: [target.device], event: path,
|
|
172
|
+
eventKind: role === 'frame' ? 'Event' : 'Parameter', paramType: 'String',
|
|
173
|
+
onebased: target.onebased }, wires: [[tagId]] })
|
|
174
|
+
nodes.push({ id: tagId, type: 'change', z: subflowId,
|
|
175
|
+
name: `Identify xy-series ${role} ${target.device}`, x: 1000, y,
|
|
176
|
+
rules: [
|
|
177
|
+
{ t: 'set', p: 'kind', pt: 'msg', to: 'device-xy-series-' + role, tot: 'str' },
|
|
178
|
+
{ t: 'set', p: 'device', pt: 'msg', to: target.device, tot: 'str' },
|
|
179
|
+
{ t: 'set', p: 'path', pt: 'msg', to: path, tot: 'str' },
|
|
180
|
+
{ t: 'set', p: 'streamId', pt: 'msg', to: series.streamId, tot: 'str' }
|
|
181
|
+
], wires: [[]] })
|
|
182
|
+
}
|
|
183
|
+
continue
|
|
184
|
+
}
|
|
137
185
|
const key = JSON.stringify([target.device, target.path])
|
|
138
186
|
if (readTargets.has(key)) continue
|
|
139
187
|
const index = readTargets.size + 1
|
|
@@ -175,7 +223,11 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
|
|
|
175
223
|
|
|
176
224
|
const commandSwitchId = `${scopeId}-commands`
|
|
177
225
|
// Meter lanes follow ordinary feedback lanes without overlapping their labels.
|
|
178
|
-
for (const node of nodes)
|
|
226
|
+
for (const node of nodes) {
|
|
227
|
+
if (node.id.startsWith(`${scopeId}-meter-`) || node.id.startsWith(`${scopeId}-xy-series-`)) {
|
|
228
|
+
node.y += readTargets.size * 170
|
|
229
|
+
}
|
|
230
|
+
}
|
|
179
231
|
const routerId = `${scopeId}-router`
|
|
180
232
|
if (commandRules.length) {
|
|
181
233
|
nodes.push({
|
|
@@ -196,17 +248,18 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
|
|
|
196
248
|
],
|
|
197
249
|
checkall: 'false', repair: false, outputs: 2,
|
|
198
250
|
wires: [commandRules.length ? [commandSwitchId] : [],
|
|
199
|
-
[...readTargets.values(), ...meterStreams.values()].map(target => target.statusId)]
|
|
251
|
+
[...readTargets.values(), ...meterStreams.values(), ...xySeriesStreams.values()].map(target => target.statusId)]
|
|
200
252
|
})
|
|
201
253
|
const subflow = {
|
|
202
254
|
id: subflowId, type: 'subflow', name: `Binding bridge ${scopeId}`,
|
|
203
255
|
info: 'Generated panel binding bridge. Requires a Controller node and authenticated command source.',
|
|
204
256
|
category: 'panel binding',
|
|
205
257
|
in: [{ x: 170, y: 150, wires: [{ id: routerId }] }],
|
|
206
|
-
out: readTargets.size || meterStreams.size
|
|
258
|
+
out: readTargets.size || meterStreams.size || xySeriesStreams.size
|
|
207
259
|
? [{ x: meterStreams.size ? 1600 : 1290, y: Math.round((390 + (readTargets.size + meterStreams.size + 1) * 85) / 10) * 10,
|
|
208
260
|
wires: [...readTargets.values()].map(target => ({ id: target.tagId, port: 0 }))
|
|
209
|
-
.concat([...meterStreams.values()].flatMap(target => target.tagIds.map(id => ({ id, port: 0 }))))
|
|
261
|
+
.concat([...meterStreams.values()].flatMap(target => target.tagIds.map(id => ({ id, port: 0 }))))
|
|
262
|
+
.concat([...xySeriesStreams.values()].flatMap(target => target.tagIds.map(id => ({ id, port: 0 })))) }]
|
|
210
263
|
: [],
|
|
211
264
|
env: [], meta: {}, color: '#96bca7'
|
|
212
265
|
}
|
|
@@ -215,8 +268,9 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
|
|
|
215
268
|
manifest: {
|
|
216
269
|
subflowId,
|
|
217
270
|
controls: routes.size,
|
|
218
|
-
nativeReads: readTargets.size + meterStreams.size,
|
|
271
|
+
nativeReads: readTargets.size + meterStreams.size + xySeriesStreams.size,
|
|
219
272
|
nativeMeterStreams: meterStreams.size,
|
|
273
|
+
nativeXYSeriesStreams: xySeriesStreams.size,
|
|
220
274
|
nativeWrites: writeTargets.size,
|
|
221
275
|
requiresController: true,
|
|
222
276
|
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.9.
|
|
3
|
+
"version": "1.9.3",
|
|
4
4
|
"lockfileVersion": 2,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "node-red-contrib-room-tools",
|
|
9
|
-
"version": "1.9.
|
|
9
|
+
"version": "1.9.3",
|
|
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.9.
|
|
3
|
+
"version": "1.9.3",
|
|
4
4
|
"description": "Authenticated visual control panel binding for Node-RED and MUSE Automator",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"engines": {
|
|
@@ -27,6 +27,10 @@
|
|
|
27
27
|
"meter-frame.js",
|
|
28
28
|
"panel-meter.js",
|
|
29
29
|
"panel-meter-client.js",
|
|
30
|
+
"xy-series-route.js",
|
|
31
|
+
"xy-series-frame.js",
|
|
32
|
+
"panel-xy-series.js",
|
|
33
|
+
"panel-xy-series-client.js",
|
|
30
34
|
"panel-template-instrumentation.js",
|
|
31
35
|
"panel-visual-bindings.js",
|
|
32
36
|
"panel-visual-apply.js",
|
|
@@ -34,6 +38,9 @@
|
|
|
34
38
|
"panel-http.js",
|
|
35
39
|
"panel-flow-editor-sync.js",
|
|
36
40
|
"panel-binding-editor.js",
|
|
41
|
+
"panel-momentary.js",
|
|
42
|
+
"panel-momentary-client.js",
|
|
43
|
+
"panel-button-press.js",
|
|
37
44
|
"native-binding-plan.js",
|
|
38
45
|
"managed-native-bridge.js",
|
|
39
46
|
"managed-bridge-revision.js",
|
package/panel-binding-editor.js
CHANGED
|
@@ -7,9 +7,15 @@ function initializeVisualBinding () {
|
|
|
7
7
|
const base = config.editorEndpoint
|
|
8
8
|
const saved = new Map(Object.entries(config.bindings || {}))
|
|
9
9
|
const meterEntries = [...saved].filter(([, entry]) => entry.readMeta?.meter)
|
|
10
|
+
const xySeriesEntries = [...saved].filter(([, entry]) => entry.readMeta?.xySeries)
|
|
10
11
|
const meterTargets = new Map()
|
|
12
|
+
const xySeriesTargets = new Map()
|
|
11
13
|
let meterClient = null, lastMeterFrame = null
|
|
14
|
+
let xySeriesClient = null, lastXYSeriesFrame = null
|
|
12
15
|
const attached = new WeakSet()
|
|
16
|
+
const buttonPresses = new Map()
|
|
17
|
+
const momentaryButtons = new Map()
|
|
18
|
+
const momentaryStates = new Map()
|
|
13
19
|
const feedbackModels = new Map()
|
|
14
20
|
const pendingText = new Map()
|
|
15
21
|
const cleanup = []
|
|
@@ -189,11 +195,13 @@ function initializeVisualBinding () {
|
|
|
189
195
|
}
|
|
190
196
|
function showFeedback () {
|
|
191
197
|
for (const [id, binding] of saved) {
|
|
192
|
-
if (binding.mode === 'write' || binding.readMeta?.meter) continue
|
|
198
|
+
if (binding.mode === 'write' || binding.readMeta?.meter || binding.readMeta?.xySeries) continue
|
|
193
199
|
const node = nodeForBinding(binding)
|
|
194
200
|
if (!node) continue
|
|
195
201
|
if (!Object.prototype.hasOwnProperty.call(component.boundState, id)) continue
|
|
196
|
-
const value =
|
|
202
|
+
const value = node.tagName === 'BUTTON' && binding.adapter === 'switch'
|
|
203
|
+
? component.controlValue(id, component.boundState[id])
|
|
204
|
+
: component.boundDisplayValue(id, component.boundState[id])
|
|
197
205
|
const displayValue = ['range', 'meter'].includes(binding.adapter)
|
|
198
206
|
? mappedValue(Number(value), binding.readMeta || binding.writeMeta,
|
|
199
207
|
numericRange(binding, node)) : value
|
|
@@ -230,6 +238,22 @@ function initializeVisualBinding () {
|
|
|
230
238
|
}
|
|
231
239
|
}
|
|
232
240
|
function attachControls () {
|
|
241
|
+
for (const [node, button] of buttonPresses) {
|
|
242
|
+
const binding = saved.get(button.id)
|
|
243
|
+
if (!binding || nodeForBinding(binding) !== node) {
|
|
244
|
+
button.controller.dispose()
|
|
245
|
+
buttonPresses.delete(node)
|
|
246
|
+
attached.delete(node)
|
|
247
|
+
} else button.controller.check()
|
|
248
|
+
}
|
|
249
|
+
for (const [node, button] of momentaryButtons) {
|
|
250
|
+
const binding = saved.get(button.id)
|
|
251
|
+
if (!binding || nodeForBinding(binding) !== node) {
|
|
252
|
+
button.controller.dispose()
|
|
253
|
+
momentaryButtons.delete(node)
|
|
254
|
+
attached.delete(node)
|
|
255
|
+
} else button.controller.check()
|
|
256
|
+
}
|
|
233
257
|
for (const node of root.querySelectorAll('[data-cp-auto]')) {
|
|
234
258
|
if (attached.has(node)) continue
|
|
235
259
|
const locator = node.getAttribute('data-cp-auto')
|
|
@@ -239,6 +263,22 @@ function initializeVisualBinding () {
|
|
|
239
263
|
if (nodeForBinding(binding) !== node) continue
|
|
240
264
|
attached.add(node)
|
|
241
265
|
if (binding.mode === 'read') continue
|
|
266
|
+
if (node.tagName === 'BUTTON' && binding.adapter === 'switch') {
|
|
267
|
+
buttonPresses.set(node, { id, controller: component.__attachButtonPress({ node,
|
|
268
|
+
canPress: () => !disposed && !editing && component.boundAuthenticated && component.boundFeedbackConnected }) })
|
|
269
|
+
}
|
|
270
|
+
if (binding.buttonBehavior === 'momentary' && node.tagName === 'BUTTON') {
|
|
271
|
+
if (!momentaryStates.has(id)) momentaryStates.set(id, { seq: 0 })
|
|
272
|
+
const controller = component.__attachMomentaryButton({ node, id,
|
|
273
|
+
state: momentaryStates.get(id),
|
|
274
|
+
endpoint: component.__boundPanelConfig().endpoint,
|
|
275
|
+
getSession: () => component.__boundPanelSession,
|
|
276
|
+
canStart: () => !disposed && !editing && component.boundAuthenticated && component.boundFeedbackConnected,
|
|
277
|
+
request: (...args) => component.__panelFetch(...args),
|
|
278
|
+
onError: error => console.error('ControlPanel momentary command failed', error) })
|
|
279
|
+
momentaryButtons.set(node, { id, controller })
|
|
280
|
+
continue
|
|
281
|
+
}
|
|
242
282
|
if (binding.component) {
|
|
243
283
|
if (binding.adapter === 'text-input') {
|
|
244
284
|
const commit = () => {
|
|
@@ -288,6 +328,15 @@ function initializeVisualBinding () {
|
|
|
288
328
|
if (lastMeterFrame) applyMeterFrame(lastMeterFrame.frame, lastMeterFrame.ids)
|
|
289
329
|
else invalidateMeters()
|
|
290
330
|
}
|
|
331
|
+
if (xySeriesEntries.length) {
|
|
332
|
+
xySeriesTargets.clear()
|
|
333
|
+
for (const [id, binding] of xySeriesEntries) {
|
|
334
|
+
const node = nodeForBinding(binding)
|
|
335
|
+
if (node) xySeriesTargets.set(id, { node, binding })
|
|
336
|
+
}
|
|
337
|
+
if (lastXYSeriesFrame) applyXYSeriesFrame(lastXYSeriesFrame.frame, lastXYSeriesFrame.ids)
|
|
338
|
+
else invalidateXYSeries()
|
|
339
|
+
}
|
|
291
340
|
showFeedback()
|
|
292
341
|
}
|
|
293
342
|
function applyMeterFrame (frame, ids) {
|
|
@@ -313,6 +362,32 @@ function initializeVisualBinding () {
|
|
|
313
362
|
node.setAttribute('aria-invalid', 'true')
|
|
314
363
|
}
|
|
315
364
|
}
|
|
365
|
+
function applyXYSeriesFrame (frame, ids) {
|
|
366
|
+
lastXYSeriesFrame = { frame, ids }
|
|
367
|
+
for (let index = 0; index < ids.length; index++) {
|
|
368
|
+
const target = xySeriesTargets.get(ids[index])
|
|
369
|
+
if (!target) continue
|
|
370
|
+
const { node, binding } = target
|
|
371
|
+
node.setAttribute('data-cpv-xy-series-valid', String(frame.valid[index]))
|
|
372
|
+
node.setAttribute('data-cpv-xy-series-seq', String(frame.seq))
|
|
373
|
+
const points = frame.valid[index] ? frame.values[index].map(point => point.slice()) : []
|
|
374
|
+
if (binding.model) {
|
|
375
|
+
if (binding.repeat) setRepeatModel(binding, points)
|
|
376
|
+
else if (binding.model in component && !component.$options?.computed?.[binding.model]) component[binding.model] = points
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
function invalidateXYSeries () {
|
|
381
|
+
lastXYSeriesFrame = null
|
|
382
|
+
for (const { node, binding } of xySeriesTargets.values()) {
|
|
383
|
+
node.setAttribute('data-cpv-xy-series-valid', 'false')
|
|
384
|
+
node.setAttribute('aria-invalid', 'true')
|
|
385
|
+
if (binding.model) {
|
|
386
|
+
if (binding.repeat) setRepeatModel(binding, [])
|
|
387
|
+
else if (binding.model in component && !component.$options?.computed?.[binding.model]) component[binding.model] = []
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
316
391
|
function attachModelControls () {
|
|
317
392
|
for (const [id, binding] of saved) {
|
|
318
393
|
if (!binding.component || !binding.model || binding.mode === 'read') continue
|
|
@@ -555,6 +630,17 @@ function initializeVisualBinding () {
|
|
|
555
630
|
const modeOptions = ['meter', 'text-display'].includes(candidate.adapter)
|
|
556
631
|
? ['read'] : ['both', 'write', 'read']
|
|
557
632
|
const mode = field(form, 'Mode', choose('Mode', modeOptions.map(value => ({ value, text: value })), existing?.mode || modeOptions[0]))
|
|
633
|
+
const behavior = candidate.button ? field(form, 'Button behavior', choose('Button behavior', [
|
|
634
|
+
{ value: 'toggle', text: 'Click to toggle / 点击切换' },
|
|
635
|
+
{ value: 'momentary', text: 'Hold to activate / 按住生效' }
|
|
636
|
+
], existing?.buttonBehavior || 'toggle')) : null
|
|
637
|
+
function updateButtonMode () {
|
|
638
|
+
if (!behavior) return
|
|
639
|
+
mode.disabled = behavior.value === 'momentary'
|
|
640
|
+
if (mode.disabled) mode.value = 'both'
|
|
641
|
+
mode.closest('label').dataset.disabled = String(mode.disabled)
|
|
642
|
+
}
|
|
643
|
+
updateButtonMode()
|
|
558
644
|
const device = field(form, 'Device', choose('Device', devices.filter(item => item.online)
|
|
559
645
|
.map(item => ({ value: item.id, text: item.id })), existing?.device))
|
|
560
646
|
const write = field(form, 'Write point', choose('Write point', [], existing?.writePath))
|
|
@@ -588,8 +674,10 @@ function initializeVisualBinding () {
|
|
|
588
674
|
}
|
|
589
675
|
write.value = oldWrite || ''
|
|
590
676
|
read.value = oldRead || ''
|
|
591
|
-
write.
|
|
592
|
-
read.
|
|
677
|
+
write.disabled = mode.value === 'read'
|
|
678
|
+
read.disabled = mode.value === 'write'
|
|
679
|
+
write.closest('label').dataset.disabled = String(write.disabled)
|
|
680
|
+
read.closest('label').dataset.disabled = String(read.disabled)
|
|
593
681
|
const available = mode.value === 'read' ? readCount
|
|
594
682
|
: mode.value === 'write' ? writeCount : Math.min(writeCount, readCount)
|
|
595
683
|
message.textContent = targets.length && !available ? 'No compatible points for this control'
|
|
@@ -613,8 +701,12 @@ function initializeVisualBinding () {
|
|
|
613
701
|
targets = []; updateTargets(); message.textContent = error.message
|
|
614
702
|
}
|
|
615
703
|
})
|
|
616
|
-
write.addEventListener('change', () => {
|
|
704
|
+
write.addEventListener('change', () => {
|
|
705
|
+
if (mode.value === 'both') read.value = write.value
|
|
706
|
+
})
|
|
617
707
|
mode.addEventListener('change', updateTargets)
|
|
708
|
+
behavior?.addEventListener('change', () => { updateButtonMode(); updateTargets() })
|
|
709
|
+
updateTargets()
|
|
618
710
|
if (device.value) device.dispatchEvent(new Event('change'))
|
|
619
711
|
const actions = element('div', null, 'cpv-actions')
|
|
620
712
|
actions.append(button('Cancel', () => finishBindingEdit(returnToBindings)))
|
|
@@ -634,11 +726,12 @@ function initializeVisualBinding () {
|
|
|
634
726
|
message.textContent = 'Control ID must be unique and valid'; return
|
|
635
727
|
}
|
|
636
728
|
if (!name.value.trim() || !device.value || (mode.value !== 'read' && !write.value) ||
|
|
637
|
-
(mode.value !== 'write' && !read.value) || !numbering.value) {
|
|
729
|
+
(mode.value !== 'write' && !read.value) || !numbering.value || (behavior && !behavior.value)) {
|
|
638
730
|
message.textContent = 'Complete the binding fields'; return
|
|
639
731
|
}
|
|
640
732
|
const entry = {
|
|
641
733
|
id: id.value, locator: candidate.locator, adapter: candidate.adapter,
|
|
734
|
+
...(behavior ? { buttonBehavior: behavior.value } : {}),
|
|
642
735
|
...(candidate.model ? { model: candidate.model } : {}),
|
|
643
736
|
...(candidate.repeat ? { repeat: candidate.repeat, instanceKey: String(candidate.instanceKey) } : {}),
|
|
644
737
|
label: name.value.trim(), mode: mode.value, device: device.value,
|
|
@@ -719,8 +812,9 @@ function initializeVisualBinding () {
|
|
|
719
812
|
const baseCandidate = snapshot.candidates.find(item => item.locator === entry.locator && item.supported)
|
|
720
813
|
const candidate = baseCandidate?.repeat ? { ...baseCandidate, instanceKey: entry.instanceKey } : baseCandidate
|
|
721
814
|
const edit = button('Edit', () => bindingDialog(candidate, { returnToBindings: true }))
|
|
722
|
-
|
|
723
|
-
|
|
815
|
+
// Existing mappings belong to the template, not the currently rendered tab.
|
|
816
|
+
edit.disabled = !candidate
|
|
817
|
+
if (edit.disabled) edit.title = 'This control is no longer supported or present in the template'
|
|
724
818
|
actions.append(edit)
|
|
725
819
|
actions.append(button('Remove', () => {
|
|
726
820
|
draft = draft.filter(item => item.id !== entry.id)
|
|
@@ -762,6 +856,9 @@ function initializeVisualBinding () {
|
|
|
762
856
|
if (candidate.adapter === 'meter' && (typeof value !== 'number' || !Number.isFinite(value))) {
|
|
763
857
|
return { ...candidate, supported: false, reason: 'This Vuetify progress needs a numeric model field' }
|
|
764
858
|
}
|
|
859
|
+
if (candidate.adapter === 'xy-series' && !Array.isArray(value)) {
|
|
860
|
+
return { ...candidate, supported: false, reason: 'This realtime array control needs an array model field' }
|
|
861
|
+
}
|
|
765
862
|
if (candidate.adapter === 'select' && typeof value !== 'string' &&
|
|
766
863
|
!(typeof value === 'number' && Number.isFinite(value))) {
|
|
767
864
|
return { ...candidate, supported: false, reason: 'This Vuetify selection needs one scalar model field' }
|
|
@@ -822,11 +919,43 @@ function initializeVisualBinding () {
|
|
|
822
919
|
}))
|
|
823
920
|
} catch (error) { console.error('ControlPanel meter initialization failed', error) }
|
|
824
921
|
}
|
|
922
|
+
if (this.__boundPanelConfig().xySeries && xySeriesEntries.length) {
|
|
923
|
+
try {
|
|
924
|
+
const ids = xySeriesEntries.map(([id]) => id).sort((a, b) => a.localeCompare(b))
|
|
925
|
+
xySeriesClient = this.__createPanelXYSeriesClient({ socket: this.$socket,
|
|
926
|
+
panelId: config.panelId, room: this.__boundPanelConfig().room, ids,
|
|
927
|
+
request: (...args) => component.__panelFetch(...args),
|
|
928
|
+
createReceiver: this.__createXYSeriesReceiver, createRenderer: this.__createXYSeriesRenderer,
|
|
929
|
+
apply: applyXYSeriesFrame, invalidate: invalidateXYSeries,
|
|
930
|
+
onUnauthorized: () => {
|
|
931
|
+
if (editing) { editSession = null; loginView() }
|
|
932
|
+
else component.clearBoundPanelSession()
|
|
933
|
+
} })
|
|
934
|
+
cleanup.push(() => xySeriesClient.close())
|
|
935
|
+
if (!editing) cleanup.push(this.$watch('boundAuthenticated', authenticated => {
|
|
936
|
+
xySeriesClient.setAuth(authenticated ? { endpoint: component.__boundPanelConfig().endpoint,
|
|
937
|
+
csrf: component.__boundPanelSession.csrf, header: 'X-Room-CSRF' } : null)
|
|
938
|
+
}))
|
|
939
|
+
} catch (error) { console.error('ControlPanel xy-series initialization failed', error) }
|
|
940
|
+
}
|
|
825
941
|
observer = new MutationObserver(() => {
|
|
826
942
|
attachControls()
|
|
827
943
|
updateBindingMarkers()
|
|
828
944
|
})
|
|
829
|
-
observer.observe(root, { childList: true, subtree: true })
|
|
945
|
+
observer.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ['disabled'] })
|
|
946
|
+
cleanup.push(() => {
|
|
947
|
+
for (const button of buttonPresses.values()) button.controller.dispose()
|
|
948
|
+
buttonPresses.clear()
|
|
949
|
+
for (const button of momentaryButtons.values()) button.controller.dispose()
|
|
950
|
+
momentaryButtons.clear()
|
|
951
|
+
momentaryStates.clear()
|
|
952
|
+
})
|
|
953
|
+
cleanup.push(this.$watch(() => component.boundAuthenticated && component.boundFeedbackConnected, connected => {
|
|
954
|
+
if (!connected) {
|
|
955
|
+
for (const button of buttonPresses.values()) button.controller.release()
|
|
956
|
+
for (const button of momentaryButtons.values()) button.controller.release()
|
|
957
|
+
}
|
|
958
|
+
}))
|
|
830
959
|
const unwatch = this.$watch('boundState', showFeedback)
|
|
831
960
|
cleanup.push(() => unwatch(), () => observer.disconnect(), () => toolbar?.remove(),
|
|
832
961
|
() => closeOverlay(), () => closeLoginScreen())
|
|
@@ -834,6 +963,7 @@ function initializeVisualBinding () {
|
|
|
834
963
|
|
|
835
964
|
const style = element('style')
|
|
836
965
|
style.textContent = `
|
|
966
|
+
button[data-cpv-press="true"]{translate:0 2px !important;box-shadow:inset 0 2px 4px #0003 !important}
|
|
837
967
|
.cpv-toolbar,.cpv-dialog,.cpv-menu{font:14px/1.4 system-ui,sans-serif;color:#182224;background:#fff}
|
|
838
968
|
.cpv-toolbar{position:fixed;z-index:2147483000;top:12px;right:12px;display:flex;align-items:center;gap:8px;padding:8px;border:1px solid #9baaad;box-shadow:0 8px 24px #0003;max-width:calc(100vw - 24px)}
|
|
839
969
|
.cpv-toolbar button,.cpv-dialog button{padding:7px 11px;border:1px solid #809296;background:#fff;color:#182224;border-radius:4px;cursor:pointer;font:inherit}
|
|
@@ -884,6 +1014,7 @@ function initializeVisualBinding () {
|
|
|
884
1014
|
.cpv-dialog{width:min(460px,100%);max-height:min(90vh,720px);overflow:auto;padding:28px 18px 18px;border-radius:6px;box-shadow:0 12px 36px #0005}
|
|
885
1015
|
.cpv-dialog h2{font-size:18px;margin:0 0 20px}.cpv-field{display:block;margin:10px 0}.cpv-field span{display:block;font-size:12px;margin-bottom:4px}
|
|
886
1016
|
.cpv-field input,.cpv-field select{box-sizing:border-box;width:100%;padding:8px;border:1px solid #86999c;border-radius:4px;background:white;color:#182224;font:inherit}
|
|
1017
|
+
.cpv-field[data-disabled=true] span{color:#66787c}.cpv-field select:disabled{background:#edf1f2;color:#66787c;border-color:#c5cfd1;cursor:not-allowed;opacity:1}
|
|
887
1018
|
.cpv-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:16px}.cpv-dialog>button:last-child{margin-top:20px}.cpv-message{min-height:18px;font-size:12px}
|
|
888
1019
|
.cpv-menu{position:fixed;z-index:2147483002;width:170px;padding:4px;border:1px solid #9baaad;box-shadow:0 8px 24px #0003}
|
|
889
1020
|
.cpv-menu button{display:block;width:100%;padding:8px;text-align:left;border:0;background:white;color:#182224;font:inherit;cursor:pointer}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Serialized into the Dashboard component; press appearance never changes device state.
|
|
2
|
+
function attachButtonPress ({ node, canPress }) {
|
|
3
|
+
let pointer = null
|
|
4
|
+
const keys = new Set(), listeners = []
|
|
5
|
+
function listen (target, name, callback) {
|
|
6
|
+
target.addEventListener(name, callback)
|
|
7
|
+
listeners.push(() => target.removeEventListener(name, callback))
|
|
8
|
+
}
|
|
9
|
+
function render () {
|
|
10
|
+
if (pointer !== null || keys.size) node.setAttribute('data-cpv-press', 'true')
|
|
11
|
+
else node.removeAttribute('data-cpv-press')
|
|
12
|
+
}
|
|
13
|
+
function release () { pointer = null; keys.clear(); render() }
|
|
14
|
+
function allowed () { return node.isConnected && !node.disabled && !document.hidden && canPress() }
|
|
15
|
+
listen(node, 'pointerdown', event => {
|
|
16
|
+
if (event.button !== 0 || pointer !== null || !allowed()) return
|
|
17
|
+
pointer = event.pointerId
|
|
18
|
+
render()
|
|
19
|
+
})
|
|
20
|
+
const pointerEnd = event => {
|
|
21
|
+
if (pointer !== event.pointerId) return
|
|
22
|
+
pointer = null
|
|
23
|
+
render()
|
|
24
|
+
}
|
|
25
|
+
listen(window, 'pointerup', pointerEnd)
|
|
26
|
+
listen(window, 'pointercancel', pointerEnd)
|
|
27
|
+
listen(node, 'lostpointercapture', pointerEnd)
|
|
28
|
+
listen(node, 'keydown', event => {
|
|
29
|
+
if (![' ', 'Enter'].includes(event.key) || !allowed()) return
|
|
30
|
+
keys.add(event.key)
|
|
31
|
+
render()
|
|
32
|
+
})
|
|
33
|
+
listen(window, 'keyup', event => { if (keys.delete(event.key)) render() })
|
|
34
|
+
listen(node, 'blur', release)
|
|
35
|
+
listen(window, 'blur', release)
|
|
36
|
+
listen(window, 'offline', release)
|
|
37
|
+
listen(window, 'pagehide', release)
|
|
38
|
+
listen(document, 'visibilitychange', () => { if (document.hidden) release() })
|
|
39
|
+
return {
|
|
40
|
+
release,
|
|
41
|
+
check () { if (!allowed()) release() },
|
|
42
|
+
dispose () { release(); for (const remove of listeners) remove() }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = { attachButtonPress }
|