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/CONTROL_PANEL_AI_GUIDE.md +220 -0
- package/README.md +85 -0
- package/control-panel.html +36 -12
- package/control-panel.js +13 -1
- package/control-policy.js +449 -0
- package/gateway.cjs +429 -89
- package/package.json +2 -2
- package/room-gateway.html +2 -2
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
|
|
4
|
-
const
|
|
5
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
if([
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
if(req.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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,
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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(
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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')
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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'){
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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.
|
|
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/<区域 ID>
|
|
17
|
+
<p>Template 通过同源 /room-panel-api/regions/<区域 ID> 登录和发送控制请求;ui-control-panel 登录后自动订阅该区域的实时设备反馈。Template 输出不再用于控制设备。</p>
|
|
18
18
|
<p>本节点输出已授权消息,连接原来的 Switch。设备反馈必须含 room、kind: state、topic 和 payload,接入本节点输入,不接 Template。</p>
|
|
19
|
-
<p
|
|
19
|
+
<p>公开状态只包含设备反馈,不会把刚接受的控制目标冒充实际状态。Node-RED 编辑器属于管理员入口,部署时应独立保护。生产 iPad 访问请配置 HTTPS 并启用 HTTPS Cookie。</p>
|
|
20
20
|
</script>
|