diff --git a/CHANGELOG.md b/CHANGELOG.md index bdceb1f..8a74de1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,25 +7,41 @@ and this project adheres to [Conventional Commits](https://www.conventionalcommi ## [Unreleased] +### Changed + +- BLE protocol replaced with compact binary encoding (22-byte state snapshot, 1–4 byte commands) — see `docs/binary_protocol.md` +- State snapshots now fit in 1–2 BLE packets (down from ~15 with JSON) +- Setter acks reduced to a single byte (`0x00` OK / `0xE0` parse / `0xE1` range) +- RX write callback processes the full BLE write as a complete command (no newline framing) +- BLE TX buffer shrunk from 512 to 32 bytes; RX reassembly buffer removed entirely + ### Added -- TX link detection via RC stick channels — if all 4 sticks (AETR) read exactly 1500 µs, no TX is bound; strobe only activates when TX is linked -- `tx_linked` field exposed in BLE `StateResponse` for app display +- `TestPattern` command (`0xF0`): triggers a 5-second solid-color episode (red/green/blue/white) at full intensity, bypassing all post-processing +- `GetVersion` command returning protocol version + firmware semver +- TX link detection via RC stick channels (±10 deadband around 1500 µs center) +- `tx_linked` field exposed in BLE state snapshot for app display + +### Fixed + +- Phantom strobe activation on bench: gate AUX strobe on armed/arming-allowed flight mode so default RC channel values (~1500) can't trigger it with no TX powered on +- TX link detection: use ±10 deadband around 1500 instead of exact comparison to tolerate FC jitter +- UART desync: drain stale RX bytes before each MSP_RC poll to prevent misreads after MSP_STATUS timeout ### Removed +- JSON-over-NUS BLE protocol (`Command` enum, `StateResponse` struct, serde-based parsing/serialization) +- `serde`, `serde_json_core`, and `heapless` dependencies - Wi-Fi AP hotspot, HTTP web UI, and DHCP server (BLE is now the sole control interface) - `embassy-net`, `smoltcp`, `edge-dhcp`, and `embedded-io` dependencies - `wifi` and `coex` features from `esp-radio` (no longer needed without Wi-Fi) ### 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 Nordic UART Service (NUS) for app control +- `ble` module (`src/ble.rs`): binary command parser, state encoder, version encoder — 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 e64789c..e78b6c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1056,7 +1056,6 @@ checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" dependencies = [ "defmt 0.3.100", "hash32", - "serde", "stable_deref_trait", ] @@ -1520,17 +1519,6 @@ 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" @@ -1910,11 +1898,8 @@ dependencies = [ "esp-radio", "esp-rtos", "futures", - "heapless 0.8.0", "panic-rtt-target", "rtt-target", - "serde", - "serde-json-core", "smart-leds", "static_cell", "ws2812-spi", diff --git a/Cargo.toml b/Cargo.toml index 909c770..78181cc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,10 +41,7 @@ esp-radio = { version = "0.17.0", features = [ "esp32c3", "unstable", ] } -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/docs/binary_protocol.md b/docs/binary_protocol.md new file mode 100644 index 0000000..73eed0e --- /dev/null +++ b/docs/binary_protocol.md @@ -0,0 +1,235 @@ +# AirLED Binary BLE Protocol v1 + +> Compact binary protocol for BLE communication between the AirLED controller +> (ESP32-C3) and a companion app. Replaces the previous JSON-over-NUS protocol. + +## Design Goals + +- **Compact**: Full state snapshot in a single BLE write (no chunking) +- **Simple**: Fixed-size commands, no framing or escaping needed +- **Extensible**: Version byte + reserved space for future fields +- **Zero-copy friendly**: All multi-byte values are little-endian, naturally aligned + +## Transport + +| Property | Value | +|-------------------|--------------------------------------------| +| BLE Service | Nordic UART Service (NUS) — unchanged | +| RX Characteristic | `6e400002-...` (app → device, Write) | +| TX Characteristic | `6e400003-...` (device → app, Notify) | +| Byte order | Little-endian throughout | +| Integrity | Delegated to BLE link layer (CRC + retx) | + +## Frame Format + +Every frame (command or response) starts with a 1-byte header: + +``` +┌─────────┐ +│ CMD: u8 │ Payload (0–N bytes, size determined by CMD) +└─────────┘ +``` + +Each command ID implies a fixed payload length — no length field needed. + +--- + +## Commands (App → Device) + +| ID | Name | Payload | Size | Description | +|------|--------------------|----------------------|------|-----------------------------------| +| 0x01 | `GetState` | — | 1 | Request full state snapshot | +| 0x02 | `GetVersion` | — | 1 | Request firmware/protocol version | +| 0x10 | `SetBrightness` | `u8` | 2 | 0–255 | +| 0x11 | `SetNumLeds` | `u16` | 3 | 1–200 | +| 0x12 | `SetFps` | `u8` | 2 | 1–150 | +| 0x13 | `SetMaxCurrent` | `u16` | 3 | 100–2500 mA | +| 0x14 | `SetColorMode` | `u8` | 2 | See Color Mode table | +| 0x15 | `SetAnimMode` | `u8` | 2 | See Anim Mode table | +| 0x16 | `SetColorBalance` | `u8, u8, u8` | 4 | R, G, B scaling (0–255 each) | +| 0x17 | `SetUseHsi` | `u8` | 2 | 0 = HSV, 1 = HSI | +| 0x18 | `SetHueSpeed` | `u8` | 2 | 1–10 (rainbow only) | +| 0x19 | `SetPulseSpeed` | `u16` | 3 | 100–2000 ms period | +| 0x1A | `SetPulseMinBrt` | `u8` | 2 | 0–80 (%) | +| 0x1B | `SetRippleSpeed` | `u8` | 2 | 5–50 (×0.1 fixed-point) | +| 0x1C | `SetRippleWidth` | `u8` | 2 | 10–255 (×0.1 fixed-point) | +| 0x1D | `SetRippleDecay` | `u8` | 2 | 90–99 (%) | +| 0xF0 | `TestPattern` | `u8` | 2 | 5 s solid color (see table below) | + +Command IDs are grouped: +- `0x01–0x0F` — queries +- `0x10–0x3F` — setters (user-configurable fields) +- `0xF0–0xFF` — system commands + +### Test Pattern Color (`u8`) + +| Value | Color | +|-------|--------| +| 0 | Red | +| 1 | Green | +| 2 | Blue | +| 3 | White | + +Triggers a 5-second solid-color episode at full intensity, bypassing all +post-processing (gamma, color balance, brightness, current limit). +A new `TestPattern` command restarts the 5-second timer. + +--- + +## Responses (Device → App) + +### Ack / Error (1 byte) + +Sent after every setter or system command: + +| ID | Name | Meaning | +|------|--------------|----------------------------------| +| 0x00 | `Ok` | Command accepted | +| 0xE0 | `ErrParse` | Unknown or malformed command | +| 0xE1 | `ErrRange` | Value out of valid range | +| 0xE2 | `ErrBusy` | Device busy (e.g. flash write) | + +### State Snapshot (response to `GetState` or unsolicited push) + +Response ID: **0x01** + +``` +Offset Size Field Notes +───────────────────────────────────────────────────── + 0 1 response_id 0x01 + 1 1 protocol_version 1 + 2 1 brightness 0–255 + 3 2 num_leds u16 LE, 1–200 + 5 1 fps 1–150 + 6 2 max_current_ma u16 LE, 100–2500 + 8 1 color_mode enum (see table) + 9 1 anim_mode enum (see table) +10 1 color_bal_r 0–255 +11 1 color_bal_g 0–255 +12 1 color_bal_b 0–255 +13 1 use_hsi 0 or 1 +14 1 hue_speed 1–10 +15 2 pulse_speed u16 LE, 100–2000 +17 1 pulse_min_brt 0–80 +18 1 ripple_speed 5–50 +19 1 ripple_width 10–255 +20 1 ripple_decay 90–99 +21 1 flags bitfield (see below) +───────────────────────────────────────────────────── +Total: 22 bytes +``` + +**Flags byte (offset 21)**: + +``` +Bit Field +─────────────────── + 0 fc_connected + 1 tx_linked + 2 armed + 3 failsafe + 4 arming_allowed +5–7 reserved (0) +``` + +Flight mode is encoded in bits 2–4: + +| armed | failsafe | arming_allowed | Meaning | +|-------|----------|----------------|--------------------| +| 0 | 0 | 0 | Arming forbidden | +| 0 | 0 | 1 | Arming allowed | +| 0 | 1 | x | Failsafe | +| 1 | 0 | x | Armed | + +When `fc_connected = 0`, bits 2–4 should be ignored by the app. + +### Version Response (response to `GetVersion`) + +Response ID: **0x02** + +``` +Offset Size Field Notes +───────────────────────────────────────────────────── + 0 1 response_id 0x02 + 1 1 protocol_version 1 + 2 1 fw_major Firmware semver major + 3 1 fw_minor Firmware semver minor + 4 1 fw_patch Firmware semver patch +───────────────────────────────────────────────────── +Total: 5 bytes +``` + +--- + +## Enum Encodings + +### Color Mode (`u8`) + +| Value | Mode | +|-------|---------------| +| 0 | `solid_green` | +| 1 | `solid_red` | +| 2 | `split` | +| 3 | `rainbow` | + +### Animation Mode (`u8`) + +| Value | Mode | +|-------|----------| +| 0 | `static` | +| 1 | `pulse` | +| 2 | `ripple` | + +--- + +## State Push + +The device pushes the full 22-byte state snapshot (ID `0x01`) over the TX +characteristic whenever the MSP task detects a flight-mode change. This is +the same format as the `GetState` response — the app does not need to poll. + +--- + +## Size Comparison + +| Metric | JSON protocol | Binary protocol | +|-----------------------|---------------|-----------------| +| State snapshot | ~300 bytes | 22 bytes | +| BLE chunks needed* | 15 | 2 | +| Setter command | 20–40 bytes | 2–4 bytes | +| Ack response | 3–30 bytes | 1 byte | + +*At default 20-byte MTU. With negotiated 23+ byte MTU, binary state fits in 1 ATT packet. + +--- + +## Examples + +### Set brightness to 128 + +``` +App → Device: [0x10, 0x80] (2 bytes) +Device → App: [0x00] (1 byte: Ok) +``` + +### Set color balance to (255, 200, 220) + +``` +App → Device: [0x16, 0xFF, 0xC8, 0xDC] (4 bytes) +Device → App: [0x00] (1 byte: Ok) +``` + +### Request full state + +``` +App → Device: [0x01] (1 byte) +Device → App: [0x01, 0x01, 0xFF, 0xB4, 0x00, 0x64, 0xD0, 0x07, + 0x02, 0x01, 0xFF, 0xB4, 0xF0, 0x00, 0x01, 0x58, + 0x02, 0x2A, 0x0F, 0xBE, 0x61, 0x00] + (22 bytes) +``` + +Decoded: brightness=255, num_leds=180, fps=100, max_current=2000, +color_mode=split, anim_mode=pulse, bal=(255,180,240), hsi=off, +hue_speed=1, pulse_speed=600, pulse_min=42, ripple_speed=15, +ripple_width=190, ripple_decay=97, flags=0x00 (FC disconnected). diff --git a/src/bin/main.rs b/src/bin/main.rs index 266ef24..204ca45 100644 --- a/src/bin/main.rs +++ b/src/bin/main.rs @@ -31,11 +31,10 @@ 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::ble::{self as ble_proto, HandleResult}; use xiao_drone_led_controller::state::{ AnimMode, AnimModeParams, BLE_FLASH, ColorMode, FlightMode, STATE, STATE_CHANGED, + TEST_PATTERN, }; use static_cell::StaticCell; @@ -127,28 +126,16 @@ fn main() -> ! { 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], + data: [u8; 32], 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], + data: [0; 32], len: 0, offset: 0, })); @@ -159,44 +146,29 @@ static BLE_NOTIFY: embassy_sync::signal::Signal< (), > = embassy_sync::signal::Signal::new(); -/// Process a complete command message from the RX buffer. +/// Process a binary command from the RX write callback. /// /// 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; - }; +fn ble_handle_message(data: &[u8]) { + defmt::info!("BLE RX: {} bytes, cmd=0x{:02x}", data.len(), if data.is_empty() { 0 } else { data[0] }); // 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); + let result = ble_proto::handle_binary_command(data, &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); + tx.len = ble_proto::encode_state(&state, &mut tx.data); } - HandleResult::Ack => { - tx.data[..3].copy_from_slice(b"ok\n"); - tx.len = 3; + HandleResult::SendVersion => { + tx.len = ble_proto::encode_version(&mut tx.data); } - 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; + HandleResult::Ack(code) => { + tx.data[0] = code; + tx.len = 1; } } }); @@ -207,7 +179,7 @@ fn ble_handle_message(msg: &[u8]) { /// 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. +/// Commands arrive as binary on RX; responses go out as notifications on TX. #[embassy_executor::task] async fn ble_task(mut connector: BleConnector<'static>) { info!("BLE task started"); @@ -216,10 +188,8 @@ async fn ble_task(mut connector: BleConnector<'static>) { let mut ble = Ble::new(&mut connector, current_millis); loop { - // Reset buffers between connections + // Reset TX buffer 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; @@ -278,6 +248,8 @@ async fn ble_task(mut connector: BleConnector<'static>) { // Write callback for NUS RX characteristic (sync — runs inside do_work) let mut rx_wf = |_offset: usize, data: &[u8]| { + defmt::info!("BLE RX: {} bytes", data.len()); + // Flash blue on first write (= real client connection confirmed) critical_section::with(|cs| { if !BLE_CONNECTED.borrow(cs).get() { @@ -287,35 +259,8 @@ async fn ble_task(mut connector: BleConnector<'static>) { } }); - 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]); - } + // Binary protocol: each BLE write is a complete command (no framing) + ble_handle_message(data); }; // Read callback for NUS TX (unused — we use notifications) @@ -385,16 +330,14 @@ async fn ble_task(mut connector: BleConnector<'static>) { // Command response queued — loop back to send chunks } futures::future::Either::Right(_) => { - // State changed — snapshot and queue + // State changed — encode and queue binary snapshot 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.len = ble_proto::encode_state(&state, &mut tx.data); tx.offset = 0; }); + drop(state); // Loop back to send chunks } } @@ -414,6 +357,14 @@ async fn ble_task(mut connector: BleConnector<'static>) { } } +/// Drain any stale bytes from the UART RX buffer. +/// +/// Reads with a 1 ms timeout until no more data arrives, discarding everything. +async fn drain_uart(uart: &mut Uart<'static, esp_hal::Async>) { + let mut byte = [0u8; 1]; + while let Ok(Ok(_)) = with_timeout(Duration::from_millis(1), AsyncRead::read(uart, &mut byte)).await {} +} + /// 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>, @@ -505,6 +456,11 @@ async fn msp_task(mut uart: Uart<'static, esp_hal::Async>) { let mut rc_tick: u8 = 0; let mut logged_rc_once = false; let mut tx_linked = false; + // Strobe is only allowed when the FC reports Armed or ArmingAllowed. + // This is critical: without this gate, the FC may report default RC + // channel values (~1500) that fall inside the AUX7 strobe threshold, + // causing phantom strobe activation on the bench with no TX powered on. + let mut strobe_allowed = false; loop { // Retry box map if we never got it (FC wasn't ready at startup) @@ -579,6 +535,7 @@ async fn msp_task(mut uart: Uart<'static, esp_hal::Async>) { state.fc_connected = true; state.flight_mode = mode; state.debug_flags = flags; + strobe_allowed = matches!(mode, FlightMode::Armed | FlightMode::ArmingAllowed); drop(state); if changed { STATE_CHANGED.signal(()); @@ -601,6 +558,7 @@ async fn msp_task(mut uart: Uart<'static, esp_hal::Async>) { state.aux_strobe = 0; state.tx_linked = false; tx_linked = false; + strobe_allowed = false; drop(state); // Reset counter to avoid spamming state writes every tick error_count = 10; @@ -609,6 +567,8 @@ async fn msp_task(mut uart: Uart<'static, esp_hal::Async>) { // Poll RC channels every tick with short timeout rc_tick = rc_tick.wrapping_add(1); { + // Drain any stale bytes left over from a timed-out MSP_STATUS exchange + drain_uart(&mut uart).await; 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) = @@ -632,12 +592,14 @@ async fn msp_task(mut uart: Uart<'static, esp_hal::Async>) { } // TX link detection: when no TX is bound all 4 stick - // channels (AETR) sit at exactly 1500 µs. + // channels (AETR) sit near 1500 µs. Use a deadband to + // tolerate jitter/rounding from the FC. if count >= 4 { - let linked = !(rc_channels[0] == 1500 - && rc_channels[1] == 1500 - && rc_channels[2] == 1500 - && rc_channels[3] == 1500); + let near_center = |ch: u16| (1490..=1510).contains(&ch); + let linked = !(near_center(rc_channels[0]) + && near_center(rc_channels[1]) + && near_center(rc_channels[2]) + && near_center(rc_channels[3])); if linked != tx_linked { info!("MSP: TX link {}", if linked { "up" } else { "down" }); tx_linked = linked; @@ -653,15 +615,13 @@ 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 - // Suppress strobe for first 10s after boot and when TX is not linked + // Only active when armed, TX linked, and past boot guard let uptime_ms = embassy_time::Instant::now().as_millis(); - if count >= 12 && uptime_ms > 10_000 && tx_linked { + if count >= 12 && uptime_ms > 10_000 && tx_linked && strobe_allowed { 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 + let strobe_level: u8 = if aux8 > 1800 || aux7 > 1650 { + 255 // AUX8 spring switch or AUX7 position 3 → full } else if aux7 > 1250 { 80 // AUX7 position 2 → low } else { @@ -755,6 +715,10 @@ async fn led_task(spi_bus: SpiDmaBus<'static, esp_hal::Blocking>) { let mut flash_count: u8 = 0; let mut flash_total_frames: u32 = 0; + // Test pattern state: wall-clock deadline, solid color to display + let mut test_deadline: Option = None; + let mut test_color = RGB8 { r: 0, g: 0, b: 0 }; + loop { // Check for new BLE flash request if let Some(count) = BLE_FLASH.try_take() { @@ -763,6 +727,17 @@ async fn led_task(spi_bus: SpiDmaBus<'static, esp_hal::Blocking>) { flash_remaining = u32::MAX; // sentinel — set properly after FPS read } + // Check for new test pattern request + if let Some(color_code) = TEST_PATTERN.try_take() { + test_color = match color_code { + 0 => RGB8 { r: 255, g: 0, b: 0 }, + 1 => RGB8 { r: 0, g: 255, b: 0 }, + 2 => RGB8 { r: 0, g: 0, b: 255 }, + _ => RGB8 { r: 255, g: 255, b: 255 }, + }; + test_deadline = Some(embassy_time::Instant::now() + Duration::from_secs(5)); + } + let state = STATE.lock().await; let num_leds = state.num_leds.min(MAX_NUM_LEDS) as usize; let led_brightness = state.brightness; @@ -871,6 +846,28 @@ async fn led_task(spi_bus: SpiDmaBus<'static, esp_hal::Blocking>) { continue; } + // Test pattern override: solid color for 5 seconds, no post-processing + if test_deadline.is_some_and(|d| embassy_time::Instant::now() < d) { + for led in active.iter_mut() { + *led = test_color; + } + + match ws.write(buf.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 ripple // - Failsafe → sliding red bars diff --git a/src/ble.rs b/src/ble.rs index 05dccfe..4a77cd7 100644 --- a/src/ble.rs +++ b/src/ble.rs @@ -1,300 +1,317 @@ -//! BLE Nordic UART Service (NUS) protocol layer. +//! BLE Nordic UART Service (NUS) binary 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. +//! Implements the AirLED Binary BLE Protocol v1 (see `docs/binary_protocol.md`). +//! Commands are received as compact binary frames on the RX characteristic, +//! responses are sent as binary on the TX characteristic. -use heapless::String as HString; -use serde::{Deserialize, Serialize}; - -use crate::state::{AnimMode, AnimModeParams, ColorMode, FlightMode, LedState}; +use crate::state::{AnimMode, AnimModeParams, ColorMode, FlightMode, LedState, TEST_PATTERN}; /// Maximum number of active LEDs (mirrors `MAX_LEDS` in main). const MAX_NUM_LEDS: u16 = 200; -// --------------------------------------------------------------------------- -// Command (app → ESP, deserialize from JSON) -// --------------------------------------------------------------------------- +/// Current protocol version. +const PROTOCOL_VERSION: u8 = 1; -/// 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 }, -} +/// Firmware version (from Cargo.toml). +const FW_MAJOR: u8 = 0; +const FW_MINOR: u8 = 1; +const FW_PATCH: u8 = 0; // --------------------------------------------------------------------------- -// Response (ESP → app, serialize to JSON) +// Command IDs (app → device) // --------------------------------------------------------------------------- -/// 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, - pub tx_linked: bool, -} +const CMD_GET_STATE: u8 = 0x01; +const CMD_GET_VERSION: u8 = 0x02; +const CMD_SET_BRIGHTNESS: u8 = 0x10; +const CMD_SET_NUM_LEDS: u8 = 0x11; +const CMD_SET_FPS: u8 = 0x12; +const CMD_SET_MAX_CURRENT: u8 = 0x13; +const CMD_SET_COLOR_MODE: u8 = 0x14; +const CMD_SET_ANIM_MODE: u8 = 0x15; +const CMD_SET_COLOR_BALANCE: u8 = 0x16; +const CMD_SET_USE_HSI: u8 = 0x17; +const CMD_SET_HUE_SPEED: u8 = 0x18; +const CMD_SET_PULSE_SPEED: u8 = 0x19; +const CMD_SET_PULSE_MIN_BRT: u8 = 0x1A; +const CMD_SET_RIPPLE_SPEED: u8 = 0x1B; +const CMD_SET_RIPPLE_WIDTH: u8 = 0x1C; +const CMD_SET_RIPPLE_DECAY: u8 = 0x1D; +const CMD_TEST_PATTERN: u8 = 0xF0; // --------------------------------------------------------------------------- -// Mapping helpers +// Response codes (device → app) // --------------------------------------------------------------------------- -/// 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, - } -} +/// Command accepted. +pub const RSP_OK: u8 = 0x00; +/// State snapshot response ID. +pub const RSP_STATE: u8 = 0x01; +/// Version response ID. +pub const RSP_VERSION: u8 = 0x02; +/// Unknown or malformed command. +pub const RSP_ERR_PARSE: u8 = 0xE0; +/// Value out of valid range. +pub const RSP_ERR_RANGE: u8 = 0xE1; // --------------------------------------------------------------------------- -// State snapshot +// Result type // --------------------------------------------------------------------------- -/// 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), - tx_linked: state.tx_linked, - } +/// Result of handling a binary command. +pub enum HandleResult { + /// Send the full state snapshot (22 bytes). + SendState, + /// Send the version response (5 bytes). + SendVersion, + /// Send a 1-byte ack/error code. + Ack(u8), } // --------------------------------------------------------------------------- -// Command handling +// Command parsing + handling (combined) // --------------------------------------------------------------------------- -/// 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), -} +/// Parse and apply a binary command to the shared [`LedState`]. +/// +/// Returns what response to send, or a 1-byte error code on failure. +pub fn handle_binary_command(data: &[u8], state: &mut LedState) -> HandleResult { + if data.is_empty() { + return HandleResult::Ack(RSP_ERR_PARSE); + } -/// 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 + match data[0] { + CMD_GET_STATE => HandleResult::SendState, + CMD_GET_VERSION => HandleResult::SendVersion, + + CMD_SET_BRIGHTNESS if data.len() >= 2 => { + state.brightness = data[1]; + HandleResult::Ack(RSP_OK) } - Command::SetFps { value } => { - state.fps = (*value).clamp(1, 150); - HandleResult::Ack + CMD_SET_NUM_LEDS if data.len() >= 3 => { + let val = u16::from_le_bytes([data[1], data[2]]); + if !(1..=MAX_NUM_LEDS).contains(&val) { + return HandleResult::Ack(RSP_ERR_RANGE); + } + state.num_leds = val; + HandleResult::Ack(RSP_OK) } - Command::SetMaxCurrent { value } => { - state.max_current_ma = (*value).clamp(100, 2500); - HandleResult::Ack + CMD_SET_FPS if data.len() >= 2 => { + let val = data[1]; + if !(1..=150).contains(&val) { + return HandleResult::Ack(RSP_ERR_RANGE); + } + state.fps = val; + HandleResult::Ack(RSP_OK) } - Command::SetColorMode { mode } => match parse_color_mode(mode.as_str()) { - Some(m) => { - state.color_mode = m; - HandleResult::Ack + CMD_SET_MAX_CURRENT if data.len() >= 3 => { + let val = u16::from_le_bytes([data[1], data[2]]); + if !(100..=2500).contains(&val) { + return HandleResult::Ack(RSP_ERR_RANGE); } - 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 + state.max_current_ma = val as u32; + HandleResult::Ack(RSP_OK) + } + CMD_SET_COLOR_MODE if data.len() >= 2 => { + let mode = match data[1] { + 0 => ColorMode::SolidGreen, + 1 => ColorMode::SolidRed, + 2 => ColorMode::Split, + 3 => ColorMode::Rainbow, + _ => return HandleResult::Ack(RSP_ERR_RANGE), + }; + state.color_mode = mode; + HandleResult::Ack(RSP_OK) + } + CMD_SET_ANIM_MODE if data.len() >= 2 => { + let mode = match data[1] { + 0 => AnimMode::Static, + 1 => AnimMode::Pulse, + 2 => AnimMode::Ripple, + _ => return HandleResult::Ack(RSP_ERR_RANGE), + }; + if mode != state.anim_mode { + state.anim_mode = mode; + state.anim_params = AnimModeParams::default_for(mode); } - 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 + HandleResult::Ack(RSP_OK) + } + CMD_SET_COLOR_BALANCE if data.len() >= 4 => { + state.color_bal_r = data[1]; + state.color_bal_g = data[2]; + state.color_bal_b = data[3]; + HandleResult::Ack(RSP_OK) } - Command::SetUseHsi { value } => { - state.use_hsi = *value; - HandleResult::Ack + CMD_SET_USE_HSI if data.len() >= 2 => { + state.use_hsi = data[1] != 0; + HandleResult::Ack(RSP_OK) } - Command::SetHueSpeed { value } => { - state.color_params.hue_speed = (*value).clamp(1, 10); - HandleResult::Ack + CMD_SET_HUE_SPEED if data.len() >= 2 => { + let val = data[1]; + if !(1..=10).contains(&val) { + return HandleResult::Ack(RSP_ERR_RANGE); + } + state.color_params.hue_speed = val; + HandleResult::Ack(RSP_OK) } - Command::SetPulseSpeed { value } => { + CMD_SET_PULSE_SPEED if data.len() >= 3 => { + let val = u16::from_le_bytes([data[1], data[2]]); + if !(100..=2000).contains(&val) { + return HandleResult::Ack(RSP_ERR_RANGE); + } if let AnimModeParams::Pulse { speed, .. } = &mut state.anim_params { - *speed = (*value).clamp(100, 2000); + *speed = val; } - HandleResult::Ack + HandleResult::Ack(RSP_OK) } - Command::SetPulseMinBrightness { value } => { + CMD_SET_PULSE_MIN_BRT if data.len() >= 2 => { + let val = data[1]; + if val > 80 { + return HandleResult::Ack(RSP_ERR_RANGE); + } if let AnimModeParams::Pulse { min_intensity_pct, .. } = &mut state.anim_params { - *min_intensity_pct = (*value).min(80); + *min_intensity_pct = val; } - HandleResult::Ack + HandleResult::Ack(RSP_OK) } - Command::SetRippleSpeed { value } => { + CMD_SET_RIPPLE_SPEED if data.len() >= 2 => { + let val = data[1]; + if !(5..=50).contains(&val) { + return HandleResult::Ack(RSP_ERR_RANGE); + } if let AnimModeParams::Ripple { speed_x10, .. } = &mut state.anim_params { - *speed_x10 = (*value).clamp(5, 50); + *speed_x10 = val; } - HandleResult::Ack + HandleResult::Ack(RSP_OK) } - Command::SetRippleWidth { value } => { + CMD_SET_RIPPLE_WIDTH if data.len() >= 2 => { + let val = data[1]; + if val < 10 { + return HandleResult::Ack(RSP_ERR_RANGE); + } if let AnimModeParams::Ripple { width_x10, .. } = &mut state.anim_params { - *width_x10 = (*value).clamp(10, 255); + *width_x10 = val; } - HandleResult::Ack + HandleResult::Ack(RSP_OK) } - Command::SetRippleDecay { value } => { + CMD_SET_RIPPLE_DECAY if data.len() >= 2 => { + let val = data[1]; + if !(90..=99).contains(&val) { + return HandleResult::Ack(RSP_ERR_RANGE); + } if let AnimModeParams::Ripple { decay_pct, .. } = &mut state.anim_params { - *decay_pct = (*value).clamp(90, 99); + *decay_pct = val; } - HandleResult::Ack + HandleResult::Ack(RSP_OK) } + + CMD_TEST_PATTERN if data.len() >= 2 => { + if data[1] > 3 { + return HandleResult::Ack(RSP_ERR_RANGE); + } + TEST_PATTERN.signal(data[1]); + HandleResult::Ack(RSP_OK) + } + + // Known command ID but insufficient payload bytes + CMD_SET_BRIGHTNESS | CMD_SET_FPS | CMD_SET_COLOR_MODE | CMD_SET_ANIM_MODE + | CMD_SET_USE_HSI | CMD_SET_HUE_SPEED | CMD_SET_PULSE_MIN_BRT + | CMD_SET_RIPPLE_SPEED | CMD_SET_RIPPLE_WIDTH | CMD_SET_RIPPLE_DECAY + | CMD_TEST_PATTERN => { + HandleResult::Ack(RSP_ERR_PARSE) + } + CMD_SET_NUM_LEDS | CMD_SET_MAX_CURRENT | CMD_SET_PULSE_SPEED + | CMD_SET_COLOR_BALANCE => HandleResult::Ack(RSP_ERR_PARSE), + + _ => HandleResult::Ack(RSP_ERR_PARSE), } } // --------------------------------------------------------------------------- -// Serialization helpers +// State encoding // --------------------------------------------------------------------------- -/// Parse a JSON command from a byte slice. +/// Encode the full state snapshot into `buf` (must be >= 22 bytes). /// -/// 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 +/// Returns the number of bytes written (always 22). +pub fn encode_state(state: &LedState, buf: &mut [u8]) -> usize { + let (pulse_speed, pulse_min_brt) = match state.anim_params { + AnimModeParams::Pulse { + speed, + min_intensity_pct, + } => (speed, min_intensity_pct), + _ => (600, 42), }; - serde_json_core::from_slice::(data).ok().map(|(cmd, _)| cmd) + 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), + }; + + let color_mode: u8 = match state.color_mode { + ColorMode::SolidGreen => 0, + ColorMode::SolidRed => 1, + ColorMode::Split => 2, + ColorMode::Rainbow => 3, + }; + let anim_mode: u8 = match state.anim_mode { + AnimMode::Static => 0, + AnimMode::Pulse => 1, + AnimMode::Ripple => 2, + }; + + let flags: u8 = (state.fc_connected as u8) + | ((state.tx_linked as u8) << 1) + | ((matches!(state.flight_mode, FlightMode::Armed) as u8) << 2) + | ((matches!(state.flight_mode, FlightMode::Failsafe) as u8) << 3) + | ((matches!(state.flight_mode, FlightMode::ArmingAllowed) as u8) << 4); + + let num_leds = state.num_leds.to_le_bytes(); + let max_current = (state.max_current_ma as u16).to_le_bytes(); + let pulse_speed_le = pulse_speed.to_le_bytes(); + + buf[0] = RSP_STATE; + buf[1] = PROTOCOL_VERSION; + buf[2] = state.brightness; + buf[3] = num_leds[0]; + buf[4] = num_leds[1]; + buf[5] = state.fps; + buf[6] = max_current[0]; + buf[7] = max_current[1]; + buf[8] = color_mode; + buf[9] = anim_mode; + buf[10] = state.color_bal_r; + buf[11] = state.color_bal_g; + buf[12] = state.color_bal_b; + buf[13] = state.use_hsi as u8; + buf[14] = state.color_params.hue_speed; + buf[15] = pulse_speed_le[0]; + buf[16] = pulse_speed_le[1]; + buf[17] = pulse_min_brt; + buf[18] = ripple_speed; + buf[19] = ripple_width; + buf[20] = ripple_decay; + buf[21] = flags; + + 22 } -/// Serialize a [`StateResponse`] into a buffer, appending a newline delimiter. +/// Encode the version response into `buf` (must be >= 5 bytes). /// -/// 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 - } +/// Returns the number of bytes written (always 5). +pub fn encode_version(buf: &mut [u8]) -> usize { + buf[0] = RSP_VERSION; + buf[1] = PROTOCOL_VERSION; + buf[2] = FW_MAJOR; + buf[3] = FW_MINOR; + buf[4] = FW_PATCH; + 5 } // --------------------------------------------------------------------------- @@ -310,83 +327,148 @@ mod tests { } #[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)); + fn get_state_returns_send_state() { + let mut state = default_state(); + let result = handle_binary_command(&[CMD_GET_STATE], &mut state); + assert!(matches!(result, HandleResult::SendState)); } #[test] - fn parse_set_brightness() { - let cmd = parse_command(b"{\"SetBrightness\":{\"value\":128}}").unwrap(); - assert!(matches!(cmd, Command::SetBrightness { value: 128 })); + fn get_version_returns_send_version() { + let mut state = default_state(); + let result = handle_binary_command(&[CMD_GET_VERSION], &mut state); + assert!(matches!(result, HandleResult::SendVersion)); } #[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"); - } + fn set_brightness() { + let mut state = default_state(); + let result = handle_binary_command(&[CMD_SET_BRIGHTNESS, 42], &mut state); + assert!(matches!(result, HandleResult::Ack(RSP_OK))); + assert_eq!(state.brightness, 42); } #[test] - fn parse_invalid_json() { - assert!(parse_command(b"not json").is_none()); + fn set_num_leds() { + let mut state = default_state(); + let val: u16 = 100; + let bytes = val.to_le_bytes(); + let result = + handle_binary_command(&[CMD_SET_NUM_LEDS, bytes[0], bytes[1]], &mut state); + assert!(matches!(result, HandleResult::Ack(RSP_OK))); + assert_eq!(state.num_leds, 100); } #[test] - fn handle_get_state_returns_send_state() { + fn set_num_leds_out_of_range() { let mut state = default_state(); - let result = handle_command(&Command::GetState, &mut state); - assert!(matches!(result, HandleResult::SendState)); + let val: u16 = 999; + let bytes = val.to_le_bytes(); + let result = + handle_binary_command(&[CMD_SET_NUM_LEDS, bytes[0], bytes[1]], &mut state); + assert!(matches!(result, HandleResult::Ack(RSP_ERR_RANGE))); } #[test] - fn handle_set_brightness() { + fn set_color_mode_rainbow() { 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); + let result = handle_binary_command(&[CMD_SET_COLOR_MODE, 3], &mut state); + assert!(matches!(result, HandleResult::Ack(RSP_OK))); + assert_eq!(state.color_mode, ColorMode::Rainbow); } #[test] - fn handle_set_num_leds_clamps() { + fn set_color_mode_invalid() { let mut state = default_state(); - handle_command(&Command::SetNumLeds { value: 999 }, &mut state); - assert_eq!(state.num_leds, MAX_NUM_LEDS); + let result = handle_binary_command(&[CMD_SET_COLOR_MODE, 99], &mut state); + assert!(matches!(result, HandleResult::Ack(RSP_ERR_RANGE))); } #[test] - fn handle_set_anim_mode_resets_params() { + fn 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); + let result = handle_binary_command(&[CMD_SET_ANIM_MODE, 2], &mut state); + assert!(matches!(result, HandleResult::Ack(RSP_OK))); assert_eq!(state.anim_mode, AnimMode::Ripple); assert!(matches!(state.anim_params, AnimModeParams::Ripple { .. })); } #[test] - fn serialize_state_response() { + fn set_color_balance() { + let mut state = default_state(); + let result = + handle_binary_command(&[CMD_SET_COLOR_BALANCE, 0xFF, 0xC8, 0xDC], &mut state); + assert!(matches!(result, HandleResult::Ack(RSP_OK))); + assert_eq!(state.color_bal_r, 255); + assert_eq!(state.color_bal_g, 200); + assert_eq!(state.color_bal_b, 220); + } + + #[test] + fn empty_command_returns_parse_error() { + let mut state = default_state(); + let result = handle_binary_command(&[], &mut state); + assert!(matches!(result, HandleResult::Ack(RSP_ERR_PARSE))); + } + + #[test] + fn unknown_command_returns_parse_error() { + let mut state = default_state(); + let result = handle_binary_command(&[0xFF], &mut state); + assert!(matches!(result, HandleResult::Ack(RSP_ERR_PARSE))); + } + + #[test] + fn truncated_command_returns_parse_error() { + let mut state = default_state(); + // CMD_SET_NUM_LEDS needs 3 bytes, only 2 provided + let result = handle_binary_command(&[CMD_SET_NUM_LEDS, 0x01], &mut state); + assert!(matches!(result, HandleResult::Ack(RSP_ERR_PARSE))); + } + + #[test] + fn encode_state_snapshot() { 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\"")); + let mut buf = [0u8; 32]; + let n = encode_state(&state, &mut buf); + assert_eq!(n, 22); + assert_eq!(buf[0], RSP_STATE); + assert_eq!(buf[1], PROTOCOL_VERSION); + assert_eq!(buf[2], state.brightness); + // num_leds = 180 = 0x00B4 LE + assert_eq!(buf[3], 0xB4); + assert_eq!(buf[4], 0x00); + assert_eq!(buf[5], state.fps); + // flags: fc_connected=false → 0x00 + assert_eq!(buf[21], 0x00); + } + + #[test] + fn encode_version_response() { + let mut buf = [0u8; 8]; + let n = encode_version(&mut buf); + assert_eq!(n, 5); + assert_eq!(buf[0], RSP_VERSION); + assert_eq!(buf[1], PROTOCOL_VERSION); + assert_eq!(buf[2], FW_MAJOR); + assert_eq!(buf[3], FW_MINOR); + assert_eq!(buf[4], FW_PATCH); + } + + #[test] + fn encode_state_flags() { + let mut state = default_state(); + state.fc_connected = true; + state.tx_linked = true; + state.flight_mode = FlightMode::Armed; + let mut buf = [0u8; 22]; + encode_state(&state, &mut buf); + // flags: fc_connected=1, tx_linked=1<<1, armed=1<<2 = 0b00000111 = 0x07 + assert_eq!(buf[21], 0x07); } } diff --git a/src/state.rs b/src/state.rs index 571b121..a27a18c 100644 --- a/src/state.rs +++ b/src/state.rs @@ -172,6 +172,12 @@ impl Default for LedState { /// The LED task picks this up and plays a blue flash sequence over 750 ms. pub static BLE_FLASH: Signal = Signal::new(); +/// Test pattern request: color code (0=red, 1=green, 2=blue, 3=white). +/// +/// The LED task picks this up and plays a 5-second solid color episode, +/// bypassing all post-processing (gamma, balance, current limit). +pub static TEST_PATTERN: 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.