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.
@@ -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
- function targetFamily (target) {
15
- if (target.controlSchemaVersion !== contract.schemaVersion) return null
16
- const definition = contract.types[String(target.controlType || '').toLowerCase()]
17
- if (!definition) return null
18
- const type = target.type
19
- if (definition.family === 'number' && !['integer', 'float'].includes(type)) return null
20
- if (definition.family === 'boolean' && type !== 'boolean' &&
21
- !(type === 'integer' && target.booleanEncoding === 'integer01' &&
22
- target.min === 0 && target.max === 1)) return null
23
- if (definition.family === 'text' && !['string', 'dstring'].includes(type)) return null
24
- if (definition.family === 'enum' && !['enum', 'integer', 'string'].includes(type)) return null
25
- return definition.family
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
- function compatibleTarget (adapter, target, direction, candidate = {}) {
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 || PREFIX + '/regions/' + encodeURIComponent(room)
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 (!boundOnly || !Array.isArray(items) || !items.length ||
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
- const origin = req.headers.origin
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 body(req)
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 (boundOnly && typeof onBoundLogin === 'function') {
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, boundOnly ? boundSnapshot() : feedbackMessage())
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, boundOnly ? boundSnapshot() : feedbackMessage())
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 body(req)
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 }
@@ -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 flowDigest (flows) {
6
- return crypto.createHash('sha256').update(JSON.stringify(flows)).digest('hex')
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, flowDigest, localDirectory, flowLocation }
84
+ module.exports = { backupFlow, runtimeDirectory, flowLocation }
@@ -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 local PoC. Requires a Controller node and authenticated command source.',
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