Skip to content

I/O Provider Specification

Note: This document describes the internal IIOProvider interface. For developing third-party plugins that can be installed without recompiling PiPLC, see the I/O Provider Plugin Specification.

This document specifies how to design and implement a custom I/O Provider for PiPLC. An I/O Provider abstracts hardware or virtual I/O so the execution engine can read inputs and write outputs without knowing the underlying implementation.

Overview

Purpose

I/O Providers enable PiPLC to interface with different hardware platforms:

  • VirtualIOProvider — In-memory simulation for testing and development
  • GPIOProvider — Linux GPIO via libgpiod for Raspberry Pi and other SBCs
  • ExplorerHatProProvider — Fixed mapping for Pimoroni Explorer HAT Pro
  • ModbusTcpClientProvider — Network I/O via Modbus TCP polling/mapping

Architecture

┌─────────────────────────────────────────────────────────────────┐
│  EngineContext                                                  │
│  ├─ ExecutionEngine (worker thread)                             │
│  │   ├─ MemoryManager                                           │
│  │   │   ├─ I: input words (digital)                            │
│  │   │   ├─ O: output words (digital)                           │
│  │   │   └─ N: integer words (analog)                           │
│  │   └─ IIOProvider* ───────────────────────┐                   │
│  │                                          │                   │
│  └─ Concrete Provider:                      ▼                   │
│      ┌─────────────────────────────────────────────────────┐    │
│      │ VirtualIOProvider     │ GPIOProvider │ YourProvider │    │
│      │ (all platforms)       │ (Linux ARM)  │ (custom)     │    │
│      └─────────────────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────────────────┘

IIOProvider Interface Reference

Located in src/runtime/IIOProvider.h.

Required Methods (Pure Virtual)

These methods must be implemented by every provider:

Method Signature Description
initialize() bool initialize() Initialize hardware/resources. Return true on success, false on failure (set m_lastError).
shutdown() void shutdown() Release resources. Called before destruction.
isReady() bool isReady() const Return true if provider is initialized and operational.
readInputs() const QVector<bool>& readInputs() Return current digital input states. Called each scan cycle.
writeOutputs() void writeOutputs(const QVector<bool> &outputs) Write digital output states. Called each scan cycle.
inputCount() int inputCount() const Return number of digital inputs.
outputCount() int outputCount() const Return number of digital outputs.
name() QString name() const Return display name (e.g., "GPIO", "Explorer HAT Pro").

Optional Methods (Virtual with Defaults)

Override these only if your provider supports the functionality:

Method Default Override When
readAnalogInputs() Returns empty vector Provider has ADC channels
writeAnalogOutputs() No-op Provider has DAC channels
analogInputCount() Returns 0 Provider has ADC channels
analogOutputCount() Returns 0 Provider has DAC channels
analogInputMappings() Returns empty vector Analog inputs map to N: words
analogOutputMappings() Returns empty vector Analog outputs map to N: words
applySafeState() No-op Hardware outputs need de-energizing on stop/fault

Signals

Providers should emit these signals when appropriate:

Signal When to Emit
inputChanged(int index, bool value) Digital input state changes (optional, for UI updates)
outputChanged(int index, bool value) Digital output state changes (optional, for UI updates)
analogInputChanged(int channel, qint32 value) Analog input value changes
analogOutputChanged(int channel, qint32 value) Analog output value changes
readyChanged(bool ready) Provider becomes ready or not ready
errorOccurred(const QString &message) Runtime error occurs

Error Handling

Set m_lastError (protected member) when initialize() fails:

bool MyProvider::initialize()
{
    if (!openHardware()) {
        m_lastError = tr("Failed to open hardware: %1").arg(getErrorString());
        return false;
    }
    return true;
}

Provider Lifecycle

Initialization Sequence

1. `EngineContext::loadIOConfig(config)` is called
2. `IOProviderRegistry` resolves provider ID and validates configuration
3. Registry creates provider instance (or context falls back to `VirtualIOProvider`)
4. `provider->initialize()` is called
5. `ExecutionEngine::setIOProvider(provider)` stores reference
6. Provider is ready for scan cycle operations

Scan Cycle Integration

The ExecutionEngine calls provider methods from its worker thread during each scan:

void ExecutionEngine::executeScan()
{
    // Phase 1: Read inputs
    const auto &inputs = m_ioProvider->readInputs();
    // Copy to I: memory region...

    const auto &analogInputs = m_ioProvider->readAnalogInputs();
    // Copy to N: memory region via mappings...

    // Phase 2: Execute ladder logic
    m_ladderExecutor->executeAllRungs();

    // Phase 3: Write outputs
    m_ioProvider->writeOutputs(outputVector);
    m_ioProvider->writeAnalogOutputs(analogOutputVector);
}

Shutdown Sequence

1. Engine stopped (state → Stopped)
2. provider->applySafeState() called (if engine was running)
3. provider->shutdown() called
4. Provider destroyed (delete)

Registration and Factory

Step 1: Add Enum Value

In src/runtime/IOProviderConfig.h:

enum class IOProviderType {
    Virtual,
    GPIO,
    ExplorerHatPro,
    ModbusTcpClient,
    MyCustomProvider  // Add your type here
};

Step 2: Map Type to Provider IDs

In src/runtime/EngineContext.cpp, update the type-to-provider-id mapping used by the registry:

QString providerIdFromType(IOProviderType type)
{
    switch (type) {
    case IOProviderType::Virtual:         return "virtual";
    case IOProviderType::GPIO:            return "gpio";
    case IOProviderType::ExplorerHatPro:  return "explorer-hat-pro";
    case IOProviderType::ModbusTcpClient: return "modbus-tcp-client";
    case IOProviderType::MyCustomProvider:return "my-custom-provider";
    default:                              return "virtual";
    }
}

Provider identifiers differ by layer:

Layer Explorer HAT Pro Modbus TCP Client
.ioconfig (IOConfigSerializer) explorer_hat_pro modbus_tcp_client
Runtime registry plugin ID explorer-hat-pro modbus-tcp-client

Step 3: Extend .ioconfig Serialization

If your provider needs custom JSON fields, extend IOConfigSerializer load/save:

if (config.type == IOProviderType::MyCustomProvider) {
    root["myCustomBlock"] = myCustomJson;
}

Step 4: Register/Load Plugin

Add a static plugin registration or dynamic plugin build/output so IOProviderRegistry can discover it, then verify it appears in availableProviderIds().

Step 5: Update UI (IOConfigPanel)

In src/widgets/IOConfigPanel.cpp, add provider selection UI and config widgets:

m_providerTypeCombo->addItem(tr("My Custom Provider"));

Configuration

Fixed Mapping vs. User-Configurable

Approach Example When to Use
Fixed Mapping ExplorerHatProProvider Specific hardware board with known pinout
User-Configurable GPIOProvider Generic hardware with user-defined pin assignments

IOProviderConfig Structure

For user-configurable providers, use the IOProviderConfig structure:

struct IOProviderConfig {
    IOProviderType type;
    QString boardName;           // Board descriptor name
    QString gpioChipPath;        // GPIO device path
    QVector<DigitalPinConfig> digitalPins;
    QVector<AnalogChannelConfig> analogChannels;
    int virtualInputCount = 8;   // Fallback counts
    int virtualOutputCount = 8;
};

JSON Serialization

Use IOConfigSerializer to load/save .ioconfig files. If your provider needs custom configuration fields, extend the serializer:

// In IOConfigSerializer.cpp, writeConfig():
if (config.type == IOProviderType::MyCustomProvider) {
    json["myCustomField"] = config.myCustomField;
}

Threading Model

Worker Thread Context

Critical: Provider methods (readInputs(), writeOutputs(), etc.) are called from the ExecutionEngine's worker thread, not the main/UI thread.

Thread Safety Requirements

Scenario Solution
Provider state accessed from UI Use QMutex or std::mutex
Async hardware operations Use std::atomic for shared flags
Signal emissions Qt handles cross-thread signal delivery

Example: Thread-Safe State Access

class MyProvider : public IIOProvider {
private:
    mutable QMutex m_mutex;
    QVector<bool> m_inputs;

public:
    const QVector<bool>& readInputs() override {
        QMutexLocker lock(&m_mutex);
        updateFromHardware();  // Read from hardware into m_inputs
        return m_inputs;
    }

    // Called from UI thread
    bool getInput(int index) const {
        QMutexLocker lock(&m_mutex);
        return m_inputs.value(index, false);
    }
};

Async I/O Pattern

For hardware requiring async operations (e.g., software PWM), see ExplorerHatProProvider::SoftPwmThread:

class MyProvider : public IIOProvider {
private:
    std::atomic<bool> m_running{false};
    std::thread m_asyncThread;

public:
    bool initialize() override {
        m_running = true;
        m_asyncThread = std::thread(&MyProvider::asyncLoop, this);
        return true;
    }

    void shutdown() override {
        m_running = false;
        if (m_asyncThread.joinable())
            m_asyncThread.join();
    }

private:
    void asyncLoop() {
        while (m_running) {
            // Perform async I/O operations
        }
    }
};

Analog I/O Implementation

Memory Mapping

Analog channels map to N: (integer) memory region words. The mapping is defined by:

  • analogInputMappings() — Returns QVector<int> where index is channel, value is N: word
  • analogOutputMappings() — Same for outputs

Example: 4-Channel ADC

int MyProvider::analogInputCount() const { return 4; }

QVector<int> MyProvider::analogInputMappings() const {
    return {10, 11, 12, 13};  // Channels 0-3 → N:10 through N:13
}

const QVector<qint32>& MyProvider::readAnalogInputs() {
    // Read from ADC hardware
    for (int ch = 0; ch < 4; ++ch) {
        m_analogInputs[ch] = readAdcChannel(ch);
    }
    return m_analogInputs;
}

Scaling

Raw ADC/DAC values can be scaled to engineering units using AnalogChannelConfig:

struct AnalogChannelConfig {
    int rawMin = 0;        // ADC minimum (e.g., 0)
    int rawMax = 4095;     // ADC maximum (e.g., 4095 for 12-bit)
    int scaledMin = 0;     // Engineering minimum (e.g., 0 PSI)
    int scaledMax = 10000; // Engineering maximum (e.g., 100.00 PSI)
};

Scaling formula:

scaledValue = (rawValue - rawMin) * (scaledMax - scaledMin) / (rawMax - rawMin) + scaledMin


Safe State Implementation

The applySafeState() method is called when:

  1. Engine transitions to Stopped state
  2. Engine encounters a fault condition
  3. Provider is being shut down

Example: De-energize All Outputs

void MyProvider::applySafeState()
{
    for (int i = 0; i < m_outputCount; ++i) {
        writeHardwareOutput(i, false);  // Drive LOW
    }

    for (int i = 0; i < m_analogOutputCount; ++i) {
        writeHardwareDac(i, 0);  // Zero output
    }
}

Per-Pin Safe State

For user-configurable safe states per output:

void GPIOProvider::applySafeState()
{
    for (const auto &pin : m_outputPins) {
        switch (pin.safeState) {
        case SafeState::Off:
            setGpioLine(pin.lineHandle, false);
            break;
        case SafeState::On:
            setGpioLine(pin.lineHandle, true);
            break;
        case SafeState::Hold:
            // Do nothing - keep current state
            break;
        }
    }
}

Testing

Unit Testing with VirtualIOProvider

Inject a virtual provider for testing without hardware:

void TestMyLogic::testStartButton()
{
    auto *provider = new VirtualIOProvider(8, 8);
    m_context->setExternalIOProvider(provider);

    // Simulate input
    provider->setInput(0, true);  // Start button pressed

    // Run one scan
    m_context->start();
    QTest::qWait(50);
    m_context->stop();

    // Verify output
    QVERIFY(provider->getOutput(0) == true);  // Motor should be on
}

Test Injection

Use EngineContext::setExternalIOProvider() to inject providers for testing:

void EngineContext::setExternalIOProvider(IIOProvider *provider)
{
    // Replaces the internal provider with an external one
    // Useful for test mocking
}

Testing Your Provider

Create a test file following the pattern in tests/runtime/tst_VirtualIOProvider.cpp:

class tst_MyProvider : public QObject
{
    Q_OBJECT

private slots:
    void init() {
        m_provider = new MyProvider();
    }

    void cleanup() {
        delete m_provider;
    }

    void testInitialize() {
        QVERIFY(m_provider->initialize());
        QVERIFY(m_provider->isReady());
    }

    void testReadInputs() {
        m_provider->initialize();
        auto inputs = m_provider->readInputs();
        QCOMPARE(inputs.size(), m_provider->inputCount());
    }

    void testWriteOutputs() {
        m_provider->initialize();
        QVector<bool> outputs(m_provider->outputCount(), true);
        m_provider->writeOutputs(outputs);
        // Verify hardware state...
    }

private:
    MyProvider *m_provider;
};

Minimal Provider Template

// MyProvider.h
#pragma once

#include "IIOProvider.h"

namespace PiPLC {

class MyProvider : public IIOProvider
{
    Q_OBJECT

public:
    explicit MyProvider(QObject *parent = nullptr);
    ~MyProvider() override;

    // Required
    bool initialize() override;
    void shutdown() override;
    bool isReady() const override;

    const QVector<bool>& readInputs() override;
    void writeOutputs(const QVector<bool> &outputs) override;

    int inputCount() const override;
    int outputCount() const override;
    QString name() const override;

    // Optional: Override if hardware needs safe shutdown
    void applySafeState() override;

private:
    QVector<bool> m_inputs;
    QVector<bool> m_outputs;
    bool m_ready = false;
};

} // namespace PiPLC
// MyProvider.cpp
#include "MyProvider.h"

namespace PiPLC {

MyProvider::MyProvider(QObject *parent)
    : IIOProvider(parent)
    , m_inputs(8, false)
    , m_outputs(8, false)
{
}

MyProvider::~MyProvider()
{
    if (m_ready)
        shutdown();
}

bool MyProvider::initialize()
{
    // TODO: Initialize your hardware
    // if (hardwareError) {
    //     m_lastError = tr("Failed to initialize");
    //     return false;
    // }

    m_ready = true;
    emit readyChanged(true);
    return true;
}

void MyProvider::shutdown()
{
    if (!m_ready)
        return;

    applySafeState();

    // TODO: Release hardware resources

    m_ready = false;
    emit readyChanged(false);
}

bool MyProvider::isReady() const
{
    return m_ready;
}

const QVector<bool>& MyProvider::readInputs()
{
    // TODO: Read from hardware into m_inputs
    return m_inputs;
}

void MyProvider::writeOutputs(const QVector<bool> &outputs)
{
    m_outputs = outputs;
    // TODO: Write to hardware
}

int MyProvider::inputCount() const { return 8; }
int MyProvider::outputCount() const { return 8; }
QString MyProvider::name() const { return QStringLiteral("My Provider"); }

void MyProvider::applySafeState()
{
    // TODO: De-energize all outputs
    for (int i = 0; i < outputCount(); ++i) {
        // writeHardwareOutput(i, false);
    }
}

} // namespace PiPLC

Checklist: Implementing a New Provider

  • [ ] Create header and source files in src/runtime/
  • [ ] Inherit from IIOProvider
  • [ ] Implement all pure virtual methods
  • [ ] Handle errors in initialize() by setting m_lastError
  • [ ] Add IOProviderType enum value in IOProviderConfig.h
  • [ ] Update factory switch in EngineContext::loadIOConfig()
  • [ ] Add compile definition to CMake if platform-specific
  • [ ] Update IOConfigPanel provider combo box (optional)
  • [ ] Implement applySafeState() if hardware outputs need de-energizing
  • [ ] Consider thread safety if state accessed from UI
  • [ ] Write unit tests
  • [ ] Update documentation if user-facing