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_AI_GUIDE.md +147 -36
- package/README.md +16 -4
- package/control-bound-protocol.js +2 -1
- package/control-panel.html +10 -36
- package/control-panel.js +21 -7
- package/control-policy.js +5 -1
- package/control-type-model.js +8 -6
- package/gateway.cjs +33 -15
- package/native-binding-plan.js +4 -2
- package/npm-shrinkwrap.json +2 -2
- package/package.json +8 -1
- package/panel-binding-editor.js +186 -44
- package/panel-button-press.js +46 -0
- package/panel-meter.js +4 -4
- package/panel-momentary-client.js +91 -0
- package/panel-momentary.js +93 -0
- package/panel-telemetry-send.js +13 -0
- package/panel-template-instrumentation.js +1 -0
- package/panel-visual-bindings.js +27 -9
- package/panel-xy-series.js +16 -7
- package/xy-series-frame.js +1 -1
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) =>
|
|
188
|
-
() => gateway?.sessionActive(identity.token))
|
|
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
|
-
|
|
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) =>
|
|
219
|
-
() => editor?.sessionActive(identity.token))
|
|
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
|
},
|
package/control-type-model.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/native-binding-plan.js
CHANGED
|
@@ -222,10 +222,11 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
|
|
|
222
222
|
}
|
|
223
223
|
|
|
224
224
|
const commandSwitchId = `${scopeId}-commands`
|
|
225
|
-
// Meter
|
|
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,
|
|
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 })))) }]
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-red-contrib-room-tools",
|
|
3
|
-
"version": "1.9.
|
|
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.
|
|
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.
|
|
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",
|