node-red-contrib-room-tools 1.9.3 → 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.js CHANGED
@@ -2,6 +2,7 @@ const crypto = require('node:crypto')
2
2
  const path = require('node:path')
3
3
  const { injectControlPolicy } = require('./control-policy')
4
4
  const { configuredRoutes, resolveBoundCommand, resolveBoundFeedback } = require('./control-bound-protocol')
5
+ const { createDeviceAvailability } = require('./device-availability')
5
6
  const { catalogBase, listCatalogDevices, listCatalogTargets } = require('./control-catalog')
6
7
  const { contract } = require('./control-type-model')
7
8
  const { parseVisualBindings } = require('./panel-visual-bindings')
@@ -142,6 +143,7 @@ module.exports = function (RED) {
142
143
  }
143
144
  }
144
145
 
146
+ const availability = createDeviceAvailability(routes)
145
147
  let bridgeReady = !config.bridgeRevisionApplied
146
148
  if (config.nativeBridge === true) {
147
149
  try {
@@ -174,6 +176,10 @@ module.exports = function (RED) {
174
176
 
175
177
  let gateway
176
178
  let gatewayAuthKey
179
+ async function telemetryTicket (req, res, identity, active) {
180
+ if (await meter?.ticket(req, res, identity, active)) return true
181
+ return await xySeries?.ticket(req, res, identity, active) || false
182
+ }
177
183
  const momentary = createMomentaryControls({ routes,
178
184
  send: (command, identity) => node.send({ ...command, room: identity.room, user: identity.user }),
179
185
  onError: error => node.error(error) })
@@ -189,11 +195,13 @@ module.exports = function (RED) {
189
195
  holdEpoch: momentary.epoch,
190
196
  secureCookie: config.secureCookie === true,
191
197
  restoredSessions: takeSessions(gatewayHandoffs, node.id, gatewayAuthKey),
192
- onAuthenticatedRequest: (req, res, identity) => meter?.ticket(req, res, identity,
193
- () => gateway?.sessionActive(identity.token)) || xySeries?.ticket(req, res, identity,
194
- () => gateway?.sessionActive(identity.token)) || false,
198
+ onAuthenticatedRequest: (req, res, identity) => telemetryTicket(req, res, identity,
199
+ () => gateway?.sessionActive(identity.token)),
195
200
  onBoundCommand: (message, identity) => {
196
201
  if (!bridgeReady) return false
202
+ if (message?._controlPanel?.hold?.action !== 'release' && !availability.allowed(message?._controlPanel?.id)) {
203
+ throw Error('Device is unavailable')
204
+ }
197
205
  if (message?._controlPanel?.hold) return momentary.handle(message, identity)
198
206
  const command = resolveBoundCommand(routes, message)
199
207
  if (routes.get(command.controlId)?.control.buttonBehavior === 'momentary' || momentary.isHeld(command)) {
@@ -207,6 +215,7 @@ module.exports = function (RED) {
207
215
  try { node.send({ kind: 'read-all', room: identity.room, user: identity.user }) } catch (error) { node.error(error) }
208
216
  }
209
217
  })
218
+ gateway.boundAvailability(availability.snapshot())
210
219
  activePanels.set(node.id, gateway)
211
220
  } catch (error) {
212
221
  node.error(error)
@@ -226,11 +235,10 @@ module.exports = function (RED) {
226
235
  password: node.credentials?.editorPassword,
227
236
  secureCookie: config.secureCookie === true,
228
237
  restoredSessions: takeSessions(editorHandoffs, node.id, editorAuthKey),
229
- onAuthenticatedRequest: (req, res, identity) => meter?.ticket(req, res, identity,
230
- () => editor?.sessionActive(identity.token)) || xySeries?.ticket(req, res, identity,
231
- () => editor?.sessionActive(identity.token)) || false,
238
+ onAuthenticatedRequest: (req, res, identity) => telemetryTicket(req, res, identity,
239
+ () => editor?.sessionActive(identity.token)),
232
240
  getConfig: async req => editorConfig(await getSnapshot(req.socket.localPort, fetch,
233
- RED.settings.httpAdminRoot ?? '/'), node.id),
241
+ RED.settings.httpAdminRoot ?? '/', { maxAgeMs: 5000, allowStaleMs: 60000 }), node.id),
234
242
  getDevices: async () => {
235
243
  if (!base) throw new Error('Device catalog unavailable')
236
244
  return listCatalogDevices(base)
@@ -247,7 +255,8 @@ module.exports = function (RED) {
247
255
  userDir: RED.settings.userDir,
248
256
  localPort: req.socket.localPort,
249
257
  adminRoot: RED.settings.httpAdminRoot ?? '/',
250
- catalogBase: base
258
+ catalogBase: base,
259
+ editorCookie: req.headers.cookie
251
260
  })
252
261
  })
253
262
  if (editor) activeEditors.set(node.id, editor)
@@ -269,6 +278,22 @@ module.exports = function (RED) {
269
278
  ...((meter || xySeries) ? { onSocket: { ...(meter?.hooks || {}), ...(xySeries?.hooks || {}) } } : {}),
270
279
  beforeSend (msg) {
271
280
  if (msg?._controlPanel?.kind === 'command') throw new Error('Bound commands require authenticated HTTP')
281
+ if (msg?.kind === 'device-availability') {
282
+ if (bridgeReady && gateway) {
283
+ const items = availability.update(msg)
284
+ if (items) {
285
+ for (const item of items) if (!item.available) momentary.releaseControl(item.id)
286
+ const pending = availability.takePending()
287
+ if (pending.length) gateway.boundFeedback(pending)
288
+ const changed = availability.changes()
289
+ if (changed) gateway.boundAvailability(changed)
290
+ // Re-read exported real state after readiness; no browser refresh,
291
+ // no Deploy and no periodic catalog requests.
292
+ if (msg.payload === true || msg.payload === 'true') node.send({ kind: 'read-all', room: boundRoom })
293
+ }
294
+ }
295
+ return false
296
+ }
272
297
  if (['device-meter-frame', 'device-meter-manifest'].includes(msg?.kind)) {
273
298
  if (bridgeReady) meter?.accept(msg)
274
299
  // Dashboard 1.31 reads properties before checking whether a message
@@ -281,7 +306,10 @@ module.exports = function (RED) {
281
306
  }
282
307
  if (boundMode) {
283
308
  if (msg?.kind === 'device-feedback' && gateway) {
284
- gateway.boundFeedback(resolveBoundFeedback(routes, msg))
309
+ const items = availability.feedback(resolveBoundFeedback(routes, msg))
310
+ if (items.length) gateway.boundFeedback(items)
311
+ const changed = availability.changes()
312
+ if (changed) gateway.boundAvailability(changed)
285
313
  }
286
314
  return {}
287
315
  }
package/control-policy.js CHANGED
@@ -194,6 +194,7 @@ export default {
194
194
  boundUnconfirmed: {},
195
195
  boundFeedbackRevision: 0,
196
196
  boundFeedbackConnected: false,
197
+ boundAvailability: {},
197
198
  boundAuthenticated: false,
198
199
  }
199
200
  },
@@ -211,11 +212,28 @@ export default {
211
212
  __boundControl (id) {
212
213
  return this.__boundControls().find(control => control.id === id)
213
214
  },
215
+ __deviceAvailable (id) {
216
+ const binding = this.__boundPanelConfig()?.visual?.bindings?.[id]
217
+ if (!binding?.readMeta?.availability && !binding?.writeMeta?.availability) return true
218
+ return this.boundFeedbackConnected && this.boundAvailability[id] === true
219
+ },
214
220
  applyBoundFeedback (message) {
221
+ if (message?.kind === 'bound-availability') {
222
+ if (message.room !== this.__boundPanelConfig()?.room || !Array.isArray(message.availability) ||
223
+ !Number.isInteger(message.revision) || message.revision < this.boundFeedbackRevision) return false
224
+ this.boundFeedbackRevision = message.revision
225
+ this.boundAvailability = Object.fromEntries(message.availability.filter(item =>
226
+ typeof item?.id === 'string' && typeof item.available === 'boolean').map(item => [item.id, item.available]))
227
+ for (const [id, available] of Object.entries(this.boundAvailability)) if (!available) this.__clearBoundPending(id)
228
+ return true
229
+ }
215
230
  if (['bound-snapshot', 'bound-feedback'].includes(message?.kind)) {
216
231
  if (message.room !== this.__boundPanelConfig()?.room || !Array.isArray(message.items) ||
217
232
  !Number.isInteger(message.revision) || message.revision < this.boundFeedbackRevision) return false
218
- if (message.kind === 'bound-snapshot') this.boundState = {}
233
+ if (message.kind === 'bound-snapshot') {
234
+ this.boundState = {}
235
+ this.boundAvailability = Object.fromEntries((message.availability || []).map(item => [item.id, item.available === true]))
236
+ }
219
237
  this.boundFeedbackRevision = message.revision
220
238
  return message.items.map(item => this.applyBoundFeedback({
221
239
  __boundRevision: message.revision,
@@ -279,6 +297,7 @@ export default {
279
297
  const control = this.__boundControl(id)
280
298
  if (!control) throw new Error('Unknown control ID: ' + id)
281
299
  if (control.mode === 'read') throw new Error('Read-only control: ' + id)
300
+ if (!this.__deviceAvailable(id)) throw new Error('Device is unavailable')
282
301
  if (!Object.prototype.hasOwnProperty.call(command, 'value')) throw new Error('Bound control needs a value')
283
302
  if (!this.__boundPanelSession?.csrf) throw new Error('Bound panel login required')
284
303
  const previous = this.__boundSettlements.get(id)
@@ -351,6 +370,7 @@ export default {
351
370
  this.__boundPanelSource?.close()
352
371
  this.__boundPanelSource = null
353
372
  this.boundFeedbackConnected = false
373
+ this.boundAvailability = {}
354
374
  },
355
375
  __connectBoundFeedback () {
356
376
  this.__closeBoundFeedback()
@@ -369,7 +389,12 @@ export default {
369
389
  }
370
390
  }
371
391
  source.onerror = () => {
372
- if (this.__boundPanelSource === source) this.boundFeedbackConnected = false
392
+ if (this.__boundPanelSource === source) {
393
+ this.boundFeedbackConnected = false
394
+ this.boundAvailability = {}
395
+ this.__resetControlPolicy()
396
+ this.__clearAllBoundSettlements()
397
+ }
373
398
  }
374
399
  },
375
400
  setBoundPanelSession (session) {
@@ -412,7 +437,7 @@ export default {
412
437
  this.setBoundPanelSession(session)
413
438
  return session
414
439
  },
415
- async restoreBoundPanelSession (timeoutMs = 2500) {
440
+ async restoreBoundPanelSession (timeoutMs = 12000) {
416
441
  const config = this.__boundPanelConfig()
417
442
  if (!config) return null
418
443
  const generation = this.__boundAuthGeneration
@@ -421,8 +446,11 @@ export default {
421
446
  }, timeoutMs, async response => ({ response, session: response.ok ? await response.json() : null }))
422
447
  if (generation !== this.__boundAuthGeneration) return this.__boundPanelSession
423
448
  if (!response.ok) {
424
- this.clearBoundPanelSession()
425
- return null
449
+ if (response.status === 401) {
450
+ this.clearBoundPanelSession()
451
+ return null
452
+ }
453
+ throw new Error('Panel session unavailable (' + response.status + ')')
426
454
  }
427
455
  if (generation !== this.__boundAuthGeneration) return this.__boundPanelSession
428
456
  this.setBoundPanelSession(session)
@@ -450,6 +478,7 @@ export default {
450
478
  const session = this.__boundPanelSession;
451
479
  if (!config || !session)
452
480
  throw new Error('Bound panel login required');
481
+ if (!this.__deviceAvailable(command.controlKey)) throw new Error('Device is unavailable');
453
482
  const response = await this.__panelFetch(config.endpoint + '/bound-command', {
454
483
  method: 'POST',
455
484
  credentials: 'same-origin',
@@ -1,14 +1,58 @@
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
+ }
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
+ }
12
56
  function family (target) {
13
57
  if (target.xySeries) return 'xy-series'
14
58
  if (target.controlSchemaVersion !== typeContract.schemaVersion) return null
@@ -24,28 +68,43 @@ function createTargetMatcher (typeContract) {
24
68
  return definition.family
25
69
  }
26
70
  function compatible (adapter, target, direction, candidate = {}) {
27
- if (adapter === 'meter' && !target.meter) return false
71
+ if (adapter === 'meter' && !meterTarget(target)) return false
72
+ if (adapter === 'range' && meterTarget(target)) return false
28
73
  if (adapter === 'xy-series' && !target.xySeries) return false
29
74
  if (target.meter && (adapter !== 'meter' || direction !== 'read' || target.access !== 'ro')) return false
30
75
  if (target.xySeries && (adapter !== 'xy-series' || direction !== 'read' || target.access !== 'ro')) return false
31
- 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
32
78
  if (direction === 'write' && ['meter', 'xy-series', 'text-display'].includes(adapter)) return false
33
79
  if (!['read', 'write'].includes(direction)) return false
34
80
  if (!(direction === 'write' ? ['wo', 'rw'] : ['ro', 'rw']).includes(target.access)) return false
35
81
  if (candidate.unit && candidate.unit !== target.unit) return false
36
82
  return true
37
83
  }
38
- return { family, compatible }
84
+ return { family, compatible, modes, states, reverse, meterTarget }
39
85
  }
40
86
 
41
- const { family: targetFamily, compatible: compatibleTarget } = 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
+ }
42
100
 
43
101
  function controlTypeForAdapter (adapter, target) {
44
102
  if (!compatibleTarget(adapter, target, 'read') && !compatibleTarget(adapter, target, 'write')) {
45
103
  throw new Error('Target is incompatible with this control')
46
104
  }
47
- if (adapter === 'meter' && target.meter) return 'meter'
105
+ if (adapter === 'meter') return 'meter'
48
106
  if (adapter === 'xy-series' && target.xySeries) return 'xy-series'
107
+ if (adapter === 'switch') return 'mute'
49
108
  const family = targetFamily(target)
50
109
  if (family === 'number') return 'level'
51
110
  if (family === 'boolean') return 'mute'
@@ -54,7 +113,7 @@ function controlTypeForAdapter (adapter, target) {
54
113
  throw new Error('Unsupported control family')
55
114
  }
56
115
 
57
- function targetMeta (target, onebased) {
116
+ function targetMeta (target, onebased, booleanMapping) {
58
117
  if (typeof target.onebased !== 'boolean' && typeof onebased !== 'boolean') {
59
118
  throw new Error('Select target numbering mode')
60
119
  }
@@ -63,10 +122,13 @@ function targetMeta (target, onebased) {
63
122
  onebased: typeof target.onebased === 'boolean' ? target.onebased : onebased,
64
123
  min: target.min, max: target.max, enums: target.enums,
65
124
  ...(target.booleanEncoding ? { booleanEncoding: target.booleanEncoding } : {}),
125
+ ...(booleanMapping ? { booleanMapping: switchMapping(target, booleanMapping) } : {}),
66
126
  controlType: target.controlType, controlSchemaVersion: target.controlSchemaVersion,
67
127
  ...(target.meter ? { meter: meterRoute(target.meter), unit: target.unit || '' } : {}),
68
- ...(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) } : {})
69
131
  }
70
132
  }
71
133
 
72
- module.exports = { contract, createTargetMatcher, compatibleTarget, controlTypeForAdapter, targetFamily, targetMeta }
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
@@ -47,15 +47,24 @@ function createGateway ({
47
47
  let closed = false
48
48
  let heartbeatTimer = null
49
49
 
50
- function closeSubscriber (subscriber) {
51
- subscribers.delete(subscriber)
52
- 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 (_) {}
53
53
  if (!subscribers.size && heartbeatTimer) {
54
54
  clearInterval(heartbeatTimer)
55
55
  heartbeatTimer = null
56
56
  }
57
57
  }
58
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
+
59
68
  function ensureHeartbeat () {
60
69
  if (heartbeatTimer || !subscribers.size) return
61
70
  heartbeatTimer = setInterval(() => {
@@ -68,23 +77,21 @@ function createGateway ({
68
77
  closeSubscriber(subscriber)
69
78
  continue
70
79
  }
71
- try { subscriber.res.write(': heartbeat\n\n') } catch (_) { closeSubscriber(subscriber) }
80
+ writeSubscriber(subscriber, ': heartbeat\n\n')
72
81
  }
73
82
  }, FEEDBACK_HEARTBEAT_MS)
74
83
  heartbeatTimer.unref?.()
75
84
  }
76
85
 
77
86
  function sendFeedbackEvent (subscriber, message) {
78
- try {
79
- subscriber.res.write('data: ' + JSON.stringify(message) + '\n\n')
80
- } catch (_) {
81
- closeSubscriber(subscriber)
82
- }
87
+ writeSubscriber(subscriber, 'data: ' + JSON.stringify(message) + '\n\n')
83
88
  }
84
89
 
85
90
 
86
91
  function broadcastFeedback (message) {
87
- 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)
88
95
  }
89
96
 
90
97
  function closeSubscribersForToken (token) {
@@ -107,11 +114,22 @@ function createGateway ({
107
114
  return message
108
115
  }
109
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
+
110
127
  function boundSnapshot () {
111
128
  return {
112
129
  room, kind: 'bound-snapshot',
113
130
  items: [...boundState].map(([id, value]) => ({ id, value })),
114
- revision: boundRevision
131
+ revision: boundRevision,
132
+ ...(availability.length ? { availability } : {})
115
133
  }
116
134
  }
117
135
 
@@ -205,6 +223,10 @@ function createGateway ({
205
223
  return true
206
224
  }
207
225
  if (path === routePrefix + '/events' && req.method === 'GET') {
226
+ if (subscribers.size >= 100) {
227
+ reply(res, 503, { error: 'Feedback connection limit reached' })
228
+ return true
229
+ }
208
230
  res.writeHead(200, {
209
231
  'Content-Type': 'text/event-stream; charset=utf-8',
210
232
  'Cache-Control': 'no-cache, no-transform',
@@ -213,8 +235,9 @@ function createGateway ({
213
235
  'X-Content-Type-Options': 'nosniff'
214
236
  })
215
237
  res.flushHeaders?.()
216
- const subscriber = { res, token }
238
+ const subscriber = { res, token, blocked: false }
217
239
  subscribers.add(subscriber)
240
+ res.on('drain', () => { subscriber.blocked = false })
218
241
  res.on('close', () => closeSubscriber(subscriber))
219
242
  sendFeedbackEvent(subscriber, boundSnapshot())
220
243
  ensureHeartbeat()
@@ -264,6 +287,7 @@ function createGateway ({
264
287
  return {
265
288
  handler,
266
289
  boundFeedback,
290
+ boundAvailability,
267
291
  sessionActive: token => !closed && (sessions.get(token)?.expiry || 0) > Date.now(),
268
292
  sessionSnapshot () {
269
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
  },