radio_tx_burst(): captures current slot, calls fhss_next_channel() to advance the sequence, sets channel, transmits ptt_frame_t, then holds the dwell window via TIMER0 so the receiver has the full 2 ms to listen. radio_rx_burst(): advances to the same channel, enables RX, waits for EVENTS_END or TIMER0 COMPARE0 expiry. On a valid CRC the ptt_frame_t is returned; caller syncs FHSS with fhss_set_slot(frame.slot + 1). TIMER0: configured once in radio_init() (MODE=Timer, BITMODE=16-bit, PRESCALER=4 -> 1 MHz tick, CC[0]=2000 -> 2 ms, COMPARE0_STOP shortcut). fhss.c: replace zero key with a non-trivial 128-bit value; add fhss_set_slot() and fhss_get_slot() for RX synchronisation. power.c: add BUTTON_ACTIVE_LOW flag (default 1 = pull-up + GND button); configure PIN_CNF accordingly; implement power_button_pressed() via NRF_P0->IN. regs.h: add gpio_pin_cnf_t with GPIO_PULL_* constants. main.c: tight PTT loop -- radio_tx_burst() while button held, otherwise radio_rx_burst() with slot sync on reception. No WFI in RX mode.
ptt-fhss
Bare-metal PTT (Push-to-Talk) firmware for the Seeed XIAO BLE (nRF52840) with frequency-hopping spread spectrum (FHSS) on the 2.4 GHz band.
Designed to be low-power, hard to detect, and built with a fully open toolchain - no Zephyr, no Nordic SDK, no SoftDevice.
Hardware
| Component | Part |
|---|---|
| Target | Seeed XIAO BLE (nRF52840) |
| Programmer | RP2040 running DAPLink |
How it works
FHSS
The radio hops across 40 channels (2402-2480 MHz, 2 MHz steps) every 2 ms. The hopping sequence is generated by AES-128-ECB keyed with a shared secret - both devices derive identical sequences independently without any synchronisation traffic. To an observer with an SDR the transmissions appear as short, scattered impulses with no identifiable pattern.
Low power
The nRF52840 stays in SYSTEM_ON sleep (WFI) with the DC/DC converter active
(~1.5 uA quiescent). A GPIOTE edge event on the PTT button wakes the CPU;
the radio is active only during the burst.
Register access
All hardware register writes use typed bitfield unions defined in include/regs.h
rather than raw bit-shift expressions. The union layout is guaranteed correct with
arm-none-eabi-gcc (LSB-first bitfields on Cortex-M).
Toolchain
Everything that produces the binary runs inside a container. The host only needs tools that talk to hardware (pyocd) or manage the build (just).
| Tool | Purpose | Install |
|---|---|---|
| podman or docker | Container runtime | distro package |
| just | Task runner | cargo install just or distro package |
| pyocd | Flash / debug via DAPLink | pip install pyocd |
| git | Source control + submodules | distro package |
Inside the container: debian:bookworm-slim + gcc-arm-none-eabi from apt +
cmake + ninja.
Quick start
git clone <repo-url> ptt
cd ptt
git submodule update --init --depth=1
just build # build firmware.hex inside container
just flash # flash via DAPLink (host pyocd)
On first run just build pulls the container image and compiles. Subsequent
builds reuse the cached image and only recompile changed files.
Tasks
just build compile firmware inside the container
just flash build + flash via DAPLink
just gdbserver start pyocd GDB server on port 3333
just clean remove build/
just clean-all remove build/ and the container image
Project structure
.
|-- Dockerfile container image (debian + arm-gcc from apt)
|-- justfile build / flash / debug tasks
|-- CMakeLists.txt
|-- cmake/
| \-- arm-none-eabi.cmake CMake toolchain file
|-- link/
| \-- nrf52840.ld linker script (no SoftDevice, Flash @ 0x00000000)
|-- include/
| |-- regs.h hardware register bitfield unions
| |-- radio.h
| |-- fhss.h
| \-- power.h
|-- src/
| |-- startup.c vector table (64 entries) + Reset_Handler
| |-- main.c
| |-- radio.c RADIO peripheral driver (stub)
| |-- fhss.c AES-ECB hopping sequence generator
| \-- power.c DC/DC, GPIOTE wakeup, WFI sleep
\-- vendor/
|-- nrfx/ Nordic HAL headers, pinned to v2.9.0 (includes mdk/)
|-- CMSIS_5/ ARM core headers (core_cm4.h etc.)
\-- tiny-aes-c/ Public domain AES-128 implementation
Before first flash
The XIAO BLE ships with a SoftDevice which occupies the start of Flash. Erase it before flashing bare-metal firmware:
pyocd erase --target nrf52840 --chip
Status
| Module | State |
|---|---|
| Startup / vector table | done |
| Linker script | done |
| Power management (sleep + wakeup) | done |
| FHSS sequence generator | done |
| RADIO driver | stub - TX loop not yet implemented |
| Sync protocol | not started |