_.---..._
./^ ^-._
./^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.
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.
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 thatco_return
s aValue
. Use this as the return type of any function containingco_await
expressions orco_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 specifieddelay
amount of time and then resume it using the specifiedcontext
. - #include <picoro/event_loop.h> defines
run_event_loop(async_context_t*, Coroutines...)
.run_event_loop
is an infinite loop that repeatedly polls theasync_context_t
for work to do, sleeping when there is no work to do. TheCoroutines...
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 anaccept()
member function that can beco_await
ed to obtain a tuple(Connection, err_t)
.class Connection
is anaccept()
ed connection that hassend(std::string_view)
andrecv(char*, int)
member functions that can beco_await
ed to send and receive data, respectively, to and from the client.
- #include <picoro/debug.h> defines
debug
, which is a wrapper aroundprintf
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 beco_await
ed 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 beco_await
ed 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 whichclass dht22::Sensor
instances can be made.dht22::Sensor
has a member function that can beco_await
ed 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.
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 functionalityhardware_i2c
for the SCD4x sensor driverhardware_pio
,hardware_dma
, andhardware_clocks
for the DHT22 sensor driverpico_async_context_poll
for scheduling suspended coroutinespico_cyw43_arch_lwip_poll
for scheduling network event handlers
See Picoro's CMakeLists.txt for more details.
Each header file is documented in a comment block at the beginning of the file.