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:
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:
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()— ReturnsQVector<int>where index is channel, value is N: wordanalogOutputMappings()— 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:
Safe State Implementation¶
The applySafeState() method is called when:
- Engine transitions to Stopped state
- Engine encounters a fault condition
- 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 settingm_lastError - [ ] Add
IOProviderTypeenum value inIOProviderConfig.h - [ ] Update factory switch in
EngineContext::loadIOConfig() - [ ] Add compile definition to CMake if platform-specific
- [ ] Update
IOConfigPanelprovider 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
Related Documentation¶
- I/O Provider Plugin Specification — Third-party plugin development guide
- GPIO Architecture — System architecture for GPIO integration
- Explorer HAT Pro Guide — Example of fixed-mapping provider
- Memory Regions Reference — I:, O:, N: addressing