diff --git a/clients/rust/CLAUDE_TODO.md b/clients/rust/CLAUDE_TODO.md new file mode 100644 index 00000000..7fa5ebc3 --- /dev/null +++ b/clients/rust/CLAUDE_TODO.md @@ -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> { + // 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, 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. diff --git a/firmware/.cargo/config.toml b/firmware/.cargo/config.toml index 0ed53177..67b24524 100644 --- a/firmware/.cargo/config.toml +++ b/firmware/.cargo/config.toml @@ -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", +] diff --git a/firmware/Cargo.lock b/firmware/Cargo.lock index 2b818bdb..ddb4ae30 100644 --- a/firmware/Cargo.lock +++ b/firmware/Cargo.lock @@ -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", diff --git a/firmware/Cargo.toml b/firmware/Cargo.toml index c282f688..557ecfe3 100644 --- a/firmware/Cargo.toml +++ b/firmware/Cargo.toml @@ -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", diff --git a/firmware/build.rs b/firmware/build.rs index 87bdc4c8..3d94ff8f 100644 --- a/firmware/build.rs +++ b/firmware/build.rs @@ -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"); } diff --git a/firmware/justfile b/firmware/justfile index 3f2e1b37..24e3d855 100644 --- a/firmware/justfile +++ b/firmware/justfile @@ -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 diff --git a/firmware/ld/nrf52840-memory.x b/firmware/ld/nrf52840-memory.x new file mode 100644 index 00000000..8de03147 --- /dev/null +++ b/firmware/ld/nrf52840-memory.x @@ -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 +} diff --git a/firmware/mise.toml b/firmware/mise.toml index 43fd002e..2e61d348 100644 --- a/firmware/mise.toml +++ b/firmware/mise.toml @@ -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" diff --git a/firmware/src/board/heltec_v3.rs b/firmware/src/board/heltec_v3.rs index 5a55fb36..031bd3bc 100644 --- a/firmware/src/board/heltec_v3.rs +++ b/firmware/src/board/heltec_v3.rs @@ -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); diff --git a/firmware/src/board/heltec_v4.rs b/firmware/src/board/heltec_v4.rs index 95c1b6b5..8e9c1150 100644 --- a/firmware/src/board/heltec_v4.rs +++ b/firmware/src/board/heltec_v4.rs @@ -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. diff --git a/firmware/src/board/rak_wisblock_4631.rs b/firmware/src/board/rak_wisblock_4631.rs index eb8cab55..d0671f5b 100644 --- a/firmware/src/board/rak_wisblock_4631.rs +++ b/firmware/src/board/rak_wisblock_4631.rs @@ -87,7 +87,6 @@ impl super::traits::LoRaBoard for Board { } impl Board { - pub fn into_parts(self) -> (RadioParts, UsbParts, Option) { 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 = 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), }; diff --git a/firmware/src/board/wio_tracker_l1.rs b/firmware/src/board/wio_tracker_l1.rs new file mode 100644 index 00000000..3add31a0 --- /dev/null +++ b/firmware/src/board/wio_tracker_l1.rs @@ -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; + SPIM3 => embassy_nrf::spim::InterruptHandler; + TWISPI0 => embassy_nrf::twim::InterruptHandler; +}); + +// ── Concrete peripheral types ──────────────────────────────────────── + +type SpiBus = Spim<'static>; +type Nss = Output<'static>; +type Iv = GenericSx126xInterfaceVariant, Input<'static>>; +type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, SpiBus, Nss>; +/// SX126x driver (pre-LoRa construction — LoRa::new is async). +pub type RadioDriver = Sx126x; + +/// 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) { + 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> = + 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 = + 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) + } +} diff --git a/firmware/src/display/mod.rs b/firmware/src/display/mod.rs index 50b4bdf1..bb78536d 100644 --- a/firmware/src/display/mod.rs +++ b/firmware/src/display/mod.rs @@ -1,5 +1,6 @@ //! OLED display driver with radio status dashboard. mod render; +pub mod sh1106; mod task; pub use task::display_task; diff --git a/firmware/src/display/render.rs b/firmware/src/display/render.rs index 034e0275..2c3145d2 100644 --- a/firmware/src/display/render.rs +++ b/firmware/src/display/render.rs @@ -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, - board: &BoardInfo, -) { +pub fn splash(target: &mut impl DrawTarget, 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 { 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). diff --git a/firmware/src/display/sh1106.rs b/firmware/src/display/sh1106.rs new file mode 100644 index 00000000..7216e8d8 --- /dev/null +++ b/firmware/src/display/sh1106.rs @@ -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 { + i2c: I, + addr: u8, + buffer: [u8; BUF_SIZE], +} + +impl Sh1106 { + 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 DrawTarget for Sh1106 { + type Color = BinaryColor; + type Error = core::convert::Infallible; + + fn draw_iter(&mut self, pixels: T) -> Result<(), Self::Error> + where + T: IntoIterator>, + { + 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 OriginDimensions for Sh1106 { + fn size(&self) -> Size { + Size::new(WIDTH as u32, HEIGHT as u32) + } +} diff --git a/firmware/src/display/task.rs b/firmware/src/display/task.rs index 2abfc32f..5b28e253 100644 --- a/firmware/src/display/task.rs +++ b/firmware/src/display/task.rs @@ -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, + 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 = ssd1306::Ssd1306Async< - I, - DisplaySize128x64, - ssd1306::mode::BufferedGraphicsModeAsync, ->; - -/// Render the appropriate screen (active dashboard or splash) and flush. -async fn render_current( - display: &mut Display, - 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( - display: &mut Display, - 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(display: &mut Display, board: &render::BoardInfo<'_>) -where - I: display_interface::AsyncWriteOnlyDataCommand, -{ - render::splash(display, board); - let _ = display.flush().await; -} diff --git a/firmware/src/main.rs b/firmware/src/main.rs index 86163ee4..25c1c37e 100644 --- a/firmware/src/main.rs +++ b/firmware/src/main.rs @@ -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; diff --git a/firmware/src/protocol.rs b/firmware/src/protocol.rs index f62c9eb5..6f05bef4 100644 --- a/firmware/src/protocol.rs +++ b/firmware/src/protocol.rs @@ -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); diff --git a/firmware/src/radio/task.rs b/firmware/src/radio/task.rs index ed2b8f9d..99d09e3b 100644 --- a/firmware/src/radio/task.rs +++ b/firmware/src/radio/task.rs @@ -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?; diff --git a/firmware/src/usb/task.rs b/firmware/src/usb/task.rs index e005581a..246be4f5 100644 --- a/firmware/src/usb/task.rs +++ b/firmware/src/usb/task.rs @@ -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; diff --git a/mux/rust/src/main.rs b/mux/rust/src/main.rs index 5f7562f0..dfcf0e59 100644 --- a/mux/rust/src/main.rs +++ b/mux/rust/src/main.rs @@ -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"),