node-red-contrib-room-tools 1.4.2 → 1.5.0
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/README.md +35 -0
- package/gateway.cjs +334 -88
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,6 +9,41 @@ One package containing three nodes:
|
|
|
9
9
|
Every room gateway uses `/room-panel-api/regions/<room-id>` so additional
|
|
10
10
|
regions follow the same URL structure.
|
|
11
11
|
|
|
12
|
+
## Control traffic policy
|
|
13
|
+
|
|
14
|
+
Version 1.5.0 uses the same control semantics at the panel, gateway and device
|
|
15
|
+
driver boundaries:
|
|
16
|
+
|
|
17
|
+
- `power`, `source`, `preset` and `mute` are discrete controls. The panel sends
|
|
18
|
+
them in order and the gateway never coalesces them.
|
|
19
|
+
- `level`, `dimmer` and `position` are continuous controls. The panel sends at
|
|
20
|
+
most 25 updates per second, overwrites an unsent value for the same
|
|
21
|
+
`controlKey`, and sends the final value again on `change`/release.
|
|
22
|
+
- The gateway independently enforces the same 40 ms latest-wins window. A
|
|
23
|
+
custom panel that produces too many slider events therefore cannot create an
|
|
24
|
+
unbounded downstream queue.
|
|
25
|
+
- Every command emitted by the gateway includes validated `controlType`,
|
|
26
|
+
`controlKey` and `final` fields. Authentication, CSRF checks, room isolation
|
|
27
|
+
and value validation still happen before a command is accepted.
|
|
28
|
+
|
|
29
|
+
The two deployed `ControlPanel` templates provide these reusable component
|
|
30
|
+
methods: `sendDiscrete`, `sendContinuous`, `flushContinuous`,
|
|
31
|
+
`scheduleContinuous` and `dispatchContinuous`. New controls should call the
|
|
32
|
+
method matching their semantic type and use a stable key that identifies the
|
|
33
|
+
room, target device and control number.
|
|
34
|
+
|
|
35
|
+
```html
|
|
36
|
+
<input
|
|
37
|
+
type="range"
|
|
38
|
+
@input="sendContinuous('level', 'audio/volume', 'rane/0/level/2', value)"
|
|
39
|
+
@change="flushContinuous('rane/0/level/2')"
|
|
40
|
+
/>
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```js
|
|
44
|
+
sendDiscrete('mute', 'audio/mute', 'rane/0/mute/1', true)
|
|
45
|
+
```
|
|
46
|
+
|
|
12
47
|
The node type names and configuration fields are unchanged from the individual
|
|
13
48
|
1.0.0 packages. No external dependencies or installation scripts are required.
|
|
14
49
|
|
package/gateway.cjs
CHANGED
|
@@ -1,109 +1,355 @@
|
|
|
1
1
|
const crypto = require('node:crypto')
|
|
2
|
+
|
|
2
3
|
const PREFIX = '/room-panel-api'
|
|
3
|
-
const
|
|
4
|
-
|
|
5
|
-
const
|
|
4
|
+
const CONTINUOUS_INTERVAL_MS = 40
|
|
5
|
+
|
|
6
|
+
const defaults = () => ({
|
|
7
|
+
scene: 'meeting', display: true, source: '无线投屏', volume: 42,
|
|
8
|
+
muted: false, light: 80, curtains: 75, temperature: 23, meeting: false
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
const fields = {
|
|
12
|
+
'display/power': 'display',
|
|
13
|
+
'display/source': 'source',
|
|
14
|
+
'audio/volume': 'volume',
|
|
15
|
+
'audio/mute': 'muted',
|
|
16
|
+
'lighting/brightness': 'light',
|
|
17
|
+
'room/curtain': 'curtains',
|
|
18
|
+
'climate/temperature': 'temperature',
|
|
19
|
+
'room/scene': 'scene',
|
|
20
|
+
'room/meeting': 'meeting'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const controlPolicies = {
|
|
24
|
+
'display/power': { type: 'power', mode: 'discrete' },
|
|
25
|
+
'display/source': { type: 'source', mode: 'discrete' },
|
|
26
|
+
'audio/volume': { type: 'level', mode: 'continuous' },
|
|
27
|
+
'audio/mute': { type: 'mute', mode: 'discrete' },
|
|
28
|
+
'lighting/brightness': { type: 'dimmer', mode: 'continuous' },
|
|
29
|
+
'room/curtain': { type: 'position', mode: 'continuous' },
|
|
30
|
+
'climate/temperature': { type: 'position', mode: 'continuous' },
|
|
31
|
+
'room/scene': { type: 'preset', mode: 'discrete' },
|
|
32
|
+
'room/meeting': { type: 'preset', mode: 'discrete' },
|
|
33
|
+
'room/reset': { type: 'preset', mode: 'discrete' }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const ranges = {
|
|
37
|
+
volume: [0, 100], light: [0, 100], curtains: [0, 100], temperature: [16, 30]
|
|
38
|
+
}
|
|
39
|
+
|
|
6
40
|
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}
|
|
41
|
+
focus: { light: 85, curtains: 80, volume: 25, display: true },
|
|
42
|
+
cinema: { light: 20, curtains: 10, volume: 65, display: true },
|
|
43
|
+
social: { light: 70, curtains: 80, volume: 40, display: true },
|
|
44
|
+
unwind: { light: 35, curtains: 80, volume: 32, display: true },
|
|
45
|
+
meeting: { light: 80, curtains: 75, volume: 42, source: '无线投屏', display: true },
|
|
46
|
+
present: { light: 30, curtains: 10, volume: 55, source: 'HDMI 1', display: true },
|
|
47
|
+
video: { light: 65, curtains: 35, volume: 65, source: '视频会议', display: true },
|
|
48
|
+
relax: { light: 45, curtains: 100, volume: 20, source: '无线投屏', display: false }
|
|
15
49
|
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
if([
|
|
19
|
-
|
|
20
|
-
|
|
50
|
+
|
|
51
|
+
function valid (key, value) {
|
|
52
|
+
if (ranges[key]) {
|
|
53
|
+
return typeof value === 'number' && Number.isFinite(value) &&
|
|
54
|
+
value >= ranges[key][0] && value <= ranges[key][1]
|
|
55
|
+
}
|
|
56
|
+
if (['display', 'muted', 'meeting'].includes(key)) return typeof value === 'boolean'
|
|
57
|
+
if (key === 'source') return ['无线投屏', 'HDMI 1', 'HDMI 2', '视频会议'].includes(value)
|
|
58
|
+
if (key === 'scene') return ['custom', ...Object.keys(presets)].includes(value)
|
|
21
59
|
return false
|
|
22
60
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
61
|
+
|
|
62
|
+
function createLatestWinsDispatcher ({
|
|
63
|
+
onDispatch,
|
|
64
|
+
intervalMs = CONTINUOUS_INTERVAL_MS,
|
|
65
|
+
now = Date.now,
|
|
66
|
+
setTimer = setTimeout,
|
|
67
|
+
clearTimer = clearTimeout
|
|
68
|
+
}) {
|
|
69
|
+
if (typeof onDispatch !== 'function') throw Error('onDispatch must be a function')
|
|
70
|
+
if (!Number.isFinite(intervalMs) || intervalMs < 0) throw Error('intervalMs must be non-negative')
|
|
71
|
+
|
|
72
|
+
const controls = new Map()
|
|
73
|
+
let closed = false
|
|
74
|
+
|
|
75
|
+
const schedule = (key, control) => {
|
|
76
|
+
if (closed || control.timer || !control.pending) return
|
|
77
|
+
const elapsed = control.lastSentAt === null ? intervalMs : now() - control.lastSentAt
|
|
78
|
+
const wait = Math.max(0, intervalMs - elapsed)
|
|
79
|
+
if (wait === 0) dispatch(key, control)
|
|
80
|
+
else control.timer = setTimer(() => dispatch(key, control), wait)
|
|
34
81
|
}
|
|
35
|
-
|
|
36
|
-
|
|
82
|
+
|
|
83
|
+
const dispatch = (key, control) => {
|
|
84
|
+
control.timer = null
|
|
85
|
+
if (closed || !control.pending) return
|
|
86
|
+
const message = control.pending
|
|
87
|
+
control.pending = null
|
|
88
|
+
control.lastSentAt = now()
|
|
89
|
+
onDispatch(message)
|
|
90
|
+
if (control.pending) schedule(key, control)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
push (key, message) {
|
|
95
|
+
if (closed) return false
|
|
96
|
+
let control = controls.get(key)
|
|
97
|
+
if (!control) {
|
|
98
|
+
control = { pending: null, timer: null, lastSentAt: null }
|
|
99
|
+
controls.set(key, control)
|
|
100
|
+
}
|
|
101
|
+
// 同一房间、设备、控制号始终最多只有一个尚未发送的连续目标。
|
|
102
|
+
control.pending = message
|
|
103
|
+
schedule(key, control)
|
|
104
|
+
return true
|
|
105
|
+
},
|
|
106
|
+
close () {
|
|
107
|
+
closed = true
|
|
108
|
+
for (const control of controls.values()) {
|
|
109
|
+
if (control.timer) clearTimer(control.timer)
|
|
110
|
+
}
|
|
111
|
+
controls.clear()
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function createGateway ({
|
|
117
|
+
username,
|
|
118
|
+
password,
|
|
119
|
+
room = 'room-01',
|
|
120
|
+
onCommand = () => {},
|
|
121
|
+
secureCookie = false,
|
|
122
|
+
prefix,
|
|
123
|
+
continuousIntervalMs = CONTINUOUS_INTERVAL_MS
|
|
124
|
+
}) {
|
|
125
|
+
const routePrefix = prefix || PREFIX + '/regions/' + encodeURIComponent(room)
|
|
126
|
+
if (!username || typeof password !== 'string' || password.length < 12) {
|
|
127
|
+
throw Error('Set a username and password of at least 12 characters')
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const salt = crypto.randomBytes(16)
|
|
131
|
+
const hash = crypto.scryptSync(password, salt, 32)
|
|
132
|
+
const sessions = new Map()
|
|
133
|
+
const attempts = new Map()
|
|
134
|
+
const continuousDispatcher = createLatestWinsDispatcher({
|
|
135
|
+
onDispatch: onCommand,
|
|
136
|
+
intervalMs: continuousIntervalMs
|
|
137
|
+
})
|
|
138
|
+
let state = defaults()
|
|
139
|
+
let revision = 0
|
|
140
|
+
let closed = false
|
|
141
|
+
|
|
142
|
+
function feedback (msg) {
|
|
143
|
+
if (msg.room !== room || msg.kind !== 'state') {
|
|
144
|
+
throw Error('Feedback requires matching room and kind=state')
|
|
145
|
+
}
|
|
146
|
+
const values = msg.topic === 'room/state'
|
|
147
|
+
? msg.payload
|
|
148
|
+
: fields[msg.topic] ? { [fields[msg.topic]]: msg.payload } : null
|
|
149
|
+
if (!values || typeof values !== 'object' || Array.isArray(values) ||
|
|
150
|
+
!Object.keys(values).length ||
|
|
151
|
+
Object.entries(values).some(([key, value]) => !valid(key, value))) {
|
|
152
|
+
throw Error('Invalid feedback')
|
|
153
|
+
}
|
|
154
|
+
state = { ...state, ...values }
|
|
155
|
+
revision++
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function reply (res, status, data, headers = {}) {
|
|
159
|
+
res.writeHead(status, {
|
|
160
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
161
|
+
'Cache-Control': 'no-store',
|
|
162
|
+
'X-Content-Type-Options': 'nosniff',
|
|
163
|
+
...headers
|
|
164
|
+
})
|
|
37
165
|
res.end(JSON.stringify(data))
|
|
38
166
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
if(req.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
167
|
+
|
|
168
|
+
async function body (req) {
|
|
169
|
+
if (!String(req.headers['content-type'] || '').startsWith('application/json')) {
|
|
170
|
+
throw Error('JSON required')
|
|
171
|
+
}
|
|
172
|
+
if (req.body !== undefined) {
|
|
173
|
+
if (Buffer.isBuffer(req.body)) return JSON.parse(req.body.toString('utf8'))
|
|
174
|
+
if (typeof req.body === 'string') return JSON.parse(req.body)
|
|
175
|
+
if (req.body && typeof req.body === 'object' && Object.keys(req.body).length) return req.body
|
|
176
|
+
}
|
|
177
|
+
let raw = ''
|
|
178
|
+
for await (const chunk of req) {
|
|
179
|
+
raw += chunk
|
|
180
|
+
if (Buffer.byteLength(raw) > 8192) throw Error('Request too large')
|
|
45
181
|
}
|
|
46
|
-
let raw='';for await(const chunk of req){raw+=chunk;if(Buffer.byteLength(raw)>8192)throw Error('Request too large')}
|
|
47
182
|
return JSON.parse(raw)
|
|
48
183
|
}
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
184
|
+
|
|
185
|
+
const cookieName = 'room_panel_' + Buffer.from(room).toString('hex')
|
|
186
|
+
const cookie = (token, maxAge) => `${cookieName}=${token}; Path=${routePrefix}; HttpOnly; SameSite=Strict; Max-Age=${maxAge}${secureCookie ? '; Secure' : ''}`
|
|
187
|
+
|
|
188
|
+
async function handler (req, res) {
|
|
189
|
+
const path = new URL(req.url, 'http://localhost').pathname
|
|
190
|
+
if (!path.startsWith(routePrefix + '/')) return false
|
|
191
|
+
if (closed) {
|
|
192
|
+
reply(res, 503, { error: 'Gateway unavailable' })
|
|
193
|
+
return true
|
|
194
|
+
}
|
|
195
|
+
|
|
55
196
|
try {
|
|
56
|
-
const now=Date.now()
|
|
57
|
-
for(const [id,
|
|
58
|
-
for(const [ip,
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
197
|
+
const now = Date.now()
|
|
198
|
+
for (const [id, session] of sessions) if (session.expiry < now) sessions.delete(id)
|
|
199
|
+
for (const [ip, attempt] of attempts) if (attempt.until < now) attempts.delete(ip)
|
|
200
|
+
|
|
201
|
+
if (req.method === 'POST') {
|
|
202
|
+
const origin = req.headers.origin
|
|
203
|
+
if (!origin || new URL(origin).host !== req.headers.host) {
|
|
204
|
+
reply(res, 403, { error: 'Origin rejected' })
|
|
205
|
+
return true
|
|
206
|
+
}
|
|
62
207
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
entry
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
208
|
+
|
|
209
|
+
if (path === routePrefix + '/login' && req.method === 'POST') {
|
|
210
|
+
const ip = req.socket.remoteAddress || 'unknown'
|
|
211
|
+
const entry = attempts.get(ip) || { count: 0, until: now + 60000 }
|
|
212
|
+
if (entry.count >= 10 || attempts.size > 1000) {
|
|
213
|
+
reply(res, 429, { error: 'Too many attempts. Wait one minute.' })
|
|
214
|
+
return true
|
|
215
|
+
}
|
|
216
|
+
entry.count++
|
|
217
|
+
attempts.set(ip, entry)
|
|
218
|
+
const data = await body(req)
|
|
219
|
+
const supplied = String(data.password || '')
|
|
220
|
+
if (supplied.length > 256) {
|
|
221
|
+
reply(res, 401, { error: 'Invalid credentials' })
|
|
222
|
+
return true
|
|
223
|
+
}
|
|
224
|
+
const test = await new Promise((resolve, reject) => {
|
|
225
|
+
crypto.scrypt(supplied, salt, 32, (error, key) => error ? reject(error) : resolve(key))
|
|
226
|
+
})
|
|
227
|
+
const passwordMatch = crypto.timingSafeEqual(test, hash)
|
|
228
|
+
const usernameMatch = data.username === username
|
|
229
|
+
if (!passwordMatch || !usernameMatch) {
|
|
230
|
+
reply(res, 401, { error: 'Invalid credentials' })
|
|
231
|
+
return true
|
|
232
|
+
}
|
|
233
|
+
if (sessions.size >= 100) {
|
|
234
|
+
reply(res, 503, { error: 'Session limit reached' })
|
|
235
|
+
return true
|
|
236
|
+
}
|
|
74
237
|
attempts.delete(ip)
|
|
75
|
-
const token=crypto.randomBytes(32).toString('hex')
|
|
76
|
-
|
|
77
|
-
|
|
238
|
+
const token = crypto.randomBytes(32).toString('hex')
|
|
239
|
+
const csrf = crypto.randomBytes(24).toString('hex')
|
|
240
|
+
sessions.set(token, { csrf, expiry: now + 8 * 3600000 })
|
|
241
|
+
reply(res, 200, { room, username, csrf }, { 'Set-Cookie': cookie(token, 28800) })
|
|
242
|
+
return true
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const token = String(req.headers.cookie || '').split(';')
|
|
246
|
+
.map(value => value.trim())
|
|
247
|
+
.find(value => value.startsWith(cookieName + '='))
|
|
248
|
+
?.split('=')[1]
|
|
249
|
+
const session = sessions.get(token)
|
|
250
|
+
if (!session) {
|
|
251
|
+
reply(res, 401, { error: 'Login required' })
|
|
252
|
+
return true
|
|
253
|
+
}
|
|
254
|
+
if (req.method === 'POST' && req.headers['x-room-csrf'] !== session.csrf) {
|
|
255
|
+
reply(res, 403, { error: 'CSRF rejected' })
|
|
256
|
+
return true
|
|
78
257
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
258
|
+
if (path === routePrefix + '/session' && req.method === 'GET') {
|
|
259
|
+
reply(res, 200, { room, username, csrf: session.csrf })
|
|
260
|
+
return true
|
|
87
261
|
}
|
|
88
|
-
if(path===routePrefix+'/
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
262
|
+
if (path === routePrefix + '/state' && req.method === 'GET') {
|
|
263
|
+
if (new URL(req.url, 'http://localhost').searchParams.get('room') !== room) {
|
|
264
|
+
reply(res, 403, { error: 'Region denied' })
|
|
265
|
+
return true
|
|
266
|
+
}
|
|
267
|
+
reply(res, 200, { room, kind: 'state', topic: 'room/state', payload: state, revision })
|
|
268
|
+
return true
|
|
102
269
|
}
|
|
103
|
-
|
|
104
|
-
|
|
270
|
+
if (path === routePrefix + '/logout' && req.method === 'POST') {
|
|
271
|
+
sessions.delete(token)
|
|
272
|
+
reply(res, 200, { ok: true }, { 'Set-Cookie': cookie('', 0) })
|
|
273
|
+
return true
|
|
274
|
+
}
|
|
275
|
+
if (path === routePrefix + '/command' && req.method === 'POST') {
|
|
276
|
+
const data = await body(req)
|
|
277
|
+
if (data.room !== room) {
|
|
278
|
+
reply(res, 403, { error: 'Region denied' })
|
|
279
|
+
return true
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const key = fields[data.topic]
|
|
283
|
+
const policy = controlPolicies[data.topic]
|
|
284
|
+
const invalidValue = data.topic === 'room/reset'
|
|
285
|
+
? data.payload !== true
|
|
286
|
+
: !key || !valid(key, data.payload) || (key === 'scene' && !presets[data.payload])
|
|
287
|
+
const invalidType = data.controlType !== undefined && data.controlType !== policy?.type
|
|
288
|
+
const controlKey = data.controlKey === undefined ? data.topic : data.controlKey
|
|
289
|
+
const invalidControlKey = typeof controlKey !== 'string' ||
|
|
290
|
+
!/^[a-zA-Z0-9._:/-]{1,160}$/.test(controlKey)
|
|
291
|
+
if (!policy || invalidValue || invalidType || invalidControlKey ||
|
|
292
|
+
(data.final !== undefined && typeof data.final !== 'boolean')) {
|
|
293
|
+
reply(res, 400, { error: 'Invalid control value' })
|
|
294
|
+
return true
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
let next = { ...state }
|
|
298
|
+
if (data.topic === 'room/reset') next = defaults()
|
|
299
|
+
else if (key === 'scene') next = { ...next, ...presets[data.payload], scene: data.payload }
|
|
300
|
+
else {
|
|
301
|
+
next[key] = data.payload
|
|
302
|
+
if (['light', 'curtains'].includes(key)) next.scene = 'custom'
|
|
303
|
+
if (key === 'meeting' && data.payload) next.display = true
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const command = {
|
|
307
|
+
room,
|
|
308
|
+
topic: data.topic,
|
|
309
|
+
payload: data.payload,
|
|
310
|
+
kind: 'command',
|
|
311
|
+
controlType: policy.type,
|
|
312
|
+
controlKey,
|
|
313
|
+
final: data.final === true,
|
|
314
|
+
user: username,
|
|
315
|
+
state: next
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (policy.mode === 'continuous') {
|
|
319
|
+
continuousDispatcher.push(room + ':' + controlKey, command)
|
|
320
|
+
} else {
|
|
321
|
+
onCommand(command)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
state = next
|
|
325
|
+
revision++
|
|
326
|
+
reply(res, 200, { ok: true, revision })
|
|
327
|
+
return true
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
reply(res, 404, { error: 'Not found' })
|
|
331
|
+
} catch (error) {
|
|
332
|
+
reply(res, 400, { error: 'Invalid request' })
|
|
333
|
+
}
|
|
105
334
|
return true
|
|
106
335
|
}
|
|
107
|
-
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
handler,
|
|
339
|
+
feedback,
|
|
340
|
+
close () {
|
|
341
|
+
closed = true
|
|
342
|
+
continuousDispatcher.close()
|
|
343
|
+
sessions.clear()
|
|
344
|
+
attempts.clear()
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
module.exports = {
|
|
350
|
+
CONTINUOUS_INTERVAL_MS,
|
|
351
|
+
PREFIX,
|
|
352
|
+
controlPolicies,
|
|
353
|
+
createGateway,
|
|
354
|
+
createLatestWinsDispatcher
|
|
108
355
|
}
|
|
109
|
-
module.exports={createGateway,PREFIX}
|
package/package.json
CHANGED