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-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 && (request.topic === 'room/reset' || Object.is(value, request.value))) {
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 && (request.topic === 'room/reset' || Object.is(value, request.value))) {
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 + 8 * 3600000 })
314
- reply(res, 200, { room, username, csrf }, { 'Set-Cookie': cookie(token, 28800) })
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()