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
377 lines
11 KiB
Rust
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);
|
|
}
|
|
}
|