Chapter 16: Data & Communication
Learning Objectives
This chapter covers:
- Use Serde for serialization in no_std embedded environments
- Send structured temperature data as JSON over USB Serial
- Implement efficient binary protocols with postcard
- Create command/response interfaces for embedded systems
- Handle communication errors gracefully in resource-constrained environments
- Design protocols optimized for IoT and embedded applications
Task: Send Structured Temperature Data via JSON
Building on chapters 13-15, where we created temperature monitoring with testing, now we need to enable communication with external systems.
Your Mission:
- Add serialization support to temperature data structures using Serde
- Send JSON data over USB Serial for monitoring dashboards
- Implement command/response protocol for remote control
- Use fixed-size strings and heapless collections for efficiency
- Handle communication errors gracefully in resource-constrained environment
Why This Matters:
- Remote monitoring: Send data to dashboards and cloud services
- Remote control: Change settings without reflashing firmware
- Interoperability: JSON works with any programming language
- Debugging: Structured data makes debugging easier than raw values
The Challenge:
- No heap allocation for JSON serialization
- Fixed-size buffers for serial communication
- Error handling without panicking
Serde in no_std: Serialization for Embedded
Serde is Rust’s premier serialization framework, and it works great in no_std environments:
[package]
name = "chapter16_communication"
version = "0.1.0"
edition = "2024"
rust-version = "1.88"
[[bin]]
name = "chapter16_communication"
path = "./src/bin/main.rs"
[lib]
name = "chapter16_communication"
path = "src/lib.rs"
[dependencies]
# Only include ESP dependencies when not testing
esp-hal = { version = "1.0.0", features = ["esp32c3", "unstable"], optional = true }
esp-bootloader-esp-idf = { version = "0.4.0", features = ["esp32c3"], optional = true }
esp-println = { version = "0.16", features = ["esp32c3"], optional = true }
# Core dependencies
critical-section = "1.2.0"
heapless = "0.8"
# Serialization
serde = { version = "1.0", default-features = false, features = ["derive"] }
serde-json-core = "0.6"
[features]
default = ["esp-hal", "esp-println", "esp-bootloader-esp-idf"]
embedded = ["esp-hal", "esp-println", "esp-bootloader-esp-idf"]
Making Temperature Data Serializable
Let’s update our temperature types to support serialization:
#![allow(unused)] fn main() { // src/temperature.rs - Updated with serde support #![cfg_attr(not(test), no_std)] use serde::{Deserialize, Serialize}; use core::fmt; #[cfg(test)] use std::vec::Vec; #[cfg(not(test))] use heapless::Vec; #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] pub struct Temperature { celsius_tenths: i16, } impl Temperature { pub const fn from_celsius(celsius: f32) -> Self { Self { celsius_tenths: (celsius * 10.0) as i16, } } pub fn celsius(&self) -> f32 { self.celsius_tenths as f32 / 10.0 } pub fn fahrenheit(&self) -> f32 { self.celsius() * 9.0 / 5.0 + 32.0 } pub const fn is_overheating(&self) -> bool { self.celsius_tenths > 500 // > 50°C } // Helper for JSON serialization with nice format pub fn to_celsius_rounded(&self) -> f32 { (self.celsius() * 10.0).round() / 10.0 } } #[derive(Debug, Clone, Copy, Serialize, Deserialize)] pub struct TemperatureReading { pub temperature: Temperature, pub timestamp_ms: u32, pub sensor_id: u8, // Compact sensor identifier } impl TemperatureReading { pub fn new(temperature: Temperature, timestamp_ms: u32, sensor_id: u8) -> Self { Self { temperature, timestamp_ms, sensor_id, } } pub fn current_time(temperature: Temperature) -> Self { // In real implementation, this would get actual timestamp // For now, use a simple counter static mut TIMESTAMP: u32 = 0; unsafe { TIMESTAMP += 1000; // Simulate 1-second intervals Self::new(temperature, TIMESTAMP, 0) } } } #[derive(Debug, Clone, Copy, Serialize, Deserialize)] pub struct TemperatureStats { pub count: u16, // Use u16 to save space pub total_count: u32, pub min_celsius: f32, // Store as f32 for JSON compatibility pub max_celsius: f32, pub avg_celsius: f32, pub timestamp_ms: u32, } impl TemperatureStats { pub fn from_buffer<const N: usize>( buffer: &TemperatureBuffer<N>, timestamp_ms: u32 ) -> Option<Self> { if buffer.len() == 0 { return None; } let min = buffer.min()?.celsius(); let max = buffer.max()?.celsius(); let avg = buffer.average()?.celsius(); Some(Self { count: buffer.len() as u16, total_count: buffer.total_readings(), min_celsius: min, max_celsius: max, avg_celsius: avg, timestamp_ms, }) } } }
JSON Serialization with serde-json-core
For IoT integration, JSON is widely supported but needs special handling in no_std:
#![allow(unused)] fn main() { // src/communication.rs - JSON communication module #![cfg_attr(not(test), no_std)] use heapless::{String, Vec}; use serde::{Deserialize, Serialize}; use serde_json_core; use crate::temperature::{Temperature, TemperatureReading, TemperatureStats}; /// Commands that can be sent to the temperature monitor #[derive(Debug, Clone, Serialize, Deserialize)] pub enum Command { GetStatus, GetLatestReading, GetStats, SetSampleRate { rate_hz: u8 }, SetThreshold { threshold_celsius: f32 }, Reset, } /// Responses from the temperature monitor #[derive(Debug, Clone, Serialize, Deserialize)] pub enum Response { Status { uptime_ms: u32, sample_rate_hz: u8, threshold_celsius: f32, buffer_usage: u8, // Percentage full }, Reading(TemperatureReading), Stats(TemperatureStats), SampleRateSet(u8), ThresholdSet(f32), ResetComplete, Error { code: u8, message: String<32> }, } impl Response { pub fn error(code: u8, message: &str) -> Self { let mut error_message = String::new(); error_message.push_str(message).ok(); Self::Error { code, message: error_message, } } } /// Communication handler for temperature monitor pub struct TemperatureComm { sample_rate_hz: u8, threshold_celsius: f32, start_time_ms: u32, } impl TemperatureComm { pub const fn new() -> Self { Self { sample_rate_hz: 1, // 1 Hz default threshold_celsius: 35.0, start_time_ms: 0, } } pub fn init(&mut self, start_time_ms: u32) { self.start_time_ms = start_time_ms; } /// Process a command and return appropriate response pub fn process_command<const N: usize>( &mut self, command: Command, buffer: &TemperatureBuffer<N>, current_time_ms: u32 ) -> Response { match command { Command::GetStatus => { let uptime = current_time_ms.saturating_sub(self.start_time_ms); let buffer_usage = if buffer.capacity() > 0 { ((buffer.len() * 100) / buffer.capacity()) as u8 } else { 0 }; Response::Status { uptime_ms: uptime, sample_rate_hz: self.sample_rate_hz, threshold_celsius: self.threshold_celsius, buffer_usage, } } Command::GetLatestReading => { if let Some(temp) = buffer.latest() { let reading = TemperatureReading::new(temp, current_time_ms, 0); Response::Reading(reading) } else { Response::error(1, "No readings available") } } Command::GetStats => { if let Some(stats) = TemperatureStats::from_buffer(buffer, current_time_ms) { Response::Stats(stats) } else { Response::error(2, "No data for statistics") } } Command::SetSampleRate { rate_hz } => { if rate_hz > 0 && rate_hz <= 10 { self.sample_rate_hz = rate_hz; Response::SampleRateSet(rate_hz) } else { Response::error(3, "Rate must be 1-10 Hz") } } Command::SetThreshold { threshold_celsius } => { if threshold_celsius > 0.0 && threshold_celsius < 100.0 { self.threshold_celsius = threshold_celsius; Response::ThresholdSet(threshold_celsius) } else { Response::error(4, "Threshold must be 0-100°C") } } Command::Reset => { self.start_time_ms = current_time_ms; self.sample_rate_hz = 1; self.threshold_celsius = 35.0; Response::ResetComplete } } } /// Serialize response to JSON string for transmission pub fn response_to_json(&self, response: &Response) -> Result<String<512>, ()> { // Use heapless String with fixed capacity match serde_json_core::to_string::<_, 512>(response) { Ok(json) => Ok(json), Err(_) => Err(()), } } /// Deserialize command from JSON string pub fn json_to_command(&self, json: &str) -> Result<Command, ()> { match serde_json_core::from_str(json) { Ok(command) => Ok(command), Err(_) => Err(()), } } /// Create a status response as JSON pub fn status_json<const N: usize>( &self, buffer: &TemperatureBuffer<N>, current_time_ms: u32 ) -> String<256> { let status = self.process_command( Command::GetStatus, buffer, current_time_ms ); self.response_to_json(&status) .unwrap_or_else(|_| { let mut error = String::new(); error.push_str("{\"error\":\"serialization_failed\"}").ok(); error }) } /// Create latest reading as JSON pub fn reading_json<const N: usize>( &self, buffer: &TemperatureBuffer<N>, current_time_ms: u32 ) -> String<256> { let reading = self.process_command( Command::GetLatestReading, buffer, current_time_ms ); self.response_to_json(&reading) .unwrap_or_else(|_| { let mut error = String::new(); error.push_str("{\"error\":\"no_reading\"}").ok(); error }) } pub fn sample_rate(&self) -> u8 { self.sample_rate_hz } pub fn threshold(&self) -> f32 { self.threshold_celsius } } #[cfg(test)] mod tests { use super::*; use crate::temperature::TemperatureBuffer; #[test] fn test_json_serialization() { let temp = Temperature::from_celsius(23.5); let reading = TemperatureReading::new(temp, 1000, 0); // Test command serialization let command = Command::GetStatus; let json = serde_json_core::to_string::<_, 64>(&command).unwrap(); assert_eq!(json, "\"GetStatus\""); // Test response serialization let response = Response::Reading(reading); let json = serde_json_core::to_string::<_, 256>(&response).unwrap(); assert!(json.contains("Reading")); assert!(json.contains("23.5")); } #[test] fn test_command_processing() { let mut comm = TemperatureComm::new(); comm.init(0); let buffer = TemperatureBuffer::<5>::new(); // Test status command let status_resp = comm.process_command(Command::GetStatus, &buffer, 5000); if let Response::Status { uptime_ms, .. } = status_resp { assert_eq!(uptime_ms, 5000); } else { panic!("Expected status response"); } // Test rate setting let rate_resp = comm.process_command( Command::SetSampleRate { rate_hz: 5 }, &buffer, 5000 ); assert!(matches!(rate_resp, Response::SampleRateSet(5))); assert_eq!(comm.sample_rate(), 5); } #[test] fn test_json_roundtrip() { let mut comm = TemperatureComm::new(); // Test command deserialization let json_cmd = "\"GetStatus\""; let command = comm.json_to_command(json_cmd).unwrap(); assert!(matches!(command, Command::GetStatus)); // Test response serialization let response = Response::ResetComplete; let json_resp = comm.response_to_json(&response).unwrap(); assert_eq!(json_resp, "\"ResetComplete\""); } #[test] fn test_error_handling() { let mut comm = TemperatureComm::new(); let buffer = TemperatureBuffer::<5>::new(); // Test invalid sample rate let response = comm.process_command( Command::SetSampleRate { rate_hz: 20 }, // Invalid: too high &buffer, 0 ); if let Response::Error { code, message } = response { assert_eq!(code, 3); assert!(message.contains("Rate must be")); } else { panic!("Expected error response"); } } } }
Binary Serialization with postcard
For bandwidth-constrained applications, binary serialization is more efficient:
#![allow(unused)] fn main() { // src/binary_comm.rs - Binary communication with postcard #![cfg_attr(not(test), no_std)] use heapless::Vec; use serde::{Deserialize, Serialize}; use postcard; use crate::communication::{Command, Response}; /// Binary communication handler pub struct BinaryComm; impl BinaryComm { /// Serialize command to binary format pub fn command_to_binary(command: &Command) -> Result<Vec<u8, 64>, postcard::Error> { postcard::to_vec(command) } /// Deserialize command from binary format pub fn binary_to_command(data: &[u8]) -> Result<Command, postcard::Error> { postcard::from_bytes(data) } /// Serialize response to binary format pub fn response_to_binary(response: &Response) -> Result<Vec<u8, 256>, postcard::Error> { postcard::to_vec(response) } /// Deserialize response from binary format pub fn binary_to_response(data: &[u8]) -> Result<Response, postcard::Error> { postcard::from_bytes(data) } /// Get size of serialized command pub fn command_size(command: &Command) -> usize { Self::command_to_binary(command) .map(|v| v.len()) .unwrap_or(0) } /// Get size of serialized response pub fn response_size(response: &Response) -> usize { Self::response_to_binary(response) .map(|v| v.len()) .unwrap_or(0) } } #[cfg(test)] mod tests { use super::*; use crate::temperature::{Temperature, TemperatureReading}; #[test] fn test_binary_command_serialization() { let command = Command::SetSampleRate { rate_hz: 5 }; // Serialize to binary let binary = BinaryComm::command_to_binary(&command).unwrap(); // Deserialize back let deserialized = BinaryComm::binary_to_command(&binary).unwrap(); if let Command::SetSampleRate { rate_hz } = deserialized { assert_eq!(rate_hz, 5); } else { panic!("Deserialization failed"); } } #[test] fn test_binary_response_serialization() { let temp = Temperature::from_celsius(25.0); let reading = TemperatureReading::new(temp, 1000, 0); let response = Response::Reading(reading); // Serialize to binary let binary = BinaryComm::response_to_binary(&response).unwrap(); // Should be much smaller than JSON println!("Binary size: {} bytes", binary.len()); assert!(binary.len() < 20); // Much smaller than JSON // Deserialize back let deserialized = BinaryComm::binary_to_response(&binary).unwrap(); if let Response::Reading(r) = deserialized { assert!((r.temperature.celsius() - 25.0).abs() < 0.1); assert_eq!(r.timestamp_ms, 1000); } else { panic!("Deserialization failed"); } } #[test] fn test_size_comparison() { let temp = Temperature::from_celsius(23.5); let reading = TemperatureReading::new(temp, 1000, 0); let response = Response::Reading(reading); // Binary size let binary_size = BinaryComm::response_size(&response); // JSON size (approximate) let json = serde_json_core::to_string::<_, 256>(&response).unwrap(); let json_size = json.len(); println!("Binary: {} bytes, JSON: {} bytes", binary_size, json_size); println!("Binary is {}% smaller", ((json_size - binary_size) * 100) / json_size); assert!(binary_size < json_size); assert!(binary_size < 16); // Binary should be very compact } } }
Integrating Communication with ESP32-C3
Let’s update our main application to use these communication capabilities:
// src/bin/main.rs - ESP32 temperature monitor with communication #![no_std] #![no_main] #![deny( clippy::mem_forget, reason = "mem::forget is generally not safe to do with esp_hal types" )] use esp_hal::clock::CpuClock; use esp_hal::gpio::{Level, Output, OutputConfig}; use esp_hal::main; use esp_hal::time::{Duration, Instant}; use esp_hal::tsens::{Config, TemperatureSensor}; // Use the communication library types use chapter16_communication::{Temperature, TemperatureBuffer, Command, TemperatureComm}; const BUFFER_SIZE: usize = 20; const SAMPLE_INTERVAL_MS: u64 = 1000; // 1 second #[panic_handler] fn panic(info: &core::panic::PanicInfo) -> ! { esp_println::println!("💥 SYSTEM PANIC: {}", info); loop {} } esp_bootloader_esp_idf::esp_app_desc!(); #[main] fn main() -> ! { // Initialize hardware let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max()); let peripherals = esp_hal::init(config); // Initialize GPIO for LED on GPIO8 let mut led = Output::new(peripherals.GPIO8, Level::Low, OutputConfig::default()); // Initialize the built-in temperature sensor let temp_sensor = TemperatureSensor::new(peripherals.TSENS, Config::default()).unwrap(); // Create fixed-capacity temperature buffer let mut temp_buffer = TemperatureBuffer::<BUFFER_SIZE>::new(); // Initialize communication handler let mut comm = TemperatureComm::new(); comm.init(0); // Startup messages with JSON communication esp_println::println!("🌡️ ESP32-C3 Temperature Monitor with Communication"); esp_println::println!("📊 Buffer capacity: {} readings", temp_buffer.capacity()); esp_println::println!("📡 JSON communication enabled"); esp_println::println!("🔧 Send commands: status, reading, stats, reset"); esp_println::println!(); // Demonstrate initial JSON output let status_json = comm.status_json(&temp_buffer, 0); esp_println::println!("INITIAL_STATUS: {}", status_json); esp_println::println!(); let mut reading_count = 0u32; // Main monitoring loop loop { // Get current timestamp (simplified) let current_time = reading_count * SAMPLE_INTERVAL_MS as u32; // Small stabilization delay (recommended by ESP-HAL) let delay_start = Instant::now(); while delay_start.elapsed() < Duration::from_micros(200) {} // Read temperature from built-in sensor let esp_temperature = temp_sensor.get_temperature(); let temp_celsius = esp_temperature.to_celsius(); let temperature = Temperature::from_celsius(temp_celsius); // Store in buffer temp_buffer.push(temperature); reading_count += 1; // LED status based on temperature if temperature.is_overheating() { // Rapid triple blink for overheating (>50°C) for _ in 0..3 { led.set_high(); let blink_start = Instant::now(); while blink_start.elapsed() < Duration::from_millis(100) {} led.set_low(); let blink_start = Instant::now(); while blink_start.elapsed() < Duration::from_millis(100) {} } } else if !temperature.is_normal_range() { // Double blink for out of normal range (not 15-35°C) led.set_high(); let blink_start = Instant::now(); while blink_start.elapsed() < Duration::from_millis(150) {} led.set_low(); let blink_start = Instant::now(); while blink_start.elapsed() < Duration::from_millis(100) {} led.set_high(); let blink_start = Instant::now(); while blink_start.elapsed() < Duration::from_millis(150) {} led.set_low(); } else { // Single blink for normal temperature led.set_high(); let blink_start = Instant::now(); while blink_start.elapsed() < Duration::from_millis(200) {} led.set_low(); } // Output structured JSON data let reading_json = comm.latest_reading_json(&temp_buffer, current_time); esp_println::println!("READING: {}", reading_json); // Print statistics every 5 readings if reading_count % 5 == 0 { let stats_json = comm.stats_json(&temp_buffer, current_time); esp_println::println!("STATS: {}", stats_json); let status_json = comm.status_json(&temp_buffer, current_time); esp_println::println!("STATUS: {}", status_json); esp_println::println!(); } // Wait for next sample let wait_start = Instant::now(); while wait_start.elapsed() < Duration::from_millis(SAMPLE_INTERVAL_MS) {} } }
Example Output
When you run this on the ESP32-C3, you’ll see structured JSON output like:
🌡️ ESP32-C3 Temperature Monitor with Communication
📊 Buffer capacity: 20 readings
📡 JSON communication enabled
🔧 Send commands: status, reading, stats, reset
INITIAL_STATUS: {"Status":{"uptime_ms":0,"sample_rate_hz":1,"threshold_celsius":35.0,"buffer_usage":0}}
READING: {"Reading":{"temperature":{"celsius_tenths":523},"timestamp_ms":1000,"sensor_id":0}}
READING: {"Reading":{"temperature":{"celsius_tenths":524},"timestamp_ms":2000,"sensor_id":0}}
READING: {"Reading":{"temperature":{"celsius_tenths":521},"timestamp_ms":3000,"sensor_id":0}}
READING: {"Reading":{"temperature":{"celsius_tenths":522},"timestamp_ms":4000,"sensor_id":0}}
READING: {"Reading":{"temperature":{"celsius_tenths":523},"timestamp_ms":5000,"sensor_id":0}}
STATS: {"Stats":{"count":5,"total_count":5,"average":{"celsius_tenths":523},"min":{"celsius_tenths":521},"max":{"celsius_tenths":524},"timestamp_ms":5000}}
STATUS: {"Status":{"uptime_ms":5000,"sample_rate_hz":1,"threshold_celsius":35.0,"buffer_usage":25}}
Building and Testing
# Run tests on desktop
cargo test
# Build and flash to ESP32-C3 (recommended)
cargo run --release --features embedded
# Alternative: Build then flash separately
cargo build --release --target riscv32imc-unknown-none-elf --features embedded
cargo espflash flash target/riscv32imc-unknown-none-elf/release/chapter16_communication
Exercise: JSON Temperature Communication System
Build a complete JSON communication system for your temperature monitor.
Requirements
- JSON Output: Send temperature readings as JSON over serial every second
- Command Processing: Parse and respond to JSON commands
- Status Reporting: Provide system status via JSON
- Statistics Export: Export temperature statistics in JSON format
- Error Handling: Handle serialization errors gracefully
Starting Project Structure
Create these files:
#![allow(unused)] fn main() { // src/temperature.rs - Add Serde support to existing types #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] pub struct Temperature { celsius_tenths: i16, } // TODO: Add Serde derives to TemperatureBuffer // TODO: Create TemperatureReading struct with timestamp }
#![allow(unused)] fn main() { // src/communication.rs - Create command/response system #[derive(Debug, Clone, Serialize, Deserialize)] pub enum Command { GetStatus, GetLatestReading, GetStats, SetSampleRate { rate_hz: u8 }, Reset, } #[derive(Debug, Clone, Serialize, Deserialize)] pub enum Response { // TODO: Define response types } pub struct TemperatureComm { // TODO: Implement communication handler } }
Implementation Tasks
-
Add Serde Support:
- Add
Serialize, Deserializeto Temperature struct - Create TemperatureReading with timestamp
- Update Cargo.toml with serde dependencies
- Add
-
Create Command System:
- Define Command enum for incoming commands
- Define Response enum for outgoing responses
- Implement command processing logic
-
JSON Communication:
- Serialize responses to JSON strings
- Deserialize commands from JSON
- Handle serialization errors gracefully
-
Integration:
- Update main loop to output JSON readings
- Add command demonstration
- Test JSON format with serial monitor
Success Criteria
- Program compiles without warnings
- Temperature readings output as valid JSON
- Commands processed and responses sent as JSON
- Statistics exported in JSON format
- Serial output shows structured data
- No panics on malformed input
Expected JSON Output
🌡️ ESP32-C3 Temperature Monitor with Communication
READING: {"Reading":{"temperature":{"celsius_tenths":523},"timestamp_ms":1000,"sensor_id":0}}
STATUS: {"Status":{"uptime_ms":1000,"sample_rate_hz":1,"threshold_celsius":52.0,"buffer_usage":5}}
STATS: {"Stats":{"count":5,"average":{"celsius_tenths":522},"min":{"celsius_tenths":520},"max":{"celsius_tenths":525}}}
Command Response: {"SampleRateSet":2}
Testing Commands
# Run tests first
./test.sh
# Build and flash
cargo run --release
# Monitor output
cargo espflash monitor
You can test commands by sending JSON to the serial interface:
"GetStatus"{"SetSampleRate":{"rate_hz":2}}"Reset"
Extension Challenges
- Command Input: Read commands from serial input
- Binary Protocol: Compare JSON vs postcard serialization
- Compression: Implement message compression for efficiency
- Authentication: Add simple command authentication
- Batch Operations: Send multiple readings in one JSON message
Troubleshooting
Serialization Errors:
- Check that all types implement Serde traits
- Ensure fixed-size strings for heapless compatibility
- Use
serde-json-coreinstead ofserde_jsonfor no_std
JSON Format Issues:
- Validate JSON with online tools
- Use pretty-printing for debugging
- Check string buffer sizes are sufficient
Memory Errors:
- Monitor stack usage during JSON operations
- Use smaller buffer sizes if memory is limited
- Consider streaming large responses
Key Communication Patterns Learned
✅ Serde Integration: Add serialization support to embedded types with #[derive(Serialize, Deserialize)]
✅ Fixed-size Collections: Use heapless::String and heapless::Vec for JSON without heap allocation
✅ Command/Response Protocol: Design structured interfaces for remote control
✅ Error Handling: Handle serialization errors gracefully in resource-constrained environments
✅ JSON vs Binary: Understand trade-offs between readability and efficiency
Next: In Chapter 17, we’ll integrate all these components into a production-ready system with proper error handling and deployment strategies.