Senior project

BitWands

The project started as a simple interaction idea: point a wand up or down to set or clear a shared state. It turned into a full stack that includes custom hardware decisions, embedded firmware, a radio protocol, OTA update tooling, Linux host software, and a terminal-native presentation runtime.

By the end, it was no longer just a handheld prototype. It was a complete path from motion sensing and radio acknowledgements up through host software, live state displays, and the presentation used to explain the project.

  • Input Orientation-based game state
  • Hardware XIAO RP2040, ADXL345, RFM69HCW, Li-ion power
  • Software Fig, Munch, Jig, Jig-RP2040, RP2040 HAL, Loom
  • Deliverables Firmware, OTA path, Linux daemon, flashing tool, live deck

Overview

What BitWands is

BitWands is a distributed game system built around a binary interaction model. Each wand reports one of three visible states: set, clear, or unknown. The hub collects those updates, acknowledges them, and exposes the shared state to a Linux host.

I did not want this to stop at “the board works.” The useful part was building the layers around it: configuration, parsing, hardware generation, firmware packaging, host tooling, and a way to present the system live without faking the hard parts.

Core interaction

Three visible states

Each wand resolves motion into set, clear, or unknown. That simple state model makes the system easy to read in a room, but it still leaves space for real protocol, synchronization, and power-management work underneath.

System boundary

Handheld to host chain

The project is really a chain of boundaries: wand hardware, receiver hub, USB CDC protocol, Linux bridge, and terminal presentation or game surface. The site follows that same structure because it is how the project actually had to be debugged.

Why it grew

Missing layers became real work

The stack expanded because each shortcut showed up quickly. If a layer was vague or manual, it got in the way almost immediately. That is how the work turned into reusable libraries as well as the local project code.

Education and openness

Clean tools, reproducible hardware, and fun all matter

BitWands is also an argument about educational technology. Education needs better support in clean, free, accessible software and in hardware that can be reproduced, understood, and modified without asking a vendor toolchain for permission.

A separate paper I wrote, The Cost of Convenience: How Vendor Lock-In Is Undermining Scientific Research, makes the broader research version of that case. When instruments depend on opaque SDKs, vendor-only tooling, black-box firmware, and throwaway code, reproducibility takes a direct hit. BitWands tries to go the other way: local-first tools, documented parts, explicit boundaries, and a design another educator could rebuild without much drama.

Fun matters

Physical play helps abstractions stick

Binary state, shared state, latency, and protocol behavior are easier to care about when they are attached to motion, light, and group interaction. Fun is not decoration here. It is part of why the lesson works.

Tools should teach

Students should be able to inspect the stack

The build system, firmware, host tools, protocol, and presentation path are all visible enough to study directly. A teaching tool should not turn into a black box the moment it gets interesting.

Reproducibility matters

Educators should be able to rebuild it

The project uses common buses, local build tools, and modular hardware blocks so a classroom or lab can reproduce it without depending on one company's IDE, one cloud service, or one sealed reference design.

Architecture

How the full system fits together

The system works best when the boundaries stay narrow. Each layer has one job, and the visible state only changes when the next layer in the chain has accepted it.

You can see that rule everywhere in the final build: wands wait for acknowledgements, the hub owns the shared state, the USB side speaks a machine-readable protocol, and the host software republishes that state instead of inventing a second source of truth.

System rule

State only moves forward when the next boundary agrees

BitWands looks simple from the outside, but it only feels solid because each layer is careful about what it claims. The wand does not light a final state without an acknowledgement, the hub does not guess missing information, and the host starts from explicit snapshots instead of stale UI state.

1. Wand

Sense, decide, report

Each wand samples orientation, drives its local LEDs, and sends radio updates for set, clear, or unknown.

The wand is both the input device and the first visible output. Motion sensing, wake/sleep policy, and packet retries all meet here.

  • ADXL345 over I2C for orientation input
  • RFM69HCW for gameplay and OTA traffic
  • Visible state waits for receiver acknowledgement

2. Receiver hub

Aggregate and acknowledge

The USB-connected receiver is the authoritative state collector. It decodes packets, replies with matching acknowledgements, and maintains the aggregate wand masks.

Here, individual wand events become shared game state. It is also the boundary for host software and OTA gateway work.

  • One radio sink for all wand addresses
  • Ack path keeps wand LEDs aligned with receiver truth
  • Hub state becomes the source of truth for the host

3. USB CDC boundary

Expose a machine-readable protocol

The hub exports a narrow USB CDC line protocol instead of a heavyweight host API. That keeps the embedded boundary debuggable and easy to consume from small tools.

That let the host side stay small: one bridge process and a few focused utilities instead of a larger application framework.

  • READY, PONG, and STATE messages
  • Host verifies the dongle before trusting it
  • No hidden board-specific GUI layer at the cable boundary

4. Host tools

Bridge, flash, install

The Linux side stays simple and explicit: a local socket bridge for state, a BOOTSEL flasher for UF2 installs, and OTA tools that talk to the gateway and bootloader.

These tools exist to make development and demos less annoying. They solve specific project problems. They are not trying to be a generic device-management suite.

  • bitwandsd.py republishes hub state to `/tmp/bitwands.sock`
  • fwtool handles BOOTSEL UF2 flashing
  • hub_repl and gateway firmware cover OTA install

Design principle

Keep the boundaries swappable

Another architecture rule was staying out of deep lock-in to any one hardware or software vendor. The wand is built from separable blocks with standard interfaces: SPI for the radio, I2C for the accelerometer, USB CDC for the host boundary, and a simple one-wire LED path for visible state.

That does not make every substitution free, but it does make them legible. A future educator or lab can replace a module, rewrite one driver, or change one board-policy layer without throwing away the rest of the project.

  • Documented buses keep the module edges visible.
  • No vendor SDK is required to build or flash the core project.
  • The project is intended to outlive any one exact BOM revision.

5. Game and presentation surface

Render the live state

The terminal game and the capstone presentation both sit on top of the same project boundaries. The deck is not a slide export about the system. It is part of the system.

Because of that, weak spots stayed visible all the way through the final presentation. If the host path or live state handling were shaky, the deck exposed it immediately.

  • Terminal-native runtime instead of exported slide images
  • Live USB/state views fit the project’s debugging style
  • One stack, shown end to end

Hardware

System hardware at a glance

This section follows the same framing as the capstone deck: subsystem slices instead of every schematic detail. The software stack grew out of real board constraints, not out of a generic dev board mockup.

The full prototype schematic helps because it shows how tightly packed the real hardware problems were. Sensing, radio, charging, power conversion, LEDs, and wake signaling all had to fit inside one handheld form factor.

The numbers here come from the RP2040, ADXL345, RFM69HCW, TP4056, MT3608, and WS2812B datasheets, plus the XIAO RP2040 board spec. Where it makes sense, they are tied back to the exact settings used in the firmware.

Full BitWands prototype schematic shown at full width
Full prototype schematic This full wand-level schematic drove the firmware and tooling decisions. The MCU, accelerometer, radio, charging path, battery supply, and LED signaling all had to coexist in one small handheld design. That pushed the software toward careful power state, interrupt ownership, and explicit hardware boundaries.
XIAO RP2040 module used in the BitWands prototype
XIAO RP2040 Clocks, buses, USB, interrupts, and LED control all meet at the MCU. The radio SPI path, the ADXL345 interrupt line, and the status LED wiring all land here. Board policy turns into concrete code here.
Accelerometer portion of the schematic
ADXL345 accelerometer Orientation input mattered more than buttons for this project. The accelerometer sits on I2C and uses an interrupt line for active sampling and wake-on-motion, which helps the wand feel physical instead of abstract. It pushed the firmware toward clearer sensor ownership and better wake handling too.
RFM69 radio portion of the schematic
RFM69HCW radio At this point the project stops being a single device. The same radio path carries gameplay packets and OTA install traffic, and the acknowledgement protocol keeps the wand LEDs aligned with receiver truth. Reliability mattered more here than theoretical simplicity.
Power management portion of the schematic
Power system The TP4056 charger, Li-ion cell, and MT3608 boost path all had to coexist cleanly. That is one reason dormant sleep, LED shutdown, and wake behavior mattered so much in the firmware. The power path directly shaped how long the wand could behave like a real untethered object.
Status LED portion of the BitWands schematic
Status LEDs The wand needs to show state immediately without depending on a host screen. The external WS2812 path and the mirrored onboard NeoPixel turn set, clear, and unknown into something visible at a glance. That feedback loop is part of the interaction design, not just decoration.

MCU and board

RP2040 on the XIAO RP2040 module

The RP2040 is the center of the whole handheld. In BitWands it owns the radio SPI path, the ADXL345 I2C path, the USB side on the hub, the interrupt routing, and the visible LED signaling. The XIAO module was especially useful because it packages that MCU into a tiny board with flash, USB, and an onboard RGB LED. It is small enough to feel like part of the wand instead of a bench board hanging off the side.

Hard data

  • The RP2040 provides dual Cortex-M0+ cores up to 133 MHz, 264 kB of SRAM split across six banks, a dedicated 4 kB USB DPRAM, USB 1.1 host/device support, and 8 PIO state machines.
  • It exposes 30 GPIO, 2 SPI controllers, 2 I2C controllers, 2 UARTs, 16 PWM channels, and a 12-bit ADC that can run at 500 ksps.
  • The chip also gives 12 DMA channels, a 64-bit timer with 4 alarms, and a clock tree that can keep the USB side at 48 MHz while the rest of the system runs from its own policy.
  • PIO is split across two blocks, each with 4 state machines and a shared 32-word instruction memory, which is why the RP2040 is so comfortable with custom serial or one-wire signaling.
  • The XIAO RP2040 board adds 2 MB of onboard flash, a 21 x 17.8 mm module footprint, USB-C, and onboard indicator LEDs.

Why it fit BitWands

BitWands needed one MCU that could comfortably handle sub-GHz radio, an interrupt-driven sensor, local LED feedback, and a USB-connected hub variant without dragging in a vendor SDK or a giant board support layer. RP2040 fit well because the peripheral set is broad, the USB block is built in, and the chip is simple enough to understand all the way down to startup and register ownership.

The XIAO board mattered just as much as the MCU. It made the handheld physically plausible early on. There was enough flash for the project, USB was already present, and the tiny board size left room for the radio, charging path, and sensor without turning the prototype into a cable nest.

The less flashy datasheet features mattered too. DMA, separate USB clocking, and PIO headroom mean the chip has room for the project to get more disciplined over time instead of hitting a hard architectural wall the moment one more protocol shows up.

PIO and one-wire LED handling

One of the nicest RP2040 features here is PIO. The chip has 8 PIO state machines built for deterministic, timing-heavy I/O, which makes them a natural fit for protocols like WS2812-style one-wire LED signaling.

The current BitWands firmware does not use PIO for the LED path yet. The checked-in ws2812_strip.zig implementation bit-bangs the signal directly at the normal 125 MHz system clock and temporarily disables interrupts during frame output. The timing constants are concrete and short:

const t0h_cycles = 28;
const t0l_cycles = 92;
const t1h_cycles = 76;
const t1l_cycles = 44;

The WS2812 protocol itself wants a tight 1.25 us bit cell and a long low reset period, which is exactly the kind of repetitive waveform PIO was built for.

That works fine in the current build because the wand only drives a tiny number of pixels, but PIO would be the cleaner next step. A dedicated state machine could keep the 800 kbps LED waveform precise without masking interrupts, which would leave more headroom for radio receive timing, sensor interrupts, and future animation.

Sensor

ADXL345 orientation and wake sensor

The ADXL345 shaped both the interaction and the firmware. It was more than a source of XYZ numbers. It provided the wake path, the interrupt model, and the threshold behavior that kept the wand from feeling like a glorified button.

Hard data

  • The part supports +/-2 g, +/-4 g, +/-8 g, and +/-16 g ranges with up to 13-bit full-resolution output at +/-16 g.
  • Its scale can stay at about 4 mg/LSB in full-resolution mode, which is useful for tilt detection rather than only large shocks.
  • It supports output data rates up to 3200 Hz, runs from 2.0 V to 3.6 V, and supports both SPI and I2C.
  • Low-power behavior is one of the main reasons this part is attractive: the datasheet calls out about 23 uA in measurement mode and about 0.1 uA in standby at typical conditions.
  • It includes a 32-sample FIFO with bypass, FIFO, stream, and trigger modes plus built-in activity, inactivity, tap, and free-fall interrupt features.
  • Interrupt sources can be mapped to either interrupt pin, and the activity threshold registers are scaled in small fixed steps instead of forcing the host to infer motion from raw samples alone.
  • The part is also mechanically tougher than the project needs; the datasheet rates it for very high shock survivability, which is helpful for a handheld classroom object.

Why it fit BitWands

BitWands needed stable tilt sensing more than high-end inertial fusion. The ADXL345 is especially good at that kind of work because it can resolve gravity cleanly, signal motion through interrupts, and stay power-aware enough to participate in a dormant wake policy.

That let the wand wake on motion, sample faster only when it had to, and avoid polling the sensor blindly all the time. The part is small, common, and well understood, which was the right trade for a senior project that still needed real embedded discipline.

The FIFO and interrupt routing matter here because they let the sensor participate in system policy. The MCU does not have to guess when motion is worth paying attention to. The sensor can raise its hand directly.

How BitWands uses it

  • The firmware runs the ADXL345 over I2C at 400 kHz and routes INT1 into the RP2040 as both an active-mode data-ready source and a dormant-wake source.
  • Initial slow capture runs at 6.25 Hz, active sampling runs at 800 Hz, and dormant wake uses an activity profile at 50 Hz.
  • The current wand thresholds are concrete: wake activity is set around 625 mg, sky-entry is 700 mg, and ground-entry is 650 mg.
  • The project uses the accelerometer's interrupt and threshold features instead of treating it like a raw stream-only device. That is one reason the power policy feels more deliberate than constant sampling.
  • In practice that means the ADXL345 is not just a data source. It is part of the wake, sleep, and debounce story.

Radio

RFM69HCW packet radio

The RFM69HCW is where BitWands stops being one embedded gadget and becomes a distributed system. The project needed a radio that was still understandable at register level but had enough packet-engine support to avoid rebuilding CRC, framing, and receive behavior from scratch.

Hard data

  • The module covers the 315, 433, 868, and 915 MHz ISM bands and supports FSK, GFSK, MSK, GMSK, and OOK modulation.
  • It runs from about 1.8 V to 3.6 V, which makes it easy to keep on the same regulated rail as the RP2040 and accelerometer.
  • The datasheet quotes sensitivity down to about -120 dBm at 1.2 kbps and programmable output power from about -18 dBm to +20 dBm with the high-power path.
  • Maximum FSK bitrate reaches about 300 kbps, the synthesizer step is on the order of tens of hertz, and the receive path exposes more than 115 dB of RSSI dynamic range.
  • Receive current is roughly in the mid-teen milliamp range, while high-power transmit can push well over 100 mA, which is one reason the firmware keeps packets short and retries disciplined.
  • The packet engine includes programmable preamble and sync detection, CRC-16, AES-128, address filtering, whitening, FIFO fill-on-sync behavior, and a 66-byte FIFO.

Why it fit BitWands

BitWands cared more about robust small packets than about chasing maximum raw throughput. The RFM69HCW fit that job well because it is a sub-GHz part with a useful packet engine, a clear register model, and room to trade bitrate for margin if needed.

Sub-GHz also made sense for a classroom-scale wand system. This was never about high-bandwidth media. It was about dependable state delivery with simple antennas, compact packets, and behavior that could still be debugged from the firmware side without black-box tooling.

The important datasheet story is that the RFM69 does enough in hardware to be useful without turning into a closed stack. It handles the repetitive packet chores, but it still leaves the application in charge of higher-level truth like sequence numbers and acknowledgement policy.

How BitWands uses it

  • The current runtime uses 915 MHz, a 17 dBm transmit level, a 3-byte sync word of 2D D4 42, and a maximum project frame size of 60 bytes.
  • The checked-in firmware uses the driver’s midrate_50kbps preset right now, while a separate design note explores a longer-range 9.6 kbps profile with an 8-byte preamble and the same 60-byte payload cap.
  • Whitening, address filtering, sync-based FIFO fill, and CRC all stay enabled because they improve real packet correctness before the project’s own application-level ack logic even runs.
  • The 60-byte project frame limit sits comfortably below the hardware FIFO size, which keeps buffering simple and leaves room for predictable framing choices.
  • The same radio path also carries OTA-related traffic, which is one reason the radio layer had to be treated as real infrastructure instead of a one-off game input link.

Power

Battery, charging, and boost path

The power path is less glamorous than the sensor or the radio, but it is the reason the wand could act like a handheld object instead of a tethered lab board. It explains why dormant sleep ended up as a first-class firmware feature instead of cleanup work at the end.

Hard data

  • The TP4056 is a single-cell Li-ion linear charger commonly configured for up to about 1 A charge current with a fixed 4.2 V termination voltage and thermal regulation.
  • Common TP4056 datasheets also call out about +/-1% charge-voltage accuracy, which is good enough for a low-cost student-built handheld charger path.
  • Typical TP4056 module behavior uses a constant-current/constant-voltage charge curve, trickle-charges deeply discharged cells, and terminates near one tenth of the programmed charge current.
  • The MT3608 is a 1.2 MHz step-up converter with an advertised output range up to about 28 V, a switch-current limit in the multi-amp range, and quoted peak efficiencies in the low 90% range under favorable conditions.
  • The project BOM assumes a roughly 500 mAh single-cell LiPo. That means a nominal battery rail around 3.7 V and a full-charge voltage around 4.2 V, which is why the LED side benefits from a boost stage.
  • The common TP4056 breakout does not provide a sophisticated power-path manager by itself, so the design stays intentionally simple and treats charging and runtime behavior as a prototype trade rather than a finished PMIC solution.

Why it fit BitWands

These parts were a pragmatic prototype choice. They are cheap, easy to source, and simple to wire into a one-cell handheld power path while still supporting charging, boosting, and untethered demo use.

They are not the most integrated or size-optimized final-product answer, but they are a good way to get a real battery-powered object working while the rest of the stack is still moving. In a senior project, practical bring-up beats theoretical neatness.

The trade is legibility. These modules are easy to source, easy to replace, and easy to reason about with a datasheet and a meter. That matters for an educational build.

BitWands-specific implication

The power lesson from this project was simple: radio transmissions and visible LEDs dominate the user experience and the power budget far more than tiny MCU instruction-level savings do. That is why the firmware spends so much effort on dormant sleep, wake-on-motion, and LED shutdown behavior. The big wins come from being inactive aggressively, not from pretending active time is free.

The exact small charger and boost modules can vary by vendor, so the numbers above describe the common controller ICs rather than one vendor-locked breakout board revision. That is the right level of specificity for this prototype.

The battery math is not mysterious here. A few short radio bursts are cheap compared with leaving bright LEDs on continuously, and a linear charger can only dissipate so much heat before charge rate becomes a thermal problem. Both of those realities show up quickly in a handheld build.

Visible state

WS2812 status LEDs and the one-wire UI path

The LEDs are not cosmetic in BitWands. They are part of the interaction model. The wand needs to tell the user whether the hub has accepted a state, whether the state is still unknown, and when the device is sleeping or waking without relying on a separate monitor.

Hard data

  • WS2812B-class pixels integrate the LED and control logic in one package and use a single-wire NZR data stream at about 800 kbps.
  • Each pixel carries 24-bit color data with 8 bits per color channel, for 256 brightness steps per channel and 16,777,216 possible RGB color combinations.
  • The datasheet quotes a supply range around 3.5 V to 5.3 V, built-in signal reshaping, and chain lengths of at least 1024 pixels at 30 fps.
  • Nominal timing is a 1.25 us bit cell with short-high pulses for 0, longer-high pulses for 1, and a reset/latch interval that wants the line held low for tens of microseconds.
  • Worst-case current can be on the order of 60 mA per pixel at full-bright white, which is far more important to battery life than the control signal overhead.

Why it fit BitWands

A one-wire LED path is almost ideal for this kind of prototype because it gives immediate local feedback without consuming a whole bus or a large number of GPIOs. The same signal style works for the onboard XIAO pixel and the external status strip, which kept the visible behavior consistent.

It also let the project make one useful promise: final visible state only changes when the radio state has actually been accepted. That feels better than speculative LEDs that later get corrected by the hub.

The electrical caveat is real too. Once LEDs are powered from a boosted rail and want tight pulse timing, signal integrity starts to matter. Short traces and modest pixel count keep that manageable here.

How BitWands uses them

  • The current wand drives one onboard XIAO pixel plus a small external strip, with unknown shown in blue, set in green, and clear in red.
  • The onboard pixel is powered through GPIO 11 and driven on GPIO 12; the external strip uses GPIO 28.
  • The current implementation stays bit-banged because the total pixel count is tiny. That keeps the code straightforward, but it also makes the PIO offload path an obvious future improvement if the LED side grows more ambitious.
  • The firmware does not need flashy full-white animation. It needs a few high-contrast status colors that are easy to read and cheap to power.

Battery-life estimates in the deck were intentionally rough rather than bench-profiled. The useful result was clear anyway: active LEDs and radio traffic dominate runtime, so getting back to dormant quickly matters more than squeezing tiny gains out of the rest of the loop.

The broader hardware lesson was that small handheld electronics force software decisions early. Wiring, wake sources, bus ownership, and power conversion all show up in the firmware architecture whether you plan for them or not.

Protocol design follows the same pattern. Once the hardware is real, the project has to decide exactly when a wand is allowed to claim success, how a sleeping device reappears, and what happens when a packet fails. That is why the radio and ack flow have their own section.

Protocol

RFM69 framing and the BitWands ack model

The radio protocol is intentionally small. The RFM69 packet engine already handles the low-level chores like sync detection, CRC, and FIFO behavior, so BitWands only adds what it actually needs: destination, source, sequence number, a tiny payload vocabulary, and a matching ack message.

That keeps the system grounded. A wand does not need a complicated distributed-state algorithm. It needs a reliable rule for when a visible state is provisional and when it can become final.

System rule

Visible state waits for matching acknowledgement

The rule is simple: the wand can detect an orientation locally, but it does not treat that state as accepted until the hub sends back an ack with the same sequence number. That keeps the local LEDs tied to hub truth instead of wishful thinking.

That means the radio layer and the interaction design are tightly coupled. Reliability is not only about whether bytes got through. It is about whether the user sees a state change that the rest of the system also agrees on.

On-air shape

Small custom header inside RFM69 packet mode

BitWands puts one small frame structure on top of the RFM69 packet engine:

[length][destination][source][sequence_be_u32][payload...]
  • The project header is 6 bytes long after the initial length byte.
  • The maximum project frame size is 60 bytes, which leaves plenty of margin inside the radio FIFO for these small state messages.
  • The application payload is intentionally tiny: one-byte events and a five-byte ack.

Payload vocabulary

'S'  set
'G'  clear
'U'  unknown
'Z'  sleeping
'W'  waking
'A' + 4-byte big-endian acknowledged sequence

Ack flow

How one wand update becomes final

  1. The wand classifies its current orientation and encodes a one-byte state payload.
  2. It wraps that payload with destination, source, and a big-endian sequence number.
  3. After transmit, the wand immediately switches back into receive mode and waits for a matching ack.
  4. If the hub replies with 'A' and the same sequence number, the wand commits the visible state locally.
  5. If the ack times out, the wand retries with randomized backoff until it either succeeds or exhausts the retry budget.
  • The current ack timeout is 60 ms.
  • The retry limit is 31 attempts, with an initial send jitter of about 8 ms and randomized backoff after failures.
  • If retries are exhausted, the wand falls back to unknown instead of pretending the change worked.

Receiver policy

Hub behavior keeps stale state from lingering

  • On boot, a wand first reports unknown so the hub can clear stale prior state for that device.
  • Sequence 0 is reserved for that boot-time reset/connection case and is always accepted by the hub.
  • If a wand stays quiet for about 2 minutes, the hub expires it back to unknown.
  • On dormant wake, the wand reports waking and then re-reports its current known state instead of silently assuming continuity.

Those rules are small, but they solve the problems that actually show up in demos: unplugged devices, stale state, sleeping devices, and optimistic LEDs that drift away from receiver truth.

Why RFM69 helped

Chip-level packet features plus app-level delivery semantics

  • Variable-length packet mode matches the project’s short payloads without wasting airtime.
  • Sync words and address filtering reduce false matches before the application parser even sees a frame.
  • CRC-16 catches corrupted packets below the application layer, while the BitWands ack confirms semantic delivery above that layer.
  • Whitening helps short repetitive packets behave better on air, and auto RX restart keeps repeated reception smoother.
  • The 66-byte FIFO is large relative to the project’s normal message sizes, which keeps the framing model comfortably simple.

Current tuned values

What the checked-in runtime actually uses

  • 915 MHz carrier and a transmit level of 17 dBm.
  • Driver preset midrate_50kbps for the active firmware path, with a documented longer-range 9.6 kbps alternative kept as a design note.
  • A 3-byte sync word of 2D D4 42.
  • Maximum frame length 60, SPI bus at 1 MHz, and FIFO-fill behavior tied to sync/address detection.

The transport layer is not generic for its own sake. It is tuned for one specific job: tiny state messages that need to feel dependable in a room full of people.

Stack

Libraries, tools, and project deliverables

These sections reflect the authored stack in this repository tree. The reusable libraries and fwtool include quick-start tutorials with small examples. The local senior-project components are described here as project deliverables instead of reusable packages.

Together they show why the project grew beyond a single firmware tree. Some pieces exist to keep embedded development repeatable, while others exist to get the real system built, flashed, tested, and demoed.

Read this section from top to bottom and it moves from formats and parsers, to generated hardware and firmware infrastructure, to the project-specific pieces needed to demo the full system.

Read order

Bottom of the stack first

Start with Fig spec, fig-zig, and Munch if you want to understand the language, the Zig implementation, and the parser machinery, then move upward into Jig, Jig-RP2040, and the HAL.

Reusable pieces

What escaped the project boundary

Several layers became reusable on their own: the config language, parser toolkit, register generator, RP2040 image pipeline, HAL, USB framework, and the small flashing tool.

Project pieces

What stayed specific to BitWands

firmware and presentation are where all of those reusable layers get composed into the actual capstone system: wands, hub, OTA path, daemon, and live deck.

Specification

Fig Spec

Repository

Fig itself is a small configuration language with its own normative spec. That distinction matters in this project because the language rules are not just whatever the current Zig parser happens to accept.

The spec is aimed at hardware, embedded, and networking work. It borrows the broad shape of TOML, but it keeps the grammar smaller and adds the parts that actually matter here: tagged headers, semantic versions, bit ranges, and deterministic declarative merge rules.

Design Goals

The spec keeps the language small on purpose. It is supposed to be readable in source form, easy to implement without a giant parser stack, and precise enough to describe generated hardware manifests without drifting into ad hoc string conventions.

  • Readability, determinism, and declarative merge behavior are explicit goals in the spec.
  • Native literals cover integers in multiple bases, floats, booleans, strings, versions, bit ranges, and enum literals.
  • The intentional differences from TOML are part of the design: comments are //, arrays are {...}, and tags belong in headers.

Repo Structure

The spec repo is more than one long syntax document. It also carries the design rationale, mapping boundary notes, and conformance direction for future implementations.

  • SPEC.md is the normative language document.
  • docs/design-rationale.md explains why tags, merged declarations, and homogeneous arrays exist.
  • docs/mapping.md defines the boundary between Fig structure and consumer-side typing.

Semantics That Matter

What makes Fig useful for generated hardware and embedded config is not only the scalar syntax. It is the document model. Tables can be reopened, dotted keys and headers merge to one structure, tagged entries publish structural names, and positional sequences can mix explicit indices with append-style entries.

  • [server] and server.port = 8080 merge into the same nested table.
  • [[register: CR1]] lets later headers refer to CR1 as a structural name instead of a positional slot.
  • [@root] resets header context back to the document root.
  • Plain value arrays are homogeneous values, not path-addressable scopes.
  • Current spec version: 0.5.0.
  • Written for hardware, embedded, and networking configuration rather than generic app-config sprawl.
[peripheral: SPI1]
base_address = 0x40013000
version = 1.2.0

[SPI1.clock]
source = "HSI"

[[SPI1.register: CR1]]
offset = 0x00

Core notation

The fastest way to get a feel for the spec is to read a few small patterns instead of starting with the whole language document. These are the constructs that show up constantly in real config and manifest files.

  1. Use dotted keys or table headers interchangeably when they describe the same structure.
  2. Use {...} for homogeneous value arrays and [[...]] for repeated tables.
  3. Use header tags when the domain name matters more than the container name.
  4. Use version and bitrange literals directly instead of inventing string encodings.

Tables and dotted keys merge

server.host = "localhost"

[server]
port = 8080

Value arrays versus table sequences

ports = {80, 443, 8080}

[[endpoint]]
path = "/health"
method = "GET"

Tags, versions, and bit ranges

firmware = 1.2.3

[[register: CR1]]
bits = 0:7
access = .RW

Library

fig-zig

Repository

fig-zig is the Zig implementation of the Fig spec. It is the parser, raw-table builder, mapper, and validator that turn .fig source into typed Zig values.

The important architectural point is that this repo is not only a file loader. It takes the language through several clean phases: munch-based lexing and parsing, structural merge into a raw table tree, then typed mapping into ordinary Zig structs and slices.

Parser Pipeline

The checked-in implementation is explicit about each stage. lexer.zig exports a FigLexer built on Munch, parser.zig produces the typed AST, raw_table.zig merges the document into one structural tree, and type_map.zig maps that tree onto the caller's Zig type.

  • parseBytes(...) and parseFile(...) both go through the same parser, raw-table, and mapper path.
  • Syntax diagnostics come from the parser; structural merge errors come from the raw-table pass.
  • The raw-table layer is what makes reopened headers, tagged entries, and dotted integer paths merge deterministically.

Mapping Model

The consumer-facing surface stays simple on purpose. The caller supplies a normal Zig type, and the mapper fills it directly using ordinary fields, slices, nested structs, fig.Version, fig.BitRange, and support types such as fig.Map(T).

  • Result(T) owns the parsed value and the heap allocations it references.
  • fig.Tag lets a mapped struct receive the header tag that opened it.
  • fig.Map(T) is the clean target type for tagged table sequences.

Built On Munch And Backed By Validation

This repo is where Munch becomes real. The Fig lexer and parser are written as normal Zig modules on top of Munch, then the repo adds the Fig-specific structural passes and typed mapping that Munch alone is not supposed to provide.

  • fig-check validates both syntax and structural rules from the command line.
  • The build splits lexer, parser, and mapper tests so the language surface can be checked in layers.
  • Repeated tagged declarations, dotted integer paths, and merged sequence rules are covered in dedicated tests rather than treated as incidental behavior.
  • Zig parser and mapper for the Fig spec.
  • Built with Munch, then extended with Fig-specific merge and typing passes.
const fig = @import("fig");

const Config = struct {
    name: []const u8,
    version: fig.Version,
    radio: struct {
        frequency_mhz: u16,
        tx_power_dbm: i8,
    },
};

var result = try fig.parseFile(Config, alloc, "bitwands.fig");
defer result.release(alloc);

Quick start

The easiest way to approach fig-zig is as a typed parser library. Define the Zig shape you actually want, write a matching .fig file, parse it, then keep the Result(T) alive for as long as you need the mapped data.

  1. Add fig to build.zig.zon and import it into your root module.
  2. Write a .fig file with the values you want to edit outside the binary.
  3. Define a Zig struct with matching field names and types.
  4. Call fig.parseFile(...) or fig.parseBytes(...), then read result.value.
  5. Keep the Result(T) alive for as long as you need any slices or nested allocations owned by the parsed value.
  6. Run fig-check when you want syntax and structural validation without writing a consumer first.

Typed config parse

const std = @import("std");
const fig = @import("fig");

const Config = struct {
    name: []const u8,
    version: fig.Version,
    radio: struct {
        frequency_mhz: u16,
        tx_power_dbm: i8,
    },
};

var result = try fig.parseFile(Config, alloc, "bitwands.fig");
defer result.release(alloc);

std.debug.print("{s}: {d} MHz\n", .{
    result.value.name,
    result.value.radio.frequency_mhz,
});

Tagged sequence mapped into a keyed collection

const Register = struct {
    tag: fig.Tag,
    offset: u32,
};

const Device = struct {
    register: fig.Map(Register),
};

var result = try fig.parseBytes(Device, alloc,
    "[[register: CR1]]\n" ++
    "offset = 0x00\n");
defer result.release(alloc);

Validator usage

cd fig/fig-zig
zig build run -- path/to/config.fig

# or
zig build run-check -- path/to/config.fig

Library

Munch

Repository

Munch is the lexer and parser toolkit that makes Fig practical. Rules live at comptime, tokenization is zero-copy, and the parser surface stays close to plain Zig structs and unions.

The important architectural choice is that the grammar model is still just Zig. The lexer is configured at comptime, the parser is defined in terms of normal structs and unions, and wrapper types add the token semantics without dragging the whole parser into a separate DSL.

Lexer Architecture

The lexer uses tagged rules and matcher combinators. Every rule is tried at each input position, the longest match wins, and ties fall back to declaration order. That makes rule ordering matter only for ties instead of for general correctness.

  • ByteSet provides comptime character classes with merge and invert operations.
  • Matcher.literal, span, set, prefix, and delimiter cover most token patterns.
  • Unmatched bytes become ERR tokens instead of killing the token stream.

Parser Architecture

On the parser side, the main pieces are Token, Skip, Many, Optional, and Drain. Those wrappers let the parser keep the AST shape close to the grammar shape while still supporting recovery and recursive types.

  • Drain(T) is especially important for whole-document parsing with diagnostics.
  • Token bytes are zero-copy slices into the original source buffer.
  • Recursive ASTs can use pointers and are freed automatically by result.release(...).
  • Comptime lexer rules with maximal munch behavior.
  • Recursive-descent parsing with recovery via Drain.
const ByteSet = munch.lex.ByteSet;
const Matcher = munch.lex.Matcher;

const alpha = ByteSet.from("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ");
const digit = ByteSet.from("0123456789");
const alnum = alpha.merge(digit);

const MyLexer = munch.lex.Lexer(.{
    .rules = &.{
        .{ .tag = .identifier, .matcher = Matcher.span(alpha, alnum) },
        .{ .tag = .number, .matcher = Matcher.set(digit) },
        .{ .tag = .equals, .matcher = Matcher.literal("=") },
        .{ .tag = .whitespace, .matcher = Matcher.set(ByteSet.from(" \t")) },
        .{ .tag = .newline, .matcher = Matcher.literal("\n") },
    },
});

Quick start

The easiest way to learn Munch is to parse a tiny line-based language. Start with one token set, one AST node, and one root parser. Once that works, add more tokens and more node types without changing the basic shape. The README's end-to-end flow is the right way to approach it: lexer first, AST second, parser third, diagnostics last.

  1. Define the lexer rules with munch.lex.Lexer and the matchers you need.
  2. Model the grammar with plain Zig structs and wrapper types such as Token, Skip, and Drain.
  3. Configure a parser with munch.parse.Parser and tell it which token tags to skip.
  4. Parse a byte buffer, then inspect the typed result and any diagnostics.
  5. If you need whole-document recovery, make the root type Drain(T) instead of a single statement node.
  6. Add recursive pointers only after the flat version is already working.

Simple parse target

const Token = munch.parse.Token;
const Skip = munch.parse.Skip;
const Drain = munch.parse.Drain;

const Assignment = struct {
    name: Token(.identifier),
    _eq: Skip(.equals),
    value: Token(.number),
};

const Parser = munch.parse.Parser(.{
    .lexer = MyLexer,
    .root = Drain(Assignment),
    .skip = &.{ .whitespace, .newline },
});

var result = try Parser.parse(alloc, "score = 42\n");
defer result.release(alloc);

Diagnostics and recovery

if (result.hasErrors()) {
    for (result.diag) |d| {
        std.debug.print("{}:{}: expected {s}, found {s}\n",
            .{ d.loc.line, d.loc.col, d.expected, d.found });
    }
}

Generator

Jig

Repository

Jig generates thin, typed register-access layers from structured hardware manifests. It exists to keep the generated APIs readable and reject bad hardware descriptions early.

The project/target/block split is the center of the tool. The manifest hierarchy becomes the generated namespace hierarchy, so the keys and paths you write directly shape the output tree and import surface.

Manifest Architecture

Jig reads a three-layer Fig tree: project.fig -> target.fig(s) -> block.fig(s). A project names the generated HAL, each target names a chip family or variant, and each block describes the registers, groups, and buffers for one peripheral region.

  • target.<key> controls both the target directory and the hal.<key> namespace.
  • block.<key> controls filenames and target-level re-export names.
  • block.name must match the block key because it becomes the top-level generated declaration.

Generation Pipeline

The generator parses the project first, then each target, then each block. During that process it validates field widths, access policy, overlaps, and array configuration so invalid definitions fail at generation time instead of leaving questionable Zig in the output tree.

  • Targets emit info.zig and a target re-export file.
  • Blocks emit the typed register API plus any helpers they need.
  • hal.zig is the intended consumer entry point.

Why Fig Fits Jig So Well

Fig’s tagged tables and declarative path rules are not incidental here. They are the reason Jig manifests can stay readable while still describing nested hardware structure. A tag like reset or gpio becomes a structural name you can reopen later, which keeps the manifest close to how people already talk about registers and peripheral groups.

  • target.rp2040.path becomes both a filesystem location and the generated hal.rp2040 namespace.
  • [[register: reset]] creates one named register entry that later headers can reopen.
  • [[reset.field: uart0]] walks back through that tag and appends field metadata to the same register.
  • [[gpio.register.ctrl.field: funcsel]] extends the same idea one level deeper for arrayed register groups.
  • Reads a three-layer manifest tree: project, target, and block.
  • Emits register APIs that stay close to the input model.
name = "demo_hal"
build_path = "src/jig"

[target.rp2040]
path = "hardware/rp2040.fig"

Quick start

Jig is easiest to understand as a manifest-to-code pipeline. You describe the chip tree in Fig, run the generator once, and then treat the generated files as a normal Zig module. The first pass does not need a full chip definition. One target and one block is enough to see the shape. It helps to read the generated Zig immediately so the mapping between manifest and API is obvious.

  1. Write a project.fig that names the generated HAL and points at one or more targets.
  2. Write a target manifest that points at one or more block manifests.
  3. Describe registers, groups, and fields in the block manifest.
  4. Run zig build run -- path/to/project.fig, then import the generated hal.zig from application code.
  5. Read the emitted target and block files before layering more blocks on top.
  6. Rely on the generator’s validation instead of treating invalid manifests as input you can fix later in firmware.

1. Root project manifest

name = "demo_hal"
build_path = "src/jig"

target.rp2040.path = "hardware/rp2040.fig"

2. Target manifest

name         = "rp2040"
manufacturer = "Raspberry Pi Ltd"
revision     = "B2"

block.resets.path   = "resets.fig"
block.io_bank0.path = "io_bank0.fig"

3. Block manifest with tagged register fields

name        = "resets"
address     = 0x4000_C000
width       = 32
description = "Peripheral Reset Controller."

[[register: reset]]
offset      = 0x00
description = "Peripheral reset control."

[[reset.field: uart0]]
bits   = 22:22
access = .RW
reset  = 1

[[reset.field: usbctrl]]
bits   = 24:24
access = .RW
reset  = 1

Generated consumer code

const hal = @import("jig/hal.zig");

const before = hal.rp2040.resets.reset.read();
_ = before.uart0;

hal.rp2040.resets.reset.write(.{
    .uart0 = 0,
    .usbctrl = 0,
});

Arrayed register-group example

name        = "io_bank0"
address     = 0x4001_4000
width       = 32
description = "IO bank 0."

[[register_group: gpio]]
offset      = 0x00
count       = 30
stride      = 8
description = "GPIO status and control."

[[gpio.register: ctrl]]
offset      = 0x04
description = "GPIO control."

[[gpio.register.ctrl.field: funcsel]]
bits   = 0:4
access = .RW
reset  = 31

Generated arrayed use

const ctrl0 = hal.rp2040.io_bank0.gpio(0).ctrl().read();
_ = ctrl0.funcsel;

hal.rp2040.io_bank0.gpio(0).ctrl().write(.{
    .funcsel = 5,
});

Firmware pipeline

Jig-RP2040

Repository

Jig-RP2040 owns the bare-metal RP2040 firmware path: boot2, startup, interrupt registration, packaging, and UF2 output. It keeps startup minimal and pushes board policy into the application or modules.

This stage makes the RP2040 image format explicit. The build does more than compile a binary. It produces boot2, combines it with the app image, appends the CRC where needed, and emits a ready-to-flash UF2 while still exposing the generated RP2040 register blocks to the application.

Pipeline Architecture

The README is very clear about the internal split: boot2.zig brings up XIP, startup.zig owns reset and vector-table entry, registry.zig defines the interrupt declaration surface, and the host-side helpers package the exact final bytes.

  • addFirmware(...) is the main build API for consumers.
  • rp2040 is the generated register import used by app modules.
  • boot2crc, bin2uf2, and concat handle packaging details.

Boot Model

The runtime path is ROM bootloader to boot2, then app vector table at 0x10000100, then startup entry into app.main(). Unregistered interrupt slots default to a silent trap, so applications can decide which failures deserve visible behavior instead of inheriting a noisy default.

  • Boot2 is always built ReleaseSmall.
  • The app image begins at 0x10000100.
  • zig build jig regenerates the checked-in RP2040 HAL source when hardware definitions change.
  • Builds ready-to-flash RP2040 images directly from Zig.
  • Exposes comptime interrupt registration and generated RP2040 blocks.
const fw = jig_rp2040.addFirmware(b, dep, .{
    .app_module = app,
});

b.installFile(fw.uf2, "firmware.uf2");

Quick start

This package turns a normal Zig module into a flashable RP2040 image. The shortest path is: define an app module, hand it to addFirmware(...), then export a main function from src/main.zig. Once that works, add interrupt handlers and supporting modules. The tool stays deliberately minimal at startup so board policy still lives in your application code.

  1. Add jig-rp2040 as a dependency and create an app module rooted at src/main.zig.
  2. Import the generated rp2040 and optional registry modules in build.zig.
  3. Export pub fn main() noreturn from the app module.
  4. Run zig build and flash the generated UF2 from zig-out/.
  5. Add handlers only for the IRQ slots you truly own.
  6. If you change the RP2040 hardware definitions, regenerate the HAL with zig build jig and commit the output.

Small app module

const registry = @import("registry");

pub const handlers: registry.Registry = .{
    .isr_irq5 = handleUsb,
};

fn handleUsb() callconv(.{ .arm_aapcs = .{} }) void {}

pub fn main() noreturn {
    while (true) {}
}

Build-side integration

const dep = b.dependency("jig_rp2040", .{});

const app = b.createModule(.{
    .root_source_file = b.path("src/main.zig"),
});
app.addImport("rp2040", dep.module("rp2040"));
app.addImport("registry", dep.module("registry"));

const fw = jig_rp2040.addFirmware(b, dep, .{
    .app_module = app,
});

Library

RP2040 HAL

Repository

The HAL sits between generated RP2040 register access and application code. It uses explicit ownership boundaries instead of a hidden board object, which keeps clocks, GPIO muxing, and lifecycle decisions visible.

The README frames this as the main design principle: singleton controller modules own hardware blocks, instance drivers own one external device, and the caller still owns board-level sequencing and mux setup. That makes the package easier to reason about than a single global board abstraction.

Architecture

The repo sits on top of jig-rp2040 and below application code. It provides top-level modules such as gpio, time, i2c, spi, uart, usb, clocks, and power, then instance drivers like adxl345.Device and rfm69.Device compose those controllers.

  • time owns TIMER alarm 0 and TIMER_IRQ_0.
  • usb.CdcSerial owns PLL_USB, clk_usb, and USB controller lifetime.
  • power.dormant assumes the caller already moved the system into a dormant-safe clock state.

Bring-Up Pattern

The recommended order is explicit too: let jig-rp2040 handle startup, set clocks if you need to, initialize the singleton controllers you need, then create instance drivers on top. The caller still owns GPIO muxing, pad setup, and cross-module sequencing.

  • Use init() / deinit() to return blocks to known baselines.
  • Keep singleton state and device-instance state mentally separate.
  • Use the repo’s tests and generated-API compatibility checks when changing low-level behavior.
  • Singleton modules own hardware blocks such as GPIO, SPI, I2C, time, and USB.
  • Instance drivers build on top for specific parts such as the ADXL345 and RFM69.
hal.gpio.init();
defer hal.gpio.deinit();

hal.time.init(.{ .watchdog_tick_cycles = 12 });
defer hal.time.deinit();

hal.gpio.toggle(25);
hal.time.sleep(125_000);

Quick start

The main HAL pattern is explicit ownership. You initialize the singleton controller modules you need, then build device drivers on top of them. Nothing silently claims clocks, GPIO muxing, or interrupt slots for you, which keeps board policy visible in the application. The README’s bring-up order is the right mental model to follow whenever the package feels verbose.

  1. Add rp2040-hal to your firmware build and import it as const hal = @import("rp2040_hal");.
  2. Initialize the singleton modules you need, such as GPIO, time, SPI, I2C, or USB.
  3. Create device instances like adxl345.Device or rfm69.Device only after their bus controller is ready.
  4. Deinitialize modules when you want to return hardware to a known baseline.
  5. Keep board-specific pin muxing outside the package, because the repo intentionally leaves that policy with the caller.
  6. Reach for zig build test when you change timing math, driver behavior, or ownership rules.

Simple sensor bring-up

hal.gpio.init();
defer hal.gpio.deinit();

_ = try hal.i2c.init(.{
    .controller = .i2c0,
    .clk_sys_hz = 125_000_000,
    .baud_hz = 400_000,
});
defer hal.i2c.deinit(.i2c0);

var accel = try hal.adxl345.Device.init(.{
    .controller = .i2c0,
    .pins = .{ .chip_select = 17 },
    .address = .alt_low,
});
defer accel.deinit();

const sample = try accel.readSampleMg();
_ = sample;

USB CDC serial shape

const Serial = hal.usb.CdcSerial(.{
    .reference_hz = 12_000_000,
});

try Serial.init();
defer Serial.deinit();

while (true) {
    Serial.poll();
}

Library

Loom

Repository

Loom is a framework for firmware drivers. In this project it matters mainly through USB device support, where it provides protocol logic and state machines while the hardware layer supplies thin hooks.

The USB framework is built around a clean split: generic USB protocol logic stays in Loom, and the board-specific side only has to implement the HAL contract. That works well as the USB layer underneath the RP2040 HAL and the BitWands hub/gateway path.

Architecture

The README describes three cooperating layers: UsbCore, DriverPolicy, and the state machines. UsbCore handles control transfers and standard requests, the policy owns the function-class behavior, and the state machines keep bus and endpoint phases explicit.

  • UsbCore handles GET_DESCRIPTOR, SET_ADDRESS, SET_CONFIGURATION, and EP0 transfer phases.
  • DriverPolicy provides class descriptors, setup handling, and write semantics.
  • The current practical focus is CDC ACM virtual serial support.

HAL Contract

The configuration type you give Loom is checked at compile time. It provides device identity, controller lifecycle, endpoint zero operations, general endpoint I/O, and address management. CDC mode adds endpoint layout and optional callbacks for control-line and line-coding behavior.

  • getDeviceId, getDeviceDescriptor, and getDeviceLanguages define identity.
  • nextEvent() feeds the runtime event loop.
  • cdc_config picks endpoint numbers, packet sizes, and the notification interval.
  • Separates generic USB behavior from the board-specific HAL contract.
  • Supports CDC ACM for the host-visible hub and gateway path.
const usb = @import("loom").usb;

const Device = usb.CdcDriver(HalConfig, .{});

var device = Device{};
device.init();
while (true) device.poll();

Quick start

Loom pays off when you want protocol logic without rewriting the whole driver stack for each MCU. For a first pass, define one USB configuration type, expose the required lifecycle hooks, and let the framework own the USB state machine. Once that is working, the next useful additions are identity strings, CDC callbacks, and better endpoint-complete handling.

  1. Define a HalConfig type that satisfies the USB HAL contract.
  2. Provide the CDC endpoint configuration and the device identity callbacks.
  3. Create a driver such as usb.CdcDriver(HalConfig, .{}).
  4. Call init(), then keep polling in the main loop and use the driver methods for I/O.
  5. Add callbacks like onRx and onControlLineStateChanged only after the transport basics work.
  6. If you need another USB function class later, extend the driver policy layer rather than copying the CDC path.

Small CDC configuration slice

const usb = @import("loom").usb;

const HalConfig = struct {
    pub const cdc_config = usb.Types.CdcConfig{
        .ep_notification = 2,
        .ep_data_out = 3,
        .ep_data_in = 3,
        .max_packet_notification = 8,
        .max_packet_bulk = 64,
        .notification_interval = 16,
    };

    pub fn onRx(data: []const u8) void {
        _ = data;
    }
};

Identity and poll loop

pub fn getDeviceLanguages() usb.Types.DeviceLanguages {
    return .{
        .language_id = 0x0409,
        .strings = &.{
            .{ .kind = .manufacturer, .content = "BitWands" },
            .{ .kind = .product, .content = "Hub Receiver" },
            .{ .kind = .serial, .content = "001" },
        },
    };
}

var device = Device{};
device.init();
while (true) device.poll();

Deliverable

Firmware

Local project component

The firmware package is the canonical build root for the application, the OTA bootloader, the USB gateway, and the Linux-facing host bridge. It is where the project becomes one working system instead of a set of parts.

It also carries the glue that ties the reusable libraries together: board definitions, device IDs, RF protocol behavior, host-visible message shapes, and the targets used during bring-up and demo prep.

Repository Architecture

The README lays out the main split clearly. The RFM69 runtime code handles wand and hub behavior, orientation code handles the motion sensor path, boot3 and gateway code handle OTA installation, and the Linux host bridge republishes receiver state to the rest of the project.

  • src/rfm69/orientation_tx.zig and orientation_rx.zig are the wand and hub cores.
  • orientation_payload.zig and usb_hub_protocol.zig define the narrow machine-readable boundaries.
  • boot3.zig, gateway_ping.zig, and hub_repl.zig form the OTA install path.
  • host/bitwandsd.py bridges the hub to a local AF_UNIX socket.

Behavioral Rules

The wand and hub rules matter just as much as the file layout. On boot the wand reports unknown to clear stale hub state. During normal operation it waits for acknowledgements before committing visible state, and after long retry failure it falls back to unknown.

  • On dormant wake, the wand re-reports its last known state instead of resetting to unknown.
  • If a wand stays quiet for too long, the hub expires it back to unknown.
  • The XIAO onboard NeoPixel mirrors the external one-wire LED colors.
  • Wand transmitters report orientation and wait for matching acknowledgements.
  • Hub firmware exports a machine-readable USB CDC protocol to the Linux side.
{"type":"state",
 "dongle_connected":true,
 "known_mask":5,
 "set_mask":1,
 "states":["set","unknown","clear"]}

Host-Visible Protocol

The host boundary is intentionally small. The hub reports readiness, answers simple health checks, and publishes aggregate state snapshots or updates. That lets the Linux daemon, the presentation, and small debugging tools all consume the same information without another API layer on top.

  • READY and PONG make link health easy to test.
  • STATE messages carry the aggregate masks and per-wand view.
  • The line protocol is simple enough to inspect manually while still being machine-readable.

Project Workflow

The firmware tree expects jig-rp2040, loom, and rp2040-hal as sibling repos. There are three main workflows: build wands and hub, work on OTA components, or run the Linux bridge so the rest of the project can subscribe to live state.

  1. Build a specific device target when you are focused on one path, or use zig build bitwands for the full wand-and-hub set.
  2. Use boot3, gateway-ping, hub-repl, and app-demo targets for OTA work.
  3. Run python3 host/bitwandsd.py to expose aggregate state to host-side tools and the presentation runtime.

Core commands

cd firmware
zig build bitwands
zig build rfm69-orientation-tx -Ddevice-id=3
zig build gateway-ping
zig build hub-repl
python3 host/bitwandsd.py

Tool

fwtool

Local project component

fwtool is a small Zig command-line utility for hardware-oriented flashing tasks. Its current scope is narrow on purpose: detect an RP2040 in BOOTSEL mode, copy a UF2, and wait for it to disconnect cleanly.

That narrow scope is a feature for this project. The job was to remove one repetitive source of friction during testing, not to build a universal flashing framework.

Architecture

The command surface is intentionally small: fwtool rp2040 flash <path-to-firmware.uf2>. The utility looks for the BOOTSEL drive, copies the UF2, and waits for disconnect. It does not try to become a full serial monitor or board database.

  • BOOTSEL mount detection is Linux-specific.
  • The tool searches the common /run/media and /media mount locations.
  • Current scope is copy-and-disconnect, not runtime board management.

Current Limits

The README is explicit about the current limits, and they are useful limits to call out because they keep expectations realistic during development.

  • Only rp2040 flash is implemented.
  • The UF2 path is required; there is no default firmware path.
  • There are no aliases, reset helpers, probe commands, or monitor commands yet.
  • Simple hardware-first command layout.
  • Designed as a practical frontend rather than a generic device manager.
fwtool rp2040 flash zig-out/bin/firmware.uf2

// Finds the RPI-RP2 volume
// Copies the UF2
// Waits for the volume to disconnect

Quick start

fwtool is deliberately small, so the tutorial is mostly about workflow. Build the tool, put the board in BOOTSEL mode, then flash a UF2 directly. There is no hidden board database or monitor layer. It just finds RPI-RP2, copies the file, and waits for the drive to disappear. That waiting step matters because it keeps the command from returning before the board transitions away from the mass-storage path.

  1. cd fwtool and run zig build to build the utility.
  2. Put the RP2040 board into BOOTSEL mode so it mounts as RPI-RP2.
  3. Run zig build run -- rp2040 flash path/to/firmware.uf2 while working locally.
  4. Optionally install the binary and use fwtool rp2040 flash ... directly after that.
  5. If flashing fails, verify the board is actually in BOOTSEL mode and check the expected mount paths first.

Simple workflow

cd fwtool
zig build
zig build run -- rp2040 flash ../firmware/zig-out/bin/hub_firmware.uf2

# or, after install
fwtool rp2040 flash ../firmware/zig-out/bin/wand_3_firmware.uf2

Mount search paths

/run/media/RPI-RP2
/media/RPI-RP2
/run/media/$USER/RPI-RP2
/media/$USER/RPI-RP2

Presentation

Local project component

The capstone deck is also part of the project. It is a standalone Zig presentation runtime that uses kitty, retained widgets, live command panes, and authored slide content instead of exported slide images.

That turned the presentation into a real system component. It had to consume project artifacts, show live state, and handle the same runtime constraints and demo pressure as the rest of the stack.

Architecture

The build defines both a reusable module and a standalone executable. The public module re-exports pieces like widgets, presentation, layout, render, and theme, while runtime internals such as kitty handling and PTY sessions stay behind the internal boundary.

  • src/root.zig defines the public surface.
  • src/main.zig owns the standalone presenter loop.
  • src/bitwands_deck.zig holds the authored BitWands-specific deck content and support loading.
  • build.zig installs the images and the state-view script alongside the executable.

Runtime Flow

At startup the executable checks for kitty and a real TTY, enters raw mode and the alt screen, loads deck assets, then hands control to the presenter runtime. That runtime choice is why the deck can show live state panes and command output rather than static screenshots.

  • kitty.requireKitty(...) rejects unsupported terminals early.
  • presenter_runtime owns redraws, slide navigation, and event handling.
  • scripts/bitwands_state_view.py provides a smaller companion panel for live receiver state.
  • Runs as a real terminal application with its own runtime constraints.
  • Holds the project narrative, live demos, and supporting images in source.
kitty.requireKitty(stdin, stdout) catch |err| switch (err) {
    error.NotATerminal, error.KittyRequired => return,
    else => return err,
};

try deck.init(allocator, assets, support);

Project Workflow

The usual workflow is to edit the deck or runtime pieces, rebuild, and rerun inside kitty. The deck is not separate content pasted on top of the runtime. It is source code and assets that live in the same package as the runtime itself.

  1. cd presentation and run zig build to build the runtime and install local assets.
  2. Run zig build run inside kitty.
  3. Edit src/bitwands_deck.zig for slide content and project-specific live panes.
  4. Use python3 scripts/bitwands_state_view.py when you want a smaller live state panel.

Public surface and run loop

pub const widgets = @import("widgets.zig");
pub const presentation = @import("presentation.zig");
pub const layout = @import("layout.zig");
pub const render = @import("render.zig");
pub const theme = @import("theme.zig");
cd presentation
zig build
zig build run
python3 scripts/bitwands_state_view.py

term_pres

The terminal presentation software itself

The BitWands deck runs on a terminal presentation runtime I built as term_pres. It is not a static slide exporter. It is a real terminal UI layer with layout, widgets, code blocks, images, and support for live panes inside kitty.

That mattered because the presentation needed to behave like the rest of the stack: readable in a terminal, friendly to code-heavy content, and able to show live system state instead of flattening everything into screenshots.

Terminal-native

Built for real TTY use

The runtime assumes a real terminal and leans into it. Instead of imitating a slide app in a browser, it uses terminal text, layout, and image support directly.

Code-friendly

Good for technical explanations

A lot of this project is easier to explain with structured code and side-by-side comparisons. The runtime is built to show that material cleanly rather than forcing everything into bullet slides.

Demo-ready

Built to coexist with live state

One runtime can hold authored slides, command output, and supporting state views. That made it a good fit for a capstone demo where the software itself needed to stay visible.

term_pres screenshot showing a Fig to Zig slide with side-by-side code panels
Example explanatory slide The runtime handles this kind of slide well: a title, a short framing sentence, and two large panels that can be read side by side. Here the slide compares a Fig input file with the typed Zig schema it maps into. That kind of explanation is awkward in a normal slide tool but fits naturally in a terminal-native layout.
term_pres screenshot showing the BitWands game slide with target letter and team status panels
BitWands game slide Here the runtime is part of the project instead of just describing it. The slide carries the game target, team groupings, receiver status, and room for live wand-state feedback. That is what made the deck matter: it could take part in the demo instead of documenting it after the fact.

Build and Run

Bring up the full system

Below is the shortest path from source tree to working hardware, host tools, and presentation. It assumes the reusable support repos are available as siblings for the firmware build.

The order matters. Build the firmware first, flash one board at a time, bring up the Linux bridge, then launch the presentation or supporting state views. That mirrors the dependency chain in the actual system.

1. Build the firmware set

cd firmware
zig build bitwands
zig build gateway-ping
zig build hub-repl

This produces the wand and hub UF2s, the gateway firmware, and the OTA REPL under firmware/zig-out/bin/.

  • Start here whenever the radio or host layers change.
  • Keep the generated UF2 names straight before moving to the flashing step.

2. Flash boards through BOOTSEL

cd fwtool
zig build
zig build run -- rp2040 flash ../firmware/zig-out/bin/hub_firmware.uf2
zig build run -- rp2040 flash ../firmware/zig-out/bin/wand_3_firmware.uf2

Put each RP2040 board into BOOTSEL mode one at a time, then flash the matching UF2. Repeat for the other wand IDs as needed.

  • The BOOTSEL volume should unmount after a successful copy.
  • Flashing one board at a time keeps hub and wand roles from getting mixed up.

3. Run the Linux hub bridge

cd firmware
python3 host/bitwandsd.py

# or pin to a known device
python3 host/bitwandsd.py --tty /dev/ttyACM0

The bridge verifies the USB receiver, requests a snapshot, and republishes wand state over a local AF_UNIX socket.

  • Use the pinned --tty form when multiple serial devices are attached.
  • This bridge is the easiest place to confirm the live aggregate state before opening the presentation.

4. Launch the presentation or state views

cd presentation
zig build run

# supporting state view
python3 scripts/bitwands_state_view.py

The presentation runtime expects kitty and a real TTY. The supporting state view script is useful when you want a smaller live panel.

  • Use the full deck for walkthroughs and the state view for quick protocol checks.
  • This final step is where the whole stack proves it can run end to end.

Process

How the project grew

The project started with a small interaction idea and kept expanding whenever a missing layer blocked the next step. That growth was not arbitrary, but it did compress integration time near the end.

Most of the process was a series of forced clarifications. Every time a boundary felt vague during bring-up, it turned into a concrete design or tooling task.

1. Start from a summer-camp game idea

The original goal was simple: preserve the feel of a physical group game where a wand points up or down to represent a binary state. That gave the project a clear interaction target before any hardware or tooling existed.

That early framing mattered because it kept the project anchored to a real human interaction instead of drifting into a generic electronics demo.

2. Let the board force the architecture

Once the project had to live on a real handheld device, power delivery, motion sensing, radio reliability, and LED behavior stopped being side notes. They became the constraints that shaped the firmware and the user experience.

That was the point where battery assumptions, wake behavior, and bus ownership stopped being implementation details and started shaping the code structure.

3. Build the missing layers instead of hiding them

The stack kept demanding one more real layer: a config language, a parser toolkit, hardware description tooling, generated register access, firmware packaging, and host-side utilities. Fig, Munch, Jig, and the RP2040 pipeline came out of those repeated needs.

Those layers were not added for novelty. They were added because manual or ad hoc versions of the same work kept showing up and slowing down the next task.

4. Integration pressure showed up at the end

By the end, the project was more than the wand and hub firmware. It also included OTA install tooling, a Linux state bridge, and the presentation runtime itself. Each layer solved a concrete problem, but together they left less stabilization time than they should have before presentation week.

The clearest project-management lesson was that end-to-end polish needs protected time. A working stack is not the same thing as a stable demo-ready system.

What I learned

Lessons from building the whole stack

The most useful lessons came from the places where different layers touched. Reliability, ownership, power, and scheduling all looked more important at the end than they did at the start.

Visible state should follow receiver truth

The main firmware rule was simple: do not claim a visible state change until the receiver acknowledges it. Sequence numbers, retries, and fallback to unknown made the system feel more trustworthy than pretending the radio path was always reliable.

That one rule improved both the user experience and the debugging story because visible errors became meaningful instead of random.

Explicit ownership beats hidden convenience

The strongest low-level design choice in the software stack was making ownership visible. The HAL, USB path, and board setup were easier to reason about when clocks, GPIO muxing, interrupts, and peripheral lifetime were explicit instead of hidden behind one global board abstraction.

It made the code noisier in a good way. The setup path got longer, but the responsibility boundaries got clearer.

Power policy is part of the interaction design

Dormant sleep, LED shutdown, wake behavior, and battery assumptions were not background details. They directly shaped whether the wand felt like a usable handheld object or a wired classroom demo.

In a small battery-powered system, power handling is one of the main ways software affects whether the hardware feels finished.

Protect stabilization time at the end

Scope growth was real: the project expanded from a game mechanic into hardware, firmware, OTA, host tools, and the presentation runtime itself. The practical scheduling lesson is that the final week should be for end-to-end testing and rehearsal, not fresh debugging.

That applies even more when the presentation itself depends on the system being live. Demo infrastructure is part of the product if the product has to be shown in motion.

Next additions

Requirements, proposal, materials, and progress artifacts

These sections tie the finished build back to its original goals and constraints. They also capture the practical side of the project: costs, requirement changes, and intermediate hardware states.

This part of the page is less about implementation mechanics and more about accountability: what the project set out to do, where the final build stayed faithful to that plan, and which claims still need measurement instead of confidence.

Stayed the same

Physical shared-state learning tool

The core identity held steady from start to finish: multiple wireless wands, one hub, terminal-first software, and a physical way to make binary and hexadecimal ideas more tangible.

Changed during build

Much more infrastructure

The project added far more supporting tooling than the proposal implied. That was the price of making the firmware, host path, and demo flow repeatable instead of manual.

Still worth measuring

Targets vs. verified numbers

The project has clear target metrics for latency, packet delivery, and battery life. The site should keep distinguishing those targets from fully measured final numbers.

Requirements status

This table reads better at full width because the requirement notes are doing real work. It is meant to show where the final build matched the original intent, where the boundary changed, and where claims still need better measurement.

Requirement area Status Notes
Eight wand + hub architecture Implemented The delivered stack still follows the original multi-wand plus hub shape.
Terminal-first software surface Implemented The project stayed terminal-native for the game/presentation side instead of adding a GUI.
Zig firmware and custom HAL/toolchain Implemented The shipped stack uses Zig, generated hardware layers, custom linker/build work, and no vendor SDK dependency.
Budget target On track The current rough estimate is below the original `$300` system budget and `$27` per-wand target.
USB host protocol Adjusted The shipped boundary is a machine-readable USB CDC protocol plus Linux-side bridge instead of a generic debug-oriented serial link.
Cross-platform host support Not fully met The current host tooling is Linux-first. The requirements document asked for Windows, macOS, and Linux.
Performance targets Partly verified The implementation exists, but the site should treat latency, packet-delivery, and battery claims as targets unless backed by measurements.

Proposal vs. final build

Interaction model

Proposal: a collaborative binary/hex teaching tool built around eight wands, one hub, and terminal game software.

Final build: the core interaction stayed intact. The wand-up/wand-down model and the shared state game surface are still the center of the project.

Sensor choice

Proposal: accelerometer or custom tilt hardware were both still on the table.

Final build: the system uses the ADXL345 path, with active sampling, wake behavior, and firmware-side orientation classification.

Host boundary

Proposal: a simple USB serial path to the game software.

Final build: a tighter machine-readable USB CDC protocol, a Linux socket bridge, OTA gateway tooling, and a presentation USB client.

Tooling scope

Proposal: bare-metal firmware, educational documentation, and a simple terminal game.

Final build: the project grew into Fig, Munch, Jig, Jig-RP2040, the RP2040 HAL, OTA install tooling, `fwtool`, and the standalone presentation runtime.

Build system

Proposal: the plan still mentioned Make or CMake as possible tooling.

Final build: the implemented stack is Zig-first, with Zig build roots across the project-specific components.

Original requirements

The original requirements document framed BitWands as an interactive CS learning system: eight wireless wands, one hub, and terminal-based game software built around collaborative binary and hexadecimal play.

Even where implementation details changed, the educational framing stayed intact. The project is still about making shared state, representation, and systems behavior more tangible.

  • Orientation maps to a binary state, LED feedback, and hub aggregation across all eight wands.
  • Latency target: less than 300 ms from wand motion to on-screen feedback.
  • Radio target: more than 95% packet delivery at classroom distances.
  • Power target: at least 2 hours of active gameplay on one charge.
  • Implementation target: Zig firmware and software, custom HAL generation, and no vendor SDK dependency.
  • Budget target: less than $300 for the full system and less than $27 per wand.

Rough bill of materials

This parts-price estimate is derived from the presentation BOM and checked against current retailer and marketplace prices on May 15, 2026. It is a prototype estimate, not a manufacturing quote, and it excludes the existing Linux host computer.

The pricing goal here is not accounting precision. It is to show that the project stayed within the rough educational-prototype budget band laid out in the original requirements.

Per wand hardware Rough unit cost
XIAO RP2040 module$3.50
ADXL345 accelerometer board$2.83
RFM69HCW radio module$5.36
500 mAh Li-ion or LiPo cell$7.16
TP4056 charger module$0.57
MT3608 boost converter$1.65
WS2812 status LED$0.13
Mini slide switch$0.47

Per-wand rough total: about $21.67

Shared demo setup Rough cost
Hub electronics (XIAO RP2040 + RFM69HCW)$8.86
Proto board(s)$4.50
Header strips$4.95
Hookup wire and bench wiring stock$14.98

8 wands + shared setup: about $215.51 before tax and extra spares

Bulk pricing is assumed where a full system naturally buys multiples: 10+ pricing for the XIAO, RFM69HCW, and 500 mAh battery, plus bundle pricing for the charger, boost, and ADXL345 modules. That keeps this rough estimate close to the original budget target instead of pricing every part as a one-off retail purchase.

Original proposal

The proposal pitched BitWands as both a teaching tool and a documented embedded-systems case study. The core idea was to make binary and hexadecimal concepts physical instead of leaving them as paper exercises.

The finished project stayed close to that motivation even as the technical scope grew underneath it.

  • Early summer-camp testing suggested the physical game format got better engagement than traditional methods.
  • The proposed system already had the same three-part shape: eight wands, a hub, and game software.
  • The technical plan centered on RP2040-based hardware, RFM69 radios, orientation sensing, and a terminal-first interface.
  • The long-term goal was to release both the finished tool and the design process itself as open educational material.

Project progress photos

A few intermediate project states are in place now. More build photos and bring-up shots can be added here as the gallery grows.

Even this small set shows the shift from bench wiring to a fuller end-to-end prototype.

Early full-system BitWands prototype test setup
First full-system test.
Hand-wired BitWands prototype hardware during assembly
Hand-wired prototype assembly.