- heltec_v3 now uses USB OTG (requires hardware mod: solder R29/R3)
- heltec_v3_uart added as fallback for stock boards
- Removed every #[allow(dead_code)] — fixed root causes instead:
- Comm types (UsbParts/UartParts) moved to individual board files
- USB/UART init removed from hal (boards call esp-hal directly)
- SH1106 driver gated on boards that use it
- UART task sends DisplayCommand::Reset at startup (variant now used)
- ErrorCode::CrcError removed (SX1262 drops bad-CRC silently)
- check/clippy now use --release (no more Xtensa dev-mode warnings)
- Updated README with hardware mod docs and board table
The default heltec_v3 feature now uses native USB OTG (same as V4),
appearing as /dev/ttyACM0 with the DongLoRa VID:PID (1209:5741).
This requires a hardware mod: solder R29/R3 and disconnect the CP2102
from the USB data lines.
Stock (unmodified) V3 boards should use the new heltec_v3_uart feature
which preserves the original UART-over-CP2102 behavior (/dev/ttyUSB0).
- heltec_v3: switched from UART to USB OTG
- heltec_v3_uart: new feature, UART fallback for stock boards
- Removed #[cfg] gates on UsbParts/UartParts (both always defined)
- Updated README with hardware mod instructions and board table
- Updated justfile with heltec_v3_uart board definition
The display task started with disconnected=true, waiting for a
DisplayCommand::On that the USB task sends on DTR connect. The UART
task has no DTR, so the display never left the "disconnected" state
and ignored all radio status updates. Start connected instead — the
USB task will send Reset on actual disconnect.
- LoRaBoard trait now has associated types: RadioParts, CommParts, DisplayParts, DisplayDriver
- into_parts() is on the trait (enforced by compiler, not implicit convention)
- BoardParts<R, C, D> struct replaces tuple return from into_parts()
- mac_address() stays on trait, MAC also in BoardParts for convenience
- Removed mac field from DisplayParts (display task calls Board::mac_address())
- DisplayDriver associated type requires DrawTarget<Color = BinaryColor>
- Each board now exports DisplayDriver type and create_display() async fn
- ESP32-S3 boards share SSD1306 create_display in board/esp32s3.rs
- RAK WisBlock has its own SSD1306 create_display (different I2C type)
- Wio Tracker has SH1106 create_display (different display IC)
- Removed all cfg_if from display.rs — calls board::create_display()
- Removed display init retry logic (single attempt, return None on failure)
- ZERO board-specific #[cfg(feature)] outside board/, host/mod.rs, and hal/
- Flattened radio/ directory to radio.rs (was just one file)
- Flattened display/ to display.rs (combined task + rendering as inline module)
- Moved SH1106 display driver reference from display/ to driver/ (done in Phase 2)
- Suppressed dead_code warnings for conditional USB/UART types in hal
- All boards: zero warnings, zero cfg in application code
- Created host/ module with internal cfg dispatch (USB vs UART)
- Moved usb/task.rs → host/usb.rs, uart/task.rs → host/uart.rs
- Moved protocol_io.rs → host/framing.rs
- Renamed usb_task/uart_task to host_task (uniform name)
- Removed all host-comm cfg from main.rs
- The run() function now has ZERO board-specific conditional compilation
- Deleted old usb/, uart/, protocol_io.rs modules
- Created hal/esp32s3.rs with MCU primitives (SPI, I2C, USB, UART, timer, MAC)
- Created hal/nrf52840.rs with shared nRF boilerplate (interrupts, bus wrapping, VBUS, MAC)
- Moved SH1106 OLED driver from display/ to driver/ (clear separation from app code)
- Board esp32s3.rs now delegates MCU init to hal, keeps only peripheral wiring (RadioParts)
- nRF board files use hal for shared StaticCell patterns, mac_address, interrupt bindings
- Board files still own pin assignments and peripheral construction
- Made protocol_io.rs compile for all boards (not just heltec_v3)
- Renamed FrameAccumulator → CobsDecoder, encode_response → cobs_encode_response
- Refactored USB task to use shared CobsDecoder and route_command from protocol_io
- Removed ~80 lines of duplicated COBS framing and command routing from usb/task.rs
- Single canonical MAX_FRAME definition in protocol_io.rs
- Track serial timeout in field instead of returning hardcoded 2s
- Always restore timeout in drain_rx, even on read errors
- Handle EINTR/Interrupted in read_frame (retry instead of error)
- Use connect_timeout for TCP mux (no more 2-min hangs)
- Set write timeout on mux sockets (unix + tcp)
- Log unexpected unsolicited frames in recv via tracing::warn
- Add unix socket timeout test for WouldBlock handling
- Add CHANGELOG.md, remove resolved CLAUDE_TODO.md
Add donglora-client (clients/rust/) and donglora-mux (mux/rust/) as
Rust implementations of the existing Python client library and mux
daemon. Both are standalone Cargo projects with no-panic guarantees
enforced by clippy deny lints.
donglora-client provides protocol types, COBS framing, USB device
discovery, serial/mux transports, and a high-level Client<T> with
send/recv. donglora-mux is a fully async (tokio) multiplexer daemon
with dongle hot-plug reconnect, per-client bounded send queues,
SetConfig locking, and StartRx/StopRx reference counting.
52 unit tests across both crates. Updates README roadmap checkboxes
and CONTRIBUTING docs to reflect the new implementations.
Drop INVALID and UNRELIABLE packets per PROTOCOL.md quality grading
to avoid retransmitting likely-corrupt data. MARGINAL packets are
forwarded with a warning tag. All RX log lines now include SNR.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add cargo-binutils to mise.toml (needed for `just size`)
- Remove jj from mise.toml (optional, not a build dependency)
- Silence `mise install` output when tools are current (--quiet)
- Add `just outdated` recipe with embassy-executor exclusion
(esp-rtos 0.2 pins embassy-executor ^0.9; 0.10 renamed arch-*
features to platform-*, which crashes cargo-outdated)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Audited against meshcore-spec and fixed all compliance gaps:
- Fix trace hash size encoding: use 1<<(flags&3) not (flags&3)+1
- Reject reserved path hash_size_code 3 per spec Section 3
- Minimum packet size 3 bytes (was 2)
- Add MAX_PACKET_PAYLOAD (184) and min payload (1) validation
- Tighten payload minimums: ANON_REQ≥51, encrypted≥20, group≥19,
multipart≥2
- Add node_type 0 ("none") to NODE_TYPES
- Fix node_type fallback display (was "node=type=14", now "node=0x0e")
Add packet quality warnings for likely-corrupt frames:
- decode_meshcore_packet returns (str, list[str]) with quality warnings
- ADVERT: flag bad node_type, bad UTF-8 in name, future/bad timestamps
- Combine content warnings with low-SNR signal at display layer
- Warnings stack: one flag = maybe, multiple = high confidence corrupt
Update test corpus and generators for tightened validation rules.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
These paths are already in .gitignore but were committed before the
ignore rules existed. Remove them from the index so they stay local.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add SAFETY comment to nRF52840 FICR unsafe block explaining the
register addresses and why read_volatile is correct.
Replace silent fallback defaults in to_sf()/to_cr() with defmt::warn
so invalid values are logged instead of silently accepted.
Remove unnecessary #[allow(dead_code)] on DisplayCommand enum.
Add clippy::undocumented_unsafe_blocks = "deny" lint to Cargo.toml.
Replace hardcoded USB serial "001" with the board's MAC address so
multiple dongles are uniquely identifiable on one host. Thread MAC
through usb_task as a parameter, removing the Board import.
Add 31 unit tests for protocol serialization: RadioConfig roundtrips,
all Command tags, all Response variants, validation boundaries, and
edge cases. Gate firmware modules with #[cfg(not(test))] and add
DONGLORA_HOST_TEST env var so `just test` runs on the host.
Move common type aliases, peripheral bundles, and init helpers for
ESP32-S3 boards into src/board/esp32s3.rs. Heltec V3 (146->48 lines)
and V4 (155->57 lines) become thin wrappers containing only
board-specific code (Vext GPIO pin, display reset sequence).
Update build.rs to exclude esp32s3.rs from board discovery and
mod.rs.j2 to include the shared module under an ESP feature gate.
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>