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_AI_GUIDE.md +58 -18
- package/README.md +28 -6
- package/control-bound-protocol.js +25 -7
- package/control-catalog.js +26 -3
- package/control-panel.html +10 -36
- package/control-panel.js +37 -9
- package/control-policy.js +34 -5
- package/control-type-model.js +73 -11
- package/device-availability.js +104 -0
- package/gateway.cjs +36 -12
- package/managed-native-bridge.js +3 -1
- package/meter-frame.js +7 -1
- package/native-binding-plan.js +51 -12
- package/npm-shrinkwrap.json +2 -2
- package/package.json +6 -1
- package/panel-binding-editor.js +311 -62
- package/panel-meter.js +4 -4
- package/panel-momentary.js +7 -1
- package/panel-telemetry-send.js +13 -0
- package/panel-template-instrumentation.js +1 -0
- package/panel-visual-apply.js +86 -27
- package/panel-visual-bindings.js +26 -11
- package/panel-xy-series.js +16 -7
- package/xy-series-frame.js +8 -2
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) =>
|
|
193
|
-
() => gateway?.sessionActive(identity.token))
|
|
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) =>
|
|
230
|
-
() => editor?.sessionActive(identity.token))
|
|
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
|
-
|
|
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')
|
|
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)
|
|
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 =
|
|
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
|
-
|
|
425
|
-
|
|
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',
|
package/control-type-model.js
CHANGED
|
@@ -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
|
|
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' || !
|
|
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'
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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())
|
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
|
},
|