Modern C++ voor Software Architecten: ESP32 Boordcomputer

πŸ–‹οΈ bert

Voor wie: Ervaren Java/TypeScript Software Architecten die embedded development willen benaderen met dezelfde architecturele discipline als enterprise software. Platform: ESP32 + PlatformIO + C++17 Aanpak: OO-patterns first, hardware second.


# Inhoudsopgave

  1. De mentale switch: van JVM/V8 naar bare metal
  2. C++ vs Java/TS: de essentiΓ«le verschillen
  3. Geheugenbeheer zonder Garbage Collector
  4. Interfaces & Dependency Injection in C++
  5. Het Transport Strategy Pattern
  6. De Middleware Stack voor Telemetrie
  7. Hardware Abstractie: de MVC-split
  8. Alles samenvoegen: de Boordcomputer Architectuur
  9. PlatformIO Project Setup
  10. Testing zonder OS

# 1. De mentale switch

# Wat er niet meer is

In Java/TypeScript neem je een hoop zaken voor lief die op embedded systemen simpelweg niet bestaan:

Java/TypeScript ESP32 bare metal
Garbage Collector Jij beheert het geheugen
Exceptions (try/catch overal) Exceptions kosten heap β€” gebruik spaarzaam
Onbeperkt heap ~320KB SRAM totaal, ~200KB beschikbaar
Threads / async event loop FreeRTOS tasks of de Arduino loop()
console.log / SLF4J Serial.println β€” soms letterlijk de enige debugger
Reflection / runtime type info Standaard uitgeschakeld (-fno-rtti)
ClassLoader / module system Linker + preprocessor directives

# Wat er beter is dan je verwacht

  • Compileertijd-polymorfisme via templates is krachtiger dan Java generics
  • Zero-cost abstractions: een goed ontworpen C++ interface heeft nul runtime overhead t.o.v. directe hardware-aanroepen
  • Deterministische timing: geen GC-pauses, geen JIT-compilatie β€” essentieel voor real-time sensordata
  • constexpr: berekeningen op compile-time uitvoeren die in Java runtime zouden kosten

# De gouden regel voor embedded OO

Ontwerp voor minimale heap-allocatie. Gebruik stack en statische allocatie waar mogelijk.

In Java alloceer je alles op de heap (new) en de GC ruimt op. Op de ESP32 fragmenteert de heap snel, en er is niemand die opruimt. We lossen dit op met std::unique_ptr, stack-allocatie, en objectpools.


# 2. C++ vs Java/TS: de essentiΓ«le verschillen

# 2.1 Pointers: wat zijn ze en waarom bestaan ze?

In Java heb je referenties, maar je ziet ze nooit. In C++ zijn ze expliciet β€” en dat is bewust, want jij bepaalt waar het object leeft.

// Java: altijd impliciet een referentie naar heap
MyClass obj = new MyClass();  // obj is een verborgen pointer

// C++: jij kiest bewust
MyClass onStack;          // Object leeft op de stack β€” automatisch vernietigd
MyClass* rawPtr = new MyClass();  // Object op heap β€” JIJ moet delete aanroepen ⚠️
std::unique_ptr<MyClass> safePtr = std::make_unique<MyClass>(); // RAII β€” automatisch

*De `en&` tekens in context:**

void processData(const SensorReading& reading) {
//                              ^ ampersand = "geef me een referentie, geen kopie"
//   Dit is C++'s equivalent van Java's object-parameters.
//   Zonder & krijg je een *kopie* van het object β€” duur en zelden gewenst.

    float temp = reading.temperature;  // normale toegang via referentie
}

void updateBuffer(float* buffer, size_t length) {
//                      ^ asterisk = "dit is een pointer naar een float-array"
//   Pointers naar arrays zijn common in hardware-code voor DMA-buffers etc.
    buffer[0] = 23.5f;
}

Waarom heeft hardware dit nodig? Een sensorchip heeft een I2C-adres en registers in zijn eigen geheugenruimte. De enige manier om erin te schrijven is via een pointer naar dat geheugeradres. C++ maakt dit expliciet, zodat de compiler en jij precies weten wat er naar welk stuk geheugen gaat.

# 2.2 const correctheid: meer dan final

In Java/TS is final / const vrij simpel. In C++ heeft het drie dimensies:

// 1. Const variabele β€” equivalent aan Java final / TS const
const int BAUD_RATE = 115200;

// 2. Const referentie β€” "ik ga dit object niet aanpassen"
//    Dit is de standaard voor functie-parameters in C++
void logReading(const SensorReading& reading) {
    // reading.temperature = 0;  // ← Compileerfout! Goed.
    Serial.println(reading.temperature);
}

// 3. Const methode β€” "deze methode verandert het object niet"
class Sensor {
public:
    float getTemperature() const {  // ← const na de parameterlijst
        return _temperature;        // mag _temperature niet aanpassen
    }
private:
    float _temperature;
};

Vuistregel: Markeer alles const tenzij je een expliciete reden hebt om dat niet te doen. De compiler helpt je dan als je per ongeluk state aanpast.

# 2.3 Header files: de "interface declaration" van C++

C++ splitst declaratie en implementatie. Dit voelt vreemd voor Java-ontwikkelaars, maar het is feitelijk vergelijkbaar met TypeScript .d.ts declaration files:

ITransport.h     ←→  interface ITransport.java  /  ITransport.d.ts
ITransport.cpp   ←→  AbstractTransport.java     /  transport.ts (implementatie)
// ITransport.h β€” de "interface" (puur virtuele klasse)
#pragma once  // Equivalent van Java's package system: voorkomt dubbele includes

class ITransport {
public:
    virtual ~ITransport() = default;  // Altijd een virtuele destructor in interfaces!
    virtual bool connect() = 0;       // = 0 betekent "puur virtueel" = abstract method
    virtual bool send(const uint8_t* data, size_t length) = 0;
    virtual bool isConnected() const = 0;
};
// WiFiTransport.cpp β€” de implementatie
#include "ITransport.h"
#include <WiFi.h>

class WiFiTransport : public ITransport {  // "implements ITransport" in Java
public:
    bool connect() override {  // override = @Override in Java
        WiFi.begin(_ssid, _password);
        return WiFi.waitForConnectResult() == WL_CONNECTED;
    }
    // ...
};

Waarom = default bij de destructor? In Java/TS worden objecten automatisch opgeruimd. In C++ moet de juiste destructor aangeroepen worden als je een ITransport* pointer hebt die eigenlijk naar een WiFiTransport wijst. Zonder virtual ~ITransport() roep je de verkeerde destructor aan β€” memory leak gegarandeerd.

# 2.4 Namespaces vs packages

// Java: package nl.mijnbedrijf.tractorcomputer;
// C++:
namespace TractorOS {
namespace Transport {

class WiFiTransport : public ITransport { /* ... */ };

} // namespace Transport
} // namespace TractorOS

// Gebruik:
TractorOS::Transport::WiFiTransport wifi;
// Of met using:
using namespace TractorOS::Transport;
WiFiTransport wifi;

# 2.5 nullptr vs null

ITransport* transport = nullptr;  // C++ nullptr β€” type-safe, niet gewoon 0

if (transport != nullptr) {
    transport->send(data, length);
}

In embedded code wil je nullptr-checks overal waar je raw pointers gebruikt. Met unique_ptr en referenties kun je dit grotendeels elimineren (zie volgende hoofdstuk).


# 3. Geheugenbeheer zonder Garbage Collector

Dit is waarschijnlijk het meest kritieke hoofdstuk voor een Java-architect.

# 3.1 RAII: het patroon dat alles oplost

Resource Acquisition Is Initialization. Wanneer een object gecreΓ«erd wordt, verwerft het zijn resources. Wanneer het vernietigd wordt, geeft het ze vrij β€” automatisch, determinΓ­stisch, zonder GC.

// Java: try-with-resources (handmatig resource management)
try (InputStream is = new FileInputStream("data.txt")) {
    // is automatisch gesloten bij einde blok
}

// C++ RAII: het object regelt zichzelf altijd
class SpiDevice {
public:
    SpiDevice(uint8_t csPin) : _csPin(csPin) {
        SPI.begin();  // Resource verwerven in constructor
        pinMode(_csPin, OUTPUT);
        Serial.println("SPI geopend");
    }

    ~SpiDevice() {
        SPI.end();  // Resource vrijgeven in destructor β€” altijd, ook bij exceptions
        Serial.println("SPI gesloten");
    }

    // Geen copy toegestaan β€” resource ownership is exclusief
    SpiDevice(const SpiDevice&) = delete;
    SpiDevice& operator=(const SpiDevice&) = delete;

private:
    uint8_t _csPin;
};

void measureData() {
    SpiDevice sensor(GPIO_NUM_5);  // GecreΓ«erd op stack
    // ... gebruik sensor ...
}  // ← sensor destructor aangeroepen hier β€” altijd, ook bij early return

# 3.2 Smart Pointers: de Garbage Collector van C++

#include <memory>

// unique_ptr β€” één eigenaar, automatisch vrijgegeven
// Equivalent van: final MyClass obj = new MyClass();  maar dan met automatische cleanup
auto transport = std::make_unique<WiFiTransport>("ssid", "wachtwoord");
transport->connect();
// Geen delete nodig β€” unique_ptr vernietigt WiFiTransport als het scope verlaat

// shared_ptr β€” gedeeld eigenaarschap (gebruik spaarzaam op embedded!)
// Equivalent van normale Java/TS objectreferenties
auto sharedConfig = std::make_shared<SystemConfig>();
auto component1 = std::make_unique<TelemetryModule>(sharedConfig);
auto component2 = std::make_unique<LoggingModule>(sharedConfig);
// sharedConfig vrijgegeven als BEIDE modules vernietigd zijn

// weak_ptr β€” observeren zonder eigenaarschap (voorkomt cycli)
std::weak_ptr<SystemConfig> configObserver = sharedConfig;

ESP32-waarschuwing: shared_ptr gebruikt atomic reference counting, wat cycles-duur is op de ESP32. Prefereer unique_ptr en overdraag eigenaarschap expliciet.

# 3.3 Stack vs Heap: de strategische keuze

// SLECHT voor embedded: alles op heap alloceren (Java-gewoonte)
void badPattern() {
    auto* sensor = new TemperatureSensor(GPIO_NUM_4);  // heap allocatie
    auto* buffer = new float[100];                      // heap allocatie
    // Heap fragmenteert over tijd β†’ crash na uren/dagen draaien
    delete sensor;
    delete[] buffer;
}

// GOED: stack allocatie voor kortstondige objecten
void goodPattern() {
    TemperatureSensor sensor(GPIO_NUM_4);  // stack β€” automatisch vrijgegeven
    float buffer[100];                     // stack array β€” geen heap
    // ...
}  // alles automatisch vrijgegeven

// GOED: statische allocatie voor langlevende singletons
// Eenmalig gealloceerd, nooit vrijgegeven β€” geen fragmentatie
class SensorManager {
    static TemperatureSensor _tempSensor;  // statisch = leeft voor altijd
};

# 3.4 De "geen dynamische allocatie in loop()" regel

void loop() {
    // ❌ NOOIT DIT β€” creΓ«ert en vernietigt objecten elke iteratie
    auto reading = std::make_unique<SensorReading>();

    // βœ… WEL DIT β€” hergebruik stack-objecten
    SensorReading reading;  // stack allocatie, elke loop opnieuw geΓ―nitialiseerd
    sensor.read(reading);

    // βœ… OF: objecten als class members β€” eenmalig gealloceerd
    _transport->send(_readingBuffer.data(), _readingBuffer.size());
}

# 4. Interfaces & Dependency Injection in C++

Nu kunnen we de echte architectuur bouwen. We maken het systeem testbaar door afhankelijkheden via constructors te injecteren β€” precies zoals Spring DI of Angular's inject().

# 4.1 De Interface HiΓ«rarchie

ITransport (pure interface)
β”œβ”€β”€ WiFiTransport
β”œβ”€β”€ LoRaTransport
└── MockTransport (voor testing)

IMiddleware (pure interface)
β”œβ”€β”€ CompressionMiddleware
β”œβ”€β”€ EncryptionMiddleware
└── LoggingMiddleware

ISensor (pure interface)
β”œβ”€β”€ DS18B20Sensor (temperatuur)
β”œβ”€β”€ GPSSensor
└── MockSensor (voor testing)

# 4.2 De Transport Interface

// src/interfaces/ITransport.h
#pragma once
#include <cstdint>
#include <cstddef>

namespace TractorOS {

/**
 * Transportlaag interface β€” uitwisselbaar via Strategy Pattern.
 * Equivalent van:
 *   Java:  interface ITransport { boolean connect(); ... }
 *   TS:    interface ITransport { connect(): Promise<boolean>; ... }
 */
class ITransport {
public:
    virtual ~ITransport() = default;

    virtual bool connect() = 0;
    virtual void disconnect() = 0;
    virtual bool send(const uint8_t* data, size_t length) = 0;
    virtual bool receive(uint8_t* buffer, size_t& bytesRead, size_t maxLength) = 0;
    virtual bool isConnected() const = 0;
    virtual const char* getName() const = 0;
};

} // namespace TractorOS

# 4.3 Concrete Implementaties

// src/transport/WiFiTransport.h
#pragma once
#include "interfaces/ITransport.h"
#include <WiFi.h>
#include <WiFiClient.h>

namespace TractorOS {

class WiFiTransport : public ITransport {
public:
    WiFiTransport(const char* ssid, const char* password, 
                  const char* serverHost, uint16_t serverPort)
        : _ssid(ssid)
        , _password(password)
        , _serverHost(serverHost)
        , _serverPort(serverPort) {}

    bool connect() override {
        WiFi.begin(_ssid, _password);
        uint8_t attempts = 0;
        while (WiFi.status() != WL_CONNECTED && attempts < 20) {
            delay(500);
            attempts++;
        }
        if (WiFi.status() != WL_CONNECTED) return false;
        return _client.connect(_serverHost, _serverPort);
    }

    void disconnect() override {
        _client.stop();
        WiFi.disconnect();
    }

    bool send(const uint8_t* data, size_t length) override {
        if (!isConnected()) return false;
        return _client.write(data, length) == length;
    }

    bool receive(uint8_t* buffer, size_t& bytesRead, size_t maxLength) override {
        bytesRead = 0;
        while (_client.available() && bytesRead < maxLength) {
            buffer[bytesRead++] = _client.read();
        }
        return bytesRead > 0;
    }

    bool isConnected() const override {
        return WiFi.status() == WL_CONNECTED && _client.connected();
    }

    const char* getName() const override { return "WiFi"; }

private:
    const char* _ssid;
    const char* _password;
    const char* _serverHost;
    uint16_t    _serverPort;
    WiFiClient  _client;
};

} // namespace TractorOS
// src/transport/LoRaTransport.h
#pragma once
#include "interfaces/ITransport.h"
#include <LoRa.h>

namespace TractorOS {

class LoRaTransport : public ITransport {
public:
    explicit LoRaTransport(long frequency = 868E6)
        : _frequency(frequency) {}

    bool connect() override {
        return LoRa.begin(_frequency);
    }

    void disconnect() override {
        LoRa.end();
    }

    bool send(const uint8_t* data, size_t length) override {
        LoRa.beginPacket();
        LoRa.write(data, length);
        return LoRa.endPacket() == 1;
    }

    bool receive(uint8_t* buffer, size_t& bytesRead, size_t maxLength) override {
        int packetSize = LoRa.parsePacket();
        bytesRead = 0;
        if (packetSize == 0) return false;
        while (LoRa.available() && bytesRead < maxLength) {
            buffer[bytesRead++] = LoRa.read();
        }
        return true;
    }

    bool isConnected() const override { return _connected; }
    const char* getName() const override { return "LoRa"; }

private:
    long _frequency;
    bool _connected = false;
};

} // namespace TractorOS

# 4.4 De Dependency Injection Container

We bouwen een simpele DI-container zonder frameworks. In Java/Spring zou dit @Autowired zijn; in Angular providedIn: 'root'. In C++ doen we dit expliciet in de "composition root" (je main.cpp of setup()):

// src/core/ServiceLocator.h
#pragma once
#include <memory>
#include "interfaces/ITransport.h"

namespace TractorOS {

/**
 * Simpele service locator / DI container.
 * In een groter project kun je dit vervangen door constructor injection
 * in elke klasse afzonderlijk (zuiverder, beter testbaar).
 */
class ServiceLocator {
public:
    static ServiceLocator& instance() {
        static ServiceLocator inst;  // Thread-safe singleton in C++11+
        return inst;
    }

    void registerTransport(std::unique_ptr<ITransport> transport) {
        _transport = std::move(transport);  // std::move = eigenaarschap overdragen
    }

    ITransport* getTransport() const {
        return _transport.get();  // .get() geeft raw pointer zonder eigenaarschap
    }

private:
    ServiceLocator() = default;
    std::unique_ptr<ITransport> _transport;
};

} // namespace TractorOS

std::move uitgelegd: In Java koppier je referenties vrijelijk. In C++ heeft een unique_ptr precies één eigenaar. std::move zegt letterlijk: "ik draag eigenaarschap over β€” na deze regel is mijn pointer leeg." Dit is de expliciete versie van wat de JVM's GC impliciet doet.


# 5. Strategy Pattern voor Transport

Het Strategy Pattern is in C++ nΓ³g krachtiger dan in Java, omdat we ook compile-time strategieΓ«n kunnen selecteren via templates.

# 5.1 Runtime Strategy (polymorfisme β€” equivalent aan Java)

// src/core/TelemetryDispatcher.h
#pragma once
#include <memory>
#include <functional>
#include "interfaces/ITransport.h"

namespace TractorOS {

struct TelemetryPacket {
    uint32_t timestamp;
    float    engineTemp;
    float    fuelLevel;
    float    gpsLat;
    float    gpsLon;
    uint16_t rpm;
    uint8_t  checksum;
};

/**
 * Gebruikt transport als uitwisselbare strategie.
 * In Java: class TelemetryDispatcher { private ITransport transport; ... }
 * In TS:   class TelemetryDispatcher { constructor(private transport: ITransport) {} }
 */
class TelemetryDispatcher {
public:
    // Constructor injection β€” de zuiverste vorm van DI
    explicit TelemetryDispatcher(ITransport& transport)
        : _transport(transport) {}

    bool dispatch(const TelemetryPacket& packet) {
        if (!_transport.isConnected()) {
            if (!_transport.connect()) {
                Serial.printf("[%s] Verbinding mislukt\n", _transport.getName());
                return false;
            }
        }

        // Serialiseer struct naar bytes
        const uint8_t* rawData = reinterpret_cast<const uint8_t*>(&packet);
        //                       ^ reinterpret_cast: "behandel dit als byte-array"
        //   In Java: ByteBuffer.wrap(...)  In TS: new Uint8Array(buffer)

        return _transport.send(rawData, sizeof(TelemetryPacket));
    }

    // Strategy wisselen op runtime β€” typisch voor "fallback naar LoRa als WiFi wegvalt"
    void setTransport(ITransport& newTransport) {
        _transport = newTransport;
    }

private:
    ITransport& _transport;  // Referentie, geen pointer β€” kan niet nullptr zijn!
    //          ^ Referentie als member = "dit object MOET een transport hebben"
    //   Dit is een bewuste keuze: we elimineren de nullptr-mogelijkheid by design.
};

} // namespace TractorOS

# 5.2 Transport Failover Strategy

// src/transport/FailoverTransport.h
#pragma once
#include "interfaces/ITransport.h"
#include <vector>
#include <memory>

namespace TractorOS {

/**
 * Composite Strategy: probeert transports in volgorde van prioriteit.
 * Design pattern: Chain of Responsibility + Strategy gecombineerd.
 */
class FailoverTransport : public ITransport {
public:
    void addTransport(ITransport* transport, uint8_t priority) {
        _transports.push_back({transport, priority});
        // Sorteer op prioriteit
        std::sort(_transports.begin(), _transports.end(),
            [](const auto& a, const auto& b) { return a.priority < b.priority; });
        //  ^ Lambda β€” equivalent van Java's Comparator.comparingInt(...)
    }

    bool connect() override {
        for (auto& entry : _transports) {
            if (entry.transport->connect()) {
                _active = entry.transport;
                Serial.printf("Failover: actief transport = %s\n", _active->getName());
                return true;
            }
        }
        return false;
    }

    bool send(const uint8_t* data, size_t length) override {
        if (!_active) return false;

        if (_active->send(data, length)) return true;

        // Automatische failover bij verzendingsfout
        Serial.printf("%s mislukt, failover...\n", _active->getName());
        for (auto& entry : _transports) {
            if (entry.transport == _active) continue;
            if (entry.transport->connect() && entry.transport->send(data, length)) {
                _active = entry.transport;
                return true;
            }
        }
        return false;
    }

    // ... overige interface-methoden ...
    void disconnect() override { if (_active) _active->disconnect(); }
    bool receive(uint8_t* buf, size_t& read, size_t max) override {
        return _active ? _active->receive(buf, read, max) : false;
    }
    bool isConnected() const override { return _active && _active->isConnected(); }
    const char* getName() const override { return "Failover"; }

private:
    struct TransportEntry {
        ITransport* transport;
        uint8_t     priority;
    };

    std::vector<TransportEntry> _transports;
    ITransport* _active = nullptr;
};

} // namespace TractorOS

# 6. Middleware Stack voor Telemetrie

Dit is het meest architectureel interessante deel. We bouwen een pipeline vergelijkbaar met Express.js middleware, ASP.NET middleware, of Java Servlet filters.

# 6.1 De Middleware Interface

// src/middleware/IMiddleware.h
#pragma once
#include <cstdint>
#include <functional>

namespace TractorOS {

/**
 * Middleware context: de "request/response" van onze telemetrie-pipeline.
 * Equivalent van Express.js's `req` object of Java's HttpServletRequest.
 */
struct TelemetryContext {
    uint8_t* data;
    size_t   dataLength;
    bool     shouldContinue = true;  // false = pipeline afbreken (zoals next(err) in Express)
    bool     hasError = false;
    char     errorMessage[64] = {};

    // Metadata voor middleware
    uint32_t timestamp;
    uint8_t  retryCount = 0;
};

/**
 * Middleware interface β€” elke schakel in de pipeline.
 * 
 * Java equivalent:
 *   interface Middleware { void process(Context ctx, Runnable next); }
 * 
 * TS/Express equivalent:
 *   type Middleware = (ctx: Context, next: () => void) => void;
 */
class IMiddleware {
public:
    virtual ~IMiddleware() = default;

    /**
     * @param context  De telemetrie-context (mutable β€” middleware kan data aanpassen)
     * @param next     Aanroepen om door te gaan naar volgende middleware
     *                 std::function is C++'s equivalent van Java's Function<T,R>
     *                 of TS's () => void
     */
    virtual void process(TelemetryContext& context, 
                        std::function<void()> next) = 0;

    virtual const char* getName() const = 0;
};

} // namespace TractorOS

# 6.2 Concrete Middleware Implementaties

// src/middleware/ChecksumMiddleware.h
#pragma once
#include "IMiddleware.h"

namespace TractorOS {

class ChecksumMiddleware : public IMiddleware {
public:
    void process(TelemetryContext& ctx, std::function<void()> next) override {
        // Pre-processing: checksum toevoegen
        uint8_t checksum = 0;
        for (size_t i = 0; i < ctx.dataLength; ++i) {
            checksum ^= ctx.data[i];  // XOR checksum
        }
        ctx.data[ctx.dataLength++] = checksum;  // Append checksum

        next();  // Doorgaan naar volgende middleware

        // Post-processing kan hier (na next()) β€” zoals Express.js "response" fase
    }

    const char* getName() const override { return "Checksum"; }
};

} // namespace TractorOS
// src/middleware/CompressionMiddleware.h
#pragma once
#include "IMiddleware.h"

namespace TractorOS {

/**
 * Simpele run-length encoding compressie voor telemetrie-data.
 * Op embedded systemen geen zlib/gzip β€” te zwaar.
 */
class CompressionMiddleware : public IMiddleware {
public:
    explicit CompressionMiddleware(size_t threshold = 32)
        : _threshold(threshold) {}

    void process(TelemetryContext& ctx, std::function<void()> next) override {
        if (ctx.dataLength < _threshold) {
            // Kleine packets niet comprimeren β€” overhead niet waard
            next();
            return;
        }

        // In een echte implementatie: compress data hier
        // We markeren compressed data met een header byte
        _compressedBuffer[0] = 0xC0;  // Compression marker
        // ... compressie-logica ...

        // Swap buffer β€” middleware modificeert transparant de context
        uint8_t* originalData = ctx.data;
        ctx.data = _compressedBuffer;

        next();  // Verstuur gecomprimeerd

        ctx.data = originalData;  // Herstel na pipeline (indien nodig)
    }

    const char* getName() const override { return "Compression"; }

private:
    size_t  _threshold;
    uint8_t _compressedBuffer[512];  // Pre-gealloceerd! Geen new[] in middleware
};

} // namespace TractorOS
// src/middleware/RateLimitMiddleware.h
#pragma once
#include "IMiddleware.h"
#include <Arduino.h>

namespace TractorOS {

/**
 * Rate limiting middleware β€” beschermt het netwerk tegen overbelasting.
 * Equivalent van een leaky bucket algorithm.
 */
class RateLimitMiddleware : public IMiddleware {
public:
    explicit RateLimitMiddleware(uint32_t minIntervalMs = 100)
        : _minIntervalMs(minIntervalMs)
        , _lastSendTime(0) {}

    void process(TelemetryContext& ctx, std::function<void()> next) override {
        uint32_t now = millis();
        uint32_t elapsed = now - _lastSendTime;

        if (elapsed < _minIntervalMs) {
            // Te vroeg β€” packet droppen of bufferen
            snprintf(ctx.errorMessage, sizeof(ctx.errorMessage),
                    "Rate limit: wacht nog %ums", _minIntervalMs - elapsed);
            ctx.shouldContinue = false;
            return;  // next() NIET aanroepen = pipeline stoppen
        }

        _lastSendTime = now;
        next();
    }

    const char* getName() const override { return "RateLimit"; }

private:
    uint32_t _minIntervalMs;
    uint32_t _lastSendTime;
};

} // namespace TractorOS

# 6.3 De Pipeline Orchestrator

// src/core/MiddlewarePipeline.h
#pragma once
#include <vector>
#include <memory>
#include "middleware/IMiddleware.h"

namespace TractorOS {

/**
 * Bouwt en executeert de middleware pipeline.
 * 
 * Java equivalent: interceptor chain in Spring
 * TS equivalent:   Express app.use() + router
 * 
 * Gebruik:
 *   pipeline.use(make_unique<ChecksumMiddleware>())
 *          .use(make_unique<CompressionMiddleware>())
 *          .use(make_unique<RateLimitMiddleware>(100));
 *   pipeline.execute(context);
 */
class MiddlewarePipeline {
public:
    // Method chaining β€” builder pattern
    // Retourtype is referentie naar *this, voor chaining: pipeline.use(...).use(...)
    MiddlewarePipeline& use(std::unique_ptr<IMiddleware> middleware) {
        _chain.push_back(std::move(middleware));
        return *this;
    }

    void execute(TelemetryContext& ctx) {
        _executeFrom(ctx, 0);
    }

private:
    /**
     * Recursieve pipeline executie.
     * Dit is het hart van het middleware pattern:
     * elke middleware ontvangt een 'next' functie die de VOLGENDE middleware aanroept.
     */
    void _executeFrom(TelemetryContext& ctx, size_t index) {
        if (index >= _chain.size() || !ctx.shouldContinue) {
            return;  // Einde van de pipeline
        }

        // Maak een lambda die de volgende stap aanroept
        // In Java: Runnable next = () -> executeFrom(ctx, index + 1);
        auto next = [this, &ctx, index]() {
            _executeFrom(ctx, index + 1);
        };

        _chain[index]->process(ctx, next);
    }

    std::vector<std::unique_ptr<IMiddleware>> _chain;
};

} // namespace TractorOS

# 7. Hardware Abstractie: de MVC-split

Dit is het meest embedded-specifieke onderdeel. We scheiden drie lagen:

Model:      Sensordata structuren + domeinlogica
View:       Display-output (LCD, Serial, LED-indicatoren)
Controller: Sensor polling, event handling, main loop

# 7.1 De Sensor Interface (Model laag)

// src/interfaces/ISensor.h
#pragma once
#include <cstdint>

namespace TractorOS {

/**
 * Generieke sensordata container.
 * Gebruik een tagged union (of std::variant in C++17) voor type-veiligheid.
 */
struct SensorReading {
    enum class Type : uint8_t {
        TEMPERATURE,
        FUEL_LEVEL,
        RPM,
        GPS_POSITION,
        INVALID
    };

    Type type = Type::INVALID;
    uint32_t timestamp;  // millis() op moment van meting
    bool     isValid = false;

    union {              // Union bespaart geheugen β€” slechts één waarde tegelijk
        float   floatValue;
        int32_t intValue;
        struct { float lat; float lon; } gps;
    } value;
};

/**
 * Sensorinterface β€” hardware-agnostisch.
 * Testbaar door MockSensor, herbruikbaar voor alle sensortypen.
 */
class ISensor {
public:
    virtual ~ISensor() = default;

    virtual bool         initialize() = 0;
    virtual SensorReading read() = 0;
    virtual bool         isReady() const = 0;
    virtual const char*  getLabel() const = 0;
};

} // namespace TractorOS

# 7.2 Concrete Sensor Implementatie

// src/sensors/DS18B20Sensor.h
#pragma once
#include "interfaces/ISensor.h"
#include <OneWire.h>
#include <DallasTemperature.h>

namespace TractorOS {

/**
 * DS18B20 temperatuursensor (populair voor motor-temp meting).
 * De hardware-interactie is volledig ingekapseld in deze klasse.
 * TelemetryDispatcher weet niets van OneWire of Dallas protocol.
 */
class DS18B20Sensor : public ISensor {
public:
    explicit DS18B20Sensor(uint8_t dataPin)
        : _oneWire(dataPin)
        , _dallasSensors(&_oneWire) {}

    bool initialize() override {
        _dallasSensors.begin();
        _deviceCount = _dallasSensors.getDeviceCount();
        Serial.printf("DS18B20: %d sensor(s) gevonden\n", _deviceCount);
        return _deviceCount > 0;
    }

    SensorReading read() override {
        SensorReading reading;
        reading.type = SensorReading::Type::TEMPERATURE;
        reading.timestamp = millis();

        _dallasSensors.requestTemperatures();
        float temp = _dallasSensors.getTempCByIndex(0);

        // Valideer de meting β€” hardware faalt soms
        if (temp == DEVICE_DISCONNECTED_C || temp < -55.0f || temp > 125.0f) {
            reading.isValid = false;
            return reading;
        }

        reading.isValid = true;
        reading.value.floatValue = temp;
        return reading;
    }

    bool isReady() const override { return _deviceCount > 0; }
    const char* getLabel() const override { return "Motortemperatuur"; }

private:
    OneWire          _oneWire;
    DallasTemperature _dallasSensors;
    uint8_t          _deviceCount = 0;
};

} // namespace TractorOS

# 7.3 De Mock Sensor (voor testing zonder hardware)

// src/sensors/MockSensor.h
#pragma once
#include "interfaces/ISensor.h"
#include <functional>

namespace TractorOS {

/**
 * Mock implementatie voor unit testing en development zonder hardware.
 * 
 * Java equivalent: @Mock ISensor sensor; Mockito.when(sensor.read()).thenReturn(...)
 * TS equivalent:   jest.fn().mockReturnValue(...)
 * 
 * In C++ maken we een expliciete mock met configureerbaar gedrag via een lambda.
 */
class MockSensor : public ISensor {
public:
    using ReadingProvider = std::function<SensorReading()>;

    MockSensor(const char* label, ReadingProvider provider)
        : _label(label)
        , _provider(provider) {}

    // Factory method voor simpele statische waarden
    static std::unique_ptr<MockSensor> withConstantValue(
        const char* label, float value, SensorReading::Type type) {

        return std::make_unique<MockSensor>(label, [value, type]() {
            SensorReading r;
            r.type = type;
            r.isValid = true;
            r.timestamp = millis();
            r.value.floatValue = value;
            return r;
        });
    }

    bool initialize() override { return true; }
    SensorReading read() override { return _provider(); }
    bool isReady() const override { return true; }
    const char* getLabel() const override { return _label; }

private:
    const char*     _label;
    ReadingProvider _provider;
};

} // namespace TractorOS

# 7.4 De SensorManager (Controller laag)

// src/core/SensorManager.h
#pragma once
#include <vector>
#include <memory>
#include <functional>
#include "interfaces/ISensor.h"

namespace TractorOS {

/**
 * CoΓΆrdineert alle sensoren.
 * Observer pattern: consumers registreren callbacks voor nieuwe metingen.
 * 
 * Java: EventBus / Spring ApplicationEventPublisher
 * TS:   EventEmitter / RxJS Subject
 */
class SensorManager {
public:
    using ReadingCallback = std::function<void(const SensorReading&)>;

    void registerSensor(std::unique_ptr<ISensor> sensor) {
        if (sensor->initialize()) {
            Serial.printf("Sensor geregistreerd: %s\n", sensor->getLabel());
            _sensors.push_back(std::move(sensor));
        }
    }

    void onReading(ReadingCallback callback) {
        _callbacks.push_back(callback);
    }

    // Aanroepen in de main loop
    void poll() {
        for (auto& sensor : _sensors) {
            if (!sensor->isReady()) continue;

            SensorReading reading = sensor->read();
            if (!reading.isValid) continue;

            // Notificeer alle geregistreerde consumers
            for (auto& cb : _callbacks) {
                cb(reading);
            }
        }
    }

private:
    std::vector<std::unique_ptr<ISensor>>  _sensors;
    std::vector<ReadingCallback>            _callbacks;
};

} // namespace TractorOS

# 8. Alles samenvoegen: de Boordcomputer Architectuur

// src/main.cpp β€” de "composition root"
// Dit is waar alle DI plaatsvindt. Vergelijk met:
//   Java Spring: @SpringBootApplication main()
//   Angular:     app.module.ts providers[]
//   NestJS:      AppModule providers

#include <Arduino.h>
#include <memory>

#include "core/ServiceLocator.h"
#include "core/TelemetryDispatcher.h"
#include "core/MiddlewarePipeline.h"
#include "core/SensorManager.h"

#include "transport/WiFiTransport.h"
#include "transport/LoRaTransport.h"
#include "transport/FailoverTransport.h"

#include "middleware/ChecksumMiddleware.h"
#include "middleware/CompressionMiddleware.h"
#include "middleware/RateLimitMiddleware.h"

#include "sensors/DS18B20Sensor.h"
#include "sensors/MockSensor.h"

using namespace TractorOS;

// ===== CONFIGURATIE =====
constexpr const char* WIFI_SSID     = "TractorNet";
constexpr const char* WIFI_PASS     = "geheim123";
constexpr const char* SERVER_HOST   = "192.168.1.100";
constexpr uint16_t    SERVER_PORT   = 8765;
constexpr uint32_t    POLL_INTERVAL = 1000;  // ms

// ===== LANGLEVENDE OBJECTEN (buiten loop() om heap-allocatie te vermijden) =====
// Statische allocatie in BSS segment β€” meest memory-efficiΓ«nt
static WiFiTransport  wifiTransport(WIFI_SSID, WIFI_PASS, SERVER_HOST, SERVER_PORT);
static LoRaTransport  loraTransport(868E6);
static FailoverTransport failoverTransport;

static MiddlewarePipeline pipeline;
static SensorManager      sensorManager;

// TelemetryDispatcher heeft een referentie nodig β€” initialiseren na failoverTransport
static TelemetryDispatcher* dispatcher = nullptr;

void setup() {
    Serial.begin(115200);
    Serial.println("TractorOS booting...");

    // === Transport configureren (Strategy + Failover) ===
    failoverTransport.addTransport(&wifiTransport, 0);   // Primair
    failoverTransport.addTransport(&loraTransport,  1);  // Fallback

    // === Middleware pipeline configureren ===
    pipeline
        .use(std::make_unique<RateLimitMiddleware>(100))     // Max 10 packets/sec
        .use(std::make_unique<ChecksumMiddleware>())          // Integriteitscheck
        .use(std::make_unique<CompressionMiddleware>(64));    // Comprimeer >64 bytes

    // === Sensoren registreren ===
    #ifdef HARDWARE_BUILD
        sensorManager.registerSensor(
            std::make_unique<DS18B20Sensor>(GPIO_NUM_4)
        );
    #else
        // Development zonder hardware: mock sensoren
        sensorManager.registerSensor(
            MockSensor::withConstantValue("Motortemp", 87.5f, 
                                          SensorReading::Type::TEMPERATURE)
        );
    #endif

    // === Observer: sensor readings β†’ telemetrie dispatcher ===
    dispatcher = new TelemetryDispatcher(failoverTransport);

    sensorManager.onReading([](const SensorReading& reading) {
        // Bouw een telemetrie packet
        TelemetryPacket packet{};
        packet.timestamp = reading.timestamp;

        if (reading.type == SensorReading::Type::TEMPERATURE) {
            packet.engineTemp = reading.value.floatValue;
        }

        // Stuur door de middleware pipeline
        uint8_t buffer[sizeof(TelemetryPacket)];
        memcpy(buffer, &packet, sizeof(packet));

        TelemetryContext ctx{};
        ctx.data       = buffer;
        ctx.dataLength = sizeof(packet);
        ctx.timestamp  = packet.timestamp;

        pipeline.execute(ctx);

        if (ctx.shouldContinue) {
            dispatcher->dispatch(packet);
        }
    });

    Serial.println("TractorOS gereed.");
}

void loop() {
    static uint32_t lastPoll = 0;
    uint32_t now = millis();

    if (now - lastPoll >= POLL_INTERVAL) {
        lastPoll = now;
        sensorManager.poll();
    }

    // Andere taken (display update, knop-input, etc.) kunnen hier
    delay(10);
}

# Architecturale samenvatting

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                   main.cpp                          β”‚
β”‚              (Composition Root / DI)                β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
           β”‚              β”‚              β”‚
    β”Œβ”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β”‚SensorManagerβ”‚ β”‚Middleware  β”‚ β”‚Telemetry      β”‚
    β”‚(Controller) β”‚ β”‚Pipeline    β”‚ β”‚Dispatcher     β”‚
    β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
           β”‚              β”‚              β”‚
    β”Œβ”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β”‚  ISensor    β”‚ β”‚IMiddleware β”‚ β”‚  ITransport   β”‚
    β”‚  (Model)    β”‚ β”‚(Pipeline)  β”‚ β”‚  (Strategy)   β”‚
    β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
           β”‚              β”‚              β”‚
     DS18B20      Checksumβ”‚Compress    WiFiβ”‚LoRaβ”‚4G
     MockSensor   RateLimit           FailoverTransport

# 9. PlatformIO Project Setup

# platformio.ini

[env:esp32dev]
platform  = espressif32
board     = esp32dev
framework = arduino

; C++17 voor structured bindings, std::optional, if constexpr
build_flags = 
    -std=gnu++17
    -DCORE_DEBUG_LEVEL=3
    -DCONFIG_ARDUHAL_LOG_COLORS=1
    ; Activeer hardware build voor echte sensoren:
    ; -DHARDWARE_BUILD

lib_deps = 
    milesburton/DallasTemperature @ ^3.11.0
    paulstoffregen/OneWire @ ^2.3.7
    sandeepmistry/LoRa @ ^0.8.0

; Testenvironment β€” draait native op je development machine
[env:native_test]
platform  = native
build_flags = -std=gnu++17 -DNATIVE_TEST

# Projectstructuur

tractor-computer/
β”œβ”€β”€ platformio.ini
β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ main.cpp
β”‚   β”œβ”€β”€ interfaces/
β”‚   β”‚   β”œβ”€β”€ ITransport.h
β”‚   β”‚   β”œβ”€β”€ ISensor.h
β”‚   β”‚   └── IMiddleware.h
β”‚   β”œβ”€β”€ transport/
β”‚   β”‚   β”œβ”€β”€ WiFiTransport.h
β”‚   β”‚   β”œβ”€β”€ LoRaTransport.h
β”‚   β”‚   └── FailoverTransport.h
β”‚   β”œβ”€β”€ middleware/
β”‚   β”‚   β”œβ”€β”€ ChecksumMiddleware.h
β”‚   β”‚   β”œβ”€β”€ CompressionMiddleware.h
β”‚   β”‚   └── RateLimitMiddleware.h
β”‚   β”œβ”€β”€ sensors/
β”‚   β”‚   β”œβ”€β”€ DS18B20Sensor.h
β”‚   β”‚   └── MockSensor.h
β”‚   └── core/
β”‚       β”œβ”€β”€ SensorManager.h
β”‚       β”œβ”€β”€ TelemetryDispatcher.h
β”‚       β”œβ”€β”€ MiddlewarePipeline.h
β”‚       └── ServiceLocator.h
└── test/
    β”œβ”€β”€ test_pipeline/
    β”‚   └── test_main.cpp
    └── test_transport/
        └── test_main.cpp

# 10. Testing zonder OS

Op embedded systemen heb je geen JUnit of Jest. PlatformIO biedt wel Unity (C test framework), en met de native omgeving kun je pure logica testen zonder ESP32.

# Unit test met PlatformIO Unity

// test/test_pipeline/test_main.cpp
#include <unity.h>
#include "../../src/core/MiddlewarePipeline.h"
#include "../../src/middleware/ChecksumMiddleware.h"
#include "../../src/middleware/RateLimitMiddleware.h"

using namespace TractorOS;

void test_checksum_appended() {
    MiddlewarePipeline pipeline;
    pipeline.use(std::make_unique<ChecksumMiddleware>());

    uint8_t data[] = {0x01, 0x02, 0x03, 0x04};
    TelemetryContext ctx{};
    ctx.data = data;
    ctx.dataLength = 4;

    pipeline.execute(ctx);

    // XOR van 01^02^03^04 = 0x04
    TEST_ASSERT_EQUAL(5, ctx.dataLength);
    TEST_ASSERT_EQUAL(0x04, data[4]);
}

void test_rate_limit_blocks_rapid_sends() {
    MiddlewarePipeline pipeline;
    pipeline.use(std::make_unique<RateLimitMiddleware>(1000));  // 1 per seconde

    uint8_t data[4] = {};
    TelemetryContext ctx1{}, ctx2{};
    ctx1.data = ctx2.data = data;
    ctx1.dataLength = ctx2.dataLength = 4;

    pipeline.execute(ctx1);
    pipeline.execute(ctx2);  // Direct daarna β€” moet geblokkeerd worden

    TEST_ASSERT_TRUE(ctx1.shouldContinue);
    TEST_ASSERT_FALSE(ctx2.shouldContinue);
}

int main() {
    UNITY_BEGIN();
    RUN_TEST(test_checksum_appended);
    RUN_TEST(test_rate_limit_blocks_rapid_sends);
    return UNITY_END();
}

# Mock-gebaseerde integratietest

void test_dispatcher_uses_mock_transport() {
    // Arrange
    bool sendCalled = false;

    // Lambda-based mock transport β€” geen volledige klasse nodig voor simpele tests
    class InlineTransportMock : public ITransport {
    public:
        bool& _called;
        InlineTransportMock(bool& called) : _called(called) {}
        bool connect() override { return true; }
        void disconnect() override {}
        bool send(const uint8_t*, size_t) override { _called = true; return true; }
        bool receive(uint8_t*, size_t&, size_t) override { return false; }
        bool isConnected() const override { return true; }
        const char* getName() const override { return "Mock"; }
    } mockTransport(sendCalled);

    TelemetryDispatcher dispatcher(mockTransport);
    TelemetryPacket packet{};

    // Act
    dispatcher.dispatch(packet);

    // Assert
    TEST_ASSERT_TRUE(sendCalled);
}

# Bijlage: Veelgestelde Syntax Vragen

# Waarom -> soms en . soms?

MySensor sensor;      // Object op stack
sensor.read();        // Punt: direct object toegang

MySensor* ptr = &sensor;  // Pointer
ptr->read();              // Pijl: pointer dereference + member access
// ptr->read() is exact hetzelfde als (*ptr).read()

# Wat is uint8_t, size_t etc.?

// Java: byte, int, long  (altijd gesigned, altijd dezelfde grootte)
// C++: int kan 16, 32 of 64 bit zijn afhankelijk van platform!

#include <cstdint>   // Gegarandeerde groottes:
uint8_t  byte_val;   // 0-255         (= Java's byte maar unsigned)
uint16_t word_val;   // 0-65535
uint32_t dword_val;  // 0-4294967295
int32_t  signed_val; // -2^31 tot 2^31-1

size_t   size;       // Platform-native unsigned integer voor maten/indices
                     // = Java's int voor arrays, maar unsigned

# Waarom #pragma once en niet #ifndef?

// Oude stijl (nog veel gebruikt):
#ifndef MY_HEADER_H
#define MY_HEADER_H
// ... inhoud ...
#endif

// Moderne stijl (ondersteund door alle C++ compilers voor embedded):
#pragma once
// ... inhoud ...
// Simpeler, sneller, en voorkomt typo-bugs in de guard naam

# Wat is constexpr?

// Runtime constante (Java final): waarde bepaald bij uitvoering
const int bufferSize = getConfiguredSize();

// Compile-time constante (geen Java equivalent): waarde bepaald bij compilatie
constexpr int MAX_SENSORS = 8;
constexpr int BUFFER_SIZE = MAX_SENSORS * sizeof(SensorReading);
// De array-grootte is nu bekend bij compilatie β€” essentieel voor stack-allocatie
uint8_t buffer[BUFFER_SIZE];  // Dit werkt alleen met constexpr, niet met const

# override vs Java's @Override

class WiFiTransport : public ITransport {
    bool connect() override { ... }
    //              ^^^^^^^^
    //   override vertelt de compiler: "dit MOET een bestaande virtual methode overschrijven"
    //   Zonder override: compileert ook, maar geeft geen fout bij een typefout in de naam!
    //   Met override: compileert NIET als er geen virtual connect() in de base class is.
    //   Altijd gebruiken β€” equivalent van Java's @Override.
};

Cursus einde. Volgende stappen: implementeer de GPS-sensor, voeg een LCD display toe als View-laag, en experimenteer met FreeRTOS tasks voor concurrent sensor polling.

Reacties (0 )

Geen reacties beschikbaar.