node-red-contrib-room-tools 1.8.4 → 1.8.8
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 +156 -39
- package/README.md +10 -0
- package/control-catalog.js +7 -6
- package/control-panel.js +5 -5
- package/control-policy.js +36 -478
- package/control-type-model.js +30 -31
- package/gateway.cjs +13 -261
- package/managed-bridge-admin.js +5 -11
- package/native-binding-plan.js +1 -1
- package/npm-shrinkwrap.json +433 -0
- package/package.json +8 -5
- package/panel-binding-editor.js +25 -36
- package/panel-editor-auth.js +3 -39
- package/panel-http.js +69 -0
- package/panel-visual-apply.js +19 -13
package/control-type-model.js
CHANGED
|
@@ -1,39 +1,38 @@
|
|
|
1
1
|
const contract = require('./control-type-contract.json')
|
|
2
2
|
|
|
3
|
-
const ADAPTER_FAMILIES = Object.freeze({
|
|
4
|
-
range: ['number'],
|
|
5
|
-
switch: ['boolean'],
|
|
6
|
-
meter: ['number'],
|
|
7
|
-
'text-display': ['text', 'number', 'enum', 'boolean'],
|
|
8
|
-
'text-input': ['text'],
|
|
9
|
-
select: ['enum']
|
|
10
|
-
})
|
|
11
3
|
|
|
12
|
-
const READ_ONLY = new Set(['meter', 'text-display'])
|
|
13
4
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
5
|
+
// Used by both server validation and the injected editor; keep it self-contained.
|
|
6
|
+
function createTargetMatcher (typeContract) {
|
|
7
|
+
const families = {
|
|
8
|
+
range: ['number'], switch: ['boolean'], meter: ['number'],
|
|
9
|
+
'text-display': ['text', 'number', 'enum', 'boolean'], 'text-input': ['text'], select: ['enum']
|
|
10
|
+
}
|
|
11
|
+
function family (target) {
|
|
12
|
+
if (target.controlSchemaVersion !== typeContract.schemaVersion) return null
|
|
13
|
+
const definition = typeContract.types[String(target.controlType || '').toLowerCase()]
|
|
14
|
+
if (!definition) return null
|
|
15
|
+
const type = target.type
|
|
16
|
+
if (definition.family === 'number' && !['integer', 'float'].includes(type)) return null
|
|
17
|
+
if (definition.family === 'boolean' && type !== 'boolean' &&
|
|
18
|
+
!(type === 'integer' && target.booleanEncoding === 'integer01' &&
|
|
19
|
+
target.min === 0 && target.max === 1)) return null
|
|
20
|
+
if (definition.family === 'text' && !['string', 'dstring'].includes(type)) return null
|
|
21
|
+
if (definition.family === 'enum' && !['enum', 'integer', 'string'].includes(type)) return null
|
|
22
|
+
return definition.family
|
|
23
|
+
}
|
|
24
|
+
function compatible (adapter, target, direction, candidate = {}) {
|
|
25
|
+
if (target.kind !== 'param' || !families[adapter]?.includes(family(target))) return false
|
|
26
|
+
if (direction === 'write' && ['meter', 'text-display'].includes(adapter)) return false
|
|
27
|
+
if (!['read', 'write'].includes(direction)) return false
|
|
28
|
+
if (!(direction === 'write' ? ['wo', 'rw'] : ['ro', 'rw']).includes(target.access)) return false
|
|
29
|
+
if (candidate.unit && candidate.unit !== target.unit) return false
|
|
30
|
+
return true
|
|
31
|
+
}
|
|
32
|
+
return { family, compatible }
|
|
26
33
|
}
|
|
27
34
|
|
|
28
|
-
|
|
29
|
-
const family = targetFamily(target)
|
|
30
|
-
if (!family || target.kind !== 'param' || !ADAPTER_FAMILIES[adapter]?.includes(family)) return false
|
|
31
|
-
if (direction === 'write' && READ_ONLY.has(adapter)) return false
|
|
32
|
-
if (direction === 'write' && !['wo', 'rw'].includes(target.access)) return false
|
|
33
|
-
if (direction === 'read' && !['ro', 'rw'].includes(target.access)) return false
|
|
34
|
-
if (candidate.unit && candidate.unit !== target.unit) return false
|
|
35
|
-
return true
|
|
36
|
-
}
|
|
35
|
+
const { family: targetFamily, compatible: compatibleTarget } = createTargetMatcher(contract)
|
|
37
36
|
|
|
38
37
|
function controlTypeForAdapter (adapter, target) {
|
|
39
38
|
if (!compatibleTarget(adapter, target, 'read') && !compatibleTarget(adapter, target, 'write')) {
|
|
@@ -60,4 +59,4 @@ function targetMeta (target, onebased) {
|
|
|
60
59
|
}
|
|
61
60
|
}
|
|
62
61
|
|
|
63
|
-
module.exports = { contract, compatibleTarget, controlTypeForAdapter, targetFamily, targetMeta }
|
|
62
|
+
module.exports = { contract, createTargetMatcher, compatibleTarget, controlTypeForAdapter, targetFamily, targetMeta }
|
package/gateway.cjs
CHANGED
|
@@ -1,134 +1,23 @@
|
|
|
1
1
|
const crypto = require('node:crypto')
|
|
2
|
+
const { readJsonBody, replyJson: reply, sameOrigin } = require('./panel-http')
|
|
2
3
|
|
|
3
|
-
const PREFIX = '/room-panel-api'
|
|
4
|
-
const CONTINUOUS_INTERVAL_MS = 40
|
|
5
4
|
const FEEDBACK_HEARTBEAT_MS = 20000
|
|
6
5
|
|
|
7
|
-
const defaults = () => ({
|
|
8
|
-
scene: 'meeting', display: true, source: '无线投屏', volume: 42,
|
|
9
|
-
muted: false, light: 80, curtains: 75, temperature: 23, meeting: false
|
|
10
|
-
})
|
|
11
|
-
|
|
12
|
-
const fields = {
|
|
13
|
-
'display/power': 'display',
|
|
14
|
-
'display/source': 'source',
|
|
15
|
-
'audio/volume': 'volume',
|
|
16
|
-
'audio/mute': 'muted',
|
|
17
|
-
'lighting/brightness': 'light',
|
|
18
|
-
'room/curtain': 'curtains',
|
|
19
|
-
'climate/temperature': 'temperature',
|
|
20
|
-
'room/scene': 'scene',
|
|
21
|
-
'room/meeting': 'meeting'
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const controlPolicies = {
|
|
25
|
-
'display/power': { type: 'power', mode: 'discrete' },
|
|
26
|
-
'display/source': { type: 'source', mode: 'discrete' },
|
|
27
|
-
'audio/volume': { type: 'level', mode: 'continuous' },
|
|
28
|
-
'audio/mute': { type: 'mute', mode: 'discrete' },
|
|
29
|
-
'lighting/brightness': { type: 'dimmer', mode: 'continuous' },
|
|
30
|
-
'room/curtain': { type: 'position', mode: 'continuous' },
|
|
31
|
-
'climate/temperature': { type: 'position', mode: 'continuous' },
|
|
32
|
-
'room/scene': { type: 'preset', mode: 'discrete' },
|
|
33
|
-
'room/meeting': { type: 'preset', mode: 'discrete' },
|
|
34
|
-
'room/reset': { type: 'preset', mode: 'discrete' }
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const ranges = {
|
|
38
|
-
volume: [0, 100], light: [0, 100], curtains: [0, 100], temperature: [16, 30]
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const presets = {
|
|
42
|
-
focus: { light: 85, curtains: 80, volume: 25, display: true },
|
|
43
|
-
cinema: { light: 20, curtains: 10, volume: 65, display: true },
|
|
44
|
-
social: { light: 70, curtains: 80, volume: 40, display: true },
|
|
45
|
-
unwind: { light: 35, curtains: 80, volume: 32, display: true },
|
|
46
|
-
meeting: { light: 80, curtains: 75, volume: 42, source: '无线投屏', display: true },
|
|
47
|
-
present: { light: 30, curtains: 10, volume: 55, source: 'HDMI 1', display: true },
|
|
48
|
-
video: { light: 65, curtains: 35, volume: 65, source: '视频会议', display: true },
|
|
49
|
-
relax: { light: 45, curtains: 100, volume: 20, source: '无线投屏', display: false }
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function valid (key, value) {
|
|
53
|
-
if (ranges[key]) {
|
|
54
|
-
return typeof value === 'number' && Number.isFinite(value) &&
|
|
55
|
-
value >= ranges[key][0] && value <= ranges[key][1]
|
|
56
|
-
}
|
|
57
|
-
if (['display', 'muted', 'meeting'].includes(key)) return typeof value === 'boolean'
|
|
58
|
-
if (key === 'source') return ['无线投屏', 'HDMI 1', 'HDMI 2', '视频会议'].includes(value)
|
|
59
|
-
if (key === 'scene') return ['custom', ...Object.keys(presets)].includes(value)
|
|
60
|
-
return false
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function createLatestWinsDispatcher ({
|
|
64
|
-
onDispatch,
|
|
65
|
-
intervalMs = CONTINUOUS_INTERVAL_MS,
|
|
66
|
-
now = Date.now,
|
|
67
|
-
setTimer = setTimeout,
|
|
68
|
-
clearTimer = clearTimeout
|
|
69
|
-
}) {
|
|
70
|
-
if (typeof onDispatch !== 'function') throw Error('onDispatch must be a function')
|
|
71
|
-
if (!Number.isFinite(intervalMs) || intervalMs < 0) throw Error('intervalMs must be non-negative')
|
|
72
|
-
|
|
73
|
-
const controls = new Map()
|
|
74
|
-
let closed = false
|
|
75
|
-
|
|
76
|
-
const schedule = (key, control) => {
|
|
77
|
-
if (closed || control.timer || !control.pending) return
|
|
78
|
-
const elapsed = control.lastSentAt === null ? intervalMs : now() - control.lastSentAt
|
|
79
|
-
const wait = Math.max(0, intervalMs - elapsed)
|
|
80
|
-
if (wait === 0) dispatch(key, control)
|
|
81
|
-
else control.timer = setTimer(() => dispatch(key, control), wait)
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const dispatch = (key, control) => {
|
|
85
|
-
control.timer = null
|
|
86
|
-
if (closed || !control.pending) return
|
|
87
|
-
const message = control.pending
|
|
88
|
-
control.pending = null
|
|
89
|
-
control.lastSentAt = now()
|
|
90
|
-
onDispatch(message)
|
|
91
|
-
if (control.pending) schedule(key, control)
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
return {
|
|
95
|
-
push (key, message) {
|
|
96
|
-
if (closed) return false
|
|
97
|
-
let control = controls.get(key)
|
|
98
|
-
if (!control) {
|
|
99
|
-
control = { pending: null, timer: null, lastSentAt: null }
|
|
100
|
-
controls.set(key, control)
|
|
101
|
-
}
|
|
102
|
-
// 同一房间、设备、控制号始终最多只有一个尚未发送的连续目标。
|
|
103
|
-
control.pending = message
|
|
104
|
-
schedule(key, control)
|
|
105
|
-
return true
|
|
106
|
-
},
|
|
107
|
-
close () {
|
|
108
|
-
closed = true
|
|
109
|
-
for (const control of controls.values()) {
|
|
110
|
-
if (control.timer) clearTimer(control.timer)
|
|
111
|
-
}
|
|
112
|
-
controls.clear()
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
6
|
function createGateway ({
|
|
118
7
|
username,
|
|
119
8
|
password,
|
|
120
9
|
room = 'room-01',
|
|
121
|
-
onCommand = () => {},
|
|
122
10
|
onBoundCommand,
|
|
123
11
|
onBoundLogin,
|
|
124
|
-
boundOnly = false,
|
|
125
12
|
secureCookie = false,
|
|
126
13
|
prefix,
|
|
127
|
-
continuousIntervalMs = CONTINUOUS_INTERVAL_MS,
|
|
128
14
|
sessionTtlMs = 8 * 3600000,
|
|
129
15
|
restoredSessions
|
|
130
16
|
}) {
|
|
131
|
-
const routePrefix = prefix
|
|
17
|
+
const routePrefix = prefix
|
|
18
|
+
if (typeof prefix !== 'string' || !/^\/control-panel-api\/panels\/[A-Za-z0-9._-]+$/.test(prefix)) {
|
|
19
|
+
throw Error('A panel-scoped API prefix is required')
|
|
20
|
+
}
|
|
132
21
|
if (!username || typeof password !== 'string' || password.length < 12) {
|
|
133
22
|
throw Error('Set a username and password of at least 12 characters')
|
|
134
23
|
}
|
|
@@ -149,17 +38,9 @@ function createGateway ({
|
|
|
149
38
|
}
|
|
150
39
|
}
|
|
151
40
|
const attempts = new Map()
|
|
152
|
-
const continuousDispatcher = createLatestWinsDispatcher({
|
|
153
|
-
onDispatch: onCommand,
|
|
154
|
-
intervalMs: continuousIntervalMs
|
|
155
|
-
})
|
|
156
41
|
const subscribers = new Set()
|
|
157
|
-
let desiredState = defaults()
|
|
158
|
-
let reportedState = {}
|
|
159
42
|
const boundState = new Map()
|
|
160
43
|
let boundRevision = 0
|
|
161
|
-
let revision = 0
|
|
162
|
-
let commandRevision = 0
|
|
163
44
|
let closed = false
|
|
164
45
|
let heartbeatTimer = null
|
|
165
46
|
|
|
@@ -197,17 +78,6 @@ function createGateway ({
|
|
|
197
78
|
}
|
|
198
79
|
}
|
|
199
80
|
|
|
200
|
-
function feedbackMessage (topic = 'room/state', payload = reportedState, controlKey) {
|
|
201
|
-
return {
|
|
202
|
-
room,
|
|
203
|
-
kind: 'state',
|
|
204
|
-
topic,
|
|
205
|
-
payload,
|
|
206
|
-
state: { ...reportedState },
|
|
207
|
-
revision,
|
|
208
|
-
...(controlKey ? { controlKey } : {})
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
81
|
|
|
212
82
|
function broadcastFeedback (message) {
|
|
213
83
|
for (const subscriber of [...subscribers]) sendFeedbackEvent(subscriber, message)
|
|
@@ -219,32 +89,9 @@ function createGateway ({
|
|
|
219
89
|
}
|
|
220
90
|
}
|
|
221
91
|
|
|
222
|
-
function feedback (msg) {
|
|
223
|
-
if (boundOnly) throw Error('Use boundFeedback for a bound panel')
|
|
224
|
-
if (msg.room !== room || msg.kind !== 'state') {
|
|
225
|
-
throw Error('Feedback requires matching room and kind=state')
|
|
226
|
-
}
|
|
227
|
-
const values = msg.topic === 'room/state'
|
|
228
|
-
? msg.payload
|
|
229
|
-
: fields[msg.topic] ? { [fields[msg.topic]]: msg.payload } : null
|
|
230
|
-
if (!values || typeof values !== 'object' || Array.isArray(values) ||
|
|
231
|
-
!Object.keys(values).length ||
|
|
232
|
-
Object.entries(values).some(([key, value]) => !valid(key, value))) {
|
|
233
|
-
throw Error('Invalid feedback')
|
|
234
|
-
}
|
|
235
|
-
reportedState = { ...reportedState, ...values }
|
|
236
|
-
revision++
|
|
237
|
-
const message = feedbackMessage(
|
|
238
|
-
msg.topic,
|
|
239
|
-
msg.topic === 'room/state' ? { ...reportedState } : msg.payload,
|
|
240
|
-
typeof msg.controlKey === 'string' ? msg.controlKey : undefined
|
|
241
|
-
)
|
|
242
|
-
broadcastFeedback(message)
|
|
243
|
-
return message
|
|
244
|
-
}
|
|
245
92
|
|
|
246
93
|
function boundFeedback (items) {
|
|
247
|
-
if (!
|
|
94
|
+
if (!Array.isArray(items) || !items.length ||
|
|
248
95
|
items.some(item => !item || typeof item.id !== 'string' || !item.id ||
|
|
249
96
|
!Object.hasOwn(item, 'value'))) {
|
|
250
97
|
throw Error('Invalid bound feedback')
|
|
@@ -264,33 +111,6 @@ function createGateway ({
|
|
|
264
111
|
}
|
|
265
112
|
}
|
|
266
113
|
|
|
267
|
-
function reply (res, status, data, headers = {}) {
|
|
268
|
-
res.writeHead(status, {
|
|
269
|
-
'Content-Type': 'application/json; charset=utf-8',
|
|
270
|
-
'Cache-Control': 'no-store',
|
|
271
|
-
'X-Content-Type-Options': 'nosniff',
|
|
272
|
-
...headers
|
|
273
|
-
})
|
|
274
|
-
res.end(JSON.stringify(data))
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
async function body (req) {
|
|
278
|
-
if (!String(req.headers['content-type'] || '').startsWith('application/json')) {
|
|
279
|
-
throw Error('JSON required')
|
|
280
|
-
}
|
|
281
|
-
if (req.body !== undefined) {
|
|
282
|
-
if (Buffer.isBuffer(req.body)) return JSON.parse(req.body.toString('utf8'))
|
|
283
|
-
if (typeof req.body === 'string') return JSON.parse(req.body)
|
|
284
|
-
if (req.body && typeof req.body === 'object' && Object.keys(req.body).length) return req.body
|
|
285
|
-
}
|
|
286
|
-
let raw = ''
|
|
287
|
-
for await (const chunk of req) {
|
|
288
|
-
raw += chunk
|
|
289
|
-
if (Buffer.byteLength(raw) > 8192) throw Error('Request too large')
|
|
290
|
-
}
|
|
291
|
-
return JSON.parse(raw)
|
|
292
|
-
}
|
|
293
|
-
|
|
294
114
|
const cookieName = 'room_panel_' + Buffer.from(room).toString('hex')
|
|
295
115
|
const cookie = (token, maxAge, req) => `${cookieName}=${token}; Path=${routePrefix}; HttpOnly; SameSite=Strict; Max-Age=${maxAge}${secureCookie && req.socket.encrypted ? '; Secure' : ''}`
|
|
296
116
|
|
|
@@ -313,8 +133,7 @@ function createGateway ({
|
|
|
313
133
|
for (const [ip, attempt] of attempts) if (attempt.until < now) attempts.delete(ip)
|
|
314
134
|
|
|
315
135
|
if (req.method === 'POST') {
|
|
316
|
-
|
|
317
|
-
if (!origin || new URL(origin).host !== req.headers.host) {
|
|
136
|
+
if (!sameOrigin(req)) {
|
|
318
137
|
reply(res, 403, { error: 'Origin rejected' })
|
|
319
138
|
return true
|
|
320
139
|
}
|
|
@@ -329,7 +148,7 @@ function createGateway ({
|
|
|
329
148
|
}
|
|
330
149
|
entry.count++
|
|
331
150
|
attempts.set(ip, entry)
|
|
332
|
-
const data = await
|
|
151
|
+
const data = await readJsonBody(req, 8192)
|
|
333
152
|
const supplied = String(data.password || '')
|
|
334
153
|
if (supplied.length > 256) {
|
|
335
154
|
reply(res, 401, { error: 'Invalid credentials' })
|
|
@@ -355,7 +174,7 @@ function createGateway ({
|
|
|
355
174
|
reply(res, 200, { room, username, csrf }, {
|
|
356
175
|
'Set-Cookie': cookie(token, Math.ceil(sessionTtlMs / 1000), req)
|
|
357
176
|
})
|
|
358
|
-
if (
|
|
177
|
+
if (typeof onBoundLogin === 'function') {
|
|
359
178
|
queueMicrotask(() => { if (!closed) onBoundLogin({ room, user: username }) })
|
|
360
179
|
}
|
|
361
180
|
return true
|
|
@@ -390,7 +209,7 @@ function createGateway ({
|
|
|
390
209
|
const subscriber = { res, token }
|
|
391
210
|
subscribers.add(subscriber)
|
|
392
211
|
res.on('close', () => closeSubscriber(subscriber))
|
|
393
|
-
sendFeedbackEvent(subscriber,
|
|
212
|
+
sendFeedbackEvent(subscriber, boundSnapshot())
|
|
394
213
|
ensureHeartbeat()
|
|
395
214
|
return true
|
|
396
215
|
}
|
|
@@ -399,7 +218,7 @@ function createGateway ({
|
|
|
399
218
|
reply(res, 403, { error: 'Region denied' })
|
|
400
219
|
return true
|
|
401
220
|
}
|
|
402
|
-
reply(res, 200,
|
|
221
|
+
reply(res, 200, boundSnapshot())
|
|
403
222
|
return true
|
|
404
223
|
}
|
|
405
224
|
if (path === routePrefix + '/logout' && req.method === 'POST') {
|
|
@@ -413,7 +232,7 @@ function createGateway ({
|
|
|
413
232
|
reply(res, 404, { error: 'Not found' })
|
|
414
233
|
return true
|
|
415
234
|
}
|
|
416
|
-
const data = await
|
|
235
|
+
const data = await readJsonBody(req, 8192)
|
|
417
236
|
if (data.room !== room) {
|
|
418
237
|
reply(res, 403, { error: 'Region denied' })
|
|
419
238
|
return true
|
|
@@ -425,64 +244,6 @@ function createGateway ({
|
|
|
425
244
|
reply(res, 200, { ok: true })
|
|
426
245
|
return true
|
|
427
246
|
}
|
|
428
|
-
if (path === routePrefix + '/command' && req.method === 'POST') {
|
|
429
|
-
if (boundOnly) {
|
|
430
|
-
reply(res, 404, { error: 'Not found' })
|
|
431
|
-
return true
|
|
432
|
-
}
|
|
433
|
-
const data = await body(req)
|
|
434
|
-
if (data.room !== room) {
|
|
435
|
-
reply(res, 403, { error: 'Region denied' })
|
|
436
|
-
return true
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
const key = fields[data.topic]
|
|
440
|
-
const policy = controlPolicies[data.topic]
|
|
441
|
-
const invalidValue = data.topic === 'room/reset'
|
|
442
|
-
? data.payload !== true
|
|
443
|
-
: !key || !valid(key, data.payload) || (key === 'scene' && !presets[data.payload])
|
|
444
|
-
const invalidType = data.controlType !== undefined && data.controlType !== policy?.type
|
|
445
|
-
const controlKey = data.controlKey === undefined ? data.topic : data.controlKey
|
|
446
|
-
const invalidControlKey = typeof controlKey !== 'string' ||
|
|
447
|
-
!/^[a-zA-Z0-9._:/-]{1,160}$/.test(controlKey)
|
|
448
|
-
if (!policy || invalidValue || invalidType || invalidControlKey ||
|
|
449
|
-
(data.final !== undefined && typeof data.final !== 'boolean')) {
|
|
450
|
-
reply(res, 400, { error: 'Invalid control value' })
|
|
451
|
-
return true
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
let next = { ...desiredState }
|
|
455
|
-
if (data.topic === 'room/reset') next = defaults()
|
|
456
|
-
else if (key === 'scene') next = { ...next, ...presets[data.payload], scene: data.payload }
|
|
457
|
-
else {
|
|
458
|
-
next[key] = data.payload
|
|
459
|
-
if (['light', 'curtains'].includes(key)) next.scene = 'custom'
|
|
460
|
-
if (key === 'meeting' && data.payload) next.display = true
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
const command = {
|
|
464
|
-
room,
|
|
465
|
-
topic: data.topic,
|
|
466
|
-
payload: data.payload,
|
|
467
|
-
kind: 'command',
|
|
468
|
-
controlType: policy.type,
|
|
469
|
-
controlKey,
|
|
470
|
-
final: data.final === true,
|
|
471
|
-
user: username,
|
|
472
|
-
state: next
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
if (policy.mode === 'continuous') {
|
|
476
|
-
continuousDispatcher.push(room + ':' + controlKey, command)
|
|
477
|
-
} else {
|
|
478
|
-
onCommand(command)
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
desiredState = next
|
|
482
|
-
commandRevision++
|
|
483
|
-
reply(res, 200, { ok: true, revision, commandRevision })
|
|
484
|
-
return true
|
|
485
|
-
}
|
|
486
247
|
|
|
487
248
|
reply(res, 404, { error: 'Not found' })
|
|
488
249
|
} catch (error) {
|
|
@@ -493,7 +254,6 @@ function createGateway ({
|
|
|
493
254
|
|
|
494
255
|
return {
|
|
495
256
|
handler,
|
|
496
|
-
feedback,
|
|
497
257
|
boundFeedback,
|
|
498
258
|
sessionSnapshot () {
|
|
499
259
|
return new Map([...sessions].filter(([, session]) => session.expiry > Date.now())
|
|
@@ -501,7 +261,6 @@ function createGateway ({
|
|
|
501
261
|
},
|
|
502
262
|
close () {
|
|
503
263
|
closed = true
|
|
504
|
-
continuousDispatcher.close()
|
|
505
264
|
for (const subscriber of [...subscribers]) closeSubscriber(subscriber)
|
|
506
265
|
if (heartbeatTimer) clearInterval(heartbeatTimer)
|
|
507
266
|
heartbeatTimer = null
|
|
@@ -511,11 +270,4 @@ function createGateway ({
|
|
|
511
270
|
}
|
|
512
271
|
}
|
|
513
272
|
|
|
514
|
-
module.exports = {
|
|
515
|
-
CONTINUOUS_INTERVAL_MS,
|
|
516
|
-
FEEDBACK_HEARTBEAT_MS,
|
|
517
|
-
PREFIX,
|
|
518
|
-
controlPolicies,
|
|
519
|
-
createGateway,
|
|
520
|
-
createLatestWinsDispatcher
|
|
521
|
-
}
|
|
273
|
+
module.exports = { createGateway }
|
package/managed-bridge-admin.js
CHANGED
|
@@ -2,16 +2,10 @@ const crypto = require('node:crypto')
|
|
|
2
2
|
const fs = require('node:fs')
|
|
3
3
|
const path = require('node:path')
|
|
4
4
|
|
|
5
|
-
function
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
function localDirectory (userDir, remoteAddress) {
|
|
10
|
-
const address = String(remoteAddress || '')
|
|
11
|
-
if (!['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(address)) {
|
|
12
|
-
throw new Error('Managed bridge editor is restricted to a local request')
|
|
13
|
-
}
|
|
14
|
-
return fs.realpathSync(userDir)
|
|
5
|
+
function runtimeDirectory (userDir) {
|
|
6
|
+
const directory = fs.realpathSync(userDir)
|
|
7
|
+
if (!fs.statSync(directory).isDirectory()) throw new Error('Node-RED user directory must be a directory')
|
|
8
|
+
return directory
|
|
15
9
|
}
|
|
16
10
|
|
|
17
11
|
function regularFile (file) {
|
|
@@ -87,4 +81,4 @@ function backupFlow (location, rev, flows) {
|
|
|
87
81
|
return backupDir
|
|
88
82
|
}
|
|
89
83
|
|
|
90
|
-
module.exports = { backupFlow,
|
|
84
|
+
module.exports = { backupFlow, runtimeDirectory, flowLocation }
|
package/native-binding-plan.js
CHANGED
|
@@ -152,7 +152,7 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
|
|
|
152
152
|
})
|
|
153
153
|
const subflow = {
|
|
154
154
|
id: subflowId, type: 'subflow', name: `Binding bridge ${scopeId}`,
|
|
155
|
-
info: 'Generated
|
|
155
|
+
info: 'Generated panel binding bridge. Requires a Controller node and authenticated command source.',
|
|
156
156
|
category: 'panel binding',
|
|
157
157
|
in: [{ x: 170, y: 150, wires: [{ id: routerId }] }],
|
|
158
158
|
out: readTargets.size
|