GZM Embedded Systems
Back to blog
// article Firmware bench

Creating a full IoT application with Raspberry Pi Pico W + Zephyr RTOS

· Jorge Guzman
ZephyrRTOSRaspberry Pi Pico WHTTP ServerIoT

The demo running: web interface, relays, RGB LED, RTTTL ringtones and file transfer over Wi-Fi

Introduction

Unlike many other RTOS, Zephyr has grown into much more than a real-time operating system. Today it offers a complete framework, made up of a broad set of libraries and APIs that make its ecosystem rich and flexible.

A good example is the file system layer, which lets you switch between implementations such as LittleFS and FATFS without major changes to the application. In the same way, Zephyr ships a wide variety of drivers, protocols and libraries that simplify embedded development.

In this article we explore Zephyr’s IoT features using the Raspberry Pi Pico W (RP2040 with an Infineon CYW43439 Wi-Fi chip) together with the Waveshare Pico-Relay-B expansion board.

Beyond the usual connectivity features, this demo covers a topic that is still rarely explored in Zephyr articles: building embedded web pages and HTTP APIs to control the application remotely.

Hardware

The hardware for this demo is deliberately cheap and easy to reproduce:

  • Board: Raspberry Pi Pico W, RP2040 dual-core Cortex-M0+ @ 133 MHz, 264 KB of SRAM, 2 MB of QSPI flash and Infineon CYW43439 Wi-Fi (airoc driver)
  • Expansion module: Waveshare Pico-Relay-B, with 8 relay channels, a WS2812 RGB LED and a passive buzzer
  • Zephyr RTOS: v4.4.0 with Zephyr SDK v1.0.1
  • PC: Ubuntu 22.04 / 24.04

The pin map ended up like this:

PinFunctionPeripheral / Driver
GP6Passive buzzerPWM (slice 3, channel A)
GP13WS2812 RGB LEDPIO1 + led_strip driver
GP14 – GP21Relays CH8 – CH1GPIO, aliases relay1..relay8
-CYW43439 Wi-Fiairoc driver (takes over PIO0)
USBConsole and shellCDC ACM

Firmware

The application uses an out-of-tree Device Tree (DTS), which means we keep a hardware description that is independent from the official Zephyr repository. This approach reduces the impact of future project updates, preventing changes in the default board configuration from breaking the application build.

As an example, a change in the clock tree or in the peripheral configuration could alter the PWM instance used by the RTTTL playback module, breaking the application. By keeping the hardware configuration separate, we preserve compatibility across different Zephyr versions.

The demo application implements the following features:

  • Shell: interactive terminal reachable over the serial port or via Telnet once the board joins the network.
  • Wi-Fi: scanning and connecting to the wireless network.
  • Web server: an interface built with HTML, CSS and JavaScript to control the application remotely.
  • HTTP APIs: endpoints for integration and control of the board’s resources.
  • Relay control: driving the eight channels of the Pico-Relay-B board from the web interface.
  • RGB LED control (WS2812): changing colors directly from the web page.
  • RTTTL melody playback: playing ringtones on a buzzer using the RTTTL format.
  • File management: upload, download, listing and removal of files through the web interface.
  • RTC: system time base, kept in UTC and synchronized over SNTP after the network comes up.
  • Watchdog: hardware monitoring that reboots the board if the firmware stops responding.

Application structure

A practice I follow in every project: main() should do nothing beyond calling setup_init(), which holds the entire initialization chain of the application’s libraries and threads.

int main(void)
{
	setup_init();

	while (1) {
		k_msleep(MAIN_LOOP_PERIOD_MS);   /* 300 ms */
		setup_wdt_feed();
	}

	return 0;
}

All initialization lives in setup.c, split into four explicit phases, with a central fatal-error handler:

setup_init()
  ├── [1/4] sanity      -> cause of the last reset (hwinfo)
  ├── [2/4] middleware  -> initializes watchdog, RTC, LittleFS, ringtone
  ├── [3/4] database    -> reserved for configuration parameters
  └── [4/4] tasks       -> starts the application threads

The sanity phase is small, but it pays off in the field. Right at boot the firmware reads hwinfo_get_reset_cause() and classifies the reason for the last reset: a RESET_PIN or RESET_POR is logged as LOG_INF, while RESET_WATCHDOG, RESET_BROWNOUT or RESET_CPU_LOCKUP come out as LOG_ERR. When a unit comes back from the customer with the generic complaint “it reboots by itself”, that single log line answers almost everything. After reporting, the firmware clears the flags so that the next boot only reports its own cause.

The watchdog is a hardware one, configured with a 5-second timeout. Since this is a simple demo, it is fed by the idle loop in main() every 300 ms.

The subsystems live as independent libraries under lib/ (wifi_mgr, rtc_mgr, fs_mgr and ringtone), enabled through Kconfig and reusable in other projects. It is the same philosophy Zephyr itself follows, applied to the application code.

Wi-Fi

The Wi-Fi connection is the subsystem that causes the most trouble in a real product, because the customer’s network drops, the router reboots and the DHCP lease expires. That is why network bring-up lives in a dedicated thread, modeled as a state machine:

OFFLINE → CONNECTING → RUNNING
   ↑                      │
   └──────────────────────┘
    (disconnect event)

The wifi_mgr library produces events with a payload (IP, gateway, netmask) into a k_msgq, and the thread consumes them in the RUNNING state with a 3-second timeout. If bring-up fails at any step (association, DHCP or web server), the state machine waits 10 seconds and goes back to OFFLINE, retrying indefinitely. When entering CONNECTING, the event queue is drained so the new session is not woken up by data from an old one.

The full bring-up sequence is: connect to Wi-Fi, wait for the DHCP address, synchronize the RTC over SNTP against time.google.com and, finally, start the web server. One note about the clock: the RTC is kept always in UTC, and the time zone (-3, BRT) is applied only at query time, in rtc_mgr_get_local(). Storing local time in the RTC is one of those decisions that only sends you the bill months later, at daylight-saving time or on the first log export.

web_app_start() is called exactly once. Zephyr’s HTTP server survives Wi-Fi drops and reconnections, since the sockets are recovered by the stack itself.

With mDNS enabled (CONFIG_MDNS_RESPONDER and CONFIG_NET_HOSTNAME="embarcados"), the board answers to embarcados.local, which saves you from having to discover the DHCP-assigned IP on every boot.

RTTTL

RTTTL (Ring Tone Text Transfer Language) is a text format created by Nokia to describe monophonic ringtones. Each string follows the pattern <name>:<control>:<notes>, where the control section carries the defaults (d= duration, o= octave, b= BPM) and the notes come separated by commas:

Mario:d=4,o=5,b=100:16e6,16e6,32p,8e6,16c6,8e6,8g6

Playback is handled by the lib/ringtone/ library, which converts each note into a frequency and drives the passive buzzer on GP6 via PWM. The library knows nothing about the hardware: it is the application that resolves the pwm_dt_spec from the Device Tree alias, which keeps the library portable to other boards.

static const struct pwm_dt_spec buzzer_pwm = PWM_DT_SPEC_GET(DT_ALIAS(pwm_buzzer0));

ringtone_init(&buzzer_pwm);

This is exactly the case mentioned at the start of the section: the pwm-buzzer0 alias is declared in our out-of-tree overlay, pointing to the PWM_3A_P6 channel. If a Zephyr update renumbers the RP2040 PWM channels, we fix a single spot in the overlay and nothing else in the code.

The ringtone can be triggered in three different ways, which is great for debugging without the web interface:

uart:~$ ringtone test                        # built-in Mario theme
uart:~$ ringtone play "Beep:d=4,o=5,b=100:c,e,g"
uart:~$ ringtone stop
$ curl -X POST http://embarcados.local/api/buzzer \
    -H "Content-Type: application/json" \
    -d '{"action":"play","rtttl":"Beep:d=4,o=5,b=100:c,e,g"}'
ringtone_play_custom("Beep:d=4,o=5,b=100:c,e,g");
ringtone_play_notification(RINGTONE_ALARM1);
ringtone_stop();

The web page

Here is the heart of the demo. The firmware brings up an HTTP server on port 80, enabled by nothing more than CONFIG_HTTP_SERVER=y, and the control interface is served by it, straight from flash.

From the user’s point of view the flow is that of any website: when you open http://embarcados.local/, the browser issues GET /, GET /style.css and GET /app.js, and the already-loaded app.js starts polling the endpoints with fetch('/api/relays'), fetch('/api/time') and so on. The difference is where those three files live.

How the HTML ends up inside the firmware

Anyone who has embedded a web page into a firmware before, most likely in the Arduino world, knows the two traditional approaches to this problem.

The first is converting the page into a byte array with the xxd utility:

$ xxd -i index.html > index.h
unsigned char index_html[] = { 0x3c, 0x21, 0x44, 0x4f, 0x43, /* ... */ };
unsigned int  index_html_len = 6546;

Note that xxd generates the array without the const qualifier. Straight out of the tool, the array is placed in the initialized data section and copied to RAM during boot, eating up a few kilobytes that a microcontroller rarely has to spare. You have to edit the generated header and declare the array as const so that it stays in flash:

const unsigned char index_html[] = { 0x3c, 0x21, 0x44, 0x4f, 0x43, /* ... */ };

Since xxd converts one file at a time, the most common path is to inline the CSS and the JavaScript inside index.html itself, in <style> and <script> blocks, so that a single array holds the whole interface. If you keep the files separate, you need to generate one header per file and register one route per file, since the browser will issue independent requests for style.css and app.js.

The second approach is declaring the page as a constant string, marking with %d or %s the fields that need updating and assembling the response with snprintf() on every request:

const char index_html[] =
	"<html><body>"
	"<p>Relay 1: <b>%s</b></p>"
	"<p>Uptime: <b>%d</b> s</p>"
	"</body></html>";

snprintf(buf, sizeof(buf), index_html, relay_state[0] ? "ON" : "OFF", uptime);

Both work, but they charge a price. With the first one, xxd has to be run by hand on every change to the page, and it is easy to forget that step and spend the afternoon debugging a firmware that still serves the previous version of the HTML. With the second one, the page stops being a file: you lose syntax highlighting and the ability to open it in the browser during development, the layout gets mixed into the application logic, and the response is rebuilt in RAM on every request, not to mention that a page like that cannot be compressed.

In Zephyr, this job is done by the build system itself. The files under app/www/ are not written to the file system: they become code. At build time, CMake calls Zephyr’s generate_inc_file_for_target() function, which runs the scripts/build/file2hex.py script with the --gzip option, compressing the file and emitting the bytes in hexadecimal:

foreach(web_resource index.html app.js style.css)
    generate_inc_file_for_target(
        app
        www/${web_resource}
        ${gen_dir}/${web_resource}.gz.inc
        --gzip
    )
endforeach()

web_app.c simply includes the result inside an array initializer, which the compiler places in flash:

static const uint8_t index_html_gz[] = {
#include "index.html.gz.inc"
};

The resource declares .content_encoding = "gzip" and the browser decompresses it on its own. The gain is considerable and costs nothing at runtime, since the compression happens on the development machine:

FileOriginalCompressed
index.html6,546 B2,220 B
style.css7,894 B1,765 B
app.js14,591 B4,007 B
Total28.4 KB7.8 KB

In other words, the entire web interface takes less than 8 KB of flash. From a maintenance standpoint, the win is that the workflow stays the one of an ordinary website: you edit the HTML, the CSS and the JavaScript with your usual tools, and compression happens automatically at build time. Because the files become part of the binary, firmware and interface ship as a single image, with no extra step to write them into the file system and no risk of the page drifting out of sync with the firmware version.

The APIs

The web interface is not the only way to interact with the firmware. The same HTTP server also serves clients that hit the API endpoints directly, without loading the page. That includes tools such as curl, Postman collections, mobile apps or even another device on the network polling the state of the relays.

From the firmware’s point of view, there is no difference between those forms of access. The interface’s JavaScript sends exactly the same HTTP requests an external client would. For instance, when the user clicks a button on the page, the browser sends a POST to /api/relays, exactly as a command run through curl would. In other words, the page served by the Pico W is just one more API client, with no special treatment.

This is one of the main architectural decisions of the project. Because all the control logic is concentrated in the REST API, the interface can be modified, replaced by a mobile app or integrated into a SCADA system without requiring changes to the code that drives the relays. That separation makes the firmware more reusable, eases integration with other systems and simplifies the evolution of the application.

Every route, both the ones serving the page and the ones answering a curl, is registered through the same HTTP_RESOURCE_DEFINE macro. What changes between them is the resource type, and that choice is what determines the cost of each request:

/* STATIC: everything decided at compile time */
static struct http_resource_detail_static info_detail = {
	.common = {
		.type = HTTP_RESOURCE_TYPE_STATIC,
		.bitmask_of_supported_http_methods = BIT(HTTP_GET),
		.content_type = "application/json",
	},
	.static_data     = info_json,
	.static_data_len = sizeof(info_json) - 1,
};

/* DYNAMIC: callback invoked on every request */
static struct http_resource_detail_dynamic relay_detail = {
	.common = {
		.type = HTTP_RESOURCE_TYPE_DYNAMIC,
		.bitmask_of_supported_http_methods = BIT(HTTP_GET) | BIT(HTTP_POST),
	},
	.cb = web_app_relay_handler,
};

/* Both are registered by the same macro */
HTTP_RESOURCE_DEFINE(info_resource,  web_service, "/api/info",   &info_detail);
HTTP_RESOURCE_DEFINE(relay_resource, web_service, "/api/relays", &relay_detail);

In a STATIC resource, the detail points to a byte array in flash and the kernel simply copies those bytes into the TCP socket, with no custom CPU work.

In a DYNAMIC one, the detail points to a function called on every request, and it is that function that decides what to do: parse JSON, take the semaphore, call the driver and build the response.

The rule I use is simple: if the response depends on something that can change between boots or between requests, it is dynamic; otherwise, it is static. /api/info (firmware version, author, contact) is static because it is constant. /api/time is dynamic because the RTC ticks forward every second. Dynamic costs CPU and buffer RAM; static is essentially free.

Our demo application registers twelve routes, four static and eight dynamic:

EndpointMethodDescription
/, /style.css, /app.jsGETStatic resources (gzip, straight from flash)
/api/infoGETFirmware version, author and contact
/api/timeGETCurrent time, already converted to the local zone
/api/relaysGET/POSTState of the 8 relays / individual switching
/api/rgbGET/POSTCurrent color / RGB set of the WS2812
/api/buzzerGET/POSTStatus / play and stop of an RTTTL ringtone
/api/filesGETDirectory listing and LittleFS statistics
/api/uploadPOSTMultipart file upload
/api/deletePOSTFile removal
/download/*GETChunked file download

Switching a relay is a POST with two fields:

$ curl -X POST http://embarcados.local/api/relays \
    -H "Content-Type: application/json" \
    -d '{"ch":3,"state":true}'

{"relays":[false,false,true,false,false,false,false,false]}

On the firmware side, the handler parses it with Zephyr’s own JSON library (CONFIG_JSON_LIBRARY), protects the access with a semaphore and calls the GPIO driver:

ret = json_obj_parse((char *)request_ctx->data, request_ctx->data_len,
		     relay_cmd_descr, ARRAY_SIZE(relay_cmd_descr), &cmd);

k_sem_take(&relay_lock, K_FOREVER);
if (ret > 0 && cmd.ch >= 1 && cmd.ch <= NUM_RELAYS) {
	idx = cmd.ch - 1;
	relay_state[idx] = cmd.state;
	ret = gpio_pin_set_dt(&relays[idx], relay_state[idx] ? 1 : 0);
}
len = web_app_build_relays_json(resp_buf, sizeof(resp_buf));
k_sem_give(&relay_lock);
SET_JSON_RESPONSE(response_ctx, resp_buf, len);

Two details apply to any dynamic handler. The first is that the response always returns the complete state, not a plain “ok”: that way a client that missed an update resynchronizes on its own. The second is that the handler is called several times over the same transaction (with the headers, with the body and on completion or abort), so you must look at status and at request_ctx->data_len before acting. Ignoring that is the most common source of strange behavior with CONFIG_HTTP_SERVER.

The RGB LED follows the same pattern, with the POST carrying the color and the handler calling the WS2812 led_strip driver.

File management

As mentioned in the introduction, Zephyr’s file system layer is independent of the implementation. Here we use LittleFS, mounted at /lfs1 over a 64 KB partition.

It is worth clarifying where those 64 KB actually live. The RP2040 has no internal flash: the Pico W carries a 2 MB external QSPI memory, memory-mapped by XIP (execute in place) starting at address 0x10000000. That is the flash the overlay partitions, reserving the beginning for the second-stage bootloader, the middle for the application and the end for the file system:

PartitionOffsetStart addressEnd addressSizeUsage
second_stage_bootloader0x0000000x100000000x100000FF256 BRP2040 boot2 (read-only)
code-partition0x0001000x100001000x101EFFFF1,983.75 KBApplication firmware
storage0x1F00000x101F00000x101FFFFF64 KBLittleFS mounted at /lfs1

That is why the build report shows 2031360 B as the size of the FLASH region: the value matches the code-partition exactly, not the 2 MB of the component. The space reserved for the file system is out of the linker’s reach, which prevents firmware growth from invading the data area.

Resizing the file system is a matter of adjusting two lines in the overlay, keeping in mind that LittleFS works in 4 KB sectors and that the total must be a multiple of that value. With the file system mounted, /api/files returns the directory listing along with the usage statistics:

$ curl http://embarcados.local/api/files

{
  "dir":"/lfs1",
  "total_bytes":65536,
  "used_bytes":21888,
  "used_pct":33,
  "files":[
    {"name":"guzman.jpeg","size":21886,"type":"file"}
  ]
}

Download is the most interesting part, because it is where it becomes obvious that you cannot think like you would on a PC server. There is no RAM to load an entire file before sending it. The handler keeps the file open across calls and returns 1 KB blocks, signaling the end through final_chunk:

bytes = fs_read(&file, chunk, sizeof(chunk));   /* DOWNLOAD_CHUNK_SIZE = 1024 */
if (bytes <= 0) {
	fs_close(&file);
	response_ctx->final_chunk = true;
	return 0;
}

response_ctx->body        = chunk;
response_ctx->body_len    = bytes;
response_ctx->final_chunk = (bytes < (ssize_t)sizeof(chunk));

Because that state is static and shared, the handler records which client owns the transfer (download_owner) and uses a semaphore to serialize downloads. And, above all, it handles HTTP_SERVER_TRANSACTION_ABORTED: if the user closes the tab in the middle of a download, the file is closed and the semaphore is released. Without that, the second download hangs forever, and that is exactly the kind of bug that only shows up at the customer’s site.

Shell over serial and Telnet

Zephyr’s shell is enabled with a handful of Kconfig lines and serves both the USB CDC console and port 23 at the same time:

CONFIG_SHELL=y
CONFIG_SHELL_BACKEND_TELNET=y
CONFIG_SHELL_TELNET_PORT=23

With CONFIG_FILE_SYSTEM_SHELL, CONFIG_RTC_SHELL and CONFIG_NET_SHELL enabled, we get file system, clock and network diagnostic commands for free, on top of the application’s own commands:

$ telnet embarcados.local
uart:~$ fs ls /lfs1
uart:~$ net iface
uart:~$ ringtone play "Beep:d=4,o=5,b=100:c,e,g"

Build, flashing and debugging

Before the first build you need to fetch the binary blobs required by the board. The Pico W’s Wi-Fi depends on the proprietary CYW43439 firmware distributed by Infineon, and the RP2040 uses HAL libraries from Raspberry Pi. For licensing reasons, those files are not part of the Zephyr tree and are fetched on demand by west:

$ west blobs fetch hal_infineon
$ west blobs fetch hal_rpi_pico

The blobs are stored inside their respective modules and only need to be downloaded again when the workspace is recreated or when a west update brings a different version of the modules. It is worth double-checking this step when moving the project to another machine: without the blobs, the build fails at the link stage of the airoc driver.

That done, the build follows the usual Zephyr pattern, pointing the board root at the custom board and the lib/ directory as extra modules:

$ west build -b dev_rpi_pico/rp2040/w -p -s app -d app/build \
    -- -DBOARD_ROOT=${PWD} \
       -DZEPHYR_EXTRA_MODULES=${PWD}/lib \
       -DDTC_OVERLAY_FILE=${PWD}/app/boards/dev_rpi_pico_rp2040_w.overlay

Memory region         Used Size  Region Size  %age Used
      BOOT_FLASH:         256 B        256 B    100.00%
           FLASH:      733608 B    2031360 B     36.11%
             RAM:      178368 B       264 KB     65.98%

HTTP server, Wi-Fi, TCP/IP, LittleFS, shell, logging, JSON and the entire web interface in 716 KB of flash. To flash it, west flash uses picotool with the board in BOOTSEL mode, or you can simply copy zephyr.uf2 onto the RPI-RP2 volume.

VSCode

The whole flow is automated in VSCode, in .vscode/tasks.json, reachable through Ctrl+Shift+B. For this demo we created task runners to automate the entire build, flash and debug process. These are the tasks:

TaskWhat it does
app: buildOptimized build, prj.conf only
app: build (debug.conf)Build with extra debug symbols
app: flashwest flash
app: rom_reportFlash usage per symbol and module
app: ram_reportRAM usage per symbol and module
app: clearCleans the build directory

The rom_report and ram_report targets deserve a special mention: they show exactly who is consuming memory, by symbol and by module. When the build starts to get tight, that is where you find out that 20 KB went away in a subsystem nobody remembered enabling in prj.conf.

Breakpoint debugging is also ready in launch.json, with two Cortex-Debug profiles: J-Link, for those using the SEGGER probe, and Pico-Probe, which uses OpenOCD with the Raspberry Pi Debug Probe. Keep in mind that an ordinary Pico can become a CMSIS-DAP probe by flashing debugprobe_on_pico.uf2 onto it, meaning you can debug one Pico with another Pico, without buying anything.

Conclusion

With this demo application it was possible to build a complete IoT solution, showing in practice how to build an embedded web interface, expose services through REST APIs, reach the shell remotely over Telnet, transfer files between the computer and the device, and control resources such as an RGB LED and ringtone playback.

Beyond serving as a demonstration, the infrastructure built here can easily be reused in real applications for features such as over-the-air firmware update (OTA), diagnostic log collection, file management and remote device configuration.

This project also demonstrates the potential of Zephyr’s library ecosystem. Most of the components used (the network stack, the HTTP server, the file system, the shell and the remaining services) can be reused on other platforms supported by the RTOS. The same application, for instance, could be ported with few changes to an ESP32 with Wi-Fi connectivity, taking advantage of virtually the whole architecture developed here.

A few important topics were left out of this demo, such as using HTTPS with TLS certificates for secure communication and integrating MQTT to connect the board to Home Assistant, enabling home automation scenarios. Those will be explored in a future article.

Source code

The whole project, including the custom board, the libraries under lib/, the documentation for each endpoint and the VSCode configuration, is available at:

References