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.
- package/control-policy.js +337 -24
- 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 (
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
return format
|
|
743
|
+
let wrapped
|
|
744
|
+
try {
|
|
745
|
+
wrapped = mergeDashboardOptions(source)
|
|
746
|
+
} catch (_) {
|
|
747
|
+
return format
|
|
425
748
|
}
|
|
426
|
-
|
|
427
|
-
|
|
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.
|
|
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": {
|