Switch ucobs from local path dependency to crates.io v0.2. Add note
documenting that embassy-executor 0.10 and ucobs 0.3 are blocked on
the ESP Xtensa toolchain supporting Rust >= 1.93 stable.
Replace raw dict dumps and debug-style prints with consistent
themed output using >>> / <<< markers and formatted responses.
Quiet TxDone noise, dim status messages, and align all output
with the 2-space indent used by RX packet lines.
New layout:
- Mode box (left): RX/TX indicator with vertical separator
- Title box (right): 'DongLoRa v{ver}' + radio settings (freq/bw/sf/cr)
- Centered info rows: packet counters + RSSI/SNR with labels
- Sparkline bar graph fills remaining height
- Idle state returns to splash screen (active screen only for RX/TX)
- Layout positions derived from display dimensions (h/w agnostic)
- RSSI history buffer doubled to 128 slots (~2 min at 1s/slot)
- Smart compact_count() formatting for large RX/TX counters (k/M)
The rightmost graph bar now renders the current (uncommitted) time
slot, so incoming packets appear on the sparkline instantly instead
of waiting up to 1 second for the slot timer to commit them. When
the slot advances, the live bar shifts left into committed history
and a fresh live slot begins.
build-std=core,alloc was globally enabled in .cargo/config.toml, which
conflicted with cargo-fuzz's own -Zbuild-std, causing duplicate core
errors. Now only passed for Xtensa targets via the Justfile.
New protocol command (tag 8): GetMac returns the board's 6-byte hardware
address. Useful when multiple dongles are connected to one host.
Response (tag 6): MacAddress containing 6 raw bytes.
Updated: PROTOCOL.md, CHANGELOG, donglora.py decoder, all_commands.py.
- 8 new interop tests cross-validating our COBS encoder/decoder against
corncobs (including 500 random payloads). Total: 39 tests.
- New: src/board/PORTING.md — step-by-step guide to adding a new board
- New: crates/donglora-cobs/README.md — documents design choice and testing
- Updated README with lora_bridge.py example
Every board's Board struct now implements the LoRaBoard trait, which
enforces that NAME, TX_POWER_RANGE, init(), and mac_address() are all
provided. Missing any of these is a compile error — no "good intentions"
needed.
The trait verifies metadata and initialization. Tasks use the board's
concrete types directly (Embassy tasks must be concrete). Board-specific
type aliases (RadioDriver, UsbDriver, DisplayI2c) and struct definitions
(RadioParts, UsbParts, DisplayParts) remain in each board file.
- New: src/board/traits.rs (LoRaBoard trait definition)
- Modified: all 3 board files (implement trait, remove standalone constants)
- Modified: main.rs (import trait for init)
- Modified: radio/task.rs (use Board::TX_POWER_RANGE via trait)
- Modified: display/task.rs (use Board::NAME via trait)
- Modified: build.rs (exclude traits.rs from board discovery)
- Removed: embedded-hal-async direct dep (not needed for trait approach)
Board init: all .unwrap() → .expect() with diagnostic messages.
Vext/reset forget(): documented why pin must stay in output state.
Fixed LoRa params (PREAMBLE_LEN, CRC_ON, etc.): documented as
intentionally non-configurable.
Watch capacity: documented that display sees latest state only.
Config response (GetConfig) now returns decoded RadioConfig fields instead
of raw hex. New all_commands.py exercises all 8 protocol commands in
sequence — Ping, SetConfig, GetConfig, StartRx, StopRx, Transmit,
DisplayOff, DisplayOn.
If Transmit included a per-packet config with different bandwidth/SF,
the radio would silently switch parameters when resuming RX. Now uses
state.config (the config from SetConfig) for post-TX RX restart.
All examples now catch serial.SerialException instead of crashing with
a traceback. This covers port contention (multiple processes), device
disconnect, and USB errors. meshcore_rx.py already had this.
Extract COBS encoder/decoder into a separate no_std workspace crate
(crates/donglora-cobs) with comprehensive tests against the Cheshire &
Baker 1999 IEEE/ACM specification and Wikipedia canonical vectors.
Tests cover: empty input, single/multiple zeros, block boundaries at
254/255 bytes, alternating patterns, the Ping regression case (tag 0x00),
round-trip verification, and error cases (truncation, unexpected zeros).
Replace inline cobs_decode() in usb/task.rs with the tested crate.
Replace external cobs dependency with workspace crate.
Fix encoder bug at 254-byte block boundary (spurious trailing code byte).
to_sf() relied on the catch-all _ => _7 to handle SF=7, and to_cr()
relied on _ => _4_5 for CR=5. Both work by accident but are misleading
— a reader would think SF=7 and CR=5 are unhandled. Make them explicit.
The cobs_decode() function had a bogus 'strip trailing zero' step that
removed the last byte if it was 0x00. The Ping command IS tag byte 0x00,
so its decoded payload was stripped to empty, causing from_bytes to return
None. The COBS standard algorithm doesn't produce spurious trailing zeros
— the post-processing was simply wrong.
Also fix read_frame to skip bare 0x00 sentinels on fresh USB connects.
Splash/waiting screen now shows:
Row 1: "DongLoRa" (bold)
Row 2: "Board Name vX.Y.Z"
Row 3: MAC address (XX:XX:XX:XX:XX:XX)
Row 4: "Waiting for host..."
Each board reads its MAC at init:
- ESP32-S3: efuse MAC via esp_hal::efuse::Efuse::mac_address()
- nRF52840: FICR device address registers
BoardInfo struct bundles name/version/mac to keep dashboard() under
clippy's argument limit.
Remove all varint/zigzag encoding. Every field is now struct.pack/unpack
with fixed-size little-endian format strings. RadioConfig is a single
struct.pack('<IBBBHB', ...) call. Response decoding uses struct.unpack_from
with known offsets. No encoding helpers needed beyond struct.
Drop postcard and serde dependencies entirely. All integers are now
fixed-width little-endian on the wire — no varints, no zigzag encoding.
A developer sending tx_power_dbm=14 sees 0x0E on the wire. No surprises.
- RadioConfig: 10 bytes fixed (freq:4 LE, bw:1, sf:1, cr:1, sync:2 LE, pwr:1)
- Commands/Responses: tag byte + fixed-size fields
- Vec<u8>: 2-byte LE length prefix + raw bytes
- Option<T>: 1-byte (0=None, 1=Some) + T
- COBS framing preserved (just byte-stuffing, no encoding tricks)
- Manual COBS decode replaces postcard's CobsAccumulator
- Removes ~15KB from firmware binary (no serde monomorphization)
- Each board exports TX_POWER_RANGE constant (min, max dBm)
- RadioConfig::validate() uses the board's range, not hardcoded limits
- TX_POWER_MAX sentinel (-128 on the wire) means "use board's maximum"
— resolved to the actual max before storing config
- GetConfig returns the resolved actual power, not the sentinel
- Default configs in examples use TX_POWER_MAX
- Protocol docs updated
ROOT CAUSE: postcard encodes u8 and i8 as raw bytes, NOT varint/zigzag.
The Python encoders used varint() for u8 and zigzag() for i8. For small
values this was silently correct (varint(7) == raw byte 7), but for
tx_power_dbm: zigzag(14) = 28, which the new validation correctly
rejected as out of range (-9..22). This was a pre-existing bug masked
by the absence of validation.
Fix all RadioConfig field encoders in both donglora.py and meshcore_rx.py.
Fix docs/PROTOCOL.md to correctly document i8 as raw byte, not zigzag.
Restore full config validation.
MeshCore is a protocol implementation ON TOP of DongLoRa, not part of the
dongle itself. Moving it to examples/ makes the separation clear while
keeping it accessible as a real-world usage example.
CodingRate was an enum with repr(u8) values that didn't match postcard's
wire encoding (variant index). Instead of papering over the mismatch,
make cr a plain u8 in RadioConfig. Host sends the denominator directly:
5 for CR 4/5, 6 for CR 4/6, etc. No lookup tables, no index confusion.
Remove CodingRate enum, cr_denom() helper, and the Bandwidth import that
was only needed for cr_denom. ErrorCode repr values also fixed to 0-based.
Firmware:
- fix(protocol): SNR type i8→i16 to match lora_phy PacketStatus
- fix(display): combine splash + waiting into one screen
- fix(display): move TX power to row 0 right-justified
- feat(display): sparkline distinguishes TX from RX — TX bars are dotted
(alternating pixel rows), RX bars are solid. TX-only slots show a short
marker. TX takes precedence over RX in the same time slot.
Host tool (tools/meshcore_rx.py):
- feat: full MeshCore packet decoder with colored ANSI output
- feat: GRP_TXT decryption using 276 channels from channels.csv
- feat: Watchman loop/collision reporter to #watchman
- feat: ADVERT-informed node registry with 12h TTL persistence
- feat: transport code fallback for older MeshCore firmware
- fix: robust error handling for long-running operation
- fix: collision detection uses evidence-based reasoning from advert cache
Channel directory (tools/channels.csv): 276 channels
Test corpus (corpus/): JSON Schema, 28 hand-crafted vectors, Hypothesis generator
- Poll DTR every 250ms via select3 timer arm (read_packet blocks
forever after host closes port, so DTR check must not depend on it)
- DTR drop → send DisplayCommand::Reset (blocking send, not try_send)
- DTR rise → send DisplayCommand::On
- Display task: disconnected flag ignores StatusWatch while set,
preventing stale radio status from overriding the idle screen
- On reconnect, display grabs current RadioStatus from watch and
renders dashboard immediately (no waiting for next radio event)
- Simplified clippy to use _cargo helper
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Switch all ESP32-S3 boards from UsbSerialJtag to USB OTG CDC-ACM via
embassy-usb. The USB device stack is now shared code — boards only
provide the platform-specific embassy-usb Driver.
- Custom VID:PID 1209:5741 (open-source VID, "WA" PID)
- CDC-ACM class with proper device enumeration
- COBS+postcard protocol over CDC-ACM packet read/write
- Concurrent USB device loop + protocol loop via join()
- On disconnect: sends DisplayCommand::Reset to show "Waiting for host..."
- Justfile: espflash no longer uses --monitor (OTG steals PHY)
- Test script updated to detect by new VID:PID
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix CodingRate encoding: postcard uses 0-based variant index, not
repr(u8) value. CR 4/5 is variant 0, not 1.
- Shorten RSSI/SNR display line to fit 21-char width
- Python script auto-detects DongLoRa by USB VID:PID (303a:1001),
reconnects on device reset/disconnect, no more crash on RST
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Protocol:
- SetConfig, StartRx, StopRx now send Response::Ok on success
(host was timing out waiting for acks that never came)
Display:
- Full frequency precision: 910.525MHz not 910.5MHz
- Full bandwidth labels: 62.5kHz not 62k
- RSSI/SNR row shows units (dBm, dB)
- Separator line moved down 3px to give RSSI/SNR breathing room
- Sparkline is now time-based: each bar = 1 second, scrolls left,
showing ~1 minute of RSSI history. Empty slots = no packets.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Wire up the lora-phy driver in radio_task:
- SetConfig stores radio parameters
- StartRx enters continuous receive mode via lora-phy
- rx_once waits for a packet, reports RSSI/SNR/payload to host
- StopRx returns to standby
- Transmit sends a payload with the configured (or overridden) params
- Concurrent RX: select between incoming packets and host commands
- Automatic RX restart after receiving a packet or completing TX
- Error handling: radio errors are reported to host, RX fallback to idle
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Wire up UsbSerialJtag on ESP32-S3 boards for host communication:
- Read COBS-framed postcard commands from host via serial
- Route DisplayOn/Off to display task, everything else to radio
- Serialize responses back to host as COBS frames
- Use postcard::CobsAccumulator for chunked deserialization
- Concurrent read/write via embassy select
RAK nRF52840 USB (embassy-usb CDC-ACM) is still a stub — different
init path, will be implemented separately.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Rebrand from lora-dongle to DongLoRa (portmanteau of dongle + LoRa).
Lowercase donglora in Cargo package name, firmware filenames, and code.
Splash screen shows "DongLoRa" in 9x15 bold.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Root causes found and fixed:
- USB OTG init stole the PHY from USB-Serial-JTAG (the only USB on V4)
- executor-thread feature conflicted with esp-hal-embassy's executor
- Linker flags needed -Wl, prefix for GCC-to-LD passthrough
- Missing exception-handler feature on esp-backtrace
- Missing alloc in build-std
- Missing LTO and codegen-units=1 in release profile
- Vext on V4 is active LOW (datasheet correct, Arduino lib was wrong)
Now matches the official esp-hal v0.22.0 examples exactly.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- flash command uses espflash for Xtensa boards, probe-rs for ARM
(ESP32-S3 has a USB-serial bootloader, not a debug probe)
- Pre-flight checks before flash: missing tools get a clear error with
the exact install command instead of a cryptic failure
- Pre-flight check for esp toolchain before build/clippy too
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three fixes:
- Add .cargo/config.toml with -Tlinkall.x for xtensa target (resolves
ROM functions, interrupt handlers, memory layout)
- Add integrated-timers feature to embassy-executor for ESP32 boards
(resolves _embassy_time_schedule_wake)
- Add `use esp_println as _` to pull in defmt global_logger (was being
stripped as dead code because nothing referenced the crate)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three tiers: supported now, next up (popular/straightforward), and
future (niche/contributions welcome). Board names follow MeshCore
flasher conventions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Source ~/export-esp.sh in Justfile for xtensa builds (provides GCC)
- Add build-all target for producing firmware
- Trap SIGINT in build-all/check-all so Ctrl+C stops immediately
- Add .cargo/config.toml to .gitignore (may be generated)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add heltec_v4 board: ESP32-S3R2, SX1262, SSD1315 display, native USB,
Vext on GPIO36 (active HIGH)
- Wire up heltec_v3 with real esp-hal peripherals (was a stub)
- V3 and V4 share radio/I2C pins, differ in Vext pin/polarity and
display reset
- Restructure main.rs: cfg_if selects embassy_executor::main (Cortex-M)
vs esp_hal_embassy::main (Xtensa) per board
- Display detection via SSD1306 init in display_task — boards always
provide I2C bus, task exits gracefully if no display responds
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Use nightly cargo + esp rustc (RUSTC override) + -Zbuild-std=core for
Xtensa targets. This avoids the esp cargo's inability to parse modern
crate manifests (edition 2024) while still using esp's LLVM with full
Xtensa support.
Pin proc-macro-crate to 3.2.0 to avoid transitive deps requiring
rustc 1.85+.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Implement full SSD1306 OLED display support:
- Splash screen on boot (1.5s) with project name, version, board
- Dashboard showing radio state, config, counters, RSSI/SNR
- RSSI history sparkline (64-sample ring buffer, 2px-wide bars)
- DisplayOn/DisplayOff host commands routed via dedicated channel
- Pure render functions against generic DrawTarget (no hardware coupling)
- Display on/off control bypasses radio task entirely
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Reverts the removal of heltec_v3 — it's a major board. Xtensa builds
use the esp toolchain (+esp via espup); check-all gracefully skips
boards whose toolchain isn't installed.
Build artifacts now land in firmware/ with readable names like
lora-dongle-rak_4631-release.elf instead of being buried in target/.
DESIGN.md updated with "support all common boards" principle (#10)
and corrected toolchain policy (#11).
Heltec V3 (ESP32-S3) requires the espup Xtensa toolchain fork, which
violates the standard-toolchain-only principle. Removed until upstream
Rust supports Xtensa targets via stock rustup.
Also removes all #[allow(dead_code)] band-aids — unused items are
deleted rather than suppressed. DESIGN.md updated with standard
toolchain principle (#10) and peripheral model (input/telemetry slots).