Skip to content

C++20 coroutines for the Raspberry Pi Pico W microcontroller board

License

Notifications You must be signed in to change notification settings

dgoffredo/picoro

Repository files navigation

Picoro

              _.---..._
           ./^         ^-._
         ./^C===.         ^\.   /\
        .|'     \\        _ ^|.^.|
   ___.--'_     ( )  .      ./ /||
  /.---^T\      ,     |     / /|||
 C'   ._`|  ._ /  __,-/    / /-,||
      \ \/    ;  /O  / _    |) )|,
       i \./^O\./_,-^/^    ,;-^,'
        \ |`--/ ..-^^      |_-^
         `|  \^-           /|:
          i.  .--         / '|.
           i   =='       /'  |\._
         _./`._        //    |.  ^-ooo.._
  _.oo../'  |  ^-.__./X/   . `|    |#######b
 d####     |'      ^^^^   /   |    _\#######
 #####b ^^^^^^^^--. ...--^--^^^^^^^_.d######
 ######b._         Y            _.d#########
 ##########b._     |        _.d#############

Picoro is a header-only library of C++20 coroutines for the Raspberry Pi Pico W microcontroller board.

Why

I've been playing around with the Raspberry Pi Pico W (datasheet) microcontroller board. I wanted to have an accurate CO₂ sensor for less than $100, and the only way was to buy the bare sensor module and learn how to connect it to a computer.

The SCD41 (datasheet) is a nondispersive infrared sensor (NDIR) of CO₂. It speaks I²C, so if you want to use it you need either an adapter for your computer or a microcontroller board.

The "W" in Raspberry Pi Pico W refers to its onboard 2.4 GHz WiFi and Bluetooth chip, the CYW43439. My plan was to wire the SCD41 to the Pico, write some code, and then query the current CO₂ concentration over the WiFi network.

And it works! My initial solution was written in MicroPython, which is an implementation of Python specifically for microcontrollers. It even implements the asyncio module.

The initial solution had periodic latency spikes that I attributed to MicroPython's garbage collector. I was wrong — the spikes are unrelated to MicroPython. Still, I began rewriting the sensor server in C++ using the Pico's excellent C SDK (GitHub).

The C++ standard library does not have an equivalent to Python's asyncio module. This presents an obstacle when you're writing programs for a platform that does not have an operating system. If your program needs to sleep (wait) for a few milliseconds as part of the CO₂ sensor's communication protocol, the CPU cannot do anything else during that time. The CPU will still handle interrupts, but your program cannot do any more real work until the wait time has elapsed. For example, the program cannot serve an incoming HTTP request.

There are multiple ways to work around this problem. The most straightforward approach is to use the Pico's second CPU core. One part of the program can run on the first core, waiting for the sensor to complete its measurement, while another part of the program running on the second core can manage the WiFi chip and serve HTTP requests. That's cheating, though. My problem is not a lack of compute, but an inappropriate programming model.

So, change the programming model! I could design the program as a state machine. When the program sends a "take a measurement" request to the CO₂ sensor, it could then transition into the "I'm waiting for the sensor" state. From that state, it could transition into the "I'm replying to an HTTP request" state. Only later will the program enter the "I'm reading the response from the sensor" state.

Writing programs this way is not fun. It's easy enough to get right, but woe to the future you trying to understand the program again just a few months later. Our human minds do not think of "poll the sensor for data, and also serve HTTP requests as they come in" as a set of program states with appropriate transitions between them that are equivalent to performing both tasks. That's crazy talk. Instead, we think of the program as being composed of two routines that execute concurrently. Coroutines. I need conceptual machinery that allows me to write the program that way.

In MicroPython, the asyncio module provides the necessary machinery. C++ does not have an equivalent facility.

Except it does. C++20 introduced the coroutines library, which does not contain any coroutines. What it does contain, though, are tools for interacting with a new feature of the programming language: suspendible functions. Using these tools, you can implement your own coroutines, which you can then use to write your program as a composition of concurrently executing procedures.

I'm not the first to take this approach. FunMiles went deep implementing DMA channels, UART, sleep, and other async facilities of the Pico in terms of C++20 coroutines.

My implementation is less powerful, solely focused on the task of writing a CO₂ sensing HTTP server on a single core.

What

Picoro is a header-only library of C++20 coroutine-compatible facilities for use with the Raspberry Pi Pico W. Coroutines that use this library are intended to be scheduled by the Pico C SDK's pico_async_context_poll library.

Picoro consists of the following, all of which live in namespace picoro:

  • #include <picoro/coroutine.h> defines class Coroutine<Value>, a coroutine that co_returns a Value. Use this as the return type of any function containing co_await expressions or co_return statements.
  • #include <picoro/sleep.h> defines sleep_for(async_context_t*, std::chrono::microseconds). co_await sleep_for(context, delay) will suspend the invoking coroutine for the specified delay amount of time and then resume it using the specified context.
  • #include <picoro/event_loop.h> defines run_event_loop(async_context_t*, Coroutines...). run_event_loop is an infinite loop that repeatedly polls the async_context_t for work to do, sleeping when there is no work to do. The Coroutines... are ignored; those parameters exist as a convenient place for coroutine objects that must outlive the event loop.
  • #include <picoro/tcp.h> defines coroutine adapters for the callback-based lwIP library included in the Pico's C SDK. It provides only those facilities needed to run a socket server:
    • class Listener listens on all interfaces on a specified port, with a specified backlog, and has an accept() member function that can be co_awaited to obtain a tuple (Connection, err_t).
    • class Connection is an accept()ed connection that has send(std::string_view) and recv(char*, int) member functions that can be co_awaited to send and receive data, respectively, to and from the client.
  • #include <picoro/debug.h> defines debug, which is a wrapper around printf in debug builds, and a no-op in release builds.
  • #include <picoro/drivers/sensirion/scd4x.h> is a driver for the Sensirion SCD40 and SCD41 CO₂ sensors. It defines struct sensirion::SCD4x, whose member functions can be co_awaited to interact with the sensor.
  • #include <picoro/drivers/sensirion/sht3x.h> is a driver for the Sensirion SHT33 temperature and humidity sensor. It defines struct sensirion::SHT3x, whose member function can be co_awaited to obtain a measurement from the sensor.
  • #include <picoro/drivers/dht22.h> is a driver for the DHT22 (a.k.a. AM2302) temperature and humidity sensor. It defines class dht22::Driver, from which class dht22::Sensor instances can be made. dht22::Sensor has a member function that can be co_awaited to interact with the sensor.
  • examples/co2-server/ is an example program that motivated the writing of this library. It's an HTTP server that responds to all requests with the latest data read from an SCD41 CO₂ sensor.
  • examples/fridge-monitor/ is an example program that prints lines of JSON containing the current temperature and relative humidity as measured by three DHT22 sensors connected to the Pico's GPIO pins.

How

In CMake, add_subdirectory(picoro) and then add the appropriate picoro_* libraries to your target_link_libraries. See the CO2 server example's CMakeLists.txt.

Each include file has a corresponding library target:

  • picoro_coroutine when you #include <picoro/coroutine.h>
  • picoro_debug when you #include <picoro/debug.h>
  • picoro_drivers_dht22 when you #include <picoro/drivers/dht22.h>
  • picoro_drivers_scd4x when you #include <picoro/drivers/sensirion/scd4x.h>
  • picoro_drivers_sht3x when you #include <picoro/drivers/sensirion/sht3x.h>
  • picoro_event_loop when you #include <picoro/event_loop.h>
  • picoro_sleep when you #include <picoro/sleep.h>
  • picoro_tcp when you #include <picoro/tcp.h>

Picoro is a header-only library, but its headers imply link dependencies that are named in the picoro_* targets. For example:

  • pico_stdlib for standard C library functionality
  • hardware_i2c for the SCD4x sensor driver
  • hardware_pio, hardware_dma, and hardware_clocks for the DHT22 sensor driver
  • pico_async_context_poll for scheduling suspended coroutines
  • pico_cyw43_arch_lwip_poll for scheduling network event handlers

See Picoro's CMakeLists.txt for more details.

More

Each header file is documented in a comment block at the beginning of the file.

About

C++20 coroutines for the Raspberry Pi Pico W microcontroller board

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published