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.
package/control-panel.js CHANGED
@@ -12,6 +12,7 @@ const { createGateway } = require('./gateway.cjs')
12
12
  const { managedBridgeRevision } = require('./managed-bridge-revision')
13
13
  const { createMeterRoom, createPanelMeter } = require('./panel-meter')
14
14
  const { createXYSeriesRoom, createPanelXYSeries } = require('./panel-xy-series')
15
+ const { createMomentaryControls } = require('./panel-momentary')
15
16
 
16
17
  let statestore
17
18
  const authHandoffKey = crypto.randomBytes(32)
@@ -173,6 +174,13 @@ module.exports = function (RED) {
173
174
 
174
175
  let gateway
175
176
  let gatewayAuthKey
177
+ async function telemetryTicket (req, res, identity, active) {
178
+ if (await meter?.ticket(req, res, identity, active)) return true
179
+ return await xySeries?.ticket(req, res, identity, active) || false
180
+ }
181
+ const momentary = createMomentaryControls({ routes,
182
+ send: (command, identity) => node.send({ ...command, room: identity.room, user: identity.user }),
183
+ onError: error => node.error(error) })
176
184
  if (visualBinding && /^[a-zA-Z0-9_-]+$/.test(boundRoom || '')) {
177
185
  try {
178
186
  gatewayAuthKey = authSignature([node.id, boundRoom, node.credentials?.username,
@@ -182,15 +190,21 @@ module.exports = function (RED) {
182
190
  password: node.credentials?.password,
183
191
  room: boundRoom,
184
192
  prefix: endpoint,
193
+ holdEpoch: momentary.epoch,
185
194
  secureCookie: config.secureCookie === true,
186
195
  restoredSessions: takeSessions(gatewayHandoffs, node.id, gatewayAuthKey),
187
- onAuthenticatedRequest: (req, res, identity) => meter?.ticket(req, res, identity,
188
- () => gateway?.sessionActive(identity.token)) || xySeries?.ticket(req, res, identity,
189
- () => gateway?.sessionActive(identity.token)) || false,
196
+ onAuthenticatedRequest: (req, res, identity) => telemetryTicket(req, res, identity,
197
+ () => gateway?.sessionActive(identity.token)),
190
198
  onBoundCommand: (message, identity) => {
191
199
  if (!bridgeReady) return false
192
- node.send({ ...resolveBoundCommand(routes, message), room: identity.room, user: identity.user })
200
+ if (message?._controlPanel?.hold) return momentary.handle(message, identity)
201
+ const command = resolveBoundCommand(routes, message)
202
+ if (routes.get(command.controlId)?.control.buttonBehavior === 'momentary' || momentary.isHeld(command)) {
203
+ throw Error('A held button requires the momentary protocol')
204
+ }
205
+ node.send({ ...command, room: identity.room, user: identity.user })
193
206
  },
207
+ onSessionClosed: token => momentary.releaseSession(token),
194
208
  onBoundLogin: identity => {
195
209
  if (!boundMode || !bridgeReady) return
196
210
  try { node.send({ kind: 'read-all', room: identity.room, user: identity.user }) } catch (error) { node.error(error) }
@@ -215,9 +229,8 @@ module.exports = function (RED) {
215
229
  password: node.credentials?.editorPassword,
216
230
  secureCookie: config.secureCookie === true,
217
231
  restoredSessions: takeSessions(editorHandoffs, node.id, editorAuthKey),
218
- onAuthenticatedRequest: (req, res, identity) => meter?.ticket(req, res, identity,
219
- () => editor?.sessionActive(identity.token)) || xySeries?.ticket(req, res, identity,
220
- () => editor?.sessionActive(identity.token)) || false,
232
+ onAuthenticatedRequest: (req, res, identity) => telemetryTicket(req, res, identity,
233
+ () => editor?.sessionActive(identity.token)),
221
234
  getConfig: async req => editorConfig(await getSnapshot(req.socket.localPort, fetch,
222
235
  RED.settings.httpAdminRoot ?? '/'), node.id),
223
236
  getDevices: async () => {
@@ -338,6 +351,7 @@ module.exports = function (RED) {
338
351
  registerWidget()
339
352
  node.on('close', () => {
340
353
  clearTimeout(retryTimer)
354
+ momentary.close()
341
355
  if (gateway) holdSessions(gatewayHandoffs, node.id, gatewayAuthKey, gateway.sessionSnapshot())
342
356
  if (editor) holdSessions(editorHandoffs, node.id, editorAuthKey, editor.sessionSnapshot())
343
357
  gateway?.close()
package/control-policy.js CHANGED
@@ -7,6 +7,8 @@ const { createRoomMeterReceiver, createMeterRenderer } = require('./meter-frame'
7
7
  const { createPanelMeterClient } = require('./panel-meter-client')
8
8
  const { createRoomXYSeriesReceiver, createXYSeriesRenderer } = require('./xy-series-frame')
9
9
  const { createPanelXYSeriesClient } = require('./panel-xy-series-client')
10
+ const { attachMomentaryButton } = require('./panel-momentary-client')
11
+ const { attachButtonPress } = require('./panel-button-press')
10
12
 
11
13
  const CONTROL_POLICY_MARKER = '/* ui-control-panel-control-api-v1 */'
12
14
 
@@ -173,6 +175,8 @@ function buildDashboardPolicyOptionsSource (controls = [], boundPanel = null) {
173
175
  : ''
174
176
  const visualMethods = boundPanel?.visual
175
177
  ? `__createTargetMatcher: ${createTargetMatcher.toString()},
178
+ __attachMomentaryButton: ${attachMomentaryButton.toString()},
179
+ __attachButtonPress: ${attachButtonPress.toString()},
176
180
  __initializeVisualBinding: ${initializeVisualBinding.toString()},
177
181
  __disposeVisualBinding: ${disposeVisualBinding.toString()},`
178
182
  : ''
@@ -379,7 +383,7 @@ export default {
379
383
  this.boundState = {}
380
384
  this.boundFeedbackRevision = 0
381
385
  }
382
- this.__boundPanelSession = { room: session.room, csrf: session.csrf }
386
+ this.__boundPanelSession = { room: session.room, csrf: session.csrf, holdEpoch: session.holdEpoch }
383
387
  this.boundAuthenticated = true
384
388
  this.__connectBoundFeedback()
385
389
  },
@@ -1,14 +1,16 @@
1
1
  const contract = require('./control-type-contract.json')
2
2
  const { meterRoute } = require('./meter-route')
3
-
4
-
5
-
6
3
  // Used by both server validation and the injected editor; keep it self-contained.
7
4
  function createTargetMatcher (typeContract) {
8
5
  const families = {
9
6
  range: ['number'], switch: ['boolean'], meter: ['number'], 'xy-series': ['xy-series'],
10
7
  'text-display': ['text', 'number', 'enum', 'boolean'], 'text-input': ['text'], select: ['enum']
11
8
  }
9
+ function modes (adapter) {
10
+ if (!families[adapter]) return []
11
+ return ['meter', 'xy-series', 'text-display'].includes(adapter)
12
+ ? ['read'] : ['both', 'write', 'read']
13
+ }
12
14
  function family (target) {
13
15
  if (target.xySeries) return 'xy-series'
14
16
  if (target.controlSchemaVersion !== typeContract.schemaVersion) return null
@@ -35,10 +37,10 @@ function createTargetMatcher (typeContract) {
35
37
  if (candidate.unit && candidate.unit !== target.unit) return false
36
38
  return true
37
39
  }
38
- return { family, compatible }
40
+ return { family, compatible, modes }
39
41
  }
40
42
 
41
- const { family: targetFamily, compatible: compatibleTarget } = createTargetMatcher(contract)
43
+ const { family: targetFamily, compatible: compatibleTarget, modes: bindingModes } = createTargetMatcher(contract)
42
44
 
43
45
  function controlTypeForAdapter (adapter, target) {
44
46
  if (!compatibleTarget(adapter, target, 'read') && !compatibleTarget(adapter, target, 'write')) {
@@ -69,4 +71,4 @@ function targetMeta (target, onebased) {
69
71
  }
70
72
  }
71
73
 
72
- module.exports = { contract, createTargetMatcher, compatibleTarget, controlTypeForAdapter, targetFamily, targetMeta }
74
+ module.exports = { contract, createTargetMatcher, compatibleTarget, controlTypeForAdapter, targetFamily, targetMeta, bindingModes }
package/gateway.cjs CHANGED
@@ -10,6 +10,8 @@ function createGateway ({
10
10
  onBoundCommand,
11
11
  onBoundLogin,
12
12
  onAuthenticatedRequest,
13
+ onSessionClosed,
14
+ holdEpoch,
13
15
  secureCookie = false,
14
16
  prefix,
15
17
  sessionTtlMs = 8 * 3600000,
@@ -45,15 +47,24 @@ function createGateway ({
45
47
  let closed = false
46
48
  let heartbeatTimer = null
47
49
 
48
- function closeSubscriber (subscriber) {
49
- subscribers.delete(subscriber)
50
- try { subscriber.res.end() } catch (_) {}
50
+ function closeSubscriber (subscriber, destroy = false) {
51
+ if (!subscribers.delete(subscriber)) return
52
+ try { if (destroy) subscriber.res.destroy(); else subscriber.res.end() } catch (_) {}
51
53
  if (!subscribers.size && heartbeatTimer) {
52
54
  clearInterval(heartbeatTimer)
53
55
  heartbeatTimer = null
54
56
  }
55
57
  }
56
58
 
59
+ function writeSubscriber (subscriber, data) {
60
+ try {
61
+ // Allow one queued write to drain, including a large initial snapshot.
62
+ // A persistently blocked client reconnects to the latest snapshot.
63
+ if (subscriber.blocked) return closeSubscriber(subscriber, true)
64
+ if (subscriber.res.write(data) === false) subscriber.blocked = true
65
+ } catch (_) { closeSubscriber(subscriber, true) }
66
+ }
67
+
57
68
  function ensureHeartbeat () {
58
69
  if (heartbeatTimer || !subscribers.size) return
59
70
  heartbeatTimer = setInterval(() => {
@@ -62,26 +73,25 @@ function createGateway ({
62
73
  const session = sessions.get(subscriber.token)
63
74
  if (!session || session.expiry < now) {
64
75
  sessions.delete(subscriber.token)
76
+ onSessionClosed?.(subscriber.token)
65
77
  closeSubscriber(subscriber)
66
78
  continue
67
79
  }
68
- try { subscriber.res.write(': heartbeat\n\n') } catch (_) { closeSubscriber(subscriber) }
80
+ writeSubscriber(subscriber, ': heartbeat\n\n')
69
81
  }
70
82
  }, FEEDBACK_HEARTBEAT_MS)
71
83
  heartbeatTimer.unref?.()
72
84
  }
73
85
 
74
86
  function sendFeedbackEvent (subscriber, message) {
75
- try {
76
- subscriber.res.write('data: ' + JSON.stringify(message) + '\n\n')
77
- } catch (_) {
78
- closeSubscriber(subscriber)
79
- }
87
+ writeSubscriber(subscriber, 'data: ' + JSON.stringify(message) + '\n\n')
80
88
  }
81
89
 
82
90
 
83
91
  function broadcastFeedback (message) {
84
- for (const subscriber of [...subscribers]) sendFeedbackEvent(subscriber, message)
92
+ if (!subscribers.size) return
93
+ const data = 'data: ' + JSON.stringify(message) + '\n\n'
94
+ for (const subscriber of [...subscribers]) writeSubscriber(subscriber, data)
85
95
  }
86
96
 
87
97
  function closeSubscribersForToken (token) {
@@ -128,6 +138,7 @@ function createGateway ({
128
138
  for (const [id, session] of sessions) {
129
139
  if (session.expiry < now) {
130
140
  sessions.delete(id)
141
+ onSessionClosed?.(id)
131
142
  closeSubscribersForToken(id)
132
143
  }
133
144
  }
@@ -172,7 +183,7 @@ function createGateway ({
172
183
  const token = crypto.randomBytes(32).toString('hex')
173
184
  const csrf = crypto.randomBytes(24).toString('hex')
174
185
  sessions.set(token, { csrf, expiry: now + sessionTtlMs })
175
- reply(res, 200, { room, username, csrf }, {
186
+ reply(res, 200, { room, username, csrf, ...(holdEpoch ? { holdEpoch } : {}) }, {
176
187
  'Set-Cookie': cookie(token, Math.ceil(sessionTtlMs / 1000), req)
177
188
  })
178
189
  if (typeof onBoundLogin === 'function') {
@@ -197,10 +208,14 @@ function createGateway ({
197
208
  if (onAuthenticatedRequest && await onAuthenticatedRequest(req, res,
198
209
  { token, expiry: session.expiry, room, user: username })) return true
199
210
  if (path === routePrefix + '/session' && req.method === 'GET') {
200
- reply(res, 200, { room, username, csrf: session.csrf })
211
+ reply(res, 200, { room, username, csrf: session.csrf, ...(holdEpoch ? { holdEpoch } : {}) })
201
212
  return true
202
213
  }
203
214
  if (path === routePrefix + '/events' && req.method === 'GET') {
215
+ if (subscribers.size >= 100) {
216
+ reply(res, 503, { error: 'Feedback connection limit reached' })
217
+ return true
218
+ }
204
219
  res.writeHead(200, {
205
220
  'Content-Type': 'text/event-stream; charset=utf-8',
206
221
  'Cache-Control': 'no-cache, no-transform',
@@ -209,8 +224,9 @@ function createGateway ({
209
224
  'X-Content-Type-Options': 'nosniff'
210
225
  })
211
226
  res.flushHeaders?.()
212
- const subscriber = { res, token }
227
+ const subscriber = { res, token, blocked: false }
213
228
  subscribers.add(subscriber)
229
+ res.on('drain', () => { subscriber.blocked = false })
214
230
  res.on('close', () => closeSubscriber(subscriber))
215
231
  sendFeedbackEvent(subscriber, boundSnapshot())
216
232
  ensureHeartbeat()
@@ -226,6 +242,7 @@ function createGateway ({
226
242
  }
227
243
  if (path === routePrefix + '/logout' && req.method === 'POST') {
228
244
  sessions.delete(token)
245
+ onSessionClosed?.(token)
229
246
  closeSubscribersForToken(token)
230
247
  reply(res, 200, { ok: true }, { 'Set-Cookie': cookie('', 0, req) })
231
248
  return true
@@ -240,11 +257,12 @@ function createGateway ({
240
257
  reply(res, 403, { error: 'Region denied' })
241
258
  return true
242
259
  }
243
- if (await onBoundCommand(data, { room, user: username }) === false) {
260
+ const result = await onBoundCommand(data, { room, user: username, token, expiry: session.expiry })
261
+ if (result === false) {
244
262
  reply(res, 409, { error: 'Managed bridge is out of sync' })
245
263
  return true
246
264
  }
247
- reply(res, 200, { ok: true })
265
+ reply(res, 200, { ok: true, ...(typeof result?.held === 'boolean' ? { held: result.held } : {}) })
248
266
  return true
249
267
  }
250
268
 
@@ -222,10 +222,11 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
222
222
  }
223
223
 
224
224
  const commandSwitchId = `${scopeId}-commands`
225
- // Meter lanes follow ordinary feedback lanes without overlapping their labels.
225
+ // Ordinary feedback, Meter, and XY-series each get a separate vertical band.
226
226
  for (const node of nodes) {
227
227
  if (node.id.startsWith(`${scopeId}-meter-`) || node.id.startsWith(`${scopeId}-xy-series-`)) {
228
228
  node.y += readTargets.size * 170
229
+ if (node.id.startsWith(`${scopeId}-xy-series-`)) node.y += meterStreams.size * 280
229
230
  }
230
231
  }
231
232
  const routerId = `${scopeId}-router`
@@ -256,7 +257,8 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
256
257
  category: 'panel binding',
257
258
  in: [{ x: 170, y: 150, wires: [{ id: routerId }] }],
258
259
  out: readTargets.size || meterStreams.size || xySeriesStreams.size
259
- ? [{ x: meterStreams.size ? 1600 : 1290, y: Math.round((390 + (readTargets.size + meterStreams.size + 1) * 85) / 10) * 10,
260
+ ? [{ x: meterStreams.size || xySeriesStreams.size ? 1600 : 1290,
261
+ y: Math.round((390 + (readTargets.size + meterStreams.size + xySeriesStreams.size + 1) * 85) / 10) * 10,
260
262
  wires: [...readTargets.values()].map(target => ({ id: target.tagId, port: 0 }))
261
263
  .concat([...meterStreams.values()].flatMap(target => target.tagIds.map(id => ({ id, port: 0 }))))
262
264
  .concat([...xySeriesStreams.values()].flatMap(target => target.tagIds.map(id => ({ id, port: 0 })))) }]
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "node-red-contrib-room-tools",
3
- "version": "1.9.1",
3
+ "version": "1.9.4",
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.1",
9
+ "version": "1.9.4",
10
10
  "license": "UNLICENSED",
11
11
  "dependencies": {
12
12
  "@vue/compiler-sfc": "3.5.42",
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "node-red-contrib-room-tools",
3
- "version": "1.9.1",
3
+ "version": "1.9.4",
4
4
  "description": "Authenticated visual control panel binding for Node-RED and MUSE Automator",
5
5
  "license": "UNLICENSED",
6
+ "scripts": {
7
+ "test": "node --test test-*.cjs"
8
+ },
6
9
  "engines": {
7
10
  "node": ">=18.16.0"
8
11
  },
@@ -26,6 +29,7 @@
26
29
  "meter-route.js",
27
30
  "meter-frame.js",
28
31
  "panel-meter.js",
32
+ "panel-telemetry-send.js",
29
33
  "panel-meter-client.js",
30
34
  "xy-series-route.js",
31
35
  "xy-series-frame.js",
@@ -38,6 +42,9 @@
38
42
  "panel-http.js",
39
43
  "panel-flow-editor-sync.js",
40
44
  "panel-binding-editor.js",
45
+ "panel-momentary.js",
46
+ "panel-momentary-client.js",
47
+ "panel-button-press.js",
41
48
  "native-binding-plan.js",
42
49
  "managed-native-bridge.js",
43
50
  "managed-bridge-revision.js",