node-red-contrib-room-tools 1.8.2 → 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 -220
- package/README.md +10 -148
- 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 +267 -7
- 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
|
|
@@ -413,11 +414,27 @@ const __controlPanelPolicyMixin = {
|
|
|
413
414
|
// as component options when the script contains one `export default { ... }`
|
|
414
415
|
// statement. Keep every helper inside data, methods, or lifecycle hooks so the
|
|
415
416
|
// generated script remains inside that supported subset.
|
|
416
|
-
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()' : ''
|
|
417
427
|
return `
|
|
418
428
|
export default {
|
|
419
429
|
data () {
|
|
420
430
|
return {
|
|
431
|
+
boundState: {},
|
|
432
|
+
boundPending: {},
|
|
433
|
+
boundTargets: {},
|
|
434
|
+
boundUnconfirmed: {},
|
|
435
|
+
boundFeedbackRevision: 0,
|
|
436
|
+
boundFeedbackConnected: false,
|
|
437
|
+
boundAuthenticated: false,
|
|
421
438
|
deviceState: {},
|
|
422
439
|
deviceFeedbackRevision: 0,
|
|
423
440
|
deviceFeedbackConnected: false,
|
|
@@ -426,6 +443,117 @@ export default {
|
|
|
426
443
|
}
|
|
427
444
|
},
|
|
428
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
|
+
},
|
|
429
557
|
__controlPanelCreatePolicy: ${createControlPanelPolicy.toString()},
|
|
430
558
|
__controlPanelNormalizeFeedback: ${normalizeControlFeedback.toString()},
|
|
431
559
|
__controlPanelFeedbackFields () {
|
|
@@ -435,16 +563,28 @@ export default {
|
|
|
435
563
|
this.__controlPanelSession = null
|
|
436
564
|
this.__controlPanelFeedbackSource = null
|
|
437
565
|
this.__controlPanelRequests = new Map()
|
|
566
|
+
this.__boundPanelSession = null
|
|
567
|
+
this.__boundPanelSource = null
|
|
568
|
+
this.__boundAuthGeneration = 0
|
|
569
|
+
this.__boundSettlements = new Map()
|
|
438
570
|
this.__resetControlPolicy()
|
|
571
|
+
if (typeof this.$watch === 'function') {
|
|
572
|
+
this.__boundUnwatch = this.$watch('msg', message => this.applyBoundFeedback(message), { immediate: true })
|
|
573
|
+
}
|
|
439
574
|
},
|
|
440
575
|
__disposeControlPanelPolicy () {
|
|
576
|
+
this.__boundUnwatch?.()
|
|
577
|
+
this.__boundUnwatch = null
|
|
578
|
+
this.__clearAllBoundSettlements()
|
|
441
579
|
this.__closeControlFeedback()
|
|
580
|
+
this.__closeBoundFeedback()
|
|
442
581
|
this.__controlPanelPolicy?.dispose()
|
|
443
582
|
this.__controlPanelPolicy = null
|
|
444
583
|
this.__controlPanelRequests?.clear()
|
|
445
584
|
this.controlPending = {}
|
|
446
585
|
this.controlTargets = {}
|
|
447
586
|
this.__controlPanelSession = null
|
|
587
|
+
this.__boundPanelSession = null
|
|
448
588
|
},
|
|
449
589
|
__resetControlPolicy () {
|
|
450
590
|
this.__controlPanelPolicy?.dispose()
|
|
@@ -452,6 +592,9 @@ export default {
|
|
|
452
592
|
intervalMs: 40,
|
|
453
593
|
dispatch: (command) => this.__dispatchControl(command),
|
|
454
594
|
onError: (error, command) => {
|
|
595
|
+
if (command?.topic === '__bound' && command.controlKey) {
|
|
596
|
+
this.__clearBoundPending(command.controlKey)
|
|
597
|
+
}
|
|
455
598
|
if (command?.controlKey) this.__clearControlPending(command.controlKey)
|
|
456
599
|
if (typeof this.onControlError === 'function') this.onControlError(error, command)
|
|
457
600
|
else console.error('ControlPanel command failed', error)
|
|
@@ -562,7 +705,103 @@ export default {
|
|
|
562
705
|
this.deviceState = {}
|
|
563
706
|
this.deviceFeedbackRevision = 0
|
|
564
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
|
+
},
|
|
565
801
|
sendControl (command) {
|
|
802
|
+
if (command && Object.prototype.hasOwnProperty.call(command, 'id')) {
|
|
803
|
+
return this.sendBoundControl(command)
|
|
804
|
+
}
|
|
566
805
|
const key = String(command?.key ?? command?.controlKey ?? '')
|
|
567
806
|
const type = String(command?.type ?? command?.controlType ?? '').toLowerCase()
|
|
568
807
|
const value = Object.prototype.hasOwnProperty.call(command || {}, 'value') ? command.value : command?.payload
|
|
@@ -616,6 +855,24 @@ export default {
|
|
|
616
855
|
return this.deviceValue(field, fallback)
|
|
617
856
|
},
|
|
618
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
|
+
}
|
|
619
876
|
if (typeof this.controlTransport === 'function') return this.controlTransport(command)
|
|
620
877
|
|
|
621
878
|
const session = this.__controlPanelSession
|
|
@@ -638,8 +895,11 @@ export default {
|
|
|
638
895
|
},
|
|
639
896
|
mounted () {
|
|
640
897
|
this.__initializeControlPanelPolicy()
|
|
898
|
+
if (this.__boundPanelConfig()) this.restoreBoundPanelSession().catch(() => undefined)
|
|
899
|
+
${visualMounted}
|
|
641
900
|
},
|
|
642
901
|
unmounted () {
|
|
902
|
+
${visualUnmounted}
|
|
643
903
|
this.__disposeControlPanelPolicy()
|
|
644
904
|
}
|
|
645
905
|
}
|
|
@@ -673,11 +933,11 @@ function functionSource (property) {
|
|
|
673
933
|
return property ? escodegen.generate(property.value) : null
|
|
674
934
|
}
|
|
675
935
|
|
|
676
|
-
function mergeDashboardOptions (userSource) {
|
|
936
|
+
function mergeDashboardOptions (userSource, controls = [], boundPanel = null) {
|
|
677
937
|
const user = parseOptionsObject(userSource)
|
|
678
938
|
if (!user) return null
|
|
679
939
|
|
|
680
|
-
const policy = parseOptionsObject(buildDashboardPolicyOptionsSource())
|
|
940
|
+
const policy = parseOptionsObject(buildDashboardPolicyOptionsSource(controls, boundPanel))
|
|
681
941
|
const policyData = findProperty(policy, 'data')
|
|
682
942
|
const policyMethods = findProperty(policy, 'methods')
|
|
683
943
|
const policyMounted = findProperty(policy, 'mounted')
|
|
@@ -738,7 +998,7 @@ function mergeDashboardOptions (userSource) {
|
|
|
738
998
|
})
|
|
739
999
|
}
|
|
740
1000
|
|
|
741
|
-
function injectControlPolicy (format) {
|
|
1001
|
+
function injectControlPolicy (format, controls = [], boundPanel = null) {
|
|
742
1002
|
if (typeof format !== 'string' || format.includes(CONTROL_POLICY_MARKER)) return format
|
|
743
1003
|
|
|
744
1004
|
const scriptMatch = /<script(?!\s+setup\b)(?:\s[^>]*)?>/i.exec(format)
|
|
@@ -746,7 +1006,7 @@ function injectControlPolicy (format) {
|
|
|
746
1006
|
const setupMatch = /<script\s+setup\b([^>]*)>/i.exec(format)
|
|
747
1007
|
if (setupMatch || !/<template(?:\s|>)/i.test(format)) return format
|
|
748
1008
|
return format + '\n<script>\n' + CONTROL_POLICY_MARKER + '\n' +
|
|
749
|
-
buildDashboardPolicyOptionsSource().trim() + '\n</script>\n'
|
|
1009
|
+
buildDashboardPolicyOptionsSource(controls, boundPanel).trim() + '\n</script>\n'
|
|
750
1010
|
}
|
|
751
1011
|
|
|
752
1012
|
const scriptStart = scriptMatch.index + scriptMatch[0].length
|
|
@@ -756,7 +1016,7 @@ function injectControlPolicy (format) {
|
|
|
756
1016
|
const source = format.slice(scriptStart, scriptEnd)
|
|
757
1017
|
let wrapped
|
|
758
1018
|
try {
|
|
759
|
-
wrapped = mergeDashboardOptions(source)
|
|
1019
|
+
wrapped = mergeDashboardOptions(source, controls, boundPanel)
|
|
760
1020
|
} catch (_) {
|
|
761
1021
|
return format
|
|
762
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()
|