6  IoT Programming Examples

6.1 Learning Objectives

By the end of this chapter, you will be able to:

  • Construct OOP sensor libraries using inheritance, polymorphism, and encapsulation for extensible multi-sensor systems
  • Build functional data pipelines that process sensor data through composable pure-function transformations
  • Configure professional workflows with Git branching strategies, PlatformIO, and CI/CD pipelines
  • Integrate multiple paradigms in complete, working IoT applications that combine OOP, functional, and event-driven code

Prototyping is building rough, working versions of your IoT device to test ideas quickly and cheaply. Think of it like building a model airplane before constructing the real thing – a prototype reveals problems when they are still easy and inexpensive to fix. Modern prototyping tools make it possible to go from idea to working device in days rather than months.

“Let me show you how object-oriented programming works for sensors,” said Max the Microcontroller. “You create a Sensor class with properties like name, pin, and readInterval. Then you create instances – temperatureSensor, humiditySensor – each with their own settings. When you want a new sensor, just create another instance. No copying code!”

Sammy the Sensor demonstrated functional programming: “Imagine a data pipeline: raw reading goes in, gets filtered to remove noise, converted to the right units, checked against thresholds, and packaged for transmission. Each step is a pure function that takes input and produces output. Clean, testable, and easy to debug!”

Lila the LED showed the professional workflow: “Use Git branches for features, PlatformIO for building and uploading, and a CI pipeline that automatically tests your code when you push changes. It sounds like a lot of setup, but once it is running, it catches bugs before they reach the hardware!” Bella the Battery concluded, “Real code examples teach you more than theory. Follow along, modify the examples, and build something of your own!”

6.2 Prerequisites

Before diving into this chapter, you should be familiar with:

6.3 Introduction

This chapter demonstrates how different programming paradigms are applied in real Arduino/ESP32 projects. Each example is complete and runnable, helping you choose the right approach for your IoT applications.

6.4 Example 1: Object-Oriented Sensor Library Architecture

Scenario

Building a reusable, extensible sensor library using OOP principles for a multi-sensor IoT device.

Key concepts demonstrated:

  • Abstract base classes and inheritance
  • Polymorphism for sensor interface
  • Encapsulation of sensor-specific logic
  • Factory pattern for sensor creation
  • Clean separation of concerns
// ============================================
// Abstract Sensor Base Class
// ============================================
class Sensor {
protected:
  String name;
  float lastReading;
  unsigned long lastReadTime;
  bool initialized;

public:
  Sensor(String sensorName)
    : name(sensorName), lastReading(0.0),
      lastReadTime(0), initialized(false) {}

  virtual ~Sensor() {}  // Virtual destructor for proper cleanup

  // Pure virtual functions - must be implemented by derived classes
  virtual bool begin() = 0;
  virtual float read() = 0;
  virtual String getUnit() = 0;

  // Common functionality
  String getName() const { return name; }
  float getLastReading() const { return lastReading; }
  bool isInitialized() const { return initialized; }

  unsigned long getTimeSinceLastRead() const {
    return millis() - lastReadTime;
  }

  void printStatus() {
    Serial.print(name);
    Serial.print(": ");
    if (initialized) {
      Serial.print(lastReading, 2);
      Serial.print(" ");
      Serial.println(getUnit());
    } else {
      Serial.println("Not initialized");
    }
  }
};

// ============================================
// Concrete Sensor Implementations
// ============================================

// DHT22 Temperature/Humidity Sensor
class DHT22Sensor : public Sensor {
private:
  int pin;
  DHT* dht;
  bool readTemperature;  // true=temp, false=humidity

public:
  DHT22Sensor(String name, int dataPin, bool isTemp)
    : Sensor(name), pin(dataPin), readTemperature(isTemp) {
    dht = new DHT(pin, DHT22);
  }

  ~DHT22Sensor() {
    delete dht;
  }

Calculate OOP overhead vs procedural for 10-sensor system with polymorphic read():

Procedural approach (manual sensor checks): \[Code_{proc} = N_{sensors} \times L_{read\_func} = 10 \times 50 \text{ lines} = 500 \text{ lines total}\] \[t_{modify} = N_{sensors} \times t_{edit} = 10 \times 5 \text{ min} = 50 \text{ min to change all}\]

OOP approach (polymorphic Sensor base class): \[Code_{OOP} = L_{base} + N_{sensors} \times L_{derived} = 100 + 10 \times 30 = 400 \text{ lines}\] \[t_{modify} = t_{base\_change} = 5 \text{ min (change once, affects all)}\]

Memory overhead (virtual function table): \[Overhead_{RAM} = N_{sensors} \times size_{vptr} = 10 \times 4 \text{ bytes} = 40 \text{ bytes}\]

Runtime cost (virtual function call): \[t_{virtual} = t_{vtable\_lookup} + t_{func} = 2 \text{ cycles} + 100 \text{ cycles} = 102 \text{ cycles (2\% overhead)}\]

For 10+ sensors, OOP saves 20% code and 90% modification time at cost of 40 bytes RAM (negligible on ESP32 with 520KB SRAM)!

Interactive Calculator:

  bool begin() override {
    dht->begin();
    delay(2000);  // DHT22 needs time to stabilize

    // Test reading
    float test = dht->readTemperature();
    initialized = !isnan(test);

    if (initialized) {
      Serial.print(name);
      Serial.println(" initialized successfully");
    } else {
      Serial.print("ERROR: ");
      Serial.print(name);
      Serial.println(" initialization failed");
    }

    return initialized;
  }

  float read() override {
    if (!initialized) return NAN;

    float reading = readTemperature ?
                    dht->readTemperature() :
                    dht->readHumidity();

    if (!isnan(reading)) {
      lastReading = reading;
      lastReadTime = millis();
    }

    return reading;
  }

  String getUnit() override {
    return readTemperature ? "°C" : "%";
  }
};

The AnalogSensor class reads from the ESP32’s ADC with multi-sample averaging and calibration support:

// Analog Sensor (e.g., LDR light sensor)
class AnalogSensor : public Sensor {
private:
  int pin;
  float calibrationMultiplier;
  float calibrationOffset;
  int numSamples;

public:
  AnalogSensor(String name, int analogPin,
               float multiplier = 1.0, float offset = 0.0,
               int samples = 10)
    : Sensor(name), pin(analogPin),
      calibrationMultiplier(multiplier),
      calibrationOffset(offset), numSamples(samples) {}

  float read() override {
    if (!initialized) return NAN;
    long sum = 0;
    for (int i = 0; i < numSamples; i++) {
      sum += analogRead(pin);
      delay(10);
    }
    lastReading = (sum / (float)numSamples
                   * calibrationMultiplier) + calibrationOffset;
    lastReadTime = millis();
    return lastReading;
  }
  // ... begin(), getUnit(), setCalibration() follow same pattern
};

The BME280Sensor wraps the I2C environmental sensor, demonstrating how the same base class accommodates different communication protocols:

class BME280Sensor : public Sensor {
private:
  Adafruit_BME280* bme;
  enum ReadingType { TEMPERATURE, HUMIDITY, PRESSURE };
  ReadingType type;

public:
  BME280Sensor(String name, uint8_t addr, ReadingType readType)
    : Sensor(name), type(readType) {
    bme = new Adafruit_BME280();
  }

  float read() override {
    if (!initialized) return NAN;
    switch (type) {
      case TEMPERATURE: lastReading = bme->readTemperature(); break;
      case HUMIDITY:    lastReading = bme->readHumidity();    break;
      case PRESSURE:    lastReading = bme->readPressure() / 100.0F; break;
    }
    lastReadTime = millis();
    return lastReading;
  }
  // ... begin() configures sampling, getUnit() returns per-type units
};

The SensorManager class uses polymorphism to manage any mix of sensor types through a single interface:

class SensorManager {
private:
  Sensor** sensors;
  int sensorCount, maxSensors;

public:
  SensorManager(int max = 10) : sensorCount(0), maxSensors(max) {
    sensors = new Sensor*[maxSensors];
  }
  bool addSensor(Sensor* sensor) {
    if (sensorCount >= maxSensors) return false;
    sensors[sensorCount++] = sensor;
    return true;
  }
  void readAll() {
    for (int i = 0; i < sensorCount; i++) {
      sensors[i]->read();       // Polymorphic call
      sensors[i]->printStatus();
    }
  }
  // ... initializeAll(), getSensor(), destructor cleanup
};

Finally, the main program ties everything together – note how the setup() creates different sensor types but manages them uniformly:

SensorManager sensorMgr;

void setup() {
  Serial.begin(115200);
  // Create sensors using polymorphism
  sensorMgr.addSensor(new DHT22Sensor("Living Room Temp", 4, true));
  sensorMgr.addSensor(new DHT22Sensor("Living Room Humidity", 4, false));
  sensorMgr.addSensor(new AnalogSensor("Light Level", 34, 0.024, 0, 20));
  sensorMgr.addSensor(new BME280Sensor("Outdoor Temp", 0x76,
                                        BME280Sensor::TEMPERATURE));
  sensorMgr.initializeAll();
}

void loop() {
  sensorMgr.readAll();
  Sensor* temp = sensorMgr.getSensor(0);
  if (temp && temp->getLastReading() > 30.0)
    Serial.println("HIGH TEMPERATURE ALERT!");
  delay(10000);
}
Benefits of this OOP approach
  • Extensibility: Add new sensor types by inheriting from Sensor
  • Polymorphism: Treat all sensors uniformly through base class interface
  • Maintainability: Each sensor encapsulates its own logic
  • Reusability: Sensor classes can be used in multiple projects
  • Testability: Easy to mock sensors for testing

How to extend:

  1. Create new sensor class inheriting from Sensor
  2. Implement begin(), read(), and getUnit()
  3. Add sensor to manager: sensorMgr.addSensor(new YourSensor(...))
Try It: OOP Class Hierarchy Explorer

Explore how the Sensor class hierarchy works by configuring different sensor types. See how polymorphism lets the SensorManager treat all sensors uniformly while each subclass provides its own implementation.

6.5 Example 2: Functional Programming Data Pipeline

Scenario

Processing sensor data through a functional pipeline with immutable data transformations, perfect for reliable data analytics.

Key concepts demonstrated:

  • Pure functions (no side effects)
  • Function composition
  • Immutable data structures
  • Pipeline architecture
  • Predictable data transformations
#include <vector>
#include <functional>

// Immutable data structures for sensor readings
struct SensorReading {
  float value;
  unsigned long timestamp;
  bool valid;
  SensorReading(float v = 0.0, unsigned long t = 0, bool isValid = true)
    : value(v), timestamp(t), valid(isValid) {}
};

struct ProcessedData {
  float rawValue, calibratedValue, filteredValue, normalizedValue;
  unsigned long timestamp;
  bool anomaly;
};

Pure functions are the building blocks – each has no side effects and always returns the same output for the same input:

// Factory function with validation
SensorReading createReading(float value, bool validate = true) {
  bool isValid = !validate || (!isnan(value) && value >= -40.0 && value <= 125.0);
  return SensorReading(value, millis(), isValid);
}

// Calibration, conversion, filtering -- all pure
float applyCalibration(float raw, float offset, float scale = 1.0) {
  return (raw + offset) * scale;
}
float celsiusToFahrenheit(float c) { return c * 9.0 / 5.0 + 32.0; }

float movingAverage(const std::vector<float>& values) {
  float sum = 0.0;
  for (float v : values) sum += v;
  return values.empty() ? 0.0 : sum / values.size();
}

float medianFilter(std::vector<float> values) {
  std::sort(values.begin(), values.end());
  size_t n = values.size();
  return n % 2 ? values[n/2] : (values[n/2-1] + values[n/2]) / 2.0;
}

float normalize(float value, float min, float max) {
  return (max <= min) ? 0.0 : (value - min) / (max - min);
}

The DataPipeline class composes these pure functions into a processing chain:

class DataPipeline {
  std::vector<float> recentReadings;
  const int windowSize = 10;
  float calibOffset, calibScale, emaValue, emaAlpha;

public:
  DataPipeline(float offset, float scale, float alpha)
    : calibOffset(offset), calibScale(scale),
      emaValue(0), emaAlpha(alpha) {}

  ProcessedData process(float rawValue) {
    ProcessedData r;
    r.rawValue = rawValue;
    r.calibratedValue = applyCalibration(rawValue, calibOffset, calibScale);

    recentReadings.push_back(r.calibratedValue);
    if (recentReadings.size() > windowSize)
      recentReadings.erase(recentReadings.begin());

    r.filteredValue = medianFilter(recentReadings);
    emaValue = exponentialMovingAverage(r.filteredValue, emaValue, emaAlpha);
    r.normalizedValue = normalize(r.filteredValue, 0.0, 50.0);
    // Anomaly detection via z-score on recent window
    // ... (uses calculateStdDev and isAnomaly pure functions)
    return r;
  }
};

Higher-order functions accept other functions as parameters, enabling flexible data transformations:

void processReadings(
  const std::vector<SensorReading>& readings,
  std::function<float(float)> transform,
  std::function<void(float)> output
) {
  for (const auto& r : readings)
    if (r.valid) output(transform(r.value));
}

void loop() {
  float rawTemp = analogRead(34) * 0.0322;
  ProcessedData result = tempPipeline.process(rawTemp);

  Serial.printf("Raw: %.2f, Calibrated: %.2f, Filtered: %.2f\n",
                result.rawValue, result.calibratedValue, result.filteredValue);

  // Higher-order: convert batch to Fahrenheit
  processReadings(recent, celsiusToFahrenheit,
    [](float v) { Serial.printf("%.1f ", v); });
  delay(5000);
}
Why functional programming for IoT
  • Predictability: Pure functions always return same output for same input
  • Testability: Easy to unit test without mocking hardware
  • Reliability: No hidden side effects or state mutations
  • Composability: Build complex pipelines from simple functions
  • Debugging: Easier to trace data transformations

Real-world applications:

  • Edge analytics and data preprocessing
  • Sensor fusion algorithms
  • Digital signal processing
  • Machine learning feature engineering
  • Data quality validation
Try It: Sensor Data Filter Explorer

Experiment with different filter algorithms applied to noisy sensor data. Adjust the noise level, window size, and EMA smoothing factor to see how each filter cleans the signal differently.

6.6 Example 3: Git Workflow and PlatformIO Professional Setup

Scenario

Setting up a professional development environment with version control, continuous integration, and multi-developer collaboration.

Key concepts demonstrated:

  • Git branching strategy for firmware
  • PlatformIO configuration management
  • Continuous integration with GitHub Actions
  • Dependency management
  • Environment-specific builds

Directory structure:

iot-weather-station/
├── .github/
│   └── workflows/
│       └── platformio.yml          # CI/CD configuration
├── include/
│   ├── config.h                    # Project configuration
│   ├── sensors.h                   # Sensor interfaces
│   └── mqtt_client.h               # MQTT wrapper
├── src/
│   ├── main.cpp                    # Main application
│   ├── sensors.cpp                 # Sensor implementations
│   └── mqtt_client.cpp             # MQTT implementation
├── test/
│   └── test_sensors.cpp            # Unit tests
├── lib/
│   └── custom_libraries/           # Project-specific libraries
├── .gitignore                      # Git ignore rules
├── platformio.ini                  # PlatformIO configuration
└── README.md                       # Project documentation

platformio.ini - Multi-Environment Configuration:

[platformio]
default_envs = esp32_dev

[env]
platform = espressif32
framework = arduino
monitor_speed = 115200
lib_deps =
    adafruit/Adafruit BME280 Library @ ^2.2.2
    knolleary/PubSubClient @ ^2.8
    bblanchon/ArduinoJson @ ^6.21.3

[env:esp32_dev]
board = esp32dev
build_type = debug
build_flags = ${env.build_flags} -D ENV_DEV -D ENABLE_SERIAL_DEBUG
monitor_filters = esp32_exception_decoder

[env:esp32_prod]
board = esp32dev
build_type = release
build_flags = ${env.build_flags} -D ENV_PROD -O2
extra_scripts = pre:scripts/load_secrets.py

[env:esp32_ota]
extends = env:esp32_prod
upload_protocol = espota
upload_port = 192.168.1.100

Git Workflow (.gitignore):

# PlatformIO
.pio/
.pioenvs/
.piolibdeps/

# Secrets and credentials
include/secrets.h
include/config_prod.h
*.pem
*.key

# Build artifacts
*.bin
*.elf
*.map

# IDE files
.vscode/
.idea/
*.code-workspace

# OS files
.DS_Store
Thumbs.db

# Keep templates
!include/secrets.h.template

include/secrets.h.template:

#ifndef SECRETS_H
#define SECRETS_H

// Wi-Fi Credentials
#define WIFI_SSID "your_ssid_here"
#define WIFI_PASSWORD "your_password_here"

// MQTT Configuration
#define MQTT_SERVER "mqtt.example.com"
#define MQTT_PORT 1883
#define MQTT_USER "your_username"
#define MQTT_PASSWORD "your_password"

// API Keys
#define API_KEY "your_api_key_here"

#endif

GitHub Actions CI/CD (.github/workflows/platformio.yml):

name: PlatformIO CI

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  build:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        environment: [esp32_dev, esp32_prod]

    steps:
    - uses: actions/checkout@v3

    - name: Cache PlatformIO
      uses: actions/cache@v3
      with:
        path: |
          ~/.platformio
          .pio
        key: ${{ runner.os }}-pio-${{ hashFiles('**/platformio.ini') }}

    - name: Set up Python
      uses: actions/setup-python@v4
      with:
        python-version: '3.9'

    - name: Install PlatformIO Core
      run: pip install --upgrade platformio

    - name: Build firmware
      run: pio run -e ${{ matrix.environment }}

    - name: Run tests
      run: pio test -e native

    - name: Upload firmware artifacts
      uses: actions/upload-artifact@v3
      with:
        name: firmware-${{ matrix.environment }}
        path: .pio/build/${{ matrix.environment }}/firmware.bin
        retention-days: 30

  release:
    needs: build
    runs-on: ubuntu-latest
    if: startsWith(github.ref, 'refs/tags/v')

    steps:
    - uses: actions/checkout@v3

    - name: Create Release
      uses: softprops/action-gh-release@v1
      with:
        files: |
          .pio/build/*/firmware.bin
        draft: false
        prerelease: false

Git Branching Strategy:

# Initialize repository
git init
git add .
git commit -m "Initial commit: Weather station firmware"

# Create develop branch for active development
git checkout -b develop

# Feature development workflow
git checkout -b feature/mqtt-reconnect
# ... make changes ...
git add src/mqtt_client.cpp
git commit -m "Add MQTT reconnection logic with exponential backoff"
git push origin feature/mqtt-reconnect

# Create pull request for code review
# After approval, merge to develop
git checkout develop
git merge feature/mqtt-reconnect
git push origin develop

# Release workflow
git checkout main
git merge develop
git tag -a v1.0.0 -m "Release version 1.0.0"
git push origin main --tags

# Hotfix workflow
git checkout -b hotfix/sensor-timeout main
# ... fix critical bug ...
git commit -m "Fix sensor timeout causing device crash"
git checkout main
git merge hotfix/sensor-timeout
git tag v1.0.1
git checkout develop
git merge hotfix/sensor-timeout

Professional Git commit messages:

# Good commit messages follow this format:
# <type>(<scope>): <subject>
#
# Types: feat, fix, docs, style, refactor, test, chore

git commit -m "feat(mqtt): add QoS 1 support with message acknowledgment"
git commit -m "fix(sensor): correct BME280 I2C address detection"
git commit -m "refactor(power): extract sleep logic into PowerManager class"
git commit -m "docs(readme): add wiring diagram and setup instructions"
git commit -m "test(wifi): add unit tests for connection retry logic"

Using PlatformIO CLI:

# Build for development
pio run -e esp32_dev

# Upload to device
pio run -e esp32_dev -t upload

# Monitor serial output
pio device monitor

# Run unit tests
pio test

# Clean build
pio run -t clean

# Update libraries
pio pkg update

# Build and upload in one command
pio run -e esp32_dev -t upload && pio device monitor

# OTA update to remote device
pio run -e esp32_ota -t upload
Why this matters for professional IoT
  • Reproducible Builds: platformio.ini locks dependency versions
  • CI/CD: Automated testing catches bugs before deployment
  • Code Review: Pull requests ensure code quality
  • Rollback: Git tags enable quick rollback to previous versions
  • Team Collaboration: Clear branching strategy prevents conflicts
  • Environment Separation: Dev/staging/prod configurations isolated

Real-world benefits:

  • A team of 5 developers can work on same codebase without conflicts
  • Automated builds run on every commit, catching integration issues early
  • Firmware versions are traceable to specific git commits
  • Production secrets never committed to repository
  • OTA updates deployed with confidence after CI tests pass
Try It: CI/CD Pipeline Timing Simulator

Configure your project’s CI/CD pipeline and see how build times, test coverage, and team size affect total deployment time. Compare manual vs automated workflows.

Common Pitfalls

Example code often uses fixed delays calibrated for one microcontroller clock speed that produce wrong timings on a different clock. Copying without checking can cause sensors to receive malformed I2C timing. Parameterise timing values from the clock speed constant and verify with a logic analyser on the actual target hardware.

Calling blocking sensor read functions within an MQTT callback can stall the network stack long enough for a watchdog reset or missed keep-alive. Trigger sensor reads from a timer, cache the latest value, and return cached data from any function called within a network callback.

Assuming a serial read() always returns a complete packet causes firmware to process partial payloads as valid data when bytes arrive in multiple TCP segments. Implement a length-prefixed or delimiter-terminated framing protocol and accumulate bytes into a ring buffer until a complete frame is received.

6.8 Summary

  • OOP Sensor Libraries provide extensible, maintainable sensor management through inheritance, polymorphism, and encapsulation—ideal for multi-sensor systems
  • Functional Data Pipelines enable predictable, testable data processing through pure functions, immutable data, and composition—perfect for edge analytics
  • Professional Git Workflows with feature branches, pull requests, and semantic versioning enable team collaboration and traceable firmware releases
  • PlatformIO Configuration supports multi-environment builds (dev/prod/test/OTA) with locked dependencies and CI/CD integration
  • Combining Paradigms is the norm—use OOP for structure, functional for data processing, event-driven for responsiveness
  • CI/CD Automation catches bugs early, ensures consistent builds, and enables confident OTA deployments

6.9 Knowledge Check

6.10 Concept Relationships

IoT Programming Examples
├── Demonstrates: [Programming Paradigms](programming-paradigms-overview.html) - OOP, functional, event-driven in action
├── Applies: [Best Practices](programming-best-practices.html) - Selection criteria and hybrid approaches
├── Uses: [Development Tools](programming-development-tools.html) - PlatformIO, Git workflows, CI/CD
├── Builds on: [Microcontroller Programming](microcontroller-programming-essentials.html) - Arduino/ESP32 fundamentals
└── Integrates with: [MQTT](../app-protocols/mqtt-fundamentals.html) - Messaging for IoT communications

Code Architecture Patterns:

  1. OOP sensor library shows inheritance, polymorphism, and encapsulation for reusable components
  2. Functional pipeline demonstrates pure functions, immutability, and composition for data processing
  3. Professional Git workflow illustrates branching strategies, CI/CD, and team collaboration

6.11 See Also

6.11.1 Programming Foundations

6.11.2 Embedded Fundamentals

6.11.3 Testing and Deployment

6.11.4 Communication Protocols

  • MQTT - Messaging protocol used in examples
  • CoAP - RESTful alternative to MQTT

6.11.5 Architecture

6.11.6 Learning Resources

  • Simulations Hub - Interactive tools and simulators
  • Arduino Examples - https://www.arduino.cc/en/Tutorial/
  • ESP32 Examples - https://github.com/espressif/arduino-esp32/tree/master/libraries
In 60 Seconds

This chapter covers iot programming examples, explaining the core concepts, practical design decisions, and common pitfalls that IoT practitioners need to build effective, reliable connected systems.

6.12 What’s Next

If you want to… Read this
Learn the underlying programming paradigms Programming Paradigms and Tools
Apply examples to a full microcontroller project Microcontroller Programming Essentials
Explore best practices for production code Programming Best Practices