node-red-contrib-room-tools 1.8.15 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTROL_PANEL_AI_GUIDE.md +224 -100
- package/README.md +3 -1
- package/control-bound-protocol.js +1 -1
- package/control-catalog.js +5 -2
- package/control-panel.js +33 -2
- package/control-policy.js +16 -7
- package/control-type-contract.json +1 -0
- package/control-type-model.js +6 -1
- package/gateway.cjs +4 -0
- package/managed-native-bridge.js +2 -1
- package/meter-frame.js +111 -0
- package/meter-route.js +12 -0
- package/native-binding-plan.js +56 -6
- package/npm-shrinkwrap.json +2 -2
- package/package.json +8 -2
- package/panel-binding-editor.js +209 -37
- package/panel-editor-auth.js +5 -2
- package/panel-http.js +3 -2
- package/panel-meter-client.js +84 -0
- package/panel-meter.js +213 -0
- package/panel-template-instrumentation.js +181 -31
- package/panel-visual-apply.js +1 -1
- package/panel-visual-bindings.js +43 -6
package/control-policy.js
CHANGED
|
@@ -3,6 +3,8 @@ const escodegen = require('escodegen')
|
|
|
3
3
|
const { initializeVisualBinding, disposeVisualBinding } = require('./panel-binding-editor')
|
|
4
4
|
const { fetchWithTimeout } = require('./panel-http')
|
|
5
5
|
const { createTargetMatcher } = require('./control-type-model')
|
|
6
|
+
const { createRoomMeterReceiver, createMeterRenderer } = require('./meter-frame')
|
|
7
|
+
const { createPanelMeterClient } = require('./panel-meter-client')
|
|
6
8
|
|
|
7
9
|
const CONTROL_POLICY_MARKER = '/* ui-control-panel-control-api-v1 */'
|
|
8
10
|
|
|
@@ -157,6 +159,11 @@ function createControlPanelPolicy ({
|
|
|
157
159
|
// generated script remains inside that supported subset.
|
|
158
160
|
function buildDashboardPolicyOptionsSource (controls = [], boundPanel = null) {
|
|
159
161
|
const boundControls = controls.map(({ id, type, mode }) => ({ id, type, mode }))
|
|
162
|
+
const meterMethods = boundPanel?.meter
|
|
163
|
+
? `__createRoomMeterReceiver: ${createRoomMeterReceiver.toString()},
|
|
164
|
+
__createMeterRenderer: ${createMeterRenderer.toString()},
|
|
165
|
+
__createPanelMeterClient: ${createPanelMeterClient.toString()},`
|
|
166
|
+
: ''
|
|
160
167
|
const visualMethods = boundPanel?.visual
|
|
161
168
|
? `__createTargetMatcher: ${createTargetMatcher.toString()},
|
|
162
169
|
__initializeVisualBinding: ${initializeVisualBinding.toString()},
|
|
@@ -180,6 +187,7 @@ export default {
|
|
|
180
187
|
}
|
|
181
188
|
},
|
|
182
189
|
methods: {
|
|
190
|
+
${meterMethods}
|
|
183
191
|
${visualMethods}
|
|
184
192
|
__panelFetch: ${fetchWithTimeout.toString()},
|
|
185
193
|
__boundPanelConfig () {
|
|
@@ -304,6 +312,7 @@ export default {
|
|
|
304
312
|
}
|
|
305
313
|
},
|
|
306
314
|
__disposeControlPanelPolicy () {
|
|
315
|
+
this.__boundAuthGeneration++
|
|
307
316
|
this.__boundUnwatch?.()
|
|
308
317
|
this.__boundUnwatch = null
|
|
309
318
|
this.__clearAllBoundSettlements()
|
|
@@ -381,28 +390,28 @@ export default {
|
|
|
381
390
|
if (!config) throw new Error('This panel has no bound login endpoint')
|
|
382
391
|
this.clearBoundPanelSession()
|
|
383
392
|
const generation = this.__boundAuthGeneration
|
|
384
|
-
const response = await this.__panelFetch(config.endpoint + '/login', {
|
|
393
|
+
const { response, session } = await this.__panelFetch(config.endpoint + '/login', {
|
|
385
394
|
method: 'POST', credentials: 'same-origin',
|
|
386
395
|
headers: { 'Content-Type': 'application/json' },
|
|
387
396
|
body: JSON.stringify({ username, password })
|
|
388
|
-
})
|
|
397
|
+
}, 12000, async response => ({ response, session: response.ok ? await response.json() : null }))
|
|
389
398
|
if (!response.ok) throw new Error('Panel login failed (' + response.status + ')')
|
|
390
|
-
const session = await response.json()
|
|
391
399
|
if (generation !== this.__boundAuthGeneration) return null
|
|
392
400
|
this.setBoundPanelSession(session)
|
|
393
401
|
return session
|
|
394
402
|
},
|
|
395
|
-
async restoreBoundPanelSession () {
|
|
403
|
+
async restoreBoundPanelSession (timeoutMs = 2500) {
|
|
396
404
|
const config = this.__boundPanelConfig()
|
|
397
405
|
if (!config) return null
|
|
398
406
|
const generation = this.__boundAuthGeneration
|
|
399
|
-
const response = await this.__panelFetch(config.endpoint + '/session', {
|
|
407
|
+
const { response, session } = await this.__panelFetch(config.endpoint + '/session', {
|
|
408
|
+
credentials: 'same-origin'
|
|
409
|
+
}, timeoutMs, async response => ({ response, session: response.ok ? await response.json() : null }))
|
|
400
410
|
if (generation !== this.__boundAuthGeneration) return this.__boundPanelSession
|
|
401
411
|
if (!response.ok) {
|
|
402
412
|
this.clearBoundPanelSession()
|
|
403
413
|
return null
|
|
404
414
|
}
|
|
405
|
-
const session = await response.json()
|
|
406
415
|
if (generation !== this.__boundAuthGeneration) return this.__boundPanelSession
|
|
407
416
|
this.setBoundPanelSession(session)
|
|
408
417
|
return session
|
|
@@ -456,7 +465,7 @@ export default {
|
|
|
456
465
|
},
|
|
457
466
|
mounted () {
|
|
458
467
|
this.__initializeControlPanelPolicy()
|
|
459
|
-
if (this.__boundPanelConfig()) this.restoreBoundPanelSession().catch(() => undefined)
|
|
468
|
+
${boundPanel?.visual ? '' : 'if (this.__boundPanelConfig()) this.restoreBoundPanelSession().catch(() => undefined)'}
|
|
460
469
|
${visualMounted}
|
|
461
470
|
},
|
|
462
471
|
unmounted () {
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
"slider": { "family": "number", "delivery": "continuous" },
|
|
5
5
|
"level": { "family": "number", "delivery": "continuous" },
|
|
6
6
|
"volume": { "family": "number", "delivery": "continuous" },
|
|
7
|
+
"meter": { "family": "number", "delivery": "realtime", "access": "ro" },
|
|
7
8
|
"dimmer": { "family": "number", "delivery": "continuous" },
|
|
8
9
|
"position": { "family": "number", "delivery": "continuous" },
|
|
9
10
|
"toggle": { "family": "boolean", "delivery": "discrete" },
|
package/control-type-model.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const contract = require('./control-type-contract.json')
|
|
2
|
+
const { meterRoute } = require('./meter-route')
|
|
2
3
|
|
|
3
4
|
|
|
4
5
|
|
|
@@ -22,6 +23,8 @@ function createTargetMatcher (typeContract) {
|
|
|
22
23
|
return definition.family
|
|
23
24
|
}
|
|
24
25
|
function compatible (adapter, target, direction, candidate = {}) {
|
|
26
|
+
if (adapter === 'meter' && !target.meter) return false
|
|
27
|
+
if (target.meter && (adapter !== 'meter' || direction !== 'read' || target.access !== 'ro')) return false
|
|
25
28
|
if (target.kind !== 'param' || !families[adapter]?.includes(family(target))) return false
|
|
26
29
|
if (direction === 'write' && ['meter', 'text-display'].includes(adapter)) return false
|
|
27
30
|
if (!['read', 'write'].includes(direction)) return false
|
|
@@ -38,6 +41,7 @@ function controlTypeForAdapter (adapter, target) {
|
|
|
38
41
|
if (!compatibleTarget(adapter, target, 'read') && !compatibleTarget(adapter, target, 'write')) {
|
|
39
42
|
throw new Error('Target is incompatible with this control')
|
|
40
43
|
}
|
|
44
|
+
if (adapter === 'meter' && target.meter) return 'meter'
|
|
41
45
|
const family = targetFamily(target)
|
|
42
46
|
if (family === 'number') return 'level'
|
|
43
47
|
if (family === 'boolean') return 'mute'
|
|
@@ -55,7 +59,8 @@ function targetMeta (target, onebased) {
|
|
|
55
59
|
onebased: typeof target.onebased === 'boolean' ? target.onebased : onebased,
|
|
56
60
|
min: target.min, max: target.max, enums: target.enums,
|
|
57
61
|
...(target.booleanEncoding ? { booleanEncoding: target.booleanEncoding } : {}),
|
|
58
|
-
controlType: target.controlType, controlSchemaVersion: target.controlSchemaVersion
|
|
62
|
+
controlType: target.controlType, controlSchemaVersion: target.controlSchemaVersion,
|
|
63
|
+
...(target.meter ? { meter: meterRoute(target.meter), unit: target.unit || '' } : {})
|
|
59
64
|
}
|
|
60
65
|
}
|
|
61
66
|
|
package/gateway.cjs
CHANGED
|
@@ -9,6 +9,7 @@ function createGateway ({
|
|
|
9
9
|
room = 'room-01',
|
|
10
10
|
onBoundCommand,
|
|
11
11
|
onBoundLogin,
|
|
12
|
+
onAuthenticatedRequest,
|
|
12
13
|
secureCookie = false,
|
|
13
14
|
prefix,
|
|
14
15
|
sessionTtlMs = 8 * 3600000,
|
|
@@ -193,6 +194,8 @@ function createGateway ({
|
|
|
193
194
|
reply(res, 403, { error: 'CSRF rejected' })
|
|
194
195
|
return true
|
|
195
196
|
}
|
|
197
|
+
if (onAuthenticatedRequest && await onAuthenticatedRequest(req, res,
|
|
198
|
+
{ token, expiry: session.expiry, room, user: username })) return true
|
|
196
199
|
if (path === routePrefix + '/session' && req.method === 'GET') {
|
|
197
200
|
reply(res, 200, { room, username, csrf: session.csrf })
|
|
198
201
|
return true
|
|
@@ -255,6 +258,7 @@ function createGateway ({
|
|
|
255
258
|
return {
|
|
256
259
|
handler,
|
|
257
260
|
boundFeedback,
|
|
261
|
+
sessionActive: token => !closed && (sessions.get(token)?.expiry || 0) > Date.now(),
|
|
258
262
|
sessionSnapshot () {
|
|
259
263
|
return new Map([...sessions].filter(([, session]) => session.expiry > Date.now())
|
|
260
264
|
.map(([token, session]) => [token, { ...session }]))
|
package/managed-native-bridge.js
CHANGED
|
@@ -24,7 +24,8 @@ function panelTargets (panel) {
|
|
|
24
24
|
const target = {
|
|
25
25
|
device: binding.device, path, kind: meta.kind, type: meta.type,
|
|
26
26
|
access: meta.access, onebased: meta.onebased,
|
|
27
|
-
...(meta.booleanEncoding ? { booleanEncoding: meta.booleanEncoding } : {})
|
|
27
|
+
...(meta.booleanEncoding ? { booleanEncoding: meta.booleanEncoding } : {}),
|
|
28
|
+
...(meta.meter ? { meter: meta.meter } : {})
|
|
28
29
|
}
|
|
29
30
|
const key = JSON.stringify([target.device, target.path])
|
|
30
31
|
const previous = targets.get(key)
|
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.
|
|
3
|
+
"version": "1.9.0",
|
|
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
|
+
"version": "1.9.0",
|
|
10
10
|
"license": "UNLICENSED",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@vue/compiler-sfc": "3.5.42",
|
package/package.json
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-red-contrib-room-tools",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"description": "Authenticated visual control panel binding for Node-RED and MUSE Automator",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
|
-
"engines": {
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=18.16.0"
|
|
8
|
+
},
|
|
7
9
|
"keywords": [
|
|
8
10
|
"node-red"
|
|
9
11
|
],
|
|
@@ -21,6 +23,10 @@
|
|
|
21
23
|
"control-catalog.js",
|
|
22
24
|
"control-type-contract.json",
|
|
23
25
|
"control-type-model.js",
|
|
26
|
+
"meter-route.js",
|
|
27
|
+
"meter-frame.js",
|
|
28
|
+
"panel-meter.js",
|
|
29
|
+
"panel-meter-client.js",
|
|
24
30
|
"panel-template-instrumentation.js",
|
|
25
31
|
"panel-visual-bindings.js",
|
|
26
32
|
"panel-visual-apply.js",
|