Skip to content

Commit ec898f6

Browse files
committed
chore(stagehand): add validation to prevent false positives
1 parent 400e2eb commit ec898f6

1 file changed

Lines changed: 165 additions & 6 deletions

File tree

apps/stagehand/src/app/dashboards/dashboards.service.ts

Lines changed: 165 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -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(
148149
function 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

Comments
 (0)