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.
@@ -0,0 +1,449 @@
1
+ const CONTROL_POLICY_MARKER = '/* ui-control-panel-control-api-v1 */'
2
+ const FEEDBACK_TOPIC_FIELDS = Object.freeze({
3
+ 'display/power': 'display',
4
+ 'display/source': 'source',
5
+ 'audio/volume': 'volume',
6
+ 'audio/mute': 'muted',
7
+ 'lighting/brightness': 'light',
8
+ 'room/curtain': 'curtains',
9
+ 'climate/temperature': 'temperature',
10
+ 'room/scene': 'scene',
11
+ 'room/meeting': 'meeting'
12
+ })
13
+
14
+ function normalizeControlFeedback (message, expectedRoom, topicFields) {
15
+ if (!message || typeof message !== 'object' || Array.isArray(message)) {
16
+ throw new Error('Control feedback must be an object')
17
+ }
18
+ if (message.kind !== 'state') throw new Error('Control feedback must have kind=state')
19
+ if (expectedRoom && message.room !== expectedRoom) throw new Error('Control feedback room mismatch')
20
+ if (typeof message.topic !== 'string' || !message.topic) throw new Error('Control feedback needs a topic')
21
+
22
+ let state = message.state && typeof message.state === 'object' && !Array.isArray(message.state)
23
+ ? { ...message.state }
24
+ : {}
25
+ if (message.topic === 'room/state') {
26
+ if (!message.payload || typeof message.payload !== 'object' || Array.isArray(message.payload)) {
27
+ throw new Error('room/state feedback needs an object payload')
28
+ }
29
+ state = { ...state, ...message.payload }
30
+ } else if (topicFields[message.topic]) {
31
+ state[topicFields[message.topic]] = message.payload
32
+ }
33
+
34
+ return {
35
+ room: message.room,
36
+ kind: 'state',
37
+ topic: message.topic,
38
+ payload: message.payload,
39
+ state,
40
+ revision: Number.isInteger(message.revision) && message.revision >= 0 ? message.revision : null,
41
+ controlKey: typeof message.controlKey === 'string' ? message.controlKey : null
42
+ }
43
+ }
44
+
45
+ function createControlPanelPolicy ({
46
+ dispatch,
47
+ intervalMs = 40,
48
+ now = () => Date.now(),
49
+ setTimer = setTimeout,
50
+ clearTimer = clearTimeout,
51
+ onError = () => {}
52
+ }) {
53
+ if (typeof dispatch !== 'function') throw new Error('ControlPanel dispatch function is required')
54
+ if (!Number.isFinite(intervalMs) || intervalMs < 0) throw new Error('ControlPanel interval must be a non-negative number')
55
+
56
+ const continuousTypes = new Set(['level', 'dimmer', 'position'])
57
+ const discreteTypes = new Set(['power', 'source', 'preset', 'mute'])
58
+ const continuousControls = new Map()
59
+ let discreteQueue = Promise.resolve()
60
+ let disposed = false
61
+
62
+ const reportError = (error, command) => {
63
+ try {
64
+ onError(error, command)
65
+ } catch (_) {}
66
+ }
67
+
68
+ const normalize = (command) => {
69
+ if (!command || typeof command !== 'object' || Array.isArray(command)) {
70
+ throw new Error('Control command must be an object')
71
+ }
72
+ const type = String(command.type ?? command.controlType ?? '').trim().toLowerCase()
73
+ const topic = String(command.topic ?? '').trim()
74
+ const key = String(command.key ?? command.controlKey ?? '').trim()
75
+ const hasValue = Object.prototype.hasOwnProperty.call(command, 'value') ||
76
+ Object.prototype.hasOwnProperty.call(command, 'payload')
77
+
78
+ if (!continuousTypes.has(type) && !discreteTypes.has(type)) {
79
+ throw new Error('Unsupported control type: ' + type)
80
+ }
81
+ if (!topic) throw new Error('Control command needs a topic')
82
+ if (!/^[a-zA-Z0-9._:/-]{1,160}$/.test(key)) throw new Error('Control command needs a valid key')
83
+ if (!hasValue) throw new Error('Control command needs a value')
84
+
85
+ return {
86
+ topic,
87
+ payload: Object.prototype.hasOwnProperty.call(command, 'value') ? command.value : command.payload,
88
+ controlType: type,
89
+ controlKey: key,
90
+ final: command.final === true
91
+ }
92
+ }
93
+
94
+ const continuousControl = (key) => {
95
+ let control = continuousControls.get(key)
96
+ if (!control) {
97
+ control = {
98
+ inFlight: false,
99
+ lastCommand: null,
100
+ lastSentAt: -intervalMs,
101
+ latest: null,
102
+ timer: null
103
+ }
104
+ continuousControls.set(key, control)
105
+ }
106
+ return control
107
+ }
108
+
109
+ const dispatchContinuous = async (key) => {
110
+ const control = continuousControl(key)
111
+ if (control.inFlight || !control.latest) return
112
+
113
+ const command = control.latest
114
+ control.latest = null
115
+ control.inFlight = true
116
+ try {
117
+ await dispatch(command)
118
+ } catch (error) {
119
+ reportError(error, command)
120
+ } finally {
121
+ control.lastSentAt = now()
122
+ control.inFlight = false
123
+ if (!disposed && control.latest) scheduleContinuous(key, control.latest.final)
124
+ }
125
+ }
126
+
127
+ const scheduleContinuous = (key, force) => {
128
+ const control = continuousControl(key)
129
+ if (control.inFlight || !control.latest) return
130
+ if (force && control.timer) {
131
+ clearTimer(control.timer)
132
+ control.timer = null
133
+ }
134
+ if (control.timer) return
135
+
136
+ const delay = force ? 0 : Math.max(0, intervalMs - (now() - control.lastSentAt))
137
+ if (delay === 0) {
138
+ void dispatchContinuous(key)
139
+ return
140
+ }
141
+ control.timer = setTimer(() => {
142
+ control.timer = null
143
+ void dispatchContinuous(key)
144
+ }, delay)
145
+ }
146
+
147
+ const sendDiscrete = (command) => {
148
+ discreteQueue = discreteQueue
149
+ .catch(() => undefined)
150
+ .then(() => disposed ? undefined : dispatch(command))
151
+ .catch((error) => reportError(error, command))
152
+ return discreteQueue
153
+ }
154
+
155
+ return {
156
+ send (command) {
157
+ if (disposed) return Promise.resolve()
158
+ const normalized = normalize(command)
159
+ if (continuousTypes.has(normalized.controlType)) {
160
+ const control = continuousControl(normalized.controlKey)
161
+ control.lastCommand = normalized
162
+ control.latest = normalized
163
+ scheduleContinuous(normalized.controlKey, normalized.final)
164
+ return Promise.resolve()
165
+ }
166
+ return sendDiscrete(normalized)
167
+ },
168
+
169
+ flush (key) {
170
+ const control = continuousControls.get(String(key))
171
+ if (disposed || !control || !control.lastCommand) return Promise.resolve()
172
+ control.latest = { ...control.lastCommand, final: true }
173
+ scheduleContinuous(control.latest.controlKey, true)
174
+ return Promise.resolve()
175
+ },
176
+
177
+ dispose () {
178
+ disposed = true
179
+ for (const control of continuousControls.values()) {
180
+ if (control.timer) clearTimer(control.timer)
181
+ control.latest = null
182
+ }
183
+ continuousControls.clear()
184
+ }
185
+ }
186
+ }
187
+
188
+ function buildControlPolicyMixinSource () {
189
+ return `
190
+ ${CONTROL_POLICY_MARKER}
191
+ const __controlPanelCreatePolicy = ${createControlPanelPolicy.toString()}
192
+ const __controlPanelFeedbackFields = ${JSON.stringify(FEEDBACK_TOPIC_FIELDS)}
193
+ const __controlPanelNormalizeFeedback = ${normalizeControlFeedback.toString()}
194
+ const __controlPanelPolicyMixin = {
195
+ data () {
196
+ return {
197
+ deviceState: {},
198
+ deviceFeedbackRevision: 0,
199
+ deviceFeedbackConnected: false,
200
+ controlPending: {},
201
+ controlTargets: {}
202
+ }
203
+ },
204
+ created () {
205
+ this.__controlPanelSession = null
206
+ this.__controlPanelFeedbackSource = null
207
+ this.__controlPanelRequests = new Map()
208
+ this.__resetControlPolicy()
209
+ },
210
+ methods: {
211
+ __resetControlPolicy () {
212
+ this.__controlPanelPolicy?.dispose()
213
+ this.__controlPanelPolicy = __controlPanelCreatePolicy({
214
+ intervalMs: 40,
215
+ dispatch: (command) => this.__dispatchControl(command),
216
+ onError: (error, command) => {
217
+ if (command?.controlKey) this.__clearControlPending(command.controlKey)
218
+ if (typeof this.onControlError === 'function') this.onControlError(error, command)
219
+ else console.error('ControlPanel command failed', error)
220
+ }
221
+ })
222
+ },
223
+ __setControlPending (key, pending) {
224
+ const next = { ...this.controlPending }
225
+ if (pending) next[key] = true
226
+ else delete next[key]
227
+ this.controlPending = next
228
+ },
229
+ __setControlTarget (key, value) {
230
+ this.controlTargets = { ...this.controlTargets, [key]: value }
231
+ },
232
+ __clearControlPending (key) {
233
+ this.__controlPanelRequests.delete(String(key))
234
+ this.__setControlPending(String(key), false)
235
+ const targets = { ...this.controlTargets }
236
+ delete targets[String(key)]
237
+ this.controlTargets = targets
238
+ },
239
+ __setFeedbackConnected (connected) {
240
+ const next = connected === true
241
+ if (this.deviceFeedbackConnected === next) return
242
+ this.deviceFeedbackConnected = next
243
+ if (typeof this.onControlFeedbackStatus === 'function') this.onControlFeedbackStatus(next)
244
+ },
245
+ __closeControlFeedback () {
246
+ this.__controlPanelFeedbackSource?.close()
247
+ this.__controlPanelFeedbackSource = null
248
+ this.__setFeedbackConnected(false)
249
+ },
250
+ __connectControlFeedback () {
251
+ this.__closeControlFeedback()
252
+ const session = this.__controlPanelSession
253
+ if (!session || typeof EventSource === 'undefined') return
254
+ const source = new EventSource('/room-panel-api/regions/' + encodeURIComponent(session.room) + '/events')
255
+ this.__controlPanelFeedbackSource = source
256
+ source.onopen = () => {
257
+ if (this.__controlPanelFeedbackSource === source) this.__setFeedbackConnected(true)
258
+ }
259
+ source.onmessage = (event) => {
260
+ if (this.__controlPanelFeedbackSource !== source) return
261
+ try {
262
+ this.applyControlFeedback(JSON.parse(event.data))
263
+ } catch (error) {
264
+ if (typeof this.onControlError === 'function') this.onControlError(error)
265
+ else console.error('ControlPanel feedback failed', error)
266
+ }
267
+ }
268
+ source.onerror = () => {
269
+ if (this.__controlPanelFeedbackSource === source) this.__setFeedbackConnected(false)
270
+ }
271
+ },
272
+ __acknowledgeControlFeedback (feedback) {
273
+ for (const [key, request] of this.__controlPanelRequests) {
274
+ if (feedback.controlKey && feedback.controlKey !== key) continue
275
+ let matched = request.topic === 'room/reset'
276
+ let value
277
+ if (!matched && feedback.topic === request.topic) {
278
+ matched = true
279
+ value = feedback.payload
280
+ } else if (!matched && feedback.topic === 'room/state') {
281
+ const field = __controlPanelFeedbackFields[request.topic]
282
+ if (field && Object.prototype.hasOwnProperty.call(feedback.state, field)) {
283
+ matched = true
284
+ value = feedback.state[field]
285
+ }
286
+ }
287
+ if (matched && (request.topic === 'room/reset' || Object.is(value, request.value))) {
288
+ this.__clearControlPending(key)
289
+ }
290
+ }
291
+ },
292
+ setControlSession (session) {
293
+ if (!session || typeof session !== 'object' || !session.room || !session.csrf) {
294
+ throw new Error('ControlPanel session needs room and csrf')
295
+ }
296
+ const next = { room: String(session.room), csrf: String(session.csrf) }
297
+ if (this.__controlPanelSession &&
298
+ (this.__controlPanelSession.room !== next.room || this.__controlPanelSession.csrf !== next.csrf)) {
299
+ this.__resetControlPolicy()
300
+ this.__controlPanelRequests.clear()
301
+ this.controlPending = {}
302
+ this.controlTargets = {}
303
+ this.deviceState = {}
304
+ this.deviceFeedbackRevision = 0
305
+ }
306
+ this.__controlPanelSession = next
307
+ this.__connectControlFeedback()
308
+ },
309
+ clearControlSession () {
310
+ this.__closeControlFeedback()
311
+ this.__controlPanelSession = null
312
+ this.__resetControlPolicy()
313
+ this.__controlPanelRequests.clear()
314
+ this.controlPending = {}
315
+ this.controlTargets = {}
316
+ this.deviceState = {}
317
+ this.deviceFeedbackRevision = 0
318
+ },
319
+ sendControl (command) {
320
+ const key = String(command?.key ?? command?.controlKey ?? '')
321
+ const type = String(command?.type ?? command?.controlType ?? '').toLowerCase()
322
+ const value = Object.prototype.hasOwnProperty.call(command || {}, 'value') ? command.value : command?.payload
323
+ if (key) {
324
+ this.__controlPanelRequests.set(key, {
325
+ topic: String(command.topic ?? ''),
326
+ type,
327
+ value
328
+ })
329
+ this.__setControlPending(key, true)
330
+ if (['level', 'dimmer', 'position'].includes(type)) this.__setControlTarget(key, value)
331
+ }
332
+ try {
333
+ return this.__controlPanelPolicy.send(command)
334
+ } catch (error) {
335
+ if (key) this.__clearControlPending(key)
336
+ throw error
337
+ }
338
+ },
339
+ flushControl (key) {
340
+ return this.__controlPanelPolicy.flush(key)
341
+ },
342
+ applyControlFeedback (message) {
343
+ const room = this.__controlPanelSession?.room || this.authorizedRoom
344
+ const feedback = __controlPanelNormalizeFeedback(message, room, __controlPanelFeedbackFields)
345
+ this.deviceState = { ...this.deviceState, ...feedback.state }
346
+ if (feedback.revision !== null) this.deviceFeedbackRevision = feedback.revision
347
+ this.__acknowledgeControlFeedback(feedback)
348
+ if (typeof this.onDeviceFeedback === 'function') this.onDeviceFeedback(feedback, this.deviceState)
349
+ return feedback
350
+ },
351
+ hasDeviceState (field) {
352
+ return Object.prototype.hasOwnProperty.call(this.deviceState, String(field))
353
+ },
354
+ deviceValue (field, fallback = null) {
355
+ return this.hasDeviceState(field) ? this.deviceState[String(field)] : fallback
356
+ },
357
+ isControlPending (key) {
358
+ return this.controlPending[String(key)] === true
359
+ },
360
+ controlDisplayValue (key, field, fallback = null) {
361
+ const controlKey = String(key)
362
+ if (this.isControlPending(controlKey) &&
363
+ Object.prototype.hasOwnProperty.call(this.controlTargets, controlKey)) {
364
+ return this.controlTargets[controlKey]
365
+ }
366
+ return this.deviceValue(field, fallback)
367
+ },
368
+ async __dispatchControl (command) {
369
+ if (typeof this.controlTransport === 'function') return this.controlTransport(command)
370
+
371
+ const session = this.__controlPanelSession
372
+ const room = session?.room || this.authorizedRoom
373
+ if (typeof this.postControl === 'function') {
374
+ return this.postControl(room ? { ...command, room } : command, this.sessionGeneration)
375
+ }
376
+ if (!session) throw new Error('Call setControlSession() after login, or define controlTransport(command)')
377
+
378
+ const response = await fetch('/room-panel-api/regions/' + encodeURIComponent(session.room) + '/command', {
379
+ method: 'POST',
380
+ credentials: 'same-origin',
381
+ headers: { 'Content-Type': 'application/json', 'X-Room-CSRF': session.csrf },
382
+ body: JSON.stringify({ room: session.room, ...command }),
383
+ signal: AbortSignal.timeout(8000)
384
+ })
385
+ if (!response.ok) throw new Error('Control command failed (' + response.status + ')')
386
+ return response
387
+ }
388
+ },
389
+ unmounted () {
390
+ this.__closeControlFeedback()
391
+ this.__controlPanelPolicy?.dispose()
392
+ this.__controlPanelPolicy = null
393
+ this.__controlPanelRequests?.clear()
394
+ this.controlPending = {}
395
+ this.controlTargets = {}
396
+ this.__controlPanelSession = null
397
+ }
398
+ }
399
+ `
400
+ }
401
+
402
+ function injectControlPolicy (format) {
403
+ if (typeof format !== 'string' || format.includes(CONTROL_POLICY_MARKER)) return format
404
+
405
+ const scriptMatch = /<script(?!\s+setup\b)(?:\s[^>]*)?>/i.exec(format)
406
+ if (!scriptMatch) {
407
+ const setupMatch = /<script\s+setup\b([^>]*)>/i.exec(format)
408
+ if (!setupMatch && !/<template(?:\s|>)/i.test(format)) return format
409
+ const langMatch = setupMatch?.[1]?.match(/\blang\s*=\s*(["'])([^"']+)\1/i)
410
+ const scriptOpen = langMatch ? '<script lang="' + langMatch[2] + '">' : '<script>'
411
+ return format + '\n' + scriptOpen + '\n' + buildControlPolicyMixinSource() +
412
+ '\nexport default { mixins: [__controlPanelPolicyMixin] }\n</script>\n'
413
+ }
414
+
415
+ const scriptStart = scriptMatch.index + scriptMatch[0].length
416
+ const scriptEnd = format.indexOf('</script>', scriptStart)
417
+ if (scriptEnd === -1) return format
418
+
419
+ const source = format.slice(scriptStart, scriptEnd)
420
+ const exportMatch = /(^|\n)([ \t]*)export\s+default\b/.exec(source)
421
+ if (!exportMatch) {
422
+ const wrapped = source + '\n' + buildControlPolicyMixinSource() +
423
+ '\nexport default { mixins: [__controlPanelPolicyMixin] }\n'
424
+ return format.slice(0, scriptStart) + wrapped + format.slice(scriptEnd)
425
+ }
426
+
427
+ const exportStart = exportMatch.index + exportMatch[1].length + exportMatch[2].length
428
+ const exportEnd = exportMatch.index + exportMatch[0].length
429
+ const userOptions = source.slice(0, exportStart) +
430
+ 'const __controlPanelUserOptions =' +
431
+ source.slice(exportEnd)
432
+ const wrapped = userOptions + '\n' + buildControlPolicyMixinSource() + `
433
+ export default {
434
+ ...__controlPanelUserOptions,
435
+ mixins: [...(__controlPanelUserOptions.mixins || []), __controlPanelPolicyMixin]
436
+ }
437
+ `
438
+
439
+ return format.slice(0, scriptStart) + wrapped + format.slice(scriptEnd)
440
+ }
441
+
442
+ module.exports = {
443
+ CONTROL_POLICY_MARKER,
444
+ FEEDBACK_TOPIC_FIELDS,
445
+ buildControlPolicyMixinSource,
446
+ createControlPanelPolicy,
447
+ injectControlPolicy,
448
+ normalizeControlFeedback
449
+ }