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.
@@ -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.meter) return false
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' || !families[adapter]?.includes(family(target))) return false
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' && target.meter) return '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())
@@ -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
- latest = { values: frame.values.slice(), valid: frame.valid.slice(), seq: frame.seq }
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
  },
@@ -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.type !== 'boolean' &&
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) && !['integer', 'float'].includes(target.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' && (!target.meter || !['integer', 'float'].includes(target.type) || target.access !== 'ro')) {
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,
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "node-red-contrib-room-tools",
3
- "version": "1.9.4",
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.4",
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.4",
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",