node-red-contrib-room-tools 1.8.0 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/control-policy.js +337 -24
  2. package/package.json +5 -1
package/control-policy.js CHANGED
@@ -1,3 +1,6 @@
1
+ const acorn = require('acorn')
2
+ const escodegen = require('escodegen')
3
+
1
4
  const CONTROL_POLICY_MARKER = '/* ui-control-panel-control-api-v1 */'
2
5
  const FEEDBACK_TOPIC_FIELDS = Object.freeze({
3
6
  'display/power': 'display',
@@ -399,17 +402,337 @@ const __controlPanelPolicyMixin = {
399
402
  `
400
403
  }
401
404
 
405
+ // Dashboard 2.0 1.31 parses ui-template scripts itself. It only treats a script
406
+ // as component options when the script contains one `export default { ... }`
407
+ // statement. Keep every helper inside data, methods, or lifecycle hooks so the
408
+ // generated script remains inside that supported subset.
409
+ function buildDashboardPolicyOptionsSource () {
410
+ return `
411
+ export default {
412
+ data () {
413
+ return {
414
+ deviceState: {},
415
+ deviceFeedbackRevision: 0,
416
+ deviceFeedbackConnected: false,
417
+ controlPending: {},
418
+ controlTargets: {}
419
+ }
420
+ },
421
+ methods: {
422
+ __controlPanelCreatePolicy: ${createControlPanelPolicy.toString()},
423
+ __controlPanelNormalizeFeedback: ${normalizeControlFeedback.toString()},
424
+ __controlPanelFeedbackFields () {
425
+ return ${JSON.stringify(FEEDBACK_TOPIC_FIELDS)}
426
+ },
427
+ __initializeControlPanelPolicy () {
428
+ this.__controlPanelSession = null
429
+ this.__controlPanelFeedbackSource = null
430
+ this.__controlPanelRequests = new Map()
431
+ this.__resetControlPolicy()
432
+ },
433
+ __disposeControlPanelPolicy () {
434
+ this.__closeControlFeedback()
435
+ this.__controlPanelPolicy?.dispose()
436
+ this.__controlPanelPolicy = null
437
+ this.__controlPanelRequests?.clear()
438
+ this.controlPending = {}
439
+ this.controlTargets = {}
440
+ this.__controlPanelSession = null
441
+ },
442
+ __resetControlPolicy () {
443
+ this.__controlPanelPolicy?.dispose()
444
+ this.__controlPanelPolicy = this.__controlPanelCreatePolicy({
445
+ intervalMs: 40,
446
+ dispatch: (command) => this.__dispatchControl(command),
447
+ onError: (error, command) => {
448
+ if (command?.controlKey) this.__clearControlPending(command.controlKey)
449
+ if (typeof this.onControlError === 'function') this.onControlError(error, command)
450
+ else console.error('ControlPanel command failed', error)
451
+ }
452
+ })
453
+ },
454
+ __setControlPending (key, pending) {
455
+ const next = { ...this.controlPending }
456
+ if (pending) next[key] = true
457
+ else delete next[key]
458
+ this.controlPending = next
459
+ },
460
+ __setControlTarget (key, value) {
461
+ this.controlTargets = { ...this.controlTargets, [key]: value }
462
+ },
463
+ __clearControlPending (key) {
464
+ this.__controlPanelRequests.delete(String(key))
465
+ this.__setControlPending(String(key), false)
466
+ const targets = { ...this.controlTargets }
467
+ delete targets[String(key)]
468
+ this.controlTargets = targets
469
+ },
470
+ __setFeedbackConnected (connected) {
471
+ const next = connected === true
472
+ if (this.deviceFeedbackConnected === next) return
473
+ this.deviceFeedbackConnected = next
474
+ if (typeof this.onControlFeedbackStatus === 'function') this.onControlFeedbackStatus(next)
475
+ },
476
+ __closeControlFeedback () {
477
+ this.__controlPanelFeedbackSource?.close()
478
+ this.__controlPanelFeedbackSource = null
479
+ this.__setFeedbackConnected(false)
480
+ },
481
+ __connectControlFeedback () {
482
+ this.__closeControlFeedback()
483
+ const session = this.__controlPanelSession
484
+ if (!session || typeof EventSource === 'undefined') return
485
+ const source = new EventSource('/room-panel-api/regions/' + encodeURIComponent(session.room) + '/events')
486
+ this.__controlPanelFeedbackSource = source
487
+ source.onopen = () => {
488
+ if (this.__controlPanelFeedbackSource === source) this.__setFeedbackConnected(true)
489
+ }
490
+ source.onmessage = (event) => {
491
+ if (this.__controlPanelFeedbackSource !== source) return
492
+ try {
493
+ this.applyControlFeedback(JSON.parse(event.data))
494
+ } catch (error) {
495
+ if (typeof this.onControlError === 'function') this.onControlError(error)
496
+ else console.error('ControlPanel feedback failed', error)
497
+ }
498
+ }
499
+ source.onerror = () => {
500
+ if (this.__controlPanelFeedbackSource === source) this.__setFeedbackConnected(false)
501
+ }
502
+ },
503
+ __acknowledgeControlFeedback (feedback) {
504
+ const feedbackFields = this.__controlPanelFeedbackFields()
505
+ for (const [key, request] of this.__controlPanelRequests) {
506
+ if (feedback.controlKey && feedback.controlKey !== key) continue
507
+ let matched = request.topic === 'room/reset'
508
+ let value
509
+ if (!matched && feedback.topic === request.topic) {
510
+ matched = true
511
+ value = feedback.payload
512
+ } else if (!matched && feedback.topic === 'room/state') {
513
+ const field = feedbackFields[request.topic]
514
+ if (field && Object.prototype.hasOwnProperty.call(feedback.state, field)) {
515
+ matched = true
516
+ value = feedback.state[field]
517
+ }
518
+ }
519
+ if (matched && (request.topic === 'room/reset' || Object.is(value, request.value))) {
520
+ this.__clearControlPending(key)
521
+ }
522
+ }
523
+ },
524
+ setControlSession (session) {
525
+ if (!session || typeof session !== 'object' || !session.room || !session.csrf) {
526
+ throw new Error('ControlPanel session needs room and csrf')
527
+ }
528
+ const next = { room: String(session.room), csrf: String(session.csrf) }
529
+ if (this.__controlPanelSession &&
530
+ (this.__controlPanelSession.room !== next.room || this.__controlPanelSession.csrf !== next.csrf)) {
531
+ this.__resetControlPolicy()
532
+ this.__controlPanelRequests.clear()
533
+ this.controlPending = {}
534
+ this.controlTargets = {}
535
+ this.deviceState = {}
536
+ this.deviceFeedbackRevision = 0
537
+ }
538
+ this.__controlPanelSession = next
539
+ this.__connectControlFeedback()
540
+ },
541
+ clearControlSession () {
542
+ this.__closeControlFeedback()
543
+ this.__controlPanelSession = null
544
+ this.__resetControlPolicy()
545
+ this.__controlPanelRequests.clear()
546
+ this.controlPending = {}
547
+ this.controlTargets = {}
548
+ this.deviceState = {}
549
+ this.deviceFeedbackRevision = 0
550
+ },
551
+ sendControl (command) {
552
+ const key = String(command?.key ?? command?.controlKey ?? '')
553
+ const type = String(command?.type ?? command?.controlType ?? '').toLowerCase()
554
+ const value = Object.prototype.hasOwnProperty.call(command || {}, 'value') ? command.value : command?.payload
555
+ if (key) {
556
+ this.__controlPanelRequests.set(key, {
557
+ topic: String(command.topic ?? ''),
558
+ type,
559
+ value
560
+ })
561
+ this.__setControlPending(key, true)
562
+ if (['level', 'dimmer', 'position'].includes(type)) this.__setControlTarget(key, value)
563
+ }
564
+ try {
565
+ return this.__controlPanelPolicy.send(command)
566
+ } catch (error) {
567
+ if (key) this.__clearControlPending(key)
568
+ throw error
569
+ }
570
+ },
571
+ flushControl (key) {
572
+ return this.__controlPanelPolicy.flush(key)
573
+ },
574
+ applyControlFeedback (message) {
575
+ const room = this.__controlPanelSession?.room || this.authorizedRoom
576
+ const feedback = this.__controlPanelNormalizeFeedback(
577
+ message,
578
+ room,
579
+ this.__controlPanelFeedbackFields()
580
+ )
581
+ this.deviceState = { ...this.deviceState, ...feedback.state }
582
+ if (feedback.revision !== null) this.deviceFeedbackRevision = feedback.revision
583
+ this.__acknowledgeControlFeedback(feedback)
584
+ if (typeof this.onDeviceFeedback === 'function') this.onDeviceFeedback(feedback, this.deviceState)
585
+ return feedback
586
+ },
587
+ hasDeviceState (field) {
588
+ return Object.prototype.hasOwnProperty.call(this.deviceState, String(field))
589
+ },
590
+ deviceValue (field, fallback = null) {
591
+ return this.hasDeviceState(field) ? this.deviceState[String(field)] : fallback
592
+ },
593
+ isControlPending (key) {
594
+ return this.controlPending[String(key)] === true
595
+ },
596
+ controlDisplayValue (key, field, fallback = null) {
597
+ const controlKey = String(key)
598
+ if (this.isControlPending(controlKey) &&
599
+ Object.prototype.hasOwnProperty.call(this.controlTargets, controlKey)) {
600
+ return this.controlTargets[controlKey]
601
+ }
602
+ return this.deviceValue(field, fallback)
603
+ },
604
+ async __dispatchControl (command) {
605
+ if (typeof this.controlTransport === 'function') return this.controlTransport(command)
606
+
607
+ const session = this.__controlPanelSession
608
+ const room = session?.room || this.authorizedRoom
609
+ if (typeof this.postControl === 'function') {
610
+ return this.postControl(room ? { ...command, room } : command, this.sessionGeneration)
611
+ }
612
+ if (!session) throw new Error('Call setControlSession() after login, or define controlTransport(command)')
613
+
614
+ const response = await fetch('/room-panel-api/regions/' + encodeURIComponent(session.room) + '/command', {
615
+ method: 'POST',
616
+ credentials: 'same-origin',
617
+ headers: { 'Content-Type': 'application/json', 'X-Room-CSRF': session.csrf },
618
+ body: JSON.stringify({ room: session.room, ...command }),
619
+ signal: AbortSignal.timeout(8000)
620
+ })
621
+ if (!response.ok) throw new Error('Control command failed (' + response.status + ')')
622
+ return response
623
+ }
624
+ },
625
+ mounted () {
626
+ this.__initializeControlPanelPolicy()
627
+ },
628
+ unmounted () {
629
+ this.__disposeControlPanelPolicy()
630
+ }
631
+ }
632
+ `
633
+ }
634
+
635
+ function parseOptionsObject (source) {
636
+ const tree = acorn.parse(source, { ecmaVersion: 'latest', sourceType: 'module' })
637
+ if (tree.body.length !== 1 || tree.body[0].type !== 'ExportDefaultDeclaration' ||
638
+ tree.body[0].declaration.type !== 'ObjectExpression') return null
639
+ return tree.body[0].declaration
640
+ }
641
+
642
+ function propertyName (property) {
643
+ if (!property || property.type !== 'Property') return null
644
+ if (!property.computed && property.key.type === 'Identifier') return property.key.name
645
+ if (property.key.type === 'Literal') return String(property.key.value)
646
+ return null
647
+ }
648
+
649
+ function findProperty (object, name) {
650
+ return object.properties.find(property => propertyName(property) === name)
651
+ }
652
+
653
+ function parseProperty (source) {
654
+ const object = acorn.parse('({' + source + '})', { ecmaVersion: 'latest' }).body[0].expression
655
+ return object.properties[0]
656
+ }
657
+
658
+ function functionSource (property) {
659
+ return property ? escodegen.generate(property.value) : null
660
+ }
661
+
662
+ function mergeDashboardOptions (userSource) {
663
+ const user = parseOptionsObject(userSource)
664
+ if (!user) return null
665
+
666
+ const policy = parseOptionsObject(buildDashboardPolicyOptionsSource())
667
+ const policyData = findProperty(policy, 'data')
668
+ const policyMethods = findProperty(policy, 'methods')
669
+ const policyMounted = findProperty(policy, 'mounted')
670
+ const policyUnmounted = findProperty(policy, 'unmounted')
671
+ const userData = findProperty(user, 'data')
672
+ const userMethods = findProperty(user, 'methods')
673
+ const userMounted = findProperty(user, 'mounted')
674
+ const userUnmounted = findProperty(user, 'unmounted')
675
+
676
+ if (userMethods && userMethods.value.type !== 'ObjectExpression') return null
677
+
678
+ const data = userData
679
+ ? parseProperty(`data () {
680
+ const userState = (${functionSource(userData)}).call(this) || {}
681
+ const policyState = (${functionSource(policyData)}).call(this) || {}
682
+ return { ...userState, ...policyState }
683
+ }`)
684
+ : policyData
685
+
686
+ const policyMethodNames = new Set(policyMethods.value.properties.map(propertyName))
687
+ const methods = parseProperty('methods: {}')
688
+ methods.value.properties = [
689
+ ...(userMethods ? userMethods.value.properties.filter(property => !policyMethodNames.has(propertyName(property))) : []),
690
+ ...policyMethods.value.properties
691
+ ]
692
+
693
+ const mounted = userMounted
694
+ ? parseProperty(`mounted () {
695
+ (${functionSource(policyMounted)}).call(this)
696
+ return (${functionSource(userMounted)}).call(this)
697
+ }`)
698
+ : policyMounted
699
+
700
+ const unmounted = userUnmounted
701
+ ? parseProperty(`unmounted () {
702
+ try {
703
+ return (${functionSource(userUnmounted)}).call(this)
704
+ } finally {
705
+ (${functionSource(policyUnmounted)}).call(this)
706
+ }
707
+ }`)
708
+ : policyUnmounted
709
+
710
+ const replaced = new Set(['data', 'methods', 'mounted', 'unmounted'])
711
+ const merged = {
712
+ ...user,
713
+ properties: [
714
+ ...user.properties.filter(property => !replaced.has(propertyName(property))),
715
+ data,
716
+ methods,
717
+ mounted,
718
+ unmounted
719
+ ]
720
+ }
721
+
722
+ return CONTROL_POLICY_MARKER + '\nexport default ' + escodegen.generate(merged, {
723
+ format: { indent: { style: ' ' } }
724
+ })
725
+ }
726
+
402
727
  function injectControlPolicy (format) {
403
728
  if (typeof format !== 'string' || format.includes(CONTROL_POLICY_MARKER)) return format
404
729
 
405
730
  const scriptMatch = /<script(?!\s+setup\b)(?:\s[^>]*)?>/i.exec(format)
406
731
  if (!scriptMatch) {
407
732
  const setupMatch = /<script\s+setup\b([^>]*)>/i.exec(format)
408
- if (!setupMatch && !/<template(?:\s|>)/i.test(format)) return format
409
- const langMatch = setupMatch?.[1]?.match(/\blang\s*=\s*(["'])([^"']+)\1/i)
410
- const scriptOpen = langMatch ? '<script lang="' + langMatch[2] + '">' : '<script>'
411
- return format + '\n' + scriptOpen + '\n' + buildControlPolicyMixinSource() +
412
- '\nexport default { mixins: [__controlPanelPolicyMixin] }\n</script>\n'
733
+ if (setupMatch || !/<template(?:\s|>)/i.test(format)) return format
734
+ return format + '\n<script>\n' + CONTROL_POLICY_MARKER + '\n' +
735
+ buildDashboardPolicyOptionsSource().trim() + '\n</script>\n'
413
736
  }
414
737
 
415
738
  const scriptStart = scriptMatch.index + scriptMatch[0].length
@@ -417,33 +740,23 @@ function injectControlPolicy (format) {
417
740
  if (scriptEnd === -1) return format
418
741
 
419
742
  const source = format.slice(scriptStart, scriptEnd)
420
- const exportMatch = /(^|\n)([ \t]*)export\s+default\b/.exec(source)
421
- if (!exportMatch) {
422
- const wrapped = source + '\n' + buildControlPolicyMixinSource() +
423
- '\nexport default { mixins: [__controlPanelPolicyMixin] }\n'
424
- return format.slice(0, scriptStart) + wrapped + format.slice(scriptEnd)
743
+ let wrapped
744
+ try {
745
+ wrapped = mergeDashboardOptions(source)
746
+ } catch (_) {
747
+ return format
425
748
  }
426
-
427
- const exportStart = exportMatch.index + exportMatch[1].length + exportMatch[2].length
428
- const exportEnd = exportMatch.index + exportMatch[0].length
429
- const userOptions = source.slice(0, exportStart) +
430
- 'const __controlPanelUserOptions =' +
431
- source.slice(exportEnd)
432
- const wrapped = userOptions + '\n' + buildControlPolicyMixinSource() + `
433
- export default {
434
- ...__controlPanelUserOptions,
435
- mixins: [...(__controlPanelUserOptions.mixins || []), __controlPanelPolicyMixin]
436
- }
437
- `
438
-
439
- return format.slice(0, scriptStart) + wrapped + format.slice(scriptEnd)
749
+ if (!wrapped) return format
750
+ return format.slice(0, scriptStart) + '\n' + wrapped + '\n' + format.slice(scriptEnd)
440
751
  }
441
752
 
442
753
  module.exports = {
443
754
  CONTROL_POLICY_MARKER,
444
755
  FEEDBACK_TOPIC_FIELDS,
445
756
  buildControlPolicyMixinSource,
757
+ buildDashboardPolicyOptionsSource,
446
758
  createControlPanelPolicy,
447
759
  injectControlPolicy,
760
+ mergeDashboardOptions,
448
761
  normalizeControlFeedback
449
762
  }
package/package.json CHANGED
@@ -1,9 +1,13 @@
1
1
  {
2
2
  "name": "node-red-contrib-room-tools",
3
- "version": "1.8.0",
3
+ "version": "1.8.1",
4
4
  "description": "Room panel message generator and authenticated region gateway for Node-RED and MUSE Automator",
5
5
  "license": "UNLICENSED",
6
6
  "keywords": ["node-red"],
7
+ "dependencies": {
8
+ "acorn": "^8.15.0",
9
+ "escodegen": "^2.1.0"
10
+ },
7
11
  "files": ["gateway.cjs", "room-gateway.js", "room-gateway.html", "room-message.js", "room-message.html", "control-panel.js", "control-panel.html", "control-policy.js", "CONTROL_PANEL_AI_GUIDE.md", "README.md"],
8
12
  "node-red": {
9
13
  "nodes": {