node-red-contrib-room-tools 1.4.2 → 1.7.1

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/gateway.cjs CHANGED
@@ -1,109 +1,449 @@
1
1
  const crypto = require('node:crypto')
2
+
2
3
  const PREFIX = '/room-panel-api'
3
- const defaults = () => ({scene:'meeting',display:true,source:'无线投屏',volume:42,muted:false,light:80,curtains:75,temperature:23,meeting:false})
4
- const fields = {'display/power':'display','display/source':'source','audio/volume':'volume','audio/mute':'muted','lighting/brightness':'light','room/curtain':'curtains','climate/temperature':'temperature','room/scene':'scene','room/meeting':'meeting'}
5
- const ranges = {volume:[0,100],light:[0,100],curtains:[0,100],temperature:[16,30]}
4
+ const CONTINUOUS_INTERVAL_MS = 40
5
+ const FEEDBACK_HEARTBEAT_MS = 20000
6
+
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
+
6
41
  const presets = {
7
- focus:{light:85,curtains:80,volume:25,display:true},
8
- cinema:{light:20,curtains:10,volume:65,display:true},
9
- social:{light:70,curtains:80,volume:40,display:true},
10
- unwind:{light:35,curtains:80,volume:32,display:true},
11
- meeting:{light:80,curtains:75,volume:42,source:'无线投屏',display:true},
12
- present:{light:30,curtains:10,volume:55,source:'HDMI 1',display:true},
13
- video:{light:65,curtains:35,volume:65,source:'视频会议',display:true},
14
- relax:{light:45,curtains:100,volume:20,source:'无线投屏',display:false}
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 }
15
50
  }
16
- function valid(key, value) {
17
- if(ranges[key]) return typeof value === 'number' && Number.isFinite(value) && value >= ranges[key][0] && value <= ranges[key][1]
18
- if(['display','muted','meeting'].includes(key)) return typeof value === 'boolean'
19
- if(key === 'source') return ['无线投屏','HDMI 1','HDMI 2','视频会议'].includes(value)
20
- if(key === 'scene') return ['custom',...Object.keys(presets)].includes(value)
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)
21
60
  return false
22
61
  }
23
- function createGateway({username,password,room='room-01',onCommand=()=>{},secureCookie=false,prefix}) {
24
- const routePrefix=prefix||PREFIX+'/regions/'+encodeURIComponent(room)
25
- if(!username || typeof password !== 'string' || password.length < 12) throw Error('Set a username and password of at least 12 characters')
26
- const salt=crypto.randomBytes(16), hash=crypto.scryptSync(password,salt,32)
27
- const sessions=new Map(), attempts=new Map()
28
- let state=defaults(), revision=0, closed=false
29
- function feedback(msg) {
30
- if(msg.room!==room || msg.kind!=='state') throw Error('Feedback requires matching room and kind=state')
31
- const values=msg.topic==='room/state' ? msg.payload : fields[msg.topic] ? {[fields[msg.topic]]:msg.payload} : null
32
- if(!values || typeof values!=='object' || Array.isArray(values) || !Object.keys(values).length || Object.entries(values).some(([k,v])=>!valid(k,v))) throw Error('Invalid feedback')
33
- state={...state,...values};revision++
34
- }
35
- function reply(res,status,data,headers={}) {
36
- res.writeHead(status,{'Content-Type':'application/json; charset=utf-8','Cache-Control':'no-store','X-Content-Type-Options':'nosniff',...headers})
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
+ function createGateway ({
118
+ username,
119
+ password,
120
+ room = 'room-01',
121
+ onCommand = () => {},
122
+ secureCookie = false,
123
+ prefix,
124
+ continuousIntervalMs = CONTINUOUS_INTERVAL_MS
125
+ }) {
126
+ const routePrefix = prefix || PREFIX + '/regions/' + encodeURIComponent(room)
127
+ if (!username || typeof password !== 'string' || password.length < 12) {
128
+ throw Error('Set a username and password of at least 12 characters')
129
+ }
130
+
131
+ const salt = crypto.randomBytes(16)
132
+ const hash = crypto.scryptSync(password, salt, 32)
133
+ const sessions = new Map()
134
+ const attempts = new Map()
135
+ const continuousDispatcher = createLatestWinsDispatcher({
136
+ onDispatch: onCommand,
137
+ intervalMs: continuousIntervalMs
138
+ })
139
+ const subscribers = new Set()
140
+ let desiredState = defaults()
141
+ let reportedState = {}
142
+ let revision = 0
143
+ let commandRevision = 0
144
+ let closed = false
145
+ let heartbeatTimer = null
146
+
147
+ function closeSubscriber (subscriber) {
148
+ subscribers.delete(subscriber)
149
+ try { subscriber.res.end() } catch (_) {}
150
+ if (!subscribers.size && heartbeatTimer) {
151
+ clearInterval(heartbeatTimer)
152
+ heartbeatTimer = null
153
+ }
154
+ }
155
+
156
+ function ensureHeartbeat () {
157
+ if (heartbeatTimer || !subscribers.size) return
158
+ heartbeatTimer = setInterval(() => {
159
+ const now = Date.now()
160
+ for (const subscriber of [...subscribers]) {
161
+ const session = sessions.get(subscriber.token)
162
+ if (!session || session.expiry < now) {
163
+ sessions.delete(subscriber.token)
164
+ closeSubscriber(subscriber)
165
+ continue
166
+ }
167
+ try { subscriber.res.write(': heartbeat\n\n') } catch (_) { closeSubscriber(subscriber) }
168
+ }
169
+ }, FEEDBACK_HEARTBEAT_MS)
170
+ heartbeatTimer.unref?.()
171
+ }
172
+
173
+ function sendFeedbackEvent (subscriber, message) {
174
+ try {
175
+ subscriber.res.write('data: ' + JSON.stringify(message) + '\n\n')
176
+ } catch (_) {
177
+ closeSubscriber(subscriber)
178
+ }
179
+ }
180
+
181
+ function feedbackMessage (topic = 'room/state', payload = reportedState, controlKey) {
182
+ return {
183
+ room,
184
+ kind: 'state',
185
+ topic,
186
+ payload,
187
+ state: { ...reportedState },
188
+ revision,
189
+ ...(controlKey ? { controlKey } : {})
190
+ }
191
+ }
192
+
193
+ function broadcastFeedback (message) {
194
+ for (const subscriber of [...subscribers]) sendFeedbackEvent(subscriber, message)
195
+ }
196
+
197
+ function closeSubscribersForToken (token) {
198
+ for (const subscriber of [...subscribers]) {
199
+ if (subscriber.token === token) closeSubscriber(subscriber)
200
+ }
201
+ }
202
+
203
+ function feedback (msg) {
204
+ if (msg.room !== room || msg.kind !== 'state') {
205
+ throw Error('Feedback requires matching room and kind=state')
206
+ }
207
+ const values = msg.topic === 'room/state'
208
+ ? msg.payload
209
+ : fields[msg.topic] ? { [fields[msg.topic]]: msg.payload } : null
210
+ if (!values || typeof values !== 'object' || Array.isArray(values) ||
211
+ !Object.keys(values).length ||
212
+ Object.entries(values).some(([key, value]) => !valid(key, value))) {
213
+ throw Error('Invalid feedback')
214
+ }
215
+ reportedState = { ...reportedState, ...values }
216
+ revision++
217
+ const message = feedbackMessage(
218
+ msg.topic,
219
+ msg.topic === 'room/state' ? { ...reportedState } : msg.payload,
220
+ typeof msg.controlKey === 'string' ? msg.controlKey : undefined
221
+ )
222
+ broadcastFeedback(message)
223
+ return message
224
+ }
225
+
226
+ function reply (res, status, data, headers = {}) {
227
+ res.writeHead(status, {
228
+ 'Content-Type': 'application/json; charset=utf-8',
229
+ 'Cache-Control': 'no-store',
230
+ 'X-Content-Type-Options': 'nosniff',
231
+ ...headers
232
+ })
37
233
  res.end(JSON.stringify(data))
38
234
  }
39
- async function body(req) {
40
- if(!String(req.headers['content-type']||'').startsWith('application/json')) throw Error('JSON required')
41
- if(req.body !== undefined) {
42
- if(Buffer.isBuffer(req.body)) return JSON.parse(req.body.toString('utf8'))
43
- if(typeof req.body === 'string') return JSON.parse(req.body)
44
- if(req.body&&typeof req.body==='object'&&Object.keys(req.body).length)return req.body
235
+
236
+ async function body (req) {
237
+ if (!String(req.headers['content-type'] || '').startsWith('application/json')) {
238
+ throw Error('JSON required')
239
+ }
240
+ if (req.body !== undefined) {
241
+ if (Buffer.isBuffer(req.body)) return JSON.parse(req.body.toString('utf8'))
242
+ if (typeof req.body === 'string') return JSON.parse(req.body)
243
+ if (req.body && typeof req.body === 'object' && Object.keys(req.body).length) return req.body
244
+ }
245
+ let raw = ''
246
+ for await (const chunk of req) {
247
+ raw += chunk
248
+ if (Buffer.byteLength(raw) > 8192) throw Error('Request too large')
45
249
  }
46
- let raw='';for await(const chunk of req){raw+=chunk;if(Buffer.byteLength(raw)>8192)throw Error('Request too large')}
47
250
  return JSON.parse(raw)
48
251
  }
49
- const cookieName='room_panel_'+Buffer.from(room).toString('hex')
50
- const cookie=(token,maxAge)=>`${cookieName}=${token}; Path=${routePrefix}; HttpOnly; SameSite=Strict; Max-Age=${maxAge}${secureCookie?'; Secure':''}`
51
- async function handler(req,res) {
52
- const path=new URL(req.url,'http://localhost').pathname
53
- if(!path.startsWith(routePrefix+'/')) return false
54
- if(closed){reply(res,503,{error:'Gateway unavailable'});return true}
252
+
253
+ const cookieName = 'room_panel_' + Buffer.from(room).toString('hex')
254
+ const cookie = (token, maxAge) => `${cookieName}=${token}; Path=${routePrefix}; HttpOnly; SameSite=Strict; Max-Age=${maxAge}${secureCookie ? '; Secure' : ''}`
255
+
256
+ async function handler (req, res) {
257
+ const path = new URL(req.url, 'http://localhost').pathname
258
+ if (!path.startsWith(routePrefix + '/')) return false
259
+ if (closed) {
260
+ reply(res, 503, { error: 'Gateway unavailable' })
261
+ return true
262
+ }
263
+
55
264
  try {
56
- const now=Date.now()
57
- for(const [id,s]of sessions)if(s.expiry<now)sessions.delete(id)
58
- for(const [ip,a]of attempts)if(a.until<now)attempts.delete(ip)
59
- if(req.method==='POST') {
60
- const origin=req.headers.origin
61
- if(!origin || new URL(origin).host!==req.headers.host){reply(res,403,{error:'Origin rejected'});return true}
265
+ const now = Date.now()
266
+ for (const [id, session] of sessions) {
267
+ if (session.expiry < now) {
268
+ sessions.delete(id)
269
+ closeSubscribersForToken(id)
270
+ }
62
271
  }
63
- if(path===routePrefix+'/login' && req.method==='POST') {
64
- const ip=req.socket.remoteAddress||'unknown', entry=attempts.get(ip)||{count:0,until:now+60000}
65
- if(entry.count>=10 || attempts.size>1000){reply(res,429,{error:'Too many attempts. Wait one minute.'});return true}
66
- entry.count++;attempts.set(ip,entry)
67
- const data=await body(req)
68
- const supplied=String(data.password||'')
69
- if(supplied.length>256){reply(res,401,{error:'Invalid credentials'});return true}
70
- const test=await new Promise((resolve,reject)=>crypto.scrypt(supplied,salt,32,(err,key)=>err?reject(err):resolve(key)))
71
- const passwordMatch=crypto.timingSafeEqual(test,hash), usernameMatch=data.username===username
72
- if(!passwordMatch||!usernameMatch){reply(res,401,{error:'Invalid credentials'});return true}
73
- if(sessions.size>=100){reply(res,503,{error:'Session limit reached'});return true}
272
+ for (const [ip, attempt] of attempts) if (attempt.until < now) attempts.delete(ip)
273
+
274
+ if (req.method === 'POST') {
275
+ const origin = req.headers.origin
276
+ if (!origin || new URL(origin).host !== req.headers.host) {
277
+ reply(res, 403, { error: 'Origin rejected' })
278
+ return true
279
+ }
280
+ }
281
+
282
+ if (path === routePrefix + '/login' && req.method === 'POST') {
283
+ const ip = req.socket.remoteAddress || 'unknown'
284
+ const entry = attempts.get(ip) || { count: 0, until: now + 60000 }
285
+ if (entry.count >= 10 || attempts.size > 1000) {
286
+ reply(res, 429, { error: 'Too many attempts. Wait one minute.' })
287
+ return true
288
+ }
289
+ entry.count++
290
+ attempts.set(ip, entry)
291
+ const data = await body(req)
292
+ const supplied = String(data.password || '')
293
+ if (supplied.length > 256) {
294
+ reply(res, 401, { error: 'Invalid credentials' })
295
+ return true
296
+ }
297
+ const test = await new Promise((resolve, reject) => {
298
+ crypto.scrypt(supplied, salt, 32, (error, key) => error ? reject(error) : resolve(key))
299
+ })
300
+ const passwordMatch = crypto.timingSafeEqual(test, hash)
301
+ const usernameMatch = data.username === username
302
+ if (!passwordMatch || !usernameMatch) {
303
+ reply(res, 401, { error: 'Invalid credentials' })
304
+ return true
305
+ }
306
+ if (sessions.size >= 100) {
307
+ reply(res, 503, { error: 'Session limit reached' })
308
+ return true
309
+ }
74
310
  attempts.delete(ip)
75
- const token=crypto.randomBytes(32).toString('hex'),csrf=crypto.randomBytes(24).toString('hex')
76
- sessions.set(token,{csrf,expiry:now+8*3600000})
77
- reply(res,200,{room,username,csrf},{'Set-Cookie':cookie(token,28800)});return true
311
+ const token = crypto.randomBytes(32).toString('hex')
312
+ const csrf = crypto.randomBytes(24).toString('hex')
313
+ sessions.set(token, { csrf, expiry: now + 8 * 3600000 })
314
+ reply(res, 200, { room, username, csrf }, { 'Set-Cookie': cookie(token, 28800) })
315
+ return true
316
+ }
317
+
318
+ const token = String(req.headers.cookie || '').split(';')
319
+ .map(value => value.trim())
320
+ .find(value => value.startsWith(cookieName + '='))
321
+ ?.split('=')[1]
322
+ const session = sessions.get(token)
323
+ if (!session) {
324
+ reply(res, 401, { error: 'Login required' })
325
+ return true
326
+ }
327
+ if (req.method === 'POST' && req.headers['x-room-csrf'] !== session.csrf) {
328
+ reply(res, 403, { error: 'CSRF rejected' })
329
+ return true
330
+ }
331
+ if (path === routePrefix + '/session' && req.method === 'GET') {
332
+ reply(res, 200, { room, username, csrf: session.csrf })
333
+ return true
334
+ }
335
+ if (path === routePrefix + '/events' && req.method === 'GET') {
336
+ res.writeHead(200, {
337
+ 'Content-Type': 'text/event-stream; charset=utf-8',
338
+ 'Cache-Control': 'no-cache, no-transform',
339
+ Connection: 'keep-alive',
340
+ 'X-Accel-Buffering': 'no',
341
+ 'X-Content-Type-Options': 'nosniff'
342
+ })
343
+ res.flushHeaders?.()
344
+ const subscriber = { res, token }
345
+ subscribers.add(subscriber)
346
+ res.on('close', () => closeSubscriber(subscriber))
347
+ sendFeedbackEvent(subscriber, feedbackMessage())
348
+ ensureHeartbeat()
349
+ return true
78
350
  }
79
- const token=String(req.headers.cookie||'').split(';').map(v=>v.trim()).find(v=>v.startsWith(cookieName+'='))?.split('=')[1]
80
- const session=sessions.get(token)
81
- if(!session){reply(res,401,{error:'Login required'});return true}
82
- if(req.method==='POST' && req.headers['x-room-csrf']!==session.csrf){reply(res,403,{error:'CSRF rejected'});return true}
83
- if(path===routePrefix+'/session' && req.method==='GET'){reply(res,200,{room,username,csrf:session.csrf});return true}
84
- if(path===routePrefix+'/state' && req.method==='GET') {
85
- if(new URL(req.url,'http://localhost').searchParams.get('room')!==room){reply(res,403,{error:'Region denied'});return true}
86
- reply(res,200,{room,kind:'state',topic:'room/state',payload:state,revision});return true
351
+ if (path === routePrefix + '/state' && req.method === 'GET') {
352
+ if (new URL(req.url, 'http://localhost').searchParams.get('room') !== room) {
353
+ reply(res, 403, { error: 'Region denied' })
354
+ return true
355
+ }
356
+ reply(res, 200, feedbackMessage())
357
+ return true
87
358
  }
88
- if(path===routePrefix+'/logout' && req.method==='POST'){sessions.delete(token);reply(res,200,{ok:true},{'Set-Cookie':cookie('',0)});return true}
89
- if(path===routePrefix+'/command' && req.method==='POST') {
90
- const data=await body(req)
91
- if(data.room!==room){reply(res,403,{error:'Region denied'});return true}
92
- const key=fields[data.topic]
93
- if(data.topic==='room/reset' ? data.payload!==true : !key || !valid(key,data.payload) || (key==='scene' && !presets[data.payload])){reply(res,400,{error:'Invalid control value'});return true}
94
- let next={...state}
95
- if(data.topic==='room/reset')next=defaults()
96
- else if(key==='scene')next={...next,...presets[data.payload],scene:data.payload}
97
- else {next[key]=data.payload;if(['light','curtains'].includes(key))next.scene='custom';if(key==='meeting'&&data.payload)next.display=true}
98
- // Identity and the complete command state are rebuilt on the server.
99
- onCommand({room,topic:data.topic,payload:data.payload,kind:'command',user:username,state:next})
100
- state=next;revision++
101
- reply(res,200,{ok:true,revision});return true
359
+ if (path === routePrefix + '/logout' && req.method === 'POST') {
360
+ sessions.delete(token)
361
+ closeSubscribersForToken(token)
362
+ reply(res, 200, { ok: true }, { 'Set-Cookie': cookie('', 0) })
363
+ return true
102
364
  }
103
- reply(res,404,{error:'Not found'})
104
- }catch(err){reply(res,400,{error:'Invalid request'})}
365
+ if (path === routePrefix + '/command' && req.method === 'POST') {
366
+ const data = await body(req)
367
+ if (data.room !== room) {
368
+ reply(res, 403, { error: 'Region denied' })
369
+ return true
370
+ }
371
+
372
+ const key = fields[data.topic]
373
+ const policy = controlPolicies[data.topic]
374
+ const invalidValue = data.topic === 'room/reset'
375
+ ? data.payload !== true
376
+ : !key || !valid(key, data.payload) || (key === 'scene' && !presets[data.payload])
377
+ const invalidType = data.controlType !== undefined && data.controlType !== policy?.type
378
+ const controlKey = data.controlKey === undefined ? data.topic : data.controlKey
379
+ const invalidControlKey = typeof controlKey !== 'string' ||
380
+ !/^[a-zA-Z0-9._:/-]{1,160}$/.test(controlKey)
381
+ if (!policy || invalidValue || invalidType || invalidControlKey ||
382
+ (data.final !== undefined && typeof data.final !== 'boolean')) {
383
+ reply(res, 400, { error: 'Invalid control value' })
384
+ return true
385
+ }
386
+
387
+ let next = { ...desiredState }
388
+ if (data.topic === 'room/reset') next = defaults()
389
+ else if (key === 'scene') next = { ...next, ...presets[data.payload], scene: data.payload }
390
+ else {
391
+ next[key] = data.payload
392
+ if (['light', 'curtains'].includes(key)) next.scene = 'custom'
393
+ if (key === 'meeting' && data.payload) next.display = true
394
+ }
395
+
396
+ const command = {
397
+ room,
398
+ topic: data.topic,
399
+ payload: data.payload,
400
+ kind: 'command',
401
+ controlType: policy.type,
402
+ controlKey,
403
+ final: data.final === true,
404
+ user: username,
405
+ state: next
406
+ }
407
+
408
+ if (policy.mode === 'continuous') {
409
+ continuousDispatcher.push(room + ':' + controlKey, command)
410
+ } else {
411
+ onCommand(command)
412
+ }
413
+
414
+ desiredState = next
415
+ commandRevision++
416
+ reply(res, 200, { ok: true, revision, commandRevision })
417
+ return true
418
+ }
419
+
420
+ reply(res, 404, { error: 'Not found' })
421
+ } catch (error) {
422
+ reply(res, 400, { error: 'Invalid request' })
423
+ }
105
424
  return true
106
425
  }
107
- return {handler,feedback,close(){closed=true;sessions.clear();attempts.clear()}}
426
+
427
+ return {
428
+ handler,
429
+ feedback,
430
+ close () {
431
+ closed = true
432
+ continuousDispatcher.close()
433
+ for (const subscriber of [...subscribers]) closeSubscriber(subscriber)
434
+ if (heartbeatTimer) clearInterval(heartbeatTimer)
435
+ heartbeatTimer = null
436
+ sessions.clear()
437
+ attempts.clear()
438
+ }
439
+ }
440
+ }
441
+
442
+ module.exports = {
443
+ CONTINUOUS_INTERVAL_MS,
444
+ FEEDBACK_HEARTBEAT_MS,
445
+ PREFIX,
446
+ controlPolicies,
447
+ createGateway,
448
+ createLatestWinsDispatcher
108
449
  }
109
- module.exports={createGateway,PREFIX}
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "node-red-contrib-room-tools",
3
- "version": "1.4.2",
3
+ "version": "1.7.1",
4
4
  "description": "Room panel message generator and authenticated region gateway for Node-RED and MUSE Automator",
5
5
  "license": "UNLICENSED",
6
6
  "keywords": ["node-red"],
7
- "files": ["gateway.cjs", "room-gateway.js", "room-gateway.html", "room-message.js", "room-message.html", "control-panel.js", "control-panel.html", "README.md"],
7
+ "files": ["gateway.cjs", "room-gateway.js", "room-gateway.html", "room-message.js", "room-message.html", "control-panel.js", "control-panel.html", "control-policy.js", "CONTROL_PANEL_AI_GUIDE.md", "README.md"],
8
8
  "node-red": {
9
9
  "nodes": {
10
10
  "room-gateway": "room-gateway.js",
package/room-gateway.html CHANGED
@@ -14,7 +14,7 @@ RED.nodes.registerType('room-gateway',{
14
14
  <p>密码至少 12 个字符。每个区域 ID 只能部署一个网关,账号只能访问该网关配置的区域。</p>
15
15
  </script>
16
16
  <script type="text/html" data-help-name="room-gateway">
17
- <p>Template 通过同源 /room-panel-api/regions/&lt;区域 ID&gt; 登录、发送控制请求和读取反馈。Template 输出不再用于控制设备。</p>
17
+ <p>Template 通过同源 /room-panel-api/regions/&lt;区域 ID&gt; 登录和发送控制请求;ui-control-panel 登录后自动订阅该区域的实时设备反馈。Template 输出不再用于控制设备。</p>
18
18
  <p>本节点输出已授权消息,连接原来的 Switch。设备反馈必须含 room、kind: state、topic 和 payload,接入本节点输入,不接 Template。</p>
19
- <p>状态包含最近接受的目标值,实际设备反馈可覆盖。Node-RED 编辑器属于管理员入口,部署时应独立保护。生产 iPad 访问请配置 HTTPS 并启用 HTTPS Cookie。</p>
19
+ <p>公开状态只包含设备反馈,不会把刚接受的控制目标冒充实际状态。Node-RED 编辑器属于管理员入口,部署时应独立保护。生产 iPad 访问请配置 HTTPS 并启用 HTTPS Cookie。</p>
20
20
  </script>