node-red-contrib-room-tools 1.9.4 → 1.9.5
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 +54 -16
- package/README.md +19 -3
- package/control-bound-protocol.js +23 -6
- package/control-catalog.js +26 -3
- package/control-panel.js +29 -3
- package/control-policy.js +34 -5
- package/control-type-model.js +68 -8
- package/device-availability.js +104 -0
- package/gateway.cjs +13 -1
- package/managed-native-bridge.js +3 -1
- package/meter-frame.js +7 -1
- package/native-binding-plan.js +47 -10
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/panel-binding-editor.js +208 -30
- package/panel-momentary.js +7 -1
- package/panel-template-instrumentation.js +1 -0
- package/panel-visual-apply.js +86 -27
- package/panel-visual-bindings.js +18 -4
- package/xy-series-frame.js +7 -1
package/control-type-model.js
CHANGED
|
@@ -11,6 +11,48 @@ function createTargetMatcher (typeContract) {
|
|
|
11
11
|
return ['meter', 'xy-series', 'text-display'].includes(adapter)
|
|
12
12
|
? ['read'] : ['both', 'write', 'read']
|
|
13
13
|
}
|
|
14
|
+
function states (target) {
|
|
15
|
+
if (target.meter || target.xySeries || !['boolean', 'integer', 'float', 'enum', 'string', 'dstring'].includes(target.type)) return null
|
|
16
|
+
if (Array.isArray(target.enums) && target.enums.length) {
|
|
17
|
+
if (target.enums.length !== 2) return null
|
|
18
|
+
const values = target.enums.map(entry => entry && typeof entry === 'object' ? entry.value : entry)
|
|
19
|
+
if (values.some(value => !['boolean', 'string', 'number'].includes(typeof value) ||
|
|
20
|
+
(typeof value === 'number' && !Number.isFinite(value))) || values[0] === values[1]) return null
|
|
21
|
+
if (target.type === 'integer' && values.some(value => !Number.isSafeInteger(value))) return null
|
|
22
|
+
if (target.type === 'float' && values.some(value => typeof value !== 'number')) return null
|
|
23
|
+
if (target.type === 'boolean' && values.some(value => typeof value !== 'boolean')) return null
|
|
24
|
+
if (['string', 'dstring'].includes(target.type) && values.some(value => typeof value !== 'string')) return null
|
|
25
|
+
return values
|
|
26
|
+
}
|
|
27
|
+
if (target.type === 'boolean') return [false, true]
|
|
28
|
+
if (target.type === 'integer' && Number.isSafeInteger(target.min) &&
|
|
29
|
+
Number.isSafeInteger(target.max) && target.max - target.min === 1) return [target.min, target.max]
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
// Native readonly numeric parameters need no custom frame/manifest route.
|
|
33
|
+
function meterTarget (target) {
|
|
34
|
+
return target.access === 'ro' && !target.xySeries &&
|
|
35
|
+
['integer', 'float'].includes(target.type) &&
|
|
36
|
+
Boolean(target.meter || (!target.enums?.length && !states(target)))
|
|
37
|
+
}
|
|
38
|
+
function reverse (binding = {}, write = binding.writeMeta, read = binding.readMeta) {
|
|
39
|
+
if (Object.hasOwn(binding, 'stateReverse')) {
|
|
40
|
+
if (typeof binding.stateReverse !== 'boolean') throw new Error('State reverse must be boolean')
|
|
41
|
+
return binding.stateReverse
|
|
42
|
+
}
|
|
43
|
+
const point = write || read
|
|
44
|
+
const mapping = write
|
|
45
|
+
? (Object.hasOwn(binding, 'writeMapping') ? binding.writeMapping : binding.writeMeta?.booleanMapping)
|
|
46
|
+
: (Object.hasOwn(binding, 'readMapping') ? binding.readMapping : binding.readMeta?.booleanMapping)
|
|
47
|
+
if (mapping === undefined) return false
|
|
48
|
+
const values = point && states(point)
|
|
49
|
+
if (!values || !mapping || typeof mapping !== 'object' || Array.isArray(mapping) ||
|
|
50
|
+
!Object.hasOwn(mapping, 'falseValue') || !Object.hasOwn(mapping, 'trueValue') ||
|
|
51
|
+
Object.is(mapping.falseValue, mapping.trueValue) ||
|
|
52
|
+
!values.some(value => Object.is(value, mapping.falseValue)) ||
|
|
53
|
+
!values.some(value => Object.is(value, mapping.trueValue))) throw new Error('Invalid two-state mapping')
|
|
54
|
+
return Object.is(mapping.trueValue, values[0])
|
|
55
|
+
}
|
|
14
56
|
function family (target) {
|
|
15
57
|
if (target.xySeries) return 'xy-series'
|
|
16
58
|
if (target.controlSchemaVersion !== typeContract.schemaVersion) return null
|
|
@@ -26,28 +68,43 @@ function createTargetMatcher (typeContract) {
|
|
|
26
68
|
return definition.family
|
|
27
69
|
}
|
|
28
70
|
function compatible (adapter, target, direction, candidate = {}) {
|
|
29
|
-
if (adapter === 'meter' && !target
|
|
71
|
+
if (adapter === 'meter' && !meterTarget(target)) return false
|
|
72
|
+
if (adapter === 'range' && meterTarget(target)) return false
|
|
30
73
|
if (adapter === 'xy-series' && !target.xySeries) return false
|
|
31
74
|
if (target.meter && (adapter !== 'meter' || direction !== 'read' || target.access !== 'ro')) return false
|
|
32
75
|
if (target.xySeries && (adapter !== 'xy-series' || direction !== 'read' || target.access !== 'ro')) return false
|
|
33
|
-
if (target.kind !== 'param' || !
|
|
76
|
+
if (target.kind !== 'param' || !(adapter === 'switch'
|
|
77
|
+
? states(target) : families[adapter]?.includes(family(target)))) return false
|
|
34
78
|
if (direction === 'write' && ['meter', 'xy-series', 'text-display'].includes(adapter)) return false
|
|
35
79
|
if (!['read', 'write'].includes(direction)) return false
|
|
36
80
|
if (!(direction === 'write' ? ['wo', 'rw'] : ['ro', 'rw']).includes(target.access)) return false
|
|
37
81
|
if (candidate.unit && candidate.unit !== target.unit) return false
|
|
38
82
|
return true
|
|
39
83
|
}
|
|
40
|
-
return { family, compatible, modes }
|
|
84
|
+
return { family, compatible, modes, states, reverse, meterTarget }
|
|
41
85
|
}
|
|
42
86
|
|
|
43
|
-
const { family: targetFamily, compatible: compatibleTarget, modes: bindingModes } = createTargetMatcher(contract)
|
|
87
|
+
const { family: targetFamily, compatible: compatibleTarget, modes: bindingModes, states: targetStates, reverse: stateReverse, meterTarget: isMeterTarget } = createTargetMatcher(contract)
|
|
88
|
+
|
|
89
|
+
function switchMapping (target, mapping, reverse = false) {
|
|
90
|
+
const states = targetStates(target)
|
|
91
|
+
if (!states) throw new Error('Switch target must describe exactly two distinct states')
|
|
92
|
+
const result = mapping === undefined ? { falseValue: states[reverse ? 1 : 0], trueValue: states[reverse ? 0 : 1] } : mapping
|
|
93
|
+
if (!result || typeof result !== 'object' || Array.isArray(result) ||
|
|
94
|
+
!Object.hasOwn(result, 'falseValue') || !Object.hasOwn(result, 'trueValue') ||
|
|
95
|
+
Object.is(result.falseValue, result.trueValue) ||
|
|
96
|
+
!states.some(value => Object.is(value, result.falseValue)) ||
|
|
97
|
+
!states.some(value => Object.is(value, result.trueValue))) throw new Error('Invalid two-state mapping')
|
|
98
|
+
return { falseValue: result.falseValue, trueValue: result.trueValue }
|
|
99
|
+
}
|
|
44
100
|
|
|
45
101
|
function controlTypeForAdapter (adapter, target) {
|
|
46
102
|
if (!compatibleTarget(adapter, target, 'read') && !compatibleTarget(adapter, target, 'write')) {
|
|
47
103
|
throw new Error('Target is incompatible with this control')
|
|
48
104
|
}
|
|
49
|
-
if (adapter === 'meter'
|
|
105
|
+
if (adapter === 'meter') return 'meter'
|
|
50
106
|
if (adapter === 'xy-series' && target.xySeries) return 'xy-series'
|
|
107
|
+
if (adapter === 'switch') return 'mute'
|
|
51
108
|
const family = targetFamily(target)
|
|
52
109
|
if (family === 'number') return 'level'
|
|
53
110
|
if (family === 'boolean') return 'mute'
|
|
@@ -56,7 +113,7 @@ function controlTypeForAdapter (adapter, target) {
|
|
|
56
113
|
throw new Error('Unsupported control family')
|
|
57
114
|
}
|
|
58
115
|
|
|
59
|
-
function targetMeta (target, onebased) {
|
|
116
|
+
function targetMeta (target, onebased, booleanMapping) {
|
|
60
117
|
if (typeof target.onebased !== 'boolean' && typeof onebased !== 'boolean') {
|
|
61
118
|
throw new Error('Select target numbering mode')
|
|
62
119
|
}
|
|
@@ -65,10 +122,13 @@ function targetMeta (target, onebased) {
|
|
|
65
122
|
onebased: typeof target.onebased === 'boolean' ? target.onebased : onebased,
|
|
66
123
|
min: target.min, max: target.max, enums: target.enums,
|
|
67
124
|
...(target.booleanEncoding ? { booleanEncoding: target.booleanEncoding } : {}),
|
|
125
|
+
...(booleanMapping ? { booleanMapping: switchMapping(target, booleanMapping) } : {}),
|
|
68
126
|
controlType: target.controlType, controlSchemaVersion: target.controlSchemaVersion,
|
|
69
127
|
...(target.meter ? { meter: meterRoute(target.meter), unit: target.unit || '' } : {}),
|
|
70
|
-
...(target.xySeries ? { xySeries: require('./xy-series-route').xySeriesRoute(target.xySeries) } : {})
|
|
128
|
+
...(target.xySeries ? { xySeries: require('./xy-series-route').xySeriesRoute(target.xySeries) } : {}),
|
|
129
|
+
...(target.availability ? { availability: require('./device-availability').availabilityRoute(target.availability,
|
|
130
|
+
typeof target.onebased === 'boolean' ? target.onebased : onebased) } : {})
|
|
71
131
|
}
|
|
72
132
|
}
|
|
73
133
|
|
|
74
|
-
module.exports = { contract, createTargetMatcher, compatibleTarget, controlTypeForAdapter, targetFamily, targetMeta, bindingModes }
|
|
134
|
+
module.exports = { contract, createTargetMatcher, compatibleTarget, controlTypeForAdapter, targetFamily, targetMeta, bindingModes, targetStates, switchMapping, stateReverse, isMeterTarget }
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
function availabilityRoute (value, onebased) {
|
|
2
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw Error('Invalid device availability route')
|
|
3
|
+
const result = {}
|
|
4
|
+
for (const role of ['online', 'ready']) {
|
|
5
|
+
const point = value[role]
|
|
6
|
+
if (!point && role === 'ready') continue
|
|
7
|
+
if (!point || typeof point.path !== 'string' || !/^\/\/[^\s]{1,1024}$/.test(point.path) ||
|
|
8
|
+
(typeof point.onebased !== 'boolean' && typeof onebased !== 'boolean')) throw Error('Invalid ' + role + ' availability point')
|
|
9
|
+
result[role] = { path: point.path, onebased: point.onebased ?? onebased }
|
|
10
|
+
}
|
|
11
|
+
return result
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function createDeviceAvailability (routes) {
|
|
15
|
+
const groups = new Map()
|
|
16
|
+
const controls = new Map()
|
|
17
|
+
const needsFeedback = new Set()
|
|
18
|
+
const fresh = new Set()
|
|
19
|
+
for (const [id, { binding, control }] of routes) {
|
|
20
|
+
const keys = new Set()
|
|
21
|
+
for (const meta of [binding.readMeta, binding.writeMeta]) {
|
|
22
|
+
if (!meta?.availability) continue
|
|
23
|
+
const route = availabilityRoute(meta.availability)
|
|
24
|
+
const key = JSON.stringify([binding.device, route])
|
|
25
|
+
if (!groups.has(key)) groups.set(key, { device: binding.device, route, online: false, ready: false })
|
|
26
|
+
keys.add(key)
|
|
27
|
+
}
|
|
28
|
+
if (keys.size) {
|
|
29
|
+
controls.set(id, [...keys])
|
|
30
|
+
if (control.mode !== 'write' && !binding.readMeta?.meter && !binding.readMeta?.xySeries) needsFeedback.add(id)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const pending = new Map()
|
|
34
|
+
const healthy = id => !controls.has(id) || controls.get(id).every(key => {
|
|
35
|
+
const group = groups.get(key)
|
|
36
|
+
return group.online === true && (!group.route.ready || group.ready === true)
|
|
37
|
+
})
|
|
38
|
+
const allowed = id => healthy(id) && (!needsFeedback.has(id) || fresh.has(id))
|
|
39
|
+
const snapshot = () => [...controls.keys()].map(id => ({ id, available: allowed(id) }))
|
|
40
|
+
let previous = JSON.stringify(snapshot())
|
|
41
|
+
let dirty = false
|
|
42
|
+
return {
|
|
43
|
+
allowed,
|
|
44
|
+
snapshot,
|
|
45
|
+
changes () {
|
|
46
|
+
if (!dirty) return null
|
|
47
|
+
dirty = false
|
|
48
|
+
const items = snapshot()
|
|
49
|
+
const next = JSON.stringify(items)
|
|
50
|
+
if (next === previous) return null
|
|
51
|
+
previous = next
|
|
52
|
+
return items
|
|
53
|
+
},
|
|
54
|
+
update (message) {
|
|
55
|
+
if (message?.kind !== 'device-availability') return null
|
|
56
|
+
const value = message.payload === true || message.payload === 'true' ? true
|
|
57
|
+
: message.payload === false || message.payload === 'false' ? false : null
|
|
58
|
+
if (value === null) return null
|
|
59
|
+
let changed = false
|
|
60
|
+
for (const group of groups.values()) {
|
|
61
|
+
if (group.device !== message.device) continue
|
|
62
|
+
for (const role of ['online', 'ready']) {
|
|
63
|
+
if (group.route[role]?.path !== message.path) continue
|
|
64
|
+
if (group[role] !== value) changed = true
|
|
65
|
+
group[role] = value
|
|
66
|
+
if (role === 'online' && !value) group.ready = false
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (!changed) return null
|
|
70
|
+
dirty = true
|
|
71
|
+
for (const id of controls.keys()) if (!healthy(id)) {
|
|
72
|
+
fresh.delete(id)
|
|
73
|
+
if (!controls.get(id).every(key => groups.get(key).online)) pending.delete(id)
|
|
74
|
+
}
|
|
75
|
+
return snapshot()
|
|
76
|
+
},
|
|
77
|
+
feedback (items) {
|
|
78
|
+
const result = []
|
|
79
|
+
for (const item of items) {
|
|
80
|
+
if (healthy(item.id)) {
|
|
81
|
+
if (controls.has(item.id) && !fresh.has(item.id)) { fresh.add(item.id); dirty = true }
|
|
82
|
+
result.push(item)
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
// Fresh initialization replies may arrive before stateReady=true.
|
|
86
|
+
// Never retain values received while the transport is offline.
|
|
87
|
+
if (controls.get(item.id)?.every(key => groups.get(key).online)) pending.set(item.id, item)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return result
|
|
91
|
+
},
|
|
92
|
+
takePending () {
|
|
93
|
+
const result = []
|
|
94
|
+
for (const [id, item] of pending) if (healthy(id)) {
|
|
95
|
+
if (!fresh.has(id)) { fresh.add(id); dirty = true }
|
|
96
|
+
result.push(item)
|
|
97
|
+
pending.delete(id)
|
|
98
|
+
}
|
|
99
|
+
return result
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = { availabilityRoute, createDeviceAvailability }
|
package/gateway.cjs
CHANGED
|
@@ -114,11 +114,22 @@ function createGateway ({
|
|
|
114
114
|
return message
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
let availability = []
|
|
118
|
+
function boundAvailability (items) {
|
|
119
|
+
if (!Array.isArray(items) || items.some(item => typeof item?.id !== 'string' || typeof item.available !== 'boolean')) {
|
|
120
|
+
throw Error('Invalid bound availability')
|
|
121
|
+
}
|
|
122
|
+
availability = items.map(item => ({ id: item.id, available: item.available }))
|
|
123
|
+
boundRevision++
|
|
124
|
+
broadcastFeedback({ room, kind: 'bound-availability', availability, revision: boundRevision })
|
|
125
|
+
}
|
|
126
|
+
|
|
117
127
|
function boundSnapshot () {
|
|
118
128
|
return {
|
|
119
129
|
room, kind: 'bound-snapshot',
|
|
120
130
|
items: [...boundState].map(([id, value]) => ({ id, value })),
|
|
121
|
-
revision: boundRevision
|
|
131
|
+
revision: boundRevision,
|
|
132
|
+
...(availability.length ? { availability } : {})
|
|
122
133
|
}
|
|
123
134
|
}
|
|
124
135
|
|
|
@@ -276,6 +287,7 @@ function createGateway ({
|
|
|
276
287
|
return {
|
|
277
288
|
handler,
|
|
278
289
|
boundFeedback,
|
|
290
|
+
boundAvailability,
|
|
279
291
|
sessionActive: token => !closed && (sessions.get(token)?.expiry || 0) > Date.now(),
|
|
280
292
|
sessionSnapshot () {
|
|
281
293
|
return new Map([...sessions].filter(([, session]) => session.expiry > Date.now())
|
package/managed-native-bridge.js
CHANGED
|
@@ -24,9 +24,11 @@ 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
|
+
min: meta.min, max: meta.max, enums: meta.enums,
|
|
27
28
|
...(meta.booleanEncoding ? { booleanEncoding: meta.booleanEncoding } : {}),
|
|
28
29
|
...(meta.meter ? { meter: meta.meter } : {}),
|
|
29
|
-
...(meta.xySeries ? { xySeries: meta.xySeries } : {})
|
|
30
|
+
...(meta.xySeries ? { xySeries: meta.xySeries } : {}),
|
|
31
|
+
...(meta.availability ? { availability: meta.availability } : {})
|
|
30
32
|
}
|
|
31
33
|
const key = JSON.stringify([target.device, target.path])
|
|
32
34
|
const previous = targets.get(key)
|
package/meter-frame.js
CHANGED
|
@@ -99,7 +99,13 @@ function createRoomMeterReceiver (layout) {
|
|
|
99
99
|
frame.valid.some((flag, index) => typeof flag !== 'boolean' || (flag
|
|
100
100
|
? typeof frame.values[index] !== 'number' || !Number.isFinite(frame.values[index])
|
|
101
101
|
: frame.values[index] !== null))) return false
|
|
102
|
-
|
|
102
|
+
if (frame.sources !== undefined && (!Array.isArray(frame.sources) || frame.sources.length > 256 ||
|
|
103
|
+
frame.sources.some(source => typeof source?.device !== 'string' || source.device.length > 128 ||
|
|
104
|
+
typeof source.streamId !== 'string' || source.streamId.length > 128 ||
|
|
105
|
+
typeof source.epoch !== 'string' || source.epoch.length > 128 ||
|
|
106
|
+
!Number.isSafeInteger(source.seq) || source.seq < 0))) return false
|
|
107
|
+
latest = { values: frame.values.slice(), valid: frame.valid.slice(), seq: frame.seq,
|
|
108
|
+
...(frame.sources ? { sources: frame.sources.map(({ device, streamId, epoch, seq }) => ({ device, streamId, epoch, seq })) } : {}) }
|
|
103
109
|
pending = true
|
|
104
110
|
return true
|
|
105
111
|
},
|
package/native-binding-plan.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
const { configuredRoutes } = require('./control-bound-protocol')
|
|
2
2
|
const { meterRoute } = require('./meter-route')
|
|
3
3
|
const { xySeriesRoute } = require('./xy-series-route')
|
|
4
|
+
const { targetStates, isMeterTarget } = require('./control-type-model')
|
|
5
|
+
const { availabilityRoute } = require('./device-availability')
|
|
4
6
|
|
|
5
7
|
const supportedTypes = {
|
|
6
8
|
boolean: { eventType: 'Boolean', eventOptions: { boolParamFilter: 'Any' } },
|
|
@@ -32,21 +34,23 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
|
|
|
32
34
|
const writeTargets = new Map()
|
|
33
35
|
const meterStreams = new Map()
|
|
34
36
|
const xySeriesStreams = new Map()
|
|
37
|
+
const healthTargets = new Map()
|
|
35
38
|
const commandRules = []
|
|
36
39
|
const commandWires = []
|
|
37
40
|
|
|
38
|
-
function metadata (device, path, type, access) {
|
|
41
|
+
function metadata (device, path, type, access, adapter) {
|
|
39
42
|
const target = targetIndex.get(JSON.stringify([device, path]))
|
|
40
43
|
if (!target) throw new Error(`Missing target descriptor: ${device} ${path}`)
|
|
41
44
|
if (target.kind !== 'param') throw new Error(`Only parameter targets are supported: ${path}`)
|
|
42
45
|
if (!target.xySeries && !Object.hasOwn(supportedTypes, target.type)) {
|
|
43
46
|
throw new Error(`Unsupported native parameter type: ${path} (${target.type})`)
|
|
44
47
|
}
|
|
45
|
-
if (['mute', 'power'].includes(type) && target
|
|
48
|
+
if (['mute', 'power'].includes(type) && !targetStates(target) &&
|
|
46
49
|
!(target.type === 'integer' && target.booleanEncoding === 'integer01')) {
|
|
47
50
|
throw new Error(`Boolean control bound to ${target.type}: ${path}`)
|
|
48
51
|
}
|
|
49
|
-
if (['level', 'dimmer', 'position'].includes(type) &&
|
|
52
|
+
if (['level', 'dimmer', 'position'].includes(type) &&
|
|
53
|
+
(!['integer', 'float'].includes(target.type) || (adapter === 'range' && isMeterTarget(target)))) {
|
|
50
54
|
throw new Error(`Numeric control bound to ${target.type}: ${path}`)
|
|
51
55
|
}
|
|
52
56
|
if (type === 'text' && !['string', 'dstring'].includes(target.type)) {
|
|
@@ -55,7 +59,7 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
|
|
|
55
59
|
if (['source', 'preset'].includes(type) && !['enum', 'integer', 'string'].includes(target.type)) {
|
|
56
60
|
throw new Error(`Choice control bound to ${target.type}: ${path}`)
|
|
57
61
|
}
|
|
58
|
-
if (type === 'meter' &&
|
|
62
|
+
if (type === 'meter' && !isMeterTarget(target)) {
|
|
59
63
|
throw new Error(`Meter control must bind to a readonly Meter target: ${path}`)
|
|
60
64
|
}
|
|
61
65
|
if (type === 'xy-series' && (!target.xySeries || target.access !== 'ro')) {
|
|
@@ -70,6 +74,34 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
|
|
|
70
74
|
if (typeof target.onebased !== 'boolean') {
|
|
71
75
|
throw new Error(`Target numbering mode is required: ${path}`)
|
|
72
76
|
}
|
|
77
|
+
if (target.availability) {
|
|
78
|
+
for (const point of Object.values(availabilityRoute(target.availability, target.onebased))) {
|
|
79
|
+
const key = JSON.stringify([device, point.path])
|
|
80
|
+
if (healthTargets.has(key)) {
|
|
81
|
+
if (healthTargets.get(key).onebased !== point.onebased) throw Error('Conflicting availability numbering')
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
const index = healthTargets.size + 1
|
|
85
|
+
const statusId = `${scopeId}-health-read-${index}`
|
|
86
|
+
const eventId = `${scopeId}-health-watch-${index}`
|
|
87
|
+
const tagId = `${scopeId}-health-tag-${index}`
|
|
88
|
+
healthTargets.set(key, { statusId, tagId, onebased: point.onebased })
|
|
89
|
+
nodes.push({ id: statusId, type: 'Status', z: subflowId,
|
|
90
|
+
name: `Read availability ${device} ${point.path}`, x: 440, y: 340 + index * 170,
|
|
91
|
+
mojo: { version: '1.0', device: [device], parameter: point.path, onebased: point.onebased }, wires: [[tagId]] })
|
|
92
|
+
nodes.push({ id: eventId, type: 'Event', z: subflowId,
|
|
93
|
+
name: `Watch availability ${device} ${point.path}`, x: 440, y: 400 + index * 170,
|
|
94
|
+
mojo: { version: '1.0', device: [device], event: point.path, eventKind: 'Parameter',
|
|
95
|
+
paramType: 'Boolean', boolParamFilter: 'Any', onebased: point.onebased }, wires: [[tagId]] })
|
|
96
|
+
nodes.push({ id: tagId, type: 'change', z: subflowId,
|
|
97
|
+
name: `Identify availability ${device} ${point.path}`, x: 900, y: 370 + index * 170,
|
|
98
|
+
rules: [
|
|
99
|
+
{ t: 'set', p: 'kind', pt: 'msg', to: 'device-availability', tot: 'str' },
|
|
100
|
+
{ t: 'set', p: 'device', pt: 'msg', to: device, tot: 'str' },
|
|
101
|
+
{ t: 'set', p: 'path', pt: 'msg', to: point.path, tot: 'str' }
|
|
102
|
+
], wires: [[]] })
|
|
103
|
+
}
|
|
104
|
+
}
|
|
73
105
|
return target
|
|
74
106
|
}
|
|
75
107
|
|
|
@@ -77,7 +109,7 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
|
|
|
77
109
|
if (!binding?.device) throw new Error(`Missing device binding: ${id}`)
|
|
78
110
|
if (control.mode !== 'read') {
|
|
79
111
|
if (!binding.writePath) throw new Error(`Missing write path: ${id}`)
|
|
80
|
-
const target = metadata(binding.device, binding.writePath, control.type, 'w')
|
|
112
|
+
const target = metadata(binding.device, binding.writePath, control.type, 'w', control.adapter)
|
|
81
113
|
const key = JSON.stringify([target.device, target.path])
|
|
82
114
|
if (!writeTargets.has(key)) {
|
|
83
115
|
const nodeId = `${scopeId}-write-${writeTargets.size + 1}`
|
|
@@ -97,7 +129,7 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
|
|
|
97
129
|
|
|
98
130
|
if (control.mode !== 'write') {
|
|
99
131
|
if (!binding.readPath) throw new Error(`Missing read path: ${id}`)
|
|
100
|
-
const target = metadata(binding.device, binding.readPath, control.type, 'r')
|
|
132
|
+
const target = metadata(binding.device, binding.readPath, control.type, 'r', control.adapter)
|
|
101
133
|
if (target.meter) {
|
|
102
134
|
const meter = meterRoute(target.meter)
|
|
103
135
|
if (control.mode !== 'read' || control.adapter !== 'meter') throw Error(`Meter is read-only: ${id}`)
|
|
@@ -224,6 +256,9 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
|
|
|
224
256
|
const commandSwitchId = `${scopeId}-commands`
|
|
225
257
|
// Ordinary feedback, Meter, and XY-series each get a separate vertical band.
|
|
226
258
|
for (const node of nodes) {
|
|
259
|
+
if (node.id.startsWith(`${scopeId}-health-`)) {
|
|
260
|
+
node.y += readTargets.size * 170 + (meterStreams.size + xySeriesStreams.size) * 280
|
|
261
|
+
}
|
|
227
262
|
if (node.id.startsWith(`${scopeId}-meter-`) || node.id.startsWith(`${scopeId}-xy-series-`)) {
|
|
228
263
|
node.y += readTargets.size * 170
|
|
229
264
|
if (node.id.startsWith(`${scopeId}-xy-series-`)) node.y += meterStreams.size * 280
|
|
@@ -249,19 +284,20 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
|
|
|
249
284
|
],
|
|
250
285
|
checkall: 'false', repair: false, outputs: 2,
|
|
251
286
|
wires: [commandRules.length ? [commandSwitchId] : [],
|
|
252
|
-
[...readTargets.values(), ...meterStreams.values(), ...xySeriesStreams.values()].map(target => target.statusId)]
|
|
287
|
+
[...readTargets.values(), ...meterStreams.values(), ...xySeriesStreams.values(), ...healthTargets.values()].map(target => target.statusId)]
|
|
253
288
|
})
|
|
254
289
|
const subflow = {
|
|
255
290
|
id: subflowId, type: 'subflow', name: `Binding bridge ${scopeId}`,
|
|
256
291
|
info: 'Generated panel binding bridge. Requires a Controller node and authenticated command source.',
|
|
257
292
|
category: 'panel binding',
|
|
258
293
|
in: [{ x: 170, y: 150, wires: [{ id: routerId }] }],
|
|
259
|
-
out: readTargets.size || meterStreams.size || xySeriesStreams.size
|
|
294
|
+
out: readTargets.size || meterStreams.size || xySeriesStreams.size || healthTargets.size
|
|
260
295
|
? [{ x: meterStreams.size || xySeriesStreams.size ? 1600 : 1290,
|
|
261
296
|
y: Math.round((390 + (readTargets.size + meterStreams.size + xySeriesStreams.size + 1) * 85) / 10) * 10,
|
|
262
297
|
wires: [...readTargets.values()].map(target => ({ id: target.tagId, port: 0 }))
|
|
263
298
|
.concat([...meterStreams.values()].flatMap(target => target.tagIds.map(id => ({ id, port: 0 }))))
|
|
264
|
-
.concat([...xySeriesStreams.values()].flatMap(target => target.tagIds.map(id => ({ id, port: 0 }))))
|
|
299
|
+
.concat([...xySeriesStreams.values()].flatMap(target => target.tagIds.map(id => ({ id, port: 0 }))))
|
|
300
|
+
.concat([...healthTargets.values()].map(target => ({ id: target.tagId, port: 0 }))) }]
|
|
265
301
|
: [],
|
|
266
302
|
env: [], meta: {}, color: '#96bca7'
|
|
267
303
|
}
|
|
@@ -270,7 +306,8 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
|
|
|
270
306
|
manifest: {
|
|
271
307
|
subflowId,
|
|
272
308
|
controls: routes.size,
|
|
273
|
-
nativeReads: readTargets.size + meterStreams.size + xySeriesStreams.size,
|
|
309
|
+
nativeReads: readTargets.size + meterStreams.size + xySeriesStreams.size + healthTargets.size,
|
|
310
|
+
...(healthTargets.size ? { nativeAvailabilityReads: healthTargets.size } : {}),
|
|
274
311
|
nativeMeterStreams: meterStreams.size,
|
|
275
312
|
nativeXYSeriesStreams: xySeriesStreams.size,
|
|
276
313
|
nativeWrites: writeTargets.size,
|
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.5",
|
|
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.5",
|
|
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.5",
|
|
4
4
|
"description": "Authenticated visual control panel binding for Node-RED and MUSE Automator",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"scripts": {
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"control-catalog.js",
|
|
27
27
|
"control-type-contract.json",
|
|
28
28
|
"control-type-model.js",
|
|
29
|
+
"device-availability.js",
|
|
29
30
|
"meter-route.js",
|
|
30
31
|
"meter-frame.js",
|
|
31
32
|
"panel-meter.js",
|