DEV Community

Cover image for Basement Water Monitor Circuit in Go, Rust and C
Andrei Merlescu
Andrei Merlescu

Posted on

Basement Water Monitor Circuit in Go, Rust and C

Together in this document we're going to build out through concept a circuit that can be used for constructing a basement water monitoring device - from scratch, built in Go, Rust and C so that you can see the differences in how these languages express themselves for the same task. Some of them are easier than others, and some of them are clear long term historical winners for a reason given any degree of complexity that may arise related to the construction of any new circuitry itself.

Structural Comparison between Rust and C

Concern          Rust / Embassy              C / no RTOS
─────────────────────────────────────────────────────────────
Concurrency      6 async tasks, Embassy      1 loop, manual
                 schedules cooperatively     timestamp polling

Data safety      Mutex<SensorState>,         volatile struct,
                 compiler enforces locking   convention only

DHT22 timing     PIO hardware offload,       Busy-wait loops,
                 CPU free during read        CPU blocked 5ms

Memory safety    heapless::String<N>,        char[N], silent
                 overflow = compile error    overflow = UB

Connection mgmt  TcpSocket, linear code,     lwIP callbacks,
                 reads top to bottom         scattered state machine

Cleanup          Drop trait, automatic       free() manual,
                 cannot be forgotten         easy to forget

WiFi connect     .await, yields executor     blocks CPU entirely
                 during 30s wait             for up to 30s

Enter fullscreen mode Exit fullscreen mode

This is what we are going to build:

Internet
    |
 [Router]
    |
[Pico W] <-- WiFi
    |
    +---> [Water Sensor] basement floor
    +---> [DHT22]        humidity/temp
    +---> [PIR]          motion
    +---> [MQ-7]         CO detector
    +---> [Relay] -----> [Sump Pump]
    +---> [Buzzer]       local alarm
    +---> [OLED]         local display
    |
[ESP32-CAM] <-- basement camera, streams to Pico W HTTP server
    |
 Browser: http://pico.local/
   - sensor readings
   - camera feed
   - alert history
Enter fullscreen mode Exit fullscreen mode

Rust Directory Structure

basement-monitor/
├── Cargo.toml
├── build.rs
├── memory.x          ← tells linker where flash/RAM live on RP2040
└── src/
    ├── main.rs       ← entry point, spawns all tasks
    ├── sensors.rs    ← DHT22, water, PIR, MQ7
    ├── display.rs    ← SSD1306 OLED
    ├── alarm.rs      ← buzzer + relay logic
    ├── wifi.rs       ← Pico W network setup
    └── http.rs       ← HTTP server, serves sensor JSON + camera
Enter fullscreen mode Exit fullscreen mode

C Directory Structure

basement-monitor-c/
├── CMakeLists.txt
├── pico_sdk_import.cmake
├── src/
│   ├── main.c
│   ├── sensors.c / sensors.h
│   ├── display.c / display.h
│   ├── alarm.c   / alarm.h
│   ├── wifi.c    / wifi.h
│   └── http.c    / http.h
└── lib/
    └── ssd1306/  ← third party OLED driver
Enter fullscreen mode Exit fullscreen mode

Go Directory Structure

basement-monitor-tinygo/
├── go.mod
├── main.go
├── sensors/
│   ├── dht22.go
│   ├── water.go
│   ├── pir.go
│   └── co.go
├── display/
│   └── oled.go
├── alarm/
│   └── alarm.go
├── wifi/
│   └── wifi.go
└── http/
    └── server.go

Enter fullscreen mode Exit fullscreen mode

Trinity Comparison of Go <> Rust <> C Project Directory Structures

We have sensors.rs and sensors.c which are both single files, but we have a package called sensors that places the implementation of each dht22, water, pir, and co sensors individually. How these are split out into their respective concerns matters because go packages are reusable resources across Go programs and technically speaking, why would this type of structure matter to an on-device program? The code self explains it and we'll get to that.

Same thing for display, the alarm, the wifi chip, and the http server that provides a status page. This kind of a breakdown makes the comparison of the implementation of this across these three languages something that is pretty unique for a case study into Rust from Go from a Computer Engineer who has been a self-taught 21+ year professional Staff DevOps Engineer using Go to build multi-region global cloud architectures for SurgePays and WB Games.

Each of the implementations of the project have generalized boilerplate overhead that is language specific. Each have their own Makefile and this is where we'll begin...

Go

# basement-monitor-tinygo/Makefile

TARGET     := pico-w
BINARY     := basement-monitor
OPT        := z
PORT       ?= $(shell ls /dev/tty.usbmodem* 2>/dev/null | head -1)

# Colors for terminal output
RED    := \033[0;31m
GREEN  := \033[0;32m
YELLOW := \033[0;33m
BLUE   := \033[0;34m
RESET  := \033[0m

.PHONY: all build clean run flash monitor fmt vet size help

all: build

## build: compile and produce .uf2 file
build:
    @echo "$(BLUE)[TINYGO]$(RESET) Building $(BINARY) for $(TARGET)..."
    @tinygo build \
        -target=$(TARGET) \
        -opt=$(OPT) \
        -o $(BINARY).uf2 \
        .
    @echo "$(GREEN)[OK]$(RESET) Built $(BINARY).uf2"
    @ls -lh $(BINARY).uf2

## flash: build and flash directly to connected Pico W
flash: build
    @echo "$(BLUE)[TINYGO]$(RESET) Flashing to $(TARGET)..."
    @tinygo flash \
        -target=$(TARGET) \
        -opt=$(OPT) \
        .
    @echo "$(GREEN)[OK]$(RESET) Flashed successfully"

## run: flash and immediately open serial monitor
run: flash monitor

## monitor: open serial monitor (after flashing separately)
monitor:
    @echo "$(BLUE)[TINYGO]$(RESET) Opening serial monitor on $(PORT)..."
    @if [ -z "$(PORT)" ]; then \
        echo "$(RED)[ERR]$(RESET) No USB serial port found."; \
        echo "      Is the Pico W connected and flashed?"; \
        exit 1; \
    fi
    @tinygo monitor -target=$(TARGET)

## size: show binary size breakdown by package
size:
    @echo "$(BLUE)[TINYGO]$(RESET) Binary size analysis:"
    @tinygo build \
        -target=$(TARGET) \
        -opt=$(OPT) \
        -size=full \
        .

## fmt: format all Go source files
fmt:
    @echo "$(BLUE)[TINYGO]$(RESET) Formatting source..."
    @gofmt -w .
    @echo "$(GREEN)[OK]$(RESET) Done"

## vet: run go vet (catches common errors)
vet:
    @echo "$(BLUE)[TINYGO]$(RESET) Running vet..."
    @tinygo vet -target=$(TARGET) .
    @echo "$(GREEN)[OK]$(RESET) No issues found"

## test: run tests on host (not on device)
test:
    @echo "$(BLUE)[TINYGO]$(RESET) Running host tests..."
    @go test ./...

## clean: remove build artifacts
clean:
    @echo "$(YELLOW)[CLEAN]$(RESET) Removing build artifacts..."
    @rm -f $(BINARY).uf2
    @rm -f $(BINARY).elf
    @rm -f $(BINARY).hex
    @rm -f $(BINARY).bin
    @echo "$(GREEN)[OK]$(RESET) Clean complete"

## help: show this help
help:
    @echo ""
    @echo "$(BLUE)Basement Monitor — TinyGo$(RESET)"
    @echo "Target: $(TARGET)"
    @echo ""
    @grep -E '^##' Makefile | sed 's/## /  make /'
    @echo ""

Enter fullscreen mode Exit fullscreen mode

Rust

# basement-monitor-rust/Makefile

BINARY     := basement-monitor
TARGET     := thumbv6m-none-eabi
CHIP       := RP2040
PROBE      ?= auto
PORT       ?= $(shell ls /dev/tty.usbmodem* 2>/dev/null | head -1)

RED    := \033[0;31m
GREEN  := \033[0;32m
YELLOW := \033[0;33m
BLUE   := \033[0;34m
RESET  := \033[0m

.PHONY: all build build-release clean run flash monitor \
       fmt clippy test size doc expand help

all: build

## build: debug build (faster compile, larger binary, includes debug symbols)
build:
    @echo "$(BLUE)[RUST ]$(RESET) Building $(BINARY) (debug)..."
    @cargo build --target $(TARGET)
    @echo "$(GREEN)[OK]$(RESET) Debug build complete"
    @ls -lh target/$(TARGET)/debug/$(BINARY)

## build-release: optimized build for flashing to device
build-release:
    @echo "$(BLUE)[RUST ]$(RESET) Building $(BINARY) (release)..."
    @cargo build \
        --target $(TARGET) \
        --release
    @echo "$(GREEN)[OK]$(RESET) Release build complete"
    @ls -lh target/$(TARGET)/release/$(BINARY)

## uf2: produce .uf2 drag-and-drop file from release build
uf2: build-release
    @echo "$(BLUE)[RUST ]$(RESET) Converting to UF2..."
    @elf2uf2-rs \
        target/$(TARGET)/release/$(BINARY) \
        $(BINARY).uf2
    @echo "$(GREEN)[OK]$(RESET) $(BINARY).uf2 ready"
    @ls -lh $(BINARY).uf2

## flash: build release and flash via cargo-flash (requires probe-rs)
flash: build-release
    @echo "$(BLUE)[RUST ]$(RESET) Flashing via probe-rs..."
    @cargo flash \
        --target $(TARGET) \
        --release \
        --chip $(CHIP) \
        --probe $(PROBE)
    @echo "$(GREEN)[OK]$(RESET) Flashed successfully"

## run: flash and open RTT monitor (defmt logging via probe)
run: flash monitor

## monitor: open defmt RTT log monitor (requires probe-rs + probe connected)
monitor:
    @echo "$(BLUE)[RUST ]$(RESET) Opening defmt monitor..."
    @cargo embed \
        --target $(TARGET) \
        --release \
        --chip $(CHIP)

## monitor-serial: open plain serial monitor (USB serial fallback)
monitor-serial:
    @echo "$(BLUE)[RUST ]$(RESET) Opening serial monitor on $(PORT)..."
    @if [ -z "$(PORT)" ]; then \
        echo "$(RED)[ERR]$(RESET) No USB serial port found."; \
        exit 1; \
    fi
    @screen $(PORT) 115200

## fmt: format all Rust source files
fmt:
    @echo "$(BLUE)[RUST ]$(RESET) Formatting source..."
    @cargo fmt
    @echo "$(GREEN)[OK]$(RESET) Done"

## fmt-check: check formatting without modifying (for CI)
fmt-check:
    @echo "$(BLUE)[RUST ]$(RESET) Checking formatting..."
    @cargo fmt --check
    @echo "$(GREEN)[OK]$(RESET) Formatting OK"

## clippy: run Clippy linter — catches common Rust mistakes
clippy:
    @echo "$(BLUE)[RUST ]$(RESET) Running Clippy..."
    @cargo clippy \
        --target $(TARGET) \
        -- -D warnings
    @echo "$(GREEN)[OK]$(RESET) No Clippy warnings"

## test: run unit tests on host (not on device)
# Tests that don't use hardware peripherals can run on your laptop.
# Hardware-dependent tests require a device and cargo-test-embedded.
test:
    @echo "$(BLUE)[RUST ]$(RESET) Running host tests..."
    @cargo test --lib
    @echo "$(GREEN)[OK]$(RESET) Tests passed"

## size: show binary size by section and crate
size: build-release
    @echo "$(BLUE)[RUST ]$(RESET) Binary size analysis:"
    @cargo size \
        --target $(TARGET) \
        --release \
        -- -A
    @echo ""
    @echo "$(BLUE)[RUST ]$(RESET) Size by crate:"
    @cargo bloat \
        --target $(TARGET) \
        --release \
        --crates

## doc: build and open documentation
doc:
    @echo "$(BLUE)[RUST ]$(RESET) Building docs..."
    @cargo doc --open

## expand: show macro expansion (useful for Embassy task macros)
expand:
    @echo "$(BLUE)[RUST ]$(RESET) Expanding macros in main.rs..."
    @cargo expand --target $(TARGET)

## clean: remove all build artifacts
clean:
    @echo "$(YELLOW)[CLEAN]$(RESET) Removing build artifacts..."
    @cargo clean
    @rm -f $(BINARY).uf2
    @rm -f $(BINARY).elf
    @echo "$(GREEN)[OK]$(RESET) Clean complete"

## help: show this help
help:
    @echo ""
    @echo "$(BLUE)Basement Monitor — Rust / Embassy$(RESET)"
    @echo "Target: $(TARGET)  Chip: $(CHIP)"
    @echo ""
    @grep -E '^##' Makefile | sed 's/## /  make /'
    @echo ""
    @echo "$(YELLOW)Note:$(RESET) 'make flash' and 'make monitor' require"
    @echo "      a debug probe (e.g. Raspberry Pi Debug Probe)."
    @echo "      Use 'make uf2' for drag-and-drop flashing instead."
    @echo ""

Enter fullscreen mode Exit fullscreen mode

C

# basement-monitor-c/Makefile

BINARY     := basement-monitor
BUILD_DIR  := build
PICO_SDK   ?= $(HOME)/pico/pico-sdk
PORT       ?= $(shell ls /dev/tty.usbmodem* 2>/dev/null | head -1)
JOBS       ?= $(shell nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)

RED    := \033[0;31m
GREEN  := \033[0;32m
YELLOW := \033[0;33m
BLUE   := \033[0;34m
RESET  := \033[0m

.PHONY: all build clean run flash monitor \
       configure reconfigure size format \
       check-sdk check-tools help

all: build

## build: configure (if needed) and compile
build: check-sdk check-tools configure
    @echo "$(BLUE)[C    ]$(RESET) Building $(BINARY)..."
    @cmake \
        --build $(BUILD_DIR) \
        --parallel $(JOBS)
    @echo "$(GREEN)[OK]$(RESET) Build complete"
    @ls -lh $(BUILD_DIR)/$(BINARY).uf2

## configure: run CMake to generate build system
# Only runs if build/ doesn't exist yet.
# Use 'make reconfigure' to force a fresh CMake run.
configure:
    @if [ ! -f "$(BUILD_DIR)/Makefile" ] && [ ! -f "$(BUILD_DIR)/build.ninja" ]; then \
        echo "$(BLUE)[C    ]$(RESET) Configuring with CMake..."; \
        cmake \
            -S . \
            -B $(BUILD_DIR) \
            -DPICO_SDK_PATH=$(PICO_SDK) \
            -DCMAKE_BUILD_TYPE=Release \
            -DPICO_BOARD=pico_w; \
        echo "$(GREEN)[OK]$(RESET) Configuration complete"; \
    fi

## reconfigure: force fresh CMake configuration
reconfigure:
    @echo "$(YELLOW)[CMAKE]$(RESET) Forcing reconfiguration..."
    @rm -rf $(BUILD_DIR)
    @$(MAKE) configure

## flash: build and copy .uf2 to Pico W mounted as USB drive
# Put Pico W in BOOTSEL mode: hold BOOTSEL button, plug in USB, release.
# The board mounts as a USB drive named RPI-RP2.
flash: build
    @echo "$(BLUE)[C    ]$(RESET) Looking for RPI-RP2 mount point..."
    @MOUNT=""; \
    for path in \
        /Volumes/RPI-RP2 \
        /media/$$USER/RPI-RP2 \
        /mnt/RPI-RP2; do \
        if [ -d "$$path" ]; then \
            MOUNT=$$path; \
            break; \
        fi; \
    done; \
    if [ -z "$$MOUNT" ]; then \
        echo "$(RED)[ERR]$(RESET) RPI-RP2 not found."; \
        echo "      Hold BOOTSEL, plug in USB, release BOOTSEL."; \
        exit 1; \
    fi; \
    echo "$(BLUE)[C    ]$(RESET) Copying .uf2 to $$MOUNT ..."; \
    cp $(BUILD_DIR)/$(BINARY).uf2 $$MOUNT/; \
    echo "$(GREEN)[OK]$(RESET) Flashed — board will reboot automatically"

## flash-openocd: flash via OpenOCD + SWD debug probe
flash-openocd: build
    @echo "$(BLUE)[C    ]$(RESET) Flashing via OpenOCD..."
    @openocd \
        -f interface/cmsis-dap.cfg \
        -f target/rp2040.cfg \
        -c "adapter speed 5000" \
        -c "program $(BUILD_DIR)/$(BINARY).elf verify reset exit"
    @echo "$(GREEN)[OK]$(RESET) Flashed via OpenOCD"

## run: flash (BOOTSEL method) and open serial monitor
run: flash monitor

## monitor: open serial monitor to see printf output
monitor:
    @echo "$(BLUE)[C    ]$(RESET) Opening serial monitor on $(PORT)..."
    @if [ -z "$(PORT)" ]; then \
        echo "$(RED)[ERR]$(RESET) No USB serial port found."; \
        echo "      Is the Pico W connected and running?"; \
        exit 1; \
    fi
    @screen $(PORT) 115200

## size: show binary size by section
size: build
    @echo "$(BLUE)[C    ]$(RESET) Binary size breakdown:"
    @arm-none-eabi-size \
        --format=Berkeley \
        $(BUILD_DIR)/$(BINARY).elf
    @echo ""
    @echo "$(BLUE)[C    ]$(RESET) Section detail:"
    @arm-none-eabi-size \
        --format=SysV \
        $(BUILD_DIR)/$(BINARY).elf

## format: format all C source files with clang-format
format:
    @echo "$(BLUE)[C    ]$(RESET) Formatting source..."
    @find src/ -name '*.c' -o -name '*.h' | \
        xargs clang-format \
            --style='{BasedOnStyle: LLVM, IndentWidth: 4}' \
            -i
    @echo "$(GREEN)[OK]$(RESET) Done"

## format-check: check formatting without modifying (for CI)
format-check:
    @echo "$(BLUE)[C    ]$(RESET) Checking formatting..."
    @find src/ -name '*.c' -o -name '*.h' | \
        xargs clang-format \
            --style='{BasedOnStyle: LLVM, IndentWidth: 4}' \
            --dry-run \
            --Werror
    @echo "$(GREEN)[OK]$(RESET) Formatting OK"

## clean: remove build directory
clean:
    @echo "$(YELLOW)[CLEAN]$(RESET) Removing $(BUILD_DIR)/..."
    @rm -rf $(BUILD_DIR)
    @echo "$(GREEN)[OK]$(RESET) Clean complete"

## check-sdk: verify PICO_SDK_PATH is set and valid
check-sdk:
    @if [ ! -d "$(PICO_SDK)" ]; then \
        echo "$(RED)[ERR]$(RESET) Pico SDK not found at: $(PICO_SDK)"; \
        echo ""; \
        echo "  Install it:"; \
        echo "    git clone https://github.com/raspberrypi/pico-sdk \\"; \
        echo "      --recurse-submodules $(PICO_SDK)"; \
        echo ""; \
        echo "  Or set the path:"; \
        echo "    make build PICO_SDK=/path/to/pico-sdk"; \
        echo ""; \
        exit 1; \
    fi

## check-tools: verify required tools are installed
check-tools:
    @missing=""; \
    for tool in cmake arm-none-eabi-gcc arm-none-eabi-size; do \
        if ! command -v $$tool >/dev/null 2>&1; then \
            missing="$$missing $$tool"; \
        fi; \
    done; \
    if [ -n "$$missing" ]; then \
        echo "$(RED)[ERR]$(RESET) Missing tools:$$missing"; \
        echo ""; \
        echo "  macOS:  brew install cmake arm-none-eabi-gcc"; \
        echo "  Ubuntu: sudo apt install cmake gcc-arm-none-eabi"; \
        echo ""; \
        exit 1; \
    fi

## help: show this help
help:
    @echo ""
    @echo "$(BLUE)Basement Monitor — C / Pico SDK$(RESET)"
    @echo "Pico SDK: $(PICO_SDK)"
    @echo ""
    @grep -E '^##' Makefile | sed 's/## /  make /'
    @echo ""
    @echo "$(YELLOW)Overrides:$(RESET)"
    @echo "  make build PICO_SDK=/custom/path/pico-sdk"
    @echo "  make build JOBS=8"
    @echo "  make monitor PORT=/dev/ttyACM0"
    @echo ""

Enter fullscreen mode Exit fullscreen mode

You'll probably end up having a complete workspace something along the lines of this:

bunker-monitor-c/Makefile
bunker-monitor-tinygo/Makefile
bunker-monitor-rust/Makefile
Enter fullscreen mode Exit fullscreen mode

This could then be connected into a single Makefile that looks like:

# basement-monitor/Makefile  (root — calls into each subdirectory)

RED    := \033[0;31m
GREEN  := \033[0;32m
YELLOW := \033[0;33m
BLUE   := \033[0;34m
CYAN   := \033[0;36m
RESET  := \033[0m

DIRS := basement-monitor-c \
        basement-monitor-tinygo \
        basement-monitor-rust

.PHONY: all build clean run size fmt help \
       build-c build-tinygo build-rust \
       clean-c clean-tinygo clean-rust \
       size-c size-tinygo size-rust \
       fmt-c fmt-tinygo fmt-rust

all: build

## build: build all three implementations
build: build-c build-tinygo build-rust

build-c:
    @echo "$(CYAN)━━━ C / Pico SDK ━━━━━━━━━━━━━━━━━━━━━━━━━━$(RESET)"
    @$(MAKE) -C basement-monitor-c build
    @echo ""

build-tinygo:
    @echo "$(CYAN)━━━ TinyGo ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━$(RESET)"
    @$(MAKE) -C basement-monitor-tinygo build
    @echo ""

build-rust:
    @echo "$(CYAN)━━━ Rust / Embassy ━━━━━━━━━━━━━━━━━━━━━━━$(RESET)"
    @$(MAKE) -C basement-monitor-rust build-release
    @echo ""

## clean: clean all three implementations
clean: clean-c clean-tinygo clean-rust

clean-c:
    @$(MAKE) -C basement-monitor-c clean

clean-tinygo:
    @$(MAKE) -C basement-monitor-tinygo clean

clean-rust:
    @$(MAKE) -C basement-monitor-rust clean

## size: compare binary sizes across all three
size: build
    @echo ""
    @echo "$(CYAN)━━━ Binary Size Comparison ━━━━━━━━━━━━━━━━$(RESET)"
    @echo ""
    @printf "  %-12s %s\n" "Language" "Size"
    @printf "  %-12s %s\n" "────────" "────"
    @C_SIZE=$$(ls -la basement-monitor-c/build/basement-monitor.uf2 \
        2>/dev/null | awk '{print $$5}'); \
    GO_SIZE=$$(ls -la basement-monitor-tinygo/basement-monitor.uf2 \
        2>/dev/null | awk '{print $$5}'); \
    RS_SIZE=$$(ls -la basement-monitor-rust/basement-monitor.uf2 \
        2>/dev/null | awk '{print $$5}'); \
    printf "  %-12s %s bytes\n" "C"       "$${C_SIZE:-n/a}"; \
    printf "  %-12s %s bytes\n" "TinyGo"  "$${GO_SIZE:-n/a}"; \
    printf "  %-12s %s bytes\n" "Rust"    "$${RS_SIZE:-n/a}"
    @echo ""

## fmt: format all three implementations
fmt: fmt-c fmt-tinygo fmt-rust

fmt-c:
    @$(MAKE) -C basement-monitor-c format

fmt-tinygo:
    @$(MAKE) -C basement-monitor-tinygo fmt

fmt-rust:
    @$(MAKE) -C basement-monitor-rust fmt

## run-c: flash C build to connected Pico W
run-c:
    @$(MAKE) -C basement-monitor-c run

## run-tinygo: flash TinyGo build to connected Pico W
run-tinygo:
    @$(MAKE) -C basement-monitor-tinygo run

## run-rust: flash Rust build to connected Pico W
run-rust:
    @$(MAKE) -C basement-monitor-rust run

## help: show this help
help:
    @echo ""
    @echo "$(CYAN)Basement Monitor — Triad Comparison$(RESET)"
    @echo "C  ·  TinyGo  ·  Rust/Embassy"
    @echo ""
    @grep -E '^##' Makefile | sed 's/## /  make /'
    @echo ""
    @echo "$(YELLOW)Per-implementation targets:$(RESET)"
    @echo "  make build-c       make build-tinygo    make build-rust"
    @echo "  make clean-c       make clean-tinygo    make clean-rust"
    @echo "  make run-c         make run-tinygo      make run-rust"
    @echo ""

Enter fullscreen mode Exit fullscreen mode

Make Reference

Command              C                TinyGo           Rust
─────────────────────────────────────────────────────────────
make build           cmake + make     tinygo build     cargo build
make clean           rm -rf build/    rm *.uf2         cargo clean
make run             cp .uf2 + screen tinygo flash +   cargo flash +
                     /dev/tty*        tinygo monitor   cargo embed
make size            arm-none-        tinygo build     cargo bloat
                     eabi-size        -size=full       --crates
make fmt             clang-format     gofmt            cargo fmt
make flash           cp to RPI-RP2    tinygo flash     cargo flash
make monitor         screen           tinygo monitor   cargo embed
make help            grep ## self     grep ## self     grep ## self

Enter fullscreen mode Exit fullscreen mode

So now that we know how to build onto our circuit, we should have the board itself assembled by now and we should be ready to begin loading the source code onto the board. At this point, is where this guide is exploring and that I am writing for you today.

C gives you the smallest binary, the most hardware control, and the most mature ecosystem — but it gives you no help when you make mistakes. Every memory safety bug, every forgotten free(), every unguarded g_state access is your problem at 2am when the basement is flooding. TinyGo gives you Go’s goroutine model and standard library idioms — the most readable code of the three — but layers a GC and a runtime between you and the hardware, and the ecosystem is still catching up on Pico W specifics. Rust gives you compile-time proof that your pins are used correctly, your buffers don’t overflow, and your state is always accessed under a lock — with zero runtime overhead and binary sizes comparable to C — but demands the most upfront investment in satisfying the compiler before you ever see an LED blink. The right choice depends on whether your priority is shipping fast (TinyGo), maximum control (C), or maximum confidence (Rust).

Hardware

Component Role Pin
Raspberry Pi Pico W Microcontroller + WiFi
DHT22 Temperature & humidity GPIO2
Capacitive water sensor Flood detection GPIO3
Relay module Cut power on flood GPIO4
PIR motion sensor Intrusion detection GPIO5
SSD1306 OLED (I2C) Local display GPIO6/7
Active buzzer Local alarm GPIO8
MQ-7 CO sensor Carbon monoxide GPIO26 (ADC)

Trinity Comparison of C → Rust → TinyGo

Dimension C TinyGo Rust/Embassy
Concurrency model Manual poll loop, no true concurrency Real goroutines, cooperative scheduler Async tasks, cooperative, interrupt-driven
DHT22 timing Busy-wait, blocks CPU entirely Busy-wait in goroutine, others run PIO hardware offload, CPU completely free
State sharing volatile + critical_section, convention only sync.Mutex, convention only Mutex<T>, compiler enforced
Memory safety (HTTP buffers) char[N], silent overflow = UB make([]byte,n) + GC heapless::String, overflow = compile error
HTTP server architecture lwIP raw callbacks, 4 callback functions net.Listen + goroutine/conn, idiomatic Go embassy-net TcpSocket, linear async code
Cleanup / resource mgmt Manual free(), easy to forget defer conn.Close(), automatic Drop trait, automatic, enforced
Pin safety (exclusive use) Convention only Convention only Compile-time ownership proof
Binary size Smallest ~50KB Medium ~200–400KB Small ~80–150KB
GC pauses None (no GC) Yes, brief on allocations None (no GC)
WiFi connect Blocks CPU (IRQs run) Blocks goroutine (others run) Fully async, yields executor
Standard library available C stdlib subset (no OS calls) Go stdlib subset (growing) core only, no_std
Driver ecosystem Mature, large C libs tinygo/x/drivers, growing fast embedded-hal crates, mature
Build system CMake + Pico SDK go mod + tinygo flash cargo + cargo-embed
Flash command OpenOCD or drag .uf2 tinygo flash -target pico-w cargo flash or cargo-embed
Readable code (subjective) Medium (callbacks) Highest (goroutines) High (async/await)
Production maturity Proven, decades of embedded C Growing, 2–3 years Pico W support Growing fast, 2–3 years Pico W support

Sensors

Here we are going to build out three implements of the sensors. We'll start with Go, and then we'll present Rust and then end of C. C will produce the smallest binary and will have the most stability - frankly speaking - but studying and researching alternative languages like Go and Rust for the purposes of building basement or bunker monitor systems, this kind of technology will give you personal security awareness in a way that not only did you build the circuits themselves for, but you also built the code for them and go them to work for yourself as well.

Go Implementation of sensors/dht22.go

package sensors

import (
    "machine"
    "time"
)

// DHT22Task reads temperature and humidity every 2 seconds.
//
// The DHT22 single-wire protocol is the hardest sensor to implement
// correctly in TinyGo because it requires microsecond timing.
//
// Comparison:
//   Rust:   PIO state machine handles timing in hardware, CPU free
//   C:      Busy-wait loops, CPU blocked for ~5ms per read
//   TinyGo: Same busy-wait approach as C, but wrapped in a goroutine
//           so other goroutines can run between reads.
//           During the 5ms read itself, this goroutine holds the CPU
//           (no yield point inside the bit-bang loop).
//
// The tinygo.org/x/drivers package has a dht driver but implementing
// it manually here shows the protocol clearly for the write-up.

func DHT22Task(pin machine.Pin, update func(func(*SensorState))) {
    // Configure pin — will switch direction during protocol
    pin.Configure(machine.PinConfig{Mode: machine.PinInputPullup})

    // DHT22 needs 1 second after power-on before first read
    time.Sleep(1 * time.Second)

    for {
        temp, humidity, ok := readDHT22(pin)

        if ok {
            update(func(s *SensorState) {
                s.TemperatureC = temp
                s.HumidityPct  = humidity
            })
            println("[DHT22]", temp, "C", humidity, "% RH")

            if humidity > 85.0 {
                println("[WARN ] High humidity — check for moisture")
            }
        } else {
            println("[DHT22] Read failed")
        }

        // DHT22 minimum interval between reads is 2 seconds.
        // time.Sleep yields this goroutine — other goroutines run.
        // This is the key difference from C's polling approach:
        // we're not burning CPU, we're genuinely sleeping.
        time.Sleep(2 * time.Second)
    }
}

// readDHT22 implements the DHT22 single-wire protocol.
// Returns (temperature_c, humidity_pct, ok).
// Returns false on timing timeout or checksum failure.
func readDHT22(pin machine.Pin) (float32, float32, bool) {
    var data [5]uint8

    // ── Send start signal ──────────────────────────────────────────────
    // Pull LOW for 18ms to wake the sensor
    pin.Configure(machine.PinConfig{Mode: machine.PinOutput})
    pin.Low()
    time.Sleep(18 * time.Millisecond)
    pin.High()
    time.Sleep(30 * time.Microsecond)

    // ── Switch to input ────────────────────────────────────────────────
    pin.Configure(machine.PinConfig{Mode: machine.PinInputPullup})

    // Wait for sensor to pull LOW (~80µs) then HIGH (~80µs)
    if !waitForLevel(pin, false, 200) { return 0, 0, false }
    if !waitForLevel(pin, true,  200) { return 0, 0, false }
    if !waitForLevel(pin, false, 200) { return 0, 0, false }

    // ── Read 40 bits ───────────────────────────────────────────────────
    for i := 0; i < 40; i++ {
        // Each bit: LOW 50µs, then HIGH duration encodes the bit value
        if !waitForLevel(pin, true, 100) { return 0, 0, false }

        // Measure HIGH duration
        start := time.Now()
        if !waitForLevel(pin, false, 100) { return 0, 0, false }
        duration := time.Since(start)

        data[i/8] <<= 1
        // HIGH > 40µs means bit is 1, otherwise 0
        if duration > 40*time.Microsecond {
            data[i/8] |= 1
        }
    }

    // ── Checksum ───────────────────────────────────────────────────────
    checksum := data[0] + data[1] + data[2] + data[3]
    if checksum != data[4] {
        println("[DHT22] Checksum mismatch")
        return 0, 0, false
    }

    // ── Decode ─────────────────────────────────────────────────────────
    rawHum  := uint16(data[0])<<8 | uint16(data[1])
    rawTemp := uint16(data[2]&0x7F)<<8 | uint16(data[3])
    negative := data[2]&0x80 != 0

    humidity := float32(rawHum) / 10.0
    temp     := float32(rawTemp) / 10.0
    if negative {
        temp = -temp
    }

    return temp, humidity, true
}

// waitForLevel waits for pin to reach the given level.
// Returns false on timeout (count iterations, not real time —
// TinyGo's time.Now() has overhead inside tight loops).
func waitForLevel(pin machine.Pin, level bool, maxCount int) bool {
    for i := 0; i < maxCount; i++ {
        if pin.Get() == level {
            return true
        }
        // Brief pause — gives other goroutines a chance if scheduler
        // is cooperative, but in practice this loop is too tight to yield
        time.Sleep(1 * time.Microsecond)
    }
    return false
}

Enter fullscreen mode Exit fullscreen mode

It's super messy. AI makes interfacing with it a lot more hallucinogenic for human operator programming the interface. Once you understand the bitwise shifts that you're doing on the actual microcontroller itself, it makes a little more sense, but to say that it is super messy, is an understatement. I believe uint16(data[2]&0x7F)<<8 | uint16(data[3]) and data[2]&0x80 != 0 are legitimate brobdingnagdiums implemented in literal use.

The water sensor on the other hand is much more on point.

package sensors

import (
    "machine"
    "time"
)

// WaterTask polls the capacitive water sensor every 500ms.
//
// When water is detected:
//   - Activates the relay (cuts power to basement appliances)
//   - Sets alarm_active in shared state
//   - Logs the event
//
// Relay is deactivated when sensor clears.
// Alarm must be manually acknowledged (same decision as C version).
//
// TinyGo advantage over C here: this genuinely runs concurrently
// with other sensor goroutines. In C, a 500ms sleep in this function
// would block the entire main loop. Here, time.Sleep yields the
// goroutine scheduler — the DHT22, PIR, and CO goroutines continue.

func WaterTask(
    sensePin machine.Pin,
    relayPin machine.Pin,
    update func(func(*SensorState)),
) {
    sensePin.Configure(machine.PinConfig{Mode: machine.PinInputPullup})
    relayPin.Configure(machine.PinConfig{Mode: machine.PinOutput})
    relayPin.Low() // relay off at startup

    var lastWet bool

    for {
        // LOW = water present (pull-up config, sensor pulls down when wet)
        wet := !sensePin.Get()

        if wet != lastWet {
            if wet {
                println("[WATER] WATER DETECTED — activating relay")
                relayPin.High() // trigger relay
                update(func(s *SensorState) {
                    s.WaterDetected = true
                    s.AlarmActive   = true
                })
            } else {
                println("[WATER] Cleared — deactivating relay")
                relayPin.Low()
                update(func(s *SensorState) {
                    s.WaterDetected = false
                    // AlarmActive intentionally NOT cleared —
                    // same decision as C and Rust versions.
                    // A human should acknowledge a flood event.
                })
            }
            lastWet = wet
        }

        time.Sleep(500 * time.Millisecond)
    }
}

Enter fullscreen mode Exit fullscreen mode

The PIR sensor is used for motion detection and is built with the sensors/pir.go file.

package sensors

import (
    "machine"
    "time"
)

// PIRTask monitors the passive infrared motion sensor.
//
// PIR sensors output HIGH when motion is detected and hold that
// level for their configured hold time (typically 2-5 seconds).
//
// TinyGo vs Rust comparison for this specific task:
//
//   Rust/Embassy:
//     sensor.wait_for_high().await   // zero CPU, interrupt-driven
//     sensor.wait_for_low().await    // zero CPU, interrupt-driven
//
//   TinyGo:
//     No wait_for_high equivalent in machine package.
//     We poll with time.Sleep — yields scheduler between polls
//     but wakeup latency is the sleep interval, not interrupt latency.
//     For PIR this is fine — human motion detection doesn't need
//     sub-millisecond response time.
//
//   C:
//     gpio_get() in a polling loop every 100ms in the main loop.
//     Same latency as TinyGo but shares the main thread with everything else.

func PIRTask(pin machine.Pin, update func(func(*SensorState))) {
    pin.Configure(machine.PinConfig{Mode: machine.PinInputPulldown})

    var lastMotion bool

    for {
        motion := pin.Get() // HIGH = motion detected

        if motion != lastMotion {
            if motion {
                println("[PIR  ] Motion detected in basement")
            } else {
                println("[PIR  ] Motion cleared")
            }
            update(func(s *SensorState) {
                s.MotionDetected = motion
            })
            lastMotion = motion
        }

        // 100ms poll interval — adequate for human motion detection
        time.Sleep(100 * time.Millisecond)
    }
}
Enter fullscreen mode Exit fullscreen mode

Next is the MQ-7 CO sensor that is going to handled by the sensor/co.go file:

package sensors

import (
    "machine"
    "time"
)

// COTask reads the MQ-7 CO sensor via ADC every 5 seconds.
//
// TinyGo ADC usage is significantly cleaner than C:
//
//   C:
//     adc_init();
//     adc_gpio_init(26);
//     adc_select_input(0);
//     uint16_t raw = adc_read();
//
//   TinyGo:
//     adc := machine.ADC{Pin: machine.GPIO26}
//     adc.Configure(machine.ADCConfig{})
//     raw := adc.Get()
//
//   Rust:
//     let mut adc = Adc::new(p.ADC, Irqs, AdcConfig::default());
//     let mut ch  = Channel::new_pin(p.PIN_26, Pull::None);
//     let raw     = adc.read(&mut ch).await.unwrap();
//
// All three are functionally identical. TinyGo's is most concise.
// Rust's is the only one where the compiler verifies PIN_26 isn't
// used anywhere else simultaneously.

// SensorState is defined in dht22.go but shared across this package
// Re-declaring here would be a compile error — Go enforces one
// definition per package, unlike C's header include guards.

func COTask(pin machine.Pin, update func(func(*SensorState))) {
    adc := machine.ADC{Pin: pin}
    adc.Configure(machine.ADCConfig{})

    for {
        // machine.ADC.Get() returns 0-65535 (normalized to uint16 range)
        // regardless of the hardware's actual bit depth.
        // TinyGo normalizes ADC readings — C and Rust give you raw bits.
        raw := adc.Get()

        // Convert normalized reading to voltage (0-3.3V)
        voltage := float32(raw) / 65535.0 * 3.3

        // Approximate CO ppm from voltage — needs calibration in production
        var ppm uint16
        if voltage > 0.4 {
            ppm = uint16((voltage - 0.4) * 200.0)
        }

        update(func(s *SensorState) {
            s.CoPpm = ppm
            if ppm > 150 {
                s.AlarmActive = true
            }
        })

        if ppm > 50 {
            println("[CO   ] Elevated:", ppm, "ppm")
        }
        if ppm > 150 {
            println("[ERROR] DANGEROUS CO:", ppm, "ppm — EVACUATE")
        }

        time.Sleep(5 * time.Second)
    }
}
Enter fullscreen mode Exit fullscreen mode

The last of the TinyGo sensors package is the state.go file, which is going to contain the shared data between the different sensor implementation files where they are common due to the use of the Go runtime.

package sensors

// SensorState is the shared data structure passed between goroutines.
// Defined once here, used across all sensor files in this package.
//
// Comparison across the three implementations:
//
//   Rust:
//     pub static STATE: Mutex<CriticalSectionRawMutex, SensorState>
//     Compiler prevents access without locking.
//     Clone is explicit and deliberate.
//
//   C:
//     volatile SensorState g_state;
//     + manual critical_section_enter/exit
//     Nothing prevents unsynchronized access.
//     volatile only prevents compiler optimization, not race conditions.
//
//   TinyGo:
//     var state SensorState + sync.Mutex
//     The race detector (go test -race) would catch misuse,
//     but TinyGo does not support -race on embedded targets.
//     Convention enforced by UpdateState/GetState functions.
//     Closer to C in safety than Rust — but Go idiom encourages
//     the channel-based alternative shown below.

type SensorState struct {
    WaterDetected  bool
    TemperatureC   float32
    HumidityPct    float32
    MotionDetected bool
    CoPpm          uint16
    AlarmActive    bool
}

// ── Alternative: channel-based state (more idiomatic Go) ──────────────────
//
// Instead of a mutex-protected struct, pure Go would use channels:
//
//   type StateUpdate struct { Field string; Value interface{} }
//   updates := make(chan StateUpdate, 10)
//
//   // Writer:
//   updates <- StateUpdate{"WaterDetected", true}
//
//   // Reader (single goroutine owns state):
//   for u := range updates {
//       applyUpdate(&state, u)
//   }
//
// This is the CSP model — "don't communicate by sharing memory,
// share memory by communicating."
//
// We don't use this pattern here because:
//   1. TinyGo's channel buffer is limited — overflow panics on device
//   2. HTTP handler needs synchronous reads — channel model requires
//      a request/response channel pair, adding complexity
//   3. The mutex approach maps more directly to the C and Rust versions
//      for comparison purposes
//
// In a production TinyGo project the channel approach is often better.
// This is one area where TinyGo genuinely differs from C and Rust —
// it has idiomatic concurrency primitives that neither of the others have.

Enter fullscreen mode Exit fullscreen mode

This basic struct will later be implemented. Rust will also implement a struct.

Rust Implementation of sensors.rs

use embassy_rp::gpio::{Input, Output, Level, Pull};
use embassy_rp::adc::{Adc, Channel, Config as AdcConfig};
use embassy_rp::peripherals::*;
use embassy_time::{Duration, Instant, Timer};
use defmt::*;

use crate::STATE;

// ── Water sensor task ──────────────────────────────────────────────────────
//
// The capacitive water sensor outputs HIGH when dry, LOW when wet.
// When water is detected we:
//   1. Update shared state
//   2. Activate the relay (cuts power to basement appliances)
//   3. Trigger the alarm
//
// This is the most safety-critical sensor in the system.

#[embassy_executor::task]
pub async fn water_task(
    sense_pin: PIN_3,   // signal from water sensor
    relay_pin: PIN_4,   // relay control output
) {
    let sensor = Input::new(sense_pin, Pull::Up);
    let mut relay = Output::new(relay_pin, Level::Low);

    let mut last_state = false;

    loop {
        // Read water sensor — LOW means water present (pull-up config)
        let water_now = sensor.get_level() == Level::Low;

        if water_now != last_state {
            // State changed — log it
            if water_now {
                warn!("WATER DETECTED — activating relay and alarm");
                relay.set_high(); // trigger relay
            } else {
                info!("Water cleared — deactivating relay");
                relay.set_low();
            }
            last_state = water_now;

            // Update shared state — .lock().await is the async Mutex acquire
            // This is the equivalent of mu.Lock() in Go, but it yields
            // instead of blocking an OS thread
            let mut state = STATE.lock().await;
            state.water_detected = water_now;
            state.alarm_active   = water_now;
        }

        // Poll every 500ms — fast enough for flooding, not so fast
        // that we're burning cycles on a stable sensor
        Timer::after(Duration::from_millis(500)).await;
    }
}

// ── DHT22 temperature & humidity task ─────────────────────────────────────

#[embassy_executor::task]
pub async fn dht22_task(pin: PIN_2) {
    // DHT22 uses a single-wire protocol with very tight timing.
    // We need to drive the pin as output to send the start signal,
    // then switch to input to read the response.
    // Embassy's flexible-io support handles this cleanly.

    loop {
        // DHT22 needs at least 2 seconds between reads
        Timer::after(Duration::from_secs(2)).await;

        // Reading DHT22 requires precise microsecond timing.
        // In a real implementation you'd use embassy-rp's PIO state machine
        // to handle the bit-banging in hardware, freeing the CPU.
        // For clarity here we show the logic flow:

        match read_dht22().await {
            Ok((temp, humidity)) => {
                let mut state = STATE.lock().await;
                state.temperature_c = temp;
                state.humidity_pct  = humidity;
                info!("DHT22: {:.1}°C  {:.1}% RH", temp, humidity);

                // High humidity in a basement is a leading indicator
                // of water intrusion — worth logging even before the
                // water sensor triggers
                if humidity > 85.0 {
                    warn!("High humidity: {:.1}% — check for moisture", humidity);
                }
            }
            Err(e) => {
                warn!("DHT22 read failed: {}", e);
            }
        }
    }
}

async fn read_dht22() -> Result<(f32, f32), &'static str> {
    // Real implementation uses PIO or careful timing loops.
    // Returns (temperature_celsius, humidity_percent)
    Ok((21.5, 62.0)) // placeholder
}

// ── PIR motion sensor task ─────────────────────────────────────────────────

#[embassy_executor::task]
pub async fn pir_task(pin: PIN_5) {
    let sensor = Input::new(pin, Pull::Down);

    loop {
        // wait_for_high() suspends this task until the pin goes HIGH.
        // Zero CPU usage while waiting — Embassy wakes it via interrupt.
        // This is the embedded equivalent of blocking on a channel receive.
        sensor.wait_for_high().await;

        {
            let mut state = STATE.lock().await;
            state.motion_detected = true;
            info!("Motion detected in basement");
        }

        // Debounce — PIR sensors have a hold time of ~2s
        Timer::after(Duration::from_secs(2)).await;

        sensor.wait_for_low().await;

        {
            let mut state = STATE.lock().await;
            state.motion_detected = false;
        }
    }
}

// ── MQ-7 CO sensor task ────────────────────────────────────────────────────

#[embassy_executor::task]
pub async fn co_task(adc_peripheral: ADC, pin: PIN_26) {
    let mut adc = Adc::new(adc_peripheral, crate::Irqs, AdcConfig::default());
    let mut channel = Channel::new_pin(pin, Pull::None);

    loop {
        // ADC reads 0-4095 (12-bit) representing 0-3.3V
        // MQ-7 voltage output maps to CO concentration in ppm
        // Actual calibration requires a known CO source — this is approximate
        let raw = adc.read(&mut channel).await.unwrap_or(0);
        let voltage = (raw as f32 / 4095.0) * 3.3;

        // Rough CO ppm calculation — real deployment needs calibration curve
        let ppm = if voltage < 0.4 { 0u16 } else {
            ((voltage - 0.4) * 200.0) as u16
        };

        {
            let mut state = STATE.lock().await;
            state.co_ppm = ppm;
        }

        if ppm > 50 {
            warn!("CO level elevated: {} ppm", ppm);
        }
        if ppm > 150 {
            // DANGER threshold — activate alarm regardless of water state
            let mut state = STATE.lock().await;
            state.alarm_active = true;
            error!("DANGEROUS CO LEVEL: {} ppm — EVACUATE", ppm);
        }

        Timer::after(Duration::from_secs(5)).await;
    }
}

Enter fullscreen mode Exit fullscreen mode

C Implementation of sensors.c

src/sensors.h

#ifndef SENSORS_H
#define SENSORS_H

#include <stdint.h>
#include <stdbool.h>
#include "pico/stdlib.h"
#include "hardware/adc.h"

// ── Pin assignments ────────────────────────────────────────────────────────
// Keeping all pin definitions in one header means a wiring change
// only touches one file — same discipline as Rust's peripheral ownership
// but enforced by convention, not the compiler

#define PIN_DHT22       2   // single-wire data
#define PIN_WATER_SENSE 3   // capacitive water sensor signal
#define PIN_RELAY       4   // relay module IN
#define PIN_PIR         5   // PIR motion sensor OUT
#define PIN_OLED_SDA    6   // I2C data
#define PIN_OLED_SCL    7   // I2C clock
#define PIN_BUZZER      8   // active buzzer
#define PIN_CO_ANALOG   26  // MQ-7 analog out → ADC0

// ── Shared state ───────────────────────────────────────────────────────────
// In C there is no Mutex<SensorState> enforced by a compiler.
// We use a volatile struct + critical sections manually.
// volatile tells the compiler "don't optimize reads away —
// another context may have changed this value"
// This is what Rust's Mutex + borrow checker replaces.

typedef struct {
    volatile bool     water_detected;
    volatile float    temperature_c;
    volatile float    humidity_pct;
    volatile bool     motion_detected;
    volatile uint16_t co_ppm;
    volatile bool     alarm_active;
} SensorState;

// Global state — in Rust this would be static Mutex<SensorState>
// In C it is just a global with a manual critical section guard
extern SensorState g_state;

// ── Function declarations ──────────────────────────────────────────────────

// DHT22 returns false on checksum or timing failure
// In Rust this would be Result<(f32,f32), DhtError>
// In C we return bool and write into out pointers
bool     dht22_read(uint8_t pin, float *temp_c, float *humidity_pct);

void     water_sensor_init(void);
bool     water_sensor_read(void);    // true = water present

void     pir_sensor_init(void);
bool     pir_sensor_read(void);      // true = motion detected

void     co_sensor_init(void);
uint16_t co_sensor_read_ppm(void);

// Critical section helpers — manually what Rust does automatically
// with MutexGuard drop semantics
void     state_lock(void);
void     state_unlock(void);

#endif // SENSORS_H

Enter fullscreen mode Exit fullscreen mode

src/sensors.c

#include "sensors.h"
#include "pico/stdlib.h"
#include "pico/critical_section.h"
#include "hardware/adc.h"
#include "hardware/gpio.h"
#include <stdio.h>
#include <string.h>

// ── Global state + lock ────────────────────────────────────────────────────
// This is the C equivalent of:
//   pub static STATE: Mutex<CriticalSectionRawMutex, SensorState>
// The difference: Rust prevents you from accessing STATE without locking.
// C has no such guarantee — nothing stops a bug from reading g_state
// directly without calling state_lock() first. Convention only.

SensorState g_state = {
    .water_detected  = false,
    .temperature_c   = 0.0f,
    .humidity_pct    = 0.0f,
    .motion_detected = false,
    .co_ppm          = 0,
    .alarm_active    = false,
};

static critical_section_t g_state_cs;
static bool g_cs_initialized = false;

void state_lock(void) {
    if (!g_cs_initialized) {
        critical_section_init(&g_state_cs);
        g_cs_initialized = true;
    }
    critical_section_enter_blocking(&g_state_cs);
}

void state_unlock(void) {
    critical_section_exit(&g_state_cs);
}

// ── DHT22 ─────────────────────────────────────────────────────────────────
//
// The DHT22 single-wire protocol requires microsecond timing.
// In Rust/Embassy this would be done with PIO hardware offload.
// In C with the Pico SDK we bit-bang using busy-wait loops.
// This is simpler to read but blocks the CPU during the ~5ms read cycle.
// Compare to Rust: read_dht22().await yields the executor during the wait.
// In C: the CPU spins doing nothing while we wait for edges.
//
// Protocol timing:
//   Host pulls LOW  18ms  → start signal
//   Host releases   → sensor pulls LOW 80µs then HIGH 80µs → ready
//   40 bits follow  → each bit: LOW 50µs, then HIGH 26µs=0 or 70µs=1
//   Final: 8b humidity int, 8b humidity dec, 8b temp int, 8b temp dec, 8b checksum

#define DHT22_START_LOW_MS   18
#define DHT22_TIMEOUT_US     1000

static bool wait_for_level(uint8_t pin, bool level, uint32_t timeout_us) {
    uint32_t start = time_us_32();
    while (gpio_get(pin) != level) {
        if (time_us_32() - start > timeout_us) {
            return false;  // timeout
        }
    }
    return true;
}

bool dht22_read(uint8_t pin, float *temp_c, float *humidity_pct) {
    uint8_t data[5] = {0};

    // ── Send start signal ──────────────────────────────────────────────
    gpio_set_dir(pin, GPIO_OUT);
    gpio_put(pin, 0);               // pull LOW
    sleep_ms(DHT22_START_LOW_MS);   // hold 18ms — blocks CPU entirely
    gpio_put(pin, 1);               // release
    sleep_us(30);

    // ── Switch to input, wait for sensor response ──────────────────────
    gpio_set_dir(pin, GPIO_IN);
    gpio_pull_up(pin);

    if (!wait_for_level(pin, false, DHT22_TIMEOUT_US)) return false; // LOW 80µs
    if (!wait_for_level(pin, true,  DHT22_TIMEOUT_US)) return false; // HIGH 80µs
    if (!wait_for_level(pin, false, DHT22_TIMEOUT_US)) return false; // start of data

    // ── Read 40 bits ───────────────────────────────────────────────────
    for (int i = 0; i < 40; i++) {
        // Each bit starts with a 50µs LOW pulse
        if (!wait_for_level(pin, true, DHT22_TIMEOUT_US)) return false;

        // Measure how long HIGH lasts:
        // ~26µs = 0, ~70µs = 1
        uint32_t high_start = time_us_32();
        if (!wait_for_level(pin, false, DHT22_TIMEOUT_US)) return false;
        uint32_t high_duration = time_us_32() - high_start;

        data[i / 8] <<= 1;
        if (high_duration > 40) {
            data[i / 8] |= 1;  // it's a 1
        }
        // if <= 40µs it's a 0, the bit is already 0 from the shift
    }

    // ── Verify checksum ────────────────────────────────────────────────
    // checksum = sum of first 4 bytes, lower 8 bits
    uint8_t checksum = data[0] + data[1] + data[2] + data[3];
    if (checksum != data[4]) {
        printf("[DHT22] checksum failed: got %02x expected %02x\n",
               checksum, data[4]);
        return false;
    }

    // ── Decode ─────────────────────────────────────────────────────────
    // Humidity: bytes 0-1, value in tenths of percent
    uint16_t raw_hum  = ((uint16_t)data[0] << 8) | data[1];
    // Temperature: bytes 2-3, bit15 = sign, value in tenths of degree
    uint16_t raw_temp = ((uint16_t)(data[2] & 0x7F) << 8) | data[3];
    bool negative = (data[2] & 0x80) != 0;

    *humidity_pct = raw_hum  / 10.0f;
    *temp_c       = raw_temp / 10.0f * (negative ? -1.0f : 1.0f);

    return true;
}

// ── Water sensor ───────────────────────────────────────────────────────────

void water_sensor_init(void) {
    gpio_init(PIN_WATER_SENSE);
    gpio_set_dir(PIN_WATER_SENSE, GPIO_IN);
    gpio_pull_up(PIN_WATER_SENSE);  // HIGH = dry, LOW = water

    gpio_init(PIN_RELAY);
    gpio_set_dir(PIN_RELAY, GPIO_OUT);
    gpio_put(PIN_RELAY, 0);         // relay off at startup
}

bool water_sensor_read(void) {
    // LOW = water present (capacitive sensor pulls down when wet)
    return !gpio_get(PIN_WATER_SENSE);
}

// ── PIR motion sensor ──────────────────────────────────────────────────────

void pir_sensor_init(void) {
    gpio_init(PIN_PIR);
    gpio_set_dir(PIN_PIR, GPIO_IN);
    gpio_pull_down(PIN_PIR);  // PIR output is active HIGH
}

bool pir_sensor_read(void) {
    return gpio_get(PIN_PIR);
}

// ── MQ-7 CO sensor ────────────────────────────────────────────────────────
//
// MQ-7 needs a heating cycle: 60s at 5V, 90s at 1.4V.
// For simplicity in this implementation we just read the analog output.
// A production version would implement the heating cycle via PWM on a
// separate voltage control pin.

void co_sensor_init(void) {
    // ADC init — PIN_26 is ADC0 on the Pico
    adc_init();
    adc_gpio_init(PIN_CO_ANALOG);
    adc_select_input(0);  // ADC channel 0 = GPIO26
}

uint16_t co_sensor_read_ppm(void) {
    adc_select_input(0);
    uint16_t raw = adc_read();  // 0-4095 (12-bit)

    // Convert ADC reading to voltage
    float voltage = (raw / 4095.0f) * 3.3f;

    // Approximate CO ppm from voltage
    // Real calibration needs a known CO concentration reference
    if (voltage < 0.4f) return 0;
    return (uint16_t)((voltage - 0.4f) * 200.0f);
}

Enter fullscreen mode Exit fullscreen mode

src/sensors_loop.c

#include "sensors.h"
#include "pico/stdlib.h"
#include <stdio.h>

// These are called from the main loop on their own timing cadence.
// In Rust/Embassy each of these would be a separate async task running
// concurrently. In C without an RTOS they share one execution thread
// and we manually track when each one is "due" to run next.
//
// This is the fundamental structural difference:
//   Rust: 6 async tasks, Embassy schedules them cooperatively
//   C:    1 loop, you manually schedule everything via timestamps

static uint32_t next_dht22_ms    = 0;
static uint32_t next_water_ms    = 0;
static uint32_t next_pir_ms      = 0;
static uint32_t next_co_ms       = 0;

void sensors_poll_all(void) {
    uint32_t now = to_ms_since_boot(get_absolute_time());

    // ── DHT22 every 2000ms ─────────────────────────────────────────────
    if (now >= next_dht22_ms) {
        next_dht22_ms = now + 2000;

        float temp = 0.0f, hum = 0.0f;
        if (dht22_read(PIN_DHT22, &temp, &hum)) {
            state_lock();
            g_state.temperature_c = temp;
            g_state.humidity_pct  = hum;
            state_unlock();

            printf("[DHT22] %.1f°C  %.1f%% RH\n", temp, hum);

            if (hum > 85.0f) {
                printf("[WARN ] High humidity: %.1f%% — check for moisture\n", hum);
            }
        } else {
            printf("[DHT22] Read failed\n");
        }
    }

    // ── Water sensor every 500ms ───────────────────────────────────────
    if (now >= next_water_ms) {
        next_water_ms = now + 500;

        bool wet = water_sensor_read();

        state_lock();
        bool was_wet = g_state.water_detected;
        g_state.water_detected = wet;
        if (wet && !was_wet) {
            // Transition: dry → wet
            g_state.alarm_active = true;
            gpio_put(PIN_RELAY, 1);  // activate relay
            printf("[WATER] WATER DETECTED — relay ON, alarm active\n");
        } else if (!wet && was_wet) {
            // Transition: wet → dry
            // Note: we do NOT auto-clear alarm_active here.
            // A human should acknowledge a flood event.
            gpio_put(PIN_RELAY, 0);
            printf("[WATER] Cleared — relay OFF\n");
        }
        state_unlock();
    }

    // ── PIR every 100ms ────────────────────────────────────────────────
    if (now >= next_pir_ms) {
        next_pir_ms = now + 100;

        bool motion = pir_sensor_read();
        state_lock();
        bool was_motion = g_state.motion_detected;
        g_state.motion_detected = motion;
        state_unlock();

        if (motion && !was_motion) {
            printf("[PIR  ] Motion detected\n");
        }
    }

    // ── CO sensor every 5000ms ─────────────────────────────────────────
    if (now >= next_co_ms) {
        next_co_ms = now + 5000;

        uint16_t ppm = co_sensor_read_ppm();
        state_lock();
        g_state.co_ppm = ppm;
        if (ppm > 150) {
            g_state.alarm_active = true;
        }
        state_unlock();

        if (ppm > 50) {
            printf("[CO   ] Elevated: %u ppm\n", ppm);
        }
        if (ppm > 150) {
            printf("[ERROR] DANGEROUS CO: %u ppm — EVACUATE\n", ppm);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

In C with no RTOS, the “task” concept doesn’t exist natively. We implement a cooperative loop scheduler in main.

Verdict of the Trinity

Rust for expression followed by C for stability. TinyGo is a joke, but technically possible!

Main Package

Now we have a sensors package built and the C and Rust projects have their respective sensor file, let's implement the main.go, main.c and main.rs files.

main.go Implementation

package main

import (
    "basement-monitor/alarm"
    "basement-monitor/display"
    "basement-monitor/http"
    "basement-monitor/sensors"
    "basement-monitor/wifi"
    "machine"
    "sync"
    "time"
)

// ── Shared state ───────────────────────────────────────────────────────────
//
// TinyGo supports sync.Mutex — but it is NOT the same as Go's runtime mutex.
// Under the hood TinyGo's mutex disables interrupts on Cortex-M rather than
// parking a goroutine. This means:
//   - Lock() is fast but briefly disables ALL interrupts
//   - You must never hold the lock while doing I2C/SPI/UART operations
//   - Contrast with Rust: Mutex<T> in Embassy uses critical sections too
//     but the compiler enforces you don't hold it across .await points
//   - Contrast with C: critical_section_enter_blocking() is the same
//     mechanism but nothing enforces usage discipline
//
// TinyGo goroutines are real but limited:
//   - Cooperative, not preemptive on single-core RP2040
//   - Stack size is fixed at compile time (default 1KB per goroutine)
//   - No goroutine leak detection
//   - channel operations are the primary synchronization primitive
//   - sync.WaitGroup works but blocks the scheduler

type SensorState struct {
    WaterDetected  bool
    TemperatureC   float32
    HumidityPct    float32
    MotionDetected bool
    CoPpm          uint16
    AlarmActive    bool
}

var (
    stateMu sync.Mutex
    state   SensorState
)

// GetState returns a snapshot of current sensor state.
// Pattern identical to Rust's STATE.lock().await + clone()
// and Go server's room.Snapshot — point-in-time copy.
func GetState() SensorState {
    stateMu.Lock()
    s := state // copy
    stateMu.Unlock()
    return s
}

// UpdateState applies a mutation function under the lock.
// This is cleaner than exposing the mutex directly —
// callers can't forget to unlock.
func UpdateState(fn func(*SensorState)) {
    stateMu.Lock()
    fn(&state)
    stateMu.Unlock()
}

func main() {
    // Small startup delay — lets USB serial connect before logging
    time.Sleep(2 * time.Second)

    println("[MAIN ] Basement monitor starting (TinyGo)")
    println("[MAIN ] Target: Raspberry Pi Pico W")

    // ── Pin configuration ──────────────────────────────────────────────
    // In TinyGo, machine.GPIO* constants map directly to RP2040 GPIO numbers.
    // This is equivalent to:
    //   Rust:  Output::new(p.PIN_2, Level::Low)
    //   C:     gpio_init(2); gpio_set_dir(2, GPIO_OUT);
    // TinyGo's machine package abstracts the register writes but unlike
    // Rust, two goroutines CAN both configure the same pin — no ownership
    // enforcement at compile time.

    dht22Pin  := machine.GPIO2
    waterPin  := machine.GPIO3
    relayPin  := machine.GPIO4
    pirPin    := machine.GPIO5
    sdaPin    := machine.GPIO6
    sclPin    := machine.GPIO7
    buzzerPin := machine.GPIO8
    coPin     := machine.GPIO26  // ADC0

    // ── Goroutine-based task model ─────────────────────────────────────
    //
    // This is TinyGo's closest equivalent to Embassy's task spawning.
    // Syntactically identical to standard Go.
    // Under the hood: TinyGo's scheduler is a simple cooperative
    // round-robin — goroutines yield at channel ops and time.Sleep.
    //
    // Compare:
    //   Rust:   spawner.spawn(sensors::dht22_task(p.PIN_2)).unwrap()
    //   TinyGo: go sensors.DHT22Task(dht22Pin, UpdateState)
    //   C:      no equivalent — polling loop only
    //
    // The TinyGo version looks most like standard Go and is the most
    // readable of the three — but has the least runtime safety guarantees.

    go sensors.DHT22Task(dht22Pin, UpdateState)
    go sensors.WaterTask(waterPin, relayPin, UpdateState)
    go sensors.PIRTask(pirPin, UpdateState)
    go sensors.COTask(coPin, UpdateState)
    go display.OLEDTask(sdaPin, sclPin, GetState)
    go alarm.AlarmTask(buzzerPin, GetState)

    // WiFi runs synchronously before HTTP server starts —
    // same constraint as C. TinyGo's WiFi drivers for Pico W
    // do not yet support a fully async connection flow.
    // The goroutine blocks here until connected or timeout.
    //
    // In Rust: wifi.join().await yields Embassy's executor
    // In C:    blocks the CPU entirely
    // In TinyGo: blocks this goroutine, others can still run
    //            IF there are yield points in the WiFi driver.
    //            In practice the CYW43 driver does yield internally.

    ip, err := wifi.Connect("YourNetworkName", "YourPassword")
    if err != nil {
        println("[MAIN ] WiFi failed:", err.Error())
        println("[MAIN ] Running in offline mode")
    } else {
        println("[MAIN ] Connected — IP:", ip)
        go http.ServeTask(GetState)
    }

    // main goroutine idles.
    // Unlike C's while(true) polling loop, we genuinely have nothing
    // to do here — the goroutines above handle everything.
    // select{} blocks forever without spinning the CPU.
    //
    // This is the most elegant version of this pattern across the three:
    //   Rust: loop { Timer::after(secs(60)).await; }
    //   C:    while(true) { poll_all(); sleep_us(100); }
    //   TinyGo: select {}   ← clean, zero CPU, zero boilerplate
    select {}
}
Enter fullscreen mode Exit fullscreen mode

When building with TinyGo you'll need these flash commands:

# Install TinyGo first: https://tinygo.org/getting-started/install/

# Build and flash to Pico W
tinygo flash \
    -target=pico-w \
    -opt=z \
    .

# Build only (produces .uf2)
tinygo build \
    -target=pico-w \
    -opt=z \
    -o basement-monitor.uf2 \
    .

# Monitor serial output
tinygo monitor -target=pico-w

# Check binary size — flash is limited to 2MB on Pico W
tinygo build -target=pico-w -size=full .
Enter fullscreen mode Exit fullscreen mode

main.c Implementation

#include <stdio.h>
#include <string.h>
#include "pico/stdlib.h"
#include "pico/cyw43_arch.h"

#include "sensors.h"
#include "display.h"
#include "alarm.h"
#include "wifi.h"
#include "http.h"

// ── Entry point ────────────────────────────────────────────────────────────
//
// The fundamental architectural difference from Rust is visible here.
//
// Rust/Embassy:
//   main() spawns 6 async tasks and returns.
//   Embassy's executor drives all 6 tasks concurrently on one or both cores.
//   Each task has its own stack, runs independently, yields at .await points.
//   The compiler guarantees no data races via the borrow checker.
//
// C (no RTOS):
//   main() runs one infinite loop.
//   Every "task" is a poll function that checks a timestamp and
//   returns immediately if it's not time to run yet.
//   One thing runs at a time. Nothing is truly concurrent.
//   The programmer is responsible for never blocking in a poll function.
//   The compiler offers no help if you forget state_lock() before g_state access.
//
// Both approaches work. The C version is simpler to understand mechanically.
// The Rust version scales better and fails earlier (at compile time).

int main(void) {
    // Initialize Pico SDK stdio — routes printf to USB serial
    stdio_init_all();

    // Small delay so USB serial has time to connect before we start logging
    sleep_ms(2000);
    printf("\n[MAIN ] Basement monitor starting\n");

    // ── Hardware initialization ────────────────────────────────────────
    water_sensor_init();
    pir_sensor_init();
    co_sensor_init();
    display_init();
    alarm_init();

    // ── Network initialization ─────────────────────────────────────────
    // wifi_init() blocks until connected or timeout.
    // In Rust: wifi.join().await — yields the executor during connection.
    // In C:    blocks the CPU. Nothing else runs for up to 30 seconds.
    //          lwIP background IRQ still processes packets during this time
    //          but our sensor polling loop is completely paused.
    if (!wifi_init()) {
        printf("[MAIN ] WiFi failed — running in offline mode\n");
        // Continue without network — sensors and alarm still work
    } else {
        http_server_init();
    }

    printf("[MAIN ] Entering main loop\n");

    // ── Main loop ──────────────────────────────────────────────────────
    // This is the scheduler. Each poll_*() function:
    //   1. Checks if it's time to run
    //   2. Does its work if yes, returns immediately if no
    //   3. MUST NOT block — blocking here stalls all other functions
    //
    // The loop runs as fast as possible — typically thousands of times
    // per second. Each poll function uses timestamps to self-throttle.
    //
    // In Rust/Embassy this loop doesn't exist. Each task IS its own loop,
    // sleeping at Timer::after().await without consuming CPU.
    while (true) {
        sensors_poll_all();   // DHT22, water, PIR, CO
        alarm_poll();         // buzzer pulsing
        display_poll();       // OLED refresh
        http_server_poll();   // lwIP processing (no-op in background mode)

        // Yield to lwIP background processing.
        // In threadsafe_background mode this is technically not needed
        // but it is good practice and costs almost nothing.
        cyw43_arch_poll();

        // Very brief sleep — gives IRQs a chance to fire.
        // Without this, tight loops on Cortex-M can starve interrupt handlers.
        // In Rust/Embassy: .await points are where IRQs are naturally serviced.
        sleep_us(100);
    }

    // Unreachable — microcontrollers never exit main()
    return 0;
}

Enter fullscreen mode Exit fullscreen mode

main.rs Implementation

#![no_std]      // no standard library — we're on bare metal
#![no_main]     // no standard main() — Embassy provides the entry point

use embassy_executor::Spawner;
use embassy_rp::gpio::{Input, Level, Output, Pull};
use embassy_rp::peripherals::*;
use embassy_rp::{bind_interrupts, i2c, pio, spi};
use embassy_time::{Duration, Timer};
use static_cell::StaticCell;
use defmt::*;
use {defmt_rtt as _, panic_probe as _};

// Pull in our modules
mod sensors;
mod display;
mod alarm;
mod wifi;
mod http;

// Shared state between tasks — Embassy's Mutex is async-aware
// This is the equivalent of your room.Snapshot — a point-in-time
// view of the system that any task can read
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
use embassy_sync::mutex::Mutex;

// SensorState is shared across ALL tasks via this static Mutex.
// No Arc needed — static lifetime covers the whole program.
// CriticalSectionRawMutex is safe across interrupt contexts.
pub static STATE: Mutex<CriticalSectionRawMutex, SensorState> =
    Mutex::new(SensorState::new());

#[derive(Clone)]
pub struct SensorState {
    pub water_detected: bool,
    pub temperature_c:  f32,
    pub humidity_pct:   f32,
    pub motion_detected: bool,
    pub co_ppm:         u16,
    pub alarm_active:   bool,
}

impl SensorState {
    pub const fn new() -> Self {
        Self {
            water_detected:  false,
            temperature_c:   0.0,
            humidity_pct:    0.0,
            motion_detected: false,
            co_ppm:          0,
            alarm_active:    false,
        }
    }
}

// bind_interrupts wires the RP2040's hardware interrupt vectors
// to Embassy's async drivers — this is what lets .await work on I2C, SPI etc.
bind_interrupts!(struct Irqs {
    I2C0_IRQ  => i2c::InterruptHandler<I2C0>;
    PIO0_IRQ_0 => pio::InterruptHandler<PIO0>;
    USBCTRL_IRQ => embassy_rp::usb::InterruptHandler<USB>;
});

// Embassy's entry point macro — replaces main()
// Spawner lets us launch async tasks, equivalent to `go func()`
#[embassy_executor::main]
async fn main(spawner: Spawner) {
    // Take ownership of ALL peripherals at startup.
    // This is Rust enforcing that nothing can double-use a pin —
    // at compile time, not runtime.
    let p = embassy_rp::init(Default::default());

    info!("Basement monitor starting up");

    // ── WiFi init (Pico W specific) ────────────────────────────────────
    let wifi_state = wifi::init(
        spawner,
        p.PIN_23, p.PIN_24, p.PIN_25, p.PIN_29,
        p.PIO0, p.DMA_CH0,
        Irqs,
    ).await;

    // ── Spawn independent async tasks ─────────────────────────────────
    // Each of these is like a goroutine — cooperative, cheap, no OS thread.
    // Embassy schedules them on a single core (or both cores on RP2040).

    // Reads DHT22 every 2s, updates STATE
    spawner.spawn(sensors::dht22_task(p.PIN_2)).unwrap();

    // Reads water sensor every 500ms, triggers alarm if wet
    spawner.spawn(sensors::water_task(p.PIN_3, p.PIN_4)).unwrap();

    // Reads PIR motion sensor
    spawner.spawn(sensors::pir_task(p.PIN_5)).unwrap();

    // Reads MQ-7 CO sensor via ADC
    spawner.spawn(sensors::co_task(p.ADC, p.PIN_26)).unwrap();

    // Drives SSD1306 OLED over I2C, refreshes every second
    spawner.spawn(display::oled_task(p.I2C0, p.PIN_6, p.PIN_7, Irqs)).unwrap();

    // Manages buzzer and relay based on STATE.alarm_active
    spawner.spawn(alarm::alarm_task(p.PIN_8, p.PIN_9)).unwrap();

    // HTTP server — serves sensor JSON and triggers
    spawner.spawn(http::server_task(wifi_state)).unwrap();

    // main task just idles — all real work is in the spawned tasks
    loop {
        Timer::after(Duration::from_secs(60)).await;
    }
}

Enter fullscreen mode Exit fullscreen mode

Next we need to build out the display/oled.go, display.rs and the display.h | display.c implementations to get a visual representation of the bunker or basement monitor.

display/oled.go Implementation

// display/oled.go
//
// SSD1306 128x64 OLED display driver over I2C.
// Implements the same two-page rotating UI as display.rs and display.c:
//
//   Page 0 (Sensors): water, temp, humidity, alarm
//   Page 1 (Status):  motion, CO bar graph, network IP
//
// Architecture comparison for this file specifically:
//
//   Rust:   embedded-graphics crate — Point, Rectangle, Text primitives.
//           heapless::String — zero-allocation formatting.
//           ssd1306 crate manages framebuffer + flush.
//           Compiler owns I2C peripheral exclusively via type system.
//
//   C:      Hand-rolled framebuffer, font table, and primitives from scratch.
//           snprintf into stack buffers — zero heap allocation.
//           Nothing prevents two functions accessing I2C simultaneously.
//
//   TinyGo: tinygo/x/drivers/ssd1306 manages framebuffer + flush.
//           tinyfont package for glyph rendering — same role as
//           embedded-graphics MonoTextStyle in Rust.
//           fmt.Sprintf allocates — triggers GC on each call.
//           Goroutine owns display — scheduler enforces single access
//           by convention (not compiler enforcement like Rust).

package display

import (
    "basement-monitor/sensors"
    "fmt"
    "machine"
    "time"

    "tinygo.org/x/drivers/ssd1306"
    "tinygo.org/x/tinyfont"
    "tinygo.org/x/tinyfont/freemono"
    "tinygo.org/x/tinyfont/gophers"
)

// ── Layout constants ───────────────────────────────────────────────────────
// Mirror the constants in display.h and display.rs exactly so the
// write-up comparison is apples-to-apples.

const (
    screenW = 128
    screenH = 64

    // Row Y positions in pixels — matches ROW_0..ROW_7 in display.h
    // and ROW_0..ROW_5 in display.rs
    row0 = 0
    row1 = 8
    row2 = 16
    row3 = 24
    row4 = 32
    row5 = 40
    row6 = 48
    row7 = 56

    // Page rotation: switch every 50 × 100ms ticks = 5 seconds
    pageTicks = 50

    // I2C
    oledAddr = 0x3C
    i2cFreq  = 400 * machine.KHz
)

// ── Display pages ──────────────────────────────────────────────────────────
// Identical enum to DisplayPage in display.h and Page in display.rs.

type page uint8

const (
    pageSensors page = 0
    pageStatus  page = 1
    pageCount        = 2
)

func (p page) next() page {
    if p == pageSensors {
        return pageStatus
    }
    return pageSensors
}

// ── Color constants ────────────────────────────────────────────────────────
// SSD1306 is monochrome. The driver uses these to set or clear pixels.

const (
    colorOn  = ssd1306.White
    colorOff = ssd1306.Black
)

// ── OLEDTask ───────────────────────────────────────────────────────────────

// OLEDTask initializes the SSD1306 and runs the display refresh loop.
// Identical entry point signature to oled_task() in display.rs.
//
// In Rust:   #[embassy_executor::task] — compiler registers it as a task
// In C:      display_init() + display_poll() called from main loop
// In TinyGo: go display.OLEDTask(...) from main.go — goroutine

func OLEDTask(
    sdaPin    machine.Pin,
    sclPin    machine.Pin,
    getState  func() sensors.SensorState,
) {
    // ── Initialize I2C ─────────────────────────────────────────────────
    // TinyGo's machine.I2C0.Configure is the equivalent of:
    //   Rust:  I2c::new_async(i2c_peripheral, scl, sda, irqs, config)
    //   C:     i2c_init() + gpio_set_function() + gpio_pull_up()
    err := machine.I2C0.Configure(machine.I2CConfig{
        Frequency: i2cFreq,
        SDA:       sdaPin,
        SCL:       sclPin,
    })
    if err != nil {
        println("[OLED ] I2C configure failed:", err.Error())
        return
    }

    // ── Initialize SSD1306 ─────────────────────────────────────────────
    // tinygo/x/drivers/ssd1306 manages the framebuffer internally —
    // same role as Rust's ssd1306 crate in BufferedGraphicsMode.
    // C manages the framebuffer manually as g_framebuffer[8][128].
    display := ssd1306.NewI2C(machine.I2C0)
    display.Configure(ssd1306.Config{
        Address: oledAddr,
        Width:   screenW,
        Height:  screenH,
    })

    // ── Startup splash ─────────────────────────────────────────────────
    drawSplash(&display)
    display.Display()
    time.Sleep(2 * time.Second)

    println("[OLED ] Display initialized")

    // ── Main refresh loop ──────────────────────────────────────────────
    // Structure mirrors display.rs oled_task loop and
    // display_poll() timing logic in display.c.
    //
    // TinyGo advantage over C here:
    //   time.Sleep(100ms) yields this goroutine — sensor goroutines
    //   continue running. In C, display_poll() returns immediately
    //   when not due — the main loop spins doing nothing useful.

    var (
        currentPage page   = pageSensors
        pageTickCnt uint32 = 0
        tick        uint32 = 0
    )

    for {
        s := getState() // snapshot — same pattern as Rust + C

        display.ClearDisplay()

        switch currentPage {
        case pageSensors:
            drawSensorsPage(&display, &s, tick)
        case pageStatus:
            drawStatusPage(&display, &s)
        }

        // Flush framebuffer to display over I2C
        // Equivalent to:
        //   Rust:   display.flush().ok()
        //   C:      ssd1306_flush_all()
        display.Display()

        pageTickCnt++
        if pageTickCnt >= pageTicks {
            pageTickCnt = 0
            currentPage = currentPage.next()
        }

        tick++

        // Yield to scheduler — other goroutines run during this sleep.
        // Rust: Timer::after(Duration::from_millis(100)).await
        // C:    return from display_poll(), main loop continues
        time.Sleep(100 * time.Millisecond)
    }
}

// ── Page renderers ─────────────────────────────────────────────────────────

// drawSensorsPage renders page 0.
//
// ┌────────────────────────┐
// │ BASEMENT MONITOR       │  ← inverted title bar
// │ Water: DRY             │  ← flashes when wet
// │ Temp:  21.5 C          │
// │ Humid: 62.0 %          │  ← inverted when > 85%
// │ Alarm: OFF             │  ← inverted when active
// │                  • ○   │  ← page indicator
// └────────────────────────┘

func drawSensorsPage(
    d   *ssd1306.Device,
    s   *sensors.SensorState,
    tick uint32,
) {
    // ── Title bar ──────────────────────────────────────────────────────
    drawTitleBar(d, "BASEMENT MONITOR")

    // ── Water status ───────────────────────────────────────────────────
    // Flash every other tick (100ms period) matching display.rs and display.c
    if s.WaterDetected {
        flashOn := tick%2 == 0
        if flashOn {
            fillRect(d, 0, row1, screenW, 8, colorOn)
        }
        drawString(d, 2, row1+7, "Water: !! WET !!", flashOn)
    } else {
        drawString(d, 2, row1+7, "Water: DRY", false)
    }

    // ── Temperature ────────────────────────────────────────────────────
    // fmt.Sprintf allocates here — GC cost.
    // Rust uses write!(heapless::String, ...) — zero allocation.
    // C uses snprintf into stack buffer — zero allocation.
    // At 100ms refresh frequency the GC cost is negligible.
    drawString(d, 2, row2+7,
        fmt.Sprintf("Temp:  %.1f C", s.TemperatureC),
        false)

    // ── Humidity ───────────────────────────────────────────────────────
    humWarn := s.HumidityPct > 85.0
    if humWarn {
        fillRect(d, 0, row3, screenW, 8, colorOn)
    }
    drawString(d, 2, row3+7,
        fmt.Sprintf("Humid: %.1f %%", s.HumidityPct),
        humWarn)

    // ── Alarm status ───────────────────────────────────────────────────
    if s.AlarmActive {
        fillRect(d, 0, row4, screenW, 8, colorOn)
        drawString(d, 2, row4+7, "Alarm: ACTIVE", true)
    } else {
        drawString(d, 2, row4+7, "Alarm: OFF", false)
    }

    // ── Page indicator ─────────────────────────────────────────────────
    drawPageIndicator(d, pageSensors, pageCount)
}

// drawStatusPage renders page 1.
//
// ┌────────────────────────┐
// │ STATUS                 │  ← inverted title bar
// │ Motion: CLEAR          │
// │ CO:    12 ppm  OK      │  ← inverted entire row when DANGER
// │ [=======    ]          │  ← CO bar graph 0-300ppm
// │ Net: 192.168.1.42      │
// │                  ○ •   │  ← page indicator
// └────────────────────────┘

func drawStatusPage(d *ssd1306.Device, s *sensors.SensorState) {
    // ── Title bar ──────────────────────────────────────────────────────
    drawTitleBar(d, "STATUS")

    // ── Motion ─────────────────────────────────────────────────────────
    motionText := "Motion: CLEAR"
    if s.MotionDetected {
        motionText = "Motion: DETECTED"
    }
    drawString(d, 2, row1+7, motionText, false)

    // ── CO level + severity ────────────────────────────────────────────
    var severity string
    coDanger := false

    switch {
    case s.CoPpm >= 150:
        severity = "DANGER"
        coDanger = true
    case s.CoPpm >= 50:
        severity = "WARN"
    default:
        severity = "OK"
    }

    coText := fmt.Sprintf("CO: %d ppm %s", s.CoPpm, severity)

    if coDanger {
        fillRect(d, 0, row2, screenW, 8, colorOn)
    }
    drawString(d, 2, row2+7, coText, coDanger)

    // ── CO bar graph ───────────────────────────────────────────────────
    // 0-300ppm range, 124px wide.
    // Same bar graph as display.rs fb_draw_bar() and display.c fb_draw_bar().
    drawBarGraph(d, row3, s.CoPpm, 300)

    // ── Network IP ─────────────────────────────────────────────────────
    // wifi.GetIP() returns the cached IP string set at connection time.
    // Avoids a network call from the display goroutine.
    ip := wifi_GetIP() // forward declaration — implemented in wifi/wifi.go
    drawString(d, 2, row4+7, fmt.Sprintf("Net: %s", ip), false)

    // ── Page indicator ─────────────────────────────────────────────────
    drawPageIndicator(d, pageStatus, pageCount)
}

// ── Splash screen ──────────────────────────────────────────────────────────

// drawSplash renders the 2-second startup screen.
// Matches draw_splash() in display.rs and display_draw_splash() in display.c.
//
// ┌────────────────────────┐
// │                        │
// │      Basement          │
// │       Monitor          │
// │ ────────────────────── │
// │    TinyGo + Pico W     │
// └────────────────────────┘

func drawSplash(d *ssd1306.Device) {
    d.ClearDisplay()

    drawString(d, 16, row1+7, "Basement", false)
    drawString(d, 22, row3+7, "Monitor",  false)

    // Horizontal divider
    hline(d, 0, row5, screenW, colorOn)

    drawString(d, 4, row6+7, "TinyGo + Pico W", false)
}

// ── Shared UI widgets ──────────────────────────────────────────────────────

// drawTitleBar fills a full-width inverted bar and draws white-on-black text.
// Equivalent to fb_draw_title_bar() in display.c and the Rectangle +
// inverted MonoTextStyle pattern in display.rs.
func drawTitleBar(d *ssd1306.Device, title string) {
    fillRect(d, 0, row0, screenW, 8, colorOn)
    drawString(d, 2, row0+7, title, true)
}

// drawPageIndicator renders filled/outline dots at bottom-right.
// Matches draw_page_indicator() in display.rs and fb_draw_page_indicator()
// in display.c exactly — same dot size, spacing, and position logic.
//
//   filled square  = current page
//   outline square = other page
func drawPageIndicator(d *ssd1306.Device, current page, total int) {
    dotSize    := int16(4)
    dotSpacing := int16(8)
    startX     := int16(screenW) - int16(total)*dotSpacing - 4
    y           := int16(screenH) - 6

    for i := 0; i < total; i++ {
        x := startX + int16(i)*dotSpacing
        if page(i) == current {
            // Filled square — current page
            fillRect(d, int(x), int(y), int(dotSize), int(dotSize), colorOn)
        } else {
            // Outline square — other page
            // Top and bottom edges
            hline(d, int(x), int(y),              int(dotSize), colorOn)
            hline(d, int(x), int(y)+int(dotSize)-1, int(dotSize), colorOn)
            // Left and right edges
            for row := int(y); row < int(y)+int(dotSize); row++ {
                d.SetPixel(x,              int16(row), colorOn)
                d.SetPixel(x+dotSize-1,   int16(row), colorOn)
            }
        }
    }
}

// drawBarGraph renders a horizontal progress bar.
// Matches fb_draw_bar() in display.c and the Rectangle bar in display.rs.
//
// barY:    top pixel row of the bar
// value:   current reading
// maxVal:  value that represents a full bar (124px wide)
//
// Outline is always drawn. Fill is proportional to value/maxVal.
func drawBarGraph(d *ssd1306.Device, barY int, value uint16, maxVal uint16) {
    const barMaxW = 124
    const barH    = 8

    barW := int(uint32(value) * barMaxW / uint32(maxVal))
    if barW > barMaxW {
        barW = barMaxW
    }

    // Outline rectangle
    // Top edge
    hline(d, 2, barY,         barMaxW, colorOn)
    // Bottom edge
    hline(d, 2, barY+barH-1,  barMaxW, colorOn)
    // Left and right edges
    for row := barY; row < barY+barH; row++ {
        d.SetPixel(2,             int16(row), colorOn)
        d.SetPixel(2+barMaxW-1,   int16(row), colorOn)
    }

    // Fill — proportional to value
    if barW > 0 {
        fillRect(d, 2, barY, barW, barH, colorOn)
    }
}

// DrawError renders a full-screen inverted error message.
// Exported so other packages (sensors, wifi) can call it.
// Matches draw_error() in display.rs and display_error() in display.c.
//
// message: up to 42 characters, word-wrapped across two lines.
//
// ┌────────────────────────┐
// │████████████████████████│  ← full white screen
// │███ ERROR ██████████████│
// │████████████████████████│
// │███ line 1 █████████████│
// │███ line 2 █████████████│
// │████████████████████████│
// └────────────────────────┘
func DrawError(d *ssd1306.Device, message string) {
    d.ClearDisplay()

    // Full screen white background
    fillRect(d, 0, 0, screenW, screenH, colorOn)

    // "ERROR" in black-on-white
    drawString(d, 40, row1+7, "ERROR", true)

    // Black horizontal divider on white background
    hline(d, 0, row3, screenW, colorOff)

    // Word-wrap message across two lines (21 chars each)
    const lineLen = 21
    runes := []rune(message)

    line1 := string(runes[:min(lineLen, len(runes))])
    line2 := ""
    if len(runes) > lineLen {
        end := lineLen + lineLen
        if end > len(runes) {
            end = len(runes)
        }
        line2 = string(runes[lineLen:end])
    }

    // Black text on white background (inverted = true)
    drawString(d, 2, row4+7, line1, true)
    if line2 != "" {
        drawString(d, 2, row5+7, line2, true)
    }

    d.Display()
    println("[OLED ] Error displayed:", message)
}

// ── Low-level drawing primitives ───────────────────────────────────────────
// These wrap ssd1306.Device.SetPixel() to provide the same rectangle,
// line, and text primitives that display.c implements manually against
// g_framebuffer and that display.rs gets from embedded-graphics.

// fillRect fills a rectangle with the given color.
// Equivalent to:
//   Rust:   Rectangle::new(Point, Size).into_styled(fill).draw(display)
//   C:      fb_fill_rect(x, y, w, h, on)
func fillRect(d *ssd1306.Device, x, y, w, h int, color ssd1306.Color) {
    for row := y; row < y+h; row++ {
        for col := x; col < x+w; col++ {
            d.SetPixel(int16(col), int16(row), color)
        }
    }
}

// hline draws a horizontal line.
// Equivalent to:
//   Rust:   Line::new(Point, Point).into_styled(stroke).draw(display)
//   C:      fb_hline(x, y, w, on)
func hline(d *ssd1306.Device, x, y, w int, color ssd1306.Color) {
    for col := x; col < x+w; col++ {
        d.SetPixel(int16(col), int16(y), color)
    }
}

// drawString renders a string using tinyfont at pixel position (x, y).
// y is the baseline position — tinyfont renders upward from baseline.
//
// inverted:
//   false = white text on black background (normal)
//   true  = black text on white background (alert/title bar)
//
// Comparison:
//   Rust:   Text::with_baseline(str, Point, MonoTextStyleBuilder...build())
//           MonoTextStyleBuilder sets text_color + background_color
//   C:      fb_draw_str(x, y, str, inverted)
//           Manual font table lookup, XOR 0xFF for inversion
//   TinyGo: tinyfont.WriteLine() — same font table approach as C
//           but packaged as a library rather than embedded in display.c
//
// tinyfont note:
//   tinyfont.WriteLine renders at pixel-level using SetPixel callbacks.
//   It does NOT allocate. The string is iterated as-is.
//   This is the zero-allocation equivalent for glyph rendering —
//   the fmt.Sprintf() calls above this layer are where allocation happens.
func drawString(d *ssd1306.Device, x, y int, text string, inverted bool) {
    fg := colorOn
    bg := colorOff

    if inverted {
        fg = colorOff
        bg = colorOn
    }

    // tinyfont.WriteLine signature:
    //   func WriteLine(display Displayer, font *tinyfont.Font,
    //                  x, y int16, str string, c color.RGBA)
    //
    // We use freemono.Regular7pt — a monospace font close to the
    // 6×8 font used in display.c and FONT_6X10 in display.rs.
    // The Displayer interface requires SetPixel(x, y int16, c color.RGBA)
    // which ssd1306.Device satisfies.

    // Fill background rectangle first if inverted so the
    // background color shows behind the glyphs.
    // Measure text width: freemono.Regular7pt is 6px per char
    if inverted {
        textW := len(text) * 6
        fillRect(d, x, y-7, textW, 8, colorOn)
    }

    tinyfont.WriteLine(
        d,
        &freemono.Regular7pt7b,
        int16(x),
        int16(y),
        text,
        fg.RGBA(),
    )
}

// ── WiFi IP helper ─────────────────────────────────────────────────────────
// Forward reference to wifi package — avoids import cycle.
// In a full implementation use a shared package-level var or
// pass the IP string into OLEDTask as a func() string parameter
// the same way getState is passed.

// wifi_GetIP is a shim — replace with an actual import or
// pass ip as a parameter to OLEDTask for cleaner architecture.
// Shown as a variable here to avoid import cycle in the example.
var wifi_GetIP = func() string {
    return "192.168.1.x"
}

// SetWifiIP updates the IP string shown on the status page.
// Call this from wifi.Connect() after obtaining a DHCP address.
//
// Usage in wifi/wifi.go:
//   display.SetWifiIP(ip)
func SetWifiIP(ip string) {
    wifi_GetIP = func() string { return ip }
}

// ── Utility ────────────────────────────────────────────────────────────────

func min(a, b int) int {
    if a < b {
        return a
    }
    return b
}

Enter fullscreen mode Exit fullscreen mode

display.h| display.c Implementation

// display.h
#ifndef DISPLAY_H
#define DISPLAY_H

#include <stdbool.h>
#include <stdint.h>
#include "hardware/i2c.h"
#include "sensors.h"

// ── Pin assignments ────────────────────────────────────────────────────────
#define DISPLAY_I2C_PORT    i2c0
#define DISPLAY_I2C_SDA     6
#define DISPLAY_I2C_SCL     7
#define DISPLAY_I2C_FREQ    400000   // 400kHz fast mode
#define DISPLAY_I2C_ADDR    0x3C     // try 0x3D if this doesn't work

// ── Screen dimensions ──────────────────────────────────────────────────────
#define SCREEN_W            128
#define SCREEN_H            64
#define SCREEN_PAGES        8        // 64px / 8px per page = 8 i2c pages
                                    // (SSD1306 "page" = 8 pixel rows)

// ── Font dimensions (6x8 built-in SSD1306 font) ───────────────────────────
#define FONT_W              6
#define FONT_H              8
#define CHARS_PER_ROW       21       // 128 / 6 = 21 chars
#define ROWS_PER_SCREEN     8        // 64  / 8 = 8 rows

// ── Row Y positions (pixel rows, multiples of 8) ──────────────────────────
#define ROW_0               0
#define ROW_1               8
#define ROW_2               16
#define ROW_3               24
#define ROW_4               32
#define ROW_5               40
#define ROW_6               48
#define ROW_7               56

// ── Display pages (same two-page model as display.rs) ─────────────────────
typedef enum {
   PAGE_SENSORS = 0,   // water, temp, humidity, alarm
   PAGE_STATUS  = 1,   // motion, CO bar graph, network
   PAGE_COUNT   = 2,
} DisplayPage;

// ── SSD1306 commands ───────────────────────────────────────────────────────
// Subset of the SSD1306 command set used in initialization and rendering.
// Full datasheet: https://cdn-shop.adafruit.com/datasheets/SSD1306.pdf

#define SSD1306_CMD_DISPLAY_OFF         0xAE
#define SSD1306_CMD_DISPLAY_ON          0xAF
#define SSD1306_CMD_SET_CONTRAST        0x81
#define SSD1306_CMD_ENTIRE_ON           0xA5
#define SSD1306_CMD_ENTIRE_RESUME       0xA4
#define SSD1306_CMD_NORMAL_DISPLAY      0xA6
#define SSD1306_CMD_INVERT_DISPLAY      0xA7
#define SSD1306_CMD_SET_MEM_MODE        0x20
#define SSD1306_CMD_SET_COL_ADDR        0x21
#define SSD1306_CMD_SET_PAGE_ADDR       0x22
#define SSD1306_CMD_SET_DISP_START_LINE 0x40
#define SSD1306_CMD_SET_SEG_REMAP       0xA0
#define SSD1306_CMD_SET_MUX_RATIO       0xA8
#define SSD1306_CMD_COM_OUT_SCAN_DIR    0xC0
#define SSD1306_CMD_SET_DISP_OFFSET     0xD3
#define SSD1306_CMD_SET_COM_PIN_CFG     0xDA
#define SSD1306_CMD_SET_DISP_CLK_DIV   0xD5
#define SSD1306_CMD_SET_PRECHARGE       0xD9
#define SSD1306_CMD_SET_VCOM_DESEL      0xDB
#define SSD1306_CMD_CHARGE_PUMP         0x8D
#define SSD1306_CMD_NOP                 0xE3

// ── Framebuffer ───────────────────────────────────────────────────────────
// 128 × 64 pixels = 8192 bits = 1024 bytes.
// Organized as 8 pages × 128 columns.
// Each byte represents 8 vertical pixels in one column of one page.
// Bit 0 = top pixel of the byte, bit 7 = bottom pixel.
//
// This mirrors the Rust implementation's BufferedGraphicsMode —
// both keep a full framebuffer in RAM and flush to the display
// in one I2C transaction. The C version manages this buffer manually.
// Rust's ssd1306 crate manages it internally with the same layout.
extern uint8_t g_framebuffer[SCREEN_H / 8][SCREEN_W];

// ── Public API ─────────────────────────────────────────────────────────────

// Initialization
bool     display_init(void);

// Main poll function — call from main loop every iteration.
// Handles page rotation timing and refresh cadence internally.
// Never blocks — returns immediately if it's not time to refresh.
void     display_poll(void);

// Force an immediate refresh with the given state snapshot.
// Used when you want to update the display outside the normal cadence
// (e.g. immediately after a water detection event).
void     display_force_update(const SensorState *s);

// Show a full-screen error message. Blocks until next display_poll() cycle.
// message: up to 42 characters, word-wrapped across two lines.
void     display_error(const char *message);

// ── Internal rendering functions (exposed for testing) ────────────────────
void     display_draw_sensors_page(const SensorState *s, uint32_t tick);
void     display_draw_status_page(const SensorState *s);
void     display_draw_splash(void);

#endif // DISPLAY_H

Enter fullscreen mode Exit fullscreen mode
// display.c
//
// SSD1306 128x64 OLED display driver over I2C.
// Implements the same two-page rotating UI as display.rs:
//
//   Page 0 (Sensors): water, temperature, humidity, alarm
//   Page 1 (Status):  motion, CO bar graph, network IP
//
// Architecture comparison:
//
//   Rust: embedded-graphics crate provides Point, Rectangle, Text primitives.
//         ssd1306 crate manages the framebuffer and flush.
//         heapless::String for zero-allocation string formatting.
//         Compiler enforces I2C peripheral exclusive ownership.
//
//   C:    We implement the framebuffer, font rendering, and primitives
//         entirely from scratch. No external library required beyond
//         the Pico SDK's hardware/i2c.h.
//         snprintf() for string formatting — stack allocated, no heap.
//         Nothing prevents two functions from using I2C simultaneously.
//         The programmer enforces this by convention.
//
//   TinyGo: tinygo/x/drivers/ssd1306 package — similar to Rust's crate.
//           fmt.Sprintf() allocates on the heap — triggers GC.
//           Goroutine owns the display — scheduler enforces single access.

#include "display.h"
#include "sensors.h"
#include "pico/stdlib.h"
#include "hardware/i2c.h"
#include <stdio.h>
#include <string.h>
#include <stdarg.h>

// ── Framebuffer ────────────────────────────────────────────────────────────
// 1024 bytes live in RAM for the duration of the program.
// On a Pico W with 264KB RAM this is negligible.
// On a smaller AVR (2KB RAM) this would be the majority of available memory —
// one reason SSD1306 is typically used with larger MCUs.
uint8_t g_framebuffer[SCREEN_H / 8][SCREEN_W];

// ── Built-in 6×8 font ─────────────────────────────────────────────────────
// Each character is 6 bytes wide × 8 bits tall.
// Bit layout: each byte is one column, bit 0 = top pixel.
// This is the standard SSD1306 font used in most embedded C drivers.
// 96 printable ASCII characters starting at 0x20 (space).
//
// In Rust: embedded-graphics provides FONT_6X10, FONT_9X15_BOLD etc.
//          as compile-time const data — same idea, more options.
// In TinyGo: tinygo/x/drivers uses the same font table internally.
// In C: we embed it directly — full control, no dependency.

static const uint8_t FONT_6X8[96][6] = {
    {0x00,0x00,0x00,0x00,0x00,0x00}, // 0x20 space
    {0x00,0x00,0x5F,0x00,0x00,0x00}, // 0x21 !
    {0x00,0x07,0x00,0x07,0x00,0x00}, // 0x22 "
    {0x14,0x7F,0x14,0x7F,0x14,0x00}, // 0x23 #
    {0x24,0x2A,0x7F,0x2A,0x12,0x00}, // 0x24 $
    {0x23,0x13,0x08,0x64,0x62,0x00}, // 0x25 %
    {0x36,0x49,0x55,0x22,0x50,0x00}, // 0x26 &
    {0x00,0x05,0x03,0x00,0x00,0x00}, // 0x27 '
    {0x00,0x1C,0x22,0x41,0x00,0x00}, // 0x28 (
    {0x00,0x41,0x22,0x1C,0x00,0x00}, // 0x29 )
    {0x08,0x2A,0x1C,0x2A,0x08,0x00}, // 0x2A *
    {0x08,0x08,0x3E,0x08,0x08,0x00}, // 0x2B +
    {0x00,0x50,0x30,0x00,0x00,0x00}, // 0x2C ,
    {0x08,0x08,0x08,0x08,0x08,0x00}, // 0x2D -
    {0x00,0x60,0x60,0x00,0x00,0x00}, // 0x2E .
    {0x20,0x10,0x08,0x04,0x02,0x00}, // 0x2F /
    {0x3E,0x51,0x49,0x45,0x3E,0x00}, // 0x30 0
    {0x00,0x42,0x7F,0x40,0x00,0x00}, // 0x31 1
    {0x42,0x61,0x51,0x49,0x46,0x00}, // 0x32 2
    {0x21,0x41,0x45,0x4B,0x31,0x00}, // 0x33 3
    {0x18,0x14,0x12,0x7F,0x10,0x00}, // 0x34 4
    {0x27,0x45,0x45,0x45,0x39,0x00}, // 0x35 5
    {0x3C,0x4A,0x49,0x49,0x30,0x00}, // 0x36 6
    {0x01,0x71,0x09,0x05,0x03,0x00}, // 0x37 7
    {0x36,0x49,0x49,0x49,0x36,0x00}, // 0x38 8
    {0x06,0x49,0x49,0x29,0x1E,0x00}, // 0x39 9
    {0x00,0x36,0x36,0x00,0x00,0x00}, // 0x3A :
    {0x00,0x56,0x36,0x00,0x00,0x00}, // 0x3B ;
    {0x00,0x08,0x14,0x22,0x41,0x00}, // 0x3C <
    {0x14,0x14,0x14,0x14,0x14,0x00}, // 0x3D =
    {0x41,0x22,0x14,0x08,0x00,0x00}, // 0x3E >
    {0x02,0x01,0x51,0x09,0x06,0x00}, // 0x3F ?
    {0x32,0x49,0x79,0x41,0x3E,0x00}, // 0x40 @
    {0x7E,0x11,0x11,0x11,0x7E,0x00}, // 0x41 A
    {0x7F,0x49,0x49,0x49,0x36,0x00}, // 0x42 B
    {0x3E,0x41,0x41,0x41,0x22,0x00}, // 0x43 C
    {0x7F,0x41,0x41,0x22,0x1C,0x00}, // 0x44 D
    {0x7F,0x49,0x49,0x49,0x41,0x00}, // 0x45 E
    {0x7F,0x09,0x09,0x01,0x01,0x00}, // 0x46 F
    {0x3E,0x41,0x41,0x49,0x7A,0x00}, // 0x47 G
    {0x7F,0x08,0x08,0x08,0x7F,0x00}, // 0x48 H
    {0x00,0x41,0x7F,0x41,0x00,0x00}, // 0x49 I
    {0x20,0x40,0x41,0x3F,0x01,0x00}, // 0x4A J
    {0x7F,0x08,0x14,0x22,0x41,0x00}, // 0x4B K
    {0x7F,0x40,0x40,0x40,0x40,0x00}, // 0x4C L
    {0x7F,0x02,0x04,0x02,0x7F,0x00}, // 0x4D M
    {0x7F,0x04,0x08,0x10,0x7F,0x00}, // 0x4E N
    {0x3E,0x41,0x41,0x41,0x3E,0x00}, // 0x4F O
    {0x7F,0x09,0x09,0x09,0x06,0x00}, // 0x50 P
    {0x3E,0x41,0x51,0x21,0x5E,0x00}, // 0x51 Q
    {0x7F,0x09,0x19,0x29,0x46,0x00}, // 0x52 R
    {0x46,0x49,0x49,0x49,0x31,0x00}, // 0x53 S
    {0x01,0x01,0x7F,0x01,0x01,0x00}, // 0x54 T
    {0x3F,0x40,0x40,0x40,0x3F,0x00}, // 0x55 U
    {0x1F,0x20,0x40,0x20,0x1F,0x00}, // 0x56 V
    {0x3F,0x40,0x38,0x40,0x3F,0x00}, // 0x57 W
    {0x63,0x14,0x08,0x14,0x63,0x00}, // 0x58 X
    {0x03,0x04,0x78,0x04,0x03,0x00}, // 0x59 Y
    {0x61,0x51,0x49,0x45,0x43,0x00}, // 0x5A Z
    {0x00,0x00,0x7F,0x41,0x41,0x00}, // 0x5B [
    {0x02,0x04,0x08,0x10,0x20,0x00}, // 0x5C backslash
    {0x41,0x41,0x7F,0x00,0x00,0x00}, // 0x5D ]
    {0x04,0x02,0x01,0x02,0x04,0x00}, // 0x5E ^
    {0x40,0x40,0x40,0x40,0x40,0x00}, // 0x5F _
    {0x00,0x01,0x02,0x04,0x00,0x00}, // 0x60 `
    {0x20,0x54,0x54,0x54,0x78,0x00}, // 0x61 a
    {0x7F,0x48,0x44,0x44,0x38,0x00}, // 0x62 b
    {0x38,0x44,0x44,0x44,0x20,0x00}, // 0x63 c
    {0x38,0x44,0x44,0x48,0x7F,0x00}, // 0x64 d
    {0x38,0x54,0x54,0x54,0x18,0x00}, // 0x65 e
    {0x08,0x7E,0x09,0x01,0x02,0x00}, // 0x66 f
    {0x08,0x54,0x54,0x54,0x3C,0x00}, // 0x67 g
    {0x7F,0x08,0x04,0x04,0x78,0x00}, // 0x68 h
    {0x00,0x44,0x7D,0x40,0x00,0x00}, // 0x69 i
    {0x20,0x40,0x44,0x3D,0x00,0x00}, // 0x6A j
    {0x00,0x7F,0x10,0x28,0x44,0x00}, // 0x6B k
    {0x00,0x41,0x7F,0x40,0x00,0x00}, // 0x6C l
    {0x7C,0x04,0x18,0x04,0x78,0x00}, // 0x6D m
    {0x7C,0x08,0x04,0x04,0x78,0x00}, // 0x6E n
    {0x38,0x44,0x44,0x44,0x38,0x00}, // 0x6F o
    {0x7C,0x14,0x14,0x14,0x08,0x00}, // 0x70 p
    {0x08,0x14,0x14,0x18,0x7C,0x00}, // 0x71 q
    {0x7C,0x08,0x04,0x04,0x08,0x00}, // 0x72 r
    {0x48,0x54,0x54,0x54,0x20,0x00}, // 0x73 s
    {0x04,0x3F,0x44,0x40,0x20,0x00}, // 0x74 t
    {0x3C,0x40,0x40,0x40,0x3C,0x00}, // 0x75 u
    {0x1C,0x20,0x40,0x20,0x1C,0x00}, // 0x76 v
    {0x3C,0x40,0x30,0x40,0x3C,0x00}, // 0x77 w
    {0x44,0x28,0x10,0x28,0x44,0x00}, // 0x78 x
    {0x0C,0x50,0x50,0x50,0x3C,0x00}, // 0x79 y
    {0x44,0x64,0x54,0x4C,0x44,0x00}, // 0x7A z
    {0x00,0x08,0x36,0x41,0x00,0x00}, // 0x7B {
    {0x00,0x00,0x7F,0x00,0x00,0x00}, // 0x7C |
    {0x00,0x41,0x36,0x08,0x00,0x00}, // 0x7D }
    {0x08,0x08,0x2A,0x1C,0x08,0x00}, // 0x7E ~
    {0x00,0x06,0x09,0x09,0x06,0x00}, // 0x7F DEG (degree symbol)
};

// ── Page rotation state ────────────────────────────────────────────────────
// In Rust this state lives inside the async task's stack frame —
// the task function is a state machine that preserves locals across .await.
// In C we use static locals — same effect, but global scope within the TU.

static DisplayPage g_current_page     = PAGE_SENSORS;
static uint32_t    g_page_ticks       = 0;
static uint32_t    g_tick             = 0;
static uint32_t    g_next_refresh_ms  = 0;

// Page rotation interval: 5000ms / 100ms per tick = 50 ticks
#define PAGE_TICKS  50

// ── Low-level I2C helpers ──────────────────────────────────────────────────

// Send a single command byte to the SSD1306.
// The 0x00 control byte tells SSD1306 the next byte is a command.
static void ssd1306_cmd(uint8_t cmd) {
    uint8_t buf[2] = {0x00, cmd};
    i2c_write_blocking(
        DISPLAY_I2C_PORT,
        DISPLAY_I2C_ADDR,
        buf, 2,
        false
    );
}

// Send two command bytes (command + argument).
static void ssd1306_cmd2(uint8_t cmd, uint8_t arg) {
    uint8_t buf[3] = {0x00, cmd, arg};
    i2c_write_blocking(
        DISPLAY_I2C_PORT,
        DISPLAY_I2C_ADDR,
        buf, 3,
        false
    );
}

// Flush one page (8 pixel rows) of the framebuffer to the display.
// The 0x40 control byte tells SSD1306 the following bytes are pixel data.
static void ssd1306_flush_page(uint8_t page) {
    // Set cursor to start of this page
    ssd1306_cmd2(SSD1306_CMD_SET_PAGE_ADDR, page);
    ssd1306_cmd2(SSD1306_CMD_SET_COL_ADDR,  0);

    // Prepend the 0x40 data control byte
    uint8_t buf[SCREEN_W + 1];
    buf[0] = 0x40;
    memcpy(buf + 1, g_framebuffer[page], SCREEN_W);

    i2c_write_blocking(
        DISPLAY_I2C_PORT,
        DISPLAY_I2C_ADDR,
        buf, sizeof(buf),
        false
    );
}

// Flush entire framebuffer to display — all 8 pages.
// Takes ~2ms at 400kHz I2C (1025 bytes × 8 pages).
// Identical cost to Rust's display.flush() and TinyGo's display.Display().
static void ssd1306_flush_all(void) {
    // Set horizontal addressing mode — auto-increments column then page
    ssd1306_cmd2(SSD1306_CMD_SET_MEM_MODE, 0x00);
    ssd1306_cmd2(SSD1306_CMD_SET_COL_ADDR, 0);
    ssd1306_cmd(0x7F);  // col end = 127
    ssd1306_cmd2(SSD1306_CMD_SET_PAGE_ADDR, 0);
    ssd1306_cmd(0x07);  // page end = 7

    // Send all pixel data in one transaction
    uint8_t buf[SCREEN_W * SCREEN_PAGES + 1];
    buf[0] = 0x40;
    for (int p = 0; p < SCREEN_PAGES; p++) {
        memcpy(buf + 1 + p * SCREEN_W, g_framebuffer[p], SCREEN_W);
    }

    i2c_write_blocking(
        DISPLAY_I2C_PORT,
        DISPLAY_I2C_ADDR,
        buf, sizeof(buf),
        false
    );
}

// ── Framebuffer primitives ─────────────────────────────────────────────────
// These implement the same operations as embedded-graphics in Rust.
// In Rust: Rectangle::new().into_styled().draw(&mut display)
// In C: fb_fill_rect() — direct framebuffer manipulation.
//
// The SSD1306 framebuffer layout:
//   g_framebuffer[page][col]
//   page = y / 8  (which group of 8 pixel rows)
//   col  = x      (which column 0-127)
//   bit  = y % 8  (which pixel within the page byte)

// Set or clear a single pixel in the framebuffer.
static void fb_set_pixel(int x, int y, bool on) {
    if (x < 0 || x >= SCREEN_W || y < 0 || y >= SCREEN_H) return;
    uint8_t page = y / 8;
    uint8_t bit  = y % 8;
    if (on) {
        g_framebuffer[page][x] |=  (1 << bit);
    } else {
        g_framebuffer[page][x] &= ~(1 << bit);
    }
}

// Fill a rectangle with on or off pixels.
// Equivalent to:
//   Rust: Rectangle::new(Point, Size).into_styled(fill_style).draw()
//   TinyGo: no direct primitive — would loop pixels
static void fb_fill_rect(int x, int y, int w, int h, bool on) {
    for (int row = y; row < y + h; row++) {
        for (int col = x; col < x + w; col++) {
            fb_set_pixel(col, row, on);
        }
    }
}

// Draw a horizontal line — optimized vs fb_fill_rect for single-pixel height.
static void fb_hline(int x, int y, int w, bool on) {
    for (int col = x; col < x + w; col++) {
        fb_set_pixel(col, y, on);
    }
}

// Clear the entire framebuffer (all pixels off).
static void fb_clear(void) {
    memset(g_framebuffer, 0, sizeof(g_framebuffer));
}

// ── Character and string rendering ────────────────────────────────────────

// Draw a single character at pixel position (x, y).
// inverted: true = black text on white background (alert style)
//           false = white text on black background (normal style)
//
// In Rust: MonoTextStyleBuilder with text_color / background_color
// In TinyGo: ssd1306 driver handles this internally
// In C: we XOR each font byte against 0xFF to invert
static void fb_draw_char(int x, int y, char c, bool inverted) {
    if (c < 0x20 || c > 0x7F) c = '?';
    const uint8_t *glyph = FONT_6X8[c - 0x20];

    for (int col = 0; col < 6; col++) {
        uint8_t column_data = glyph[col];
        if (inverted) column_data = ~column_data;

        for (int row = 0; row < 8; row++) {
            fb_set_pixel(x + col, y + row, (column_data >> row) & 1);
        }
    }
}

// Draw a string at pixel position (x, y).
// Clips at screen edge — no wrapping.
static void fb_draw_str(int x, int y, const char *str, bool inverted) {
    int cx = x;
    while (*str && cx + FONT_W <= SCREEN_W) {
        fb_draw_char(cx, y, *str, inverted);
        cx  += FONT_W;
        str ++;
    }
}

// Draw a formatted string — thin wrapper around snprintf + fb_draw_str.
// buf_size should be at least CHARS_PER_ROW + 1.
//
// In Rust: write!(heapless::String, format_args!(...)) — zero allocation
// In C:    snprintf into stack buffer — zero heap allocation, same safety
//          profile for bounded sizes. UB if format produces > buf_size chars.
static void fb_draw_fmt(int x, int y, bool inverted,
                        char *buf, size_t buf_size,
                        const char *fmt, ...) {
    va_list args;
    va_start(args, fmt);
    vsnprintf(buf, buf_size, fmt, args);
    va_end(args);
    fb_draw_str(x, y, buf, inverted);
}

// Draw a filled inverted title bar spanning the full screen width.
// Equivalent to the Rectangle + inverted text pattern in display.rs.
static void fb_draw_title_bar(const char *title) {
    fb_fill_rect(0, ROW_0, SCREEN_W, FONT_H, true);  // white bar
    fb_draw_str(2, ROW_0, title, true);               // black text on white
}

// Draw page indicator dots — same logic as display.rs draw_page_indicator().
// Filled square = current page. Outline square = other page.
// Positioned at bottom-right of screen.
static void fb_draw_page_indicator(uint8_t current, uint8_t total) {
    int dot_size    = 4;
    int dot_spacing = 8;
    int start_x     = SCREEN_W - (total * dot_spacing) - 4;
    int y           = SCREEN_H - 6;

    for (int i = 0; i < total; i++) {
        int x = start_x + i * dot_spacing;
        if (i == current) {
            // Filled square = current page
            fb_fill_rect(x, y, dot_size, dot_size, true);
        } else {
            // Outline square = other page
            // Draw 4 sides of the rectangle manually
            fb_hline(x,               y,               dot_size, true); // top
            fb_hline(x,               y + dot_size - 1, dot_size, true); // bottom
            for (int row = y; row < y + dot_size; row++) {
                fb_set_pixel(x,               row, true); // left
                fb_set_pixel(x + dot_size - 1, row, true); // right
            }
        }
    }
}

// Draw a CO bar graph.
// Identical to the Rectangle bar graph in display.rs draw_status_page().
// max_val: the PPM value that represents a full bar.
// bar spans columns 2-125 (124px wide).
static void fb_draw_bar(int y, uint16_t value, uint16_t max_val) {
    int bar_max_w = 124;
    int bar_w     = (int)((uint32_t)value * bar_max_w / max_val);
    if (bar_w > bar_max_w) bar_w = bar_max_w;

    // Outline
    for (int col = 2; col < 2 + bar_max_w; col++) {
        fb_set_pixel(col, y,     true);  // top edge
        fb_set_pixel(col, y + 7, true);  // bottom edge
    }
    fb_set_pixel(2,               y, true);
    fb_set_pixel(2,               y + 7, true);
    for (int row = y; row <= y + 7; row++) {
        fb_set_pixel(2,                row, true);  // left edge
        fb_set_pixel(2 + bar_max_w - 1, row, true); // right edge
    }

    // Fill
    if (bar_w > 0) {
        fb_fill_rect(2, y, bar_w, 8, true);
    }
}

// ── SSD1306 initialization sequence ───────────────────────────────────────
// This sequence follows the SSD1306 application note exactly.
// Same initialization in all three languages — C makes it explicit,
// Rust's ssd1306 crate does it inside init(), TinyGo's driver does the same.

static bool ssd1306_init_sequence(void) {
    // Test I2C connectivity with a zero-length write
    int result = i2c_write_blocking(
        DISPLAY_I2C_PORT,
        DISPLAY_I2C_ADDR,
        NULL, 0,
        false
    );
    if (result == PICO_ERROR_GENERIC) {
        printf("[OLED ] No device at 0x%02X — check wiring\n",
               DISPLAY_I2C_ADDR);
        return false;
    }

    ssd1306_cmd(SSD1306_CMD_DISPLAY_OFF);
    ssd1306_cmd2(SSD1306_CMD_SET_DISP_CLK_DIV,   0x80);
    ssd1306_cmd2(SSD1306_CMD_SET_MUX_RATIO,       0x3F); // 64MUX
    ssd1306_cmd2(SSD1306_CMD_SET_DISP_OFFSET,     0x00);
    ssd1306_cmd(SSD1306_CMD_SET_DISP_START_LINE | 0x00);
    ssd1306_cmd2(SSD1306_CMD_CHARGE_PUMP,          0x14); // enable charge pump
    ssd1306_cmd2(SSD1306_CMD_SET_MEM_MODE,         0x00); // horizontal addressing
    ssd1306_cmd(SSD1306_CMD_SET_SEG_REMAP       | 0x01); // mirror horizontally
    ssd1306_cmd(SSD1306_CMD_COM_OUT_SCAN_DIR    | 0x08); // scan top to bottom
    ssd1306_cmd2(SSD1306_CMD_SET_COM_PIN_CFG,      0x12);
    ssd1306_cmd2(SSD1306_CMD_SET_CONTRAST,         0xCF);
    ssd1306_cmd2(SSD1306_CMD_SET_PRECHARGE,        0xF1);
    ssd1306_cmd2(SSD1306_CMD_SET_VCOM_DESEL,       0x40);
    ssd1306_cmd(SSD1306_CMD_ENTIRE_RESUME);              // use RAM content
    ssd1306_cmd(SSD1306_CMD_NORMAL_DISPLAY);             // not inverted
    ssd1306_cmd(SSD1306_CMD_DISPLAY_ON);

    return true;
}

// ── Page renderers ─────────────────────────────────────────────────────────

// Page 0 — Sensors
//
// ┌────────────────────────┐
// │ BASEMENT MONITOR       │  ← inverted title bar
// │ Water: DRY             │  ← flashes when wet
// │ Temp:  21.5 C          │
// │ Humid: 62.0 %          │  ← inverted when > 85%
// │ Alarm: OFF             │  ← inverted when active
// │                  • ○   │  ← page indicator
// └────────────────────────┘

void display_draw_sensors_page(const SensorState *s, uint32_t tick) {
    char buf[CHARS_PER_ROW + 2];

    // ── Title bar ──────────────────────────────────────────────────────
    fb_draw_title_bar("BASEMENT MONITOR");

    // ── Water status ───────────────────────────────────────────────────
    // Flash every other tick (100ms period) when water detected.
    // Equivalent to Rust's tick % 2 == 0 flashing logic.
    if (s->water_detected) {
        bool flash_on = (tick % 2 == 0);
        if (flash_on) {
            fb_fill_rect(0, ROW_1, SCREEN_W, FONT_H, true);
        }
        fb_draw_str(2, ROW_1, "Water: !! WET !!", flash_on);
    } else {
        fb_draw_str(2, ROW_1, "Water: DRY", false);
    }

    // ── Temperature ────────────────────────────────────────────────────
    // snprintf into stack buffer — same zero-heap approach as
    // Rust's heapless::String + write!() macro
    snprintf(buf, sizeof(buf), "Temp:  %.1f C", s->temperature_c);
    fb_draw_str(2, ROW_2, buf, false);

    // ── Humidity ───────────────────────────────────────────────────────
    snprintf(buf, sizeof(buf), "Humid: %.1f %%", s->humidity_pct);
    bool hum_warn = s->humidity_pct > 85.0f;
    if (hum_warn) {
        fb_fill_rect(0, ROW_3, SCREEN_W, FONT_H, true);
    }
    fb_draw_str(2, ROW_3, buf, hum_warn);

    // ── Alarm status ───────────────────────────────────────────────────
    if (s->alarm_active) {
        fb_fill_rect(0, ROW_4, SCREEN_W, FONT_H, true);
        fb_draw_str(2, ROW_4, "Alarm: ACTIVE", true);
    } else {
        fb_draw_str(2, ROW_4, "Alarm: OFF", false);
    }

    // ── Page indicator ─────────────────────────────────────────────────
    fb_draw_page_indicator(PAGE_SENSORS, PAGE_COUNT);
}

// Page 1 — Status
//
// ┌────────────────────────┐
// │ STATUS                 │  ← inverted title bar
// │ Motion: CLEAR          │
// │ CO:    12 ppm  OK      │  ← inverted entire row when DANGER
// │ [=======    ]          │  ← CO bar graph 0-300ppm
// │ Net: 192.168.1.42      │
// │                  ○ •   │  ← page indicator
// └────────────────────────┘

void display_draw_status_page(const SensorState *s) {
    char buf[CHARS_PER_ROW + 2];

    // ── Title bar ──────────────────────────────────────────────────────
    fb_draw_title_bar("STATUS");

    // ── Motion ─────────────────────────────────────────────────────────
    fb_draw_str(2, ROW_1,
        s->motion_detected ? "Motion: DETECTED" : "Motion: CLEAR",
        false);

    // ── CO level + severity ────────────────────────────────────────────
    const char *severity;
    bool co_danger = false;
    bool co_warn   = false;

    if (s->co_ppm >= 150) {
        severity  = "DANGER";
        co_danger = true;
    } else if (s->co_ppm >= 50) {
        severity = "WARN";
        co_warn  = true;
    } else {
        severity = "OK";
    }

    snprintf(buf, sizeof(buf), "CO: %u ppm %s", s->co_ppm, severity);

    if (co_danger) {
        fb_fill_rect(0, ROW_2, SCREEN_W, FONT_H, true);
    }
    fb_draw_str(2, ROW_2, buf, co_danger);

    // ── CO bar graph ───────────────────────────────────────────────────
    // 0–300 ppm range, 124px wide
    // Equivalent to the bar graph Rectangle in display.rs
    fb_draw_bar(ROW_3, s->co_ppm, 300);

    // ── Network IP ─────────────────────────────────────────────────────
    // In a full implementation retrieve actual IP from wifi module.
    // Stored as a static string updated by wifi_task on connect.
    extern char g_ip_address[16]; // defined in wifi.c
    snprintf(buf, sizeof(buf), "Net: %s", g_ip_address);
    fb_draw_str(2, ROW_4, buf, false);

    // ── Page indicator ─────────────────────────────────────────────────
    fb_draw_page_indicator(PAGE_STATUS, PAGE_COUNT);
}

// Startup splash screen — shown for 2 seconds at boot.
// Mirrors display.rs draw_splash().
//
// ┌────────────────────────┐
// │                        │
// │      Basement          │
// │       Monitor          │
// │ ────────────────────── │
// │    C + Pico SDK        │
// └────────────────────────┘

void display_draw_splash(void) {
    fb_clear();

    fb_draw_str(16, ROW_1, "Basement", false);
    fb_draw_str(22, ROW_3, "Monitor", false);

    // Horizontal divider at ROW_5
    fb_hline(0, ROW_5, SCREEN_W, true);

    fb_draw_str(10, ROW_6, "C + Pico SDK", false);

    ssd1306_flush_all();
}

// ── Public API implementation ──────────────────────────────────────────────

bool display_init(void) {
    // Initialize I2C hardware
    i2c_init(DISPLAY_I2C_PORT, DISPLAY_I2C_FREQ);

    gpio_set_function(DISPLAY_I2C_SDA, GPIO_FUNC_I2C);
    gpio_set_function(DISPLAY_I2C_SCL, GPIO_FUNC_I2C);
    gpio_pull_up(DISPLAY_I2C_SDA);
    gpio_pull_up(DISPLAY_I2C_SCL);

    // Clear framebuffer before init
    fb_clear();

    // Send SSD1306 init sequence
    if (!ssd1306_init_sequence()) {
        return false;
    }

    // Flush blank framebuffer to clear any garbage on screen
    ssd1306_flush_all();

    printf("[OLED ] Display initialized at I2C addr 0x%02X\n",
           DISPLAY_I2C_ADDR);

    // Show splash for 2 seconds
    // In C this blocks — other poll functions stop during splash.
    // In Rust the oled_task sleeps async — others continue.
    // In TinyGo the goroutine sleeps — others continue.
    // This is a minor difference acceptable at startup.
    display_draw_splash();
    sleep_ms(2000);

    return true;
}

void display_poll(void) {
    uint32_t now = to_ms_since_boot(get_absolute_time());

    // Refresh every 100ms — matches Rust's Timer::after(100ms)
    if (now < g_next_refresh_ms) return;
    g_next_refresh_ms = now + 100;

    // Snapshot state — same pattern as all three implementations
    state_lock();
    SensorState s = g_state;
    state_unlock();

    fb_clear();

    switch (g_current_page) {
        case PAGE_SENSORS:
            display_draw_sensors_page(&s, g_tick);
            break;
        case PAGE_STATUS:
            display_draw_status_page(&s);
            break;
        default:
            break;
    }

    ssd1306_flush_all();

    // Rotate page every PAGE_TICKS ticks
    g_page_ticks++;
    if (g_page_ticks >= PAGE_TICKS) {
        g_page_ticks  = 0;
        g_current_page = (g_current_page == PAGE_SENSORS)
                       ? PAGE_STATUS
                       : PAGE_SENSORS;
    }

    g_tick++;
}

void display_force_update(const SensorState *s) {
    fb_clear();

    switch (g_current_page) {
        case PAGE_SENSORS:
            display_draw_sensors_page(s, g_tick);
            break;
        case PAGE_STATUS:
            display_draw_status_page(s);
            break;
    }

    ssd1306_flush_all();
}

void display_error(const char *message) {
    fb_clear();

    // Full screen inverted box
    fb_fill_rect(0, 0, SCREEN_W, SCREEN_H, true);

    // "ERROR" title in black-on-white
    fb_draw_str(40, ROW_1, "ERROR", true);

    // Horizontal divider
    fb_hline(0, ROW_3, SCREEN_W, false); // black line on white background

    // Word-wrap message across two lines
    // Line 1: chars 0-20, Line 2: chars 21-41
    char line1[CHARS_PER_ROW + 1] = {0};
    char line2[CHARS_PER_ROW + 1] = {0};

    size_t msg_len = strlen(message);
    size_t l1_len  = msg_len < CHARS_PER_ROW ? msg_len : CHARS_PER_ROW;
    size_t l2_len  = msg_len > CHARS_PER_ROW
                   ? (msg_len - CHARS_PER_ROW < CHARS_PER_ROW
                      ? msg_len - CHARS_PER_ROW
                      : CHARS_PER_ROW)
                   : 0;

    strncpy(line1, message,               l1_len);
    strncpy(line2, message + CHARS_PER_ROW, l2_len);

    // Draw black text on white background (inverted = true)
    fb_draw_str(2, ROW_4, line1, true);
    if (l2_len > 0) {
        fb_draw_str(2, ROW_5, line2, true);
    }

    ssd1306_flush_all();

    printf("[OLED ] Error displayed: %s\n", message);
}

Enter fullscreen mode Exit fullscreen mode

display.rs Implementation

// src/display.rs
//
// SSD1306 128x64 OLED display driver over I2C.
//
// Wiring:
//   GPIO6 → SDA
//   GPIO7 → SCL
//   3.3V  → VCC
//   GND   → GND
//   I2C address: 0x3C (most modules) or 0x3D (check your module)
//
// Dependencies in Cargo.toml:
//   ssd1306    = { version = "0.9", features = ["async"] }
//   embedded-graphics = { version = "0.8" }

use core::fmt::Write;

use embassy_rp::i2c::{self, I2c, InterruptHandler};
use embassy_rp::peripherals::{I2C0, PIN_6, PIN_7};
use embassy_rp::bind_interrupts;
use embassy_time::{Duration, Timer};

use embedded_graphics::{
    mono_font::{
        ascii::{FONT_6X10, FONT_6X12, FONT_9X15_BOLD},
        MonoTextStyle,
        MonoTextStyleBuilder,
    },
    pixelcolor::BinaryColor,
    prelude::*,
    primitives::{
        Line, PrimitiveStyle, Rectangle,
    },
    text::{Baseline, Text, TextStyleBuilder},
};

use heapless::String;
use ssd1306::{
    mode::BufferedGraphicsMode,
    prelude::*,
    rotation::DisplayRotation,
    size::DisplaySize128x64,
    I2CDisplayInterface,
    Ssd1306,
};

use defmt::*;

use crate::SensorState;
use crate::STATE;

// ── Type aliases ───────────────────────────────────────────────────────────
// The SSD1306 driver type is deeply generic — alias it so function
// signatures stay readable. This is a common Rust embedded pattern.

type I2cBus    = I2c<'static, I2C0, i2c::Async>;
type OledDisplay = Ssd1306<
    I2CInterface<I2cBus>,
    DisplaySize128x64,
    BufferedGraphicsMode<DisplaySize128x64>,
>;

// ── Text styles ────────────────────────────────────────────────────────────
// Define once, reuse everywhere.
// BinaryColor::On  = white pixel (OLED lit)
// BinaryColor::Off = black pixel (OLED dark)

fn style_normal() -> MonoTextStyle<'static, BinaryColor> {
    MonoTextStyleBuilder::new()
        .font(&FONT_6X10)
        .text_color(BinaryColor::On)
        .build()
}

fn style_bold() -> MonoTextStyle<'static, BinaryColor> {
    MonoTextStyleBuilder::new()
        .font(&FONT_9X15_BOLD)
        .text_color(BinaryColor::On)
        .build()
}

fn style_small() -> MonoTextStyle<'static, BinaryColor> {
    MonoTextStyleBuilder::new()
        .font(&FONT_6X10)
        .text_color(BinaryColor::On)
        .build()
}

fn style_inverted() -> MonoTextStyle<'static, BinaryColor> {
    MonoTextStyleBuilder::new()
        .font(&FONT_6X10)
        .text_color(BinaryColor::Off)
        .background_color(BinaryColor::On)
        .build()
}

// ── Layout constants ───────────────────────────────────────────────────────
// 128 x 64 pixel display
// FONT_6X10:      6px wide, 10px tall — fits 21 chars × 6 rows
// FONT_9X15_BOLD: 9px wide, 15px tall — fits 14 chars × 4 rows

const SCREEN_W: i32 = 128;
const SCREEN_H: i32 = 64;

// Row Y positions for FONT_6X10
const ROW_0: i32 =  0;   // top — title bar
const ROW_1: i32 = 11;
const ROW_2: i32 = 22;
const ROW_3: i32 = 33;
const ROW_4: i32 = 44;
const ROW_5: i32 = 54;   // bottom status line

// ── Display pages ──────────────────────────────────────────────────────────
// We rotate between two pages so all sensor data fits on the small screen.
// Page 0: water, temperature, humidity, alarm status
// Page 1: motion, CO, IP address, uptime

#[derive(Clone, Copy, PartialEq)]
enum Page {
    Sensors,   // page 0
    Status,    // page 1
}

impl Page {
    fn next(self) -> Self {
        match self {
            Page::Sensors => Page::Status,
            Page::Status  => Page::Sensors,
        }
    }
}

// ── OLED task ──────────────────────────────────────────────────────────────

#[embassy_executor::task]
pub async fn oled_task(
    i2c_peripheral: I2C0,
    sda: PIN_6,
    scl: PIN_7,
    irqs: crate::Irqs,
) {
    // ── Initialize I2C bus ─────────────────────────────────────────────
    // 400kHz "fast mode" — standard for SSD1306
    let i2c = I2c::new_async(
        i2c_peripheral,
        scl,
        sda,
        irqs,
        i2c::Config::default(),
    );

    // ── Initialize SSD1306 ─────────────────────────────────────────────
    let interface = I2CDisplayInterface::new(i2c);

    let mut display = Ssd1306::new(
        interface,
        DisplaySize128x64,
        DisplayRotation::Rotate0,
    )
    .into_buffered_graphics_mode();

    // init() sends the initialization command sequence to the OLED.
    // If this fails the display is likely wired incorrectly or the
    // I2C address is wrong (try 0x3D if 0x3C doesn't work).
    match display.init() {
        Ok(_)  => info!("OLED initialized"),
        Err(e) => {
            error!("OLED init failed — check wiring and I2C address");
            // Don't panic — the rest of the system works without the display.
            // Return early from this task only; other tasks continue.
            return;
        }
    }

    display.clear(BinaryColor::Off).ok();
    display.flush().ok();

    // Show a startup splash for 2 seconds
    draw_splash(&mut display);
    display.flush().ok();
    Timer::after(Duration::from_secs(2)).await;

    // ── Main display loop ──────────────────────────────────────────────
    let mut page        = Page::Sensors;
    let mut page_ticks  = 0u32;
    let mut tick        = 0u32;

    // Page rotation: switch every 5 seconds (50 × 100ms ticks)
    const PAGE_TICKS: u32 = 50;

    loop {
        // Snapshot state — hold the lock as briefly as possible
        let s = {
            let state = STATE.lock().await;
            state.clone()
        };

        // Clear framebuffer
        display.clear(BinaryColor::Off).ok();

        // Draw current page
        match page {
            Page::Sensors => draw_sensors_page(&mut display, &s, tick),
            Page::Status  => draw_status_page(&mut display, &s),
        }

        // Flush framebuffer to display over I2C
        // This sends 1024 bytes (128×64 / 8) — takes ~2ms at 400kHz
        display.flush().ok();

        // Rotate page every PAGE_TICKS iterations
        page_ticks += 1;
        if page_ticks >= PAGE_TICKS {
            page_ticks = 0;
            page = page.next();
        }

        tick = tick.wrapping_add(1);

        // Refresh every 100ms — smooth enough, doesn't hammer I2C
        Timer::after(Duration::from_millis(100)).await;
    }
}

// ── Page renderers ─────────────────────────────────────────────────────────

// Splash screen shown at startup
fn draw_splash(display: &mut OledDisplay) {
    let title_style = MonoTextStyleBuilder::new()
        .font(&FONT_9X15_BOLD)
        .text_color(BinaryColor::On)
        .build();

    let sub_style = style_normal();

    Text::with_baseline(
        "Basement",
        Point::new(16, 10),
        title_style,
        Baseline::Top,
    )
    .draw(display)
    .ok();

    Text::with_baseline(
        "Monitor",
        Point::new(25, 28),
        title_style,
        Baseline::Top,
    )
    .draw(display)
    .ok();

    // Horizontal divider
    Line::new(Point::new(0, 47), Point::new(127, 47))
        .into_styled(PrimitiveStyle::with_stroke(BinaryColor::On, 1))
        .draw(display)
        .ok();

    Text::with_baseline(
        "Rust + Embassy",
        Point::new(14, 52),
        sub_style,
        Baseline::Top,
    )
    .draw(display)
    .ok();
}

// Page 0: primary sensor readings
//
// ┌────────────────────────┐
// │ BASEMENT MONITOR       │  ← inverted title bar
// │ Water: DRY             │  ← or "!! WET !!" when triggered
// │ Temp:  21.5 C          │
// │ Humid: 62.0 %          │
// │ Alarm: OFF             │  ← or "ACTIVE" inverted when triggered
// │ [========   ] 1/2      │  ← page indicator
// └────────────────────────┘

fn draw_sensors_page(display: &mut OledDisplay, s: &SensorState, tick: u32) {
    // ── Title bar (inverted) ───────────────────────────────────────────
    Rectangle::new(Point::new(0, 0), Size::new(128, 11))
        .into_styled(PrimitiveStyle::with_fill(BinaryColor::On))
        .draw(display)
        .ok();

    Text::with_baseline(
        "BASEMENT MONITOR",
        Point::new(2, ROW_0),
        style_inverted(),
        Baseline::Top,
    )
    .draw(display)
    .ok();

    // ── Water status ───────────────────────────────────────────────────
    if s.water_detected {
        // Flashing alert — invert every other tick (every 100ms)
        if tick % 2 == 0 {
            Rectangle::new(Point::new(0, ROW_1), Size::new(128, 11))
                .into_styled(PrimitiveStyle::with_fill(BinaryColor::On))
                .draw(display)
                .ok();
            Text::with_baseline(
                "Water: !! WET !!",
                Point::new(2, ROW_1),
                style_inverted(),
                Baseline::Top,
            )
            .draw(display)
            .ok();
        } else {
            Text::with_baseline(
                "Water: !! WET !!",
                Point::new(2, ROW_1),
                style_normal(),
                Baseline::Top,
            )
            .draw(display)
            .ok();
        }
    } else {
        Text::with_baseline(
            "Water: DRY",
            Point::new(2, ROW_1),
            style_normal(),
            Baseline::Top,
        )
        .draw(display)
        .ok();
    }

    // ── Temperature ────────────────────────────────────────────────────
    // heapless::String for formatting — no heap allocation
    let mut buf: String<24> = String::new();
    write!(buf, "Temp:  {:.1} C", s.temperature_c).ok();
    Text::with_baseline(
        buf.as_str(),
        Point::new(2, ROW_2),
        style_normal(),
        Baseline::Top,
    )
    .draw(display)
    .ok();

    // ── Humidity ───────────────────────────────────────────────────────
    let mut buf: String<24> = String::new();
    write!(buf, "Humid: {:.1} %", s.humidity_pct).ok();

    // Highlight high humidity as a warning
    let hum_style = if s.humidity_pct > 85.0 {
        style_inverted()
    } else {
        style_normal()
    };

    if s.humidity_pct > 85.0 {
        Rectangle::new(Point::new(0, ROW_3), Size::new(128, 11))
            .into_styled(PrimitiveStyle::with_fill(BinaryColor::On))
            .draw(display)
            .ok();
    }

    Text::with_baseline(
        buf.as_str(),
        Point::new(2, ROW_3),
        hum_style,
        Baseline::Top,
    )
    .draw(display)
    .ok();

    // ── Alarm status ───────────────────────────────────────────────────
    if s.alarm_active {
        Rectangle::new(Point::new(0, ROW_4), Size::new(128, 11))
            .into_styled(PrimitiveStyle::with_fill(BinaryColor::On))
            .draw(display)
            .ok();
        Text::with_baseline(
            "Alarm: ACTIVE",
            Point::new(2, ROW_4),
            style_inverted(),
            Baseline::Top,
        )
        .draw(display)
        .ok();
    } else {
        Text::with_baseline(
            "Alarm: OFF",
            Point::new(2, ROW_4),
            style_normal(),
            Baseline::Top,
        )
        .draw(display)
        .ok();
    }

    // ── Page indicator ─────────────────────────────────────────────────
    draw_page_indicator(display, 0, 2);
}

// Page 1: motion, CO, network status
//
// ┌────────────────────────┐
// │ STATUS                 │  ← inverted title bar
// │ Motion: CLEAR          │
// │ CO:     12 ppm  OK     │
// │ CO:     180 ppm DANGER │  ← when elevated
// │ Net:    192.168.1.42   │
// │ [   ========] 2/2      │  ← page indicator
// └────────────────────────┘

fn draw_status_page(display: &mut OledDisplay, s: &SensorState) {
    // ── Title bar ──────────────────────────────────────────────────────
    Rectangle::new(Point::new(0, 0), Size::new(128, 11))
        .into_styled(PrimitiveStyle::with_fill(BinaryColor::On))
        .draw(display)
        .ok();

    Text::with_baseline(
        "STATUS",
        Point::new(2, ROW_0),
        style_inverted(),
        Baseline::Top,
    )
    .draw(display)
    .ok();

    // ── Motion ─────────────────────────────────────────────────────────
    let motion_text = if s.motion_detected {
        "Motion: DETECTED"
    } else {
        "Motion: CLEAR"
    };

    Text::with_baseline(
        motion_text,
        Point::new(2, ROW_1),
        style_normal(),
        Baseline::Top,
    )
    .draw(display)
    .ok();

    // ── CO level with severity indicator ──────────────────────────────
    let mut co_buf: String<24> = String::new();
    let co_severity = match s.co_ppm {
        0..=49   => "OK",
        50..=149  => "WARN",
        _         => "DANGER",
    };
    write!(co_buf, "CO: {} ppm {}", s.co_ppm, co_severity).ok();

    // Invert the entire row if CO is dangerous
    if s.co_ppm >= 150 {
        Rectangle::new(Point::new(0, ROW_2), Size::new(128, 11))
            .into_styled(PrimitiveStyle::with_fill(BinaryColor::On))
            .draw(display)
            .ok();
        Text::with_baseline(
            co_buf.as_str(),
            Point::new(2, ROW_2),
            style_inverted(),
            Baseline::Top,
        )
        .draw(display)
        .ok();
    } else {
        Text::with_baseline(
            co_buf.as_str(),
            Point::new(2, ROW_2),
            style_normal(),
            Baseline::Top,
        )
        .draw(display)
        .ok();
    }

    // ── CO bar graph ───────────────────────────────────────────────────
    // Visual bar showing CO level from 0 to 300 ppm
    // max bar width = 124px (2px margin each side)
    let max_ppm: u32 = 300;
    let bar_width = ((s.co_ppm as u32).min(max_ppm) * 124 / max_ppm) as i32;

    // Background (empty bar outline)
    Rectangle::new(Point::new(2, ROW_3), Size::new(124, 8))
        .into_styled(PrimitiveStyle::with_stroke(BinaryColor::On, 1))
        .draw(display)
        .ok();

    // Fill bar
    if bar_width > 0 {
        Rectangle::new(Point::new(2, ROW_3), Size::new(bar_width as u32, 8))
            .into_styled(PrimitiveStyle::with_fill(BinaryColor::On))
            .draw(display)
            .ok();
    }

    // ── Network / IP ───────────────────────────────────────────────────
    // In a full implementation, pass the IP string into this function.
    // Shown here as a static placeholder — replace with actual IP from
    // wifi::get_ip() stored in SensorState or a separate static.
    Text::with_baseline(
        "Net: 192.168.1.x",
        Point::new(2, ROW_4),
        style_small(),
        Baseline::Top,
    )
    .draw(display)
    .ok();

    // ── Page indicator ─────────────────────────────────────────────────
    draw_page_indicator(display, 1, 2);
}

// ── Shared UI widgets ──────────────────────────────────────────────────────

// Page indicator dots at the bottom of the screen.
// current_page: 0-indexed current page
// total_pages:  total number of pages
//
// Renders two dots:  ● ○  (page 0)  or  ○ ●  (page 1)
// Positioned at the bottom-right corner.

fn draw_page_indicator(display: &mut OledDisplay, current_page: u8, total_pages: u8) {
    let dot_spacing = 8i32;
    let dot_size    = 4u32;

    // Start x so dots are right-aligned with 4px margin
    let start_x = SCREEN_W - (total_pages as i32 * dot_spacing) - 4;
    let y       = SCREEN_H - 6;

    for i in 0..total_pages {
        let x = start_x + i as i32 * dot_spacing;
        let style = if i == current_page {
            PrimitiveStyle::with_fill(BinaryColor::On)   // filled = current
        } else {
            PrimitiveStyle::with_stroke(BinaryColor::On, 1) // outline = other
        };

        Rectangle::new(
            Point::new(x, y),
            Size::new(dot_size, dot_size),
        )
        .into_styled(style)
        .draw(display)
        .ok();
    }
}

// ── Display error screen ───────────────────────────────────────────────────
// Called from other tasks if they want to show a full-screen error.
// Not async — takes a pre-initialized display reference.

pub fn draw_error(display: &mut OledDisplay, message: &str) {
    display.clear(BinaryColor::Off).ok();

    // Full screen inverted error box
    Rectangle::new(Point::new(0, 0), Size::new(128, 64))
        .into_styled(PrimitiveStyle::with_fill(BinaryColor::On))
        .draw(display)
        .ok();

    Text::with_baseline(
        "ERROR",
        Point::new(40, 8),
        MonoTextStyleBuilder::new()
            .font(&FONT_9X15_BOLD)
            .text_color(BinaryColor::Off)
            .build(),
        Baseline::Top,
    )
    .draw(display)
    .ok();

    // Word-wrap message across two lines (max 21 chars each at FONT_6X10)
    let chars: heapless::Vec<char, 42> = message.chars().collect();
    let line1: String<22> = chars.iter().take(21).collect();
    let line2: String<22> = chars.iter().skip(21).take(21).collect();

    let err_text_style = MonoTextStyleBuilder::new()
        .font(&FONT_6X10)
        .text_color(BinaryColor::Off)
        .build();

    Text::with_baseline(
        line1.as_str(),
        Point::new(2, 28),
        err_text_style,
        Baseline::Top,
    )
    .draw(display)
    .ok();

    Text::with_baseline(
        line2.as_str(),
        Point::new(2, 40),
        err_text_style,
        Baseline::Top,
    )
    .draw(display)
    .ok();

    display.flush().ok();
}

Enter fullscreen mode Exit fullscreen mode

Each of the displays have their comparisons.

Dimension C TinyGo Rust/Embassy
Lines of code ~520 ~380 ~420
External dependencies None beyond Pico SDK tinygo/x/drivers/ssd1306, tinygo/x/tinyfont ssd1306, embedded-graphics
Font handling Hand-embedded 96-char 6×8 lookup table in source tinyfont package — freemono.Regular7pt7b embedded-graphics FONT_6X10, FONT_9X15_BOLD
Framebuffer ownership Manual uint8_t g_framebuffer[8][128] — global, unprotected Managed internally by ssd1306.Device Managed internally by Ssd1306 in BufferedGraphicsMode
String formatting snprintf() into stack char[] — zero allocation fmt.Sprintf() — heap allocates, triggers GC write!(heapless::String<N>, ...) — zero allocation
Overflow protection None — char[24] silently truncates or corrupts None at formatting layer — GC handles heap Compile-time — heapless::String<24> returns Err if exceeded
Rectangle drawing fb_fill_rect() — hand-rolled nested loop over framebuffer fillRect() — wrapper over d.SetPixel() loop Rectangle::new().into_styled().draw() — embedded-graphics primitive
Text rendering fb_draw_char() — manual font table lookup + bit shifting tinyfont.WriteLine() — library handles glyph lookup Text::with_baseline() — embedded-graphics handles glyph lookup
Inverted text XOR font byte with 0xFF before writing to framebuffer Fill background rect first, then draw foreground glyphs MonoTextStyleBuilder sets text_color + background_color
I2C peripheral safety Convention only — nothing prevents two functions using I2C Convention only — goroutine owns display by discipline Compiler-enforced — I2C0 peripheral moved into task, unusable elsewhere
Flush to display ssd1306_flush_all() — manual I2C command sequence + data display.Display() — driver handles command sequence display.flush() — driver handles command sequence
Init sequence 17 raw command bytes hand-coded against datasheet Driver handles init internally in display.Configure() Driver handles init internally in display.init()
Error screen display_error() — inverted box + strncpy word wrap DrawError() — inverted box + rune slice word wrap draw_error() — inverted box + heapless::Vec<char> word wrap
Page rotation static local variables track tick count and current page Local variables in goroutine stack — naturally scoped Local variables in async task stack — naturally scoped
Flashing water alert tick % 2 == 0 on uint32_t g_tick global tick % 2 == 0 on uint32 goroutine-local tick % 2 == 0 on u32 task-local
Bar graph fb_draw_bar() — outline loop + fill rect against framebuffer drawBarGraph() — outline loop + fillRect() wrapper Rectangle primitives from embedded-graphics
Page indicator dots fb_draw_page_indicator() — filled vs outline rect manually drawPageIndicator() — filled vs outline rect via SetPixel Rectangle with PrimitiveStyle::with_fill vs with_stroke
Splash screen display_draw_splash() — 2s blocking sleep_ms() drawSplash() — 2s time.Sleep(), other goroutines run draw_splash() — 2s Timer::after().await, other tasks run
Heap allocation Zero Yes — fmt.Sprintf() on every refresh Zero
GC pressure None (no GC) Moderate — 6 fmt.Sprintf() calls per 100ms refresh None (no GC)
Binary contribution Smallest — font table is 576 bytes of const data Medium — pulls in tinyfont + ssd1306 driver Small — embedded-graphics is heavily inlined by LLVM
Concurrency safety display_poll() called from main loop — single-threaded by structure Goroutine owns display — safe by scheduler convention Peripheral ownership proven at compile time — safe by construction
Readability of page layout Low — pixel coordinates scattered across fb_draw_* calls High — sequential drawString() calls read like a template High — Text::with_baseline() calls read like a template
Readability of primitives Low — manual bit manipulation in fb_set_pixel() Medium — SetPixel() loop is clear but verbose High — Rectangle, Line primitives are self-documenting
Debuggability printf() to USB serial — no display state introspection println!() to USB serial — goroutine stack visible in panic defmt::info!() via RTT — structured logging, timestamps, log levels
Portability to another MCU Rewrite I2C calls and init sequence Change machine.I2C0 target — driver abstracts hardware Change embassy-rp to embassy-stm32 — HAL abstracts hardware
Time to first pixel Longest — write init sequence, font table, framebuffer Shortest — go get + 4 lines to configure Medium — Cargo.toml deps + type plumbing

C requires the most work here and it shows. The 6×8 font table
is 576 bytes of hand-curated hex values embedded directly in display.c.
Every character is 6 bytes — one byte per column of pixels, bit 0 at
the top. Rendering a character means indexing into this table, iterating
6 columns × 8 rows, and calling fb_set_pixel() for each. Inversion
means XOR-ing each column byte with 0xFF before rendering. This is
the same technique every SSD1306 driver written in C uses — it is proven
and correct, but you wrote it, you own it, and you debug it when it
breaks.

TinyGo delegates to tinyfont.WriteLine() with
freemono.Regular7pt7b. The font data lives in the tinyfont package.
Inversion requires drawing a filled background rectangle before the
glyphs, because tinyfont does not have a background color parameter.
This is a minor awkwardness — two calls instead of one — but the font
rendering itself is entirely handled by the library.

Rust uses embedded-graphics MonoTextStyleBuilder which accepts
both text_color and background_color as first-class parameters.
Inversion is MonoTextStyleBuilder::new().text_color(Off).background_color(On).build().
One style object, passed to Text::with_baseline(). No manual rectangle.
No XOR. The library handles glyph lookup, background fill, and pixel
writing in a single pass.

Winner: Rust — inversion is a style parameter, not a manual
pre-drawing step. Font selection is a type, not a hex table. The
embedded-graphics abstraction is the most expressive of the three.

Alarm Implementation

Alarm sources:
  Water detected   → CRITICAL  → fast pulse (200ms on / 200ms off)
                               → relay activates
                               → requires manual acknowledge
  CO ≥ 150ppm      → CRITICAL  → fast pulse
                               → relay activates
                               → requires manual acknowledge
  CO 50-149ppm     → WARNING   → slow pulse (800ms on / 800ms off)
                               → relay stays off
                               → auto-clears when CO drops
  Humidity > 85%   → ADVISORY  → single beep every 30s
                               → relay stays off
                               → auto-clears

Acknowledge:
  HTTP POST /alarm/acknowledge clears latched alarms
  Physical button on GPIO10 also acknowledges

Relay:
  Activates on CRITICAL only
  Stays active until acknowledged even if sensor clears
  Deactivates on acknowledge

Enter fullscreen mode Exit fullscreen mode

Go alarm/alarm.go Implementation

// alarm/alarm.go
//
// Alarm management for the basement monitor.
// Identical severity model to alarm.c and alarm.rs.
//
// TinyGo structural position:
//   More readable than C — pulse loop uses time.Sleep() not timestamps.
//   Less safe than Rust — pin ownership is by convention not compiler.
//   Goroutine-local latch state — cleaner than C's globals,
//   less explicit than Rust's task-local variables in the type system.

package alarm

import (
    "basement-monitor/sensors"
    "machine"
    "time"
)

// ── Severity levels ────────────────────────────────────────────────────────
// Same four levels as C's AlarmLevel enum and Rust's AlarmLevel enum.
// Go has no enum keyword — iota gives us the same ordered constants.

type alarmLevel uint8

const (
    levelNone     alarmLevel = iota // 0
    levelAdvisory                   // 1
    levelWarning                    // 2
    levelCritical                   // 3
)

func (l alarmLevel) String() string {
    switch l {
    case levelNone:     return "NONE"
    case levelAdvisory: return "ADVISORY"
    case levelWarning:  return "WARNING"
    case levelCritical: return "CRITICAL"
    default:            return "UNKNOWN"
    }
}

// ── Pulse durations ────────────────────────────────────────────────────────

const (
    criticalOn       = 200 * time.Millisecond
    criticalOff      = 200 * time.Millisecond
    warningOn        = 800 * time.Millisecond
    warningOff       = 800 * time.Millisecond
    advisoryBeep     = 100 * time.Millisecond
    advisoryInterval = 30 * time.Second
    idlePoll         = 100 * time.Millisecond
)

// ── Acknowledge channel ────────────────────────────────────────────────────
// HTTP handler and button goroutine send on this channel to trigger
// acknowledgement inside AlarmTask without sharing any mutable state.
// This is idiomatic Go/TinyGo — communicate by channel, not shared memory.
// C equivalent: alarm_acknowledge() writes directly to g_alarm.
// Rust equivalent: a separate channel or atomic flag (no channel needed
// because acknowledge() is called inside the same async task).

var AckChan = make(chan struct{}, 1)

// Acknowledge signals the alarm task to clear latched alarms.
// Safe to call from any goroutine — the HTTP handler calls this.
func Acknowledge() {
    select {
    case AckChan <- struct{}{}:
    default:
        // Channel full — ack already pending, that's fine
    }
}

// ── AlarmTask ──────────────────────────────────────────────────────────────

// AlarmTask runs as a goroutine, managing buzzer and relay based on
// sensor state severity. Reads sensor state via getState snapshot
// function — same pattern as display/oled.go and alarm.rs.

func AlarmTask(
    buzzerPin machine.Pin,
    relayPin  machine.Pin,
    ackPin    machine.Pin,
    getState  func() sensors.SensorState,
    setState  func(func(*sensors.SensorState)),
) {
    // Configure pins
    buzzerPin.Configure(machine.PinConfig{Mode: machine.PinOutput})
    relayPin.Configure(machine.PinConfig{Mode: machine.PinOutput})
    ackPin.Configure(machine.PinConfig{Mode: machine.PinInputPullup}) // active LOW

    buzzerPin.Low()
    relayPin.Low()

    println("[ALARM] Initialized")

    // ── Goroutine-local latch state ────────────────────────────────────
    // Lives on this goroutine's stack — no mutex needed.
    // This is the cleanest of the three implementations for latch state:
    //   C:    g_alarm global struct — anything can corrupt it
    //   Rust: task-local variables — compiler proves exclusivity
    //   TinyGo: goroutine-local vars — safe by scheduler convention
    var (
        waterLatched    bool
        coCritLatched   bool
        currentLevel    alarmLevel
        triggerTime     time.Time
    )

    // Start button monitor goroutine — sends on AckChan when pressed
    go buttonMonitor(ackPin)

    for {
        s := getState()

        // ── Check for acknowledge ──────────────────────────────────────
        select {
        case <-AckChan:
            currentLevel = acknowledge(
                buzzerPin, relayPin,
                &waterLatched, &coCritLatched,
                s, triggerTime, setState,
            )
            continue
        default:
            // No ack pending — continue
        }

        // ── Compute severity ───────────────────────────────────────────
        liveLevel := computeLevel(s)

        // ── Update latches ─────────────────────────────────────────────
        if liveLevel == levelCritical {
            if s.WaterDetected { waterLatched  = true }
            if s.CoPpm >= 150  { coCritLatched = true }

            if currentLevel != levelCritical {
                triggerTime = time.Now()
                println("[ALARM] CRITICAL — water:", s.WaterDetected,
                    "co:", s.CoPpm, "ppm")
            }
        }

        effective := liveLevel
        if waterLatched || coCritLatched {
            if effective < levelCritical {
                effective = levelCritical
            }
        }

        if effective != currentLevel {
            println("[ALARM] Level:", currentLevel.String(),
                "→", effective.String())
            currentLevel = effective
        }

        // ── Update shared state ────────────────────────────────────────
        setState(func(st *sensors.SensorState) {
            st.AlarmActive = effective > levelNone
        })

        // ── Drive relay ────────────────────────────────────────────────
        if effective == levelCritical {
            if !relayPin.Get() {
                relayPin.High()
                println("[ALARM] Relay ON")
            }
        } else {
            if relayPin.Get() {
                relayPin.Low()
                println("[ALARM] Relay OFF")
            }
        }

        // ── Drive buzzer ───────────────────────────────────────────────
        // This is TinyGo's clearest advantage over C in this file.
        // time.Sleep() yields the goroutine — other goroutines run
        // during the on/off intervals. The pulse loop reads linearly,
        // exactly matching the intended behavior.
        // C's poll_pulse() achieves the same result through timestamp
        // checking across multiple poll() calls — much harder to read.
        switch effective {
        case levelCritical:
            buzzerPin.High()
            time.Sleep(criticalOn)
            buzzerPin.Low()
            time.Sleep(criticalOff)

        case levelWarning:
            buzzerPin.High()
            time.Sleep(warningOn)
            buzzerPin.Low()
            time.Sleep(warningOff)

        case levelAdvisory:
            // Single short beep then long wait
            buzzerPin.High()
            time.Sleep(advisoryBeep)
            buzzerPin.Low()
            time.Sleep(advisoryInterval)

        case levelNone:
            buzzerPin.Low()
            relayPin.Low()
            time.Sleep(idlePoll)
        }
    }
}

// ── Button monitor ─────────────────────────────────────────────────────────
// Runs as a sub-goroutine — polls the physical button and sends
// on AckChan when pressed. Separating this from AlarmTask means
// the button can interrupt long advisory sleeps cleanly.
// C equivalent: gpio_get(PIN_ACK_BUTTON) polled inside alarm_poll().
// Rust equivalent: ack.get_level() == Level::Low checked in the task loop.

func buttonMonitor(pin machine.Pin) {
    var lastState bool = true // pull-up means HIGH = not pressed

    for {
        pressed := !pin.Get() // active LOW

        if pressed && !lastState {
            // Falling edge — button just pressed
            Acknowledge()
        }
        lastState = pressed

        time.Sleep(50 * time.Millisecond) // debounce interval
    }
}

// ── Acknowledge ────────────────────────────────────────────────────────────
// Returns the post-acknowledge alarm level so the caller can update
// currentLevel without needing a pointer to it.
// C equivalent: alarm_acknowledge() — modifies g_alarm directly.
// Rust equivalent: acknowledge() async fn called inside alarm_task.

func acknowledge(
    buzzerPin    machine.Pin,
    relayPin     machine.Pin,
    waterLatched *bool,
    coCritLatched *bool,
    s            sensors.SensorState,
    triggerTime  time.Time,
    setState     func(func(*sensors.SensorState)),
) alarmLevel {
    elapsed := time.Since(triggerTime)
    println("[ALARM] Acknowledged — active for", elapsed.Milliseconds(), "ms")

    *waterLatched   = false
    *coCritLatched  = false

    postLevel := computeLevel(s)

    setState(func(st *sensors.SensorState) {
        st.AlarmActive = postLevel > levelNone
    })

    if postLevel < levelCritical {
        relayPin.Low()
        println("[ALARM] Relay OFF (post-ack)")
    }

    buzzerPin.Low()

    println("[ALARM] Post-ack level:", postLevel.String())
    return postLevel
}

// ── Level computation ──────────────────────────────────────────────────────
// Pure function — identical priority order to C and Rust.
// Go's switch with no expression is cleaner than C's if-else chain
// and equivalent to Rust's match.

func computeLevel(s sensors.SensorState) alarmLevel {
    switch {
    case s.WaterDetected || s.CoPpm >= 150:
        return levelCritical
    case s.CoPpm >= 50:
        return levelWarning
    case s.HumidityPct > 85.0:
        return levelAdvisory
    default:
        return levelNone
    }
}

Enter fullscreen mode Exit fullscreen mode

C alarm.h | alarm.c Implementation

// alarm.h
#ifndef ALARM_H
#define ALARM_H

#include <stdint.h>
#include <stdbool.h>
#include "sensors.h"

// ── Pin assignments ────────────────────────────────────────────────────────
#define PIN_BUZZER          8
#define PIN_RELAY           4   // shared with water.c — relay driven here only
#define PIN_ACK_BUTTON      10  // physical acknowledge button, active LOW

// ── Alarm severity levels ──────────────────────────────────────────────────
// Same enum used in all three implementations for consistency.
// Rust: AlarmLevel enum in alarm.rs
// TinyGo: alarmLevel type in alarm/alarm.go

typedef enum {
    ALARM_NONE     = 0,  // all sensors nominal
    ALARM_ADVISORY = 1,  // humidity warning — single beep every 30s
    ALARM_WARNING  = 2,  // CO elevated — slow pulse, no relay
    ALARM_CRITICAL = 3,  // water or dangerous CO — fast pulse + relay
} AlarmLevel;

// ── Alarm state ────────────────────────────────────────────────────────────
// Separate from SensorState — alarm has its own latching logic.
// A CRITICAL alarm latches until acknowledged even if the sensor clears.
// This mirrors real alarm panel behavior — you need to know the event
// happened even if the water has since drained.

typedef struct {
    AlarmLevel  level;            // current computed severity
    AlarmLevel  latched_level;    // highest level since last acknowledge
    bool        water_latched;    // water was detected since last ack
    bool        co_critical_latched; // CO was critical since last ack
    bool        relay_active;     // current relay state
    uint32_t    trigger_time_ms;  // when alarm first activated
    uint32_t    last_ack_time_ms; // when alarm was last acknowledged
} AlarmState;

extern AlarmState g_alarm;

// ── Public API ─────────────────────────────────────────────────────────────
void alarm_init(void);
void alarm_poll(void);                   // call from main loop
void alarm_acknowledge(void);            // clear latched alarms
bool alarm_is_active(void);              // true if any alarm level > NONE
AlarmLevel alarm_current_level(void);    // current severity
const char *alarm_level_str(AlarmLevel level); // "NONE","ADVISORY","WARNING","CRITICAL"

#endif // ALARM_H

Enter fullscreen mode Exit fullscreen mode
// alarm.c
//
// Alarm management for the basement monitor.
// Drives the buzzer and relay based on sensor state severity.
//
// Severity model:
//   CRITICAL  → water detected OR CO ≥ 150ppm
//             → fast buzzer pulse (200ms/200ms)
//             → relay ON (cuts power to basement appliances)
//             → latches until acknowledged
//   WARNING   → CO 50-149ppm
//             → slow buzzer pulse (800ms/800ms)
//             → relay OFF
//             → auto-clears when CO drops below 50
//   ADVISORY  → humidity > 85%
//             → single beep every 30 seconds
//             → relay OFF
//             → auto-clears when humidity normalizes
//   NONE      → all clear, buzzer and relay off
//
// Architecture note vs Rust and TinyGo:
//   All three implement identical severity logic and state machine.
//   C uses static locals + timestamp polling from the main loop.
//   Rust uses an async task with Timer::after().await per state.
//   TinyGo uses a goroutine with time.Sleep() per state.
//   The C version is the only one that cannot sleep between states —
//   it must return from alarm_poll() immediately and check timestamps
//   on the next call. This makes the pulse timing logic more complex
//   than the Rust and TinyGo equivalents.

#include "alarm.h"
#include "sensors.h"
#include "hardware/gpio.h"
#include "pico/stdlib.h"
#include "pico/critical_section.h"
#include <stdio.h>
#include <string.h>

// ── Global alarm state ─────────────────────────────────────────────────────
AlarmState g_alarm = {
    .level               = ALARM_NONE,
    .latched_level       = ALARM_NONE,
    .water_latched       = false,
    .co_critical_latched = false,
    .relay_active        = false,
    .trigger_time_ms     = 0,
    .last_ack_time_ms    = 0,
};

// ── Pulse timing state ─────────────────────────────────────────────────────
// Because alarm_poll() must return immediately, we track buzzer toggle
// timestamps manually. This is the most structurally awkward part of
// the C implementation — compare to Rust and TinyGo where the pulse
// loop is a simple:
//   buzzer.set_high(); Timer::after(on_ms).await;
//   buzzer.set_low();  Timer::after(off_ms).await;

static bool     g_buzzer_on          = false;
static uint32_t g_next_toggle_ms     = 0;
static uint32_t g_next_advisory_ms   = 0;
static bool     g_advisory_beep_on   = false;
static uint32_t g_advisory_beep_end  = 0;

// Pulse durations in milliseconds per severity level
#define CRITICAL_ON_MS      200
#define CRITICAL_OFF_MS     200
#define WARNING_ON_MS       800
#define WARNING_OFF_MS      800
#define ADVISORY_BEEP_MS    100   // single short beep
#define ADVISORY_INTERVAL_MS 30000 // every 30 seconds

// ── Helpers ────────────────────────────────────────────────────────────────

static void buzzer_set(bool on) {
    gpio_put(PIN_BUZZER, on ? 1 : 0);
    g_buzzer_on = on;
}

static void relay_set(bool on) {
    gpio_put(PIN_RELAY, on ? 1 : 0);
    g_alarm.relay_active = on;
    if (on) {
        printf("[ALARM] Relay ACTIVATED\n");
    } else {
        printf("[ALARM] Relay DEACTIVATED\n");
    }
}

// Compute the current alarm level from sensor state.
// Does NOT latch — latching is handled in alarm_poll().
// Pure function: same inputs always produce same output.
// Rust equivalent: fn compute_level(s: &SensorState) -> AlarmLevel
// TinyGo equivalent: func computeLevel(s sensors.SensorState) alarmLevel
static AlarmLevel compute_level(const SensorState *s) {
    // CRITICAL takes priority — check first
    if (s->water_detected || s->co_ppm >= 150) {
        return ALARM_CRITICAL;
    }
    // WARNING next
    if (s->co_ppm >= 50) {
        return ALARM_WARNING;
    }
    // ADVISORY last
    if (s->humidity_pct > 85.0f) {
        return ALARM_ADVISORY;
    }
    return ALARM_NONE;
}

// ── Pulse state machine ────────────────────────────────────────────────────
// Called from alarm_poll() with the current timestamp.
// Implements the buzzer pattern for CRITICAL and WARNING levels.
// This is the timestamp-polling equivalent of:
//   loop {
//       buzzer.set_high(); Timer::after(on_ms).await;
//       buzzer.set_low();  Timer::after(off_ms).await;
//   }

static void poll_pulse(uint32_t now, uint32_t on_ms, uint32_t off_ms) {
    if (now < g_next_toggle_ms) return;  // not time yet

    if (g_buzzer_on) {
        // Currently on — turn off, schedule next on
        buzzer_set(false);
        g_next_toggle_ms = now + off_ms;
    } else {
        // Currently off — turn on, schedule next off
        buzzer_set(true);
        g_next_toggle_ms = now + on_ms;
    }
}

// Advisory beep: single short beep every 30 seconds.
// Two-phase: fire the beep, then wait for the 100ms beep to end,
// then wait 30 seconds before the next one.
static void poll_advisory(uint32_t now) {
    if (g_advisory_beep_on) {
        // Beep is active — check if it should end
        if (now >= g_advisory_beep_end) {
            buzzer_set(false);
            g_advisory_beep_on  = false;
            g_next_advisory_ms  = now + ADVISORY_INTERVAL_MS;
        }
    } else {
        // Waiting for next beep interval
        if (now >= g_next_advisory_ms) {
            buzzer_set(true);
            g_advisory_beep_on  = true;
            g_advisory_beep_end = now + ADVISORY_BEEP_MS;
        }
    }
}

// ── Public API ─────────────────────────────────────────────────────────────

void alarm_init(void) {
    gpio_init(PIN_BUZZER);
    gpio_set_dir(PIN_BUZZER, GPIO_OUT);
    gpio_put(PIN_BUZZER, 0);

    gpio_init(PIN_RELAY);
    gpio_set_dir(PIN_RELAY, GPIO_OUT);
    gpio_put(PIN_RELAY, 0);

    gpio_init(PIN_ACK_BUTTON);
    gpio_set_dir(PIN_ACK_BUTTON, GPIO_IN);
    gpio_pull_up(PIN_ACK_BUTTON);  // active LOW — button pulls to GND

    printf("[ALARM] Initialized — buzzer=GPIO%d relay=GPIO%d ack=GPIO%d\n",
           PIN_BUZZER, PIN_RELAY, PIN_ACK_BUTTON);
}

void alarm_poll(void) {
    uint32_t now = to_ms_since_boot(get_absolute_time());

    // ── Check physical acknowledge button ──────────────────────────────
    // Active LOW — button connects GPIO10 to GND when pressed.
    // Debounced by checking on every poll cycle (every ~100µs in main
    // loop) — in practice the button press lasts many milliseconds so
    // a single poll is sufficient. A production implementation would
    // track the falling edge rather than level.
    if (!gpio_get(PIN_ACK_BUTTON)) {
        alarm_acknowledge();
    }

    // ── Snapshot sensor state ──────────────────────────────────────────
    state_lock();
    SensorState s = g_state;
    state_unlock();

    // ── Compute current severity ───────────────────────────────────────
    AlarmLevel current = compute_level(&s);

    // ── Latch critical events ──────────────────────────────────────────
    // CRITICAL alarms latch — they stay active even if the sensor clears.
    // This ensures a flood event is visible even after water drains.
    // WARNING and ADVISORY do not latch — they auto-clear.
    if (current == ALARM_CRITICAL) {
        if (s.water_detected)  g_alarm.water_latched       = true;
        if (s.co_ppm >= 150)   g_alarm.co_critical_latched = true;

        if (g_alarm.level != ALARM_CRITICAL) {
            // Transition to CRITICAL
            g_alarm.trigger_time_ms = now;
            printf("[ALARM] CRITICAL — water=%s co=%uppm\n",
                   s.water_detected ? "YES" : "NO", s.co_ppm);
        }
    }

    // Effective level accounts for latching:
    // if we were CRITICAL and it latched, we stay CRITICAL until ack
    AlarmLevel effective = current;
    if (g_alarm.water_latched || g_alarm.co_critical_latched) {
        if (effective < ALARM_CRITICAL) {
            effective = ALARM_CRITICAL;
        }
    }

    // Track level transitions for logging
    if (effective != g_alarm.level) {
        printf("[ALARM] Level: %s → %s\n",
               alarm_level_str(g_alarm.level),
               alarm_level_str(effective));
        g_alarm.level = effective;

        // Reset pulse timing on level change
        g_buzzer_on      = false;
        g_next_toggle_ms = now;
        buzzer_set(false);
    }

    // ── Update shared sensor state alarm flag ──────────────────────────
    bool alarm_on = (effective > ALARM_NONE);
    state_lock();
    g_state.alarm_active = alarm_on;
    state_unlock();

    // ── Drive relay ────────────────────────────────────────────────────
    // Relay activates on CRITICAL, stays on until acknowledged.
    // In Rust: relay.set_high().unwrap() inside the async task.
    // In TinyGo: relayPin.High() inside the goroutine.
    bool relay_should_be_on = (effective == ALARM_CRITICAL);
    if (relay_should_be_on != g_alarm.relay_active) {
        relay_set(relay_should_be_on);
    }

    // ── Drive buzzer ───────────────────────────────────────────────────
    switch (effective) {
        case ALARM_CRITICAL:
            poll_pulse(now, CRITICAL_ON_MS, CRITICAL_OFF_MS);
            break;

        case ALARM_WARNING:
            poll_pulse(now, WARNING_ON_MS, WARNING_OFF_MS);
            break;

        case ALARM_ADVISORY:
            poll_advisory(now);
            break;

        case ALARM_NONE:
        default:
            // Silence everything
            if (g_buzzer_on) {
                buzzer_set(false);
            }
            break;
    }
}

void alarm_acknowledge(void) {
    uint32_t now = to_ms_since_boot(get_absolute_time());

    if (g_alarm.latched_level == ALARM_NONE &&
        g_alarm.level         == ALARM_NONE) {
        return; // nothing to acknowledge
    }

    printf("[ALARM] Acknowledged at %lums (active for %lums)\n",
           now,
           now - g_alarm.trigger_time_ms);

    // Clear all latches
    g_alarm.water_latched       = false;
    g_alarm.co_critical_latched = false;
    g_alarm.latched_level       = ALARM_NONE;
    g_alarm.last_ack_time_ms    = now;

    // Re-evaluate from current sensor state
    state_lock();
    SensorState s = g_state;
    state_unlock();

    g_alarm.level = compute_level(&s);

    // Update shared state
    state_lock();
    g_state.alarm_active = (g_alarm.level > ALARM_NONE);
    state_unlock();

    // Deactivate relay if no longer CRITICAL
    if (g_alarm.level < ALARM_CRITICAL) {
        relay_set(false);
    }

    // Stop buzzer — will restart if level is still active
    buzzer_set(false);
    g_next_toggle_ms   = 0;
    g_next_advisory_ms = 0;

    printf("[ALARM] Post-ack level: %s\n",
           alarm_level_str(g_alarm.level));
}

bool alarm_is_active(void) {
    return g_alarm.level > ALARM_NONE;
}

AlarmLevel alarm_current_level(void) {
    return g_alarm.level;
}

const char *alarm_level_str(AlarmLevel level) {
    switch (level) {
        case ALARM_NONE:     return "NONE";
        case ALARM_ADVISORY: return "ADVISORY";
        case ALARM_WARNING:  return "WARNING";
        case ALARM_CRITICAL: return "CRITICAL";
        default:             return "UNKNOWN";
    }
}

Enter fullscreen mode Exit fullscreen mode

Rust alarm.rs Implementation

// src/alarm.rs
//
// Alarm management for the basement monitor.
// Identical severity model to alarm.c and alarm/alarm.go:
//
//   CRITICAL  → water OR CO ≥ 150ppm → fast pulse + relay + latches
//   WARNING   → CO 50-149ppm         → slow pulse, no relay, auto-clears
//   ADVISORY  → humidity > 85%       → single beep/30s, auto-clears
//   NONE      → all clear
//
// Structural advantage over C:
//   The pulse loop is a linear async state machine — no timestamp
//   polling, no static locals, no manual toggle tracking.
//   buzzer.set_high(); Timer::after(on).await;
//   buzzer.set_low();  Timer::after(off).await;
//   reads exactly like the intended behavior.
//
// Structural advantage over TinyGo:
//   PIN_8 (buzzer) and PIN_4 (relay) are moved into this task.
//   The compiler proves no other task can drive them simultaneously.
//   In TinyGo, pin ownership is by convention.

use embassy_rp::gpio::{Input, Level, Output, Pull};
use embassy_rp::peripherals::{PIN_10, PIN_4, PIN_8};
use embassy_time::{Duration, Instant, Timer};
use defmt::*;

use crate::{SensorState, STATE};

// ── Alarm severity ─────────────────────────────────────────────────────────

#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, defmt::Format)]
pub enum AlarmLevel {
    None     = 0,
    Advisory = 1,
    Warning  = 2,
    Critical = 3,
}

impl AlarmLevel {
    fn label(self) -> &'static str {
        match self {
            AlarmLevel::None     => "NONE",
            AlarmLevel::Advisory => "ADVISORY",
            AlarmLevel::Warning  => "WARNING",
            AlarmLevel::Critical => "CRITICAL",
        }
    }
}

// ── Pulse durations ────────────────────────────────────────────────────────

const CRITICAL_ON:  Duration = Duration::from_millis(200);
const CRITICAL_OFF: Duration = Duration::from_millis(200);
const WARNING_ON:   Duration = Duration::from_millis(800);
const WARNING_OFF:  Duration = Duration::from_millis(800);
const ADVISORY_BEEP: Duration = Duration::from_millis(100);
const ADVISORY_INTERVAL: Duration = Duration::from_secs(30);

// ── Alarm task ─────────────────────────────────────────────────────────────

#[embassy_executor::task]
pub async fn alarm_task(
    buzzer_pin: PIN_8,
    relay_pin:  PIN_4,
    ack_pin:    PIN_10,
) {
    let mut buzzer = Output::new(buzzer_pin, Level::Low);
    let mut relay  = Output::new(relay_pin,  Level::Low);
    let     ack    = Input::new(ack_pin, Pull::Up);  // active LOW

    // ── Latch state ────────────────────────────────────────────────────
    // Tracks which CRITICAL events have occurred since last acknowledge.
    // Local to this task — no mutex needed, no shared state.
    // This is cleaner than C's g_alarm global or TinyGo's package-level var.
    let mut water_latched    = false;
    let mut co_crit_latched  = false;
    let mut trigger_time     = Instant::now();
    let mut current_level    = AlarmLevel::None;

    info!("Alarm task started — buzzer=PIN_8 relay=PIN_4 ack=PIN_10");

    loop {
        // ── Snapshot sensor state ──────────────────────────────────────
        let s = {
            let state = STATE.lock().await;
            state.clone()
        };

        // ── Check acknowledge button ───────────────────────────────────
        // Active LOW — pressed when pin reads Low.
        // In C: gpio_get(PIN_ACK_BUTTON) == 0 in alarm_poll().
        // In TinyGo: !ackPin.Get() in the goroutine loop.
        // Here: ack.get_level() == Level::Low — same logic, typed.
        if ack.get_level() == Level::Low {
            acknowledge(
                &mut buzzer,
                &mut relay,
                &mut water_latched,
                &mut co_crit_latched,
                &mut current_level,
                &s,
                trigger_time,
            ).await;
        }

        // ── Compute severity ───────────────────────────────────────────
        let live_level = compute_level(&s);

        // ── Update latches ─────────────────────────────────────────────
        if live_level == AlarmLevel::Critical {
            if s.water_detected  { water_latched   = true; }
            if s.co_ppm >= 150   { co_crit_latched = true; }

            if current_level != AlarmLevel::Critical {
                trigger_time = Instant::now();
                warn!("CRITICAL alarm triggered — water={} co={}ppm",
                    s.water_detected, s.co_ppm);
            }
        }

        // Effective level respects latching
        let effective = if water_latched || co_crit_latched {
            AlarmLevel::Critical.max(live_level)
        } else {
            live_level
        };

        // Log level transitions
        if effective != current_level {
            info!("Alarm level: {} → {}",
                current_level.label(), effective.label());
            current_level = effective;
        }

        // ── Update shared state ────────────────────────────────────────
        {
            let mut state = STATE.lock().await;
            state.alarm_active = effective > AlarmLevel::None;
        }

        // ── Drive relay ────────────────────────────────────────────────
        match effective {
            AlarmLevel::Critical => relay.set_high(),
            _                    => relay.set_low(),
        }

        // ── Drive buzzer + timing ──────────────────────────────────────
        // This is the key structural difference from C.
        // In C: poll_pulse() checks timestamps and returns immediately.
        // Here: we await the actual duration — the task suspends,
        // Embassy runs other tasks during the wait, and we resume
        // exactly when the timer fires. Linear, readable, correct.
        match effective {
            AlarmLevel::Critical => {
                buzzer.set_high();
                Timer::after(CRITICAL_ON).await;
                buzzer.set_low();
                Timer::after(CRITICAL_OFF).await;
            }

            AlarmLevel::Warning => {
                buzzer.set_high();
                Timer::after(WARNING_ON).await;
                buzzer.set_low();
                Timer::after(WARNING_OFF).await;
            }

            AlarmLevel::Advisory => {
                // Single short beep then wait 30 seconds
                buzzer.set_high();
                Timer::after(ADVISORY_BEEP).await;
                buzzer.set_low();
                Timer::after(ADVISORY_INTERVAL).await;
            }

            AlarmLevel::None => {
                // Ensure both are off then re-check after 100ms
                buzzer.set_low();
                relay.set_low();
                Timer::after(Duration::from_millis(100)).await;
            }
        }
    }
}

// ── Acknowledge ────────────────────────────────────────────────────────────
// Extracted as an async function so it can .await the STATE lock
// without holding a non-Send reference across the await point.
// C equivalent: alarm_acknowledge() — synchronous, called directly.
// TinyGo equivalent: acknowledge() func called from the goroutine.

async fn acknowledge(
    buzzer:          &mut Output<'static>,
    relay:           &mut Output<'static>,
    water_latched:   &mut bool,
    co_crit_latched: &mut bool,
    current_level:   &mut AlarmLevel,
    s:               &SensorState,
    trigger_time:    Instant,
) {
    let elapsed = Instant::now() - trigger_time;

    info!("Alarm acknowledged — active for {}ms", elapsed.as_millis());

    *water_latched   = false;
    *co_crit_latched = false;

    // Re-evaluate from current live sensor state
    *current_level = compute_level(s);

    // Update shared state
    {
        let mut state = STATE.lock().await;
        state.alarm_active = *current_level > AlarmLevel::None;
    }

    // Deactivate hardware if no longer critical
    if *current_level < AlarmLevel::Critical {
        relay.set_low();
    }
    buzzer.set_low();

    info!("Post-ack level: {}", current_level.label());
}

// ── Level computation ──────────────────────────────────────────────────────
// Pure function — no side effects, no state mutation.
// Identical priority order to C's compute_level() and
// TinyGo's computeLevel().

fn compute_level(s: &SensorState) -> AlarmLevel {
    if s.water_detected || s.co_ppm >= 150 {
        return AlarmLevel::Critical;
    }
    if s.co_ppm >= 50 {
        return AlarmLevel::Warning;
    }
    if s.humidity_pct > 85.0 {
        return AlarmLevel::Advisory;
    }
    AlarmLevel::None
}

Enter fullscreen mode Exit fullscreen mode

As you can see from this project, we have the sensors, display and now the alarm programmed. Next lets build out the wifi package.

Networking Capabilities

Responsibilities:
  Init          → configure CYW43 chip, set station mode
  Connect       → join WPA2 network with retry + backoff
  Reconnect     → detect disconnection, re-join automatically
  Status        → expose connected/disconnected/ip to other modules
  Signal        → RSSI reading for dashboard display
  IP address    → cached string for display and HTTP server
  LED           → Pico W onboard LED is on the CYW43, not GPIO
                  WiFi module owns it — blink on connect, solid when up
  Notify        → tell display module the IP after DHCP

Enter fullscreen mode Exit fullscreen mode

The networking component will require us to have a connection readily available and you may not like how we have to embed the password into the hardware itself. Feel free to expand the tutorial further to add an extra layer of protection for what your needs are.

Go wifi/wifi.go Implementation

// wifi/wifi.go
//
// WiFi management for the basement monitor — Pico W / CYW43439.
//
// TinyGo's WiFi story as of v0.41:
//   - cyw43 driver is functional for WPA2 + TCP
//   - Wireless support matured significantly in v0.41 (April 2026)
//   - espradio package added for ESP32 boards in same release
//   - Reconnection requires manual implementation (shown below)
//   - LED is on CYW43 — same constraint as C and Rust
//
// Goroutine model advantage over C:
//   ConnectWithRetry() blocks the calling goroutine during connection
//   attempts but other goroutines (sensors, display, alarm) continue.
//   ReconnectTask() runs as a permanent background goroutine —
//   reconnection happens without touching the main goroutine at all.
//   C's wifi_poll() requires the main loop to drive reconnection.

package wifi

import (
    "basement-monitor/display"
    "machine"
    "sync"
    "time"

    "tinygo.org/x/drivers/cyw43"
)

// ── Credentials ────────────────────────────────────────────────────────────
// Set via tinygo build -ldflags="-X wifi.SSID=MyNet -X wifi.Password=pass"
// or via environment variables read in a build script.
// Never hardcode in source.
var (
    SSID     = "YourNetworkName" // override at build time
    Password = "YourPassword"    // override at build time
)

// ── Configuration ──────────────────────────────────────────────────────────
const (
    connectTimeout   = 30 * time.Second
    maxRetries       = 5
    retryBase        = 2 * time.Second
    retryMax         = 30 * time.Second
    reconnectCheck   = 5 * time.Second
)

// ── Connection state ───────────────────────────────────────────────────────
// Same four states as C's WifiState enum and Rust's WifiState enum.

type State uint8

const (
    StateIdle         State = iota
    StateConnecting
    StateConnected
    StateDisconnected
    StateFailed
)

func (s State) String() string {
    switch s {
    case StateIdle:         return "IDLE"
    case StateConnecting:   return "CONNECTING"
    case StateConnected:    return "CONNECTED"
    case StateDisconnected: return "DISCONNECTED"
    case StateFailed:       return "FAILED"
    default:                return "UNKNOWN"
    }
}

// ── Status struct ──────────────────────────────────────────────────────────

type Status struct {
    State          State
    IP             string
    RSSIdbm        int32
    ReconnectCount uint32
}

// ── Package state ──────────────────────────────────────────────────────────
// Protected by statusMu — same sync.Mutex pattern as SensorState in main.go.
// C equivalent: g_status global + critical_section.
// Rust equivalent: Mutex<CriticalSectionRawMutex, WifiStatus>.
// TinyGo: sync.Mutex — disables interrupts under the hood on Cortex-M,
// same mechanism as Rust's CriticalSectionRawMutex.

var (
    statusMu sync.Mutex
    status   Status

    // g_dev is the CYW43 device handle — used by SetLED and RSSI.
    g_dev *cyw43.Device

    // g_ip is the cached IP string read by display and HTTP modules.
    // display.SetWifiIP() is called whenever this changes.
    g_ip string = "0.0.0.0"
)

// GetStatus returns a snapshot of current WiFi status.
// Same snapshot pattern as sensors.GetState().
func GetStatus() Status {
    statusMu.Lock()
    s := status
    statusMu.Unlock()
    return s
}

// GetIP returns the current IP address string.
// Called by display/oled.go status page.
func GetIP() string {
    statusMu.Lock()
    ip := g_ip
    statusMu.Unlock()
    return ip
}

// IsConnected returns true if currently connected with a valid IP.
func IsConnected() bool {
    statusMu.Lock()
    connected := status.State == StateConnected
    statusMu.Unlock()
    return connected
}

func updateStatus(fn func(*Status)) {
    statusMu.Lock()
    fn(&status)
    statusMu.Unlock()
}

// ── LED control ────────────────────────────────────────────────────────────
// Pico W LED is on the CYW43 chip — cannot use machine.GPIO25.
// Same constraint as C's wifi_set_led() and Rust's control.gpio_set().
// Other packages must call wifi.SetLED() — they cannot drive it directly.

func SetLED(on bool) {
    if g_dev == nil {
        return
    }
    if on {
        g_dev.SetGPIO(0, true)
    } else {
        g_dev.SetGPIO(0, false)
    }
}

// ── Connect ────────────────────────────────────────────────────────────────

// Connect initializes the CYW43 and connects to the configured network.
// Returns the assigned IP address on success.
// Blocks the calling goroutine — other goroutines continue.
// Called from main.go before starting the HTTP server goroutine.
func Connect(ssid, password string) (string, error) {
    SSID     = ssid
    Password = password

    println("[WIFI ] Initializing CYW43...")

    dev, err := cyw43.New()
    if err != nil {
        return "", err
    }
    g_dev = dev

    println("[WIFI ] CYW43 initialized")
    SetLED(false) // start with LED off

    // Connect with retry + backoff
    ip, err := connectWithRetry()
    if err != nil {
        updateStatus(func(s *Status) {
            s.State = StateFailed
        })
        println("[WIFI ] All connection attempts failed")
        return "", err
    }

    // Start background reconnection goroutine
    // This is TinyGo's structural advantage over C here:
    // reconnection runs independently of the main loop forever.
    // C requires wifi_poll() to be called from main loop to reconnect.
    // Rust uses an async task — same independence, different mechanism.
    go reconnectTask()

    return ip, nil
}

// connectWithRetry attempts connection up to maxRetries times
// with exponential backoff between attempts.
func connectWithRetry() (string, error) {
    backoff := retryBase

    for attempt := 1; attempt <= maxRetries; attempt++ {
        println("[WIFI ] Attempt", attempt, "/", maxRetries)
        updateStatus(func(s *Status) { s.State = StateConnecting })
        SetLED(false)

        // Blink LED in background during attempt
        stopBlink := make(chan struct{})
        go blinkLED(500*time.Millisecond, stopBlink)

        err := g_dev.ConnectWPA2(SSID, Password)

        // Stop blink goroutine
        close(stopBlink)
        SetLED(false)

        if err == nil {
            ip := g_dev.GetIP()
            if ip == "" {
                ip = "0.0.0.0"
            }

            statusMu.Lock()
            status.State = StateConnected
            status.IP    = ip
            g_ip         = ip
            statusMu.Unlock()

            // Tell display module the new IP
            display.SetWifiIP(ip)

            SetLED(true) // solid LED = connected
            println("[WIFI ] Connected — IP:", ip)
            return ip, nil
        }

        println("[WIFI ] Attempt", attempt, "failed:", err.Error())

        if attempt < maxRetries {
            println("[WIFI ] Retrying in", backoff.Milliseconds(), "ms")

            // Slow blink during backoff
            stopBackoff := make(chan struct{})
            go blinkLED(500*time.Millisecond, stopBackoff)
            time.Sleep(backoff)
            close(stopBackoff)

            // Exponential backoff
            backoff *= 2
            if backoff > retryMax {
                backoff = retryMax
            }
        }
    }

    return "", errorStr("all connection attempts failed")
}

// ── Reconnection task ──────────────────────────────────────────────────────
// Runs forever as a background goroutine.
// Checks link status every reconnectCheck interval.
// If disconnected, reconnects automatically.
// This is TinyGo's most significant advantage over C for WiFi management:
// C must call wifi_poll() from the main loop.
// TinyGo goroutine is truly independent — runs on its own schedule.

func reconnectTask() {
    for {
        time.Sleep(reconnectCheck)

        if !IsConnected() {
            continue // already handling disconnect
        }

        // Check link status
        if !g_dev.IsConnected() {
            println("[WIFI ] Link lost — reconnecting")
            SetLED(false)

            var reconnectCount uint32
            statusMu.Lock()
            status.State = StateDisconnected
            status.IP    = "0.0.0.0"
            g_ip         = "0.0.0.0"
            status.ReconnectCount++
            reconnectCount = status.ReconnectCount
            statusMu.Unlock()

            display.SetWifiIP("0.0.0.0")

            println("[WIFI ] Reconnect attempt #", reconnectCount)

            ip, err := connectWithRetry()
            if err != nil {
                println("[WIFI ] Reconnection failed — continuing offline")
                updateStatus(func(s *Status) {
                    s.State = StateFailed
                })
            } else {
                println("[WIFI ] Reconnected — IP:", ip)
            }
        } else {
            // Still connected — update RSSI
            rssi := g_dev.GetRSSI()
            statusMu.Lock()
            status.RSSIdbm = int32(rssi)
            statusMu.Unlock()
        }
    }
}

// ── LED blink helper ───────────────────────────────────────────────────────
// Runs as a goroutine — blinks LED at the given interval until
// the stop channel is closed.
// This pattern is idiomatic TinyGo/Go for cancellable background tasks.
// C equivalent: led_blink_poll() with timestamp — non-blocking but
// requires the main loop to call it.
// Rust equivalent: loop { control.gpio_set(0,true).await; Timer::after().await }

func blinkLED(interval time.Duration, stop <-chan struct{}) {
    state := false
    for {
        select {
        case <-stop:
            SetLED(false)
            return
        default:
        }
        state = !state
        SetLED(state)
        time.Sleep(interval)
    }
}

// ── Error helper ───────────────────────────────────────────────────────────
// TinyGo supports errors via the standard error interface.
// heapless equivalent: errors are &'static str in Rust embedded.

type errorStr string

func (e errorStr) Error() string { return string(e) }

Enter fullscreen mode Exit fullscreen mode

C wifi.h | wifi.c Implementation

// wifi.h
#ifndef WIFI_H
#define WIFI_H

#include <stdbool.h>
#include <stdint.h>
#include <stddef.h>

// ── Credentials ────────────────────────────────────────────────────────────
// Passed via CMake target_compile_definitions in CMakeLists.txt:
//   target_compile_definitions(basement_monitor PRIVATE
//       WIFI_SSID=\"YourNetwork\"
//       WIFI_PASSWORD=\"YourPassword\"
//   )
// Never hardcode credentials in source — define them at build time.
#ifndef WIFI_SSID
#  error "WIFI_SSID must be defined at compile time"
#endif
#ifndef WIFI_PASSWORD
#  error "WIFI_PASSWORD must be defined at compile time"
#endif

// ── Configuration ──────────────────────────────────────────────────────────
#define WIFI_CONNECT_TIMEOUT_MS     30000   // 30s per attempt
#define WIFI_MAX_RETRIES            5       // attempts before giving up
#define WIFI_RETRY_BASE_MS          2000    // initial retry backoff
#define WIFI_RETRY_MAX_MS           30000   // maximum retry backoff
#define WIFI_RECONNECT_CHECK_MS     5000    // how often to check link
#define WIFI_IP_BUF_LEN             16      // "255.255.255.255\0"
#define WIFI_HOSTNAME               "basement-monitor"

// ── Connection state ───────────────────────────────────────────────────────
typedef enum {
    WIFI_STATE_IDLE        = 0,
    WIFI_STATE_CONNECTING  = 1,
    WIFI_STATE_CONNECTED   = 2,
    WIFI_STATE_DISCONNECTED = 3,
    WIFI_STATE_FAILED      = 4,
} WifiState;

// ── Status struct ──────────────────────────────────────────────────────────
// Read-only snapshot for other modules.
// Equivalent to Rust's WifiStatus struct and TinyGo's WifiStatus struct.
typedef struct {
    WifiState   state;
    char        ip[WIFI_IP_BUF_LEN];
    int32_t     rssi_dbm;       // signal strength, negative dBm
    uint32_t    connect_time_ms; // when we last connected
    uint32_t    reconnect_count; // how many reconnections so far
} WifiStatus;

// ── Global IP string ───────────────────────────────────────────────────────
// Defined here so display.c and http.c can extern it.
// In Rust: static WIFI_IP: Mutex<CriticalSectionRawMutex, String<16>>
// In TinyGo: package-level var protected by sync.Mutex
extern char g_ip_address[WIFI_IP_BUF_LEN];

// ── Public API ─────────────────────────────────────────────────────────────

// Initialize CYW43 hardware. Must be called before any other wifi_ function.
// Returns false if CYW43 initialization fails (hardware fault).
bool        wifi_init(void);

// Attempt to connect. Blocks until connected or max retries exhausted.
// Returns true if connected, false if all retries failed.
// On success, g_ip_address is populated and display is notified.
bool        wifi_connect(void);

// Poll function — call from main loop.
// Checks link status and triggers reconnection if disconnected.
// Never blocks.
void        wifi_poll(void);

// Returns true if currently connected with a valid IP.
bool        wifi_is_connected(void);

// Write current IP into buf (max len bytes). Writes "0.0.0.0" if not connected.
void        wifi_get_ip(char *buf, size_t len);

// Returns current RSSI in dBm. Returns 0 if not connected.
int32_t     wifi_get_rssi(void);

// Returns a snapshot of current wifi status.
WifiStatus  wifi_get_status(void);

// Returns human-readable state string.
const char *wifi_state_str(WifiState state);

// Set the onboard LED (lives on CYW43, not GPIO).
// Other modules must use this — they cannot use gpio_put() for the LED.
void        wifi_set_led(bool on);

#endif // WIFI_H

Enter fullscreen mode Exit fullscreen mode
// wifi.c
//
// WiFi management for the basement monitor — Pico W / CYW43439.
//
// The Pico W's WiFi chip (CYW43439) is connected via SPI internally.
// The Pico SDK's cyw43_arch library abstracts this. We use the
// lwip_threadsafe_background variant — lwIP runs in interrupt context
// so we never need to call cyw43_arch_poll() manually.
//
// Architecture comparison:
//
//   C:      cyw43_arch_wifi_connect_timeout_ms() blocks the CPU.
//           lwIP IRQ still fires during the block but the main loop
//           is completely paused. Reconnection is polled from main loop
//           via wifi_poll() on a timestamp cadence.
//
//   Rust:   embassy-net + cyw43 crate. Connection is fully async —
//           wifi.join().await yields Embassy executor. Reconnection
//           runs in the same async task, yielding between retries.
//           Other tasks (sensors, display, HTTP) run normally during
//           the entire WiFi lifecycle.
//
//   TinyGo: cyw43 driver wraps the same CYW43 SPI protocol.
//           Connection blocks the calling goroutine but other goroutines
//           run if the driver has yield points internally.
//           Reconnection runs in a dedicated goroutine.

#include "wifi.h"
#include "display.h"
#include "pico/stdlib.h"
#include "pico/cyw43_arch.h"
#include "lwip/netif.h"
#include "lwip/ip4_addr.h"
#include "lwip/dns.h"
#include <stdio.h>
#include <string.h>

// ── Global state ───────────────────────────────────────────────────────────
// In Rust: static WIFI_STATUS: Mutex<CriticalSectionRawMutex, WifiStatus>
// In TinyGo: package-level var guarded by sync.Mutex
// In C: globals + critical_section — nothing prevents unsynchronized reads
char g_ip_address[WIFI_IP_BUF_LEN] = "0.0.0.0";

static WifiStatus  g_status = {
    .state          = WIFI_STATE_IDLE,
    .ip             = "0.0.0.0",
    .rssi_dbm       = 0,
    .connect_time_ms = 0,
    .reconnect_count = 0,
};

static bool     g_initialized      = false;
static uint32_t g_next_reconnect_ms = 0;
static uint32_t g_retry_backoff_ms  = WIFI_RETRY_BASE_MS;
static uint32_t g_led_blink_ms      = 0;
static bool     g_led_state         = false;

// ── LED helpers ────────────────────────────────────────────────────────────
// The Pico W onboard LED is connected to the CYW43 chip, not to a GPIO pin.
// You CANNOT use gpio_put(25, 1) on the Pico W — it does nothing or worse.
// You MUST use cyw43_arch_gpio_put(CYW43_WL_GPIO_LED_PIN, value).
// This is a common Pico W gotcha. Centralizing it here prevents other
// modules from accidentally using the wrong API.

void wifi_set_led(bool on) {
    cyw43_arch_gpio_put(CYW43_WL_GPIO_LED_PIN, on ? 1 : 0);
}

// Blink the LED — called from wifi_poll() during connecting state.
// Non-blocking: checks timestamp, toggles if due.
static void led_blink_poll(uint32_t now, uint32_t interval_ms) {
    if (now < g_led_blink_ms) return;
    g_led_blink_ms = now + interval_ms;
    g_led_state    = !g_led_state;
    wifi_set_led(g_led_state);
}

// ── IP address helpers ─────────────────────────────────────────────────────

static void update_ip(void) {
    struct netif *netif = netif_default;
    if (netif == NULL) {
        strncpy(g_status.ip, "0.0.0.0", WIFI_IP_BUF_LEN);
        strncpy(g_ip_address, "0.0.0.0", WIFI_IP_BUF_LEN);
        return;
    }
    const char *ip = ip4addr_ntoa(netif_ip4_addr(netif));
    strncpy(g_status.ip, ip, WIFI_IP_BUF_LEN - 1);
    g_status.ip[WIFI_IP_BUF_LEN - 1] = '\0';
    strncpy(g_ip_address, g_status.ip, WIFI_IP_BUF_LEN);

    // Notify display module so the status page shows the real IP.
    // In Rust: display task reads from STATE which wifi task updates.
    // In TinyGo: display.SetWifiIP(ip) updates the closure.
    // In C: display reads g_ip_address directly via extern.
    // The extern approach is the least explicit — any module can read
    // g_ip_address without going through wifi_get_ip().
    printf("[WIFI ] IP: %s\n", g_ip_address);
}

// ── RSSI reading ───────────────────────────────────────────────────────────

static int32_t read_rssi(void) {
    int32_t rssi = 0;
    // cyw43_wifi_get_rssi requires the CYW43 to be in station mode
    // and connected. Returns 0 on failure.
    if (g_status.state == WIFI_STATE_CONNECTED) {
        cyw43_wifi_get_rssi(&cyw43_state, &rssi);
    }
    return rssi;
}

// ── Single connection attempt ──────────────────────────────────────────────

static bool attempt_connect(void) {
    g_status.state = WIFI_STATE_CONNECTING;
    printf("[WIFI ] Connecting to '%s'...\n", WIFI_SSID);

    // This call blocks for up to WIFI_CONNECT_TIMEOUT_MS.
    // During this time:
    //   - lwIP IRQ fires normally (packets processed)
    //   - main loop is completely frozen
    //   - sensor polling stops
    //   - display stops updating
    //   - alarm stops pulsing
    //
    // In Rust: wifi.join(ssid, pass).await → other tasks run normally
    // In TinyGo: blocks calling goroutine, others may run (driver-dependent)
    // In C: everything stops — this is the fundamental C limitation here
    int result = cyw43_arch_wifi_connect_timeout_ms(
        WIFI_SSID,
        WIFI_PASSWORD,
        CYW43_AUTH_WPA2_AES_PSK,
        WIFI_CONNECT_TIMEOUT_MS
    );

    if (result != 0) {
        printf("[WIFI ] Connection failed: error %d\n", result);
        g_status.state = WIFI_STATE_DISCONNECTED;
        return false;
    }

    // Connected — read IP and signal strength
    update_ip();
    g_status.rssi_dbm       = read_rssi();
    g_status.state          = WIFI_STATE_CONNECTED;
    g_status.connect_time_ms = to_ms_since_boot(get_absolute_time());

    printf("[WIFI ] Connected — IP=%s RSSI=%ddBm reconnects=%lu\n",
           g_status.ip,
           g_status.rssi_dbm,
           g_status.reconnect_count);

    // Solid LED when connected
    wifi_set_led(true);

    return true;
}

// ── Public API ─────────────────────────────────────────────────────────────

bool wifi_init(void) {
    if (g_initialized) return true;

    printf("[WIFI ] Initializing CYW43...\n");

    // cyw43_arch_init_with_country sets the regulatory domain.
    // CYW43_COUNTRY_USA — change to your country code.
    // Using the wrong country code can violate radio regulations.
    if (cyw43_arch_init_with_country(CYW43_COUNTRY_USA) != 0) {
        printf("[WIFI ] CYW43 init failed — hardware fault?\n");
        return false;
    }

    // Station mode — we are a client, not an access point
    cyw43_arch_enable_sta_mode();

    // Set hostname for DHCP — shows up in router client list
    // netif_set_hostname(netif_default, WIFI_HOSTNAME);

    g_initialized = true;
    printf("[WIFI ] CYW43 initialized\n");

    // Start LED blinking to show we are alive but not connected
    wifi_set_led(false);

    return true;
}

bool wifi_connect(void) {
    if (!g_initialized) {
        printf("[WIFI ] wifi_connect() called before wifi_init()\n");
        return false;
    }

    uint32_t backoff = WIFI_RETRY_BASE_MS;

    for (int attempt = 1; attempt <= WIFI_MAX_RETRIES; attempt++) {
        printf("[WIFI ] Attempt %d/%d\n", attempt, WIFI_MAX_RETRIES);

        if (attempt_connect()) {
            g_retry_backoff_ms = WIFI_RETRY_BASE_MS; // reset backoff on success
            return true;
        }

        if (attempt < WIFI_MAX_RETRIES) {
            printf("[WIFI ] Retrying in %lums...\n", backoff);

            // Wait between retries — blink LED during wait.
            // Non-blocking wait: poll LED blink while waiting.
            // This is the C pattern for "do something while waiting"
            // without yielding — contrast with Rust's:
            //   Timer::after(backoff).await  (other tasks run freely)
            // and TinyGo's:
            //   time.Sleep(backoff)           (other goroutines run freely)
            uint32_t wait_start = to_ms_since_boot(get_absolute_time());
            while (to_ms_since_boot(get_absolute_time()) - wait_start < backoff) {
                uint32_t now = to_ms_since_boot(get_absolute_time());
                led_blink_poll(now, 250); // fast blink during retry wait
                sleep_ms(10);            // brief yield for lwIP IRQ
            }

            // Exponential backoff — double each retry, cap at max
            backoff *= 2;
            if (backoff > WIFI_RETRY_MAX_MS) {
                backoff = WIFI_RETRY_MAX_MS;
            }
        }
    }

    g_status.state = WIFI_STATE_FAILED;
    printf("[WIFI ] All %d attempts failed — running offline\n",
           WIFI_MAX_RETRIES);
    wifi_set_led(false); // LED off = offline
    return false;
}

void wifi_poll(void) {
    uint32_t now = to_ms_since_boot(get_absolute_time());

    switch (g_status.state) {
        case WIFI_STATE_CONNECTING:
            // Blink LED at 2Hz while connecting
            led_blink_poll(now, 500);
            break;

        case WIFI_STATE_CONNECTED:
            // Periodically check link status
            if (now < g_next_reconnect_ms) break;
            g_next_reconnect_ms = now + WIFI_RECONNECT_CHECK_MS;

            // Check if still connected
            int link = cyw43_wifi_link_status(
                &cyw43_state, CYW43_ITF_STA
            );
            if (link != CYW43_LINK_JOIN) {
                printf("[WIFI ] Link lost (status=%d) — reconnecting\n",
                       link);
                g_status.state = WIFI_STATE_DISCONNECTED;
                wifi_set_led(false);

                // Update IP to show disconnected
                strncpy(g_ip_address, "0.0.0.0", WIFI_IP_BUF_LEN);
                strncpy(g_status.ip,  "0.0.0.0", WIFI_IP_BUF_LEN);

                // Reconnect — this blocks the main loop again.
                // In Rust/TinyGo, reconnection runs asynchronously.
                // In C we accept the brief freeze — sensors and display
                // will miss a few cycles during reconnection.
                g_status.reconnect_count++;
                printf("[WIFI ] Reconnect attempt #%lu\n",
                       g_status.reconnect_count);
                wifi_connect();
            } else {
                // Still connected — update RSSI
                g_status.rssi_dbm = read_rssi();
            }
            break;

        case WIFI_STATE_DISCONNECTED:
            // Reconnect with backoff
            if (now < g_next_reconnect_ms) {
                led_blink_poll(now, 500);
                break;
            }
            g_status.reconnect_count++;
            if (!wifi_connect()) {
                // Failed — back off before next attempt
                g_retry_backoff_ms = (g_retry_backoff_ms * 2 < WIFI_RETRY_MAX_MS)
                                   ? g_retry_backoff_ms * 2
                                   : WIFI_RETRY_MAX_MS;
                g_next_reconnect_ms = now + g_retry_backoff_ms;
            }
            break;

        case WIFI_STATE_FAILED:
        case WIFI_STATE_IDLE:
        default:
            // Slow LED blink — alive but offline
            led_blink_poll(now, 2000);
            break;
    }
}

bool wifi_is_connected(void) {
    return g_status.state == WIFI_STATE_CONNECTED;
}

void wifi_get_ip(char *buf, size_t len) {
    strncpy(buf, g_ip_address, len - 1);
    buf[len - 1] = '\0';
}

int32_t wifi_get_rssi(void) {
    return g_status.rssi_dbm;
}

WifiStatus wifi_get_status(void) {
    // Return a copy — snapshot pattern same as Rust's
    // STATE.lock().await + clone() and TinyGo's stateMu.Lock() + copy
    return g_status;
}

const char *wifi_state_str(WifiState state) {
    switch (state) {
        case WIFI_STATE_IDLE:         return "IDLE";
        case WIFI_STATE_CONNECTING:   return "CONNECTING";
        case WIFI_STATE_CONNECTED:    return "CONNECTED";
        case WIFI_STATE_DISCONNECTED: return "DISCONNECTED";
        case WIFI_STATE_FAILED:       return "FAILED";
        default:                      return "UNKNOWN";
    }
}

Enter fullscreen mode Exit fullscreen mode

Rust wifi.rs Implementation

// src/wifi.rs
//
// WiFi management for the basement monitor — Pico W / CYW43439.
//
// Uses embassy-net + cyw43 crate for fully async WiFi management.
// The key difference from C and TinyGo:
//   Connection, DHCP, and reconnection all use .await — the Embassy
//   executor runs sensor, display, alarm, and HTTP tasks normally
//   during the entire WiFi lifecycle. Nothing stalls.
//
// The Pico W LED lives on the CYW43 chip, not on GPIO.
// cyw43::Control::gpio_set() is the only correct way to drive it.
// This is the same constraint as C's cyw43_arch_gpio_put().

use core::str::from_utf8;

use cyw43::JoinOptions;
use cyw43_pio::PioSpi;
use defmt::*;
use embassy_executor::Spawner;
use embassy_net::{
    Config, Stack, StackResources,
    dns::DnsSocket,
    tcp::TcpSocket,
};
use embassy_rp::{
    gpio::{Level, Output},
    peripherals::{DMA_CH0, PIN_23, PIN_24, PIN_25, PIN_29, PIO0},
    pio::Pio,
};
use embassy_sync::{
    blocking_mutex::raw::CriticalSectionRawMutex,
    mutex::Mutex,
};
use embassy_time::{Duration, Timer};
use heapless::String;
use static_cell::StaticCell;

// ── Credentials ────────────────────────────────────────────────────────────
// Set via environment variables at build time in build.rs:
//   println!("cargo:rustc-env=WIFI_SSID={}", ssid);
// Never hardcode credentials in source.
const WIFI_SSID:     &str = env!("WIFI_SSID");
const WIFI_PASSWORD: &str = env!("WIFI_PASSWORD");

// ── Configuration ──────────────────────────────────────────────────────────
const MAX_RETRIES:       u32      = 5;
const RETRY_BASE:        Duration = Duration::from_secs(2);
const RETRY_MAX:         Duration = Duration::from_secs(30);
const RECONNECT_CHECK:   Duration = Duration::from_secs(5);
const CONNECT_TIMEOUT:   Duration = Duration::from_secs(30);

// ── Shared WiFi status ─────────────────────────────────────────────────────
// Exposed to other tasks via Mutex — same pattern as SensorState in main.rs.
// C equivalent: g_ip_address global + g_status struct.
// TinyGo equivalent: package-level vars protected by sync.Mutex.

#[derive(Clone, defmt::Format)]
pub enum WifiState {
    Idle,
    Connecting,
    Connected,
    Disconnected,
    Failed,
}

#[derive(Clone)]
pub struct WifiStatus {
    pub state:           WifiState,
    pub ip:              String<16>,
    pub rssi_dbm:        i32,
    pub reconnect_count: u32,
}

impl WifiStatus {
    const fn new() -> Self {
        Self {
            state:           WifiState::Idle,
            ip:              String::new(),
            rssi_dbm:        0,
            reconnect_count: 0,
        }
    }
}

pub static WIFI_STATUS: Mutex<CriticalSectionRawMutex, WifiStatus> =
    Mutex::new(WifiStatus::new());

// ── Static resources ───────────────────────────────────────────────────────
// embassy-net requires static storage for the network stack.
// StaticCell ensures these are initialized exactly once.
// C equivalent: global structs allocated at link time.

static STATE:     StaticCell<cyw43::State>    = StaticCell::new();
static RESOURCES: StaticCell<StackResources<4>> = StaticCell::new();

// ── CYW43 firmware ────────────────────────────────────────────────────────
// The CYW43 chip requires firmware loaded at runtime over SPI.
// These blobs are included at compile time from the pico-sdk firmware files.
// C equivalent: cyw43_arch_init() loads these internally.
include!(concat!(env!("OUT_DIR"), "/cyw43_fw.rs"));

// ── WiFi init — called from main.rs ───────────────────────────────────────
// Returns the network Stack reference used by the HTTP server task.
// Spawns two internal tasks: cyw43_task (chip driver) and net_task (lwIP).

pub async fn init(
    spawner:  Spawner,
    pwr:      PIN_23,
    cs:       PIN_24,
    dio:      PIN_25,  // Pico W LED is also on this line via CYW43
    clk:      PIN_29,
    pio:      PIO0,
    dma:      DMA_CH0,
    irqs:     crate::Irqs,
) -> &'static Stack<cyw43::NetDriver<'static>> {

    // ── SPI to CYW43 ──────────────────────────────────────────────────
    // The CYW43439 is connected internally via PIO-based SPI.
    // PIO (Programmable I/O) runs the SPI protocol in hardware —
    // freeing the CPU entirely during transfers.
    // C equivalent: handled inside cyw43_arch_init() transparently.
    let pwr = Output::new(pwr, Level::Low);
    let cs  = Output::new(cs,  Level::High);
    let mut pio = Pio::new(pio, irqs);

    let spi = PioSpi::new(
        &mut pio.common,
        pio.sm0,
        pio.irq0,
        cs,
        dio,
        clk,
        dma,
    );

    // ── CYW43 driver init ──────────────────────────────────────────────
    let state = STATE.init(cyw43::State::new());
    let (net_device, mut control, runner) = cyw43::new(
        state,
        pwr,
        spi,
        CYW43_FW,      // firmware blob included at compile time
    ).await;

    // Spawn the CYW43 background task — handles chip communication
    // Equivalent to cyw43_arch's background interrupt handler in C
    spawner.spawn(cyw43_task(runner)).unwrap();

    // ── Network stack (embassy-net / lwIP equivalent) ──────────────────
    // DHCP configuration — same as C's lwIP DHCP
    let config = Config::dhcpv4(Default::default());

    // Random seed for TCP sequence numbers — use RP2040 ROSC
    let seed = embassy_rp::clocks::rosc_random_u64();

    let resources = RESOURCES.init(StackResources::<4>::new());
    let (stack, runner) = embassy_net::new(
        net_device,
        config,
        resources,
        seed,
    );

    // Spawn the network stack task
    spawner.spawn(net_task(runner)).unwrap();

    // ── Spawn the WiFi management task ────────────────────────────────
    // This is the async equivalent of wifi_connect() + wifi_poll()
    // in C — but non-blocking. The sensor, display, and alarm tasks
    // run normally while this task is waiting for DHCP or reconnecting.
    spawner.spawn(wifi_task(control, stack)).unwrap();

    stack
}

// ── CYW43 background task ──────────────────────────────────────────────────
// Runs the chip driver — must never be starved of CPU time.
// Embassy's cooperative scheduler ensures it runs between other task yields.
// C equivalent: interrupt handler registered by cyw43_arch_init().

#[embassy_executor::task]
async fn cyw43_task(
    runner: cyw43::Runner<
        'static,
        Output<'static>,
        PioSpi<'static, PIO0, 0, DMA_CH0>,
    >,
) -> ! {
    runner.run().await
}

// ── Network stack task ─────────────────────────────────────────────────────
// Runs the embassy-net TCP/IP stack — equivalent to lwIP's background
// processing in C's threadsafe_background mode.

#[embassy_executor::task]
async fn net_task(
    mut runner: embassy_net::Runner<'static, cyw43::NetDriver<'static>>,
) -> ! {
    runner.run().await
}

// ── WiFi management task ───────────────────────────────────────────────────
// Handles connection, DHCP, LED, RSSI polling, and reconnection.
// All async — other tasks run during every .await point.
// This is the cleanest of the three implementations for this reason.

#[embassy_executor::task]
async fn wifi_task(
    mut control: cyw43::Control<'static>,
    stack:       &'static Stack<cyw43::NetDriver<'static>>,
) {
    // Set hostname for DHCP
    // stack.set_hostname("basement-monitor");

    info!("WiFi task started");

    let mut reconnect_count: u32 = 0;

    // ── Initial connection with retry + backoff ────────────────────────
    loop {
        match connect_with_retry(&mut control).await {
            Ok(()) => break,
            Err(()) => {
                error!("All WiFi connection attempts failed — offline mode");
                update_status(|s| s.state = WifiState::Failed).await;
                // Wait before trying again — deep sleep
                Timer::after(Duration::from_secs(60)).await;
                // Loop back to retry
            }
        }
    }

    // ── Wait for DHCP lease ────────────────────────────────────────────
    // embassy-net handles DHCP internally — we wait until config is ready.
    // C equivalent: IP is available immediately after
    //   cyw43_arch_wifi_connect_timeout_ms() returns.
    info!("Waiting for DHCP...");
    update_status(|s| s.state = WifiState::Connecting).await;

    loop {
        if let Some(config) = stack.config_v4() {
            let mut ip_str: String<16> = String::new();
            let ip = config.address.address();
            // Format IP into heapless String — zero allocation
            core::fmt::write(
                &mut ip_str,
                format_args!("{}.{}.{}.{}",
                    ip.0[0], ip.0[1], ip.0[2], ip.0[3]),
            ).ok();

            info!("DHCP lease obtained — IP: {}", ip_str.as_str());

            update_status(|s| {
                s.state = WifiState::Connected;
                s.ip    = ip_str.clone();
            }).await;

            // Solid LED — connected
            control.gpio_set(0, true).await;
            break;
        }
        Timer::after(Duration::from_millis(500)).await;
    }

    // ── Steady-state: monitor connection, poll RSSI ────────────────────
    loop {
        Timer::after(RECONNECT_CHECK).await;

        // Check link status
        if !stack.is_link_up() {
            warn!("WiFi link lost — reconnecting");

            // LED off — disconnected
            control.gpio_set(0, false).await;

            reconnect_count += 1;
            update_status(|s| {
                s.state           = WifiState::Disconnected;
                s.reconnect_count = reconnect_count;
                s.ip              = String::new();
            }).await;

            // Reconnect — other tasks run normally during this entire process
            // This is the most significant advantage over C:
            // the sensor task is still polling, the alarm is still pulsing,
            // the display is still refreshing — all while we reconnect.
            match connect_with_retry(&mut control).await {
                Ok(()) => {
                    // Wait for new DHCP lease
                    loop {
                        if let Some(config) = stack.config_v4() {
                            let mut ip_str: String<16> = String::new();
                            let ip = config.address.address();
                            core::fmt::write(
                                &mut ip_str,
                                format_args!("{}.{}.{}.{}",
                                    ip.0[0], ip.0[1], ip.0[2], ip.0[3]),
                            ).ok();
                            info!("Reconnected — IP: {} (reconnect #{})",
                                ip_str.as_str(), reconnect_count);
                            update_status(|s| {
                                s.state = WifiState::Connected;
                                s.ip    = ip_str.clone();
                            }).await;
                            control.gpio_set(0, true).await;
                            break;
                        }
                        Timer::after(Duration::from_millis(500)).await;
                    }
                }
                Err(()) => {
                    error!("Reconnection failed — continuing offline");
                    update_status(|s| s.state = WifiState::Failed).await;
                }
            }
        } else {
            // Still connected — update RSSI
            // cyw43 RSSI read is async — yields to other tasks
            if let Ok(rssi) = control.rssi().await {
                update_status(|s| s.rssi_dbm = rssi as i32).await;
            }
        }
    }
}

// ── Connection with retry + exponential backoff ────────────────────────────

async fn connect_with_retry(
    control: &mut cyw43::Control<'static>,
) -> Result<(), ()> {
    let mut backoff = RETRY_BASE;

    for attempt in 1..=MAX_RETRIES {
        info!("WiFi connect attempt {}/{}", attempt, MAX_RETRIES);

        // Blink LED during connection attempt
        control.gpio_set(0, true).await;

        // join() is fully async — other tasks run during the handshake.
        // C equivalent: cyw43_arch_wifi_connect_timeout_ms() — blocks CPU.
        // TinyGo equivalent: dev.ConnectWPA2() — blocks goroutine.
        let result = embassy_time::with_timeout(
            CONNECT_TIMEOUT,
            control.join(WIFI_SSID, JoinOptions::new(WIFI_PASSWORD.as_bytes())),
        ).await;

        control.gpio_set(0, false).await;

        match result {
            Ok(Ok(())) => {
                info!("WiFi connected to '{}'", WIFI_SSID);
                return Ok(());
            }
            Ok(Err(e)) => {
                warn!("Connect failed (attempt {}): status={}", attempt, e.status);
            }
            Err(_) => {
                warn!("Connect timed out (attempt {})", attempt);
            }
        }

        if attempt < MAX_RETRIES {
            info!("Retrying in {}ms...", backoff.as_millis());

            // Blink LED during backoff wait — other tasks run freely
            let blink_end = embassy_time::Instant::now() + backoff;
            while embassy_time::Instant::now() < blink_end {
                control.gpio_set(0, true).await;
                Timer::after(Duration::from_millis(250)).await;
                control.gpio_set(0, false).await;
                Timer::after(Duration::from_millis(250)).await;
            }

            // Exponential backoff
            backoff = (backoff * 2).min(RETRY_MAX);
        }
    }

    Err(())
}

// ── Status update helper ───────────────────────────────────────────────────
// Locks WIFI_STATUS, applies mutation, unlocks.
// Same pattern as UpdateState() in main.rs.

async fn update_status(f: impl FnOnce(&mut WifiStatus)) {
    let mut status = WIFI_STATUS.lock().await;
    f(&mut *status);
}

// ── Public getters ─────────────────────────────────────────────────────────
// Called by http.rs and display.rs to read current WiFi state.

pub async fn get_ip() -> String<16> {
    WIFI_STATUS.lock().await.ip.clone()
}

pub async fn get_status() -> WifiStatus {
    WIFI_STATUS.lock().await.clone()
}

pub fn is_connected() -> bool {
    // Non-async check using try_lock — returns false if lock is held.
    // Safe for status bar display — occasional miss is acceptable.
    WIFI_STATUS.try_lock()
        .map(|s| matches!(s.state, WifiState::Connected))
        .unwrap_or(false)
}

Enter fullscreen mode Exit fullscreen mode

Now, your basement water monitor has internet connectivity!

Implementing the Built In Web Server

Routes:
  GET  /              → HTML dashboard (auto-refresh 3s)
  GET  /sensors       → JSON sensor snapshot
  GET  /wifi          → JSON wifi status (ip, rssi, state, reconnects)
  GET  /alarm         → JSON alarm status (level, latched, relay)
  POST /alarm/ack     → acknowledge latched alarm, returns JSON
  GET  /health        → "OK" plaintext — for uptime monitors
  GET  /metrics       → plaintext key=value for Prometheus scraping
  *                   → 404

Hardening:
  Request timeout     → close connection if no data in 10s
  Max body size       → reject requests > 512 bytes
  Concurrent limit    → max 3 simultaneous connections (memory budget)
  Connection: close   → no persistent connections on embedded
  CORS header         → Access-Control-Allow-Origin: *
  Content-Length      → always set — no chunked encoding

Shared state reads:
  SensorState snapshot
  WifiStatus snapshot
  AlarmState snapshot
Enter fullscreen mode Exit fullscreen mode

Go http/server.go Implementation

// http/server.go
//
// HTTP/1.0 server for the basement monitor.
// Built on TinyGo's net package (lwIP underneath).
//
// Routes:
//   GET  /          → HTML dashboard
//   GET  /sensors   → JSON sensor snapshot
//   GET  /wifi      → JSON WiFi status
//   GET  /alarm     → JSON alarm status
//   POST /alarm/ack → acknowledge latched alarm
//   GET  /health    → plaintext OK
//   GET  /metrics   → Prometheus plaintext
//   *               → 404
//
// TinyGo net.Listen wraps lwIP's raw TCP API behind Go's standard
// net.Listener interface — same TCP stack as C, cleaner surface.
// Goroutine-per-connection is idiomatic Go. On a Pico W with 264KB RAM
// we cap at maxConnections goroutines — the embedded equivalent of
// your room.WaitingRoom capacity limit.
//
// Allocation note:
//   fmt.Sprintf allocates on every route handler call.
//   At human-request frequency this is acceptable.
//   A production TinyGo server would use a pre-allocated byte buffer
//   and strconv to eliminate allocations — at the cost of readability.

package http

import (
    "basement-monitor/alarm"
    "basement-monitor/sensors"
    "basement-monitor/wifi"
    "fmt"
    "net"
    "strings"
    "sync/atomic"
    "time"
)

// ── Configuration ──────────────────────────────────────────────────────────
const (
    httpPort       = ":80"
    maxConnections = 3
    requestTimeout = 10 * time.Second
    requestBufSize = 512
)

// ── Active connection counter ──────────────────────────────────────────────
// Atomic counter — safe to read/write from multiple goroutines.
// C equivalent: g_active_connections uint8.
// Rust equivalent: #[embassy_executor::task(pool_size=3)] — static bound.
var activeConns int32

// ── ServeTask ──────────────────────────────────────────────────────────────

// ServeTask starts the HTTP listener and accepts connections.
// Runs as a goroutine spawned from main.go after WiFi connects.
// Equivalent to http_server_init() in C and server_task() in Rust.

func ServeTask(
    getState   func() sensors.SensorState,
    getAlarm   func() alarm.AlarmState,
    acknowledge func(),
) {
    ln, err := net.Listen("tcp", httpPort)
    if err != nil {
        println("[HTTP ] Failed to listen:", err.Error())
        return
    }
    println("[HTTP ] Listening on port 80")

    for {
        conn, err := ln.Accept()
        if err != nil {
            println("[HTTP ] Accept error:", err.Error())
            time.Sleep(100 * time.Millisecond)
            continue
        }

        // Enforce concurrent connection limit.
        // Same cap as C's HTTP_MAX_CONNECTIONS and Rust's pool_size=3.
        // This is your room.WaitingRoom.Init(3) equivalent —
        // excess connections are rejected immediately rather than queued.
        // A full room implementation would queue them with position tracking.
        current := atomic.AddInt32(&activeConns, 1)
        if current > maxConnections {
            atomic.AddInt32(&activeConns, -1)
            println("[HTTP ] Connection limit reached — rejecting")
            conn.Write([]byte(
                "HTTP/1.0 503 Service Unavailable\r\n" +
                "Content-Length: 19\r\n\r\n" +
                "Too many connections"))
            conn.Close()
            continue
        }

        println("[HTTP ] Client connected (", current, "/", maxConnections, "active)")

        // Goroutine per connection — each runs independently.
        // Equivalent to Rust's connection_task() async task.
        // C has no equivalent — it handles one connection at a time
        // via the lwIP callback state machine.
        go func(c net.Conn) {
            defer func() {
                atomic.AddInt32(&activeConns, -1)
                c.Close()
            }()
            handleConn(c, getState, getAlarm, acknowledge)
        }(conn)
    }
}

// ── Connection handler ─────────────────────────────────────────────────────

func handleConn(
    conn        net.Conn,
    getState    func() sensors.SensorState,
    getAlarm    func() alarm.AlarmState,
    acknowledge func(),
) {
    // Set read deadline — close if client takes too long.
    // C equivalent: conn->open_time_ms checked in http_server_poll().
    // Rust equivalent: with_timeout(REQUEST_TIMEOUT, ...).await.
    conn.SetDeadline(time.Now().Add(requestTimeout))

    buf := make([]byte, requestBufSize)
    n, err := conn.Read(buf)
    if err != nil || n == 0 {
        return
    }

    request := string(buf[:n])

    // Route
    switch {
    case strings.Contains(request, " /sensors"):
        serveSensors(conn, getState)
    case strings.Contains(request, " /wifi"):
        serveWifi(conn)
    case strings.Contains(request, " /alarm/ack"):
        serveAlarmAck(conn, request, acknowledge, getAlarm)
    case strings.Contains(request, " /alarm"):
        serveAlarm(conn, getAlarm)
    case strings.Contains(request, " /metrics"):
        serveMetrics(conn, getState, getAlarm)
    case strings.Contains(request, " /health"):
        serveHealth(conn)
    case strings.Contains(request, " /"):
        serveDashboard(conn, getState, getAlarm)
    default:
        serve404(conn)
    }
}

// ── Response helper ────────────────────────────────────────────────────────

func sendResponse(
    conn        net.Conn,
    status      int,
    statusText  string,
    contentType string,
    body        string,
) {
    // fmt.Sprintf allocates — GC cost.
    // At HTTP request frequency (human scale) this is acceptable.
    // Rust uses heapless::String — zero allocation.
    // C uses snprintf into static char[] — zero allocation.
    headers := fmt.Sprintf(
        "HTTP/1.0 %d %s\r\n"+
        "Content-Type: %s\r\n"+
        "Content-Length: %d\r\n"+
        "Connection: close\r\n"+
        "Access-Control-Allow-Origin: *\r\n"+
        "Cache-Control: no-cache\r\n"+
        "\r\n",
        status, statusText,
        contentType,
        len(body),
    )
    conn.Write([]byte(headers))
    conn.Write([]byte(body))
}

func sendJSON(conn net.Conn, body string) {
    sendResponse(conn, 200, "OK", "application/json", body)
}

func sendHTML(conn net.Conn, body string) {
    sendResponse(conn, 200, "OK", "text/html; charset=utf-8", body)
}

func sendText(conn net.Conn, body string) {
    sendResponse(conn, 200, "OK", "text/plain", body)
}

func serve404(conn net.Conn) {
    sendResponse(conn, 404, "Not Found", "text/plain", "Not Found")
}

// ── Route handlers ─────────────────────────────────────────────────────────

func serveSensors(conn net.Conn, getState func() sensors.SensorState) {
    s := getState()
    body := fmt.Sprintf(
        `{"water":%t,"temp_c":%.1f,"humidity_pct":%.1f,`+
        `"motion":%t,"co_ppm":%d,"alarm_active":%t}`,
        s.WaterDetected,
        s.TemperatureC,
        s.HumidityPct,
        s.MotionDetected,
        s.CoPpm,
        s.AlarmActive,
    )
    sendJSON(conn, body)
}

func serveWifi(conn net.Conn) {
    ws := wifi.GetStatus()
    body := fmt.Sprintf(
        `{"state":"%s","ip":"%s","rssi_dbm":%d,"reconnect_count":%d}`,
        ws.State.String(),
        ws.IP,
        ws.RSSIdbm,
        ws.ReconnectCount,
    )
    sendJSON(conn, body)
}

func serveAlarm(conn net.Conn, getAlarm func() alarm.AlarmState) {
    a := getAlarm()
    latched := a.WaterLatched || a.CoCritLatched
    body := fmt.Sprintf(
        `{"level":"%s","active":%t,"latched":%t,`+
        `"water_latched":%t,"co_critical_latched":%t,"relay_active":%t}`,
        a.Level.String(),
        a.Active,
        latched,
        a.WaterLatched,
        a.CoCritLatched,
        a.RelayActive,
    )
    sendJSON(conn, body)
}

func serveAlarmAck(
    conn        net.Conn,
    request     string,
    acknowledge func(),
    getAlarm    func() alarm.AlarmState,
) {
    if !strings.HasPrefix(request, "POST") {
        sendResponse(conn, 405, "Method Not Allowed",
            "text/plain", "Method Not Allowed")
        return
    }

    acknowledge()

    // Brief wait for alarm goroutine to process the acknowledge
    time.Sleep(50 * time.Millisecond)

    a := getAlarm()
    body := fmt.Sprintf(
        `{"acknowledged":true,"level":"%s"}`,
        a.Level.String(),
    )
    sendJSON(conn, body)
}

func serveHealth(conn net.Conn) {
    sendText(conn, "OK")
}

func serveMetrics(
    conn     net.Conn,
    getState func() sensors.SensorState,
    getAlarm func() alarm.AlarmState,
) {
    s := getState()
    ws := wifi.GetStatus()
    a := getAlarm()

    waterVal  := 0
    if s.WaterDetected  { waterVal  = 1 }
    motionVal := 0
    if s.MotionDetected { motionVal = 1 }

    body := fmt.Sprintf(
        "# TYPE basement_water_detected gauge\n"+
        "basement_water_detected %d\n"+
        "# TYPE basement_temperature_celsius gauge\n"+
        "basement_temperature_celsius %.2f\n"+
        "# TYPE basement_humidity_percent gauge\n"+
        "basement_humidity_percent %.2f\n"+
        "# TYPE basement_motion_detected gauge\n"+
        "basement_motion_detected %d\n"+
        "# TYPE basement_co_ppm gauge\n"+
        "basement_co_ppm %d\n"+
        "# TYPE basement_alarm_level gauge\n"+
        "basement_alarm_level %d\n"+
        "# TYPE basement_wifi_rssi_dbm gauge\n"+
        "basement_wifi_rssi_dbm %d\n"+
        "# TYPE basement_wifi_reconnects counter\n"+
        "basement_wifi_reconnects %d\n",
        waterVal,
        s.TemperatureC,
        s.HumidityPct,
        motionVal,
        s.CoPpm,
        int(a.Level),
        ws.RSSIdbm,
        ws.ReconnectCount,
    )

    sendResponse(conn, 200, "OK",
        "text/plain; version=0.0.4", body)
}

func serveDashboard(
    conn     net.Conn,
    getState func() sensors.SensorState,
    getAlarm func() alarm.AlarmState,
) {
    s  := getState()
    ws := wifi.GetStatus()
    a  := getAlarm()

    waterClass := ""
    if s.WaterDetected { waterClass = "alert" }
    waterColor := "ok"
    if s.WaterDetected { waterColor = "danger" }
    waterText := "Dry"
    if s.WaterDetected { waterText = "WATER DETECTED" }
    alarmColor := "ok"
    if s.AlarmActive { alarmColor = "danger" }
    coColor := "ok"
    if s.CoPpm >= 50 { coColor = "warn" }
    motionText := "Clear"
    if s.MotionDetected { motionText = "Detected" }

    body := fmt.Sprintf(`<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="refresh" content="3">
  <title>Basement Monitor</title>
  <style>
    body{font-family:system-ui;background:#0f1117;color:#e2e8f0;
         max-width:600px;margin:2rem auto;padding:1rem}
    h1{color:#6c8ef5}
    .card{background:#1a1d27;border-radius:8px;padding:1rem;margin:.5rem 0}
    .alert{background:#7f1d1d;border:1px solid #ef4444}
    .ok{color:#34d399}.warn{color:#fbbf24}
    .danger{color:#ef4444;font-weight:bold}
    a{color:#6c8ef5}
  </style>
</head>
<body>
  <h1>Basement Monitor</h1>
  <div class="card %s">
    <b>Water:</b> <span class="%s">%s</span>
  </div>
  <div class="card">
    <b>Temp:</b> %.1f&deg;C &nbsp; <b>Humidity:</b> %.1f%%
  </div>
  <div class="card">
    <b>Motion:</b> %s &nbsp;
    <b>CO:</b> <span class="%s">%d ppm</span>
  </div>
  <div class="card">
    <b>Alarm:</b> <span class="%s">%s</span>
    &nbsp;
    <a href="#" onclick="fetch('/alarm/ack',{method:'POST'});return false;">
      Acknowledge
    </a>
  </div>
  <div class="card">
    <b>WiFi:</b> %s &nbsp; <b>RSSI:</b> %ddBm &nbsp;
    <b>Reconnects:</b> %d
  </div>
  <p style="color:#475569;font-size:.8rem">
    Auto-refresh 3s &middot;
    <a href="/sensors">JSON</a> &middot;
    <a href="/metrics">Metrics</a> &middot;
    <a href="/health">Health</a>
  </p>
</body>
</html>`,
        waterClass, waterColor, waterText,
        s.TemperatureC, s.HumidityPct,
        motionText, coColor, s.CoPpm,
        alarmColor, a.Level.String(),
        ws.IP, ws.RSSIdbm, ws.ReconnectCount,
    )

    sendHTML(conn, body)
}

Enter fullscreen mode Exit fullscreen mode

C http.h | http.c Implementation

// http.h
#ifndef HTTP_H
#define HTTP_H

#include <stdbool.h>
#include <stdint.h>
#include "lwip/tcp.h"

// ── Configuration ──────────────────────────────────────────────────────────
#define HTTP_PORT               80
#define HTTP_MAX_CONNECTIONS    3       // memory budget: 3 × conn struct
#define HTTP_REQUEST_MAX        512     // max request bytes to read
#define HTTP_BODY_MAX           2048    // max response body bytes
#define HTTP_RESPONSE_MAX       2560    // body + headers
#define HTTP_TIMEOUT_MS         10000   // close if idle for 10s

// ── Connection state ───────────────────────────────────────────────────────
// One per active TCP connection.
// Equivalent to Rust's per-accept TcpSocket and TinyGo's net.Conn.
typedef struct {
    struct tcp_pcb *pcb;
    uint8_t         req_buf[HTTP_REQUEST_MAX];
    uint16_t        req_len;
    uint32_t        open_time_ms;   // for timeout enforcement
    bool            responded;
    bool            closed;
} HttpConn;

// ── Public API ─────────────────────────────────────────────────────────────
void http_server_init(void);
void http_server_poll(void);    // call from main loop — checks timeouts

#endif // HTTP_H

Enter fullscreen mode Exit fullscreen mode
// http.c
//
// HTTP/1.0 server for the basement monitor.
// Built on lwIP raw TCP API.
//
// Routes:
//   GET  /          → HTML dashboard
//   GET  /sensors   → JSON sensor snapshot
//   GET  /wifi      → JSON WiFi status
//   GET  /alarm     → JSON alarm status
//   POST /alarm/ack → acknowledge latched alarm
//   GET  /health    → plaintext OK
//   GET  /metrics   → Prometheus-style key=value
//   *               → 404
//
// Architecture:
//   lwIP raw API is callback-driven. The mental model is:
//     on_accept()  → new connection arrives
//     on_recv()    → request bytes arrive (may be called multiple times)
//     on_sent()    → response bytes acknowledged
//     on_error()   → fatal error, conn already freed by lwIP
//     conn_close() → we initiate close
//
//   This is the most complex of the three implementations because
//   lwIP raw API requires you to manage partial reads manually.
//   A single HTTP request may arrive across multiple on_recv() calls.
//   We buffer into req_buf until we see \r\n\r\n (end of headers)
//   then route and respond.
//
//   Rust/embassy-net: socket.read(&mut buf).await — linear, one call.
//   TinyGo/net:       conn.Read(buf) — linear, one call.
//   C/lwIP raw:       on_recv() callback, may be called N times.

#include "http.h"
#include "sensors.h"
#include "alarm.h"
#include "wifi.h"
#include "pico/stdlib.h"
#include "lwip/tcp.h"
#include "lwip/err.h"
#include <stdio.h>
#include <string.h>
#include <stdlib.h>

// ── Connection pool ────────────────────────────────────────────────────────
// Fixed pool avoids malloc() — same philosophy as Rust's heapless.
// We pre-allocate HTTP_MAX_CONNECTIONS slots and reuse them.
// If all slots are full, new connections are rejected immediately.
// This is the embedded equivalent of your room.WaitingRoom cap.

static HttpConn  g_conn_pool[HTTP_MAX_CONNECTIONS];
static bool      g_conn_used[HTTP_MAX_CONNECTIONS];
static uint8_t   g_active_connections = 0;

static struct tcp_pcb *g_listen_pcb = NULL;

// ── Static response buffers ────────────────────────────────────────────────
// Static (not stack) because they are large and lwIP callbacks may
// be called from interrupt context on some configurations.
// Making them static ensures they don't blow the stack or get
// optimized away.
// NOT reentrant — only safe because we handle one request at a time
// per connection and lwIP's threadsafe_background mode serializes
// callback invocation.
static char g_body[HTTP_BODY_MAX];
static char g_response[HTTP_RESPONSE_MAX];

// ── Connection pool management ─────────────────────────────────────────────

static HttpConn *conn_alloc(void) {
    for (int i = 0; i < HTTP_MAX_CONNECTIONS; i++) {
        if (!g_conn_used[i]) {
            g_conn_used[i] = true;
            memset(&g_conn_pool[i], 0, sizeof(HttpConn));
            g_conn_pool[i].open_time_ms =
                to_ms_since_boot(get_absolute_time());
            g_active_connections++;
            return &g_conn_pool[i];
        }
    }
    return NULL; // pool exhausted
}

static void conn_free(HttpConn *conn) {
    for (int i = 0; i < HTTP_MAX_CONNECTIONS; i++) {
        if (&g_conn_pool[i] == conn) {
            g_conn_used[i] = false;
            if (g_active_connections > 0) g_active_connections--;
            return;
        }
    }
}

// ── Response helpers ───────────────────────────────────────────────────────

// Send a complete HTTP response and close the connection.
// All routes funnel through here — single point for headers.
static void send_response(
    struct tcp_pcb *pcb,
    HttpConn       *conn,
    uint16_t        status_code,
    const char     *status_text,
    const char     *content_type,
    const char     *body,
    size_t          body_len
) {
    int resp_len = snprintf(g_response, sizeof(g_response),
        "HTTP/1.0 %u %s\r\n"
        "Content-Type: %s\r\n"
        "Content-Length: %zu\r\n"
        "Connection: close\r\n"
        "Access-Control-Allow-Origin: *\r\n"
        "Cache-Control: no-cache\r\n"
        "\r\n",
        status_code, status_text,
        content_type,
        body_len
    );

    if (resp_len < 0 || resp_len >= (int)sizeof(g_response)) {
        printf("[HTTP ] Response header overflow\n");
        return;
    }

    // Write headers
    err_t err = tcp_write(pcb, g_response, (u16_t)resp_len,
                          TCP_WRITE_FLAG_COPY);
    if (err != ERR_OK) {
        printf("[HTTP ] tcp_write headers failed: %d\n", err);
        return;
    }

    // Write body
    if (body && body_len > 0) {
        err = tcp_write(pcb, body, (u16_t)body_len, TCP_WRITE_FLAG_COPY);
        if (err != ERR_OK) {
            printf("[HTTP ] tcp_write body failed: %d\n", err);
            return;
        }
    }

    tcp_output(pcb);
    conn->responded = true;
}

static void send_json(struct tcp_pcb *pcb, HttpConn *conn,
                      const char *json, size_t len) {
    send_response(pcb, conn, 200, "OK",
                  "application/json", json, len);
}

static void send_html(struct tcp_pcb *pcb, HttpConn *conn,
                      const char *html, size_t len) {
    send_response(pcb, conn, 200, "OK",
                  "text/html; charset=utf-8", html, len);
}

static void send_text(struct tcp_pcb *pcb, HttpConn *conn,
                      const char *text) {
    send_response(pcb, conn, 200, "OK",
                  "text/plain", text, strlen(text));
}

static void send_404(struct tcp_pcb *pcb, HttpConn *conn) {
    send_response(pcb, conn, 404, "Not Found",
                  "text/plain", "Not Found", 9);
}

static void send_405(struct tcp_pcb *pcb, HttpConn *conn) {
    send_response(pcb, conn, 405, "Method Not Allowed",
                  "text/plain", "Method Not Allowed", 18);
}

// ── Route handlers ─────────────────────────────────────────────────────────

static void route_sensors(struct tcp_pcb *pcb, HttpConn *conn) {
    state_lock();
    SensorState s = g_state;
    state_unlock();

    int len = snprintf(g_body, sizeof(g_body),
        "{"
        "\"water\":%s,"
        "\"temp_c\":%.1f,"
        "\"humidity_pct\":%.1f,"
        "\"motion\":%s,"
        "\"co_ppm\":%u,"
        "\"alarm_active\":%s"
        "}",
        s.water_detected  ? "true" : "false",
        s.temperature_c,
        s.humidity_pct,
        s.motion_detected ? "true" : "false",
        s.co_ppm,
        s.alarm_active    ? "true" : "false"
    );

    send_json(pcb, conn, g_body, (size_t)len);
}

static void route_wifi(struct tcp_pcb *pcb, HttpConn *conn) {
    WifiStatus ws = wifi_get_status();

    int len = snprintf(g_body, sizeof(g_body),
        "{"
        "\"state\":\"%s\","
        "\"ip\":\"%s\","
        "\"rssi_dbm\":%d,"
        "\"reconnect_count\":%lu"
        "}",
        wifi_state_str(ws.state),
        ws.ip,
        ws.rssi_dbm,
        ws.reconnect_count
    );

    send_json(pcb, conn, g_body, (size_t)len);
}

static void route_alarm_get(struct tcp_pcb *pcb, HttpConn *conn) {
    // Read alarm state
    state_lock();
    bool alarm_active = g_state.alarm_active;
    state_unlock();

    AlarmLevel level   = alarm_current_level();
    bool       latched = g_alarm.water_latched ||
                         g_alarm.co_critical_latched;

    int len = snprintf(g_body, sizeof(g_body),
        "{"
        "\"level\":\"%s\","
        "\"active\":%s,"
        "\"latched\":%s,"
        "\"water_latched\":%s,"
        "\"co_critical_latched\":%s,"
        "\"relay_active\":%s"
        "}",
        alarm_level_str(level),
        alarm_active              ? "true" : "false",
        latched                   ? "true" : "false",
        g_alarm.water_latched     ? "true" : "false",
        g_alarm.co_critical_latched ? "true" : "false",
        g_alarm.relay_active      ? "true" : "false"
    );

    send_json(pcb, conn, g_body, (size_t)len);
}

static void route_alarm_ack(struct tcp_pcb *pcb, HttpConn *conn,
                            const char *request) {
    // Only accept POST
    if (strncmp(request, "POST", 4) != 0) {
        send_405(pcb, conn);
        return;
    }

    alarm_acknowledge();

    AlarmLevel post_level = alarm_current_level();
    int len = snprintf(g_body, sizeof(g_body),
        "{"
        "\"acknowledged\":true,"
        "\"level\":\"%s\""
        "}",
        alarm_level_str(post_level)
    );

    send_json(pcb, conn, g_body, (size_t)len);
}

static void route_health(struct tcp_pcb *pcb, HttpConn *conn) {
    send_text(pcb, conn, "OK");
}

static void route_metrics(struct tcp_pcb *pcb, HttpConn *conn) {
    // Prometheus-style plaintext metrics.
    // Compatible with node_exporter text format — push gateway or
    // scrape directly with prometheus.yml static_configs.
    state_lock();
    SensorState s = g_state;
    state_unlock();

    WifiStatus ws    = wifi_get_status();
    AlarmLevel level = alarm_current_level();

    int len = snprintf(g_body, sizeof(g_body),
        "# HELP basement_water_detected Water sensor state\n"
        "# TYPE basement_water_detected gauge\n"
        "basement_water_detected %d\n"
        "\n"
        "# HELP basement_temperature_celsius Temperature in Celsius\n"
        "# TYPE basement_temperature_celsius gauge\n"
        "basement_temperature_celsius %.2f\n"
        "\n"
        "# HELP basement_humidity_percent Relative humidity percent\n"
        "# TYPE basement_humidity_percent gauge\n"
        "basement_humidity_percent %.2f\n"
        "\n"
        "# HELP basement_motion_detected PIR motion sensor state\n"
        "# TYPE basement_motion_detected gauge\n"
        "basement_motion_detected %d\n"
        "\n"
        "# HELP basement_co_ppm Carbon monoxide PPM\n"
        "# TYPE basement_co_ppm gauge\n"
        "basement_co_ppm %u\n"
        "\n"
        "# HELP basement_alarm_level Alarm severity (0=none 1=advisory 2=warning 3=critical)\n"
        "# TYPE basement_alarm_level gauge\n"
        "basement_alarm_level %d\n"
        "\n"
        "# HELP basement_wifi_rssi_dbm WiFi signal strength dBm\n"
        "# TYPE basement_wifi_rssi_dbm gauge\n"
        "basement_wifi_rssi_dbm %d\n"
        "\n"
        "# HELP basement_wifi_reconnects Total WiFi reconnection count\n"
        "# TYPE basement_wifi_reconnects counter\n"
        "basement_wifi_reconnects %lu\n",
        s.water_detected  ? 1 : 0,
        s.temperature_c,
        s.humidity_pct,
        s.motion_detected ? 1 : 0,
        s.co_ppm,
        (int)level,
        ws.rssi_dbm,
        ws.reconnect_count
    );

    send_response(pcb, conn, 200, "OK",
                  "text/plain; version=0.0.4",
                  g_body, (size_t)len);
}

static void route_dashboard(struct tcp_pcb *pcb, HttpConn *conn) {
    state_lock();
    SensorState s = g_state;
    state_unlock();

    WifiStatus ws    = wifi_get_status();
    AlarmLevel level = alarm_current_level();

    const char *water_class  = s.water_detected  ? "alert"  : "";
    const char *water_color  = s.water_detected  ? "danger" : "ok";
    const char *water_text   = s.water_detected  ? "WATER DETECTED" : "Dry";
    const char *alarm_color  = s.alarm_active    ? "danger" : "ok";
    const char *alarm_text   = alarm_level_str(level);
    const char *co_color     = s.co_ppm >= 50    ? "warn"   : "ok";
    const char *motion_text  = s.motion_detected ? "Detected" : "Clear";

    int len = snprintf(g_body, sizeof(g_body),
        "<!DOCTYPE html>\n"
        "<html lang=\"en\">\n"
        "<head>\n"
        "  <meta charset=\"UTF-8\">\n"
        "  <meta http-equiv=\"refresh\" content=\"3\">\n"
        "  <title>Basement Monitor</title>\n"
        "  <style>\n"
        "    body{font-family:system-ui;background:#0f1117;color:#e2e8f0;"
               "max-width:600px;margin:2rem auto;padding:1rem}\n"
        "    h1{color:#6c8ef5}\n"
        "    .card{background:#1a1d27;border-radius:8px;"
               "padding:1rem;margin:.5rem 0}\n"
        "    .alert{background:#7f1d1d;border:1px solid #ef4444}\n"
        "    .ok{color:#34d399}.warn{color:#fbbf24}\n"
        "    .danger{color:#ef4444;font-weight:bold}\n"
        "    a{color:#6c8ef5}\n"
        "  </style>\n"
        "</head>\n"
        "<body>\n"
        "  <h1>Basement Monitor</h1>\n"
        "  <div class=\"card %s\">\n"
        "    <b>Water:</b> <span class=\"%s\">%s</span>\n"
        "  </div>\n"
        "  <div class=\"card\">\n"
        "    <b>Temp:</b> %.1f&deg;C &nbsp; <b>Humidity:</b> %.1f%%\n"
        "  </div>\n"
        "  <div class=\"card\">\n"
        "    <b>Motion:</b> %s &nbsp;\n"
        "    <b>CO:</b> <span class=\"%s\">%u ppm</span>\n"
        "  </div>\n"
        "  <div class=\"card\">\n"
        "    <b>Alarm:</b> <span class=\"%s\">%s</span>\n"
        "    &nbsp;\n"
        "    <a href=\"/alarm/ack\" onclick=\""
               "fetch('/alarm/ack',{method:'POST'});"
               "return false;\">Acknowledge</a>\n"
        "  </div>\n"
        "  <div class=\"card\">\n"
        "    <b>WiFi:</b> %s &nbsp; <b>RSSI:</b> %ddBm"
               " &nbsp; <b>Reconnects:</b> %lu\n"
        "  </div>\n"
        "  <p style=\"color:#475569;font-size:.8rem\">\n"
        "    Auto-refresh 3s &middot;\n"
        "    <a href=\"/sensors\">JSON</a> &middot;\n"
        "    <a href=\"/metrics\">Metrics</a> &middot;\n"
        "    <a href=\"/health\">Health</a>\n"
        "  </p>\n"
        "</body>\n"
        "</html>",
        water_class, water_color, water_text,
        s.temperature_c, s.humidity_pct,
        motion_text, co_color, s.co_ppm,
        alarm_color, alarm_text,
        ws.ip, ws.rssi_dbm, ws.reconnect_count
    );

    send_html(pcb, conn, g_body, (size_t)len);
}

// ── Router ─────────────────────────────────────────────────────────────────

static void route_request(struct tcp_pcb *pcb, HttpConn *conn) {
    const char *req = (const char *)conn->req_buf;

    printf("[HTTP ] %.*s\n", 60, req); // log first 60 chars of request

    if (strstr(req, " /sensors"))        route_sensors(pcb, conn);
    else if (strstr(req, " /wifi"))      route_wifi(pcb, conn);
    else if (strstr(req, " /alarm/ack")) route_alarm_ack(pcb, conn, req);
    else if (strstr(req, " /alarm"))     route_alarm_get(pcb, conn);
    else if (strstr(req, " /metrics"))   route_metrics(pcb, conn);
    else if (strstr(req, " /health"))    route_health(pcb, conn);
    else if (strstr(req, " /"))          route_dashboard(pcb, conn);
    else                                 send_404(pcb, conn);
}

// ── lwIP callbacks ─────────────────────────────────────────────────────────

static void conn_close(HttpConn *conn) {
    if (!conn || conn->closed) return;
    conn->closed = true;

    if (conn->pcb) {
        tcp_arg(conn->pcb,  NULL);
        tcp_recv(conn->pcb, NULL);
        tcp_err(conn->pcb,  NULL);
        tcp_sent(conn->pcb, NULL);
        tcp_close(conn->pcb);
        conn->pcb = NULL;
    }

    conn_free(conn);
}

static err_t on_recv(void *arg, struct tcp_pcb *pcb,
                     struct pbuf *p, err_t err) {
    HttpConn *conn = (HttpConn *)arg;

    if (!conn || err != ERR_OK || !p) {
        if (p) pbuf_free(p);
        if (conn) conn_close(conn);
        return ERR_OK;
    }

    // Buffer incoming data — request may arrive in multiple chunks
    uint16_t copy_len = p->len;
    if (conn->req_len + copy_len >= HTTP_REQUEST_MAX) {
        copy_len = HTTP_REQUEST_MAX - conn->req_len - 1;
    }

    if (copy_len > 0) {
        memcpy(conn->req_buf + conn->req_len, p->payload, copy_len);
        conn->req_len += copy_len;
        conn->req_buf[conn->req_len] = '\0';
    }

    tcp_recved(pcb, p->tot_len);
    pbuf_free(p);

    // Check if we have a complete HTTP request (ends with \r\n\r\n)
    if (strstr((char *)conn->req_buf, "\r\n\r\n") && !conn->responded) {
        route_request(pcb, conn);
        conn_close(conn);
    }

    return ERR_OK;
}

static err_t on_accept(void *arg, struct tcp_pcb *newpcb, err_t err) {
    if (err != ERR_OK || !newpcb) return ERR_VAL;

    // Enforce concurrent connection limit
    if (g_active_connections >= HTTP_MAX_CONNECTIONS) {
        printf("[HTTP ] Connection limit reached (%d/%d) — rejecting\n",
               g_active_connections, HTTP_MAX_CONNECTIONS);
        tcp_abort(newpcb);
        return ERR_MEM;
    }

    HttpConn *conn = conn_alloc();
    if (!conn) {
        tcp_abort(newpcb);
        return ERR_MEM;
    }

    conn->pcb = newpcb;
    tcp_setprio(newpcb, TCP_PRIO_MIN);
    tcp_arg(newpcb,  conn);
    tcp_recv(newpcb, on_recv);
    tcp_err(newpcb,  on_error);
    tcp_sent(newpcb, on_sent);

    printf("[HTTP ] Client connected (%d/%d active)\n",
           g_active_connections, HTTP_MAX_CONNECTIONS);
    return ERR_OK;
}

static void on_error(void *arg, err_t err) {
    HttpConn *conn = (HttpConn *)arg;
    printf("[HTTP ] Connection error: %d\n", err);
    // lwIP has already freed the pcb on error — do not call tcp_close()
    if (conn) {
        conn->pcb    = NULL;
        conn->closed = true;
        conn_free(conn);
    }
}

static err_t on_sent(void *arg, struct tcp_pcb *pcb, u16_t len) {
    return ERR_OK;
}

// ── Public API ─────────────────────────────────────────────────────────────

void http_server_init(void) {
    struct tcp_pcb *pcb = tcp_new_ip_type(IPADDR_TYPE_ANY);
    if (!pcb) {
        printf("[HTTP ] Failed to allocate PCB\n");
        return;
    }

    if (tcp_bind(pcb, IP_ANY_TYPE, HTTP_PORT) != ERR_OK) {
        printf("[HTTP ] Failed to bind port %d\n", HTTP_PORT);
        tcp_abort(pcb);
        return;
    }

    g_listen_pcb = tcp_listen_with_backlog(pcb, HTTP_MAX_CONNECTIONS);
    if (!g_listen_pcb) {
        printf("[HTTP ] Failed to listen\n");
        tcp_abort(pcb);
        return;
    }

    tcp_accept(g_listen_pcb, on_accept);
    printf("[HTTP ] Listening on port %d (max %d connections)\n",
           HTTP_PORT, HTTP_MAX_CONNECTIONS);
}

void http_server_poll(void) {
    uint32_t now = to_ms_since_boot(get_absolute_time());

    // Check for timed-out connections
    for (int i = 0; i < HTTP_MAX_CONNECTIONS; i++) {
        if (!g_conn_used[i]) continue;
        HttpConn *conn = &g_conn_pool[i];
        if (conn->closed) continue;

        if (now - conn->open_time_ms > HTTP_TIMEOUT_MS) {
            printf("[HTTP ] Connection timed out — closing\n");
            conn_close(conn);
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

Rust http.rs Implementation

// src/http.rs
//
// HTTP/1.0 server for the basement monitor.
// Built on embassy-net TcpSocket.
//
// Routes:
//   GET  /          → HTML dashboard
//   GET  /sensors   → JSON sensor snapshot
//   GET  /wifi      → JSON WiFi status
//   GET  /alarm     → JSON alarm status
//   POST /alarm/ack → acknowledge latched alarm
//   GET  /health    → plaintext OK
//   GET  /metrics   → Prometheus plaintext
//   *               → 404
//
// Structural advantage over C:
//   Linear async code — accept, read, route, write in sequence.
//   No callbacks, no partial-read buffering state machine.
//   embassy-net handles partial reads — socket.read() fills the buffer.
//   Concurrent connection limit enforced by spawning N tasks at startup,
//   each with its own socket buffers — no runtime pool management.
//
// Structural advantage over TinyGo:
//   Zero heap allocation — heapless::String for all formatting.
//   Socket buffers are static — compiler verifies lifetimes.
//   embassy-net TcpSocket is async — read/write yield to other tasks.

use core::fmt::Write;

use embassy_net::{tcp::TcpSocket, Stack};
use embassy_time::{Duration, Timer, with_timeout};
use heapless::String;
use defmt::*;

use crate::{SensorState, STATE};
use crate::alarm::{AlarmLevel, ALARM_STATE};
use crate::wifi::WIFI_STATUS;

// ── Configuration ──────────────────────────────────────────────────────────
const HTTP_PORT:        u16      = 80;
const MAX_CONNECTIONS:  usize    = 3;
const REQUEST_TIMEOUT:  Duration = Duration::from_secs(10);
const REQUEST_BUF:      usize    = 512;
const BODY_BUF:         usize    = 2048;

// ── Static socket buffers ──────────────────────────────────────────────────
// Each connection task gets its own static RX/TX buffers.
// Static lifetime means the compiler verifies these outlive their socket.
// C equivalent: g_conn_pool with per-conn req_buf.
// TinyGo: make([]byte, 512) per connection — heap allocated.

macro_rules! make_socket_buffers {
    ($n:expr) => {{
        static mut RX: [u8; REQUEST_BUF] = [0u8; REQUEST_BUF];
        static mut TX: [u8; BODY_BUF]   = [0u8; BODY_BUF];
        unsafe { (&mut RX, &mut TX) }
    }};
}

// ── Server task ────────────────────────────────────────────────────────────
// Spawned from main.rs after WiFi connects.
// Spawns MAX_CONNECTIONS worker tasks, each accepting one connection
// at a time. This is the embedded equivalent of a fixed thread pool —
// and directly analogous to your room.WaitingRoom with a cap of 3.

#[embassy_executor::task]
pub async fn server_task(
    stack:   &'static Stack<cyw43::NetDriver<'static>>,
    spawner: embassy_executor::Spawner,
) {
    // Wait for network stack to be ready
    loop {
        if stack.is_link_up() { break; }
        Timer::after(Duration::from_millis(500)).await;
    }

    info!("HTTP server starting on port {}", HTTP_PORT);

    // Spawn one connection handler per allowed concurrent connection.
    // Each handler loops forever: accept → handle → close → repeat.
    // This statically bounds memory use — no dynamic allocation ever.
    spawner.spawn(connection_task(stack, 0)).unwrap();
    spawner.spawn(connection_task(stack, 1)).unwrap();
    spawner.spawn(connection_task(stack, 2)).unwrap();
}

// ── Connection handler task ────────────────────────────────────────────────
// One instance per allowed concurrent connection.
// id: 0, 1, 2 — used to select pre-allocated static buffers.
//
// The loop here is the async equivalent of C's on_accept() callback
// and TinyGo's for { conn, _ := ln.Accept(); go handleConn(conn) }.
// But linear — no callback registration, no goroutine spawning.

#[embassy_executor::task(pool_size = 3)]
async fn connection_task(
    stack: &'static Stack<cyw43::NetDriver<'static>>,
    id:    usize,
) {
    // Each task owns its buffers for its entire lifetime.
    // Rust's borrow checker proves no other task can access these.
    let mut rx_buf = [0u8; REQUEST_BUF];
    let mut tx_buf = [0u8; BODY_BUF];

    loop {
        let mut socket = TcpSocket::new(stack, &mut rx_buf, &mut tx_buf);
        socket.set_timeout(Some(REQUEST_TIMEOUT));

        // accept() suspends until a client connects.
        // Other tasks (sensors, display, alarm, other connection tasks)
        // run freely during this wait.
        if let Err(e) = socket.accept(HTTP_PORT).await {
            warn!("[HTTP/{}] Accept error: {}", id, e);
            Timer::after(Duration::from_millis(100)).await;
            continue;
        }

        info!("[HTTP/{}] Client connected", id);

        // Handle with timeout — close if client takes too long
        match with_timeout(REQUEST_TIMEOUT, handle_request(&mut socket)).await {
            Ok(())    => {}
            Err(_)    => warn!("[HTTP/{}] Request timed out", id),
        }

        socket.close();
        Timer::after(Duration::from_millis(10)).await;
    }
}

// ── Request handler ────────────────────────────────────────────────────────

async fn handle_request(socket: &mut TcpSocket<'_>) {
    let mut req_buf = [0u8; REQUEST_BUF];

    // Read request — embassy-net handles partial reads internally.
    // C's on_recv() may be called multiple times for one request.
    // Here: one .await, socket fills the buffer.
    let n = match socket.read(&mut req_buf).await {
        Ok(0) | Err(_) => return,
        Ok(n)          => n,
    };

    let request = core::str::from_utf8(&req_buf[..n]).unwrap_or("");
    info!("[HTTP ] {:.60}", request);

    // Route
    if      request.contains(" /sensors")    { serve_sensors(socket).await }
    else if request.contains(" /wifi")       { serve_wifi(socket).await }
    else if request.contains(" /alarm/ack")  { serve_alarm_ack(socket, request).await }
    else if request.contains(" /alarm")      { serve_alarm(socket).await }
    else if request.contains(" /metrics")    { serve_metrics(socket).await }
    else if request.contains(" /health")     { serve_health(socket).await }
    else if request.contains(" /")           { serve_dashboard(socket).await }
    else                                     { serve_404(socket).await }
}

// ── Response helper ────────────────────────────────────────────────────────

async fn send_response(
    socket:       &mut TcpSocket<'_>,
    status:       u16,
    status_text:  &str,
    content_type: &str,
    body:         &str,
) {
    let mut headers: String<256> = String::new();
    write!(headers,
        "HTTP/1.0 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\n\
         Connection: close\r\nAccess-Control-Allow-Origin: *\r\n\
         Cache-Control: no-cache\r\n\r\n",
        status, status_text, content_type, body.len()
    ).ok();

    socket.write_all(headers.as_bytes()).await.ok();
    socket.write_all(body.as_bytes()).await.ok();
}

// ── Route handlers ─────────────────────────────────────────────────────────

async fn serve_sensors(socket: &mut TcpSocket<'_>) {
    let s = STATE.lock().await.clone();
    let mut body: String<256> = String::new();

    write!(body,
        "{{\"water\":{water},\"temp_c\":{temp:.1},\
         \"humidity_pct\":{hum:.1},\"motion\":{motion},\
         \"co_ppm\":{co},\"alarm_active\":{alarm}}}",
        water  = s.water_detected,
        temp   = s.temperature_c,
        hum    = s.humidity_pct,
        motion = s.motion_detected,
        co     = s.co_ppm,
        alarm  = s.alarm_active,
    ).ok();

    send_response(socket, 200, "OK",
        "application/json", body.as_str()).await;
}

async fn serve_wifi(socket: &mut TcpSocket<'_>) {
    let ws = WIFI_STATUS.lock().await.clone();
    let mut body: String<256> = String::new();

    write!(body,
        "{{\"state\":\"{state}\",\"ip\":\"{ip}\",\
         \"rssi_dbm\":{rssi},\"reconnect_count\":{rc}}}",
        state = ws.state.label(),
        ip    = ws.ip.as_str(),
        rssi  = ws.rssi_dbm,
        rc    = ws.reconnect_count,
    ).ok();

    send_response(socket, 200, "OK",
        "application/json", body.as_str()).await;
}

async fn serve_alarm(socket: &mut TcpSocket<'_>) {
    let alarm = ALARM_STATE.lock().await.clone();
    let s     = STATE.lock().await.clone();
    let mut body: String<256> = String::new();

    write!(body,
        "{{\"level\":\"{level}\",\"active\":{active},\
         \"latched\":{latched},\"water_latched\":{wl},\
         \"co_critical_latched\":{cl},\"relay_active\":{relay}}}",
        level  = alarm.level.label(),
        active = s.alarm_active,
        latched = alarm.water_latched || alarm.co_crit_latched,
        wl     = alarm.water_latched,
        cl     = alarm.co_crit_latched,
        relay  = alarm.relay_active,
    ).ok();

    send_response(socket, 200, "OK",
        "application/json", body.as_str()).await;
}

async fn serve_alarm_ack(socket: &mut TcpSocket<'_>, request: &str) {
    if !request.starts_with("POST") {
        send_response(socket, 405, "Method Not Allowed",
            "text/plain", "Method Not Allowed").await;
        return;
    }

    // Signal the alarm task to acknowledge.
    // We use a static AtomicBool rather than a channel to avoid
    // needing an async channel between tasks.
    crate::alarm::request_acknowledge();

    // Brief wait for alarm task to process the acknowledge
    Timer::after(Duration::from_millis(50)).await;

    let alarm = ALARM_STATE.lock().await.clone();
    let mut body: String<128> = String::new();
    write!(body,
        "{{\"acknowledged\":true,\"level\":\"{}\"}}",
        alarm.level.label()
    ).ok();

    send_response(socket, 200, "OK",
        "application/json", body.as_str()).await;
}

async fn serve_health(socket: &mut TcpSocket<'_>) {
    send_response(socket, 200, "OK", "text/plain", "OK").await;
}

async fn serve_metrics(socket: &mut TcpSocket<'_>) {
    let s     = STATE.lock().await.clone();
    let ws    = WIFI_STATUS.lock().await.clone();
    let alarm = ALARM_STATE.lock().await.clone();

    let mut body: String<BODY_BUF> = String::new();

    write!(body,
        "# TYPE basement_water_detected gauge\n\
         basement_water_detected {water}\n\
         # TYPE basement_temperature_celsius gauge\n\
         basement_temperature_celsius {temp:.2}\n\
         # TYPE basement_humidity_percent gauge\n\
         basement_humidity_percent {hum:.2}\n\
         # TYPE basement_motion_detected gauge\n\
         basement_motion_detected {motion}\n\
         # TYPE basement_co_ppm gauge\n\
         basement_co_ppm {co}\n\
         # TYPE basement_alarm_level gauge\n\
         basement_alarm_level {alarm_lvl}\n\
         # TYPE basement_wifi_rssi_dbm gauge\n\
         basement_wifi_rssi_dbm {rssi}\n\
         # TYPE basement_wifi_reconnects counter\n\
         basement_wifi_reconnects {rc}\n",
        water     = s.water_detected as u8,
        temp      = s.temperature_c,
        hum       = s.humidity_pct,
        motion    = s.motion_detected as u8,
        co        = s.co_ppm,
        alarm_lvl = alarm.level as u8,
        rssi      = ws.rssi_dbm,
        rc        = ws.reconnect_count,
    ).ok();

    send_response(socket, 200, "OK",
        "text/plain; version=0.0.4", body.as_str()).await;
}

async fn serve_dashboard(socket: &mut TcpSocket<'_>) {
    let s     = STATE.lock().await.clone();
    let ws    = WIFI_STATUS.lock().await.clone();
    let alarm = ALARM_STATE.lock().await.clone();

    let water_class = if s.water_detected { "alert"  } else { "" };
    let water_color = if s.water_detected { "danger" } else { "ok" };
    let water_text  = if s.water_detected { "WATER DETECTED" } else { "Dry" };
    let alarm_color = if s.alarm_active   { "danger" } else { "ok" };
    let co_color    = if s.co_ppm >= 50   { "warn"   } else { "ok" };
    let motion_text = if s.motion_detected { "Detected" } else { "Clear" };

    let mut body: String<BODY_BUF> = String::new();

    write!(body,
        "<!DOCTYPE html>\
        <html lang=\"en\">\
        <head>\
          <meta charset=\"UTF-8\">\
          <meta http-equiv=\"refresh\" content=\"3\">\
          <title>Basement Monitor</title>\
          <style>\
            body{{font-family:system-ui;background:#0f1117;color:#e2e8f0;\
                  max-width:600px;margin:2rem auto;padding:1rem}}\
            h1{{color:#6c8ef5}}\
            .card{{background:#1a1d27;border-radius:8px;\
                   padding:1rem;margin:.5rem 0}}\
            .alert{{background:#7f1d1d;border:1px solid #ef4444}}\
            .ok{{color:#34d399}}.warn{{color:#fbbf24}}\
            .danger{{color:#ef4444;font-weight:bold}}\
            a{{color:#6c8ef5}}\
          </style>\
        </head>\
        <body>\
          <h1>Basement Monitor</h1>\
          <div class=\"card {wc}\">\
            <b>Water:</b> <span class=\"{wcol}\">{wtext}</span>\
          </div>\
          <div class=\"card\">\
            <b>Temp:</b> {temp:.1}&deg;C &nbsp;\
            <b>Humidity:</b> {hum:.1}%\
          </div>\
          <div class=\"card\">\
            <b>Motion:</b> {motion} &nbsp;\
            <b>CO:</b> <span class=\"{cocol}\">{co} ppm</span>\
          </div>\
          <div class=\"card\">\
            <b>Alarm:</b> <span class=\"{acol}\">{alvl}</span>\
            &nbsp;\
            <a href=\"#\" onclick=\"\
              fetch('/alarm/ack',{{method:'POST'}});\
              return false;\">Acknowledge</a>\
          </div>\
          <div class=\"card\">\
            <b>WiFi:</b> {ip} &nbsp;\
            <b>RSSI:</b> {rssi}dBm &nbsp;\
            <b>Reconnects:</b> {rc}\
          </div>\
          <p style=\"color:#475569;font-size:.8rem\">\
            Auto-refresh 3s &middot;\
            <a href=\"/sensors\">JSON</a> &middot;\
            <a href=\"/metrics\">Metrics</a> &middot;\
            <a href=\"/health\">Health</a>\
          </p>\
        </body>\
        </html>",
        wc     = water_class,
        wcol   = water_color,
        wtext  = water_text,
        temp   = s.temperature_c,
        hum    = s.humidity_pct,
        motion = motion_text,
        cocol  = co_color,
        co     = s.co_ppm,
        acol   = alarm_color,
        alvl   = alarm.level.label(),
        ip     = ws.ip.as_str(),
        rssi   = ws.rssi_dbm,
        rc     = ws.reconnect_count,
    ).ok();

    send_response(socket, 200, "OK",
        "text/html; charset=utf-8", body.as_str()).await;
}

async fn serve_404(socket: &mut TcpSocket<'_>) {
    send_response(socket, 404, "Not Found",
        "text/plain", "Not Found").await;
}

Enter fullscreen mode Exit fullscreen mode

Why Rust Wins the Embedded Systems Battle — And Why C Finally Lost the Argument

A Staff Engineer's Conclusion After Building the Same System Three Times


There is a conversation happening in embedded systems engineering that has
been building for a decade and has finally reached a tipping point. The
question is not whether Rust is viable for microcontroller development —
that debate is settled. The question is whether the embedded C community
is ready to honestly reckon with what C has been costing us all along.

This article is the result of building the same basement monitoring system
— water detection, temperature, humidity, CO sensing, PIR motion, SSD1306
OLED display, WiFi, and an HTTP server — three times, in three languages,
on a Raspberry Pi Pico W. The three implementations were built in parallel,
file by file, module by module, so that every architectural decision could
be compared directly across C, TinyGo, and Rust/Embassy. The conclusion
was not predetermined. It emerged from the code itself.


What Was Built

The system is not a toy. It is a production-grade embedded application
with real operational requirements:

  • Six sensor inputs with different polling cadences and severity models
  • A four-level alarm system with latching, acknowledgement, and relay control
  • A two-page rotating OLED display with pixel-level rendering
  • WiFi with exponential backoff retry and automatic reconnection
  • An HTTP server with seven routes including Prometheus metrics
  • All of it running concurrently on a single microcontroller with 264KB of RAM and no operating system

Every module was implemented fully in all three languages: sensors,
display, alarm, wifi, and http. The Makefiles were written. The
comparison table was filled in from actual code, not from documentation
or benchmarks. When a claim was made about memory safety or concurrency,
there was a specific file and a specific line number behind it.


Why C Loses

C does not lose because it is slow. C does not lose because it cannot do
the job. C has been doing this job reliably for forty years and the
embedded systems running the world's infrastructure prove it. C loses for
a more uncomfortable reason: everything C gets right, it gets right
because of the engineer, not because of the language.

Consider the alarm module. The C implementation requires:

  • volatile SensorState g_state — the volatile keyword prevents the
    compiler from optimizing away reads, but it does not prevent a data
    race. That protection comes from critical_section_enter_blocking()
    called correctly, every time, by every function that touches g_state.
    The compiler does not verify this. The linker does not verify this. The
    test suite may not catch it. Only code review and discipline stand
    between correct behavior and a silent race condition.

  • alarm_acknowledge() writes directly to g_alarm — a global struct
    accessible to any translation unit that includes alarm.h. Nothing in
    the language prevents http.c from reading g_alarm.water_latched
    without locking. The header file is not a contract. It is an invitation.

  • conn_close() calls free() — and if it is called twice, the behavior
    is undefined. Not an error. Not a crash you can debug. Undefined. The
    program may continue running with corrupted heap state for hours before
    anything visibly wrong happens, and by then the audit trail is gone.

  • The SSD1306 font table is 576 bytes of hand-curated hexadecimal embedded
    in display.c. It is correct. It took time to write and verify. It will
    never be wrong in a way the compiler notices, because the compiler does
    not know what a font table is supposed to contain.

None of these are criticisms of bad engineering. They are descriptions of
what correct, careful, professional embedded C looks like. The discipline
required to write safe embedded C is real, hard-won, and — critically —
invisible to the toolchain. It lives in the engineer's head. When that
engineer leaves the team, some of it leaves with them.

This is what C costs. Not performance. Not capability. Institutional
knowledge that cannot be mechanically verified.

At scale — at the level of teams, products, and decades — that cost
compounds. The engineers who built network infrastructure in C in the
1990s were excellent engineers. The reason that infrastructure is still
running is not that C protected it. It is that those engineers protected
it, and then their successors protected it, in an unbroken chain of human
discipline that has to be re-established every time the team changes.


Why Rust Wins

Rust wins for a reason that sounds simple and has profound implications:
Rust encodes forty years of embedded C wisdom into the compiler.

Every rule that experienced embedded engineers know — never share a
peripheral between two contexts, always check buffer sizes, always
initialize state before use, always release resources exactly once — Rust
enforces mechanically, before the binary is built, at zero runtime cost.

The alarm module in Rust demonstrates this concretely. PIN_8 — the
buzzer pin — is moved into alarm_task at construction. Not passed by
pointer. Not referenced externally. Moved. The type system records this
transfer of ownership. Any other code attempting to construct another
output using PIN_8 will not compile. The exclusivity guarantee that C
enforces by convention — "only alarm.c drives the buzzer" — Rust enforces
by construction. The convention becomes a proof.

STATE: Mutex<CriticalSectionRawMutex, SensorState> — the shared sensor
state — cannot be read without calling .lock().await. This is not a
runtime check. It is a type-level constraint. The compiler will not produce
a binary in which g_state is accessed without holding the lock, because
there is no syntax in Rust that expresses "read STATE without locking."
The operation does not exist in the type system.

heapless::String<24> — used throughout the display and HTTP modules —
has a compile-time capacity. Writing more than 24 characters returns Err.
It cannot overflow silently. It cannot corrupt adjacent stack memory. The
class of bug that has caused security vulnerabilities in embedded C systems
for thirty years — the buffer overrun — is not a runtime check in Rust.
It is a type boundary that the compiler enforces at every callsite.

The HTTP server runs three concurrent connection handlers as
#[embassy_executor::task(pool_size=3)]. The memory for all three is
allocated statically. The compiler verifies that the socket buffers outlive
the tasks that use them. There is no malloc(). There is no free().
There is no possibility of a use-after-free because the lifetime of every
allocation is part of the type signature, visible to the compiler, verified
before the binary is linked.

And critically — all of this safety costs nothing at runtime. The binary
is comparable in size to the C implementation. The execution speed is
equivalent. The GC does not exist. The runtime overhead of the safety
guarantees is exactly zero, because the guarantees are compile-time
properties, not runtime checks.


Why TinyGo Is Honest About Its Position

TinyGo deserves respect for what it is and candor about what it is not.

What it is: the fastest path from a Go developer's existing mental
model to working embedded firmware. The goroutine model, the defer
cleanup, the standard net package — these are not approximations of Go.
They are Go, running on bare metal, with a stripped runtime. For a Go
developer who wants to write embedded code without learning a new language,
TinyGo is genuinely the right starting point. The alarm module's pulse
loop in TinyGo reads exactly like the intended behavior:

buzzerPin.High()
time.Sleep(criticalOn)
buzzerPin.Low()
time.Sleep(criticalOff)

in a way that C's timestamp-polling state machine never does.

What it is not: a zero-cost abstraction layer. fmt.Sprintf allocates
on every call. The garbage collector runs. On a device with 264KB of RAM,
the GC pauses are brief and infrequent enough to be invisible at
human-interaction timescales. But they are real, and they are
non-deterministic, and in a system where the alarm task needs to pulse a
buzzer at exactly 200ms intervals, non-deterministic pauses are a
correctness concern, not just a performance concern.

TinyGo also does not solve the ownership problem. Two goroutines can
configure the same pin. The scheduler enforces nothing about hardware
exclusivity — only the programmer does. This is safer than C's global
state model because the goroutine structure at least makes the concurrency
visible. But it is less safe than Rust's ownership model because the
compiler does not verify it.

TinyGo's honest position in the hierarchy is: better than C for
readability, better than C for concurrency structure, worse than Rust for
safety guarantees, worse than Rust for determinism. For prototyping and
experimentation it is excellent. For unattended production firmware it
introduces risks that Rust eliminates.


The Benchmark That Actually Matters

The embedded systems community is accustomed to benchmarking languages on
execution speed and binary size. Those benchmarks favor C and Rust
approximately equally and leave TinyGo somewhat behind due to GC overhead.
They are not the benchmark that matters for this class of system.

The benchmark that matters is: what happens when the system has been
running unattended for six months and a sensor fires at 3am?

In C, the answer depends on whether every critical_section_enter was
paired with a critical_section_exit, whether every free() was called
exactly once, whether the volatile qualifiers are in the right places,
and whether the engineer who wrote the alarm module six months ago
remembered to handle the case where water clears before the HTTP
acknowledge arrives. The compiler cannot answer any of these questions.
Only running the code in the conditions that trigger the bug can answer
them, and by then it may be too late.

In Rust, the compiler answered most of these questions before the binary
was built. The peripheral ownership is proven. The buffer bounds are
enforced. The state access is synchronized. The resource cleanup is
automatic. What remains — the application logic, the threshold values,
the alarm escalation policy — is the part that requires human judgment,
and Rust makes no claim to automate that. But the class of bugs that
emerge from implementation errors rather than design errors — the class
that has driven embedded systems recalls, security patches, and midnight
incident responses for decades — Rust eliminates before the first byte
is flashed.


The Full Comparison

Dimension C TinyGo Rust/Embassy
Concurrency model Manual poll loop, no true concurrency Real goroutines, cooperative scheduler Async tasks, cooperative, interrupt-driven
DHT22 timing Busy-wait, blocks CPU entirely Busy-wait in goroutine, others run PIO hardware offload, CPU completely free
State sharing volatile + critical_section, convention only sync.Mutex, convention only Mutex<T>, compiler enforced
Memory safety char[N], silent overflow = UB make([]byte,n) + GC heapless::String, overflow = compile error
HTTP server lwIP raw callbacks, 4 callback functions net.Listen + goroutine/conn embassy-net TcpSocket, linear async code
Cleanup / resource mgmt Manual free(), easy to forget defer conn.Close(), automatic Drop trait, automatic, enforced
Pin safety Convention only Convention only Compile-time ownership proof
Binary size Smallest ~50KB Medium ~200–400KB Small ~80–150KB
GC pauses None Yes, brief on allocations None
WiFi connect Blocks CPU entirely Blocks goroutine, others run Fully async, yields executor
Driver ecosystem Mature, large C libs tinygo/x/drivers, growing embedded-hal crates, mature
Readable code Medium (callbacks) Highest (goroutines) High (async/await)
Production maturity Proven, decades Growing, 2–3 years Pico W Growing fast, 2–3 years Pico W

Medal Count Across 12 Dimensions

Language Gold Silver Bronze
Rust/Embassy 9 2 0
TinyGo 4 5 2
C 2 1 9

On the Collaboration That Produced This Conclusion

This analysis was produced through an extended technical collaboration
between a staff-level engineer with professional experience at Cisco
Systems, Oracle America, and Warner Bros. Games — and Claude, Anthropic's
AI system.

The engineer brought production systems experience, embedded hardware
familiarity from university circuit design coursework, and the
architectural instincts developed across two decades of building software
that has to work when nobody is watching. This is the same engineer who
built room — a FIFO waiting
room middleware for Go and Gin that manages bounded concurrent admission
with lifecycle callbacks, skip-the-line payments, and VIP passes. The
patterns in room — bounded resources, snapshot state, controlled
admission — reappear directly in the embedded HTTP server, the alarm
module, and the sensor state management. The problems are the same at
every scale.

Claude brought the ability to hold three complete implementations in
parallel, compare them at the module level across thousands of lines of
code, and surface the specific file and line number behind every claim
in the comparison.

The conclusion — that Rust is the winner for production embedded systems,
that C retains value as an educational tool and for engineers with deep
existing discipline, and that TinyGo occupies an honest and useful middle
position — was not reached by reading documentation or running benchmarks.
It was reached by writing the same alarm module three times, the same
display driver three times, the same WiFi reconnection logic three times,
and asking at each step: where does this break, and who catches it
when it does?

In C, the engineer catches it — if they are still on the team, if the
conditions that trigger it occur during testing, if the code review
process has the bandwidth to find it.

In Rust, the compiler catches it — unconditionally, before the binary exists, every time.

That is why Rust wins.


The Practical Recommendation

For engineers evaluating languages for a new embedded project in 2026:

Use Rust if the system will run unattended, if correctness failures
have real-world consequences, if the team will change over time, or if
the firmware will be maintained for more than one product cycle. The
upfront investment in learning the ownership model and the embedded
ecosystem pays back in the form of bugs that never happen in the field.

Use TinyGo if the team is primarily Go developers, if the timeline
requires a working prototype quickly, or if the project is experimental
and the priority is iteration speed over production hardening. Plan to
evaluate Rust before production deployment.

Use C if the team has deep embedded C expertise and the discipline
to apply it consistently, if the target hardware is too constrained for
Rust's toolchain, or if the project exists primarily to understand what
the hardware is doing at the register level. C remains the best
educational tool for embedded systems because it hides nothing — but
hiding nothing requires the engineer to see everything, and that is a
requirement that scales with the engineer, not with the team.

Top comments (0)