WIP: functional checkpoint!

This commit is contained in:
swaits committed 2026-04-06 21:15:35 -06:00
1 parent aebb8aedb8
commit 0d07bd2f83
21 files changed
+678 -146

No files matched your search

+86
View File
@@ -0,0 +1,86 @@
# Add non-blocking `connect` variant
## Problem
`connect(None, timeout)` in `src/connect.rs` has a three-step fallback:
1. `DONGLORA_MUX_TCP` env var -> TCP mux
2. Unix socket mux (if socket file exists)
3. Direct USB serial via `find_port().unwrap_or_else(wait_for_device)`
Step 3 calls `wait_for_device()` which polls `find_port()` every 500ms
**indefinitely** until a USB device appears. This makes `connect(None, ...)`
unsuitable for callers that need to retry with backoff or report disconnected
status (e.g. donglora-bridge's reconnect loop).
Today, donglora-bridge works around this by manually reimplementing the
fallback chain:
```rust
if let Ok(c) = connect_mux_auto(timeout) {
(c, "mux".to_string())
} else {
let port_path = find_port()
.ok_or_else(|| anyhow!("no mux or USB dongle found"))?;
let c = connect(Some(&port_path), timeout)?;
(c, shorten_path(&port_path))
}
```
This duplicates logic from `connect()` (steps 1-2 via `connect_mux_auto`, step
3 minus the blocking wait) and won't pick up any future changes to the
`connect()` fallback order.
## Proposed solution
Add a `try_connect` (or similar) that runs the same mux -> serial fallback as
`connect(None, timeout)` but replaces `wait_for_device()` with a single
`find_port()` call, returning an error immediately if no device is found:
```rust
/// Like [`connect`] but returns an error instead of blocking when no USB
/// device is present. Suitable for callers with their own retry logic.
pub fn try_connect(timeout: Duration) -> Result<Client<AnyTransport>> {
// Steps 1-2: identical to connect() — try TCP mux, then Unix socket mux
// Step 3: non-blocking USB scan
let port_path = discovery::find_port()
.ok_or_else(|| anyhow!("no DongLoRa device found (no mux, no USB device)"))?;
let transport = SerialTransport::open(&port_path, timeout)?;
Ok(Client::new(AnyTransport::Serial(transport)))
}
```
This would let donglora-bridge collapse its connection code to:
```rust
let (mut client, device) = if let Some(port) = port {
let c = connect(Some(port), timeout)?;
(c, shorten_path(port))
} else {
let c = try_connect(timeout)?;
let device = match c.transport() {
AnyTransport::Mux(_) => "mux".to_string(),
AnyTransport::Serial(_) => find_port()
.map(|p| shorten_path(&p))
.unwrap_or_else(|| "serial".to_string()),
};
(c, device)
};
```
### Device name from transport
The bridge also needs to know *what* it connected to for display purposes. Two
options:
1. Match on `AnyTransport` variant after connecting (shown above) -- works but
requires a redundant `find_port()` call for the display name since
`SerialTransport` has private fields.
2. Have `try_connect` return the port path alongside the client, e.g.
`-> Result<(Client<AnyTransport>, ConnectionInfo)>` where `ConnectionInfo`
is an enum like `Mux { socket_path }` | `Serial { port_path }`. Cleaner
but a larger API change.
Either works. Option 2 would also benefit `connect()` callers who want to log
what they connected to.
+6
View File
@@ -4,3 +4,9 @@ rustflags = [
"-C", "link-arg=-Wl,-Tdefmt.x",
"-C", "link-arg=-nostartfiles",
]
[target.thumbv7em-none-eabihf]
rustflags = [
"-C", "link-arg=-Tlink.x",
"-C", "link-arg=-Tdefmt.x",
]
+13
View File
@@ -111,6 +111,7 @@ checksum = "8ec610d8f49840a5b376c69663b6369e71f4b34484b9b2eb29fb918d92516cb9"
dependencies = [
"bare-metal",
"bitfield 0.13.2",
"critical-section",
"embedded-hal 0.2.7",
"volatile-register",
]
@@ -301,6 +302,16 @@ dependencies = [
"thiserror",
]
[[package]]
name = "defmt-rtt"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93d5a25c99d89c40f5676bec8cefe0614f17f0f40e916f98e345dae941807f9e"
dependencies = [
"critical-section",
"defmt 1.0.1",
]
[[package]]
name = "delegate"
version = "0.13.5"
@@ -364,9 +375,11 @@ name = "donglora"
version = "0.1.0"
dependencies = [
"cfg-if",
"cortex-m",
"cortex-m-rt",
"critical-section",
"defmt 1.0.1",
"defmt-rtt",
"display-interface",
"embassy-embedded-hal 0.6.0",
"embassy-executor",
+14 -1
View File
@@ -62,7 +62,9 @@ esp-rtos = { version = "0.2", optional = true, features = [
] }
# nRF52840 (RAK 4631)
cortex-m-rt = { version = "0.7", optional = true }
cortex-m = { version = "0.7", optional = true, features = ["critical-section-single-core"] }
cortex-m-rt = { version = "0.7", optional = true, features = ["set-vtor"] }
defmt-rtt = { version = "1.0", optional = true }
embassy-nrf = { version = "0.10", optional = true, features = [
"nrf52840",
"gpiote",
@@ -89,7 +91,18 @@ heltec_v4 = [
"dep:esp-rtos",
]
rak_wisblock_4631 = [
"dep:cortex-m",
"dep:cortex-m-rt",
"dep:defmt-rtt",
"dep:embassy-nrf",
"dep:panic-probe",
"embassy-executor/arch-cortex-m",
"embassy-executor/executor-thread",
]
wio_tracker_l1 = [
"dep:cortex-m",
"dep:cortex-m-rt",
"dep:defmt-rtt",
"dep:embassy-nrf",
"dep:panic-probe",
"embassy-executor/arch-cortex-m",
+14 -2
View File
@@ -50,8 +50,7 @@ fn main() {
}
let mut env = Environment::new();
let template =
fs::read_to_string("src/board/mod.rs.j2").expect("missing src/board/mod.rs.j2");
let template = fs::read_to_string("src/board/mod.rs.j2").expect("missing src/board/mod.rs.j2");
env.add_template("mod", &template)
.expect("failed to parse board template");
let rendered = env
@@ -63,4 +62,17 @@ fn main() {
println!("cargo:rerun-if-changed=src/board/mod.rs.j2");
println!("cargo:rerun-if-changed={}", board_dir);
// cortex-m-rt's link.x does `INCLUDE memory.x`. Copy our nRF layout into
// OUT_DIR (which cortex-m-rt already adds to the linker search path) so it's
// only visible to ARM builds. Keeping it out of the project root prevents
// the linker from shadowing esp-hal's generated memory.x on Xtensa builds.
let target = std::env::var("TARGET").unwrap_or_default();
if !target.starts_with("xtensa") {
let out_dir = std::env::var("OUT_DIR").unwrap();
fs::copy("ld/nrf52840-memory.x", format!("{out_dir}/memory.x"))
.expect("failed to copy ld/nrf52840-memory.x");
println!("cargo:rustc-link-search={out_dir}");
}
println!("cargo:rerun-if-changed=ld/nrf52840-memory.x");
}
+39 -4
View File
@@ -6,11 +6,12 @@ set shell := ["bash", "-c"]
heltec_v3 := "heltec_v3 xtensa-esp32s3-none-elf esp32s3"
heltec_v4 := "heltec_v4 xtensa-esp32s3-none-elf esp32s3"
rak_wisblock_4631 := "rak_wisblock_4631 thumbv7em-none-eabihf nRF52840_xxAA"
wio_tracker_l1 := "wio_tracker_l1 thumbv7em-none-eabihf nRF52840_xxAA"
builds_dir := "builds"
# All known boards
boards := "heltec_v3 heltec_v4 rak_wisblock_4631"
boards := "heltec_v3 heltec_v4 rak_wisblock_4631 wio_tracker_l1"
# Install all required tools and toolchains
setup:
@@ -65,7 +66,7 @@ build board profile="release":
@just _cargo {{board}} "build --{{profile}}"
@just _copy_firmware {{board}} {{profile}}
# Build and flash a board (espflash for Xtensa, probe-rs for ARM)
# Build and flash a board (espflash for Xtensa, UF2 DFU for ARM)
flash board:
@just _ensure_tools
@just build {{board}} release
@@ -80,9 +81,16 @@ flash board:
echo "No port found, falling back to espflash auto-detection..." >&2; \
espflash flash "{{builds_dir}}/donglora-{{board}}-release.elf"; \
fi ;; \
*) probe-rs run --chip $chip "{{builds_dir}}/donglora-{{board}}-release.elf" ;; \
*) just _flash_uf2 {{board}} ;; \
esac
# Flash ARM board via debug probe (requires J-Link or similar)
flash-probe board:
@just _ensure_tools
@just build {{board}} release
@read -r feat target chip <<< "$(just _info {{board}})"; \
probe-rs run --chip $chip "{{builds_dir}}/donglora-{{board}}-release.elf"
# Show binary size for a release build
size board:
@just _cargo {{board}} "size --release"
@@ -163,11 +171,38 @@ _copy_firmware board profile:
@read -r feat target chip <<< "$(just _info {{board}})"; \
src="target/$target/{{profile}}/donglora"; \
dst="{{builds_dir}}/donglora-{{board}}-{{profile}}"; \
if [ -f "$src" ]; then cp "$src" "$dst.elf"; echo "→ $dst.elf"; fi
if [ -f "$src" ]; then \
cp "$src" "$dst.elf"; echo "→ $dst.elf"; \
case "$target" in \
thumbv7em-*) \
mise exec -- rust-objcopy -O ihex "$src" "$dst.hex"; \
mise exec -- cargo-hex-to-uf2 hex-to-uf2 --input-path "$dst.hex" --output-path "$dst.uf2" --family nrf52840; \
rm "$dst.hex"; \
echo "→ $dst.uf2"; \
;; \
esac; \
fi
# Copy UF2 to a mounted UF2 bootloader drive
[private]
_flash_uf2 board:
@uf2="{{builds_dir}}/donglora-{{board}}-release.uf2"; \
if [ ! -f "$uf2" ]; then \
echo "error: $uf2 not found — run 'just build {{board}}' first" >&2; exit 1; \
fi; \
mnt="$(find /run/media -maxdepth 3 -name INFO_UF2.TXT -printf '%h\n' -quit 2>/dev/null)"; \
if [ -z "$mnt" ]; then \
echo "error: no UF2 drive found — double-tap reset to enter bootloader" >&2; exit 1; \
fi; \
echo "Copying to $mnt ..."; \
cp "$uf2" "$mnt/"; \
sync; \
echo "Done — board will reboot automatically"
[private]
_info name:
@if [ "{{name}}" == "heltec_v3" ]; then echo "{{heltec_v3}}"; \
elif [ "{{name}}" == "heltec_v4" ]; then echo "{{heltec_v4}}"; \
elif [ "{{name}}" == "rak_wisblock_4631" ]; then echo "{{rak_wisblock_4631}}"; \
elif [ "{{name}}" == "wio_tracker_l1" ]; then echo "{{wio_tracker_l1}}"; \
else echo "Unknown board: {{name}}" >&2; exit 1; fi
+16
View File
@@ -0,0 +1,16 @@
/* nRF52840 memory layout — Adafruit UF2 bootloader
*
* 0x00000 – 0x01000 MBR (Master Boot Record)
* 0x01000 – 0xEA000 Application ← we live here
* 0xEA000 – 0xF4000 Reserved (app data)
* 0xF4000 – 0xFC000 UF2 Bootloader
* 0xFF000 – 0x100000 Bootloader settings
*
* RAM: first 8 bytes reserved for MBR interrupt forwarding.
*/
MEMORY
{
FLASH : ORIGIN = 0x00001000, LENGTH = 0xE9000
RAM : ORIGIN = 0x20000008, LENGTH = 255K
}
+1
View File
@@ -3,6 +3,7 @@ rust = "1.93"
just = "latest"
"cargo:cargo-binutils" = "latest" # `cargo size`, `cargo objdump`, etc.
"cargo:cargo-hex-to-uf2" = "latest" # ELF → UF2 via Intel HEX for nRF52840 DFU
"cargo:espup" = "0.16"
"cargo:espflash" = "3" # 4.x has broken nix dependency on current stable Rust
"cargo:probe-rs-tools" = "0.31"
+1 -2
View File
@@ -37,8 +37,7 @@ impl Board {
core::mem::forget(vext); // hold pin low permanently; drop would reset it
let radio = esp32s3::init_radio(
p.SPI2, p.DMA_CH0, p.GPIO9, p.GPIO10, p.GPIO11, p.GPIO8, p.GPIO12, p.GPIO14,
p.GPIO13,
p.SPI2, p.DMA_CH0, p.GPIO9, p.GPIO10, p.GPIO11, p.GPIO8, p.GPIO12, p.GPIO14, p.GPIO13,
);
let usb = esp32s3::init_usb(p.USB0, p.GPIO20, p.GPIO19);
let i2c = esp32s3::init_display_i2c(p.I2C0, p.GPIO17, p.GPIO18);
+1 -2
View File
@@ -37,8 +37,7 @@ impl Board {
core::mem::forget(vext); // hold pin low permanently; drop would reset it
let radio = esp32s3::init_radio(
p.SPI2, p.DMA_CH0, p.GPIO9, p.GPIO10, p.GPIO11, p.GPIO8, p.GPIO12, p.GPIO14,
p.GPIO13,
p.SPI2, p.DMA_CH0, p.GPIO9, p.GPIO10, p.GPIO11, p.GPIO8, p.GPIO12, p.GPIO14, p.GPIO13,
);
// Note: switches internal USB PHY from Serial-JTAG to OTG.
+5 -3
View File
@@ -87,7 +87,6 @@ impl super::traits::LoRaBoard for Board {
}
impl Board {
pub fn into_parts(self) -> (RadioParts, UsbParts, Option<DisplayParts>) {
let p = self.p;
@@ -108,7 +107,8 @@ impl Board {
let dio1 = Input::new(p.P1_15, Pull::Down);
let busy = Input::new(p.P1_14, Pull::Down);
let iv = GenericSx126xInterfaceVariant::new(reset, dio1, busy, None, None).expect("SX1262 interface init");
let iv = GenericSx126xInterfaceVariant::new(reset, dio1, busy, None, None)
.expect("SX1262 interface init");
let sx_config = sx126x::Config {
chip: Sx1262,
@@ -125,7 +125,9 @@ impl Board {
// ── USB ──────────────────────────────────────────────────
static VBUS: StaticCell<embassy_nrf::usb::vbus_detect::SoftwareVbusDetect> =
StaticCell::new();
let vbus = VBUS.init(embassy_nrf::usb::vbus_detect::SoftwareVbusDetect::new(true, false));
let vbus = VBUS.init(embassy_nrf::usb::vbus_detect::SoftwareVbusDetect::new(
true, false,
));
let usb = UsbParts {
driver: Driver::new(p.USBD, Irqs, vbus),
};
+154
View File
@@ -0,0 +1,154 @@
use embassy_embedded_hal::shared_bus::asynch::spi::SpiDevice;
use embassy_nrf::gpio::{Input, Level, Output, OutputDrive, Pull};
use embassy_nrf::spim::{self, Spim};
use embassy_nrf::twim::{self, Twim};
use embassy_nrf::usb::Driver;
use embassy_nrf::{bind_interrupts, Peripherals};
use embassy_sync::blocking_mutex::raw::NoopRawMutex;
use embassy_time::Delay;
use static_cell::StaticCell;
use lora_phy::iv::GenericSx126xInterfaceVariant;
use lora_phy::sx126x::{self, Sx1262, Sx126x};
use super::traits::LoRaBoard;
bind_interrupts!(struct Irqs {
USBD => embassy_nrf::usb::InterruptHandler<embassy_nrf::peripherals::USBD>;
SPIM3 => embassy_nrf::spim::InterruptHandler<embassy_nrf::peripherals::SPI3>;
TWISPI0 => embassy_nrf::twim::InterruptHandler<embassy_nrf::peripherals::TWISPI0>;
});
// ── Concrete peripheral types ────────────────────────────────────────
type SpiBus = Spim<'static>;
type Nss = Output<'static>;
type Iv = GenericSx126xInterfaceVariant<Output<'static>, Input<'static>>;
type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, SpiBus, Nss>;
/// SX126x driver (pre-LoRa construction — LoRa::new is async).
pub type RadioDriver = Sx126x<RadioSpiDevice, Iv, Sx1262>;
/// USB driver for CDC-ACM.
pub type UsbDriver = Driver<'static, &'static embassy_nrf::usb::vbus_detect::SoftwareVbusDetect>;
/// I2C bus for SH1106 OLED.
pub type DisplayI2c = Twim<'static>;
// ── Peripheral bundles ───────────────────────────────────────────────
pub struct RadioParts {
pub driver: RadioDriver,
pub delay: Delay,
}
pub struct UsbParts {
pub driver: UsbDriver,
}
pub struct DisplayParts {
pub i2c: DisplayI2c,
pub mac: [u8; 6],
}
// ── Board init ───────────────────────────────────────────────────────
pub struct Board {
p: Peripherals,
}
impl super::traits::LoRaBoard for Board {
const NAME: &'static str = "Wio Tracker L1";
const TX_POWER_RANGE: (i8, i8) = (-9, 22);
fn init() -> Self {
let mut config = embassy_nrf::config::Config::default();
config.hfclk_source = embassy_nrf::config::HfclkSource::ExternalXtal;
config.lfclk_source = embassy_nrf::config::LfclkSource::ExternalXtal;
let p = embassy_nrf::init(config);
Self { p }
}
fn mac_address() -> [u8; 6] {
// SAFETY: 0x10000000 is the nRF52840 FICR (Factory Information Configuration
// Registers) base address. Offsets 0x0A4 and 0x0A8 are the DEVICEADDR[0] and
// DEVICEADDR[1] registers containing the factory-programmed device address.
// read_volatile is correct for memory-mapped hardware registers.
unsafe {
let ficr = 0x10000000 as *const u32;
let addr0 = core::ptr::read_volatile(ficr.byte_add(0x0A4));
let addr1 = core::ptr::read_volatile(ficr.byte_add(0x0A8));
[
addr0 as u8,
(addr0 >> 8) as u8,
(addr0 >> 16) as u8,
(addr0 >> 24) as u8,
addr1 as u8,
(addr1 >> 8) as u8,
]
}
}
}
impl Board {
pub fn into_parts(self) -> (RadioParts, UsbParts, Option<DisplayParts>) {
let p = self.p;
// ── SPI bus for SX1262 ───────────────────────────────────
let mut spi_cfg = spim::Config::default();
spi_cfg.frequency = spim::Frequency::M1;
let spi = Spim::new(p.SPI3, Irqs, p.P0_30, p.P0_03, p.P0_28, spi_cfg);
static SPI_BUS: StaticCell<embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus>> =
StaticCell::new();
let spi_bus = SPI_BUS.init(embassy_sync::mutex::Mutex::new(spi));
let nss = Output::new(p.P1_14, Level::High, OutputDrive::Standard);
let spi_device = SpiDevice::new(spi_bus, nss);
// ── SX1262 control pins ──────────────────────────────────
let reset = Output::new(p.P1_07, Level::High, OutputDrive::Standard);
let dio1 = Input::new(p.P0_07, Pull::Down);
let busy = Input::new(p.P1_10, Pull::Down);
// RF switch: RXEN on P1.08 (GPIO, toggled by lora_phy), TX via DIO2 (SX1262 internal).
let rx_enable = Output::new(p.P1_08, Level::Low, OutputDrive::Standard);
let iv = GenericSx126xInterfaceVariant::new(reset, dio1, busy, Some(rx_enable), None)
.expect("SX1262 interface init");
let sx_config = sx126x::Config {
chip: Sx1262,
tcxo_ctrl: Some(sx126x::TcxoCtrlVoltage::Ctrl1V8),
use_dcdc: true,
rx_boost: false,
};
let radio = RadioParts {
driver: Sx126x::new(spi_device, iv, sx_config),
delay: Delay,
};
// ── USB ──────────────────────────────────────────────────
static VBUS: StaticCell<embassy_nrf::usb::vbus_detect::SoftwareVbusDetect> =
StaticCell::new();
let vbus = VBUS.init(embassy_nrf::usb::vbus_detect::SoftwareVbusDetect::new(
true, true,
));
let usb = UsbParts {
driver: Driver::new(p.USBD, Irqs, vbus),
};
// ── BAT_CTL: P0.04, active HIGH — enables power to OLED and battery sense
let bat_ctl = Output::new(p.P0_04, Level::High, OutputDrive::Standard);
core::mem::forget(bat_ctl);
// ── Display (SH1106 OLED on I2C) ─────────────────────────
static TWIM_BUF: StaticCell<[u8; 256]> = StaticCell::new();
let twim_buf = TWIM_BUF.init([0u8; 256]);
let i2c_cfg = twim::Config::default();
let i2c = Twim::new(p.TWISPI0, Irqs, p.P0_06, p.P0_05, i2c_cfg, twim_buf);
let mac = Self::mac_address();
let display = Some(DisplayParts { i2c, mac });
(radio, usb, display)
}
}
+1
View File
@@ -1,5 +1,6 @@
//! OLED display driver with radio status dashboard.
mod render;
pub mod sh1106;
mod task;
pub use task::display_task;
+12 -11
View File
@@ -69,7 +69,10 @@ pub fn dashboard(
match status.state {
RadioState::Receiving => {
// "RX" inverted on row 0
Rectangle::new(Point::new(0, 0), Size::new(MODE_BOX_W as u32, FONT_H as u32))
Rectangle::new(
Point::new(0, 0),
Size::new(MODE_BOX_W as u32, FONT_H as u32),
)
.into_styled(fill)
.draw(target)
.ok();
@@ -217,10 +220,7 @@ pub fn dashboard(
}
/// Render the splash/waiting screen (shown when idle or no config).
pub fn splash(
target: &mut impl DrawTarget<Color = BinaryColor>,
board: &BoardInfo,
) {
pub fn splash(target: &mut impl DrawTarget<Color = BinaryColor>, board: &BoardInfo) {
let bb = target.bounding_box();
let w = bb.size.width as i32;
@@ -324,11 +324,8 @@ fn rssi_sparkline(
i + committed.saturating_sub(effective_bars)
} else {
let start = count - RSSI_HISTORY_LEN;
let skip = RSSI_HISTORY_LEN.saturating_sub(if live {
visible_bars - 1
} else {
visible_bars
});
let skip =
RSSI_HISTORY_LEN.saturating_sub(if live { visible_bars - 1 } else { visible_bars });
(start + skip + i) % RSSI_HISTORY_LEN
};
let is_tx = tx_history[idx];
@@ -361,7 +358,11 @@ fn bar_height(rssi: i16, is_tx: bool, spark_h: i32) -> Option<i32> {
let clamped = rssi.clamp(RSSI_MIN, RSSI_MAX);
((clamped - RSSI_MIN) as i32 * spark_h) / (RSSI_MAX - RSSI_MIN) as i32
};
if h == 0 { None } else { Some(h) }
if h == 0 {
None
} else {
Some(h)
}
}
/// Draw a single sparkline bar (solid for RX, dotted for TX).
+136
View File
@@ -0,0 +1,136 @@
//! Minimal async SH1106 I2C OLED driver (128x64, page addressing).
//!
//! The SH1106 is very similar to the SSD1306 but only supports page-mode
//! addressing (no horizontal/vertical auto-advance). This driver sends
//! framebuffer data page-by-page with explicit page/column commands.
use embedded_graphics::draw_target::DrawTarget;
use embedded_graphics::geometry::{OriginDimensions, Size};
use embedded_graphics::pixelcolor::BinaryColor;
use embedded_graphics::Pixel;
use embedded_hal_async::i2c::I2c;
const WIDTH: usize = 128;
const HEIGHT: usize = 64;
const PAGES: usize = HEIGHT / 8;
const BUF_SIZE: usize = WIDTH * PAGES; // 1024
/// SH1106 has a 132-column RAM; visible 128 columns are centered with a
/// 2-column offset on each side.
const COL_OFFSET: u8 = 2;
pub struct Sh1106<I> {
i2c: I,
addr: u8,
buffer: [u8; BUF_SIZE],
}
impl<I: I2c> Sh1106<I> {
pub fn new(i2c: I, addr: u8) -> Self {
Self {
i2c,
addr,
buffer: [0u8; BUF_SIZE],
}
}
/// Send a sequence of commands (each prefixed with the I2C command byte 0x00).
async fn cmd(&mut self, commands: &[u8]) -> Result<(), I::Error> {
for &c in commands {
self.i2c.write(self.addr, &[0x00, c]).await?;
}
Ok(())
}
/// Initialize the display with sensible defaults.
pub async fn init(&mut self) -> Result<(), I::Error> {
self.cmd(&[
0xAE, // display off
0xD5, 0x80, // clock div: default
0xA8, 0x3F, // multiplex ratio: 64
0xD3, 0x00, // display offset: 0
0x40, // start line: 0
0x8D, 0x14, // charge pump: enable
0xA1, // segment remap: column 127 mapped to SEG0
0xC8, // COM scan direction: remapped
0xDA, 0x12, // COM pin config: alternative, no L/R remap
0x81, 0xCF, // contrast: 207
0xD9, 0xF1, // pre-charge: phase1=1, phase2=15
0xDB, 0x40, // VCOMH deselect: ~0.77×Vcc
0xA4, // entire display ON (follow RAM)
0xA6, // normal display (not inverted)
0xAF, // display on
])
.await
}
/// Flush the entire framebuffer to the display, page by page.
pub async fn flush(&mut self) -> Result<(), I::Error> {
// Each I2C data write is prefixed with 0x40 (data continuation byte).
let mut page_buf = [0u8; 1 + WIDTH]; // 0x40 + 128 data bytes
page_buf[0] = 0x40;
for page in 0..PAGES {
// Set page address and column start (with SH1106 2-column offset).
self.cmd(&[
0xB0 | page as u8, // page address
0x00 | (COL_OFFSET & 0x0F), // column low nibble
0x10 | (COL_OFFSET >> 4), // column high nibble
])
.await?;
let start = page * WIDTH;
page_buf[1..].copy_from_slice(&self.buffer[start..start + WIDTH]);
self.i2c.write(self.addr, &page_buf).await?;
}
Ok(())
}
/// Set display brightness (contrast).
pub async fn set_brightness(&mut self, value: u8) -> Result<(), I::Error> {
self.cmd(&[0x81, value]).await
}
/// Clear the framebuffer (call flush() to update the display).
pub fn clear_buffer(&mut self) {
self.buffer.fill(0);
}
}
impl<I: I2c> DrawTarget for Sh1106<I> {
type Color = BinaryColor;
type Error = core::convert::Infallible;
fn draw_iter<T>(&mut self, pixels: T) -> Result<(), Self::Error>
where
T: IntoIterator<Item = Pixel<Self::Color>>,
{
for Pixel(pos, color) in pixels {
let x = pos.x;
let y = pos.y;
if x >= 0 && x < WIDTH as i32 && y >= 0 && y < HEIGHT as i32 {
let x = x as usize;
let y = y as usize;
let idx = (y / 8) * WIDTH + x;
let bit = (y % 8) as u8;
if color.is_on() {
self.buffer[idx] |= 1 << bit;
} else {
self.buffer[idx] &= !(1 << bit);
}
}
}
Ok(())
}
fn clear(&mut self, color: Self::Color) -> Result<(), Self::Error> {
self.buffer.fill(if color.is_on() { 0xFF } else { 0x00 });
Ok(())
}
}
impl<I: I2c> OriginDimensions for Sh1106<I> {
fn size(&self) -> Size {
Size::new(WIDTH as u32, HEIGHT as u32)
}
}
+69 -68
View File
@@ -1,15 +1,11 @@
use embassy_executor::task;
use embassy_futures::select::{select3, Either3};
use embassy_time::Timer;
use ssd1306::mode::DisplayConfigAsync;
use ssd1306::prelude::DisplayRotation;
use ssd1306::size::DisplaySize128x64;
use ssd1306::{I2CDisplayInterface, Ssd1306Async};
use embedded_graphics::draw_target::DrawTarget;
use embedded_graphics::pixelcolor::BinaryColor;
use crate::board::DisplayParts;
use crate::channel::{
DisplayCommand, DisplayCommandChannel, RadioState, RadioStatus, StatusWatch,
};
use crate::channel::{DisplayCommand, DisplayCommandChannel, RadioState, RadioStatus, StatusWatch};
use crate::board::{Board, LoRaBoard};
@@ -80,6 +76,28 @@ impl DisplayState {
}
}
/// Render the appropriate screen for the current state into the display buffer.
fn render_current(
display: &mut impl DrawTarget<Color = BinaryColor>,
state: &DisplayState,
board: &render::BoardInfo<'_>,
) {
if state.is_active() {
render::dashboard(
display,
&state.last_status,
&state.rssi_history,
&state.tx_history,
state.rssi_count,
state.current_slot_rssi,
state.current_slot_tx,
board,
);
} else {
render::splash(display, board);
}
}
#[task]
pub async fn display_task(
parts: DisplayParts,
@@ -103,14 +121,43 @@ pub async fn display_task(
mac: &mac_str,
};
cfg_if::cfg_if! {
if #[cfg(feature = "wio_tracker_l1")] {
let mut display = super::sh1106::Sh1106::new(parts.i2c, 0x3D);
Timer::after_millis(100).await;
if display.init().await.is_err() {
defmt::error!("SH1106 init failed — retrying");
Timer::after_millis(500).await;
if display.init().await.is_err() {
defmt::error!("SH1106 init failed twice, giving up");
return;
}
}
if display.set_brightness(0xFF).await.is_err() {
defmt::warn!("display brightness set failed");
}
} else {
use ssd1306::mode::DisplayConfigAsync;
use ssd1306::prelude::DisplayRotation;
use ssd1306::size::DisplaySize128x64;
use ssd1306::{I2CDisplayInterface, Ssd1306Async};
let interface = I2CDisplayInterface::new(parts.i2c);
let mut display = Ssd1306Async::new(interface, DisplaySize128x64, DisplayRotation::Rotate0)
.into_buffered_graphics_mode();
Timer::after_millis(100).await;
if display.init().await.is_err() {
defmt::error!("SSD1306 init failed");
defmt::error!("SSD1306 init failed — retrying");
Timer::after_millis(500).await;
if display.init().await.is_err() {
defmt::error!("SSD1306 init failed twice, giving up");
return;
}
}
if display
.set_brightness(ssd1306::prelude::Brightness::BRIGHTEST)
.await
@@ -118,6 +165,8 @@ pub async fn display_task(
{
defmt::warn!("display brightness set failed");
}
}
}
let mut state = DisplayState::new();
let Some(mut receiver) = status.receiver() else {
@@ -126,7 +175,8 @@ pub async fn display_task(
};
// Show splash/waiting screen
render_splash(&mut display, &board_info).await;
render::splash(&mut display, &board_info);
let _ = display.flush().await;
loop {
match select3(
@@ -151,7 +201,8 @@ pub async fn display_task(
state.last_status = radio_status;
if state.display_on {
render_current(&mut display, &state, &board_info).await;
render_current(&mut display, &state, &board_info);
let _ = display.flush().await;
}
}
Either3::Second(cmd) => match cmd {
@@ -167,73 +218,23 @@ pub async fn display_task(
if let Some(s) = receiver.try_get() {
state.last_status = s;
}
render_current(&mut display, &state, &board_info).await;
render_current(&mut display, &state, &board_info);
let _ = display.flush().await;
}
DisplayCommand::Reset => {
state = DisplayState::new();
render_splash(&mut display, &board_info).await;
render::splash(&mut display, &board_info);
let _ = display.flush().await;
}
},
Either3::Third(()) => {
// Timer tick: advance sparkline slot
state.advance_slot();
if state.display_on && state.is_active() {
render_dashboard(&mut display, &state, &board_info).await;
}
}
}
}
}
// The concrete display type is verbose but avoids making this generic
// over both DrawTarget and the SSD1306-specific flush method.
type Display<I> = ssd1306::Ssd1306Async<
I,
DisplaySize128x64,
ssd1306::mode::BufferedGraphicsModeAsync<DisplaySize128x64>,
>;
/// Render the appropriate screen (active dashboard or splash) and flush.
async fn render_current<I>(
display: &mut Display<I>,
state: &DisplayState,
board: &render::BoardInfo<'_>,
) where
I: display_interface::AsyncWriteOnlyDataCommand,
{
if state.is_active() {
render_dashboard(display, state, board).await;
} else {
render_splash(display, board).await;
}
}
/// Render the active dashboard and flush.
async fn render_dashboard<I>(
display: &mut Display<I>,
state: &DisplayState,
board: &render::BoardInfo<'_>,
) where
I: display_interface::AsyncWriteOnlyDataCommand,
{
render::dashboard(
display,
&state.last_status,
&state.rssi_history,
&state.tx_history,
state.rssi_count,
state.current_slot_rssi,
state.current_slot_tx,
board,
);
render_current(&mut display, &state, &board_info);
let _ = display.flush().await;
}
/// Render the splash/waiting screen and flush.
async fn render_splash<I>(display: &mut Display<I>, board: &render::BoardInfo<'_>)
where
I: display_interface::AsyncWriteOnlyDataCommand,
{
render::splash(display, board);
let _ = display.flush().await;
}
}
}
}
+3 -2
View File
@@ -37,7 +37,8 @@ use crate::channel::{CommandChannel, DisplayCommandChannel, ResponseChannel, Sta
#[cfg(not(test))]
cfg_if::cfg_if! {
if #[cfg(feature = "rak_wisblock_4631")] {
if #[cfg(any(feature = "rak_wisblock_4631", feature = "wio_tracker_l1"))] {
use defmt_rtt as _;
use panic_probe as _;
} else if #[cfg(any(feature = "heltec_v3", feature = "heltec_v4"))] {
use esp_backtrace as _;
@@ -56,7 +57,7 @@ static DISPLAY_COMMANDS: DisplayCommandChannel = DisplayCommandChannel::new();
#[cfg(not(test))]
cfg_if::cfg_if! {
if #[cfg(feature = "rak_wisblock_4631")] {
if #[cfg(any(feature = "rak_wisblock_4631", feature = "wio_tracker_l1"))] {
#[embassy_executor::main]
async fn main(spawner: Spawner) {
run(spawner).await;
+61 -15
View File
@@ -17,7 +17,6 @@ pub const TX_POWER_MAX: i8 = i8::MIN; // -128 on the wire
/// Sentinel value for `preamble_len`: use the firmware default (16 symbols).
pub const PREAMBLE_DEFAULT: u16 = 0;
/// LoRa signal bandwidth.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(not(test), derive(defmt::Format))]
@@ -188,7 +187,10 @@ impl Command {
let (config, pos) = if rest[0] == 0 {
(None, 1)
} else if rest[0] == 1 && rest.len() > RADIO_CONFIG_SIZE {
(Some(RadioConfig::from_bytes(&rest[1..])?), 1 + RADIO_CONFIG_SIZE)
(
Some(RadioConfig::from_bytes(&rest[1..])?),
1 + RADIO_CONFIG_SIZE,
)
} else {
return None;
};
@@ -370,7 +372,10 @@ mod tests {
let power_range = (-9, 22);
let base = make_config();
let mut cfg = RadioConfig { freq_hz: 150_000_000, ..base };
let mut cfg = RadioConfig {
freq_hz: 150_000_000,
..base
};
assert!(cfg.validate(power_range).is_ok());
cfg.freq_hz = 960_000_000;
@@ -392,7 +397,9 @@ mod tests {
assert!(RadioConfig { sf, ..base }.validate(power_range).is_ok());
}
assert!(RadioConfig { sf: 4, ..base }.validate(power_range).is_err());
assert!(RadioConfig { sf: 13, ..base }.validate(power_range).is_err());
assert!(RadioConfig { sf: 13, ..base }
.validate(power_range)
.is_err());
}
#[test]
@@ -409,22 +416,34 @@ mod tests {
#[test]
fn validate_tx_power_max_sentinel() {
let cfg = RadioConfig { tx_power_dbm: TX_POWER_MAX, ..make_config() };
let cfg = RadioConfig {
tx_power_dbm: TX_POWER_MAX,
..make_config()
};
assert!(cfg.validate((-9, 22)).is_ok());
}
#[test]
fn validate_tx_power_out_of_range() {
let cfg = RadioConfig { tx_power_dbm: 23, ..make_config() };
let cfg = RadioConfig {
tx_power_dbm: 23,
..make_config()
};
assert!(cfg.validate((-9, 22)).is_err());
let cfg = RadioConfig { tx_power_dbm: -10, ..make_config() };
let cfg = RadioConfig {
tx_power_dbm: -10,
..make_config()
};
assert!(cfg.validate((-9, 22)).is_err());
}
#[test]
fn validate_preamble_default_sentinel() {
let cfg = RadioConfig { preamble_len: PREAMBLE_DEFAULT, ..make_config() };
let cfg = RadioConfig {
preamble_len: PREAMBLE_DEFAULT,
..make_config()
};
assert!(cfg.validate((-9, 22)).is_ok());
}
@@ -433,33 +452,55 @@ mod tests {
let power_range = (-9, 22);
let base = make_config();
assert!(RadioConfig { preamble_len: 6, ..base }.validate(power_range).is_ok());
assert!(RadioConfig { preamble_len: 5, ..base }.validate(power_range).is_err());
assert!(RadioConfig {
preamble_len: 6,
..base
}
.validate(power_range)
.is_ok());
assert!(RadioConfig {
preamble_len: 5,
..base
}
.validate(power_range)
.is_err());
}
// ── RadioConfig::resolve ──────────────────────────────────────
#[test]
fn resolve_power_max_sentinel() {
let cfg = RadioConfig { tx_power_dbm: TX_POWER_MAX, ..make_config() };
let cfg = RadioConfig {
tx_power_dbm: TX_POWER_MAX,
..make_config()
};
assert_eq!(cfg.resolve((-9, 22)).tx_power_dbm, 22);
}
#[test]
fn resolve_power_explicit_unchanged() {
let cfg = RadioConfig { tx_power_dbm: 10, ..make_config() };
let cfg = RadioConfig {
tx_power_dbm: 10,
..make_config()
};
assert_eq!(cfg.resolve((-9, 22)).tx_power_dbm, 10);
}
#[test]
fn resolve_preamble_default() {
let cfg = RadioConfig { preamble_len: PREAMBLE_DEFAULT, ..make_config() };
let cfg = RadioConfig {
preamble_len: PREAMBLE_DEFAULT,
..make_config()
};
assert_eq!(cfg.resolve((-9, 22)).preamble_len, 16);
}
#[test]
fn resolve_preamble_explicit_unchanged() {
let cfg = RadioConfig { preamble_len: 32, ..make_config() };
let cfg = RadioConfig {
preamble_len: 32,
..make_config()
};
assert_eq!(cfg.resolve((-9, 22)).preamble_len, 32);
}
@@ -608,7 +649,12 @@ mod tests {
let mut payload = Vec::new();
let _ = payload.extend_from_slice(b"data");
let mut buf = [0u8; 64];
let n = Response::RxPacket { rssi: -80, snr: 10, payload }.write_to(&mut buf);
let n = Response::RxPacket {
rssi: -80,
snr: 10,
payload,
}
.write_to(&mut buf);
assert_eq!(n, 7 + 4); // tag(1) + rssi(2) + snr(2) + len(2) + "data"(4)
assert_eq!(buf[0], 2);
assert_eq!(i16::from_le_bytes([buf[1], buf[2]]), -80);
+23 -13
View File
@@ -125,9 +125,7 @@ pub async fn radio_task(
warn!("restart RX failed: {}", e);
state.state = RadioState::Idle;
status.sender().send(state.clone());
responses
.send(Response::Error(ErrorCode::RadioBusy))
.await;
responses.send(Response::Error(ErrorCode::RadioBusy)).await;
}
}
Err(e) => {
@@ -135,9 +133,7 @@ pub async fn radio_task(
if start_rx(&mut lora, &cfg).await.is_err() {
state.state = RadioState::Idle;
status.sender().send(state.clone());
responses
.send(Response::Error(ErrorCode::RadioBusy))
.await;
responses.send(Response::Error(ErrorCode::RadioBusy)).await;
}
}
},
@@ -218,7 +214,9 @@ async fn handle_cmd(
}
Command::DisplayOn | Command::DisplayOff | Command::GetMac => {}
Command::Transmit { config, payload } => {
let tx_config = config.map(|c| c.resolve(Board::TX_POWER_RANGE)).or(state.config);
let tx_config = config
.map(|c| c.resolve(Board::TX_POWER_RANGE))
.or(state.config);
if let Some(cfg) = tx_config {
if let Err(reason) = cfg.validate(Board::TX_POWER_RANGE) {
warn!("TX config rejected: {}", reason);
@@ -239,9 +237,7 @@ async fn handle_cmd(
}
Err(e) => {
warn!("TX failed: {}", e);
responses
.send(Response::Error(ErrorCode::TxTimeout))
.await;
responses.send(Response::Error(ErrorCode::TxTimeout)).await;
}
}
@@ -331,7 +327,12 @@ fn modulation_params(
async fn start_rx(lora: &mut Radio, cfg: &RadioConfig) -> Result<(), RadioError> {
let mdltn = modulation_params(lora, cfg)?;
let pkt = lora.create_rx_packet_params(
cfg.preamble_len, IMPLICIT_HEADER, MAX_PAYLOAD as u8, CRC_ON, IQ_INVERTED, &mdltn,
cfg.preamble_len,
IMPLICIT_HEADER,
MAX_PAYLOAD as u8,
CRC_ON,
IQ_INVERTED,
&mdltn,
)?;
lora.prepare_for_rx(RxMode::Continuous, &mdltn, &pkt).await
}
@@ -343,7 +344,12 @@ async fn rx_once(
) -> Result<(u8, lora_phy::mod_params::PacketStatus), RadioError> {
let mdltn = modulation_params(lora, cfg)?;
let pkt = lora.create_rx_packet_params(
cfg.preamble_len, IMPLICIT_HEADER, MAX_PAYLOAD as u8, CRC_ON, IQ_INVERTED, &mdltn,
cfg.preamble_len,
IMPLICIT_HEADER,
MAX_PAYLOAD as u8,
CRC_ON,
IQ_INVERTED,
&mdltn,
)?;
lora.rx(&pkt, buf).await
}
@@ -368,7 +374,11 @@ async fn do_tx(lora: &mut Radio, cfg: &RadioConfig, payload: &[u8]) -> Result<()
}
let mut tx_pkt = lora.create_tx_packet_params(
cfg.preamble_len, IMPLICIT_HEADER, CRC_ON, IQ_INVERTED, &mdltn,
cfg.preamble_len,
IMPLICIT_HEADER,
CRC_ON,
IQ_INVERTED,
&mdltn,
)?;
lora.prepare_for_tx(&mdltn, &mut tx_pkt, cfg.tx_power_dbm as i32, payload)
.await?;
+19 -15
View File
@@ -10,9 +10,7 @@ use embassy_usb::class::cdc_acm::{CdcAcmClass, Receiver, Sender, State};
use embassy_usb::Builder;
use static_cell::StaticCell;
use crate::channel::{
CommandChannel, DisplayCommand, DisplayCommandChannel, ResponseChannel,
};
use crate::channel::{CommandChannel, DisplayCommand, DisplayCommandChannel, ResponseChannel};
use crate::protocol::{Command, ErrorCode, Response};
const MAX_FRAME: usize = 512;
@@ -68,14 +66,7 @@ pub async fn usb_task(
let ctrl_buf = CTRL_BUF.init([0; 64]);
let cdc_state = CDC_STATE.init(State::new());
let mut builder = Builder::new(
parts.driver,
config,
desc_buf,
conf_buf,
bos_buf,
ctrl_buf,
);
let mut builder = Builder::new(parts.driver, config, desc_buf, conf_buf, bos_buf, ctrl_buf);
let class = CdcAcmClass::new(&mut builder, cdc_state, MAX_PACKET as u16);
let mut usb_dev = builder.build();
@@ -84,7 +75,15 @@ pub async fn usb_task(
// ── Run USB device + protocol loop concurrently ────────────────
join(
usb_dev.run(),
protocol_loop(sender, receiver, commands, responses, display_commands, has_display, mac),
protocol_loop(
sender,
receiver,
commands,
responses,
display_commands,
has_display,
mac,
),
)
.await;
}
@@ -141,7 +140,12 @@ async fn protocol_loop<'d, D: embassy_usb_driver::Driver<'d>>(
{
if let Some(cmd) = Command::from_bytes(&decode_buf[..decoded_len]) {
route_command(
cmd, commands, responses, display_commands, has_display, mac,
cmd,
commands,
responses,
display_commands,
has_display,
mac,
)
.await;
}
@@ -160,8 +164,8 @@ async fn protocol_loop<'d, D: embassy_usb_driver::Driver<'d>>(
Either3::Second(response) => {
// Serialize response to fixed-size LE bytes, then COBS encode
let raw_len = response.write_to(&mut write_buf);
let encoded_len = ucobs::encode(&write_buf[..raw_len], &mut cobs_encode_buf)
.unwrap_or(0);
let encoded_len =
ucobs::encode(&write_buf[..raw_len], &mut cobs_encode_buf).unwrap_or(0);
// Append 0x00 sentinel
if encoded_len < cobs_encode_buf.len() {
cobs_encode_buf[encoded_len] = 0x00;
+4 -8
View File
@@ -50,16 +50,14 @@ async fn main() -> anyhow::Result<()> {
// Resolve serial port
let port = match args.port {
Some(p) => p,
None => {
tokio::task::spawn_blocking(|| {
None => tokio::task::spawn_blocking(|| {
donglora_client::find_port().unwrap_or_else(|| {
info!("waiting for DongLoRa device...");
donglora_client::wait_for_device()
})
})
.await
.map_err(|e| anyhow::anyhow!("device discovery task failed: {e}"))?
}
.map_err(|e| anyhow::anyhow!("device discovery task failed: {e}"))?,
};
// Resolve socket path
@@ -102,11 +100,9 @@ fn parse_tcp_addr(tcp: &str) -> Option<(String, u16)> {
}
async fn signal_handler(shutdown: CancellationToken) -> anyhow::Result<()> {
let mut sigint =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())
let mut sigint = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())
.map_err(|e| anyhow::anyhow!("failed to register SIGINT handler: {e}"))?;
let mut sigterm =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.map_err(|e| anyhow::anyhow!("failed to register SIGTERM handler: {e}"))?;
tokio::select! {