Adds nRF52840 + SX1262 + 1.14" 240×135 ST7789 color TFT support. First color display board in the project; introduces a BoardDisplay trait so each board renders to its native pixel format and resolution without aliasing or color-quantization round-trips. Architecture: - New BoardDisplay trait (src/display.rs) bundles render_*, present(), set_bright/set_dim. Replaces the old DrawTarget<Color=BinaryColor> + DisplayBrightness combo as the LoRaBoard::DisplayDriver bound. - Mono OLED drivers (Sh1106, Ssd1306Async) get adapter impls that delegate to the existing display::render module — no behavior change for any existing board. - T114 ST7789 driver (src/driver/st7789.rs) wraps lcd-async (async fork of mipidsi) with a 64,800-byte raw byte framebuffer in .bss. Renders via lcd_async::raw_framebuf::RawFrameBuf<Rgb565, _>, presents via a single async DMA blit (panel.show_raw_data(...).await) — the await yields the executor for the ~65 ms SPI transfer so the radio task isn't starved during repaints. - PWM backlight on PWM0 / P0.15 (active-LOW, inverted polarity) for smooth dim/bright transitions instead of GPIO toggle. - Native 240×135 Rgb565 renderer (src/display/render_color.rs) with splash + dashboard + sparkline. - Shared QR-code blit (src/display/qr.rs), generic over PixelColor, re-centers the 64×64 bitmap which has cells in cols 0..50 and ~14 px of right-side padding — both mono and color splash now show the QR with 7 px quiet zones on all four sides (was visibly left-shifted). - Shared content types (BoardInfo, DashboardCtx) decouple what to draw from how each board renders it. - create_display() now takes the board's full DisplayParts bundle (was just the I2C bus) so non-I2C displays can carry SPI + control pins through. Hardware: - VEXT_ENABLE on P0.21 driven HIGH at boot — powers the SX1262 + TFT rail. Without this, lora-phy init succeeds at the SPI level but TX_DONE never fires. - VTFT_CTRL on P0.03, active LOW; backlight on P0.15, active LOW. - TFT SPI2 (SCK P1.08 / MOSI P1.09) at 8 MHz over EasyDMA. Async ExclusiveDevice from embedded-hal-bus (with the 'async' feature). - lcd-async builder at display_size(135, 240), display_offset(52, 40), Deg90 rotation, ColorInversion::Inverted — standard 1.14" ST7789 die mapping. - Linker: shared ld/nrf52840-memory.x (S140 SoftDevice preserved at 0x01000-0x26000 per project policy).
208 lines
6.9 KiB
Rust
208 lines
6.9 KiB
Rust
//! DongLoRa firmware — transparent LoRa radio over USB.
|
|
//!
|
|
//! Three async tasks communicate via static channels:
|
|
//!
|
|
//! ```text
|
|
//! host_task ──Command──► radio_task ──► SX1262
|
|
//! ◄──Response── │
|
|
//! StatusWatch
|
|
//! ▼
|
|
//! display_task (optional)
|
|
//! ```
|
|
//!
|
|
//! The host drives everything. The radio idles until commanded.
|
|
|
|
#![cfg_attr(not(test), no_std)]
|
|
#![cfg_attr(not(test), no_main)]
|
|
|
|
#[cfg(not(test))]
|
|
mod board;
|
|
#[cfg(not(test))]
|
|
mod channel;
|
|
#[cfg(all(not(test), feature = "debug-checkpoint"))]
|
|
mod debug_blink;
|
|
#[cfg(not(test))]
|
|
mod display;
|
|
#[cfg(not(test))]
|
|
mod driver;
|
|
#[cfg(not(test))]
|
|
mod hal;
|
|
#[cfg(not(test))]
|
|
mod host;
|
|
mod lbt;
|
|
mod protocol;
|
|
#[cfg(not(test))]
|
|
mod radio;
|
|
|
|
#[cfg(not(test))]
|
|
use embassy_executor::Spawner;
|
|
|
|
#[cfg(not(test))]
|
|
use crate::board::LoRaBoard;
|
|
#[cfg(not(test))]
|
|
use crate::channel::{CommandChannel, DisplayCommandChannel, OutboundChannel, RadioEventChannel};
|
|
#[cfg(not(test))]
|
|
use crate::protocol::{cap, Info, MAX_MCU_UID_LEN, MAX_RADIO_UID_LEN};
|
|
|
|
#[cfg(not(test))]
|
|
cfg_if::cfg_if! {
|
|
if #[cfg(any(feature = "heltec_mesh_node_t114", feature = "rak_wisblock_4631", feature = "wio_tracker_l1", feature = "waveshare_rp2040_lora"))] {
|
|
use defmt_rtt as _;
|
|
use panic_probe as _;
|
|
} else if #[cfg(any(feature = "heltec_v3", feature = "heltec_v3_uart", feature = "heltec_v4", feature = "elecrow_thinknode_m2", feature = "lilygo_tbeam_supreme", feature = "lilygo_tbeam"))] {
|
|
use esp_backtrace as _;
|
|
use esp_println as _;
|
|
// Emits the IDF app descriptor into `.flash.appdesc`. espflash v4
|
|
// refuses any ESP image that lacks it.
|
|
esp_bootloader_esp_idf::esp_app_desc!();
|
|
}
|
|
}
|
|
|
|
#[cfg(not(test))]
|
|
static COMMANDS: CommandChannel = CommandChannel::new();
|
|
#[cfg(not(test))]
|
|
static OUTBOUND: OutboundChannel = OutboundChannel::new();
|
|
#[cfg(not(test))]
|
|
static RADIO_EVENTS: RadioEventChannel = RadioEventChannel::new();
|
|
#[cfg(not(test))]
|
|
static DISPLAY_COMMANDS: DisplayCommandChannel = DisplayCommandChannel::new();
|
|
#[cfg(not(test))]
|
|
static mut INFO: Option<Info> = None;
|
|
|
|
#[cfg(not(test))]
|
|
cfg_if::cfg_if! {
|
|
if #[cfg(any(feature = "heltec_mesh_node_t114", feature = "rak_wisblock_4631", feature = "wio_tracker_l1", feature = "waveshare_rp2040_lora"))] {
|
|
#[embassy_executor::main]
|
|
async fn main(spawner: Spawner) {
|
|
run(spawner).await;
|
|
}
|
|
} else if #[cfg(any(feature = "heltec_v3", feature = "heltec_v3_uart", feature = "heltec_v4", feature = "elecrow_thinknode_m2", feature = "lilygo_tbeam_supreme", feature = "lilygo_tbeam"))] {
|
|
#[esp_rtos::main]
|
|
async fn main(spawner: Spawner) {
|
|
run(spawner).await;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(not(test))]
|
|
async fn run(spawner: Spawner) {
|
|
let board = <board::Board as LoRaBoard>::init();
|
|
|
|
// Read + log + clear POWER.RESETREAS before any other boot output so
|
|
// the cause of the *previous* reset (if any) lands at the very top
|
|
// of the RTT log. Critical for soak-debugging silent crash-reboots:
|
|
// a self-rebooting firmware otherwise looks like the log just
|
|
// restarted with no explanation. nRF52840-only.
|
|
#[cfg(any(feature = "heltec_mesh_node_t114", feature = "rak_wisblock_4631", feature = "wio_tracker_l1"))]
|
|
crate::hal::nrf52840::dump_reset_reason();
|
|
|
|
let parts = board.into_parts();
|
|
|
|
// Build the GET_INFO struct once at boot. This is the authoritative
|
|
// snapshot of device capabilities reported over the wire.
|
|
let info = build_info(parts.mac);
|
|
// SAFETY: touched only here at boot, before any task is spawned.
|
|
let info_ref: &'static Info = unsafe {
|
|
INFO = Some(info);
|
|
#[allow(static_mut_refs)]
|
|
INFO.as_ref().expect("INFO just initialized")
|
|
};
|
|
|
|
spawner.spawn(
|
|
radio::radio_task(parts.radio, &COMMANDS, &OUTBOUND, &RADIO_EVENTS, info_ref)
|
|
.expect("spawn radio_task"),
|
|
);
|
|
|
|
spawner.spawn(
|
|
host::host_task(
|
|
parts.host,
|
|
&COMMANDS,
|
|
&OUTBOUND,
|
|
&DISPLAY_COMMANDS,
|
|
parts.display.is_some(),
|
|
parts.mac,
|
|
)
|
|
.expect("spawn host_task"),
|
|
);
|
|
|
|
// Suppress "assigned but never read" warning on non-USB/UART path.
|
|
let _ = info_ref;
|
|
|
|
if let Some(dp) = parts.display {
|
|
#[cfg(feature = "debug-checkpoint")]
|
|
{
|
|
// LED is taken over by the checkpoint blinker; the display
|
|
// init struct is dropped unused for this debug build.
|
|
let _ = dp;
|
|
spawner
|
|
.spawn(debug_blink::debug_blink_task(parts.led).expect("spawn debug_blink_task"));
|
|
}
|
|
#[cfg(not(feature = "debug-checkpoint"))]
|
|
{
|
|
#[allow(clippy::unit_arg)] // LedDriver is () for boards without LEDs
|
|
spawner.spawn(
|
|
display::display_task(dp, parts.led, &RADIO_EVENTS, &DISPLAY_COMMANDS)
|
|
.expect("spawn display_task"),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(not(test))]
|
|
fn build_info(mac: [u8; 6]) -> Info {
|
|
let (fmin, fmax) = <board::Board as LoRaBoard>::freq_range_hz();
|
|
let chip_id = <board::Board as LoRaBoard>::radio_chip_id();
|
|
let (tx_min, tx_max) = board::Board::TX_POWER_RANGE;
|
|
let supported_sf = <board::Board as LoRaBoard>::supported_sf_bitmap();
|
|
let supported_bw = <board::Board as LoRaBoard>::supported_bw_bitmap();
|
|
|
|
let mut mcu_uid = [0u8; MAX_MCU_UID_LEN];
|
|
mcu_uid[..6].copy_from_slice(&mac);
|
|
|
|
Info {
|
|
proto_major: 1,
|
|
proto_minor: 0,
|
|
fw_major: parse_u8_or(env!("CARGO_PKG_VERSION_MAJOR"), 0),
|
|
fw_minor: parse_u8_or(env!("CARGO_PKG_VERSION_MINOR"), 0),
|
|
fw_patch: parse_u8_or(env!("CARGO_PKG_VERSION_PATCH"), 0),
|
|
radio_chip_id: chip_id,
|
|
capability_bitmap: cap::LORA | cap::CAD_BEFORE_TX,
|
|
supported_sf_bitmap: supported_sf,
|
|
supported_bw_bitmap: supported_bw,
|
|
max_payload_bytes: crate::protocol::MAX_OTA_PAYLOAD as u16,
|
|
// Real channel depths from channel.rs.
|
|
rx_queue_capacity: 32,
|
|
tx_queue_capacity: 1,
|
|
freq_min_hz: fmin,
|
|
freq_max_hz: fmax,
|
|
tx_power_min_dbm: tx_min,
|
|
tx_power_max_dbm: tx_max,
|
|
mcu_uid_len: 6,
|
|
mcu_uid,
|
|
radio_uid_len: 0,
|
|
radio_uid: [0u8; MAX_RADIO_UID_LEN],
|
|
}
|
|
}
|
|
|
|
#[cfg(not(test))]
|
|
const fn parse_u8_or(s: &str, default: u8) -> u8 {
|
|
// CARGO_PKG_VERSION_* strings are short decimal ascii. Parse them at
|
|
// compile time via a tiny constant function instead of pulling in a
|
|
// dependency.
|
|
let bytes = s.as_bytes();
|
|
let mut i = 0;
|
|
let mut out: u32 = 0;
|
|
while i < bytes.len() {
|
|
let b = bytes[i];
|
|
if b < b'0' || b > b'9' {
|
|
return default;
|
|
}
|
|
out = out * 10 + (b - b'0') as u32;
|
|
if out > u8::MAX as u32 {
|
|
return default;
|
|
}
|
|
i += 1;
|
|
}
|
|
out as u8
|
|
}
|