From 7a120c7da4056b206ba3ccb6060587a7997e755c Mon Sep 17 00:00:00 2001 From: AnonymousBit <68566858+AnonymousBit0111@users.noreply.github.com> Date: Thu, 15 Jan 2026 17:28:19 -0500 Subject: [PATCH 1/3] WIP --- Cargo.lock | 29 +++++++ firmware-common-new/src/can_bus/sender.rs | 2 +- firmware-common-new/src/lib.rs | 1 + firmware-common-new/src/usb_encoder.rs | 52 +++++++++++++ firmware-common-new/src/vlp/mod.rs | 3 +- firmware-common-new/src/vlp/usb.rs | 18 +++++ rocket-cli/Cargo.toml | 1 + rocket-cli/src/args.rs | 14 ++++ rocket-cli/src/main.rs | 93 ++++++++++++++++++++++- 9 files changed, 210 insertions(+), 3 deletions(-) create mode 100644 firmware-common-new/src/usb_encoder.rs create mode 100644 firmware-common-new/src/vlp/usb.rs diff --git a/Cargo.lock b/Cargo.lock index 5ea17b41..7f737092 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3968,6 +3968,18 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libusb1-sys" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da050ade7ac4ff1ba5379af847a10a10a8e284181e060105bf8d86960ce9ce0f" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -5920,6 +5932,7 @@ dependencies = [ "prompted", "rand 0.9.1", "regex", + "rusb", "salty", "sanitise-file-name", "serde", @@ -5931,6 +5944,16 @@ dependencies = [ "uuid", ] +[[package]] +name = "rusb" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab9f9ff05b63a786553a4c02943b74b34a988448671001e9a27e2f0565cc05a4" +dependencies = [ + "libc", + "libusb1-sys", +] + [[package]] name = "rustc-cfg" version = "0.4.0" @@ -7134,6 +7157,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "vec_map" version = "0.8.2" diff --git a/firmware-common-new/src/can_bus/sender.rs b/firmware-common-new/src/can_bus/sender.rs index bfccec9d..1e656d26 100644 --- a/firmware-common-new/src/can_bus/sender.rs +++ b/firmware-common-new/src/can_bus/sender.rs @@ -176,7 +176,7 @@ impl CanSender { for data in multi_frame_encoder { let success = self.channel.try_send((id, data)).is_ok(); if !success { - log_warn!("can bus sender buffer overflow"); + // log_warn!("can bus sender buffer overflow"); break; } } diff --git a/firmware-common-new/src/lib.rs b/firmware-common-new/src/lib.rs index e1b9af81..5fc70332 100644 --- a/firmware-common-new/src/lib.rs +++ b/firmware-common-new/src/lib.rs @@ -14,6 +14,7 @@ mod tests; pub mod bootloader; pub mod can_bus; +pub mod usb_encoder; pub(crate) mod fixed_point; pub mod gps; pub mod readings; diff --git a/firmware-common-new/src/usb_encoder.rs b/firmware-common-new/src/usb_encoder.rs new file mode 100644 index 00000000..7bd7e623 --- /dev/null +++ b/firmware-common-new/src/usb_encoder.rs @@ -0,0 +1,52 @@ +// Note , you never need a string larger than 1024 bytes + +use heapless::String; + +pub fn encode_string( + buf: &mut [u8], + index: usize, + string: String<1024>, + real_string_size: usize, +) -> Result<(), ()> { + if index + (real_string_size) > buf.len() - 1 // index + real_string_size is the last index of the string we write, since we add an extra byte + { + return Err(()); + } + + buf[index] = b'S'; + buf[index + 1..index + 1 + real_string_size] + .copy_from_slice(&string.as_bytes()[0..real_string_size]); + + Ok(()) +} + +// TODO error checking, what if string doesnt start with S or s, same applies for floats, will help catch errors + +// pub fn decode_string(string: String) -> String { +// let new_string = &string[1..]; + +// String::from(new_string) +// } + +pub fn encode_float(buf: &mut [u8], float: f32, newline: bool, index: usize) -> Result<(), ()> { + let bytes = float.to_le_bytes(); + + if index + 4 > buf.len() - 1 { + return Err(()); + } + + buf[index] = if newline { b'F' } else { b'f' }; + + buf[index + 1..index + 5].copy_from_slice(&bytes); + + Ok(()) +} + +pub fn decode_float(data: [u8; 5]) -> Result { + if data[0] != b'f' && data[0] != b'F' { + return Err(()); + } + let float_data: &[u8; 4] = data[1..].as_array().unwrap(); + + Ok(f32::from_le_bytes(*float_data)) +} diff --git a/firmware-common-new/src/vlp/mod.rs b/firmware-common-new/src/vlp/mod.rs index 7e5117e1..92f5751d 100644 --- a/firmware-common-new/src/vlp/mod.rs +++ b/firmware-common-new/src/vlp/mod.rs @@ -2,4 +2,5 @@ pub mod packets; pub mod lora; pub mod lora_config; pub mod client; -pub mod radio; \ No newline at end of file +pub mod radio; +pub mod usb; \ No newline at end of file diff --git a/firmware-common-new/src/vlp/usb.rs b/firmware-common-new/src/vlp/usb.rs new file mode 100644 index 00000000..0ef467cb --- /dev/null +++ b/firmware-common-new/src/vlp/usb.rs @@ -0,0 +1,18 @@ +#[repr(u16)] +pub enum CliRequest { + Invalid = 0, + List = 1, + Clear = 2, + Download = 3, +} + +impl Into for u16 { + fn into(self) -> CliRequest { + match self { + 1 => CliRequest::List, + 2 => CliRequest::Clear, + 3 => CliRequest::Download, + _ => CliRequest::Invalid, + } + } +} diff --git a/rocket-cli/Cargo.toml b/rocket-cli/Cargo.toml index a498e634..40246cd1 100644 --- a/rocket-cli/Cargo.toml +++ b/rocket-cli/Cargo.toml @@ -50,3 +50,4 @@ directories = "6.0.0" packed_struct = { version = "0.10.1", default-features = false } heapless = "0.8.0" fern = {version = "0.7.1", features = ["colored"]} +rusb = "0.9.4" diff --git a/rocket-cli/src/args.rs b/rocket-cli/src/args.rs index 80203457..5e8e1f36 100644 --- a/rocket-cli/src/args.rs +++ b/rocket-cli/src/args.rs @@ -36,11 +36,25 @@ pub enum ModeSelect { #[command(about = "generate private and public keys for ota")] GenOtaKey(GenOtaKeyCli), + #[command(about = "List files on void lake(connected through USB)")] + ListFiles, + + #[command(about = "Download a file from void lake(connected through USB)")] + DownloadFile(DownloadFileArgs), + + #[command(about = "clear files from void lake(connected through USB)")] + ClearStorage, + #[clap(subcommand)] #[command(about = "functions used for testing")] Testing(TestingModeSelect), } +#[derive(Parser, Debug)] +pub struct DownloadFileArgs { + pub filename: String, +} + #[derive(Parser, Debug)] pub struct DownloadCli { pub chip: String, diff --git a/rocket-cli/src/main.rs b/rocket-cli/src/main.rs index d7e18ed0..bf2f022d 100644 --- a/rocket-cli/src/main.rs +++ b/rocket-cli/src/main.rs @@ -13,8 +13,8 @@ use std::fs::File; use std::io; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; +use std::time::Duration; -use anyhow::Ok; use anyhow::{Result, anyhow}; use args::Cli; use args::ModeSelect; @@ -26,9 +26,14 @@ use connection_method::get_connection_method; use fern::Dispatch; use fern::colors::Color; use fern::colors::ColoredLevelConfig; +use firmware_common_new::vlp::usb::CliRequest; use gen_key::gen_ota_key; use log::LevelFilter; use monitor::monitor_tui; +use rusb::Context; +use rusb::Device; +use rusb::Direction; +use rusb::Language; use testing::decode_bluetooth_chunk::test_decode_bluetooth_chunk; use testing::mock_connection_method::MockConnectionMethod; @@ -91,6 +96,26 @@ async fn main() -> Result<()> { ModeSelect::Testing(TestingModeSelect::SendVLPTelemetry(args)) => { send_fake_vlp_telemetry(args).await } + ModeSelect::ListFiles => { + match list_files().await { + Ok(files) => { + for filename in files { + println!("{}", filename); + } + } + Err(e) => { + println!("[ERROR]: {}", e) + } + } + println!("done"); + todo!() + } + ModeSelect::DownloadFile(download_file_args) => { + todo!() + } + ModeSelect::ClearStorage => { + todo!() + } } } @@ -142,3 +167,69 @@ fn init_logging() -> Result<()> { Ok(()) } + +async fn list_files() -> anyhow::Result> { + use rusb::*; + + let ctx = Context::new()?; + + let dev = ctx + .devices()? + .iter() + .find(|d| { + let serial_num = get_serial_number(&d).unwrap().unwrap(); + + let pred = serial_num == "4206980085"; + + if pred { + println!("connecting to VLF5"); + } + + pred + }) + .expect("Device not found") + .open()?; + + let iface = 0; + dev.claim_interface(iface)?; + + dev.write_control( + rusb::request_type(Direction::Out, RequestType::Vendor, Recipient::Interface), + 101, + CliRequest::List as u16, // corresponds to list + iface as u16, + &[], + std::time::Duration::from_secs(1), + )?; + + let ep_in_addr = 0x81; + + let mut buf = [0u8; 1024]; + + let n = dev.read_bulk(1, &mut buf, Duration::from_secs(2))?; + + let message = buf.iter().map(|f| format!("{:?}", f)).collect(); + + println!("Received {} bytes: {:?}", n, &buf[..n]); + + Ok(vec![message]) +} + +pub fn get_serial_number(device: &Device) -> Result> { + let desc = device.device_descriptor()?; + + let Some(idx) = desc.serial_number_string_index() else { + return Ok(None); + }; + + let handle = device.open()?; + + // Read available languages + let langs = handle.read_languages(Duration::from_secs(2))?; + let lang = langs.get(0).copied().unwrap(); + + // Read string + let serial = handle.read_string_descriptor(lang, idx, Duration::from_secs(2))?; + + Ok(Some(serial)) +} From 3c3e4c611b3f0cb8c05984d446e475b5fecea614 Mon Sep 17 00:00:00 2001 From: AnonymousBit <68566858+AnonymousBit0111@users.noreply.github.com> Date: Thu, 11 Jun 2026 13:21:39 -0400 Subject: [PATCH 2/3] flight log storage format --- firmware-common-new/src/flight_data_record.rs | 19 +- firmware-common-new/src/flight_storage.rs | 316 ++++++++++++++++++ firmware-common-new/src/lib.rs | 1 + 3 files changed, 335 insertions(+), 1 deletion(-) create mode 100644 firmware-common-new/src/flight_storage.rs diff --git a/firmware-common-new/src/flight_data_record.rs b/firmware-common-new/src/flight_data_record.rs index 44823c43..4b48f6e6 100644 --- a/firmware-common-new/src/flight_data_record.rs +++ b/firmware-common-new/src/flight_data_record.rs @@ -3,7 +3,7 @@ use crate::{ vlp::packets::gps_beacon::GPSBeaconPacket, }; -#[derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)] +#[derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive, Debug, Clone, PartialEq)] pub struct FlightDataRecord { pub record_count: u32, // theoretically, since the recording will start at boot, and we know boot time, we may not need to log the timestamp, this is just in case. @@ -38,3 +38,20 @@ pub struct FlightDataRecord { // bitmask for ContinuityUpdate (which is in vlf5/firmware) pub pyro_flags: u8, } + +// `valid` bitmask: which fields in a record held trustworthy data when it was +// logged. Shared between the firmware logger and the host CLI so both agree on +// the meaning of every bit. +pub const VALID_IMU: u8 = 1 << 0; +pub const VALID_BARO: u8 = 1 << 1; +pub const VALID_MAG: u8 = 1 << 2; +pub const VALID_GPS_FIX: u8 = 1 << 3; +pub const VALID_GPS_ALT: u8 = 1 << 4; +pub const VALID_BATTERY: u8 = 1 << 5; + +// `pyro_flags` bitmask layout (see vlf5 firmware `ContinuityUpdate`). +pub const PYRO_MAIN_CONTINUITY: u8 = 1 << 0; +pub const PYRO_MAIN_FIRE: u8 = 1 << 1; +pub const PYRO_DROGUE_CONTINUITY: u8 = 1 << 2; +pub const PYRO_DROGUE_FIRE: u8 = 1 << 3; +pub const PYRO_SHORT_CIRCUIT: u8 = 1 << 4; diff --git a/firmware-common-new/src/flight_storage.rs b/firmware-common-new/src/flight_storage.rs new file mode 100644 index 00000000..c1aa141b --- /dev/null +++ b/firmware-common-new/src/flight_storage.rs @@ -0,0 +1,316 @@ +//! On-SD-card and over-USB storage format for flight data records. +//! +//! This module is the single source of truth shared by the **VLF5 firmware** +//! (which writes records to the SD card and streams them over USB) and the +//! **rocket-cli** host tool (which reads them back and writes CSV). Keeping the +//! layout here guarantees the two sides can never silently disagree. +//! +//! ## Layout on the SD card (raw 512-byte blocks, no filesystem) +//! +//! ```text +//! block 0 : superblock (see [`encode_superblock`]) +//! block 1 .. 1+N : N data blocks, each holding floor(508 / RECORD_LEN) +//! rkyv-serialised [`FlightDataRecord`]s, zero-padded, with +//! a CRC32 in the last 4 bytes. +//! ``` +//! +//! Records never straddle a block boundary, so every block except the last one +//! is completely full. The superblock records how many records and how many +//! data blocks are live, so the log survives a power cycle. +//! +//! ## USB download protocol +//! +//! The host issues a vendor control transfer ([`crate::vlp::usb::CliRequest`]), +//! then reads the bulk-IN endpoint. The device replies with a +//! [`HEADER_LEN`]-byte response header (see [`encode_response_header`]) followed +//! by `block_count` raw 512-byte data blocks (for `Download`; `List`/`Clear` +//! send the header only), terminated by a zero-length packet. + +use crate::flight_data_record::FlightDataRecord; + +use rkyv::{ + api::low::{from_bytes_unchecked, to_bytes_in_with_alloc}, + rancor::Failure, + ser::{allocator::SubAllocator, writer::Buffer}, +}; + +/// Raw SD block size in bytes. +pub const BLOCK_SIZE: usize = 512; + +/// Bytes of each data block usable for records. The trailing 4 bytes hold a +/// CRC32 over the rest of the block. +pub const USABLE_PER_BLOCK: usize = BLOCK_SIZE - 4; + +/// Block index of the superblock. +pub const SUPERBLOCK_INDEX: u32 = 0; + +/// Block index of the first data block. +pub const DATA_START_BLOCK: u32 = 1; + +/// Identifies a valid superblock written by this firmware. +pub const SUPERBLOCK_MAGIC: [u8; 4] = *b"VLF5"; + +/// On-disk format version. Bump when the record or superblock layout changes. +pub const STORAGE_VERSION: u32 = 1; + +/// Identifies a valid USB download response header. +pub const RESPONSE_MAGIC: [u8; 4] = *b"VLDR"; + +/// Length of the USB download response header in bytes. +pub const HEADER_LEN: usize = 16; + +/// Serialised length of one [`FlightDataRecord`]. Computed from the rkyv +/// archived layout so the firmware and host always agree (both compile rkyv +/// with the same `pointer_width_32` feature). +pub const RECORD_LEN: usize = size_of::<::Archived>(); + +/// Number of whole records that fit in one data block. +pub const RECORDS_PER_BLOCK: usize = USABLE_PER_BLOCK / RECORD_LEN; + +/// rkyv needs its scratch/working buffer aligned; 16 covers every primitive in +/// [`FlightDataRecord`]. +#[repr(C, align(16))] +struct AlignedRecord([u8; RECORD_LEN]); + +fn crc32(data: &[u8]) -> u32 { + crc::Crc::::new(&crc::CRC_32_ISO_HDLC).checksum(data) +} + +/// Serialise one record into an aligned `RECORD_LEN`-byte buffer. +pub fn serialize_record(record: &FlightDataRecord) -> [u8; RECORD_LEN] { + let mut scratch = AlignedRecord([0u8; RECORD_LEN]); + to_bytes_in_with_alloc::<_, _, Failure>( + record, + Buffer::from(&mut scratch.0[..]), + SubAllocator::empty(), + ) + .expect("record serialization cannot fail with a correctly-sized buffer"); + scratch.0 +} + +/// Deserialise one record from its `RECORD_LEN` leading bytes. Returns `None` +/// if the slice is too short. +/// +/// # Safety note +/// Uses rkyv's unchecked path (matching the firmware's serialiser). The bytes +/// must have been produced by [`serialize_record`] for the same record layout. +pub fn deserialize_record(bytes: &[u8]) -> Option { + if bytes.len() < RECORD_LEN { + return None; + } + let mut aligned = AlignedRecord([0u8; RECORD_LEN]); + aligned.0.copy_from_slice(&bytes[..RECORD_LEN]); + // SAFETY: `aligned` is 16-byte aligned and contains a record produced by + // `serialize_record`; FlightDataRecord is plain-old-data (no pointers). + unsafe { from_bytes_unchecked::(&aligned.0) }.ok() +} + +/// Stamp the CRC32 of `block[0..508]` into `block[508..512]`. +pub fn finalize_data_block(block: &mut [u8; BLOCK_SIZE]) { + let crc = crc32(&block[..USABLE_PER_BLOCK]); + block[USABLE_PER_BLOCK..].copy_from_slice(&crc.to_le_bytes()); +} + +/// Check the CRC32 trailer of a data block. +pub fn verify_data_block(block: &[u8; BLOCK_SIZE]) -> bool { + let expected = crc32(&block[..USABLE_PER_BLOCK]); + let stored = u32::from_le_bytes([ + block[USABLE_PER_BLOCK], + block[USABLE_PER_BLOCK + 1], + block[USABLE_PER_BLOCK + 2], + block[USABLE_PER_BLOCK + 3], + ]); + expected == stored +} + +/// Decoded contents of a valid superblock. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SuperblockInfo { + /// Total number of records in the log. + pub record_count: u32, + /// Number of live data blocks (starting at [`DATA_START_BLOCK`]). + pub block_count: u32, +} + +/// Build a 512-byte superblock describing the current log state. +/// +/// Layout: magic(4) | version(4) | record_count(4) | block_count(4) | +/// record_len(4) | reserved | crc32(4, last 4 bytes). +pub fn encode_superblock(record_count: u32, block_count: u32) -> [u8; BLOCK_SIZE] { + let mut b = [0u8; BLOCK_SIZE]; + b[0..4].copy_from_slice(&SUPERBLOCK_MAGIC); + b[4..8].copy_from_slice(&STORAGE_VERSION.to_le_bytes()); + b[8..12].copy_from_slice(&record_count.to_le_bytes()); + b[12..16].copy_from_slice(&block_count.to_le_bytes()); + b[16..20].copy_from_slice(&(RECORD_LEN as u32).to_le_bytes()); + let crc = crc32(&b[..USABLE_PER_BLOCK]); + b[USABLE_PER_BLOCK..].copy_from_slice(&crc.to_le_bytes()); + b +} + +/// Parse a superblock. Returns `None` if the magic, version, record length, or +/// CRC don't match what this build expects (e.g. an uninitialised or +/// incompatible card). +pub fn decode_superblock(block: &[u8; BLOCK_SIZE]) -> Option { + if block[0..4] != SUPERBLOCK_MAGIC { + return None; + } + if !verify_data_block(block) { + return None; + } + let version = u32::from_le_bytes(block[4..8].try_into().ok()?); + let record_len = u32::from_le_bytes(block[16..20].try_into().ok()?); + if version != STORAGE_VERSION || record_len as usize != RECORD_LEN { + return None; + } + Some(SuperblockInfo { + record_count: u32::from_le_bytes(block[8..12].try_into().ok()?), + block_count: u32::from_le_bytes(block[12..16].try_into().ok()?), + }) +} + +/// Build the 16-byte USB download response header. +/// +/// Layout: magic(4) | record_count(4) | record_len(4) | block_count(4). +pub fn encode_response_header(record_count: u32, block_count: u32) -> [u8; HEADER_LEN] { + let mut h = [0u8; HEADER_LEN]; + h[0..4].copy_from_slice(&RESPONSE_MAGIC); + h[4..8].copy_from_slice(&record_count.to_le_bytes()); + h[8..12].copy_from_slice(&(RECORD_LEN as u32).to_le_bytes()); + h[12..16].copy_from_slice(&block_count.to_le_bytes()); + h +} + +/// Decoded USB download response header: `(record_count, record_len, block_count)`. +pub fn decode_response_header(buf: &[u8]) -> Option<(u32, u32, u32)> { + if buf.len() < HEADER_LEN || buf[0..4] != RESPONSE_MAGIC { + return None; + } + let record_count = u32::from_le_bytes(buf[4..8].try_into().ok()?); + let record_len = u32::from_le_bytes(buf[8..12].try_into().ok()?); + let block_count = u32::from_le_bytes(buf[12..16].try_into().ok()?); + Some((record_count, record_len, block_count)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::can_bus::messages::vl_status::FlightStage; + use crate::flight_data_record::{VALID_BARO, VALID_GPS_FIX, VALID_IMU}; + + fn sample(i: u32) -> FlightDataRecord { + FlightDataRecord { + record_count: i, + timestamp_us: i as u64 * 2400, + acc: [i as f32, -1.5, 9.81], + gyro: [0.1, 0.2, 0.3], + temperature: 21.5, + pressure: 101325.0 - i as f32, + mag: [12.0, -34.0, 56.0], + battery_voltage: 7.4, + valid: VALID_IMU | VALID_BARO | VALID_GPS_FIX, + lat_lon: (37.421998, -122.084), + altitude: 100.0 + i as f32, + num_of_fixed_satalites: 9, + hdop: 1.1, + vdop: 2.2, + pdop: 3.3, + flight_stage: FlightStage::Armed, + pyro_flags: 0b0000_0101, + } + } + + /// One record must survive serialize -> deserialize unchanged. + #[test] + fn record_round_trips() { + let r = sample(42); + let bytes = serialize_record(&r); + assert_eq!(bytes.len(), RECORD_LEN); + let back = deserialize_record(&bytes).expect("deserialize"); + assert_eq!(r, back); + } + + #[test] + fn data_block_crc() { + let mut block = [7u8; BLOCK_SIZE]; + finalize_data_block(&mut block); + assert!(verify_data_block(&block)); + block[10] ^= 0xFF; + assert!(!verify_data_block(&block)); + } + + #[test] + fn superblock_round_trips() { + let sb = encode_superblock(1234, 56); + let info = decode_superblock(&sb).expect("decode superblock"); + assert_eq!(info.record_count, 1234); + assert_eq!(info.block_count, 56); + // A flipped byte must fail the CRC. + let mut bad = sb; + bad[100] ^= 1; + assert!(decode_superblock(&bad).is_none()); + } + + /// Full firmware-writer -> USB-stream -> host-parser round trip, including a + /// partial final block, exactly mirroring `FlightLogger::append` (firmware) + /// and `parse_records` (rocket-cli). + #[test] + fn full_download_round_trips() { + // Enough records to fill several blocks with a partial tail. + let n = RECORDS_PER_BLOCK as u32 * 3 + 2; + let records: Vec = (0..n).map(sample).collect(); + + // --- firmware side: pack records into 512-byte blocks --- + let mut blocks: Vec<[u8; BLOCK_SIZE]> = Vec::new(); + let mut cur = [0u8; BLOCK_SIZE]; + let mut off = 0usize; + for r in &records { + let bytes = serialize_record(r); + if off + bytes.len() > USABLE_PER_BLOCK { + let mut full = cur; + finalize_data_block(&mut full); + blocks.push(full); + cur = [0u8; BLOCK_SIZE]; + off = 0; + } + cur[off..off + bytes.len()].copy_from_slice(&bytes); + off += bytes.len(); + } + if off > 0 { + let mut last = cur; + finalize_data_block(&mut last); + blocks.push(last); + } + + // --- the wire: header followed by raw blocks --- + let mut wire = Vec::new(); + wire.extend_from_slice(&encode_response_header(n, blocks.len() as u32)); + for b in &blocks { + wire.extend_from_slice(b); + } + + // --- host side: parse the stream back into records --- + let (record_count, record_len, block_count) = decode_response_header(&wire).unwrap(); + assert_eq!(record_count, n); + assert_eq!(record_len as usize, RECORD_LEN); + assert_eq!(block_count as usize, blocks.len()); + + let body = &wire[HEADER_LEN..]; + let mut recovered = Vec::new(); + let mut read = 0u32; + for i in 0..block_count as usize { + let block: &[u8; BLOCK_SIZE] = + body[i * BLOCK_SIZE..(i + 1) * BLOCK_SIZE].try_into().unwrap(); + assert!(verify_data_block(block), "block {} failed CRC", i); + let in_block = (RECORDS_PER_BLOCK as u32).min(record_count - read); + for j in 0..in_block as usize { + let o = j * RECORD_LEN; + recovered.push(deserialize_record(&block[o..o + RECORD_LEN]).unwrap()); + read += 1; + } + } + + assert_eq!(recovered.len(), records.len()); + assert_eq!(recovered, records); + } +} diff --git a/firmware-common-new/src/lib.rs b/firmware-common-new/src/lib.rs index 6bb3e112..b91a0359 100644 --- a/firmware-common-new/src/lib.rs +++ b/firmware-common-new/src/lib.rs @@ -14,6 +14,7 @@ mod tests; pub mod flight_data_record; +pub mod flight_storage; pub mod bootloader; pub mod can_bus; From 94c67ac7bcd12b2e1b20941b96c587dd915a23d4 Mon Sep 17 00:00:00 2001 From: AnonymousBit <68566858+AnonymousBit0111@users.noreply.github.com> Date: Thu, 11 Jun 2026 13:21:46 -0400 Subject: [PATCH 3/3] read flight logs over usb --- Cargo.lock | 1 + rocket-cli/Cargo.toml | 1 + rocket-cli/src/main.rs | 96 +--------- rocket-cli/src/usb_storage.rs | 344 ++++++++++++++++++++++++++++++++++ 4 files changed, 350 insertions(+), 92 deletions(-) create mode 100644 rocket-cli/src/usb_storage.rs diff --git a/Cargo.lock b/Cargo.lock index cf4c19f9..deac2056 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5377,6 +5377,7 @@ dependencies = [ "clap-num", "convert_case", "critical-section", + "csv", "cursive", "cursive-aligned-view", "defmt-decoder", diff --git a/rocket-cli/Cargo.toml b/rocket-cli/Cargo.toml index 40246cd1..ffeeab28 100644 --- a/rocket-cli/Cargo.toml +++ b/rocket-cli/Cargo.toml @@ -51,3 +51,4 @@ packed_struct = { version = "0.10.1", default-features = false } heapless = "0.8.0" fern = {version = "0.7.1", features = ["colored"]} rusb = "0.9.4" +csv = "1.3.0" diff --git a/rocket-cli/src/main.rs b/rocket-cli/src/main.rs index bf2f022d..c99ad926 100644 --- a/rocket-cli/src/main.rs +++ b/rocket-cli/src/main.rs @@ -8,12 +8,12 @@ mod monitor; mod probe; mod serial_can; mod testing; +mod usb_storage; use std::fs::File; use std::io; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; -use std::time::Duration; use anyhow::{Result, anyhow}; use args::Cli; @@ -26,14 +26,9 @@ use connection_method::get_connection_method; use fern::Dispatch; use fern::colors::Color; use fern::colors::ColoredLevelConfig; -use firmware_common_new::vlp::usb::CliRequest; use gen_key::gen_ota_key; use log::LevelFilter; use monitor::monitor_tui; -use rusb::Context; -use rusb::Device; -use rusb::Direction; -use rusb::Language; use testing::decode_bluetooth_chunk::test_decode_bluetooth_chunk; use testing::mock_connection_method::MockConnectionMethod; @@ -96,26 +91,9 @@ async fn main() -> Result<()> { ModeSelect::Testing(TestingModeSelect::SendVLPTelemetry(args)) => { send_fake_vlp_telemetry(args).await } - ModeSelect::ListFiles => { - match list_files().await { - Ok(files) => { - for filename in files { - println!("{}", filename); - } - } - Err(e) => { - println!("[ERROR]: {}", e) - } - } - println!("done"); - todo!() - } - ModeSelect::DownloadFile(download_file_args) => { - todo!() - } - ModeSelect::ClearStorage => { - todo!() - } + ModeSelect::ListFiles => usb_storage::list_files(), + ModeSelect::DownloadFile(args) => usb_storage::download_file(&args.filename), + ModeSelect::ClearStorage => usb_storage::clear_storage(), } } @@ -167,69 +145,3 @@ fn init_logging() -> Result<()> { Ok(()) } - -async fn list_files() -> anyhow::Result> { - use rusb::*; - - let ctx = Context::new()?; - - let dev = ctx - .devices()? - .iter() - .find(|d| { - let serial_num = get_serial_number(&d).unwrap().unwrap(); - - let pred = serial_num == "4206980085"; - - if pred { - println!("connecting to VLF5"); - } - - pred - }) - .expect("Device not found") - .open()?; - - let iface = 0; - dev.claim_interface(iface)?; - - dev.write_control( - rusb::request_type(Direction::Out, RequestType::Vendor, Recipient::Interface), - 101, - CliRequest::List as u16, // corresponds to list - iface as u16, - &[], - std::time::Duration::from_secs(1), - )?; - - let ep_in_addr = 0x81; - - let mut buf = [0u8; 1024]; - - let n = dev.read_bulk(1, &mut buf, Duration::from_secs(2))?; - - let message = buf.iter().map(|f| format!("{:?}", f)).collect(); - - println!("Received {} bytes: {:?}", n, &buf[..n]); - - Ok(vec![message]) -} - -pub fn get_serial_number(device: &Device) -> Result> { - let desc = device.device_descriptor()?; - - let Some(idx) = desc.serial_number_string_index() else { - return Ok(None); - }; - - let handle = device.open()?; - - // Read available languages - let langs = handle.read_languages(Duration::from_secs(2))?; - let lang = langs.get(0).copied().unwrap(); - - // Read string - let serial = handle.read_string_descriptor(lang, idx, Duration::from_secs(2))?; - - Ok(Some(serial)) -} diff --git a/rocket-cli/src/usb_storage.rs b/rocket-cli/src/usb_storage.rs new file mode 100644 index 00000000..0f1918f7 --- /dev/null +++ b/rocket-cli/src/usb_storage.rs @@ -0,0 +1,344 @@ +//! Read flight-data records off a VLF5 over USB-C and write them as CSV. +//! +//! The VLF5 firmware logs [`FlightDataRecord`]s to its SD card. This module +//! speaks the small vendor protocol in [`firmware_common_new::flight_storage`]: +//! a vendor control transfer carries a [`CliRequest`] in `wValue`, and the +//! device replies on the bulk-IN endpoint with a header followed (for +//! downloads) by the raw SD data blocks. + +use anyhow::Context as _; +use anyhow::{Result, anyhow, bail}; +use rusb::{Context, Device, DeviceHandle, Direction, Recipient, RequestType, UsbContext}; +use std::time::{Duration, Instant}; + +use firmware_common_new::flight_data_record::{ + FlightDataRecord, PYRO_DROGUE_CONTINUITY, PYRO_DROGUE_FIRE, PYRO_MAIN_CONTINUITY, + PYRO_MAIN_FIRE, PYRO_SHORT_CIRCUIT, VALID_BARO, VALID_BATTERY, VALID_GPS_ALT, VALID_GPS_FIX, + VALID_IMU, VALID_MAG, +}; +use firmware_common_new::flight_storage::{ + BLOCK_SIZE, HEADER_LEN, RECORD_LEN, RECORDS_PER_BLOCK, decode_response_header, + deserialize_record, verify_data_block, +}; +use firmware_common_new::vlp::usb::CliRequest; + +/// USB serial number advertised by the VLF5 (`usb_handler.rs`). +const VLF5_SERIAL: &str = "4206980085"; +/// Bulk-IN endpoint address (EP 1 IN). +const EP_IN: u8 = 0x81; +/// Vendor interface number. +const INTERFACE: u8 = 0; + +/// Find the VLF5 on the USB bus and claim its interface. +fn find_and_open() -> Result> { + let ctx = Context::new().context("creating libusb context")?; + for device in ctx.devices().context("listing USB devices")?.iter() { + if device_serial(&device).ok().flatten().as_deref() == Some(VLF5_SERIAL) { + let handle = device.open().context( + "opening the VLF5 (on Linux you may need a udev rule or to run with sudo)", + )?; + // Linux may have a kernel driver bound to the interface. + #[cfg(target_os = "linux")] + let _ = handle.set_auto_detach_kernel_driver(true); + handle + .claim_interface(INTERFACE) + .context("claiming the VLF5 interface")?; + return Ok(handle); + } + } + bail!("VLF5 not found over USB. Is it plugged in via USB-C and powered on?") +} + +fn device_serial(device: &Device) -> Result> { + let desc = device.device_descriptor()?; + let Some(idx) = desc.serial_number_string_index() else { + return Ok(None); + }; + let handle = device.open()?; + let langs = handle.read_languages(Duration::from_secs(1))?; + let Some(lang) = langs.first().copied() else { + return Ok(None); + }; + Ok(Some(handle.read_string_descriptor( + lang, + idx, + Duration::from_secs(1), + )?)) +} + +/// Send a [`CliRequest`] as a vendor control transfer (the command rides in +/// `wValue`; `bRequest` is unused). +fn send_request(handle: &DeviceHandle, request: CliRequest) -> Result<()> { + handle.write_control( + rusb::request_type(Direction::Out, RequestType::Vendor, Recipient::Interface), + 0, + request as u16, + INTERFACE as u16, + &[], + Duration::from_secs(2), + )?; + Ok(()) +} + +/// Read a full framed response: a [`HEADER_LEN`]-byte header, then (for a +/// download) `block_count` raw 512-byte data blocks. +fn read_response(handle: &DeviceHandle) -> Result> { + let mut data: Vec = Vec::new(); + // One block per read: the firmware writes each 512-byte block atomically, so + // a read either gets a whole block (fast) or times out with nothing — never + // a partial block that rusb would discard on timeout. + let mut buf = vec![0u8; BLOCK_SIZE]; + let mut expected: Option = None; + let overall_deadline = Instant::now() + Duration::from_secs(300); + let mut idle_since: Option = None; + + loop { + match handle.read_bulk(EP_IN, &mut buf, Duration::from_millis(500)) { + Ok(n) => { + data.extend_from_slice(&buf[..n]); + idle_since = None; + } + // A gap is normal while the 1 MHz SD card is read block-by-block; + // only give up after a sustained stall. + Err(rusb::Error::Timeout) => { + if expected.is_some_and(|e| data.len() >= e) { + break; + } + let since = *idle_since.get_or_insert_with(Instant::now); + if since.elapsed() > Duration::from_secs(10) { + bail!( + "device stopped sending (got {} of {} expected bytes)", + data.len(), + expected.map_or("?".to_string(), |e| e.to_string()) + ); + } + } + Err(e) => return Err(e).context("reading from the VLF5 bulk endpoint"), + } + + if expected.is_none() && data.len() >= HEADER_LEN { + let (_record_count, record_len, block_count) = + decode_response_header(&data[..HEADER_LEN]) + .ok_or_else(|| anyhow!("device sent an invalid response header"))?; + if record_len as usize != RECORD_LEN { + bail!( + "record layout mismatch: device uses {} bytes, this CLI expects {}. \ + Rebuild the firmware and CLI from the same source.", + record_len, + RECORD_LEN + ); + } + expected = Some(HEADER_LEN + block_count as usize * BLOCK_SIZE); + } + + if expected.is_some_and(|e| data.len() >= e) { + break; + } + if Instant::now() > overall_deadline { + bail!("download exceeded 300s, aborting"); + } + } + + Ok(data) +} + +/// Read just the response header. Used by `List`/`Clear`, which reply with +/// metadata only (no data blocks follow), so we must not wait for the +/// `block_count` blocks that `read_response` expects. Skips leading +/// zero-length packets left over from a prior command's terminator. +fn read_header(handle: &DeviceHandle) -> Result<[u8; HEADER_LEN]> { + let mut data: Vec = Vec::new(); + let mut buf = vec![0u8; 64]; + let deadline = Instant::now() + Duration::from_secs(5); + while data.len() < HEADER_LEN { + match handle.read_bulk(EP_IN, &mut buf, Duration::from_secs(2)) { + Ok(n) => data.extend_from_slice(&buf[..n]), + Err(rusb::Error::Timeout) => { + bail!("timed out waiting for a response from the VLF5") + } + Err(e) => return Err(e).context("reading from the VLF5 bulk endpoint"), + } + if Instant::now() > deadline { + bail!("timed out waiting for a response from the VLF5"); + } + } + let mut header = [0u8; HEADER_LEN]; + header.copy_from_slice(&data[..HEADER_LEN]); + Ok(header) +} + +/// Split the raw block stream into decoded records. +fn parse_records(data: &[u8]) -> Result<(u32, Vec)> { + let (record_count, _record_len, block_count) = decode_response_header(data) + .ok_or_else(|| anyhow!("device sent an invalid response header"))?; + let blocks = &data[HEADER_LEN..]; + + let mut records = Vec::with_capacity(record_count as usize); + let mut read = 0u32; + let mut crc_errors = 0u32; + + for i in 0..block_count as usize { + let start = i * BLOCK_SIZE; + let block: &[u8; BLOCK_SIZE] = blocks + .get(start..start + BLOCK_SIZE) + .ok_or_else(|| anyhow!("response truncated at block {}", i))? + .try_into() + .unwrap(); + if !verify_data_block(block) { + crc_errors += 1; + } + // Only the final block is partial; cap by the records still owed. + let in_block = (RECORDS_PER_BLOCK as u32).min(record_count - read); + for j in 0..in_block as usize { + let off = j * RECORD_LEN; + let record = deserialize_record(&block[off..off + RECORD_LEN]) + .ok_or_else(|| anyhow!("failed to decode record {} in block {}", j, i))?; + records.push(record); + read += 1; + } + } + + if crc_errors > 0 { + eprintln!( + "warning: {} block(s) failed their CRC check — data may be corrupt", + crc_errors + ); + } + Ok((record_count, records)) +} + +fn bit(mask: u8, flag: u8) -> String { + ((mask & flag) != 0).to_string() +} + +fn write_csv(path: &str, records: &[FlightDataRecord]) -> Result<()> { + let mut w = csv::Writer::from_path(path).with_context(|| format!("creating {}", path))?; + w.write_record([ + "record_count", + "timestamp_us", + "acc_x", + "acc_y", + "acc_z", + "gyro_x", + "gyro_y", + "gyro_z", + "temperature", + "pressure", + "mag_x", + "mag_y", + "mag_z", + "battery_voltage", + "lat", + "lon", + "altitude", + "num_sats", + "hdop", + "vdop", + "pdop", + "flight_stage", + "imu_valid", + "baro_valid", + "mag_valid", + "gps_fix", + "gps_alt_valid", + "battery_valid", + "pyro_main_continuity", + "pyro_main_fire", + "pyro_drogue_continuity", + "pyro_drogue_fire", + "pyro_short_circuit", + ])?; + + for r in records { + let v = r.valid; + let p = r.pyro_flags; + w.write_record([ + r.record_count.to_string(), + r.timestamp_us.to_string(), + r.acc[0].to_string(), + r.acc[1].to_string(), + r.acc[2].to_string(), + r.gyro[0].to_string(), + r.gyro[1].to_string(), + r.gyro[2].to_string(), + r.temperature.to_string(), + r.pressure.to_string(), + r.mag[0].to_string(), + r.mag[1].to_string(), + r.mag[2].to_string(), + r.battery_voltage.to_string(), + r.lat_lon.0.to_string(), + r.lat_lon.1.to_string(), + r.altitude.to_string(), + r.num_of_fixed_satalites.to_string(), + r.hdop.to_string(), + r.vdop.to_string(), + r.pdop.to_string(), + format!("{:?}", r.flight_stage), + bit(v, VALID_IMU), + bit(v, VALID_BARO), + bit(v, VALID_MAG), + bit(v, VALID_GPS_FIX), + bit(v, VALID_GPS_ALT), + bit(v, VALID_BATTERY), + bit(p, PYRO_MAIN_CONTINUITY), + bit(p, PYRO_MAIN_FIRE), + bit(p, PYRO_DROGUE_CONTINUITY), + bit(p, PYRO_DROGUE_FIRE), + bit(p, PYRO_SHORT_CIRCUIT), + ])?; + } + + w.flush()?; + Ok(()) +} + +/// `list-files`: print a summary of what's stored on the VLF5. +pub fn list_files() -> Result<()> { + let handle = find_and_open()?; + send_request(&handle, CliRequest::List)?; + let header = read_header(&handle)?; + let (record_count, record_len, block_count) = decode_response_header(&header) + .ok_or_else(|| anyhow!("device sent an invalid response header"))?; + + println!("VLF5 flight log:"); + println!(" records : {}", record_count); + println!( + " data blocks : {} ({} bytes on card)", + block_count, + block_count as usize * BLOCK_SIZE + ); + println!( + " record size : {} bytes ({} records/block)", + record_len, RECORDS_PER_BLOCK + ); + if record_count == 0 { + println!(" (empty — nothing has been logged yet)"); + } + Ok(()) +} + +/// `download-file `: pull the whole log and write it as CSV. +pub fn download_file(output: &str) -> Result<()> { + let handle = find_and_open()?; + send_request(&handle, CliRequest::Download)?; + let data = read_response(&handle)?; + let (record_count, records) = parse_records(&data)?; + write_csv(output, &records)?; + println!( + "Wrote {} of {} record(s) to {}", + records.len(), + record_count, + output + ); + Ok(()) +} + +/// `clear-storage`: erase the log on the VLF5. +pub fn clear_storage() -> Result<()> { + let handle = find_and_open()?; + send_request(&handle, CliRequest::Clear)?; + let _ack = read_header(&handle)?; + println!("VLF5 storage cleared."); + Ok(()) +}