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.