Write a Firmware Module

Modules extend a CONDUYT device with custom hardware capabilities. Each module handles commands from the host, can emit events, and runs a poll loop for background work.

Write a custom module when built-in pin control and datastreams aren't enough — for example, devices with initialization sequences (displays), multi-step protocols (1-Wire sensors), or continuous processing (PID loops, stepper pulse generation).

The module interface

Every module implements ConduytModuleBase:

class ConduytModuleBase {
public:
  virtual const char* name() = 0;        // module name, max 8 characters
  virtual uint8_t versionMajor() { return 1; }
  virtual uint8_t versionMinor() { return 0; }
  virtual void begin() {}                // called after device.begin()
  virtual void handle(uint8_t cmd, ConduytPayloadReader &payload, ConduytContext &ctx) = 0;
  virtual void poll() {}                 // called every device.poll() cycle
  virtual uint8_t pinCount() { return 0; }
  virtual const uint8_t* pins() { return nullptr; }
};

Example: a relay module

This module controls a relay on pin 4 with two commands: set state and read state.

#define CONDUYT_MODULE_RELAY
#include <Conduyt.h>

CONDUYT_MODULE(RelayModule) {
public:
  const char* name() override { return "relay"; }
  uint8_t versionMajor() override { return 1; }
  uint8_t versionMinor() override { return 0; }

  void begin() override {
    // Called once after device.begin() — initialize your hardware here
    pinMode(_pin, OUTPUT);
    digitalWrite(_pin, LOW);
  }

  void handle(uint8_t cmd, ConduytPayloadReader &payload, ConduytContext &ctx) override {
    CONDUYT_ON_CMD(0x01) {
      // Command 0x01: Set relay state
      // Host sends: [0 or 1]
      uint8_t state = payload.readUInt8();
      digitalWrite(_pin, state ? HIGH : LOW);
      ctx.ack();   // confirm the command succeeded
    }

    CONDUYT_ON_CMD(0x02) {
      // Command 0x02: Read relay state
      // Host sends: nothing
      // We respond with: [0 or 1]
      uint8_t buf[1];
      ConduytPayloadWriter w(buf, sizeof(buf));
      w.writeUInt8(digitalRead(_pin));
      ctx.sendModResp(0x02, buf, w.length());
    }
  }

  void poll() override {
    // Called every loop cycle — nothing to do for a relay
  }

private:
  uint8_t _pin = 4;
};

Register the module

Add modules before calling device.begin(). Module IDs are assigned by registration order — first module = ID 0, second = ID 1, etc.

void setup() {
  Serial.begin(115200);

  device.addModule(new RelayModule());        // module ID 0
  device.addModule(new TempSensorModule());   // module ID 1
  device.begin();
}

These IDs appear in the HELLO_RESP packet so the host knows what modules are available and how to address them.

Context API reference

The ConduytContext passed to handle() controls how the device responds:

MethodWhen to useWhat it sends
ctx.ack()Command succeeded, no data to returnACK packet
ctx.nak(errorCode)Command failedNAK packet with error code
ctx.sendModResp(cmdId, data, len)Command succeeded, returning dataMOD_RESP packet
ctx.emitModEvent(eventId, code, data, len)Unsolicited event from moduleMOD_EVENT packet

If your handler doesn't call any of these, ConduytDevice auto-sends an ACK.

Reading command payloads

ConduytPayloadReader reads typed values in order from the command payload. All multi-byte values are little-endian.

void handle(uint8_t cmd, ConduytPayloadReader &payload, ConduytContext &ctx) override {
  CONDUYT_ON_CMD(0x01) {
    uint8_t  pin   = payload.readUInt8();     // 1 byte
    uint16_t speed = payload.readUInt16();    // 2 bytes, little-endian
    float    angle = payload.readFloat32();   // 4 bytes, IEEE 754
    ctx.ack();
  }
}

Writing response payloads

ConduytPayloadWriter builds response data into a buffer:

CONDUYT_ON_CMD(0x02) {
  uint8_t buf[8];
  ConduytPayloadWriter w(buf, sizeof(buf));
  w.writeUInt8(0x01);          // status byte
  w.writeFloat32(23.5f);       // temperature
  w.writeUInt16(512);          // raw ADC value
  ctx.sendModResp(0x02, buf, w.length());  // w.length() = 7 bytes
}

Poll loop

poll() runs every device.poll() cycle. Use it for continuous background work. Keep it fast and non-blocking — never call delay() inside poll().

void poll() override {
  unsigned long now = millis();
  if (now - _lastRead >= 1000) {
    _lastRead = now;
    _latestTemp = readSensor();
    _hasNewReading = true;
  }
}

poll() doesn't receive a ConduytContext, so it can't send responses directly. Store results in member variables and return them when the host sends a read command via handle().

Control the module from the host

JavaScript

The JavaScript SDK has a .module() proxy that sends MOD_CMD packets by name:

// Get the module proxy — 'relay' matches the name() return value in firmware
const relay = device.module('relay')

// Send command 0x01 (set state) with payload [1] (on)
await relay.cmd(0x01, new Uint8Array([1]))
console.log('Relay ON')

// Send command 0x02 (read state), receive response bytes
const resp = await relay.cmd(0x02)
console.log('Relay state:', resp[0])   // 0 or 1

Python

The Python SDK doesn't have a .module() proxy. For custom modules, write a thin wrapper:

# relay.py
from conduyt.protocol import CMD_MOD_CMD


class RelayModule:
    """Host-side wrapper for the relay firmware module."""

    def __init__(self, device, module_id: int):
        self._device = device
        self._id = module_id

    async def set_state(self, on: bool):
        """Send command 0x01: set relay state."""
        payload = bytes([self._id, 0x01, int(on)])
        await self._device._send_command(CMD_MOD_CMD, payload)

    async def get_state(self) -> int:
        """Send command 0x02: read relay state."""
        payload = bytes([self._id, 0x02])
        resp = await self._device._send_command(CMD_MOD_CMD, payload)
        return resp[0]

Usage:

relay = RelayModule(device, module_id=0)

await relay.set_state(True)
print("Relay ON")

state = await relay.get_state()
print(f"Relay state: {state}")  # 0 or 1

The built-in module wrappers in conduyt.modules (Servo, NeoPixel, DHT, etc.) use the same _send_command() pattern internally.

Compile guards

Gate each module with a #define to keep binary size small on constrained boards (Uno R3 has only 32 KB flash):

#define CONDUYT_MODULE_RELAY
#define CONDUYT_MODULE_TEMPSENSOR
#include <Conduyt.h>

Only the modules you define get compiled. This is especially important on AVR boards where every kilobyte counts.