Project 4: Embedded Application with OOP Structure
Refactor procedural C++ telemetry code into a clean, object-oriented framework and deploy a Python serial interface.
Domain
Object-Oriented Firmware / Python
Difficulty
⭐⭐⭐☆☆ (Intermediate)
Course Module
Programming for IoT
Deliverables
C++ OOP Source Code, Python Serial Parser
1. Object-Oriented Firmware & Serial Integration Architecture
As embedded code grows, procedural structures become difficult to scale. Refactoring code into an Object-Oriented Programming (OOP) design isolates sensor details behind interfaces. The layout below details this architecture. The C++ firmware defines an abstract `Sensor` base class, implementing polymorphic inheritance to manage LDR and DHT22 subsystems. It serializes sensor data into a structured JSON string and transmits it over serial. In the VirtualBox Linux environment, a Python script reads the data from `/dev/ttyUSB0` and displays it in the console.
2. Part 1: Step-by-Step Individual Commands & Script Setup
Follow these detailed steps to build the object-oriented firmware, open the VirtualBox guest shell, install Python serial modules, and execute the reader stream.
STEP 1
Launch Terminal and Create Dedicated OOP Folder
Boot up your VirtualBox Ubuntu machine. Open the terminal (Ctrl+Alt+T) and create a directory to organize your C++ and Python source files.
We run `mkdir -p` to verify and generate a clean workspace directory tree at the path `~/workspace/oop_telemetry` without throwing errors if the parent directories already exist.
STEP 2
Navigate to the Project Folder
Move your current command shell context into the newly generated folder to manage files locally.
ubuntu@iot-vm:~$ cd ~/workspace/oop_telemetry
We execute `cd` to change the working directory path of our terminal process, ensuring all subsequent scripts and source code files are created inside the isolated oop_telemetry folder.
STEP 3
Generate Isolated Python Virtual Environment
Create a sandbox folder containing Python libraries to prevent script execution packages from conflicting with system libraries.
We run `python3 -m venv env` to create an isolated environment directory named `env` containing a copy of the Python interpreter, protecting our VM's global configuration.
STEP 4
Activate the Virtual Environment
Instruct the current shell to look for Python execution files inside the local project folder path instead of standard root directories.
We run the `source` command against the activation shell script, modifying terminal PATH variables so that running python packages uses the local virtual environment folders.
STEP 5
Install pyserial Module inside the Sandbox Environment
Install the Python serial communications library using pip. This package is required to connect to `/dev/ttyUSB0`.
We run `pip install` to download and install the `pyserial` module, enabling Python to configure serial port attributes, read byte streams, and handle serial connections.
STEP 6
Create the Python Serial Reader Script
Open the graphical text editor in the background to write the serial listener script.
We invoke `gedit` alongside the `&` symbol to open the graphical text editor in a background thread, leaving our terminal terminal prompt active for running commands.
STEP 7
Execute the Serial Reader Program
Run the Python reader. Make sure your ESP32 board is connected to the VM via the VirtualBox USB menu, and that the serial port is not open in the Arduino IDE serial monitor.
We execute the Python reader script, starting the loop that polls `/dev/ttyUSB0`, decodes incoming JSON streams, and prints formatted telemetry to the console.
3. OOP Serialization & Parse Pipeline Flow
The OOP architecture manages telemetry data by wrapping hardware details inside classes. The workflow diagram below illustrates how values flow from raw analog signals, through the object interface, into a formatted JSON string, and finally to the parsed console dashboard.
4. Part 2: Complete Codebases & Line-by-Line Breakdowns
Below is the complete C++ firmware code showing how abstract inheritance interfaces work, followed by the complete Python script to listen to the serial port and print the data.
Asset 1: Polymorphic C++ Telemetry Code (`oop_telemetry.ino`)
Line-by-Line Code Breakdown
class Sensor { ... }: Declares the polymorphic abstract base class that defines the shared interface for all physical sensors.
virtual void begin() = 0;: Declares a pure virtual setup function. Any subclass inheriting from `Sensor` must implement this method.
virtual float readVal() = 0;: Declares a pure virtual read function that returns a float. This forces subclasses to implement their own reading logic.
class LdrSensor : public Sensor: Declares the `LdrSensor` subclass, which inherits publicly from `Sensor`.
LdrSensor(int pin) : _pin(pin) {}: Defines a constructor that uses an initialization list to assign the private variable `_pin` to the constructor argument.
analogRead(_pin): Overrides `readVal()` to return the LDR's raw analog reading.
class TempSensor : public Sensor: Declares the `TempSensor` subclass, which inherits from `Sensor` and wraps the DHT library.
TempSensor(int pin, int type) : _pin(pin), _type(type), _dht(pin, type) {}: Constructor that initializes pin numbers, model types, and instantiates the DHT object.
Sensor* sensors[2];: Declares an array of base class pointers. This is the core of polymorphism: we can store derived objects in a base class array.
sensors[0] = new TempSensor(15, DHT22);: Instantiates a `TempSensor` on the heap and assigns it to the first array index.
sensors[1] = new LdrSensor(34);: Instantiates an `LdrSensor` on the heap and assigns it to the second array index.
sensors[i]->readVal();: Loops through the array and calls the overridden read function on each object at runtime.
// Practice Project 4: C++ Object-Oriented Firmware Code#include<DHT.h>// Abstract Base Class SensorclassSensor {
public:
virtualvoidbegin() = 0; // Pure virtual setup methodvirtualfloatreadVal() = 0; // Pure virtual read method
};
// Subclass LdrSensor inheriting from SensorclassLdrSensor : publicSensor {
private:
int_pin; // Private GPIO pin referencepublic:
LdrSensor(intpin) : _pin(pin) {}
voidbegin() override {
// ADC pins do not require explicit pinMode setup
}
floatreadVal() override {
return (float)analogRead(_pin); // Return LDR voltage divider reading
}
};
// Subclass TempSensor inheriting from SensorclassTempSensor : publicSensor {
private:
int_pin;
int_type;
DHT_dht; // Encapsulate DHT object librarypublic:
TempSensor(intpin, inttype) : _pin(pin), _type(type), _dht(pin, type) {}
voidbegin() override {
_dht.begin();
}
floatreadVal() override {
floatt = _dht.readTemperature();
if (isnan(t)) {
return -999.0; // Error indicator flag value
}
returnt;
}
};
// Array of base class pointersSensor* sensors[2];
voidsetup() {
Serial.begin(115200);
// Instantiate objects polymorphicallysensors[0] = newTempSensor(15, DHT22);
sensors[1] = newLdrSensor(34);
// Initialize sensors using the interfacefor (inti = 0; i < 2; i++) {
sensors[i]->begin();
}
}
voidloop() {
// Fetch measurementsfloattemperature = sensors[0]->readVal();
floatlightValue = sensors[1]->readVal();
// Print measurements as a structured JSON stringSerial.print("{\"temperature\":");
Serial.print(temperature);
Serial.print(",\"light\":");
Serial.print(lightValue);
Serial.println("}");
delay(2000);
}
Asset 2: Python Serial Ingestion Script (`serial_reader.py`)
Line-by-Line Code Breakdown
import serial: Imports the `pyserial` module to handle serial communication.
import json: Imports Python's built-in JSON module to parse string streams into Python dictionary objects.
serial.Serial(port='/dev/ttyUSB0', baudrate=115200, timeout=1.0): Opens a connection to `/dev/ttyUSB0` at 115200 baud, setting a 1.0-second read timeout.
ser.readline(): Reads incoming characters from the serial buffer until a newline character (`\n`) is reached.
line.decode('utf-8').strip(): Converts raw bytes to a standard UTF-8 string and removes trailing whitespaces and newlines.
json.loads(decoded_line): Parses the JSON string into a Python dictionary, making the keys and values accessible.
data['temperature']: Accesses the value of the 'temperature' key from the parsed JSON dictionary.
# Practice Project 4: Python Serial Listener Scriptimportserialimportjsonimportsysdefmain():
# Target port on VirtualBox Ubuntu VMport_path = '/dev/ttyUSB0'baud_rate = 115200
print(f"Connecting to microcontroller serial interface on {port_path}...")
try:
# Initialize the serial connection objectser = serial.Serial(port=port_path, baudrate=baud_rate, timeout=1.0)
# Flush buffers to ignore incomplete initial readingsser.reset_input_buffer()
print("Connection established. Waiting for JSON telemetry...")
print("==================================================")
exceptserial.SerialExceptionase:
print(f"Error: Could not open port {port_path}. details: {e}")
print("Suggestions: Check VirtualBox USB settings or close other Serial Monitors.")
sys.exit(1)
try:
whileTrue:
# Read until a newline character '\n' is foundraw_line = ser.readline()
ifraw_line:
try:
# Decode bytes to string and strip whitespacesdecoded_line = raw_line.decode('utf-8').strip()
# Parse the JSON stringtelemetry = json.loads(decoded_line)
# Print the formatted readings to the terminaltemp = telemetry.get('temperature', 'N/A')
light = telemetry.get('light', 'N/A')
print(f"[TELEMETRY] Temperature: {temp:.2f} C | Light Sensor: {light:.0f}")
exceptUnicodeDecodeError:
print("[WARN] Received malformed serial bytes. Skipping...")
exceptjson.JSONDecodeError:
# Handle non-JSON debug strings gracefullyiflen(decoded_line) > 0:
print(f"[DEBUG] {decoded_line}")
exceptKeyboardInterrupt:
print("\nClosing serial port connection...")
ser.close()
print("Exited.")
if__name__ == "__main__":
main()
5. Deliverables Summary
Created Artifacts
Refactored polymorphic C++ project sketch code: oop_telemetry.ino.
Python script for serial data parsing: serial_reader.py.
Verification Proof
A compilation report showing 0 memory leaks and clean variable scopes.
A screenshot of the Ubuntu terminal running serial_reader.py, displaying real-time telemetry data parsed from the serial port.
6. Closing Explanation: Why We Did This & What It Accomplishes
Architectural Intent & Operational Impact
Why We Did This
Implementing polymorphic abstract interfaces isolates hardware-specific driver logic from the core application, allowing you to swap sensors with minimal code changes.
Serializing telemetry data into standard JSON strings ensures compatibility with diverse downstream systems, regardless of programming language.
Separating the data producer (microcontroller) from the consumer (Python script) mirrors standard IoT architectures where edge nodes feed gateway devices.
What This Accomplishes
Introduces professional software engineering practices to embedded firmware development, preparing you to write clean and maintainable code for complex setups.
Builds cross-process communication pipelines, showing how data is structured on-device and decoded on a host system.
Enables edge-to-gateway telemetry parsing, paving the way for forwarding data to databases, MQTT brokers, or cloud platforms.