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.
Files changed (3) hide show
  1. package/README.md +35 -0
  2. package/gateway.cjs +334 -88
  3. 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 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
+
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
- 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)
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
- 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++
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
- 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})
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
- 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
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
- 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}
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,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}
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
- 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}
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'),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
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
- 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
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+'/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
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
- reply(res,404,{error:'Not found'})
104
- }catch(err){reply(res,400,{error:'Invalid request'})}
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
- return {handler,feedback,close(){closed=true;sessions.clear();attempts.clear()}}
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-room-tools",
3
- "version": "1.4.2",
3
+ "version": "1.5.0",
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"],