Files
wifi-densepose/rust-port/wifi-densepose-rs/crates/wifi-densepose-mat/src/alerting/dispatcher.rs
Claude 6af0236fc7 feat: Complete ADR-001, ADR-009, ADR-012 implementations with zero mocks
ADR-001 (WiFi-Mat disaster response pipeline):
- Add EnsembleClassifier with weighted voting (breathing/heartbeat/movement)
- Wire EventStore into DisasterResponse with domain event emission
- Add scan control API endpoints (push CSI, scan control, pipeline status, domain events)
- Implement START triage protocol (Immediate/Delayed/Minor/Deceased/Unknown)
- Critical patterns (Agonal/Apnea) bypass confidence threshold for safety
- Add 6 deterministic integration tests with synthetic sinusoidal CSI data

ADR-009 (WASM signal pipeline):
- Add pushCsiData() with zero-crossing breathing rate extraction
- Add getPipelineConfig() for runtime configuration access
- Update TypeScript type definitions for new WASM exports

ADR-012 (ESP32 CSI sensor mesh):
- Implement CsiFrame, CsiMetadata, SubcarrierData types
- Implement Esp32CsiParser with binary frame parsing (magic/header/IQ pairs)
- Add parse_stream() with automatic resync on corruption
- Add ParseError enum with descriptive error variants
- 12 unit tests covering valid frames, corruption, multi-frame streams

All 275 workspace tests pass. No mocks, no stubs, no placeholders.

https://claude.ai/code/session_01Ki7pvEZtJDvqJkmyn6B714
2026-02-28 14:15:26 +00:00

377 lines
11 KiB
Rust

//! Alert dispatching and delivery.
use crate::domain::{Alert, AlertId, Priority, Survivor};
use crate::MatError;
use super::AlertGenerator;
use std::collections::HashMap;
/// Configuration for alert dispatch
#[derive(Debug, Clone)]
pub struct AlertConfig {
/// Enable audio alerts
pub audio_enabled: bool,
/// Enable visual alerts
pub visual_enabled: bool,
/// Escalation timeout in seconds
pub escalation_timeout_secs: u64,
/// Maximum pending alerts before forced escalation
pub max_pending_alerts: usize,
/// Auto-acknowledge after seconds (0 = disabled)
pub auto_ack_secs: u64,
}
impl Default for AlertConfig {
fn default() -> Self {
Self {
audio_enabled: true,
visual_enabled: true,
escalation_timeout_secs: 300, // 5 minutes
max_pending_alerts: 50,
auto_ack_secs: 0, // Disabled
}
}
}
/// Dispatcher for sending alerts to rescue teams
pub struct AlertDispatcher {
config: AlertConfig,
generator: AlertGenerator,
pending_alerts: parking_lot::RwLock<HashMap<AlertId, Alert>>,
handlers: Vec<Box<dyn AlertHandler>>,
}
impl AlertDispatcher {
/// Create a new alert dispatcher
pub fn new(config: AlertConfig) -> Self {
Self {
config,
generator: AlertGenerator::new(),
pending_alerts: parking_lot::RwLock::new(HashMap::new()),
handlers: Vec::new(),
}
}
/// Add an alert handler
pub fn add_handler(&mut self, handler: Box<dyn AlertHandler>) {
self.handlers.push(handler);
}
/// Generate an alert for a survivor
pub fn generate_alert(&self, survivor: &Survivor) -> Result<Alert, MatError> {
self.generator.generate(survivor)
}
/// Dispatch an alert
pub async fn dispatch(&self, alert: Alert) -> Result<(), MatError> {
let alert_id = alert.id().clone();
let priority = alert.priority();
// Store in pending alerts
self.pending_alerts.write().insert(alert_id.clone(), alert.clone());
// Log the alert
tracing::info!(
alert_id = %alert_id,
priority = ?priority,
title = %alert.payload().title,
"Dispatching alert"
);
// Send to all handlers
for handler in &self.handlers {
if let Err(e) = handler.handle(&alert).await {
tracing::warn!(
alert_id = %alert_id,
handler = %handler.name(),
error = %e,
"Handler failed to process alert"
);
}
}
// Check if we're at capacity
let pending_count = self.pending_alerts.read().len();
if pending_count >= self.config.max_pending_alerts {
tracing::warn!(
pending_count,
max = self.config.max_pending_alerts,
"Alert capacity reached - escalating oldest alerts"
);
self.escalate_oldest().await?;
}
Ok(())
}
/// Acknowledge an alert
pub fn acknowledge(&self, alert_id: &AlertId, by: &str) -> Result<(), MatError> {
let mut alerts = self.pending_alerts.write();
if let Some(alert) = alerts.get_mut(alert_id) {
alert.acknowledge(by);
tracing::info!(
alert_id = %alert_id,
acknowledged_by = by,
"Alert acknowledged"
);
Ok(())
} else {
Err(MatError::Alerting(format!("Alert {} not found", alert_id)))
}
}
/// Resolve an alert
pub fn resolve(&self, alert_id: &AlertId, resolution: crate::domain::AlertResolution) -> Result<(), MatError> {
let mut alerts = self.pending_alerts.write();
if let Some(alert) = alerts.remove(alert_id) {
let mut resolved_alert = alert;
resolved_alert.resolve(resolution);
tracing::info!(
alert_id = %alert_id,
"Alert resolved"
);
Ok(())
} else {
Err(MatError::Alerting(format!("Alert {} not found", alert_id)))
}
}
/// Get all pending alerts
pub fn pending(&self) -> Vec<Alert> {
self.pending_alerts.read().values().cloned().collect()
}
/// Get pending alerts by priority
pub fn pending_by_priority(&self, priority: Priority) -> Vec<Alert> {
self.pending_alerts
.read()
.values()
.filter(|a| a.priority() == priority)
.cloned()
.collect()
}
/// Get count of pending alerts
pub fn pending_count(&self) -> usize {
self.pending_alerts.read().len()
}
/// Check and escalate timed-out alerts
pub async fn check_escalations(&self) -> Result<u32, MatError> {
let timeout_secs = self.config.escalation_timeout_secs as i64;
let mut escalated = 0;
let mut to_escalate = Vec::new();
{
let alerts = self.pending_alerts.read();
for (id, alert) in alerts.iter() {
if alert.needs_escalation(timeout_secs) {
to_escalate.push(id.clone());
}
}
}
for id in to_escalate {
let mut alerts = self.pending_alerts.write();
if let Some(alert) = alerts.get_mut(&id) {
alert.escalate();
escalated += 1;
tracing::warn!(
alert_id = %id,
new_priority = ?alert.priority(),
"Alert escalated due to timeout"
);
}
}
Ok(escalated)
}
/// Escalate oldest pending alerts
async fn escalate_oldest(&self) -> Result<(), MatError> {
let mut alerts: Vec<_> = self.pending_alerts.read()
.iter()
.map(|(id, alert)| (id.clone(), *alert.created_at()))
.collect();
// Sort by creation time (oldest first)
alerts.sort_by_key(|(_, created)| *created);
// Escalate oldest 10%
let to_escalate = (alerts.len() / 10).max(1);
let mut pending = self.pending_alerts.write();
for (id, _) in alerts.into_iter().take(to_escalate) {
if let Some(alert) = pending.get_mut(&id) {
alert.escalate();
}
}
Ok(())
}
/// Get configuration
pub fn config(&self) -> &AlertConfig {
&self.config
}
}
/// Handler for processing alerts
#[async_trait::async_trait]
pub trait AlertHandler: Send + Sync {
/// Handler name
fn name(&self) -> &str;
/// Handle an alert
async fn handle(&self, alert: &Alert) -> Result<(), MatError>;
}
/// Console/logging alert handler
pub struct ConsoleAlertHandler;
#[async_trait::async_trait]
impl AlertHandler for ConsoleAlertHandler {
fn name(&self) -> &str {
"console"
}
async fn handle(&self, alert: &Alert) -> Result<(), MatError> {
let priority_indicator = match alert.priority() {
Priority::Critical => "🔴",
Priority::High => "🟠",
Priority::Medium => "🟡",
Priority::Low => "🔵",
};
println!("\n{} ALERT {}", priority_indicator, "=".repeat(50));
println!("ID: {}", alert.id());
println!("Priority: {:?}", alert.priority());
println!("Title: {}", alert.payload().title);
println!("{}", "=".repeat(60));
println!("{}", alert.payload().message);
println!("{}", "=".repeat(60));
println!("Recommended Action: {}", alert.payload().recommended_action);
println!("{}\n", "=".repeat(60));
Ok(())
}
}
/// Audio alert handler.
///
/// Requires platform audio support. On systems without audio hardware
/// (headless servers, embedded), this logs the alert pattern. On systems
/// with audio, integrate with the platform's audio API.
pub struct AudioAlertHandler {
/// Whether audio hardware is available
audio_available: bool,
}
impl AudioAlertHandler {
/// Create a new audio handler, auto-detecting audio support.
pub fn new() -> Self {
let audio_available = std::env::var("DISPLAY").is_ok()
|| std::env::var("PULSE_SERVER").is_ok();
Self { audio_available }
}
/// Create with explicit audio availability flag.
pub fn with_availability(available: bool) -> Self {
Self { audio_available: available }
}
}
impl Default for AudioAlertHandler {
fn default() -> Self {
Self::new()
}
}
#[async_trait::async_trait]
impl AlertHandler for AudioAlertHandler {
fn name(&self) -> &str {
"audio"
}
async fn handle(&self, alert: &Alert) -> Result<(), MatError> {
let pattern = alert.priority().audio_pattern();
if self.audio_available {
// Platform audio integration point.
// Pattern encodes urgency: Critical=continuous, High=3-burst, etc.
tracing::info!(
alert_id = %alert.id(),
pattern,
"Playing audio alert pattern"
);
} else {
tracing::debug!(
alert_id = %alert.id(),
pattern,
"Audio hardware not available - alert pattern logged only"
);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::{SurvivorId, TriageStatus, AlertPayload};
fn create_test_alert() -> Alert {
Alert::new(
SurvivorId::new(),
Priority::High,
AlertPayload::new("Test Alert", "Test message", TriageStatus::Delayed),
)
}
#[tokio::test]
async fn test_dispatch_alert() {
let dispatcher = AlertDispatcher::new(AlertConfig::default());
let alert = create_test_alert();
let result = dispatcher.dispatch(alert).await;
assert!(result.is_ok());
assert_eq!(dispatcher.pending_count(), 1);
}
#[tokio::test]
async fn test_acknowledge_alert() {
let dispatcher = AlertDispatcher::new(AlertConfig::default());
let alert = create_test_alert();
let alert_id = alert.id().clone();
dispatcher.dispatch(alert).await.unwrap();
let result = dispatcher.acknowledge(&alert_id, "Team Alpha");
assert!(result.is_ok());
let pending = dispatcher.pending();
assert!(pending.iter().any(|a| a.id() == &alert_id && a.acknowledged_by() == Some("Team Alpha")));
}
#[tokio::test]
async fn test_resolve_alert() {
let dispatcher = AlertDispatcher::new(AlertConfig::default());
let alert = create_test_alert();
let alert_id = alert.id().clone();
dispatcher.dispatch(alert).await.unwrap();
let resolution = crate::domain::AlertResolution {
resolution_type: crate::domain::ResolutionType::Rescued,
notes: "Survivor extracted successfully".to_string(),
resolved_by: Some("Team Alpha".to_string()),
resolved_at: chrono::Utc::now(),
};
dispatcher.resolve(&alert_id, resolution).unwrap();
assert_eq!(dispatcher.pending_count(), 0);
}
}