@@ -20,6 +20,7 @@ import {
2020 removeAlertDashboard ,
2121 showAlert ,
2222 showAlertDashboard ,
23+ systemModuleState ,
2324 token ,
2425 TriggerComparison ,
2526 TriggerConditionOperator ,
@@ -148,7 +149,8 @@ function calculateModuleIndex(
148149function getModuleDeviceNames ( modules : PlaceModule [ ] ) : string [ ] {
149150 return modules . map ( ( mod ) => {
150151 const driver = mod . driver || ( { class_name : 'System' } as any ) ;
151- const name = mod . custom_name || mod . name || driver . class_name || 'Blank' ;
152+ const name =
153+ mod . custom_name || mod . name || driver . class_name || 'Blank' ;
152154 const index = calculateModuleIndex ( modules , mod ) ;
153155 return `${ name } _${ index } ` ;
154156 } ) ;
@@ -429,7 +431,11 @@ export class DashboardsService extends AsyncHandler {
429431 return device_names ;
430432 } )
431433 . catch ( ( err ) => {
432- log ( 'ALERTS' , `Failed to fetch modules for system ${ system_id } :` , err ) ;
434+ log (
435+ 'ALERTS' ,
436+ `Failed to fetch modules for system ${ system_id } :` ,
437+ err ,
438+ ) ;
433439 this . _pending_module_queries . delete ( system_id ) ;
434440 // Return empty array on error - this will cause the alert to be filtered out
435441 return [ ] ;
@@ -451,6 +457,128 @@ export class DashboardsService extends AsyncHandler {
451457 return module_names . includes ( device ) ;
452458 }
453459
460+ /**
461+ * Get the current value of a status variable from a module.
462+ * Returns undefined if the value cannot be fetched.
463+ */
464+ private async _getModuleStatusValue (
465+ system_id : string ,
466+ device : string ,
467+ status_key : string ,
468+ keys : string [ ] = [ ] ,
469+ ) : Promise < any > {
470+ const parts = ( device || '' ) . split ( '_' ) ;
471+ const module_index = parseInt ( parts . pop ( ) , 10 ) || 1 ;
472+ const module_name = parts . join ( '_' ) ;
473+ if ( ! module_name ) return undefined ;
474+
475+ try {
476+ const state = await lastValueFrom (
477+ systemModuleState ( system_id , module_name , module_index ) ,
478+ ) ;
479+ let value = state ?. [ status_key ] ;
480+ // Navigate through sub-keys if specified
481+ for ( const key of keys || [ ] ) {
482+ if ( value == null ) break ;
483+ value = value [ key ] ;
484+ }
485+ return value ;
486+ } catch ( err ) {
487+ log (
488+ 'ALERTS' ,
489+ `Failed to fetch status value for ${ device } .${ status_key } on system ${ system_id } :` ,
490+ err ,
491+ ) ;
492+ return undefined ;
493+ }
494+ }
495+
496+ /**
497+ * Coerce a value that may be a string representation to its actual type.
498+ * Handles "true"/"false" strings, numeric strings, and null/undefined strings.
499+ */
500+ private _coerceValue ( value : any ) : any {
501+ if ( typeof value !== 'string' ) return value ;
502+
503+ const lower = value . toLowerCase ( ) ;
504+ // Handle boolean strings
505+ if ( lower === 'true' ) return true ;
506+ if ( lower === 'false' ) return false ;
507+ // Handle null/undefined strings
508+ if ( lower === 'null' ) return null ;
509+ if ( lower === 'undefined' ) return undefined ;
510+ // Handle numeric strings
511+ if ( value !== '' && ! isNaN ( Number ( value ) ) ) {
512+ return Number ( value ) ;
513+ }
514+ return value ;
515+ }
516+
517+ /**
518+ * Validate that an alert condition matches the current module state.
519+ * This prevents false positives from delayed or stale MQTT messages.
520+ */
521+ private async _validateAlertCondition (
522+ system_id : string ,
523+ device : string ,
524+ comparison : TriggerComparison ,
525+ ) : Promise < boolean > {
526+ // Get the status key and subkeys from the comparison
527+ let status_key : string ;
528+ let keys : string [ ] = [ ] ;
529+ if ( comparison . left instanceof Object ) {
530+ status_key = comparison . left . status ;
531+ keys = comparison . left . keys || [ ] ;
532+ } else {
533+ status_key = comparison . left as string ;
534+ }
535+
536+ // Fetch the current value from the module
537+ const raw_value = await this . _getModuleStatusValue (
538+ system_id ,
539+ device ,
540+ status_key ,
541+ keys ,
542+ ) ;
543+
544+ if ( raw_value === undefined ) {
545+ // If we can't fetch the value, don't validate (let alert through)
546+ log (
547+ 'ALERTS' ,
548+ `Could not fetch current value for ${ device } .${ status_key } , skipping validation` ,
549+ ) ;
550+ return true ;
551+ }
552+
553+ // Coerce values that may be string representations to their actual types
554+ const current_value = this . _coerceValue ( raw_value ) ;
555+ const expected = this . _coerceValue ( comparison . right ) ;
556+
557+ // Validate against the comparison operator
558+ switch ( comparison . operator ) {
559+ case TriggerConditionOperator . EQ :
560+ return current_value === expected ;
561+ case TriggerConditionOperator . NEQ :
562+ return current_value !== expected ;
563+ case TriggerConditionOperator . GT :
564+ return current_value > expected ;
565+ case TriggerConditionOperator . LT :
566+ return current_value < expected ;
567+ case TriggerConditionOperator . GTE :
568+ return current_value >= expected ;
569+ case TriggerConditionOperator . LTE :
570+ return current_value <= expected ;
571+ case TriggerConditionOperator . AND :
572+ return ! ! ( current_value && expected ) ;
573+ case TriggerConditionOperator . OR :
574+ return ! ! ( current_value || expected ) ;
575+ case TriggerConditionOperator . XOR :
576+ return ! ! current_value !== ! ! expected ;
577+ default :
578+ return true ;
579+ }
580+ }
581+
454582 private _listenToAlertTopics ( ) {
455583 if ( ! this . _connected ) {
456584 return this . timeout ( 'alert_topics' , ( ) =>
@@ -512,7 +640,10 @@ export class DashboardsService extends AsyncHandler {
512640 'update_alerts' ,
513641 async ( ) => {
514642 const alert_list = this . alerts_list ( ) ;
515- let alert_out : Alert [ ] = [ ] ;
643+ // Track alerts with their triggering comparisons for validation
644+ let alert_out : ( Alert & {
645+ _comparisons ?: TriggerComparison [ ] ;
646+ } ) [ ] = [ ] ;
516647 for ( const alert of alert_list ) {
517648 const comparision_matches : any [ ] [ ] = [ ] ;
518649 for ( const item of alert . conditions . comparisons ) {
@@ -555,26 +686,54 @@ export class DashboardsService extends AsyncHandler {
555686 body : alert . description ,
556687 status : 'open' ,
557688 timestamp : time * 1000 ,
689+ // Store comparisons for validation
690+ _comparisons : alert . conditions . comparisons ,
558691 } ) ;
559692 }
560693 }
561694 alert_out = unique ( alert_out , 'id' ) ;
562695 }
563696
564697 // Filter out alerts where the module no longer exists on the system
698+ // and validate that current state matches the alert condition
565699 const filtered_alerts : Alert [ ] = [ ] ;
566700 for ( const alert of alert_out ) {
567701 const exists = await this . _moduleExistsOnSystem (
568702 alert . location ,
569703 alert . device ,
570704 ) ;
571- if ( exists ) {
572- filtered_alerts . push ( alert ) ;
573- } else {
705+ if ( ! exists ) {
574706 log (
575707 'ALERTS' ,
576708 `Filtering out alert for non-existent module: ${ alert . device } on system ${ alert . location } ` ,
577709 ) ;
710+ continue ;
711+ }
712+
713+ // Validate current state matches at least one comparison
714+ const comparisons = alert . _comparisons || [ ] ;
715+ let valid = comparisons . length === 0 ; // If no comparisons, consider valid
716+ for ( const comparison of comparisons ) {
717+ const is_valid = await this . _validateAlertCondition (
718+ alert . location ,
719+ alert . device ,
720+ comparison ,
721+ ) ;
722+ if ( is_valid ) {
723+ valid = true ;
724+ break ; // At least one comparison is still valid
725+ }
726+ }
727+
728+ if ( valid ) {
729+ // Remove internal _comparisons before adding to output
730+ const { _comparisons, ...clean_alert } = alert ;
731+ filtered_alerts . push ( clean_alert ) ;
732+ } else {
733+ log (
734+ 'ALERTS' ,
735+ `Filtering out alert for ${ alert . device } on system ${ alert . location } : current state no longer matches condition` ,
736+ ) ;
578737 }
579738 }
580739
0 commit comments