From 96d9effe2bff7f6728fa7cc6347d1fdd121f7d5e Mon Sep 17 00:00:00 2001 From: Moritz Riede Date: Wed, 18 Feb 2026 01:15:36 +0100 Subject: [PATCH 1/4] feat: add aux8 led flash amongst other things --- .claude/settings.local.json | 5 +- CHANGELOG.md | 6 + Cargo.lock | 27 ++ Cargo.toml | 5 +- src/bin/main.rs | 475 +++++++++++++++++++++++++++++++++++- src/ble.rs | 390 +++++++++++++++++++++++++++++ src/lib.rs | 1 + src/msp.rs | 23 ++ src/state.rs | 16 ++ 9 files changed, 943 insertions(+), 5 deletions(-) create mode 100644 src/ble.rs diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 6de1c71..a4a025e 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -10,7 +10,10 @@ "Bash(git -C /Users/moritz/Documents/Projects/FPV/VX3.5_XIAO_LED_Controller/xiao_drone_led_controller log --oneline -3)", "WebFetch(domain:docs.rs)", "Bash(cargo tree:*)", - "Bash(cargo test:*)" + "Bash(cargo test:*)", + "Bash(cargo doc:*)", + "WebFetch(domain:raw.githubusercontent.com)", + "Bash(curl:*)" ] } } diff --git a/CHANGELOG.md b/CHANGELOG.md index 30bda77..178c47c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Conventional Commits](https://www.conventionalcommi ### Added +- BLE Nordic UART Service (NUS) for app control via JSON protocol +- `ble` module (`src/ble.rs`): Command/Response types with serde, command handler, state snapshot builder, JSON serialization helpers — all `no_std`, no heap, unit-testable +- `ble_task`: async task advertising as "AirLED", serving NUS GATT service with chunked notifications +- Newline-delimited JSON protocol over BLE: externally-tagged command enums (`{"GetState":null}`, `{"SetBrightness":{"value":128}}`), flat `StateResponse` struct, plain `"ok\n"` / `"err:reason\n"` acks +- Auto-push state on BLE connect and on MSP flight mode change via `STATE_CHANGED` signal +- Chunked BLE notifications (20-byte MTU) for state responses (~250 bytes) - MSP flight controller integration over UART1 (GPIO20 RX, GPIO21 TX, 115200 baud) - `msp` module (`src/msp.rs`): MSPv1 frame builder, response parser state machine, BOXNAMES decoder, flight mode resolver — all `no_std`, no heap, fully unit-testable - `msp_task`: async task polling MSP_STATUS at ~10 Hz, with BOXNAMES discovery at startup diff --git a/Cargo.lock b/Cargo.lock index 452fa0f..3d83215 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1056,6 +1056,17 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + [[package]] name = "futures-sink" version = "0.3.31" @@ -1075,6 +1086,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-core", + "futures-macro", "futures-sink", "futures-task", "pin-project-lite", @@ -1120,6 +1132,7 @@ checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" dependencies = [ "defmt 0.3.100", "hash32", + "serde", "stable_deref_trait", ] @@ -1604,6 +1617,17 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-json-core" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b81787e655bd59cecadc91f7b6b8651330b2be6c33246039a65e5cd6f4e0828" +dependencies = [ + "heapless 0.8.0", + "ryu", + "serde", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -1985,9 +2009,12 @@ dependencies = [ "esp-hal", "esp-radio", "esp-rtos", + "futures", "heapless 0.8.0", "panic-rtt-target", "rtt-target", + "serde", + "serde-json-core", "smart-leds", "smoltcp", "static_cell", diff --git a/Cargo.toml b/Cargo.toml index c76ba61..56b6bc4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,7 +62,10 @@ esp-radio = { version = "0.17.0", features = [ "wifi", "unstable", ] } -heapless = { version = "0.8.0", default-features = false } +heapless = { version = "0.8.0", default-features = false, features = ["serde"] } +serde = { version = "1.0", default-features = false, features = ["derive"] } +futures = { version = "0.3", default-features = false, features = ["async-await"] } +serde-json-core = { version = "0.6", features = ["heapless"] } static_cell = { version = "2.1.0", features = ["nightly"] } smart-leds = "0.4.0" ws2812-spi = "0.5.1" diff --git a/src/bin/main.rs b/src/bin/main.rs index 265222f..95c4c13 100644 --- a/src/bin/main.rs +++ b/src/bin/main.rs @@ -19,6 +19,14 @@ use esp_hal::spi::Mode; use esp_hal::time::Rate; use esp_hal::timer::timg::TimerGroup; use esp_hal::uart::{Config as UartConfig, Uart}; +use bleps::ad_structure::{ + create_advertising_data, AdStructure, BR_EDR_NOT_SUPPORTED, LE_GENERAL_DISCOVERABLE, +}; +use bleps::async_attribute_server::AttributeServer; +use bleps::asynch::Ble; +use bleps::attribute_server::NotificationData; +use bleps::gatt; +use esp_radio::ble::controller::BleConnector; use esp_radio::wifi::{ AccessPointConfig, ModeConfig, WifiApState, ap_state, }; @@ -32,8 +40,11 @@ use xiao_drone_led_controller::pattern::{ Animation, ColorScheme, Pulse, RippleEffect, StaticAnim, }; use xiao_drone_led_controller::postfx::{PostEffect, apply_pipeline}; +use xiao_drone_led_controller::ble::{ + self as ble_proto, HandleResult, +}; use xiao_drone_led_controller::state::{ - AnimMode, AnimModeParams, ColorMode, FlightMode, STATE, + AnimMode, AnimModeParams, BLE_FLASH, ColorMode, FlightMode, STATE, STATE_CHANGED, }; use static_cell::StaticCell; @@ -67,7 +78,7 @@ fn main() -> ! { let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max()); let peripherals = esp_hal::init(config); - esp_alloc::heap_allocator!(size: 72 * 1024); + esp_alloc::heap_allocator!(size: 96 * 1024); info!("Initializing..."); @@ -127,6 +138,16 @@ fn main() -> ! { 0, // random seed — no true randomness needed for AP ); + // --- BLE setup --- + info!("Setting up BLE..."); + let ble_connector = BleConnector::new( + radio_controller, + peripherals.BT, + esp_radio::ble::Config::default(), + ) + .expect("BLE init failed"); + info!("BLE ready"); + // --- MSP UART setup --- info!("Setting up MSP UART..."); let msp_uart = Uart::new(peripherals.UART0, UartConfig::default()) @@ -142,6 +163,7 @@ fn main() -> ! { executor.run(move |spawner| { spawner.must_spawn(led_task(spi_bus)); spawner.must_spawn(msp_task(msp_uart)); + spawner.must_spawn(ble_task(ble_connector)); spawner.must_spawn(net_task(runner)); spawner.must_spawn(web_server(stack)); spawner.must_spawn(dhcp_server(stack)); @@ -165,6 +187,297 @@ async fn wifi_keepalive(wifi_controller: esp_radio::wifi::WifiController<'static } } +/// BLE notification chunk size (BLE default MTU payload). +const BLE_CHUNK_SIZE: usize = 20; + + +/// Shared BLE RX reassembly buffer (written by write callback, read by notifier). +struct BleRxBuf { + data: [u8; 256], + len: usize, +} + +/// Shared BLE TX response buffer (written by write callback/notifier, sent by notifier). +struct BleTxBuf { + data: [u8; 512], + len: usize, + offset: usize, +} + +static BLE_RX: critical_section::Mutex> = + critical_section::Mutex::new(core::cell::RefCell::new(BleRxBuf { + data: [0; 256], + len: 0, + })); + +static BLE_TX: critical_section::Mutex> = + critical_section::Mutex::new(core::cell::RefCell::new(BleTxBuf { + data: [0; 512], + len: 0, + offset: 0, + })); + +/// Signal to wake the BLE notifier when there is data to send. +static BLE_NOTIFY: embassy_sync::signal::Signal< + embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex, + (), +> = embassy_sync::signal::Signal::new(); + +/// Process a complete command message from the RX buffer. +/// +/// Called from the sync write callback. Writes the response into BLE_TX +/// and signals the notifier. +fn ble_handle_message(msg: &[u8]) { + let Some(cmd) = ble_proto::parse_command(msg) else { + // Write error response + critical_section::with(|cs| { + let mut tx = BLE_TX.borrow_ref_mut(cs); + let err = b"err:parse\n"; + tx.data[..err.len()].copy_from_slice(err); + tx.len = err.len(); + tx.offset = 0; + }); + BLE_NOTIFY.signal(()); + return; + }; + + // Try to lock state synchronously (should almost always succeed) + if let Ok(mut state) = STATE.try_lock() { + let result = ble_proto::handle_command(&cmd, &mut state); + critical_section::with(|cs| { + let mut tx = BLE_TX.borrow_ref_mut(cs); + tx.offset = 0; + match result { + HandleResult::SendState => { + let resp = ble_proto::build_state_response(&state); + tx.len = ble_proto::serialize_state(&resp, &mut tx.data).unwrap_or(0); + } + HandleResult::Ack => { + tx.data[..3].copy_from_slice(b"ok\n"); + tx.len = 3; + } + HandleResult::Error(e) => { + let eb = e.as_bytes(); + let len = eb.len().min(tx.data.len()); + tx.data[..len].copy_from_slice(&eb[..len]); + tx.len = len; + } + } + }); + BLE_NOTIFY.signal(()); + } +} + +/// BLE Nordic UART Service task. +/// +/// Advertises as "AirLED", accepts connections, and serves the NUS GATT service. +/// Commands arrive as JSON on RX; responses go out as notifications on TX. +#[embassy_executor::task] +async fn ble_task(mut connector: BleConnector<'static>) { + info!("BLE task started"); + + let current_millis = || embassy_time::Instant::now().as_millis(); + let mut ble = Ble::new(&mut connector, current_millis); + + loop { + // Reset buffers between connections + critical_section::with(|cs| { + let mut rx = BLE_RX.borrow_ref_mut(cs); + rx.len = 0; + let mut tx = BLE_TX.borrow_ref_mut(cs); + tx.len = 0; + tx.offset = 0; + }); + + // Initialize BLE stack + if let Err(e) = ble.init().await { + defmt::warn!("BLE init error: {}", defmt::Debug2Format(&e)); + Timer::after(Duration::from_secs(1)).await; + continue; + } + + // Log our BLE MAC address (once) + match ble.cmd_read_br_addr().await { + Ok(addr) => info!( + "BLE MAC: {:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", + addr[5], addr[4], addr[3], addr[2], addr[1], addr[0] + ), + Err(e) => defmt::warn!("BLE read addr error: {}", defmt::Debug2Format(&e)), + } + + // Set advertising data + let adv_data = create_advertising_data(&[ + AdStructure::Flags(LE_GENERAL_DISCOVERABLE | BR_EDR_NOT_SUPPORTED), + AdStructure::CompleteLocalName(WIFI_SSID), + ]); + match adv_data { + Ok(data) => { + if let Err(e) = ble.cmd_set_le_advertising_data(data).await { + defmt::warn!("BLE adv data error: {}", defmt::Debug2Format(&e)); + continue; + } + } + Err(e) => { + defmt::warn!("BLE adv build error: {}", defmt::Debug2Format(&e)); + continue; + } + } + + if let Err(e) = ble.cmd_set_le_advertising_parameters().await { + defmt::warn!("BLE adv params error: {}", defmt::Debug2Format(&e)); + continue; + } + + if let Err(e) = ble.cmd_set_le_advertise_enable(true).await { + defmt::warn!("BLE adv enable error: {}", defmt::Debug2Format(&e)); + continue; + } + + info!("BLE advertising as \"{}\"", WIFI_SSID); + + // Track whether we've seen a real client (first RX write = client connected) + static BLE_CONNECTED: critical_section::Mutex> = + critical_section::Mutex::new(core::cell::Cell::new(false)); + critical_section::with(|cs| BLE_CONNECTED.borrow(cs).set(false)); + + // Write callback for NUS RX characteristic (sync — runs inside do_work) + let mut rx_wf = |_offset: usize, data: &[u8]| { + // Flash blue on first write (= real client connection confirmed) + critical_section::with(|cs| { + if !BLE_CONNECTED.borrow(cs).get() { + BLE_CONNECTED.borrow(cs).set(true); + BLE_FLASH.signal(1); + defmt::info!("BLE client connected"); + } + }); + + critical_section::with(|cs| { + let mut rx = BLE_RX.borrow_ref_mut(cs); + let space = rx.data.len() - rx.len; + let n = data.len().min(space); + let start = rx.len; + rx.data[start..start + n].copy_from_slice(&data[..n]); + rx.len = start + n; + }); + + // Check for complete message (newline-delimited) + let msg_result = critical_section::with(|cs| { + let rx = BLE_RX.borrow_ref(cs); + rx.data[..rx.len].iter().position(|&b| b == b'\n') + }); + + if let Some(nl_pos) = msg_result { + // Extract message and shift remainder + let mut msg = [0u8; 256]; + let msg_len = critical_section::with(|cs| { + let mut rx = BLE_RX.borrow_ref_mut(cs); + msg[..nl_pos].copy_from_slice(&rx.data[..nl_pos]); + let total = rx.len; + let remaining = total - nl_pos - 1; + rx.data.copy_within(nl_pos + 1..total, 0); + rx.len = remaining; + nl_pos + }); + ble_handle_message(&msg[..msg_len]); + } + }; + + // Read callback for NUS TX (unused — we use notifications) + let mut tx_rf = |_offset: usize, data: &mut [u8]| { + let msg = b"use notify"; + let len = msg.len().min(data.len()); + data[..len].copy_from_slice(&msg[..len]); + len + }; + + gatt!([service { + uuid: "6e400001-b5a3-f393-e0a9-e50e24dcca9e", + characteristics: [ + characteristic { + name: "nus_rx", + uuid: "6e400002-b5a3-f393-e0a9-e50e24dcca9e", + write: rx_wf, + }, + characteristic { + name: "nus_tx", + uuid: "6e400003-b5a3-f393-e0a9-e50e24dcca9e", + notify: true, + read: tx_rf, + }, + ], + },]); + + let mut no_rng = bleps::no_rng::NoRng; + let mut srv = AttributeServer::new(&mut ble, &mut gatt_attributes, &mut no_rng); + + info!("BLE waiting for connection..."); + + // Notifier: returns the next chunk to send as a notification. + // + // If there is unsent data in BLE_TX, returns the next chunk immediately. + // Otherwise waits for BLE_NOTIFY (command response) or STATE_CHANGED (MSP push). + let mut notifier = || async { + loop { + // Check for pending chunk data + let chunk = critical_section::with(|cs| { + let mut tx = BLE_TX.borrow_ref_mut(cs); + if tx.offset < tx.len { + let end = (tx.offset + BLE_CHUNK_SIZE).min(tx.len); + let mut buf = [0u8; BLE_CHUNK_SIZE]; + let chunk_len = end - tx.offset; + buf[..chunk_len].copy_from_slice(&tx.data[tx.offset..end]); + tx.offset = end; + Some((buf, chunk_len)) + } else { + None + } + }); + + if let Some((buf, len)) = chunk { + return NotificationData::new(nus_tx_handle, &buf[..len]); + } + + // No pending data — wait for new response or state change + let notify_fut = BLE_NOTIFY.wait(); + let state_fut = STATE_CHANGED.wait(); + + futures::pin_mut!(notify_fut); + futures::pin_mut!(state_fut); + + match futures::future::select(notify_fut, state_fut).await { + futures::future::Either::Left(_) => { + // Command response queued — loop back to send chunks + } + futures::future::Either::Right(_) => { + // State changed — snapshot and queue + let state = STATE.lock().await; + let resp = ble_proto::build_state_response(&state); + drop(state); + critical_section::with(|cs| { + let mut tx = BLE_TX.borrow_ref_mut(cs); + tx.len = ble_proto::serialize_state(&resp, &mut tx.data) + .unwrap_or(0); + tx.offset = 0; + }); + // Loop back to send chunks + } + } + } + }; + + match srv.run(&mut notifier).await { + Ok(()) => { + info!("BLE client disconnected"); + BLE_FLASH.signal(2); + } + Err(e) => { + defmt::warn!("BLE server error: {}", defmt::Debug2Format(&e)); + BLE_FLASH.signal(2); + } + } + } +} + /// Read bytes from UART until a complete MSP frame is parsed or timeout. async fn read_msp_response( uart: &mut Uart<'static, esp_hal::Async>, @@ -248,9 +561,13 @@ async fn msp_task(mut uart: Uart<'static, esp_hal::Async>) { } info!("MSP: entering poll loop"); - // --- Phase 2: poll MSP_STATUS at ~10 Hz --- + // --- Phase 2: poll MSP_STATUS at ~10 Hz, MSP_RC at ~5 Hz --- let mut error_count: u8 = 0; let mut logged_raw_status = false; + let mut rc_channels = [0u16; msp::MAX_RC_CHANNELS]; + let mut prev_aux = [0u16; 12]; // AUX1–AUX12 (channels 5–16) + let mut rc_tick: u8 = 0; + let mut logged_rc_once = false; loop { // Retry box map if we never got it (FC wasn't ready at startup) @@ -321,10 +638,14 @@ async fn msp_task(mut uart: Uart<'static, esp_hal::Async>) { if state.flight_mode != mode || !state.fc_connected { info!("MSP: flags=0x{:08x} mode={}", flags, defmt::Debug2Format(&mode)); } + let changed = state.flight_mode != mode || !state.fc_connected; state.fc_connected = true; state.flight_mode = mode; state.debug_flags = flags; drop(state); + if changed { + STATE_CHANGED.signal(()); + } error_count = 0; } else { error_count = error_count.saturating_add(1); @@ -345,6 +666,58 @@ async fn msp_task(mut uart: Uart<'static, esp_hal::Async>) { error_count = 10; } + // Poll RC channels every 5th tick (~2 Hz) with short timeout + rc_tick = rc_tick.wrapping_add(1); + if rc_tick.is_multiple_of(5) { + let len = msp::build_request(msp::MSP_RC, &[], &mut tx_buf); + if Write::write_all(&mut uart, &tx_buf[..len]).await.is_ok() { + if let Some(frame) = + read_msp_response(&mut uart, &mut parser, Duration::from_millis(50)).await + { + if frame.cmd == msp::MSP_RC { + let count = + msp::parse_rc_channels(&frame.payload, frame.size, &mut rc_channels); + + // Dump all channels once so we can see which are active + if !logged_rc_once && count >= 4 { + logged_rc_once = true; + info!( + "MSP RC ({} ch): {} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {}", + count, + rc_channels[0], rc_channels[1], rc_channels[2], rc_channels[3], + rc_channels[4], rc_channels[5], rc_channels[6], rc_channels[7], + rc_channels[8], rc_channels[9], rc_channels[10], rc_channels[11], + rc_channels[12], rc_channels[13], rc_channels[14], rc_channels[15], + ); + } + + // AUX8 (channel 12, index 11) strobe trigger + // Check BEFORE updating prev_aux so we compare old vs new + if count >= 12 { + let strobe = rc_channels[11] > 1800; + let was_strobe = prev_aux[7] > 1800; + if strobe != was_strobe { + let mut state = STATE.lock().await; + state.aux_strobe = strobe; + info!("MSP AUX8 strobe: {}", strobe); + } + } + + // Log AUX channel changes with deadband (channels 5–16) + let aux_count = count.saturating_sub(4).min(12); + for i in 0..aux_count { + let ch = rc_channels[i + 4]; + let diff = ch.abs_diff(prev_aux[i]); + if diff > 50 { + info!("MSP AUX{}: {} -> {}", i + 1, prev_aux[i], ch); + prev_aux[i] = ch; + } + } + } + } + } + } + Timer::after(Duration::from_millis(100)).await; } } @@ -479,12 +852,30 @@ async fn led_task(spi_bus: SpiDmaBus<'static, esp_hal::Blocking>) { let mut write_err_logged = false; let mut frame_counter: u32 = 0; + // BLE flash state: remaining frames in the flash sequence, number of flashes + let mut flash_remaining: u32 = 0; + let mut flash_count: u8 = 0; + let mut flash_total_frames: u32 = 0; + loop { + // Check for new BLE flash request + if let Some(count) = BLE_FLASH.try_take() { + flash_count = count; + // Will be computed after we know FPS below + flash_remaining = u32::MAX; // sentinel — set properly after FPS read + } + let state = STATE.lock().await; let num_leds = state.num_leds.min(MAX_NUM_LEDS) as usize; let led_brightness = state.brightness; let max_ma = state.max_current_ma; let fps = state.fps.max(1); + + // Initialize flash frame count now that we know FPS + if flash_remaining == u32::MAX { + flash_total_frames = (fps as u32 * 750) / 1000; // 750ms worth of frames + flash_remaining = flash_total_frames; + } let fc_connected = state.fc_connected; let flight_mode = state.flight_mode; let debug_flags = state.debug_flags; @@ -498,10 +889,88 @@ async fn led_task(spi_bus: SpiDmaBus<'static, esp_hal::Blocking>) { let bal_b = state.color_bal_b; let anim_mode = state.anim_mode; let anim_params = state.anim_params; + let aux_strobe = state.aux_strobe; drop(state); let active = &mut buf[..num_leds]; + // AUX8 strobe override: fast white strobe (~12.5 Hz) + if aux_strobe { + // Toggle every 3 frames at 100 FPS ≈ 16.7 Hz strobe + let on = (frame_counter / 3).is_multiple_of(2); + let color = if on { + RGB8 { r: 255, g: 255, b: 255 } + } else { + RGB8 { r: 0, g: 0, b: 0 } + }; + for led in active.iter_mut() { + *led = color; + } + + // Apply brightness + current limit but skip gamma/color balance + let pipeline = [ + PostEffect::Brightness(led_brightness), + PostEffect::CurrentLimit { max_ma }, + ]; + apply_pipeline(active, &pipeline); + + match ws.write(active.iter().copied()) { + Err(e) if !write_err_logged => { + defmt::warn!("LED write error: {}", defmt::Debug2Format(&e)); + write_err_logged = true; + } + Ok(_) if write_err_logged => { + info!("LED write recovered"); + write_err_logged = false; + } + _ => {} + } + frame_counter = frame_counter.wrapping_add(1); + Timer::after(Duration::from_millis(1000 / fps as u64)).await; + continue; + } + + // BLE flash override: solid blue flashes (1× connect, 2× disconnect) + if flash_remaining > 0 { + let blue = RGB8 { r: 0, g: 0, b: 255 }; + let black = RGB8 { r: 0, g: 0, b: 0 }; + + let on = if flash_count == 1 { + // Single flash: solid blue for the entire 750ms + true + } else { + // Two flashes: on/off/on split across the total frames + // Pattern: [on 40%] [off 20%] [on 40%] + let pos = flash_total_frames - flash_remaining; + let first_end = flash_total_frames * 2 / 5; + let gap_end = flash_total_frames * 3 / 5; + pos < first_end || pos >= gap_end + }; + + let color = if on { blue } else { black }; + for led in active.iter_mut() { + *led = color; + } + + flash_remaining -= 1; + + // Skip normal rendering and post-processing — write directly + match ws.write(active.iter().copied()) { + Err(e) if !write_err_logged => { + defmt::warn!("LED write error: {}", defmt::Debug2Format(&e)); + write_err_logged = true; + } + Ok(_) if write_err_logged => { + info!("LED write recovered"); + write_err_logged = false; + } + _ => {} + } + frame_counter = frame_counter.wrapping_add(1); + Timer::after(Duration::from_millis(1000 / fps as u64)).await; + continue; + } + // Flight-mode override logic: // - Armed → rainbow show cycle // - Failsafe → sliding red bars diff --git a/src/ble.rs b/src/ble.rs new file mode 100644 index 0000000..04693a5 --- /dev/null +++ b/src/ble.rs @@ -0,0 +1,390 @@ +//! BLE Nordic UART Service (NUS) protocol layer. +//! +//! Defines the JSON command/response protocol used over BLE NUS. +//! Commands are received as JSON on the RX characteristic, responses +//! are sent as JSON (or plain strings) on the TX characteristic. + +use heapless::String as HString; +use serde::{Deserialize, Serialize}; + +use crate::state::{AnimMode, AnimModeParams, ColorMode, FlightMode, LedState}; + +/// Maximum number of active LEDs (mirrors `MAX_LEDS` in main). +const MAX_NUM_LEDS: u16 = 200; + +// --------------------------------------------------------------------------- +// Command (app → ESP, deserialize from JSON) +// --------------------------------------------------------------------------- + +/// Commands received from the app over BLE NUS RX characteristic. +/// +/// Uses serde's default externally-tagged representation: +/// `{"GetState":null}` or `{"SetBrightness":{"value":128}}`. +#[derive(Deserialize)] +pub enum Command { + GetState, + SetBrightness { value: u8 }, + SetNumLeds { value: u16 }, + SetFps { value: u8 }, + SetMaxCurrent { value: u32 }, + SetColorMode { mode: HString<16> }, + SetAnimMode { mode: HString<16> }, + SetColorBalance { r: u8, g: u8, b: u8 }, + SetUseHsi { value: bool }, + SetHueSpeed { value: u8 }, + SetPulseSpeed { value: u16 }, + SetPulseMinBrightness { value: u8 }, + SetRippleSpeed { value: u8 }, + SetRippleWidth { value: u8 }, + SetRippleDecay { value: u8 }, +} + +// --------------------------------------------------------------------------- +// Response (ESP → app, serialize to JSON) +// --------------------------------------------------------------------------- + +/// Full state snapshot sent to the app. +#[derive(Serialize)] +pub struct StateResponse { + pub brightness: u8, + pub num_leds: u16, + pub fps: u8, + pub max_current_ma: u32, + pub color_mode: &'static str, + pub anim_mode: &'static str, + pub bal_r: u8, + pub bal_g: u8, + pub bal_b: u8, + pub use_hsi: bool, + pub hue_speed: u8, + pub pulse_speed: u16, + pub pulse_min_brightness: u8, + pub ripple_speed: u8, + pub ripple_width: u8, + pub ripple_decay: u8, + pub fc_connected: bool, + pub flight_mode: &'static str, +} + +// --------------------------------------------------------------------------- +// Mapping helpers +// --------------------------------------------------------------------------- + +/// Map a [`ColorMode`] to its wire-format string key. +pub fn color_mode_str(mode: ColorMode) -> &'static str { + match mode { + ColorMode::SolidGreen => "solid_green", + ColorMode::SolidRed => "solid_red", + ColorMode::Split => "split", + ColorMode::Rainbow => "rainbow", + } +} + +/// Map a [`AnimMode`] to its wire-format string key. +pub fn anim_mode_str(mode: AnimMode) -> &'static str { + match mode { + AnimMode::Static => "static", + AnimMode::Pulse => "pulse", + AnimMode::Ripple => "ripple", + } +} + +/// Map a [`FlightMode`] to its wire-format string key. +fn flight_mode_str(mode: FlightMode) -> &'static str { + match mode { + FlightMode::ArmingForbidden => "arming_forbidden", + FlightMode::ArmingAllowed => "arming_allowed", + FlightMode::Armed => "armed", + FlightMode::Failsafe => "failsafe", + } +} + +/// Parse a color mode string into a [`ColorMode`]. +fn parse_color_mode(s: &str) -> Option { + match s { + "solid_green" => Some(ColorMode::SolidGreen), + "solid_red" => Some(ColorMode::SolidRed), + "split" => Some(ColorMode::Split), + "rainbow" => Some(ColorMode::Rainbow), + _ => None, + } +} + +/// Parse an animation mode string into an [`AnimMode`]. +fn parse_anim_mode(s: &str) -> Option { + match s { + "static" => Some(AnimMode::Static), + "pulse" => Some(AnimMode::Pulse), + "ripple" => Some(AnimMode::Ripple), + _ => None, + } +} + +// --------------------------------------------------------------------------- +// State snapshot +// --------------------------------------------------------------------------- + +/// Build a [`StateResponse`] from the current [`LedState`]. +pub fn build_state_response(state: &LedState) -> StateResponse { + let (pulse_speed, pulse_min_brightness) = match state.anim_params { + AnimModeParams::Pulse { + speed, + min_intensity_pct, + } => (speed, min_intensity_pct), + _ => (600, 42), + }; + let (ripple_speed, ripple_width, ripple_decay) = match state.anim_params { + AnimModeParams::Ripple { + speed_x10, + width_x10, + decay_pct, + } => (speed_x10, width_x10, decay_pct), + _ => (15, 190, 97), + }; + + StateResponse { + brightness: state.brightness, + num_leds: state.num_leds, + fps: state.fps, + max_current_ma: state.max_current_ma, + color_mode: color_mode_str(state.color_mode), + anim_mode: anim_mode_str(state.anim_mode), + bal_r: state.color_bal_r, + bal_g: state.color_bal_g, + bal_b: state.color_bal_b, + use_hsi: state.use_hsi, + hue_speed: state.color_params.hue_speed, + pulse_speed, + pulse_min_brightness, + ripple_speed, + ripple_width, + ripple_decay, + fc_connected: state.fc_connected, + flight_mode: flight_mode_str(state.flight_mode), + } +} + +// --------------------------------------------------------------------------- +// Command handling +// --------------------------------------------------------------------------- + +/// Result of handling a command. +pub enum HandleResult { + /// Send the full state as JSON. + SendState, + /// Send a simple "ok\n" ack. + Ack, + /// Send an error string. + Error(&'static str), +} + +/// Apply a [`Command`] to the shared [`LedState`], returning what response to send. +pub fn handle_command(cmd: &Command, state: &mut LedState) -> HandleResult { + match cmd { + Command::GetState => HandleResult::SendState, + Command::SetBrightness { value } => { + state.brightness = *value; + HandleResult::Ack + } + Command::SetNumLeds { value } => { + state.num_leds = (*value).clamp(1, MAX_NUM_LEDS); + HandleResult::Ack + } + Command::SetFps { value } => { + state.fps = (*value).clamp(1, 150); + HandleResult::Ack + } + Command::SetMaxCurrent { value } => { + state.max_current_ma = (*value).clamp(100, 2500); + HandleResult::Ack + } + Command::SetColorMode { mode } => match parse_color_mode(mode.as_str()) { + Some(m) => { + state.color_mode = m; + HandleResult::Ack + } + None => HandleResult::Error("err:unknown_color_mode\n"), + }, + Command::SetAnimMode { mode } => match parse_anim_mode(mode.as_str()) { + Some(m) => { + if m != state.anim_mode { + state.anim_mode = m; + state.anim_params = AnimModeParams::default_for(m); + } + HandleResult::Ack + } + None => HandleResult::Error("err:unknown_anim_mode\n"), + }, + Command::SetColorBalance { r, g, b } => { + state.color_bal_r = *r; + state.color_bal_g = *g; + state.color_bal_b = *b; + HandleResult::Ack + } + Command::SetUseHsi { value } => { + state.use_hsi = *value; + HandleResult::Ack + } + Command::SetHueSpeed { value } => { + state.color_params.hue_speed = (*value).clamp(1, 10); + HandleResult::Ack + } + Command::SetPulseSpeed { value } => { + if let AnimModeParams::Pulse { speed, .. } = &mut state.anim_params { + *speed = (*value).clamp(100, 2000); + } + HandleResult::Ack + } + Command::SetPulseMinBrightness { value } => { + if let AnimModeParams::Pulse { + min_intensity_pct, .. + } = &mut state.anim_params + { + *min_intensity_pct = (*value).min(80); + } + HandleResult::Ack + } + Command::SetRippleSpeed { value } => { + if let AnimModeParams::Ripple { speed_x10, .. } = &mut state.anim_params { + *speed_x10 = (*value).clamp(5, 50); + } + HandleResult::Ack + } + Command::SetRippleWidth { value } => { + if let AnimModeParams::Ripple { width_x10, .. } = &mut state.anim_params { + *width_x10 = (*value).clamp(10, 255); + } + HandleResult::Ack + } + Command::SetRippleDecay { value } => { + if let AnimModeParams::Ripple { decay_pct, .. } = &mut state.anim_params { + *decay_pct = (*value).clamp(90, 99); + } + HandleResult::Ack + } + } +} + +// --------------------------------------------------------------------------- +// Serialization helpers +// --------------------------------------------------------------------------- + +/// Parse a JSON command from a byte slice. +/// +/// Returns the parsed command or `None` on failure. +pub fn parse_command(data: &[u8]) -> Option { + // Strip trailing newline if present + let data = if data.last() == Some(&b'\n') { + &data[..data.len() - 1] + } else { + data + }; + serde_json_core::from_slice::(data).ok().map(|(cmd, _)| cmd) +} + +/// Serialize a [`StateResponse`] into a buffer, appending a newline delimiter. +/// +/// Returns the number of bytes written, or `None` if the buffer is too small. +pub fn serialize_state(resp: &StateResponse, buf: &mut [u8]) -> Option { + let n = serde_json_core::to_slice(resp, buf).ok()?; + if n < buf.len() { + buf[n] = b'\n'; + Some(n + 1) + } else { + None + } +} + +// --------------------------------------------------------------------------- +// Unit tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn default_state() -> LedState { + LedState::default() + } + + #[test] + fn parse_get_state() { + let cmd = parse_command(b"{\"GetState\":null}").unwrap(); + assert!(matches!(cmd, Command::GetState)); + } + + #[test] + fn parse_get_state_with_newline() { + let cmd = parse_command(b"{\"GetState\":null}\n").unwrap(); + assert!(matches!(cmd, Command::GetState)); + } + + #[test] + fn parse_set_brightness() { + let cmd = parse_command(b"{\"SetBrightness\":{\"value\":128}}").unwrap(); + assert!(matches!(cmd, Command::SetBrightness { value: 128 })); + } + + #[test] + fn parse_set_color_mode() { + let cmd = parse_command(b"{\"SetColorMode\":{\"mode\":\"rainbow\"}}").unwrap(); + if let Command::SetColorMode { mode } = cmd { + assert_eq!(mode.as_str(), "rainbow"); + } else { + panic!("expected SetColorMode"); + } + } + + #[test] + fn parse_invalid_json() { + assert!(parse_command(b"not json").is_none()); + } + + #[test] + fn handle_get_state_returns_send_state() { + let mut state = default_state(); + let result = handle_command(&Command::GetState, &mut state); + assert!(matches!(result, HandleResult::SendState)); + } + + #[test] + fn handle_set_brightness() { + let mut state = default_state(); + let result = handle_command(&Command::SetBrightness { value: 42 }, &mut state); + assert!(matches!(result, HandleResult::Ack)); + assert_eq!(state.brightness, 42); + } + + #[test] + fn handle_set_num_leds_clamps() { + let mut state = default_state(); + handle_command(&Command::SetNumLeds { value: 999 }, &mut state); + assert_eq!(state.num_leds, MAX_NUM_LEDS); + } + + #[test] + fn handle_set_anim_mode_resets_params() { + let mut state = default_state(); + state.anim_mode = AnimMode::Pulse; + state.anim_params = AnimModeParams::Pulse { + speed: 1234, + min_intensity_pct: 77, + }; + let mode: HString<16> = HString::try_from("ripple").unwrap(); + handle_command(&Command::SetAnimMode { mode }, &mut state); + assert_eq!(state.anim_mode, AnimMode::Ripple); + assert!(matches!(state.anim_params, AnimModeParams::Ripple { .. })); + } + + #[test] + fn serialize_state_response() { + let state = default_state(); + let resp = build_state_response(&state); + let mut buf = [0u8; 512]; + let n = serialize_state(&resp, &mut buf).unwrap(); + let json = core::str::from_utf8(&buf[..n]).unwrap(); + assert!(json.ends_with('\n')); + assert!(json.contains("\"brightness\"")); + assert!(json.contains("\"color_mode\"")); + } +} diff --git a/src/lib.rs b/src/lib.rs index 0605b42..436d61a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ #![no_std] +pub mod ble; pub mod msp; pub mod pattern; pub mod postfx; diff --git a/src/msp.rs b/src/msp.rs index f12539e..00e9e44 100644 --- a/src/msp.rs +++ b/src/msp.rs @@ -13,6 +13,9 @@ pub const MSP_STATUS: u8 = 101; /// MSP command: box names (semicolon-separated list of mode names). pub const MSP_BOXNAMES: u8 = 116; +/// MSP command: RC channel values (16 × u16 LE, 1000–2000 µs). +pub const MSP_RC: u8 = 105; + /// MSP command: box IDs (permanent numeric IDs, one byte each). pub const MSP_BOXIDS: u8 = 119; @@ -287,6 +290,26 @@ pub fn parse_boxids(payload: &[u8], size: u8) -> [BoxId; MAX_BOXES] { map } +// --------------------------------------------------------------------------- +// RC channel parser +// --------------------------------------------------------------------------- + +/// Maximum number of RC channels in an MSP_RC response. +pub const MAX_RC_CHANNELS: usize = 16; + +/// Parse an MSP_RC response payload into channel values. +/// +/// Each channel is a u16 LE value (typically 1000–2000 µs). +/// Returns the number of channels parsed (up to [`MAX_RC_CHANNELS`]). +pub fn parse_rc_channels(payload: &[u8], size: u8, out: &mut [u16; MAX_RC_CHANNELS]) -> usize { + let byte_len = size as usize; + let count = (byte_len / 2).min(MAX_RC_CHANNELS); + for i in 0..count { + out[i] = u16::from_le_bytes([payload[i * 2], payload[i * 2 + 1]]); + } + count +} + // --------------------------------------------------------------------------- // Flight mode resolution // --------------------------------------------------------------------------- diff --git a/src/state.rs b/src/state.rs index e319985..d809e31 100644 --- a/src/state.rs +++ b/src/state.rs @@ -5,6 +5,7 @@ use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; use embassy_sync::mutex::Mutex; +use embassy_sync::signal::Signal; /// Active color scheme. #[derive(Clone, Copy, Debug, PartialEq, Eq, defmt::Format)] @@ -134,6 +135,8 @@ pub struct LedState { pub debug_arm_box: u8, /// Index of the FAILSAFE box in the BOXNAMES map (255 = not found). pub debug_failsafe_box: u8, + /// AUX8 strobe active (RC channel 12 > 2000). + pub aux_strobe: bool, } impl Default for LedState { @@ -156,10 +159,22 @@ impl Default for LedState { debug_flags: 0, debug_arm_box: 255, debug_failsafe_box: 255, + aux_strobe: false, } } } +/// BLE flash request: number of flashes (1 = connect, 2 = disconnect). +/// +/// The LED task picks this up and plays a blue flash sequence over 750 ms. +pub static BLE_FLASH: Signal = Signal::new(); + +/// Signal to notify the BLE task that state has changed. +/// +/// Any task that modifies state can signal this to trigger a BLE push notification. +/// The signal carries no data — the BLE task reads the current state when woken. +pub static STATE_CHANGED: Signal = Signal::new(); + /// Global shared state protected by an async mutex. /// /// Lock with `STATE.lock().await` from any embassy task. @@ -184,4 +199,5 @@ pub static STATE: Mutex = Mutex::new(LedState debug_flags: 0, debug_arm_box: 255, debug_failsafe_box: 255, + aux_strobe: false, }); From f571e0dec0366496f0d67d438f674e9721a8b8e1 Mon Sep 17 00:00:00 2001 From: Moritz Riede Date: Wed, 18 Feb 2026 01:54:04 +0100 Subject: [PATCH 2/4] feat: AUX7 3-position strobe, rainbow ripple armed mode, faster MSP polling - Replace AUX8 on/off strobe with AUX7 3-position switch: ~1100 = off, ~1400 = low (80), ~1900 = full (255) - Strobe bypasses current limiter for maximum output on battery - Shorter attack/decay ramp (4-frame cycle, 25 Hz at 100 FPS) - Replace rainbow show cycle with rainbow ripple for armed mode - MSP loop sleep reduced from 100ms to 10ms for faster response - STATUS timeout 30ms, RC timeout 20ms, RC polled every tick - Clear aux_strobe on FC disconnect to prevent stuck strobe - Always write strobe state on each RC poll (not just transitions) --- src/bin/main.rs | 150 +++++++++++++++--------------------------------- src/state.rs | 8 +-- 2 files changed, 49 insertions(+), 109 deletions(-) diff --git a/src/bin/main.rs b/src/bin/main.rs index 95c4c13..0020971 100644 --- a/src/bin/main.rs +++ b/src/bin/main.rs @@ -561,7 +561,7 @@ async fn msp_task(mut uart: Uart<'static, esp_hal::Async>) { } info!("MSP: entering poll loop"); - // --- Phase 2: poll MSP_STATUS at ~10 Hz, MSP_RC at ~5 Hz --- + // --- Phase 2: poll MSP_STATUS + MSP_RC every tick (~20 Hz) --- let mut error_count: u8 = 0; let mut logged_raw_status = false; let mut rc_channels = [0u16; msp::MAX_RC_CHANNELS]; @@ -612,7 +612,7 @@ async fn msp_task(mut uart: Uart<'static, esp_hal::Async>) { let send_ok = Write::write_all(&mut uart, &tx_buf[..len]).await.is_ok(); let frame = if send_ok { - read_msp_response(&mut uart, &mut parser, Duration::from_millis(100)).await + read_msp_response(&mut uart, &mut parser, Duration::from_millis(30)).await } else { None }; @@ -661,18 +661,19 @@ async fn msp_task(mut uart: Uart<'static, esp_hal::Async>) { let mut state = STATE.lock().await; state.fc_connected = false; state.flight_mode = FlightMode::ArmingForbidden; + state.aux_strobe = 0; drop(state); // Reset counter to avoid spamming state writes every tick error_count = 10; } - // Poll RC channels every 5th tick (~2 Hz) with short timeout + // Poll RC channels every tick with short timeout rc_tick = rc_tick.wrapping_add(1); - if rc_tick.is_multiple_of(5) { + { let len = msp::build_request(msp::MSP_RC, &[], &mut tx_buf); if Write::write_all(&mut uart, &tx_buf[..len]).await.is_ok() { if let Some(frame) = - read_msp_response(&mut uart, &mut parser, Duration::from_millis(50)).await + read_msp_response(&mut uart, &mut parser, Duration::from_millis(20)).await { if frame.cmd == msp::MSP_RC { let count = @@ -691,16 +692,30 @@ async fn msp_task(mut uart: Uart<'static, esp_hal::Async>) { ); } - // AUX8 (channel 12, index 11) strobe trigger - // Check BEFORE updating prev_aux so we compare old vs new - if count >= 12 { - let strobe = rc_channels[11] > 1800; - let was_strobe = prev_aux[7] > 1800; - if strobe != was_strobe { - let mut state = STATE.lock().await; - state.aux_strobe = strobe; - info!("MSP AUX8 strobe: {}", strobe); + // AUX7 (channel 11, index 10) 3-position strobe trigger + // ~1100 = off, ~1400 = low, ~1900 = full + if count >= 11 { + let ch = rc_channels[10]; + let strobe_level: u8 = if ch > 1650 { + 255 // full + } else if ch > 1250 { + 80 // low + } else { + 0 // off + }; + let prev_ch = prev_aux[6]; + let prev_level: u8 = if prev_ch > 1650 { + 255 + } else if prev_ch > 1250 { + 80 + } else { + 0 + }; + if strobe_level != prev_level { + info!("MSP AUX7 strobe: {}", strobe_level); } + let mut state = STATE.lock().await; + state.aux_strobe = strobe_level; } // Log AUX channel changes with deadband (channels 5–16) @@ -718,7 +733,7 @@ async fn msp_task(mut uart: Uart<'static, esp_hal::Async>) { } } - Timer::after(Duration::from_millis(100)).await; + Timer::after(Duration::from_millis(10)).await; } } @@ -735,81 +750,6 @@ fn build_color_scheme(mode: ColorMode, use_hsi: bool) -> ColorScheme { } } -/// Number of frames per armed show sub-pattern before advancing to the next. -const ARMED_SHOW_FRAMES: u32 = 1000; // ~10 s at 100 FPS - -/// Number of armed show sub-patterns in the cycle. -const ARMED_SHOW_COUNT: u32 = 4; - -/// Render the armed show pattern: cyclic rainbow variations. -/// -/// `frame` is the global frame counter; the sub-pattern index is derived from it. -fn render_armed_show(leds: &mut [RGB8], frame: u32) { - let sub = (frame / ARMED_SHOW_FRAMES) % ARMED_SHOW_COUNT; - let num = leds.len(); - let tick = frame as u8; // wrapping is fine for hue rotation - - match sub { - 0 => { - // Static rainbow gradient, slowly rotating - for (i, led) in leds.iter_mut().enumerate() { - let hue = tick.wrapping_add((i * 256 / num.max(1)) as u8); - *led = smart_leds::hsv::hsv2rgb(smart_leds::hsv::Hsv { - hue, - sat: 255, - val: 255, - }); - } - } - 1 => { - // Rainbow pulse: all LEDs same hue (rotating), pulsing brightness - // Triangle wave: 0→1→0 over 200 frames - let half = (frame % 200) as u16; - let t = if half < 100 { half } else { 200 - half } as f32 / 100.0; - let brightness = 0.4 + 0.6 * t; - let val = (brightness * 255.0) as u8; - for (i, led) in leds.iter_mut().enumerate() { - let hue = tick.wrapping_add((i * 256 / num.max(1)) as u8); - *led = smart_leds::hsv::hsv2rgb(smart_leds::hsv::Hsv { - hue, - sat: 255, - val, - }); - } - } - 2 => { - // Rainbow chase: fast scroll - let offset = (frame * 3) as u8; - for (i, led) in leds.iter_mut().enumerate() { - let hue = offset.wrapping_add((i * 256 / num.max(1)) as u8); - *led = smart_leds::hsv::hsv2rgb(smart_leds::hsv::Hsv { - hue, - sat: 255, - val: 255, - }); - } - } - _ => { - // Rainbow with sparkle: normal rainbow + occasional bright white - let xor_state = frame.wrapping_mul(2654435761); // simple hash - for (i, led) in leds.iter_mut().enumerate() { - let hue = - (tick.wrapping_mul(2)).wrapping_add((i * 256 / num.max(1)) as u8); - let sparkle = (xor_state ^ (i as u32 * 7919)).is_multiple_of(40); - if sparkle { - *led = RGB8 { r: 255, g: 255, b: 255 }; - } else { - *led = smart_leds::hsv::hsv2rgb(smart_leds::hsv::Hsv { - hue, - sat: 255, - val: 255, - }); - } - } - } - } -} - /// Render the failsafe pattern: sliding red bars with black gaps. fn render_failsafe(leds: &mut [RGB8], frame: u32) { let num = leds.len(); @@ -845,6 +785,8 @@ async fn led_task(spi_bus: SpiDmaBus<'static, esp_hal::Blocking>) { let mut static_anim = StaticAnim; let mut color_scheme = build_color_scheme(ColorMode::Split, false); + let mut armed_scheme = ColorScheme::Rainbow { hue: 0, speed: 2, use_hsi: false }; + let mut armed_ripple = RippleEffect::new(0xCAFE_BABE); let mut prev_color_mode = ColorMode::Split; let mut prev_use_hsi = false; @@ -894,26 +836,24 @@ async fn led_task(spi_bus: SpiDmaBus<'static, esp_hal::Blocking>) { let active = &mut buf[..num_leds]; - // AUX8 strobe override: fast white strobe (~12.5 Hz) - if aux_strobe { - // Toggle every 3 frames at 100 FPS ≈ 16.7 Hz strobe - let on = (frame_counter / 3).is_multiple_of(2); - let color = if on { - RGB8 { r: 255, g: 255, b: 255 } + // AUX7 strobe override: fast white strobe (~25 Hz) with short attack/decay + if aux_strobe > 0 { + // 4-frame cycle: 2 on, 2 off → 25 Hz at 100 FPS + const STROBE_HALF: u32 = 2; + const STROBE_PERIOD: u32 = STROBE_HALF * 2; + let peak = aux_strobe; + let phase = frame_counter % STROBE_PERIOD; + let intensity = if phase < STROBE_HALF { + ((phase + 1) as u16 * peak as u16 / STROBE_HALF as u16) as u8 } else { - RGB8 { r: 0, g: 0, b: 0 } + let off_phase = phase - STROBE_HALF; + ((STROBE_HALF - off_phase) as u16 * peak as u16 / STROBE_HALF as u16) as u8 }; + let color = RGB8 { r: intensity, g: intensity, b: intensity }; for led in active.iter_mut() { *led = color; } - // Apply brightness + current limit but skip gamma/color balance - let pipeline = [ - PostEffect::Brightness(led_brightness), - PostEffect::CurrentLimit { max_ma }, - ]; - apply_pipeline(active, &pipeline); - match ws.write(active.iter().copied()) { Err(e) if !write_err_logged => { defmt::warn!("LED write error: {}", defmt::Debug2Format(&e)); @@ -978,7 +918,7 @@ async fn led_task(spi_bus: SpiDmaBus<'static, esp_hal::Blocking>) { // - Arming allowed (FC connected) → solid green pulse // - No FC → user-selected pattern from web UI if fc_connected && flight_mode == FlightMode::Armed { - render_armed_show(active, frame_counter); + armed_ripple.render(active, &mut armed_scheme); } else if fc_connected && flight_mode == FlightMode::Failsafe { render_failsafe(active, frame_counter); } else if fc_connected && flight_mode == FlightMode::ArmingForbidden { diff --git a/src/state.rs b/src/state.rs index d809e31..87b4cdf 100644 --- a/src/state.rs +++ b/src/state.rs @@ -135,8 +135,8 @@ pub struct LedState { pub debug_arm_box: u8, /// Index of the FAILSAFE box in the BOXNAMES map (255 = not found). pub debug_failsafe_box: u8, - /// AUX8 strobe active (RC channel 12 > 2000). - pub aux_strobe: bool, + /// AUX7 strobe intensity (0 = off, nonzero = peak brightness). + pub aux_strobe: u8, } impl Default for LedState { @@ -159,7 +159,7 @@ impl Default for LedState { debug_flags: 0, debug_arm_box: 255, debug_failsafe_box: 255, - aux_strobe: false, + aux_strobe: 0, } } } @@ -199,5 +199,5 @@ pub static STATE: Mutex = Mutex::new(LedState debug_flags: 0, debug_arm_box: 255, debug_failsafe_box: 255, - aux_strobe: false, + aux_strobe: 0, }); From 308148535e7b9abc4c360996ebf6847d91f5de4e Mon Sep 17 00:00:00 2001 From: Moritz Riede Date: Wed, 18 Feb 2026 02:06:45 +0100 Subject: [PATCH 3/4] feat: add AUX8 spring switch strobe override and boot guard - AUX8 momentary switch (>1800) overrides AUX7 to full strobe (255) - Skip first 3 RC polls to avoid garbage values triggering strobe at boot - Add Flutter BLE integration guide (FLUTTER_BLE_GUIDE.md) --- FLUTTER_BLE_GUIDE.md | 383 +++++++++++++++++++++++++++++++++++++++++++ src/bin/main.rs | 34 ++-- 2 files changed, 398 insertions(+), 19 deletions(-) create mode 100644 FLUTTER_BLE_GUIDE.md diff --git a/FLUTTER_BLE_GUIDE.md b/FLUTTER_BLE_GUIDE.md new file mode 100644 index 0000000..da8e728 --- /dev/null +++ b/FLUTTER_BLE_GUIDE.md @@ -0,0 +1,383 @@ +# AirLED Flutter BLE Integration Guide + +This guide is for building a Flutter companion app that controls the AirLED ESP32-C3 LED controller over Bluetooth Low Energy (BLE). + +## Architecture Overview + +The device runs on an ESP32-C3 (XIAO form factor) and exposes a **Nordic UART Service (NUS)** over BLE. Communication is newline-delimited JSON over a serial-like BLE pipe. The device also runs a Wi-Fi AP with HTTP control as a fallback, but BLE is the primary interface for the app. + +## BLE Connection + +### Advertising + +- **Device name:** `AirLED` +- **Flags:** LE General Discoverable, no BR/EDR + +### NUS UUIDs + +| Role | UUID | +|------|------| +| Service | `6e400001-b5a3-f393-e0a9-e50e24dcca9e` | +| RX (app → device, write) | `6e400002-b5a3-f393-e0a9-e50e24dcca9e` | +| TX (device → app, notify) | `6e400003-b5a3-f393-e0a9-e50e24dcca9e` | + +### Recommended Flutter package + +Use `flutter_reactive_ble` or `flutter_blue_plus` for BLE communication. + +### Connection flow + +1. Scan for devices advertising name `AirLED` +2. Connect and discover services +3. Subscribe to notifications on TX characteristic +4. Send `{"GetState":null}\n` on RX to request initial state +5. The device also auto-pushes state on certain events (see below) + +## Wire Protocol + +### Sending commands (app → device) + +- Write JSON + `\n` to the **RX characteristic** +- If the command exceeds 20 bytes, split into 20-byte chunks and write sequentially +- The device reassembles until it sees `\n` + +### Receiving responses (device → app) + +- Subscribe to **TX characteristic** notifications +- Responses arrive as 20-byte chunks +- Buffer incoming bytes until you see `\n` +- Then parse the complete message as either JSON or a plain-text ack/error + +### Response types + +| Type | Format | When | +|------|--------|------| +| State | JSON object + `\n` | Response to `GetState`, or auto-push on state change | +| Ack | `ok\n` | Response to any successful `Set*` command | +| Error | `err:reason\n` | Parse failure or invalid value | + +Error reasons: `err:parse`, `err:unknown_color_mode`, `err:unknown_anim_mode` + +## Commands (app → device) + +Commands use serde's externally-tagged enum format. All commands must be terminated with `\n`. + +### GetState + +Request the full device state snapshot. + +```json +{"GetState":null} +``` + +### SetBrightness + +```json +{"SetBrightness":{"value":128}} +``` + +- `value`: `u8` (0–255), default **255** + +### SetNumLeds + +```json +{"SetNumLeds":{"value":180}} +``` + +- `value`: `u16` (clamped 1–200), default **180** + +### SetFps + +```json +{"SetFps":{"value":100}} +``` + +- `value`: `u8` (clamped 1–150), default **100** + +### SetMaxCurrent + +Maximum LED strip current budget in milliamps. + +```json +{"SetMaxCurrent":{"value":2000}} +``` + +- `value`: `u32` (clamped 100–2500), default **2000** + +### SetColorMode + +```json +{"SetColorMode":{"mode":"rainbow"}} +``` + +Valid modes: + +| Key | Description | +|-----|-------------| +| `solid_green` | Solid green | +| `solid_red` | Solid red | +| `split` | Green/red split (port/starboard navigation lights) | +| `rainbow` | Rainbow HSV gradient | + +Default: **`split`** + +### SetAnimMode + +Changing animation mode resets animation parameters to defaults. + +```json +{"SetAnimMode":{"mode":"pulse"}} +``` + +Valid modes: + +| Key | Description | +|-----|-------------| +| `static` | No animation, static fill | +| `pulse` | Sinusoidal breathing pulse | +| `ripple` | Expanding ring ripples | + +Default: **`pulse`** + +### SetColorBalance + +Per-channel color balance applied after gamma correction. + +```json +{"SetColorBalance":{"r":255,"g":180,"b":240}} +``` + +- `r`, `g`, `b`: `u8` (0–255), defaults **r=255, g=180, b=240** + +### SetUseHsi + +Toggle HSI color space for rainbow mode (more uniform perceived brightness). + +```json +{"SetUseHsi":{"value":true}} +``` + +- `value`: `bool`, default **false** + +### SetHueSpeed + +Rainbow hue rotation speed (only affects rainbow color mode). + +```json +{"SetHueSpeed":{"value":2}} +``` + +- `value`: `u8` (clamped 1–10), default **1** + +### SetPulseSpeed + +Pulse animation phase increment per frame (only applies in pulse mode). + +```json +{"SetPulseSpeed":{"value":600}} +``` + +- `value`: `u16` (clamped 100–2000), default **600** + +### SetPulseMinBrightness + +Minimum brightness floor for pulse as a percentage (only applies in pulse mode). + +```json +{"SetPulseMinBrightness":{"value":42}} +``` + +- `value`: `u8` (clamped 0–80), default **42** + +### SetRippleSpeed + +Ripple expansion speed in fixed-point x10 (e.g. 15 = 1.5 LEDs/frame). Only applies in ripple mode. + +```json +{"SetRippleSpeed":{"value":15}} +``` + +- `value`: `u8` (clamped 5–50), default **15** + +### SetRippleWidth + +Ripple wavefront half-width in fixed-point x10 (e.g. 190 = 19.0 LEDs). Only applies in ripple mode. + +```json +{"SetRippleWidth":{"value":190}} +``` + +- `value`: `u8` (clamped 10–255), default **190** + +### SetRippleDecay + +Per-frame amplitude decay percentage. Only applies in ripple mode. + +```json +{"SetRippleDecay":{"value":97}} +``` + +- `value`: `u8` (clamped 90–99), default **97** + +## StateResponse (device → app) + +Full JSON state snapshot. Approximately 250 bytes serialized. + +```json +{ + "brightness": 255, + "num_leds": 180, + "fps": 100, + "max_current_ma": 2000, + "color_mode": "split", + "anim_mode": "pulse", + "bal_r": 255, + "bal_g": 180, + "bal_b": 240, + "use_hsi": false, + "hue_speed": 1, + "pulse_speed": 600, + "pulse_min_brightness": 42, + "ripple_speed": 15, + "ripple_width": 190, + "ripple_decay": 97, + "fc_connected": false, + "flight_mode": "arming_forbidden" +} +``` + +### Field reference + +| Field | Type | Range | Description | +|-------|------|-------|-------------| +| `brightness` | int | 0–255 | Global LED brightness | +| `num_leds` | int | 1–200 | Active LED count | +| `fps` | int | 1–150 | Target frame rate | +| `max_current_ma` | int | 100–2500 | Current limit (mA) | +| `color_mode` | string | see above | Active color scheme | +| `anim_mode` | string | see above | Active animation | +| `bal_r` | int | 0–255 | Red color balance | +| `bal_g` | int | 0–255 | Green color balance | +| `bal_b` | int | 0–255 | Blue color balance | +| `use_hsi` | bool | | HSI mode for rainbow | +| `hue_speed` | int | 1–10 | Rainbow rotation speed | +| `pulse_speed` | int | 100–2000 | Pulse animation speed | +| `pulse_min_brightness` | int | 0–80 | Pulse min brightness % | +| `ripple_speed` | int | 5–50 | Ripple speed (x10) | +| `ripple_width` | int | 10–255 | Ripple width (x10) | +| `ripple_decay` | int | 90–99 | Ripple decay % | +| `fc_connected` | bool | | Flight controller connected | +| `flight_mode` | string | see below | Current flight mode | + +### Flight modes + +| Value | Description | +|-------|-------------| +| `arming_forbidden` | Pre-flight checks failed, cannot arm | +| `arming_allowed` | Disarmed, ready to arm | +| `armed` | Motors armed and active | +| `failsafe` | Communication lost | + +When `fc_connected` is `true`, the device overrides user LED patterns with flight-mode-specific displays. The app should show the flight mode status but can still send `Set*` commands — they apply when the FC is disconnected or disarmed. + +## Auto-push behavior + +The device automatically sends a full `StateResponse` (without the app requesting it) when: + +1. **Flight mode changes** — FC arms/disarms, enters failsafe, etc. +2. **FC connection/disconnection** — `fc_connected` toggles + +These are event-driven, not polled. The app should always be ready to receive unsolicited `StateResponse` messages on the TX notification stream. + +## UI design notes + +### Parameter visibility + +Not all parameters are relevant at all times. Show/hide based on current mode: + +| Parameter | Visible when | +|-----------|-------------| +| Hue Speed, Use HSI | `color_mode == "rainbow"` | +| Pulse Speed, Min Brightness | `anim_mode == "pulse"` | +| Ripple Speed/Width/Decay | `anim_mode == "ripple"` | +| Brightness, LED count, FPS, Current Limit, Color Balance | Always | + +### Recommended controls + +| Parameter | Control type | +|-----------|-------------| +| Brightness | Slider (0–255) | +| LED count | Slider (1–200) | +| FPS | Slider (1–150) | +| Current Limit | Slider (100–2500, step 50) | +| Color Mode | Dropdown or segmented control | +| Animation Mode | Dropdown or segmented control | +| Color Balance R/G/B | Three sliders (0–255) | +| Use HSI | Toggle/checkbox | +| Hue Speed | Slider (1–10) | +| Pulse Speed | Slider (100–2000) | +| Min Brightness | Slider (0–80) with % label | +| Ripple Speed | Slider (5–50) | +| Ripple Width | Slider (10–255) | +| Ripple Decay | Slider (90–99) with % label | + +### Debouncing + +Sliders should debounce sends (e.g. 80ms) to avoid flooding the BLE link. The device handles rapid updates fine, but BLE throughput is limited. + +### Connection status + +Show a connection indicator. The device flashes its LEDs blue on connect (1 flash) and disconnect (2 flashes), but the app should track connection state independently via the BLE library. + +### Flight mode display + +When `fc_connected` is `true`, show the flight mode prominently. Consider disabling or dimming the color/animation controls since the device overrides them based on flight mode: + +- **Armed:** Rainbow ripple effect (not user-controllable) +- **Failsafe:** Sliding red bars (not user-controllable) +- **Arming forbidden:** Slow red pulse (not user-controllable) +- **Arming allowed:** Green pulse (not user-controllable) + +## Example: minimal connection in Dart pseudocode + +```dart +// Scan +final device = await ble.scanForDevice(name: 'AirLED'); + +// Connect +final connection = await ble.connect(device.id); + +// Discover NUS service +final rx = QualifiedCharacteristic( + serviceId: Uuid.parse('6e400001-b5a3-f393-e0a9-e50e24dcca9e'), + characteristicId: Uuid.parse('6e400002-b5a3-f393-e0a9-e50e24dcca9e'), + deviceId: device.id, +); +final tx = QualifiedCharacteristic( + serviceId: Uuid.parse('6e400001-b5a3-f393-e0a9-e50e24dcca9e'), + characteristicId: Uuid.parse('6e400003-b5a3-f393-e0a9-e50e24dcca9e'), + deviceId: device.id, +); + +// Subscribe to notifications and reassemble +final buffer = StringBuffer(); +ble.subscribeToCharacteristic(tx).listen((bytes) { + buffer.write(utf8.decode(bytes)); + while (buffer.toString().contains('\n')) { + final str = buffer.toString(); + final idx = str.indexOf('\n'); + final message = str.substring(0, idx); + buffer.clear(); + buffer.write(str.substring(idx + 1)); + handleMessage(message); // parse JSON or ack/error + } +}); + +// Request initial state +await ble.writeCharacteristic(rx, + value: utf8.encode('{"GetState":null}\n')); + +// Send a command +await ble.writeCharacteristic(rx, + value: utf8.encode('{"SetBrightness":{"value":128}}\n')); +``` diff --git a/src/bin/main.rs b/src/bin/main.rs index 0020971..3f894d8 100644 --- a/src/bin/main.rs +++ b/src/bin/main.rs @@ -692,29 +692,25 @@ async fn msp_task(mut uart: Uart<'static, esp_hal::Async>) { ); } - // AUX7 (channel 11, index 10) 3-position strobe trigger - // ~1100 = off, ~1400 = low, ~1900 = full - if count >= 11 { - let ch = rc_channels[10]; - let strobe_level: u8 = if ch > 1650 { - 255 // full - } else if ch > 1250 { - 80 // low + // AUX7 (channel 11, index 10) 3-position strobe + // AUX8 (channel 12, index 11) spring switch override → full + // Skip first 3 RC polls to avoid garbage triggering strobe at boot + if count >= 12 && rc_tick > 3 { + let aux7 = rc_channels[10]; + let aux8 = rc_channels[11]; + let strobe_level: u8 = if aux8 > 1800 { + 255 // AUX8 spring switch → full blast + } else if aux7 > 1650 { + 255 // AUX7 position 3 → full + } else if aux7 > 1250 { + 80 // AUX7 position 2 → low } else { 0 // off }; - let prev_ch = prev_aux[6]; - let prev_level: u8 = if prev_ch > 1650 { - 255 - } else if prev_ch > 1250 { - 80 - } else { - 0 - }; - if strobe_level != prev_level { - info!("MSP AUX7 strobe: {}", strobe_level); - } let mut state = STATE.lock().await; + if state.aux_strobe != strobe_level { + info!("MSP strobe: {}", strobe_level); + } state.aux_strobe = strobe_level; } From 90c4f4787b3d752f6a72758b1ed0ab668c5a3b96 Mon Sep 17 00:00:00 2001 From: Moritz Riede Date: Wed, 18 Feb 2026 03:00:29 +0100 Subject: [PATCH 4/4] feat: continuous FC pulse, 10s strobe guard, and full-buffer LED writes - Merge arming forbidden/allowed into single continuous fc_pulse (50% floor) so the breathing curve is uninterrupted when arming state changes - Suppress strobe for first 10s after boot to avoid garbage RC triggers - Write full 200-LED buffer every frame, clearing tail beyond num_leds so BLE num_leds changes immediately turn off unused LEDs --- src/bin/main.rs | 40 ++++++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/src/bin/main.rs b/src/bin/main.rs index 3f894d8..4d9479f 100644 --- a/src/bin/main.rs +++ b/src/bin/main.rs @@ -694,8 +694,9 @@ async fn msp_task(mut uart: Uart<'static, esp_hal::Async>) { // AUX7 (channel 11, index 10) 3-position strobe // AUX8 (channel 12, index 11) spring switch override → full - // Skip first 3 RC polls to avoid garbage triggering strobe at boot - if count >= 12 && rc_tick > 3 { + // Suppress strobe for first 10s after boot to avoid garbage triggers + let uptime_ms = embassy_time::Instant::now().as_millis(); + if count >= 12 && uptime_ms > 10_000 { let aux7 = rc_channels[10]; let aux8 = rc_channels[11]; let strobe_level: u8 = if aux8 > 1800 { @@ -775,8 +776,8 @@ async fn led_task(spi_bus: SpiDmaBus<'static, esp_hal::Blocking>) { let mut ws = Ws2812::new(spi_bus, &mut ws_buf); let mut pulse = Pulse::new(); - let mut slow_pulse = Pulse::new(); - slow_pulse.set_params(400, 0.1); + let mut fc_pulse = Pulse::new(); + fc_pulse.set_params(400, 0.5); let mut ripple = RippleEffect::new(0xDEAD_BEEF); let mut static_anim = StaticAnim; @@ -830,6 +831,10 @@ async fn led_task(spi_bus: SpiDmaBus<'static, esp_hal::Blocking>) { let aux_strobe = state.aux_strobe; drop(state); + // Clear LEDs beyond active count so they don't hold stale colors + for led in buf[num_leds..].iter_mut() { + *led = RGB8 { r: 0, g: 0, b: 0 }; + } let active = &mut buf[..num_leds]; // AUX7 strobe override: fast white strobe (~25 Hz) with short attack/decay @@ -850,7 +855,7 @@ async fn led_task(spi_bus: SpiDmaBus<'static, esp_hal::Blocking>) { *led = color; } - match ws.write(active.iter().copied()) { + match ws.write(buf.iter().copied()) { Err(e) if !write_err_logged => { defmt::warn!("LED write error: {}", defmt::Debug2Format(&e)); write_err_logged = true; @@ -891,7 +896,7 @@ async fn led_task(spi_bus: SpiDmaBus<'static, esp_hal::Blocking>) { flash_remaining -= 1; // Skip normal rendering and post-processing — write directly - match ws.write(active.iter().copied()) { + match ws.write(buf.iter().copied()) { Err(e) if !write_err_logged => { defmt::warn!("LED write error: {}", defmt::Debug2Format(&e)); write_err_logged = true; @@ -908,23 +913,22 @@ async fn led_task(spi_bus: SpiDmaBus<'static, esp_hal::Blocking>) { } // Flight-mode override logic: - // - Armed → rainbow show cycle + // - Armed → rainbow ripple // - Failsafe → sliding red bars - // - Arming forbidden (FC connected) → slow red pulse - // - Arming allowed (FC connected) → solid green pulse + // - Disarmed (FC connected) → continuous pulse, red=forbidden / green=allowed // - No FC → user-selected pattern from web UI if fc_connected && flight_mode == FlightMode::Armed { armed_ripple.render(active, &mut armed_scheme); } else if fc_connected && flight_mode == FlightMode::Failsafe { render_failsafe(active, frame_counter); - } else if fc_connected && flight_mode == FlightMode::ArmingForbidden { - // FC connected, arming forbidden: slow red pulse - let mut red_scheme = ColorScheme::Solid(RGB8 { r: 204, g: 0, b: 0 }); - slow_pulse.render(active, &mut red_scheme); - } else if fc_connected && flight_mode == FlightMode::ArmingAllowed { - // FC connected, disarmed: green pulse with debug overlay - let mut green_scheme = ColorScheme::Solid(RGB8 { r: 0, g: 204, b: 0 }); - pulse.render(active, &mut green_scheme); + } else if fc_connected && (flight_mode == FlightMode::ArmingForbidden || flight_mode == FlightMode::ArmingAllowed) { + // FC connected, disarmed: continuous pulse, color indicates arming state + let mut scheme = if flight_mode == FlightMode::ArmingForbidden { + ColorScheme::Solid(RGB8 { r: 204, g: 0, b: 0 }) + } else { + ColorScheme::Solid(RGB8 { r: 0, g: 204, b: 0 }) + }; + fc_pulse.render(active, &mut scheme); // Debug: first 33 LEDs show flag bits (compile-time flag) if MSP_DEBUG_LEDS { @@ -988,7 +992,7 @@ async fn led_task(spi_bus: SpiDmaBus<'static, esp_hal::Blocking>) { ]; apply_pipeline(active, &pipeline); - match ws.write(active.iter().copied()) { + match ws.write(buf.iter().copied()) { Err(e) if !write_err_logged => { defmt::warn!("LED write error: {}", defmt::Debug2Format(&e)); write_err_logged = true;