J. Rogers, SE Ohio
The Problem Nobody Talks About
Every maker who has built a WiFi-connected sensor project has lived the same nightmare. You spend an afternoon wiring up a beautiful sensor array, and then three days fighting with HTML, JSON parsers, WiFi reconnection logic, and Home Assistant's MQTT discovery format. By the time it works, you've forgotten why you started.
The dirty secret of hobbyist IoT is that the hardware is the easy part. The networking layer is where projects go to die.
A Different Model
The PicoW IoT Framework flips that equation. It treats the Raspberry Pi Pico W like a tiny two-department company. One department — Core 0 — handles every networking concern that ever killed a project: WiFi connection, the web server, the REST API, mDNS advertisement, and Home Assistant discovery. That department runs forever and you never touch it. The other department — Core 1 — is yours. It runs a cooperative task scheduler and knows nothing about networking. It just reads hardware and writes numbers to a shared registry.
That registry is the insight. When your wind speed callback calls registry.set("wind_speed", mph), four things happen simultaneously without you writing a single additional line of code. The value appears on the auto-generated web dashboard. It's available via the REST API. It gets broadcast to Home Assistant via MQTT. And it's ready for any other task on Core 1 to read and use in its own logic. One write, four consumers.
What This Post Covers
This post walks through adding three physically distinct sensor types to a running weather station — a PIO-based pulse counter for an anemometer, a second PIO pulse counter with accumulation for a rain gauge tipping bucket, and an ADC resistor ladder for wind direction. Each follows the same four steps: add the library, define pins and globals, initialize hardware in app_setup(), write a self-rescheduling callback and register it. After those four steps the scheduler owns the timing forever, and everything the sensor produces is automatically published everywhere.
The deeper point is what this model enables going forward. An adaptive polling rate — slowing a temperature sensor to once per minute when readings are stable, speeding it back up when they're changing — is just an if statement inside the callback. The framework doesn't need to change. A sensor that detects rainfall and automatically increases its own polling rate is the same pattern. The intelligence lives in the task, and the task owns itself.
This is what a proper IoT OS for hobbyists looks like. Not a cloud service. Not a proprietary hub. A $12 board, open source code, and a model simple enough that adding a new sensor takes twenty minutes instead of a week.
STEP 1 — Libraries
No external libraries needed. PIO is part of the RP2040 SDK which the Earle Philhower core already includes.
// At the top of WeatherStation.ino, with your other includes
#include "hardware/pio.h"
#include "hardware/clocks.h"
#include "pulse_counter.pio.h" // we'll define this below
STEP 2 — Pin Definitions and Globals
// ============================================================================
// 4. PROJECT-SPECIFIC HARDWARE DEFINITIONS AND STATE VARIABLES
// ============================================================================
// --- Wind / Rain Pins ---
#define PIN_WIND_SPEED 10 // reed switch, one pulse per rotation
#define PIN_RAIN_GAUGE 11 // tipping bucket, one pulse per 0.011 inches
#define PIN_WIND_DIR 26 // analog ADC, resistor ladder
// --- PIO state machine handles ---
PIO pio_wind = pio0;
uint sm_wind = 0;
PIO pio_rain = pio0;
uint sm_rain = 1;
// --- Rain accumulator (total since last reset) ---
volatile float rain_total_inches = 0.0f;
#define RAIN_INCHES_PER_TIP 0.011f
// --- Wind speed calibration ---
// Typical anemometer: 1 rotation/sec = 1.492 mph (Davis spec — adjust for your hardware)
#define WIND_MPH_PER_RPS 1.492f
The PIO Program
Create pulse_counter.pio as a new file in your sketch folder. One word is pushed to the FIFO per falling edge. Your task drains the FIFO and counts how many words are in it — each word is one pulse. Draining the FIFO is the reset, so read-and-reset is a single operation.
Note: the default PIO RX FIFO is 4 words deep; with PIO_FIFO_JOIN_RX it doubles to 8. This is sufficient for typical weather station wind speeds but pulses can be dropped if the FIFO fills between reads in extreme conditions.
; pulse_counter.pio
; One FIFO push per falling edge. CPU drains FIFO to count pulses.
; Drain = read and reset in one operation.
.program pulse_counter
.wrap_target
wait 1 pin 0 ; wait for line to be HIGH (debounce start)
wait 0 pin 0 ; wait for falling edge
push noblock ; push anything to FIFO — we only care about count
.wrap
The C++ init function to load this onto a state machine:
// Call once in app_setup() for each PIO sensor
uint pulse_counter_init(PIO pio, uint sm, uint pin) {
uint offset = pio_add_program(pio, &pulse_counter_program);
pio_sm_config c = pulse_counter_program_get_default_config(offset);
// Configure the input pin
sm_config_set_in_pins(&c, pin);
pio_gpio_init(pio, pin);
gpio_pull_up(pin); // reed switches need pull-up
pio_sm_set_consecutive_pindirs(pio, sm, pin, 1, false); // input
// Enable RX FIFO joining for more buffer depth (optional but nice)
sm_config_set_fifo_join(&c, PIO_FIFO_JOIN_RX);
pio_sm_init(pio, sm, offset, &c);
pio_sm_set_enabled(pio, sm, true);
return offset;
}
// Drain the FIFO and return pulse count — this IS the read-and-reset
uint32_t drain_pulse_count(PIO pio, uint sm) {
uint32_t count = 0;
while (!pio_sm_is_rx_fifo_empty(pio, sm)) {
pio_sm_get(pio, sm); // discard the value, just count it
count++;
}
return count;
}
Note: This is a different FIFO than the one used for inter-core communication inside the registry.
The FIFO in PicoCoreFifo.h is the RP2040's inter-core FIFO — the hardware channel that passes messages between Core 0 and Core 1. That's how sensor values get from your callback on Core 1 to the web server on Core 0.
The FIFO used for pulse counting is the PIO's own RX FIFO — each PIO state machine has its own dedicated hardware buffer that has nothing to do with the inter-core channel. They don't share hardware or interfere with each other at all.
STEP 3 — app_setup() — Start the PIO state machines
void app_setup() {
pinMode(LED_BUILTIN, OUTPUT);
// Start PIO pulse counters
pulse_counter_init(pio_wind, sm_wind, PIN_WIND_SPEED);
pulse_counter_init(pio_rain, sm_rain, PIN_RAIN_GAUGE);
// Wind direction ADC pin — just needs to be in analog mode
pinMode(PIN_WIND_DIR, INPUT);
// ADC is always on for analog pins, no init needed
// Existing tasks
AddTaskMilli(CreateTask(), 500, &blinkLED, 1, LED_BUILTIN);
AddTaskMilli(CreateTask(), 1000, &controlLogicUpdate, 0, 0);
// Note: wind/rain tasks are auto-scheduled via SENSOR_AUTO in the registry
}
STEP 4 — The Sensor Callbacks
Wind Speed — reads pulse count, calculates mph over the sampling interval:
int readWindSpeed(struct _task_entry_type* task, int idx, int) {
uint32_t interval_ms = registry.getItem(idx)->update_interval_ms;
AddTaskMilli(task, interval_ms, &readWindSpeed, idx, 0);
// Drain pulse count from PIO FIFO — this resets it
uint32_t pulses = drain_pulse_count(pio_wind, sm_wind);
// Convert: pulses per interval -> rotations per second -> mph
float rps = (float)pulses / ((float)interval_ms / 1000.0f);
float mph = rps * WIND_MPH_PER_RPS;
registry.set("wind_speed", mph);
return 0;
}
Rain Gauge — reads pulse count, accumulates total, tracks hourly rate:
int readRainGauge(struct _task_entry_type* task, int idx, int) {
uint32_t interval_ms = registry.getItem(idx)->update_interval_ms;
AddTaskMilli(task, interval_ms, &readRainGauge, idx, 0);
// Drain pulse count — resets the counter
uint32_t tips = drain_pulse_count(pio_rain, sm_rain);
if (tips > 0) {
rain_total_inches += tips * RAIN_INCHES_PER_TIP;
}
// Push both total and rate into registry
// Rate is extrapolated to inches/hr based on actual sampling interval
float inches_this_interval = (float)tips * RAIN_INCHES_PER_TIP;
float hours_per_interval = (float)interval_ms / 3600000.0f;
registry.set("rainfall", rain_total_inches);
registry.set("rain_rate", inches_this_interval / hours_per_interval);
return 0;
}
Wind Direction — ADC lookup table, 8 or 16 position vane:
// Resistor ladder produces different voltages for each compass direction
// These are for a standard 8-position vane — calibrate to your actual hardware
// Values are ADC raw (0-4095 on RP2040 12-bit ADC), mapped to degrees
struct WindDirEntry { uint16_t adc_min; uint16_t adc_max; uint16_t degrees; };
static const WindDirEntry wind_dir_table[] = {
{ 0, 250, 0 }, // N
{ 251, 500, 45 }, // NE
{ 501, 850, 90 }, // E
{ 851, 1200, 135 }, // SE
{1201, 1700, 180 }, // S
{1701, 2200, 225 }, // SW
{2201, 2800, 270 }, // W
{2801, 4095, 315 }, // NW
};
int readWindVane(struct _task_entry_type* task, int idx, int) {
AddTaskMilli(task, registry.getItem(idx)->update_interval_ms, &readWindVane, idx, 0);
uint16_t raw = analogRead(PIN_WIND_DIR);
uint16_t degrees = 0;
for (int i = 0; i < 8; i++) {
if (raw >= wind_dir_table[i].adc_min && raw <= wind_dir_table[i].adc_max) {
degrees = wind_dir_table[i].degrees;
break;
}
}
registry.set("wind_direction", (float)degrees);
return 0;
}
STEP 5 — Registry Entries to add to app_register_items()
SENSOR_AUTO("wind_speed", "Wind Speed", 1000, "mph", readWindSpeed);
SENSOR_AUTO("wind_direction", "Wind Direction", 2000, "°", readWindVane);
SENSOR_AUTO("rain_rate", "Rain Rate", 60000, "in/hr", readRainGauge);
SENSOR_MANUAL("rainfall", "Total Rainfall", "in"); // updated by readRainGauge
And a reset button for the rain total:
CONTROL_BUTTON("rain_reset", "Reset Rain Total");
Then handle that button in controlLogicUpdate:
int controlLogicUpdate(struct _task_entry_type* task, int, int) {
AddTaskMilli(task, 1000, &controlLogicUpdate, 0, 0);
if (registry.get("rain_reset") > 0) {
rain_total_inches = 0.0f;
registry.set("rain_reset", 0.0f);
registry.set("rainfall", 0.0f);
}
return 0;
}
The pulse_counter.pio file sits in your sketch folder. Arduino IDE picks it up automatically and the Philhower core runs pioasm on it at build time and generates the pulse_counter_program struct you reference in the C++ code. The only thing you'll need to calibrate to your specific hardware is the ADC lookup table for wind direction and the WIND_MPH_PER_RPS constant — both are on the datasheet of whatever anemometer you buy.
No comments:
Post a Comment