node-red-contrib-room-tools 1.9.1 → 1.9.4

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.
@@ -0,0 +1,93 @@
1
+ const { resolveBoundCommand } = require('./control-bound-protocol')
2
+ const crypto = require('node:crypto')
3
+
4
+ function createMomentaryControls ({ routes, send, onError = () => {}, leaseMs = 3000,
5
+ now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout }) {
6
+ const records = new Map()
7
+ const epoch = crypto.randomBytes(16).toString('hex')
8
+ let timer = null
9
+ let closed = false
10
+ const destination = command => JSON.stringify([command.device, command.path])
11
+ const occupied = key => [...records.values()].some(record => record.active && record.target === key)
12
+
13
+ function release (record) {
14
+ if (!record.active) return
15
+ record.active = false
16
+ if (!occupied(record.target)) {
17
+ try { send(record.off, record.identity) } catch (error) { onError(error) }
18
+ }
19
+ }
20
+ function sweep () {
21
+ const time = now()
22
+ for (const [key, record] of records) {
23
+ if (record.active && record.until <= time) release(record)
24
+ if (record.identity.expiry <= time) { release(record); records.delete(key) }
25
+ }
26
+ }
27
+ function schedule () {
28
+ if (timer) clearTimer(timer)
29
+ timer = null
30
+ const deadlines = [...records.values()].filter(record => record.active).map(record => record.until)
31
+ if (closed || !deadlines.length) return
32
+ timer = setTimer(() => { timer = null; sweep(); schedule() }, Math.max(1, Math.min(...deadlines) - now()))
33
+ timer.unref?.()
34
+ }
35
+ function handle (message, identity) {
36
+ if (closed) throw Error('Momentary controls unavailable')
37
+ const request = message?._controlPanel
38
+ const hold = request?.hold
39
+ const route = routes.get(request?.id)
40
+ if (route?.control.buttonBehavior !== 'momentary' || route.control.mode !== 'both') {
41
+ throw Error('This control is not a momentary button')
42
+ }
43
+ if (!hold || hold.epoch !== epoch || !/^[A-Za-z0-9_-]{1,64}$/.test(hold.client || '') ||
44
+ !Number.isSafeInteger(hold.seq) || hold.seq < 1 ||
45
+ !['start', 'renew', 'release'].includes(hold.action) ||
46
+ request.value !== (hold.action !== 'release') ||
47
+ typeof identity?.token !== 'string' || !Number.isFinite(identity.expiry) || identity.expiry <= now()) {
48
+ throw Error('Invalid momentary command')
49
+ }
50
+ const command = resolveBoundCommand(routes, message)
51
+ sweep()
52
+ const key = JSON.stringify([identity.token, request.id, hold.client])
53
+ let record = records.get(key)
54
+ if (!record) {
55
+ if (records.size >= 1024) throw Error('Momentary client limit reached')
56
+ record = { seq: 0, active: false, target: destination(command), identity,
57
+ off: resolveBoundCommand(routes, { _controlPanel: { kind: 'command', id: request.id, value: false, final: true } }) }
58
+ records.set(key, record)
59
+ }
60
+ // Keep the sequence watermark after release: a delayed press must not re-enable the device.
61
+ if (hold.seq <= record.seq) return { held: record.active }
62
+ record.seq = hold.seq
63
+ if (hold.action === 'release') release(record)
64
+ else if (hold.action === 'start') {
65
+ const alreadyHeld = occupied(record.target)
66
+ if (!alreadyHeld) send(command, identity)
67
+ record.active = true
68
+ record.until = Math.min(now() + leaseMs, identity.expiry)
69
+ } else if (record.active) record.until = Math.min(now() + leaseMs, identity.expiry)
70
+ schedule()
71
+ return { held: record.active }
72
+ }
73
+ return {
74
+ epoch,
75
+ handle,
76
+ isHeld (command) { sweep(); return occupied(destination(command)) },
77
+ releaseSession (token) {
78
+ for (const [key, record] of records) {
79
+ if (record.identity.token === token) { release(record); records.delete(key) }
80
+ }
81
+ schedule()
82
+ },
83
+ close () {
84
+ closed = true
85
+ if (timer) clearTimer(timer)
86
+ timer = null
87
+ for (const record of records.values()) release(record)
88
+ records.clear()
89
+ }
90
+ }
91
+ }
92
+
93
+ module.exports = { createMomentaryControls }
@@ -0,0 +1,13 @@
1
+ function emitTelemetryFrame (event, frame, subscribers) {
2
+ const sockets = [...subscribers.values()].map(entry => entry.socket)
3
+ if (!sockets.length) return
4
+ const namespace = sockets[0].nsp
5
+ // A namespace broadcast encodes once, but targets only authenticated socket IDs.
6
+ if (typeof namespace?.to === 'function' && sockets.every(socket => socket.nsp === namespace)) {
7
+ namespace.to(sockets.map(socket => socket.id)).volatile.emit(event, frame)
8
+ } else {
9
+ for (const socket of sockets) socket.volatile.emit(event, frame)
10
+ }
11
+ }
12
+
13
+ module.exports = { emitTelemetryFrame }
@@ -341,6 +341,7 @@ function inspectTemplate (format) {
341
341
  (enabledBooleanProp(node, ['indeterminate']) || hasAttribute(node, ['min', 'max', 'step']))
342
342
  const candidate = {
343
343
  locator, adapter,
344
+ ...(adapter === 'switch' && node.tag.toLowerCase() === 'button' ? { button: true } : {}),
344
345
  ...(component ? { component } : {}),
345
346
  ...(activeRepeat ? { repeat: activeRepeat } : {}),
346
347
  label: attribute(node, 'aria-label') || attribute(node, 'label') || attribute(node, 'title') ||
@@ -1,11 +1,20 @@
1
1
  const { inspectTemplate } = require('./panel-template-instrumentation')
2
- const { compatibleTarget, controlTypeForAdapter, targetMeta } = require('./control-type-model')
2
+ const { compatibleTarget, controlTypeForAdapter, targetMeta, bindingModes } = require('./control-type-model')
3
3
  const { meterRoute } = require('./meter-route')
4
4
  const { xySeriesRoute } = require('./xy-series-route')
5
5
 
6
6
  const ID = /^[A-Za-z][A-Za-z0-9._/-]{0,127}$/
7
7
  const INSTANCE_KEY = /^[A-Za-z0-9._:/-]{1,160}$/
8
- const MODES = new Set(['read', 'write', 'both'])
8
+
9
+ function buttonBehavior (entry, candidate) {
10
+ const behavior = entry.buttonBehavior ?? 'toggle'
11
+ if (!['toggle', 'momentary'].includes(behavior) ||
12
+ (entry.buttonBehavior !== undefined && !candidate.button) ||
13
+ (behavior === 'momentary' && entry.mode !== 'both')) {
14
+ throw new Error('Button behavior requires a boolean button; momentary buttons require both mode')
15
+ }
16
+ return behavior
17
+ }
9
18
 
10
19
  function targetKey (candidate, entry) {
11
20
  if (!candidate.repeat) return candidate.locator
@@ -40,10 +49,13 @@ function visualRoutes (config) {
40
49
  throw new Error(`Visual control moved or became ambiguous: ${id}`)
41
50
  }
42
51
  const bindingKey = targetKey(candidate, entry)
52
+ const behavior = buttonBehavior(entry, candidate)
53
+ if (behavior === 'momentary' && !['mute', 'power'].includes(entry.type)) {
54
+ throw new Error('Momentary buttons require a boolean control type')
55
+ }
43
56
  if (usedLocators.has(bindingKey)) throw new Error(`Duplicate visual control locator: ${id}`)
44
57
  usedLocators.add(bindingKey)
45
- if (!MODES.has(entry.mode) || !entry.type || !entry.label ||
46
- (['meter', 'xy-series', 'text-display'].includes(entry.adapter) && entry.mode !== 'read')) {
58
+ if (!bindingModes(entry.adapter).includes(entry.mode) || !entry.type || !entry.label) {
47
59
  throw new Error(`Invalid visual binding settings: ${id}`)
48
60
  }
49
61
  if (entry.readMeta?.meter) {
@@ -62,6 +74,7 @@ function visualRoutes (config) {
62
74
  routes.set(id, {
63
75
  control: { id, type: entry.type, mode: entry.mode, label: entry.label,
64
76
  adapter: entry.adapter, locator: entry.locator,
77
+ ...(candidate.button ? { buttonBehavior: behavior } : {}),
65
78
  ...(candidate.repeat ? { instanceKey: entry.instanceKey } : {}) },
66
79
  binding: entry
67
80
  })
@@ -78,15 +91,17 @@ function validatedVisualBindings ({ format, entries, devices, targetsByDevice })
78
91
  const used = new Set()
79
92
  const usedLocators = new Set()
80
93
  const models = new Map()
94
+ const targetIndexes = new Map()
81
95
  for (const raw of entries) {
82
96
  if (!raw || !ID.test(raw.id) || used.has(raw.id)) throw new Error('Control IDs must be unique and valid')
83
97
  used.add(raw.id)
84
98
  const candidate = candidates.get(raw.locator)
85
99
  if (!candidate || candidate.adapter !== raw.adapter) throw new Error(`Control changed: ${raw.id}`)
86
100
  const bindingKey = targetKey(candidate, raw)
101
+ const behavior = buttonBehavior(raw, candidate)
87
102
  if (usedLocators.has(bindingKey)) throw new Error(`One element cannot have two bindings: ${raw.id}`)
88
103
  usedLocators.add(bindingKey)
89
- if (!MODES.has(raw.mode) || (['meter', 'xy-series', 'text-display'].includes(raw.adapter) && raw.mode !== 'read')) {
104
+ if (!bindingModes(raw.adapter).includes(raw.mode)) {
90
105
  throw new Error(`Invalid binding mode: ${raw.id}`)
91
106
  }
92
107
  if (typeof raw.label !== 'string' || !raw.label.trim() || raw.label.length > 80) {
@@ -95,7 +110,8 @@ function validatedVisualBindings ({ format, entries, devices, targetsByDevice })
95
110
  if (!knownDevices.has(raw.device)) throw new Error(`Unknown device: ${raw.id}`)
96
111
  const targets = targetsByDevice.get(raw.device)
97
112
  if (!targets) throw new Error(`Device catalog unavailable: ${raw.device}`)
98
- const byPath = new Map(targets.map(target => [target.path, target]))
113
+ if (!targetIndexes.has(raw.device)) targetIndexes.set(raw.device, new Map(targets.map(target => [target.path, target])))
114
+ const byPath = targetIndexes.get(raw.device)
99
115
  const write = raw.mode === 'read' ? null : byPath.get(raw.writePath)
100
116
  const read = raw.mode === 'write' ? null : byPath.get(raw.readPath)
101
117
  if ((raw.mode !== 'read' && !write) || (raw.mode !== 'write' && !read)) {
@@ -115,13 +131,15 @@ function validatedVisualBindings ({ format, entries, devices, targetsByDevice })
115
131
  const identity = JSON.stringify([raw.device, read?.path, write?.path])
116
132
  const modelKey = candidate.repeat ? `${candidate.model}#${raw.instanceKey}` : candidate.model
117
133
  const previous = models.get(modelKey)
118
- if (previous && (read?.meter || previous.meter) && previous.identity !== identity) {
119
- throw Error(`Meter model "${candidate.model}" is already used by another binding; use a separate Vue model for the meter. Meter and volume controls must not share a model`)
134
+ const realtime = Boolean(read?.meter || read?.xySeries)
135
+ if (previous && (realtime || previous.realtime) && previous.identity !== identity) {
136
+ throw Error(`Realtime model "${candidate.model}" is already used by another binding; use a separate Vue model for each independent telemetry target`)
120
137
  }
121
- models.set(modelKey, { identity, meter: Boolean(read?.meter) })
138
+ models.set(modelKey, { identity, realtime })
122
139
  }
123
140
  result[raw.id] = {
124
141
  locator: raw.locator, adapter: raw.adapter, type, label: raw.label.trim(), mode: raw.mode,
142
+ ...(candidate.button ? { buttonBehavior: behavior } : {}),
125
143
  ...(candidate.model ? { model: candidate.model } : {}),
126
144
  ...(candidate.repeat ? { repeat: candidate.repeat, instanceKey: raw.instanceKey } : {}),
127
145
  ...(candidate.component ? { component: candidate.component } : {}),
@@ -2,6 +2,7 @@ const crypto = require('node:crypto')
2
2
  const { createXYSeriesReceiver } = require('./xy-series-frame')
3
3
  const { readJsonBody, replyJson } = require('./panel-http')
4
4
  const { xySeriesRoute } = require('./xy-series-route')
5
+ const { emitTelemetryFrame } = require('./panel-telemetry-send')
5
6
 
6
7
  function createXYSeriesRoom ({ room, now = Date.now, staleMs = 1000 }) {
7
8
  const sources = new Map(), panels = new Map(), ticks = new Set()
@@ -12,13 +13,21 @@ function createXYSeriesRoom ({ room, now = Date.now, staleMs = 1000 }) {
12
13
  const entries = Object.entries(bindings).filter(([, entry]) => entry.readMeta?.xySeries)
13
14
  .sort(([a], [b]) => a.localeCompare(b))
14
15
  if (!entries.length || entries.length > 256) throw Error('Invalid xy-series panel size')
15
- const projections = entries.map(([id, binding]) => {
16
+ // Validate the whole registration before changing shared sources or references.
17
+ const proposed = new Map()
18
+ for (const [, binding] of entries) {
16
19
  const series = xySeriesRoute(binding.readMeta.xySeries)
17
20
  const key = JSON.stringify([binding.device, series.streamId])
18
- let source = sources.get(key)
19
- if (source && JSON.stringify(source.route) !== JSON.stringify(series)) {
21
+ const previous = sources.get(key)?.route || proposed.get(key)
22
+ if (previous && JSON.stringify(previous) !== JSON.stringify(series)) {
20
23
  throw Error(`Conflicting xy-series stream: ${series.streamId}`)
21
24
  }
25
+ proposed.set(key, series)
26
+ }
27
+ const projections = entries.map(([id, binding]) => {
28
+ const series = xySeriesRoute(binding.readMeta.xySeries)
29
+ const key = JSON.stringify([binding.device, series.streamId])
30
+ let source = sources.get(key)
22
31
  if (!source) {
23
32
  source = { device: binding.device, route: series, receiver: null,
24
33
  receivedAt: null, revision: 0, refs: new Set() }
@@ -29,7 +38,7 @@ function createXYSeriesRoom ({ room, now = Date.now, staleMs = 1000 }) {
29
38
  })
30
39
  const layout = { kind: 'room-xy-series-layout', panelId, room,
31
40
  epoch: crypto.randomBytes(16).toString('hex'), ids: entries.map(([id]) => id) }
32
- panels.set(panelId, { projections, layout, sequence: 0 })
41
+ panels.set(panelId, { projections, streams: [...new Set(projections.map(point => point.source))], layout, sequence: 0 })
33
42
  return layout
34
43
  }
35
44
 
@@ -69,7 +78,7 @@ function createXYSeriesRoom ({ room, now = Date.now, staleMs = 1000 }) {
69
78
  const frame = source.receiver?.snapshot()
70
79
  const age = source.receivedAt === null ? null : Math.max(0, time - source.receivedAt)
71
80
  const ok = Boolean(frame && age !== null && age <= staleMs)
72
- values.push(ok ? frame.points.map(point => point.slice()) : [])
81
+ values.push(ok ? frame.points.map(point => [point[0], point[1]]) : [])
73
82
  valid.push(ok)
74
83
  sourcesByStream.set(key, { device: source.device, streamId: source.route.streamId,
75
84
  epoch: source.receiver?.manifest.epoch || '', seq: frame?.seq || 0,
@@ -83,7 +92,7 @@ function createXYSeriesRoom ({ room, now = Date.now, staleMs = 1000 }) {
83
92
  const panel = panels.get(panelId)
84
93
  if (!panel) return ''
85
94
  const time = now()
86
- return panel.projections.map(({ source }) => `${source.revision}:${source.receivedAt !== null &&
95
+ return panel.streams.map(source => `${source.revision}:${source.receivedAt !== null &&
87
96
  time - source.receivedAt <= staleMs}`).join(',')
88
97
  }
89
98
 
@@ -133,7 +142,7 @@ function createPanelXYSeries ({ panelId, room, bindings, hub, onSubscribe = () =
133
142
  if (revision === lastRevision) return
134
143
  lastRevision = revision
135
144
  const frame = hub.snapshot(panelId)
136
- for (const { socket } of subscribers.values()) emitFrame(socket, frame)
145
+ emitTelemetryFrame('control-panel:xy-series-frame', frame, subscribers)
137
146
  })
138
147
  return {
139
148
  async ticket (req, res, identity, active) {
@@ -85,7 +85,7 @@ function createRoomXYSeriesReceiver (layout) {
85
85
  (valid && points.some(point => !Array.isArray(point) || point.length !== 2 ||
86
86
  !finite(point[0]) || !finite(point[1])))) return false
87
87
  }
88
- latest = { values: frame.values.map(points => points.map(point => point.slice())),
88
+ latest = { values: frame.values.map(points => points.map(point => [point[0], point[1]])),
89
89
  valid: frame.valid.slice(), seq: frame.seq }
90
90
  pending = true
91
91
  return true