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-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 + 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()