Skip to content

Porting

Run the driver on a new MCU by writing one class: the UART interface.

The driver never calls your SDK directly. It calls three methods (and an optional fourth) on a class you derive from fdo2::UartInterface<Self> (in inc/fdo2_uart_interface.hpp). The base class uses CRTP: the calls are resolved at compile time, so there are no virtual functions, no vtable and nothing to allocate.

Layer diagram: the application calls the hardware-agnostic FDO2 driver, which uses the CRTP UART interface, which calls the platform port you write, which drives the UART lines of the PyroScience FDO2-G2 oxygen sensor.Layer diagram: the application calls the hardware-agnostic FDO2 driver, which uses the CRTP UART interface, which calls the platform port you write, which drives the UART lines of the PyroScience FDO2-G2 oxygen sensor.
Three layers, one of them yours
Figure description and sources

From top to bottom. Your application talks to one fdo2::Driver object, whose public calls (listed from inc/fdo2_driver.hpp) send the FDO2-G2's ASCII commands and decode the replies into readings in hPa, degrees Celsius, mbar and %RH. The communication interface (UartInterface in inc/fdo2_uart_interface.hpp) is a CRTP base that forwards bytes to your class. The platform port is the only code you write: a class that writes a command, returns one reply line and flushes the receive buffer. Below it is the sensor.

Drawn from: inc/fdo2_driver.hpp Driver public calls; inc/fdo2_uart_interface.hpp UartInterface; figure module docs/diagrams/figures/driver-layers.py.

What you implement

Method Contract
write(data, length) Send length bytes: one command, CR included. Return when they are queued or sent
read(out, max, timeout_ms) Return one complete reply line, CR included, as a byte count; 0 if no whole line arrived within timeout_ms. With timeout_ms = 0, return only a line that is already complete
flush_rx() Drop everything received so far, including a partial line
delay_ms_impl(ms) Optional. Block for at least ms; UartInterface::delay_ms() calls it, and does nothing without it

The driver calls flush_rx() and then write() at the start of every command, and read() once per reply.

UART port

my_uart.hpp
class MyUart : public fdo2::UartInterface<MyUart> {
public:
  // Send one command line (the driver adds the CR).
  void write(const uint8_t* data, std::size_t length) noexcept { platform_uart_write(data, length); }

  // Return one complete reply line, CR included, or 0 if none arrived within timeout_ms.
  // timeout_ms == 0 means "only if a whole line is already here".
  std::size_t read(uint8_t* out, std::size_t max, uint32_t timeout_ms) noexcept {
    const uint32_t start = platform_millis();
    for (;;) {
      uint8_t byte = 0;
      while (platform_uart_read_byte(byte)) {
        if (len_ < sizeof(line_)) {
          line_[len_++] = byte;
        }
        if (byte == '\r') {                          // the module ends every reply with one CR
          const std::size_t n = (len_ < max) ? len_ : max;
          std::memcpy(out, line_, n);
          len_ = 0;
          return n;
        }
      }
      if (platform_millis() - start >= timeout_ms) {
        return 0;                                    // keep a partial line for the next call
      }
      platform_delay_ms(1);
    }
  }

  void flush_rx() noexcept {
    platform_uart_flush();
    len_ = 0;
  }

  // Optional: lets the driver and your code wait through UartInterface::delay_ms().
  void delay_ms_impl(uint32_t ms) noexcept { platform_delay_ms(ms); }

private:
  uint8_t line_[192]{};                              // longest reply: #MRAW, under 120 characters
  std::size_t len_ = 0;
};

Rules the port must follow:

  • 19200 baud, 8N1, no flow control. That is the module's setting after every power-up.
  • Return at the CR. The driver parses what one read() returns as the whole reply. A port that returns part of a line hands the driver a short reply (ProtocolError) and leaves the rest for the next command.
  • Return as soon as the line is complete. A port that waits for max bytes or for the full timeout (a plain uart_read_bytes(port, buf, max, timeout) in ESP-IDF does this) still works, but every call then takes the whole timeout.
  • Keep a partial line on timeout. The split exchange polls with timeout_ms = 0 while a reply is arriving; the port above keeps the bytes it has and returns the line on a later call.
  • Use a receive buffer of at least 128 bytes. The longest reply, #MRAW with a CRC suffix, is under 120 characters.

Reference implementation

The ESP32 examples use hf_fdo2_esp_uart.hpp: a template Fdo2EspIdfUart<port, tx_gpio, rx_gpio> over the ESP-IDF UART driver, with a static Install() that configures 19200 baud 8N1 and the pins. Its read() follows the rules above: it reads one byte at a time with the time left of the timeout, returns at the CR, and keeps a partial line for the next call. The host tests build it against a model of the ESP-IDF UART calls (tests/esp_idf_stub/) and check that a command returns when the reply arrives.

Check your port

  1. Build it: the port on this page is compiled by the host CI from tests/docs/, with the driver instantiated over it.
  2. Wait 1 s after power-up and call ReadVersion(): device id 8 proves both directions and the baud rate.
  3. Call MeasureMoxy() in air: about 210 hPa at sea level, status 0 or 1.
  4. Time a MeasureMoxy() call: about 150 ms plus transfer time means the port returns at the CR; the full timeout means it waits for more bytes than the reply has.

Threads

The driver is not thread-safe. Give each sensor one owner task, or guard every call on a driver object (and its port) with one mutex. Several sensors on separate UARTs can run in parallel.