Home Wiki Programming & Logic Capstone Project: Building a Complete Industrial Monitoring System in Rust
Programming & Logic

Capstone Project: Building a Complete Industrial Monitoring System in Rust

Project Overview and Architecture

This final lesson brings together everything from the series into a complete industrial monitoring system. The project reads sensors, processes data, evaluates alarm rules, and outputs a dashboard summary.

The data flows through four stages:

Sensor Reader -> Data Processor -> Alarm Engine -> Dashboard Output
     (read)        (validate)       (evaluate)       (display)

Each stage is a separate crate in a Cargo workspace. This mirrors how real industrial software is structured: modular, testable, and independently deployable.

Project Structure: Workspace with Multiple Crates

Using the workspace pattern from Lesson 11, the project is organized as follows:

factory-monitor/
  Cargo.toml          # Workspace root
  crates/
    sensor/            # Reading and validating sensor data
      src/lib.rs
    alarm/             # Alarm rules and notifications
      src/lib.rs
    monitor/           # Async runtime, ties everything together
      src/main.rs

The root Cargo.toml defines the workspace:

[workspace]
members = ["crates/sensor", "crates/alarm", "crates/monitor"]

[workspace.dependencies]
tokio = { version = "1", features = ["full"] }

Each crate declares its own dependencies. The monitor crate depends on sensor and alarm:

# crates/monitor/Cargo.toml
[dependencies]
sensor = { path = "../sensor" }
alarm = { path = "../alarm" }
tokio = { workspace = true }

The Sensor Module: Reading and Validating Data

The sensor crate defines the core data types and validation logic using structs, enums, traits, and error handling from earlier lessons.

// crates/sensor/src/lib.rs
use std::fmt;

#[derive(Debug, Clone, PartialEq)]
pub enum SensorType {
    Temperature,
    Pressure,
    Vibration,
}

#[derive(Debug, Clone)]
pub struct SensorReading {
    pub sensor_id: u32,
    pub sensor_type: SensorType,
    pub value: f64,
    pub timestamp: u64,
}

#[derive(Debug, PartialEq)]
pub enum SensorError {
    OutOfRange { sensor_id: u32, value: f64 },
    Disconnected(u32),
}

impl fmt::Display for SensorError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            SensorError::OutOfRange { sensor_id, value } => {
                write!(f, "Sensor {sensor_id}: value {value} out of range")
            }
            SensorError::Disconnected(id) => write!(f, "Sensor {id}: disconnected"),
        }
    }
}

/// Validates a reading against physical limits for its sensor type.
pub fn validate(reading: &SensorReading) -> Result<(), SensorError> {
    let (min, max) = match reading.sensor_type {
        SensorType::Temperature => (-40.0, 200.0),
        SensorType::Pressure => (0.0, 300.0),
        SensorType::Vibration => (0.0, 50.0),
    };

    if reading.value < min || reading.value > max {
        return Err(SensorError::OutOfRange {
            sensor_id: reading.sensor_id,
            value: reading.value,
        });
    }
    Ok(())
}

The Alarm Engine: Rules and Notifications

The alarm crate evaluates readings using pattern matching, collections, and iterators.

// crates/alarm/src/lib.rs
use sensor::{SensorReading, SensorType};

#[derive(Debug, Clone, PartialEq)]
pub enum AlarmLevel {
    None,
    Warning,
    Critical,
}

#[derive(Debug, Clone)]
pub struct Alarm {
    pub sensor_id: u32,
    pub level: AlarmLevel,
    pub message: String,
}

/// Threshold rule: (warning_limit, critical_limit)
fn thresholds(sensor_type: &SensorType) -> (f64, f64) {
    match sensor_type {
        SensorType::Temperature => (80.0, 120.0),
        SensorType::Pressure => (200.0, 260.0),
        SensorType::Vibration => (25.0, 40.0),
    }
}

/// Evaluates a single reading against alarm rules.
pub fn evaluate(reading: &SensorReading) -> Alarm {
    let (warn, crit) = thresholds(&reading.sensor_type);

    let (level, message) = if reading.value >= crit {
        (AlarmLevel::Critical, format!(
            "CRITICAL: {:?} sensor {} at {:.1}", reading.sensor_type, reading.sensor_id, reading.value
        ))
    } else if reading.value >= warn {
        (AlarmLevel::Warning, format!(
            "WARNING: {:?} sensor {} at {:.1}", reading.sensor_type, reading.sensor_id, reading.value
        ))
    } else {
        (AlarmLevel::None, String::new())
    };

    Alarm { sensor_id: reading.sensor_id, level, message }
}

/// Evaluates a batch of readings and returns only active alarms.
pub fn evaluate_batch(readings: &[SensorReading]) -> Vec<Alarm> {
    readings.iter()
        .map(evaluate)
        .filter(|a| a.level != AlarmLevel::None)
        .collect()
}

The Async Runtime: Concurrent Monitoring

The monitor crate uses Tokio to read all sensors concurrently, applying lessons from Lesson 13 on async tasks and channels.

// crates/monitor/src/main.rs
use sensor::{SensorReading, SensorType, SensorError};
use alarm::{evaluate_batch, AlarmLevel};
use tokio::sync::mpsc;
use tokio::time::{interval, Duration, timeout};

/// Simulates reading a sensor over the network.
async fn read_sensor(id: u32, sensor_type: SensorType) -> Result<SensorReading, SensorError> {
    // Simulate network latency
    tokio::time::sleep(Duration::from_millis(10 + (id as u64 % 50))).await;

    // Simulate occasional disconnection
    if id % 15 == 0 {
        return Err(SensorError::Disconnected(id));
    }

    Ok(SensorReading {
        sensor_id: id,
        sensor_type,
        value: 20.0 + (id as f64 * 1.7) % 100.0,
        timestamp: 1700000000 + id as u64,
    })
}

#[tokio::main]
async fn main() {
    println!("=== Factory Monitor Starting ===");

    let (tx, mut rx) = mpsc::channel::<SensorReading>(256);
    let mut poll_interval = interval(Duration::from_secs(5));

    // Sensor definitions: (id, type)
    let sensors: Vec<(u32, SensorType)> = (1..=30)
        .map(|id| {
            let stype = match id % 3 {
                0 => SensorType::Temperature,
                1 => SensorType::Pressure,
                _ => SensorType::Vibration,
            };
            (id, stype)
        })
        .collect();

    // Run 3 polling cycles
    for cycle in 1..=3 {
        poll_interval.tick().await;
        println!("\n--- Poll Cycle {cycle} ---");

        // Spawn concurrent reads for all sensors
        for &(id, ref stype) in &sensors {
            let tx = tx.clone();
            let stype = stype.clone();
            tokio::spawn(async move {
                match timeout(Duration::from_secs(2), read_sensor(id, stype)).await {
                    Ok(Ok(reading)) => { let _ = tx.send(reading).await; }
                    Ok(Err(e)) => eprintln!("  Error: {e}"),
                    Err(_) => eprintln!("  Timeout: sensor {id}"),
                }
            });
        }

        // Brief wait for tasks to complete, then collect
        tokio::time::sleep(Duration::from_millis(200)).await;

        let mut readings = Vec::new();
        while let Ok(r) = rx.try_recv() {
            readings.push(r);
        }

        // Validate and evaluate alarms
        let valid: Vec<_> = readings.iter()
            .filter(|r| sensor::validate(r).is_ok())
            .cloned()
            .collect();

        let alarms = evaluate_batch(&valid);
        let critical = alarms.iter().filter(|a| a.level == AlarmLevel::Critical).count();
        let warnings = alarms.iter().filter(|a| a.level == AlarmLevel::Warning).count();

        println!("  Readings: {} | Alarms: {} critical, {} warning",
            valid.len(), critical, warnings);

        for a in &alarms {
            println!("  {}", a.message);
        }
    }

    println!("\n=== Monitor Shutdown ===");
}

Adding Tests for Critical Paths

Applying Lesson 14, we add tests to the sensor and alarm crates to verify critical behavior.

// In crates/sensor/src/lib.rs
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_valid_temperature() {
        let reading = SensorReading {
            sensor_id: 1, sensor_type: SensorType::Temperature,
            value: 50.0, timestamp: 0,
        };
        assert!(validate(&reading).is_ok());
    }

    #[test]
    fn test_overpressure_rejected() {
        let reading = SensorReading {
            sensor_id: 2, sensor_type: SensorType::Pressure,
            value: 350.0, timestamp: 0,
        };
        assert_eq!(
            validate(&reading),
            Err(SensorError::OutOfRange { sensor_id: 2, value: 350.0 })
        );
    }
}

// In crates/alarm/src/lib.rs
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_critical_temperature_alarm() {
        let reading = SensorReading {
            sensor_id: 5, sensor_type: SensorType::Temperature,
            value: 130.0, timestamp: 0,
        };
        let alarm = evaluate(&reading);
        assert_eq!(alarm.level, AlarmLevel::Critical);
    }

    #[test]
    fn test_normal_reading_no_alarm() {
        let reading = SensorReading {
            sensor_id: 1, sensor_type: SensorType::Pressure,
            value: 100.0, timestamp: 0,
        };
        let alarm = evaluate(&reading);
        assert_eq!(alarm.level, AlarmLevel::None);
    }

    #[test]
    fn test_batch_filters_non_alarms() {
        let readings = vec![
            SensorReading { sensor_id: 1, sensor_type: SensorType::Temperature, value: 25.0, timestamp: 0 },
            SensorReading { sensor_id: 2, sensor_type: SensorType::Temperature, value: 130.0, timestamp: 0 },
        ];
        let alarms = evaluate_batch(&readings);
        assert_eq!(alarms.len(), 1);
        assert_eq!(alarms[0].sensor_id, 2);
    }
}

Running the Complete System

Build and run the entire workspace from the root directory:

# Build all crates
cargo build

# Run all tests across the workspace
cargo test --workspace

# Run the monitor application
cargo run -p monitor

Expected output shows three polling cycles, each reading 30 sensors concurrently and reporting alarms:

=== Factory Monitor Starting ===

--- Poll Cycle 1 ---
  Error: Sensor 15: disconnected
  Error: Sensor 30: disconnected
  Readings: 28 | Alarms: 3 critical, 5 warning
  WARNING: Temperature sensor 3 at 85.1
  CRITICAL: Vibration sensor 8 at 43.6
  ...

=== Monitor Shutdown ===

The system demonstrates async concurrency, error handling, modular architecture, and testing working together in a realistic industrial application.

Summary and Next Steps

This series covered the Rust fundamentals needed for industrial software:

  • Lessons 1-4: Variables, types, ownership, control flow -- the language foundations.
  • Lessons 5-8: Structs, enums, traits, error handling -- modeling industrial domains.
  • Lessons 9-12: Collections, iterators, modules, crates -- organizing larger systems.
  • Lessons 13-14: Async programming and testing -- production readiness.
  • Lesson 15: Bringing it all together in a workspace project.

To continue learning, explore these areas:

  • Embedded Rust (no_std): Run Rust directly on microcontrollers and PLCs.
  • Web frameworks (Axum, Actix): Build dashboards and REST APIs for sensor data.
  • SurrealDB or SQLx: Persist readings and alarm history to a database.
  • MQTT and Modbus crates: Communicate with real industrial protocols.
  • The Rust Embedded book: https://docs.rust-embedded.org/book/

Rust's safety guarantees make it an excellent choice for systems where reliability matters. Every concept in this series applies directly to building software that controls and monitors physical machines.

project monitoring dashboard alarms architecture full-stack مشروع تطبيقي نظام مراقبة لوحة المراقبة الإنذارات الهندسة المعمارية التطبيق الشامل