WIP: functional checkpoint!
This commit is contained in:
21 files changed
+698
-166
No files matched your search
@@ -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.
|
||||
@@ -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",
|
||||
]
|
||||
Generated
+13
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
//! OLED display driver with radio status dashboard.
|
||||
|
||||
mod render;
|
||||
pub mod sh1106;
|
||||
mod task;
|
||||
pub use task::display_task;
|
||||
@@ -69,10 +69,13 @@ 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))
|
||||
.into_styled(fill)
|
||||
.draw(target)
|
||||
.ok();
|
||||
Rectangle::new(
|
||||
Point::new(0, 0),
|
||||
Size::new(MODE_BOX_W as u32, FONT_H as u32),
|
||||
)
|
||||
.into_styled(fill)
|
||||
.draw(target)
|
||||
.ok();
|
||||
Text::new("RX", Point::new(1, FONT_H - 1), inv_style)
|
||||
.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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,20 +121,51 @@ pub async fn display_task(
|
||||
mac: &mac_str,
|
||||
};
|
||||
|
||||
let interface = I2CDisplayInterface::new(parts.i2c);
|
||||
let mut display = Ssd1306Async::new(interface, DisplaySize128x64, DisplayRotation::Rotate0)
|
||||
.into_buffered_graphics_mode();
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "wio_tracker_l1")] {
|
||||
let mut display = super::sh1106::Sh1106::new(parts.i2c, 0x3D);
|
||||
|
||||
if display.init().await.is_err() {
|
||||
defmt::error!("SSD1306 init failed");
|
||||
return;
|
||||
}
|
||||
if display
|
||||
.set_brightness(ssd1306::prelude::Brightness::BRIGHTEST)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
defmt::warn!("display brightness set failed");
|
||||
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 — 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
|
||||
.is_err()
|
||||
{
|
||||
defmt::warn!("display brightness set failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut state = DisplayState::new();
|
||||
@@ -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;
|
||||
render_current(&mut display, &state, &board_info);
|
||||
let _ = display.flush().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,
|
||||
);
|
||||
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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
+11
-15
@@ -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(|| {
|
||||
donglora_client::find_port().unwrap_or_else(|| {
|
||||
info!("waiting for DongLoRa device...");
|
||||
donglora_client::wait_for_device()
|
||||
})
|
||||
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}"))?
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("device discovery task failed: {e}"))?,
|
||||
};
|
||||
|
||||
// Resolve socket path
|
||||
@@ -102,12 +100,10 @@ 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())
|
||||
.map_err(|e| anyhow::anyhow!("failed to register SIGINT handler: {e}"))?;
|
||||
let mut sigterm =
|
||||
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||
.map_err(|e| anyhow::anyhow!("failed to register SIGTERM handler: {e}"))?;
|
||||
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())
|
||||
.map_err(|e| anyhow::anyhow!("failed to register SIGTERM handler: {e}"))?;
|
||||
|
||||
tokio::select! {
|
||||
_ = sigint.recv() => info!("received SIGINT"),
|
||||
|
||||
Reference in new issue
Block a user