node-red-contrib-room-tools 1.8.1 → 1.8.3-visual-binding.17
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 +55 -215
- package/README.md +10 -145
- package/assets/panel-login-bg.png +0 -0
- package/control-bound-protocol.js +97 -0
- package/control-catalog.js +97 -0
- package/control-panel.html +82 -86
- package/control-panel.js +237 -10
- package/control-policy.js +285 -11
- package/control-type-contract.json +17 -0
- package/control-type-model.js +63 -0
- package/gateway.cjs +77 -5
- package/managed-bridge-admin.js +90 -0
- package/managed-bridge-revision.js +32 -0
- package/managed-native-bridge.js +124 -0
- package/native-binding-plan.js +177 -0
- package/package.json +27 -5
- package/panel-binding-editor.js +771 -0
- package/panel-editor-auth.js +151 -0
- package/panel-template-instrumentation.js +267 -0
- package/panel-visual-apply.js +127 -0
- package/panel-visual-bindings.js +95 -0
- package/room-gateway.html +0 -21
- package/room-gateway.js +0 -57
- package/room-message.html +0 -56
- package/room-message.js +0 -43
package/control-policy.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const acorn = require('acorn')
|
|
2
2
|
const escodegen = require('escodegen')
|
|
3
|
+
const { initializeVisualBinding, disposeVisualBinding } = require('./panel-binding-editor')
|
|
3
4
|
|
|
4
5
|
const CONTROL_POLICY_MARKER = '/* ui-control-panel-control-api-v1 */'
|
|
5
6
|
const FEEDBACK_TOPIC_FIELDS = Object.freeze({
|
|
@@ -57,7 +58,7 @@ function createControlPanelPolicy ({
|
|
|
57
58
|
if (!Number.isFinite(intervalMs) || intervalMs < 0) throw new Error('ControlPanel interval must be a non-negative number')
|
|
58
59
|
|
|
59
60
|
const continuousTypes = new Set(['level', 'dimmer', 'position'])
|
|
60
|
-
const discreteTypes = new Set(['power', 'source', 'preset', 'mute'])
|
|
61
|
+
const discreteTypes = new Set(['power', 'source', 'preset', 'mute', 'text'])
|
|
61
62
|
const continuousControls = new Map()
|
|
62
63
|
let discreteQueue = Promise.resolve()
|
|
63
64
|
let disposed = false
|
|
@@ -274,7 +275,6 @@ const __controlPanelPolicyMixin = {
|
|
|
274
275
|
},
|
|
275
276
|
__acknowledgeControlFeedback (feedback) {
|
|
276
277
|
for (const [key, request] of this.__controlPanelRequests) {
|
|
277
|
-
if (feedback.controlKey && feedback.controlKey !== key) continue
|
|
278
278
|
let matched = request.topic === 'room/reset'
|
|
279
279
|
let value
|
|
280
280
|
if (!matched && feedback.topic === request.topic) {
|
|
@@ -287,7 +287,15 @@ const __controlPanelPolicyMixin = {
|
|
|
287
287
|
value = feedback.state[field]
|
|
288
288
|
}
|
|
289
289
|
}
|
|
290
|
-
if (matched
|
|
290
|
+
if (!matched) continue
|
|
291
|
+
|
|
292
|
+
const sameTopicRequests = [...this.__controlPanelRequests.values()]
|
|
293
|
+
.filter(candidate => candidate.topic === request.topic).length
|
|
294
|
+
const correlated = !feedback.controlKey || feedback.controlKey === key || sameTopicRequests === 1
|
|
295
|
+
if (!correlated) continue
|
|
296
|
+
|
|
297
|
+
const continuous = ['level', 'dimmer', 'position'].includes(request.type)
|
|
298
|
+
if (!continuous || request.topic === 'room/reset' || Object.is(value, request.value)) {
|
|
291
299
|
this.__clearControlPending(key)
|
|
292
300
|
}
|
|
293
301
|
}
|
|
@@ -406,11 +414,27 @@ const __controlPanelPolicyMixin = {
|
|
|
406
414
|
// as component options when the script contains one `export default { ... }`
|
|
407
415
|
// statement. Keep every helper inside data, methods, or lifecycle hooks so the
|
|
408
416
|
// generated script remains inside that supported subset.
|
|
409
|
-
function buildDashboardPolicyOptionsSource () {
|
|
417
|
+
function buildDashboardPolicyOptionsSource (controls = [], boundPanel = null) {
|
|
418
|
+
const boundControls = controls.map(({ id, type, mode }) => ({ id, type, mode }))
|
|
419
|
+
const visualMethods = boundPanel?.visual
|
|
420
|
+
? `__initializeVisualBinding: ${initializeVisualBinding.toString()},
|
|
421
|
+
__disposeVisualBinding: ${disposeVisualBinding.toString()},`
|
|
422
|
+
: ''
|
|
423
|
+
const visualMounted = boundPanel?.visual
|
|
424
|
+
? 'this.$nextTick(() => this.__initializeVisualBinding())'
|
|
425
|
+
: ''
|
|
426
|
+
const visualUnmounted = boundPanel?.visual ? 'this.__disposeVisualBinding()' : ''
|
|
410
427
|
return `
|
|
411
428
|
export default {
|
|
412
429
|
data () {
|
|
413
430
|
return {
|
|
431
|
+
boundState: {},
|
|
432
|
+
boundPending: {},
|
|
433
|
+
boundTargets: {},
|
|
434
|
+
boundUnconfirmed: {},
|
|
435
|
+
boundFeedbackRevision: 0,
|
|
436
|
+
boundFeedbackConnected: false,
|
|
437
|
+
boundAuthenticated: false,
|
|
414
438
|
deviceState: {},
|
|
415
439
|
deviceFeedbackRevision: 0,
|
|
416
440
|
deviceFeedbackConnected: false,
|
|
@@ -419,6 +443,117 @@ export default {
|
|
|
419
443
|
}
|
|
420
444
|
},
|
|
421
445
|
methods: {
|
|
446
|
+
${visualMethods}
|
|
447
|
+
__boundPanelConfig () {
|
|
448
|
+
return ${JSON.stringify(boundPanel)}
|
|
449
|
+
},
|
|
450
|
+
__boundControls () {
|
|
451
|
+
return ${JSON.stringify(boundControls)}
|
|
452
|
+
},
|
|
453
|
+
__boundControl (id) {
|
|
454
|
+
return this.__boundControls().find(control => control.id === id)
|
|
455
|
+
},
|
|
456
|
+
applyBoundFeedback (message) {
|
|
457
|
+
if (['bound-snapshot', 'bound-feedback'].includes(message?.kind)) {
|
|
458
|
+
if (message.room !== this.__boundPanelConfig()?.room || !Array.isArray(message.items) ||
|
|
459
|
+
!Number.isInteger(message.revision) || message.revision < this.boundFeedbackRevision) return false
|
|
460
|
+
if (message.kind === 'bound-snapshot') this.boundState = {}
|
|
461
|
+
this.boundFeedbackRevision = message.revision
|
|
462
|
+
return message.items.map(item => this.applyBoundFeedback({
|
|
463
|
+
__boundRevision: message.revision,
|
|
464
|
+
_controlPanel: { kind: 'state', id: item.id, value: item.value }
|
|
465
|
+
}))
|
|
466
|
+
}
|
|
467
|
+
const state = message?._controlPanel
|
|
468
|
+
if (state?.kind === 'states' && Array.isArray(state.items)) {
|
|
469
|
+
return state.items.map(item => this.applyBoundFeedback({ _controlPanel: { kind: 'state', ...item } }))
|
|
470
|
+
}
|
|
471
|
+
if (state?.kind !== 'state' || !this.__boundControl(state.id) ||
|
|
472
|
+
!Object.prototype.hasOwnProperty.call(state, 'value')) return false
|
|
473
|
+
this.boundState = { ...this.boundState, [state.id]: state.value }
|
|
474
|
+
const control = this.__boundControl(state.id)
|
|
475
|
+
if (!['level', 'dimmer', 'position'].includes(control.type) ||
|
|
476
|
+
Object.is(this.boundTargets[state.id], state.value)) {
|
|
477
|
+
this.__clearBoundPending(state.id)
|
|
478
|
+
} else {
|
|
479
|
+
const settlement = this.__boundSettlements.get(state.id)
|
|
480
|
+
if (settlement && (message.__boundRevision === undefined ||
|
|
481
|
+
message.__boundRevision > settlement.finalRevision)) {
|
|
482
|
+
settlement.newFeedback = true
|
|
483
|
+
if (settlement.expired) this.__clearBoundPending(state.id)
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return true
|
|
487
|
+
},
|
|
488
|
+
__clearBoundPending (id) {
|
|
489
|
+
const settlement = this.__boundSettlements.get(id)
|
|
490
|
+
if (settlement?.timer) clearTimeout(settlement.timer)
|
|
491
|
+
this.__boundSettlements.delete(id)
|
|
492
|
+
const pending = { ...this.boundPending }
|
|
493
|
+
const targets = { ...this.boundTargets }
|
|
494
|
+
const unconfirmed = { ...this.boundUnconfirmed }
|
|
495
|
+
delete pending[id]
|
|
496
|
+
delete targets[id]
|
|
497
|
+
delete unconfirmed[id]
|
|
498
|
+
this.boundPending = pending
|
|
499
|
+
this.boundTargets = targets
|
|
500
|
+
this.boundUnconfirmed = unconfirmed
|
|
501
|
+
},
|
|
502
|
+
__clearAllBoundSettlements () {
|
|
503
|
+
for (const settlement of this.__boundSettlements.values()) {
|
|
504
|
+
if (settlement.timer) clearTimeout(settlement.timer)
|
|
505
|
+
}
|
|
506
|
+
this.__boundSettlements.clear()
|
|
507
|
+
this.boundPending = {}
|
|
508
|
+
this.boundTargets = {}
|
|
509
|
+
this.boundUnconfirmed = {}
|
|
510
|
+
},
|
|
511
|
+
__boundSettleDelayMs () { return 700 },
|
|
512
|
+
controlValue (id, fallback = null) {
|
|
513
|
+
return Object.prototype.hasOwnProperty.call(this.boundState, id) ? this.boundState[id] : fallback
|
|
514
|
+
},
|
|
515
|
+
boundDisplayValue (id, fallback = null) {
|
|
516
|
+
return this.boundPending[id] === true && Object.prototype.hasOwnProperty.call(this.boundTargets, id)
|
|
517
|
+
? this.boundTargets[id] : this.controlValue(id, fallback)
|
|
518
|
+
},
|
|
519
|
+
sendBoundControl (command) {
|
|
520
|
+
const id = command?.id
|
|
521
|
+
const control = this.__boundControl(id)
|
|
522
|
+
if (!control) throw new Error('Unknown control ID: ' + id)
|
|
523
|
+
if (control.mode === 'read') throw new Error('Read-only control: ' + id)
|
|
524
|
+
if (!Object.prototype.hasOwnProperty.call(command, 'value')) throw new Error('Bound control needs a value')
|
|
525
|
+
if (!this.__boundPanelSession?.csrf) throw new Error('Bound panel login required')
|
|
526
|
+
const previous = this.__boundSettlements.get(id)
|
|
527
|
+
if (previous?.timer) clearTimeout(previous.timer)
|
|
528
|
+
this.__boundSettlements.delete(id)
|
|
529
|
+
const unconfirmed = { ...this.boundUnconfirmed }
|
|
530
|
+
delete unconfirmed[id]
|
|
531
|
+
this.boundUnconfirmed = unconfirmed
|
|
532
|
+
this.boundPending = { ...this.boundPending, [id]: true }
|
|
533
|
+
const continuous = ['level', 'dimmer', 'position'].includes(control.type)
|
|
534
|
+
this.boundTargets = { ...this.boundTargets, [id]: command.value }
|
|
535
|
+
try {
|
|
536
|
+
const sent = this.__controlPanelPolicy.send({
|
|
537
|
+
type: control.type, topic: '__bound', key: id,
|
|
538
|
+
value: command.value, final: command.final === true
|
|
539
|
+
})
|
|
540
|
+
if (continuous && command.final === true) {
|
|
541
|
+
const settlement = { finalRevision: this.boundFeedbackRevision, newFeedback: false, expired: false, timer: null }
|
|
542
|
+
settlement.timer = setTimeout(() => {
|
|
543
|
+
if (this.__boundSettlements.get(id) !== settlement) return
|
|
544
|
+
settlement.timer = null
|
|
545
|
+
settlement.expired = true
|
|
546
|
+
if (settlement.newFeedback) this.__clearBoundPending(id)
|
|
547
|
+
else this.boundUnconfirmed = { ...this.boundUnconfirmed, [id]: true }
|
|
548
|
+
}, this.__boundSettleDelayMs())
|
|
549
|
+
this.__boundSettlements.set(id, settlement)
|
|
550
|
+
}
|
|
551
|
+
return sent
|
|
552
|
+
} catch (error) {
|
|
553
|
+
this.__clearBoundPending(id)
|
|
554
|
+
throw error
|
|
555
|
+
}
|
|
556
|
+
},
|
|
422
557
|
__controlPanelCreatePolicy: ${createControlPanelPolicy.toString()},
|
|
423
558
|
__controlPanelNormalizeFeedback: ${normalizeControlFeedback.toString()},
|
|
424
559
|
__controlPanelFeedbackFields () {
|
|
@@ -428,16 +563,28 @@ export default {
|
|
|
428
563
|
this.__controlPanelSession = null
|
|
429
564
|
this.__controlPanelFeedbackSource = null
|
|
430
565
|
this.__controlPanelRequests = new Map()
|
|
566
|
+
this.__boundPanelSession = null
|
|
567
|
+
this.__boundPanelSource = null
|
|
568
|
+
this.__boundAuthGeneration = 0
|
|
569
|
+
this.__boundSettlements = new Map()
|
|
431
570
|
this.__resetControlPolicy()
|
|
571
|
+
if (typeof this.$watch === 'function') {
|
|
572
|
+
this.__boundUnwatch = this.$watch('msg', message => this.applyBoundFeedback(message), { immediate: true })
|
|
573
|
+
}
|
|
432
574
|
},
|
|
433
575
|
__disposeControlPanelPolicy () {
|
|
576
|
+
this.__boundUnwatch?.()
|
|
577
|
+
this.__boundUnwatch = null
|
|
578
|
+
this.__clearAllBoundSettlements()
|
|
434
579
|
this.__closeControlFeedback()
|
|
580
|
+
this.__closeBoundFeedback()
|
|
435
581
|
this.__controlPanelPolicy?.dispose()
|
|
436
582
|
this.__controlPanelPolicy = null
|
|
437
583
|
this.__controlPanelRequests?.clear()
|
|
438
584
|
this.controlPending = {}
|
|
439
585
|
this.controlTargets = {}
|
|
440
586
|
this.__controlPanelSession = null
|
|
587
|
+
this.__boundPanelSession = null
|
|
441
588
|
},
|
|
442
589
|
__resetControlPolicy () {
|
|
443
590
|
this.__controlPanelPolicy?.dispose()
|
|
@@ -445,6 +592,9 @@ export default {
|
|
|
445
592
|
intervalMs: 40,
|
|
446
593
|
dispatch: (command) => this.__dispatchControl(command),
|
|
447
594
|
onError: (error, command) => {
|
|
595
|
+
if (command?.topic === '__bound' && command.controlKey) {
|
|
596
|
+
this.__clearBoundPending(command.controlKey)
|
|
597
|
+
}
|
|
448
598
|
if (command?.controlKey) this.__clearControlPending(command.controlKey)
|
|
449
599
|
if (typeof this.onControlError === 'function') this.onControlError(error, command)
|
|
450
600
|
else console.error('ControlPanel command failed', error)
|
|
@@ -503,7 +653,6 @@ export default {
|
|
|
503
653
|
__acknowledgeControlFeedback (feedback) {
|
|
504
654
|
const feedbackFields = this.__controlPanelFeedbackFields()
|
|
505
655
|
for (const [key, request] of this.__controlPanelRequests) {
|
|
506
|
-
if (feedback.controlKey && feedback.controlKey !== key) continue
|
|
507
656
|
let matched = request.topic === 'room/reset'
|
|
508
657
|
let value
|
|
509
658
|
if (!matched && feedback.topic === request.topic) {
|
|
@@ -516,7 +665,15 @@ export default {
|
|
|
516
665
|
value = feedback.state[field]
|
|
517
666
|
}
|
|
518
667
|
}
|
|
519
|
-
if (matched
|
|
668
|
+
if (!matched) continue
|
|
669
|
+
|
|
670
|
+
const sameTopicRequests = [...this.__controlPanelRequests.values()]
|
|
671
|
+
.filter(candidate => candidate.topic === request.topic).length
|
|
672
|
+
const correlated = !feedback.controlKey || feedback.controlKey === key || sameTopicRequests === 1
|
|
673
|
+
if (!correlated) continue
|
|
674
|
+
|
|
675
|
+
const continuous = ['level', 'dimmer', 'position'].includes(request.type)
|
|
676
|
+
if (!continuous || request.topic === 'room/reset' || Object.is(value, request.value)) {
|
|
520
677
|
this.__clearControlPending(key)
|
|
521
678
|
}
|
|
522
679
|
}
|
|
@@ -548,7 +705,103 @@ export default {
|
|
|
548
705
|
this.deviceState = {}
|
|
549
706
|
this.deviceFeedbackRevision = 0
|
|
550
707
|
},
|
|
708
|
+
__closeBoundFeedback () {
|
|
709
|
+
this.__boundPanelSource?.close()
|
|
710
|
+
this.__boundPanelSource = null
|
|
711
|
+
this.boundFeedbackConnected = false
|
|
712
|
+
},
|
|
713
|
+
__connectBoundFeedback () {
|
|
714
|
+
this.__closeBoundFeedback()
|
|
715
|
+
const config = this.__boundPanelConfig()
|
|
716
|
+
if (!config || !this.__boundPanelSession || typeof EventSource === 'undefined') return
|
|
717
|
+
const source = new EventSource(config.endpoint + '/events')
|
|
718
|
+
this.__boundPanelSource = source
|
|
719
|
+
source.onopen = () => {
|
|
720
|
+
if (this.__boundPanelSource === source) this.boundFeedbackConnected = true
|
|
721
|
+
}
|
|
722
|
+
source.onmessage = event => {
|
|
723
|
+
if (this.__boundPanelSource !== source) return
|
|
724
|
+
try { this.applyBoundFeedback(JSON.parse(event.data)) } catch (error) {
|
|
725
|
+
if (typeof this.onControlError === 'function') this.onControlError(error)
|
|
726
|
+
else console.error('Bound feedback failed', error)
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
source.onerror = () => {
|
|
730
|
+
if (this.__boundPanelSource === source) this.boundFeedbackConnected = false
|
|
731
|
+
}
|
|
732
|
+
},
|
|
733
|
+
setBoundPanelSession (session) {
|
|
734
|
+
const config = this.__boundPanelConfig()
|
|
735
|
+
if (!config || session?.room !== config.room || !session.csrf) {
|
|
736
|
+
throw new Error('Invalid bound panel session')
|
|
737
|
+
}
|
|
738
|
+
if (this.__boundPanelSession?.csrf !== session.csrf) {
|
|
739
|
+
this.__resetControlPolicy()
|
|
740
|
+
this.__clearAllBoundSettlements()
|
|
741
|
+
this.boundState = {}
|
|
742
|
+
this.boundFeedbackRevision = 0
|
|
743
|
+
}
|
|
744
|
+
this.__boundPanelSession = { room: session.room, csrf: session.csrf }
|
|
745
|
+
this.boundAuthenticated = true
|
|
746
|
+
this.__connectBoundFeedback()
|
|
747
|
+
},
|
|
748
|
+
clearBoundPanelSession () {
|
|
749
|
+
this.__boundAuthGeneration++
|
|
750
|
+
this.__closeBoundFeedback()
|
|
751
|
+
this.__boundPanelSession = null
|
|
752
|
+
this.boundAuthenticated = false
|
|
753
|
+
this.__resetControlPolicy()
|
|
754
|
+
this.__clearAllBoundSettlements()
|
|
755
|
+
this.boundState = {}
|
|
756
|
+
this.boundFeedbackRevision = 0
|
|
757
|
+
},
|
|
758
|
+
async loginBoundPanel (username, password) {
|
|
759
|
+
const config = this.__boundPanelConfig()
|
|
760
|
+
if (!config) throw new Error('This panel has no bound login endpoint')
|
|
761
|
+
this.clearBoundPanelSession()
|
|
762
|
+
const generation = this.__boundAuthGeneration
|
|
763
|
+
const response = await fetch(config.endpoint + '/login', {
|
|
764
|
+
method: 'POST', credentials: 'same-origin',
|
|
765
|
+
headers: { 'Content-Type': 'application/json' },
|
|
766
|
+
body: JSON.stringify({ username, password })
|
|
767
|
+
})
|
|
768
|
+
if (!response.ok) throw new Error('Panel login failed (' + response.status + ')')
|
|
769
|
+
const session = await response.json()
|
|
770
|
+
if (generation !== this.__boundAuthGeneration) return null
|
|
771
|
+
this.setBoundPanelSession(session)
|
|
772
|
+
return session
|
|
773
|
+
},
|
|
774
|
+
async restoreBoundPanelSession () {
|
|
775
|
+
const config = this.__boundPanelConfig()
|
|
776
|
+
if (!config) return null
|
|
777
|
+
const generation = this.__boundAuthGeneration
|
|
778
|
+
const response = await fetch(config.endpoint + '/session', { credentials: 'same-origin' })
|
|
779
|
+
if (generation !== this.__boundAuthGeneration) return this.__boundPanelSession
|
|
780
|
+
if (!response.ok) {
|
|
781
|
+
this.clearBoundPanelSession()
|
|
782
|
+
return null
|
|
783
|
+
}
|
|
784
|
+
const session = await response.json()
|
|
785
|
+
if (generation !== this.__boundAuthGeneration) return this.__boundPanelSession
|
|
786
|
+
this.setBoundPanelSession(session)
|
|
787
|
+
return session
|
|
788
|
+
},
|
|
789
|
+
async logoutBoundPanel () {
|
|
790
|
+
const config = this.__boundPanelConfig()
|
|
791
|
+
const session = this.__boundPanelSession
|
|
792
|
+
this.clearBoundPanelSession()
|
|
793
|
+
if (!config || !session) return
|
|
794
|
+
const response = await fetch(config.endpoint + '/logout', {
|
|
795
|
+
method: 'POST', credentials: 'same-origin',
|
|
796
|
+
headers: { 'Content-Type': 'application/json', 'X-Room-CSRF': session.csrf },
|
|
797
|
+
body: '{}'
|
|
798
|
+
})
|
|
799
|
+
if (!response.ok) throw new Error('Panel logout failed (' + response.status + ')')
|
|
800
|
+
},
|
|
551
801
|
sendControl (command) {
|
|
802
|
+
if (command && Object.prototype.hasOwnProperty.call(command, 'id')) {
|
|
803
|
+
return this.sendBoundControl(command)
|
|
804
|
+
}
|
|
552
805
|
const key = String(command?.key ?? command?.controlKey ?? '')
|
|
553
806
|
const type = String(command?.type ?? command?.controlType ?? '').toLowerCase()
|
|
554
807
|
const value = Object.prototype.hasOwnProperty.call(command || {}, 'value') ? command.value : command?.payload
|
|
@@ -602,6 +855,24 @@ export default {
|
|
|
602
855
|
return this.deviceValue(field, fallback)
|
|
603
856
|
},
|
|
604
857
|
async __dispatchControl (command) {
|
|
858
|
+
if (command.topic === '__bound') {
|
|
859
|
+
const config = this.__boundPanelConfig()
|
|
860
|
+
const session = this.__boundPanelSession
|
|
861
|
+
if (!config || !session) throw new Error('Bound panel login required')
|
|
862
|
+
const response = await fetch(config.endpoint + '/bound-command', {
|
|
863
|
+
method: 'POST', credentials: 'same-origin',
|
|
864
|
+
headers: { 'Content-Type': 'application/json', 'X-Room-CSRF': session.csrf },
|
|
865
|
+
body: JSON.stringify({ room: session.room, _controlPanel: {
|
|
866
|
+
kind: 'command', id: command.controlKey, value: command.payload, final: command.final
|
|
867
|
+
} }),
|
|
868
|
+
signal: AbortSignal.timeout(8000)
|
|
869
|
+
})
|
|
870
|
+
if (!response.ok) {
|
|
871
|
+
if (response.status === 401) this.clearBoundPanelSession()
|
|
872
|
+
throw new Error('Bound command failed (' + response.status + ')')
|
|
873
|
+
}
|
|
874
|
+
return response
|
|
875
|
+
}
|
|
605
876
|
if (typeof this.controlTransport === 'function') return this.controlTransport(command)
|
|
606
877
|
|
|
607
878
|
const session = this.__controlPanelSession
|
|
@@ -624,8 +895,11 @@ export default {
|
|
|
624
895
|
},
|
|
625
896
|
mounted () {
|
|
626
897
|
this.__initializeControlPanelPolicy()
|
|
898
|
+
if (this.__boundPanelConfig()) this.restoreBoundPanelSession().catch(() => undefined)
|
|
899
|
+
${visualMounted}
|
|
627
900
|
},
|
|
628
901
|
unmounted () {
|
|
902
|
+
${visualUnmounted}
|
|
629
903
|
this.__disposeControlPanelPolicy()
|
|
630
904
|
}
|
|
631
905
|
}
|
|
@@ -659,11 +933,11 @@ function functionSource (property) {
|
|
|
659
933
|
return property ? escodegen.generate(property.value) : null
|
|
660
934
|
}
|
|
661
935
|
|
|
662
|
-
function mergeDashboardOptions (userSource) {
|
|
936
|
+
function mergeDashboardOptions (userSource, controls = [], boundPanel = null) {
|
|
663
937
|
const user = parseOptionsObject(userSource)
|
|
664
938
|
if (!user) return null
|
|
665
939
|
|
|
666
|
-
const policy = parseOptionsObject(buildDashboardPolicyOptionsSource())
|
|
940
|
+
const policy = parseOptionsObject(buildDashboardPolicyOptionsSource(controls, boundPanel))
|
|
667
941
|
const policyData = findProperty(policy, 'data')
|
|
668
942
|
const policyMethods = findProperty(policy, 'methods')
|
|
669
943
|
const policyMounted = findProperty(policy, 'mounted')
|
|
@@ -724,7 +998,7 @@ function mergeDashboardOptions (userSource) {
|
|
|
724
998
|
})
|
|
725
999
|
}
|
|
726
1000
|
|
|
727
|
-
function injectControlPolicy (format) {
|
|
1001
|
+
function injectControlPolicy (format, controls = [], boundPanel = null) {
|
|
728
1002
|
if (typeof format !== 'string' || format.includes(CONTROL_POLICY_MARKER)) return format
|
|
729
1003
|
|
|
730
1004
|
const scriptMatch = /<script(?!\s+setup\b)(?:\s[^>]*)?>/i.exec(format)
|
|
@@ -732,7 +1006,7 @@ function injectControlPolicy (format) {
|
|
|
732
1006
|
const setupMatch = /<script\s+setup\b([^>]*)>/i.exec(format)
|
|
733
1007
|
if (setupMatch || !/<template(?:\s|>)/i.test(format)) return format
|
|
734
1008
|
return format + '\n<script>\n' + CONTROL_POLICY_MARKER + '\n' +
|
|
735
|
-
buildDashboardPolicyOptionsSource().trim() + '\n</script>\n'
|
|
1009
|
+
buildDashboardPolicyOptionsSource(controls, boundPanel).trim() + '\n</script>\n'
|
|
736
1010
|
}
|
|
737
1011
|
|
|
738
1012
|
const scriptStart = scriptMatch.index + scriptMatch[0].length
|
|
@@ -742,7 +1016,7 @@ function injectControlPolicy (format) {
|
|
|
742
1016
|
const source = format.slice(scriptStart, scriptEnd)
|
|
743
1017
|
let wrapped
|
|
744
1018
|
try {
|
|
745
|
-
wrapped = mergeDashboardOptions(source)
|
|
1019
|
+
wrapped = mergeDashboardOptions(source, controls, boundPanel)
|
|
746
1020
|
} catch (_) {
|
|
747
1021
|
return format
|
|
748
1022
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"types": {
|
|
4
|
+
"slider": { "family": "number", "delivery": "continuous" },
|
|
5
|
+
"level": { "family": "number", "delivery": "continuous" },
|
|
6
|
+
"volume": { "family": "number", "delivery": "continuous" },
|
|
7
|
+
"dimmer": { "family": "number", "delivery": "continuous" },
|
|
8
|
+
"position": { "family": "number", "delivery": "continuous" },
|
|
9
|
+
"toggle": { "family": "boolean", "delivery": "discrete" },
|
|
10
|
+
"mute": { "family": "boolean", "delivery": "discrete" },
|
|
11
|
+
"power": { "family": "boolean", "delivery": "discrete" },
|
|
12
|
+
"text": { "family": "text", "delivery": "discrete" },
|
|
13
|
+
"string": { "family": "text", "delivery": "discrete" },
|
|
14
|
+
"source": { "family": "enum", "delivery": "discrete" },
|
|
15
|
+
"preset": { "family": "enum", "delivery": "discrete" }
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
const contract = require('./control-type-contract.json')
|
|
2
|
+
|
|
3
|
+
const ADAPTER_FAMILIES = Object.freeze({
|
|
4
|
+
range: ['number'],
|
|
5
|
+
switch: ['boolean'],
|
|
6
|
+
meter: ['number'],
|
|
7
|
+
'text-display': ['text', 'number', 'enum', 'boolean'],
|
|
8
|
+
'text-input': ['text'],
|
|
9
|
+
select: ['enum']
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
const READ_ONLY = new Set(['meter', 'text-display'])
|
|
13
|
+
|
|
14
|
+
function targetFamily (target) {
|
|
15
|
+
if (target.controlSchemaVersion !== contract.schemaVersion) return null
|
|
16
|
+
const definition = contract.types[String(target.controlType || '').toLowerCase()]
|
|
17
|
+
if (!definition) return null
|
|
18
|
+
const type = target.type
|
|
19
|
+
if (definition.family === 'number' && !['integer', 'float'].includes(type)) return null
|
|
20
|
+
if (definition.family === 'boolean' && type !== 'boolean' &&
|
|
21
|
+
!(type === 'integer' && target.booleanEncoding === 'integer01' &&
|
|
22
|
+
target.min === 0 && target.max === 1)) return null
|
|
23
|
+
if (definition.family === 'text' && !['string', 'dstring'].includes(type)) return null
|
|
24
|
+
if (definition.family === 'enum' && !['enum', 'integer', 'string'].includes(type)) return null
|
|
25
|
+
return definition.family
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function compatibleTarget (adapter, target, direction, candidate = {}) {
|
|
29
|
+
const family = targetFamily(target)
|
|
30
|
+
if (!family || target.kind !== 'param' || !ADAPTER_FAMILIES[adapter]?.includes(family)) return false
|
|
31
|
+
if (direction === 'write' && READ_ONLY.has(adapter)) return false
|
|
32
|
+
if (direction === 'write' && !['wo', 'rw'].includes(target.access)) return false
|
|
33
|
+
if (direction === 'read' && !['ro', 'rw'].includes(target.access)) return false
|
|
34
|
+
if (candidate.unit && candidate.unit !== target.unit) return false
|
|
35
|
+
return true
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function controlTypeForAdapter (adapter, target) {
|
|
39
|
+
if (!compatibleTarget(adapter, target, 'read') && !compatibleTarget(adapter, target, 'write')) {
|
|
40
|
+
throw new Error('Target is incompatible with this control')
|
|
41
|
+
}
|
|
42
|
+
const family = targetFamily(target)
|
|
43
|
+
if (family === 'number') return 'level'
|
|
44
|
+
if (family === 'boolean') return 'mute'
|
|
45
|
+
if (family === 'text') return 'text'
|
|
46
|
+
if (family === 'enum') return 'source'
|
|
47
|
+
throw new Error('Unsupported control family')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function targetMeta (target, onebased) {
|
|
51
|
+
if (typeof target.onebased !== 'boolean' && typeof onebased !== 'boolean') {
|
|
52
|
+
throw new Error('Select target numbering mode')
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
kind: target.kind, type: target.type, access: target.access,
|
|
56
|
+
onebased: typeof target.onebased === 'boolean' ? target.onebased : onebased,
|
|
57
|
+
min: target.min, max: target.max, enums: target.enums,
|
|
58
|
+
...(target.booleanEncoding ? { booleanEncoding: target.booleanEncoding } : {}),
|
|
59
|
+
controlType: target.controlType, controlSchemaVersion: target.controlSchemaVersion
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = { contract, compatibleTarget, controlTypeForAdapter, targetFamily, targetMeta }
|
package/gateway.cjs
CHANGED
|
@@ -119,18 +119,35 @@ function createGateway ({
|
|
|
119
119
|
password,
|
|
120
120
|
room = 'room-01',
|
|
121
121
|
onCommand = () => {},
|
|
122
|
+
onBoundCommand,
|
|
123
|
+
onBoundLogin,
|
|
124
|
+
boundOnly = false,
|
|
122
125
|
secureCookie = false,
|
|
123
126
|
prefix,
|
|
124
|
-
continuousIntervalMs = CONTINUOUS_INTERVAL_MS
|
|
127
|
+
continuousIntervalMs = CONTINUOUS_INTERVAL_MS,
|
|
128
|
+
sessionTtlMs = 8 * 3600000,
|
|
129
|
+
restoredSessions
|
|
125
130
|
}) {
|
|
126
131
|
const routePrefix = prefix || PREFIX + '/regions/' + encodeURIComponent(room)
|
|
127
132
|
if (!username || typeof password !== 'string' || password.length < 12) {
|
|
128
133
|
throw Error('Set a username and password of at least 12 characters')
|
|
129
134
|
}
|
|
135
|
+
if (!Number.isSafeInteger(sessionTtlMs) || sessionTtlMs < 1) {
|
|
136
|
+
throw Error('Invalid session lifetime')
|
|
137
|
+
}
|
|
130
138
|
|
|
131
139
|
const salt = crypto.randomBytes(16)
|
|
132
140
|
const hash = crypto.scryptSync(password, salt, 32)
|
|
133
141
|
const sessions = new Map()
|
|
142
|
+
if (restoredSessions instanceof Map) {
|
|
143
|
+
for (const [token, session] of restoredSessions) {
|
|
144
|
+
if (sessions.size >= 100) break
|
|
145
|
+
if (typeof token === 'string' && typeof session?.csrf === 'string' &&
|
|
146
|
+
Number.isFinite(session.expiry) && session.expiry > Date.now()) {
|
|
147
|
+
sessions.set(token, { csrf: session.csrf, expiry: session.expiry })
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
134
151
|
const attempts = new Map()
|
|
135
152
|
const continuousDispatcher = createLatestWinsDispatcher({
|
|
136
153
|
onDispatch: onCommand,
|
|
@@ -139,6 +156,8 @@ function createGateway ({
|
|
|
139
156
|
const subscribers = new Set()
|
|
140
157
|
let desiredState = defaults()
|
|
141
158
|
let reportedState = {}
|
|
159
|
+
const boundState = new Map()
|
|
160
|
+
let boundRevision = 0
|
|
142
161
|
let revision = 0
|
|
143
162
|
let commandRevision = 0
|
|
144
163
|
let closed = false
|
|
@@ -201,6 +220,7 @@ function createGateway ({
|
|
|
201
220
|
}
|
|
202
221
|
|
|
203
222
|
function feedback (msg) {
|
|
223
|
+
if (boundOnly) throw Error('Use boundFeedback for a bound panel')
|
|
204
224
|
if (msg.room !== room || msg.kind !== 'state') {
|
|
205
225
|
throw Error('Feedback requires matching room and kind=state')
|
|
206
226
|
}
|
|
@@ -223,6 +243,27 @@ function createGateway ({
|
|
|
223
243
|
return message
|
|
224
244
|
}
|
|
225
245
|
|
|
246
|
+
function boundFeedback (items) {
|
|
247
|
+
if (!boundOnly || !Array.isArray(items) || !items.length ||
|
|
248
|
+
items.some(item => !item || typeof item.id !== 'string' || !item.id ||
|
|
249
|
+
!Object.hasOwn(item, 'value'))) {
|
|
250
|
+
throw Error('Invalid bound feedback')
|
|
251
|
+
}
|
|
252
|
+
for (const { id, value } of items) boundState.set(id, value)
|
|
253
|
+
boundRevision++
|
|
254
|
+
const message = { room, kind: 'bound-feedback', items, revision: boundRevision }
|
|
255
|
+
broadcastFeedback(message)
|
|
256
|
+
return message
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function boundSnapshot () {
|
|
260
|
+
return {
|
|
261
|
+
room, kind: 'bound-snapshot',
|
|
262
|
+
items: [...boundState].map(([id, value]) => ({ id, value })),
|
|
263
|
+
revision: boundRevision
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
226
267
|
function reply (res, status, data, headers = {}) {
|
|
227
268
|
res.writeHead(status, {
|
|
228
269
|
'Content-Type': 'application/json; charset=utf-8',
|
|
@@ -310,8 +351,13 @@ function createGateway ({
|
|
|
310
351
|
attempts.delete(ip)
|
|
311
352
|
const token = crypto.randomBytes(32).toString('hex')
|
|
312
353
|
const csrf = crypto.randomBytes(24).toString('hex')
|
|
313
|
-
sessions.set(token, { csrf, expiry: now +
|
|
314
|
-
reply(res, 200, { room, username, csrf }, {
|
|
354
|
+
sessions.set(token, { csrf, expiry: now + sessionTtlMs })
|
|
355
|
+
reply(res, 200, { room, username, csrf }, {
|
|
356
|
+
'Set-Cookie': cookie(token, Math.ceil(sessionTtlMs / 1000))
|
|
357
|
+
})
|
|
358
|
+
if (boundOnly && typeof onBoundLogin === 'function') {
|
|
359
|
+
queueMicrotask(() => { if (!closed) onBoundLogin({ room, user: username }) })
|
|
360
|
+
}
|
|
315
361
|
return true
|
|
316
362
|
}
|
|
317
363
|
|
|
@@ -344,7 +390,7 @@ function createGateway ({
|
|
|
344
390
|
const subscriber = { res, token }
|
|
345
391
|
subscribers.add(subscriber)
|
|
346
392
|
res.on('close', () => closeSubscriber(subscriber))
|
|
347
|
-
sendFeedbackEvent(subscriber, feedbackMessage())
|
|
393
|
+
sendFeedbackEvent(subscriber, boundOnly ? boundSnapshot() : feedbackMessage())
|
|
348
394
|
ensureHeartbeat()
|
|
349
395
|
return true
|
|
350
396
|
}
|
|
@@ -353,7 +399,7 @@ function createGateway ({
|
|
|
353
399
|
reply(res, 403, { error: 'Region denied' })
|
|
354
400
|
return true
|
|
355
401
|
}
|
|
356
|
-
reply(res, 200, feedbackMessage())
|
|
402
|
+
reply(res, 200, boundOnly ? boundSnapshot() : feedbackMessage())
|
|
357
403
|
return true
|
|
358
404
|
}
|
|
359
405
|
if (path === routePrefix + '/logout' && req.method === 'POST') {
|
|
@@ -362,7 +408,28 @@ function createGateway ({
|
|
|
362
408
|
reply(res, 200, { ok: true }, { 'Set-Cookie': cookie('', 0) })
|
|
363
409
|
return true
|
|
364
410
|
}
|
|
411
|
+
if (path === routePrefix + '/bound-command' && req.method === 'POST') {
|
|
412
|
+
if (typeof onBoundCommand !== 'function') {
|
|
413
|
+
reply(res, 404, { error: 'Not found' })
|
|
414
|
+
return true
|
|
415
|
+
}
|
|
416
|
+
const data = await body(req)
|
|
417
|
+
if (data.room !== room) {
|
|
418
|
+
reply(res, 403, { error: 'Region denied' })
|
|
419
|
+
return true
|
|
420
|
+
}
|
|
421
|
+
if (await onBoundCommand(data, { room, user: username }) === false) {
|
|
422
|
+
reply(res, 409, { error: 'Managed bridge is out of sync' })
|
|
423
|
+
return true
|
|
424
|
+
}
|
|
425
|
+
reply(res, 200, { ok: true })
|
|
426
|
+
return true
|
|
427
|
+
}
|
|
365
428
|
if (path === routePrefix + '/command' && req.method === 'POST') {
|
|
429
|
+
if (boundOnly) {
|
|
430
|
+
reply(res, 404, { error: 'Not found' })
|
|
431
|
+
return true
|
|
432
|
+
}
|
|
366
433
|
const data = await body(req)
|
|
367
434
|
if (data.room !== room) {
|
|
368
435
|
reply(res, 403, { error: 'Region denied' })
|
|
@@ -427,6 +494,11 @@ function createGateway ({
|
|
|
427
494
|
return {
|
|
428
495
|
handler,
|
|
429
496
|
feedback,
|
|
497
|
+
boundFeedback,
|
|
498
|
+
sessionSnapshot () {
|
|
499
|
+
return new Map([...sessions].filter(([, session]) => session.expiry > Date.now())
|
|
500
|
+
.map(([token, session]) => [token, { ...session }]))
|
|
501
|
+
},
|
|
430
502
|
close () {
|
|
431
503
|
closed = true
|
|
432
504
|
continuousDispatcher.close()
|