From 098d53d50a28fdda300568721b7f477b4f57ebac Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Sun, 2 Aug 2026 13:32:05 -0600 Subject: [PATCH 01/12] Clarify Express host proof boundary --- docs/express-abi.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/express-abi.md b/docs/express-abi.md index 28abb31d5..4ea02e604 100644 --- a/docs/express-abi.md +++ b/docs/express-abi.md @@ -108,6 +108,14 @@ The fixed-address `ExpressInterface::from_target` constructor is also unsafe: it is only valid on the matching 32-bit Express target and is intentionally not used by host tests. This foundation deliberately does not use bindgen. +Host tests verify the independent slot order and kinds, version rejection, +optional appended slots, target/image selection, and fail-closed behavior when +functions are absent. A wider host cannot execute the table's 32-bit target +function addresses, so these tests do not claim successful runtime forwarding +or RAII teardown against Express firmware. That proof requires the compiler, +target runner, package integration, and device work tracked separately from the +STM32 SDK. + The loader entry contract is available independently of any target toolchain: `ExpressLibInfo` mirrors the pinned `lib_info` record, and `express_native_start!` emits the `.program_ptr` and `.init_fun` entry symbols From 73a0f073b3739d61e0e2eb3c85023631d14c8527 Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Sun, 2 Aug 2026 13:40:50 -0600 Subject: [PATCH 02/12] Add restrained FOC audio smoke seam --- crates/vesc-protocol/src/audio_smoke.rs | 118 ++++++++++++++++++++++++ crates/vesc-protocol/src/lib.rs | 2 + examples/loopback/src/app_data.rs | 15 +++ examples/loopback/src/audio.rs | 103 +++++++++++++++++++++ examples/loopback/src/main.rs | 2 + tools/safe-example-check.sh | 2 +- 6 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 crates/vesc-protocol/src/audio_smoke.rs create mode 100644 examples/loopback/src/audio.rs diff --git a/crates/vesc-protocol/src/audio_smoke.rs b/crates/vesc-protocol/src/audio_smoke.rs new file mode 100644 index 000000000..275228c5f --- /dev/null +++ b/crates/vesc-protocol/src/audio_smoke.rs @@ -0,0 +1,118 @@ +//! Allocation-free wire helpers for the restrained FOC-audio smoke test. + +/// Command byte requesting one fixed, short FOC-audio beep. +pub const BEEP_COMMAND: u8 = 0xa0; +/// Encoded response size for [`BeepResponse`]. +pub const BEEP_RESPONSE_BYTES: usize = 2; + +/// Device result for the fixed short-beep request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BeepStatus { + /// Firmware accepted the beep request. + Played, + /// The loaded firmware does not expose FOC audio. + Unavailable, + /// Firmware rejected the checked request. + Rejected, +} + +/// Owned response returned by the audio smoke command. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BeepResponse { + status: BeepStatus, +} + +impl BeepResponse { + /// Decode one fixed-size audio smoke response. + /// + /// # Errors + /// + /// Returns an error for the wrong length, command byte, or status byte. + pub fn decode(response: &[u8]) -> Result { + let [command, status]: [u8; BEEP_RESPONSE_BYTES] = response + .try_into() + .map_err(|_| BeepResponseError::InvalidLength)?; + if command != BEEP_COMMAND { + return Err(BeepResponseError::UnexpectedCommand); + } + let status = match status { + 0 => BeepStatus::Played, + 1 => BeepStatus::Unavailable, + 2 => BeepStatus::Rejected, + _ => return Err(BeepResponseError::UnknownStatus), + }; + Ok(Self { status }) + } + + /// Return the reported device result. + #[must_use] + pub const fn status(self) -> BeepStatus { + self.status + } +} + +/// Error returned for malformed audio smoke responses. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BeepResponseError { + /// The response did not contain exactly two bytes. + InvalidLength, + /// The response used another command byte. + UnexpectedCommand, + /// The response carried an unknown status byte. + UnknownStatus, +} + +/// Encode the fixed short-beep request. +#[must_use] +pub const fn encode_beep_command() -> [u8; 1] { + [BEEP_COMMAND] +} + +/// Encode one device result for the fixed short-beep request. +#[must_use] +pub const fn encode_beep_response(status: BeepStatus) -> [u8; BEEP_RESPONSE_BYTES] { + let status = match status { + BeepStatus::Played => 0, + BeepStatus::Unavailable => 1, + BeepStatus::Rejected => 2, + }; + [BEEP_COMMAND, status] +} + +#[cfg(test)] +mod tests { + use super::{BEEP_COMMAND, BeepResponse, BeepResponseError, BeepStatus, encode_beep_command}; + + #[test] + fn codec_round_trips_the_fixed_beep_command_and_statuses() { + assert_eq!(encode_beep_command(), [BEEP_COMMAND]); + assert_eq!( + BeepResponse::decode(&[BEEP_COMMAND, 0]).map(BeepResponse::status), + Ok(BeepStatus::Played) + ); + assert_eq!( + BeepResponse::decode(&[BEEP_COMMAND, 1]).map(BeepResponse::status), + Ok(BeepStatus::Unavailable) + ); + assert_eq!( + BeepResponse::decode(&[BEEP_COMMAND, 2]).map(BeepResponse::status), + Ok(BeepStatus::Rejected) + ); + } + + #[test] + fn codec_rejects_malformed_responses() { + assert_eq!( + BeepResponse::decode(&[BEEP_COMMAND]), + Err(BeepResponseError::InvalidLength) + ); + assert_eq!( + BeepResponse::decode(&[0, 0]), + Err(BeepResponseError::UnexpectedCommand) + ); + assert_eq!( + BeepResponse::decode(&[BEEP_COMMAND, 3]), + Err(BeepResponseError::UnknownStatus) + ); + } +} diff --git a/crates/vesc-protocol/src/lib.rs b/crates/vesc-protocol/src/lib.rs index b006b3f83..5699e0f8e 100644 --- a/crates/vesc-protocol/src/lib.rs +++ b/crates/vesc-protocol/src/lib.rs @@ -42,6 +42,8 @@ extern crate std; /// Fixed-shape VESC app-data request parsing. pub mod app_data; +/// Restrained FOC-audio smoke-test wire helpers. +pub mod audio_smoke; /// BLE loopback wire-format helpers and response handling. pub mod ble_loopback; /// VESC firmware buffer-compatible primitive encoders. diff --git a/examples/loopback/src/app_data.rs b/examples/loopback/src/app_data.rs index aa5887aee..ac32902ee 100644 --- a/examples/loopback/src/app_data.rs +++ b/examples/loopback/src/app_data.rs @@ -16,6 +16,21 @@ impl AppDataHandler for LoopbackAppData { reply: &mut AppDataReply<'_>, ) { let firmware = Firmware::new(); + let mut audio_response = [0_u8; vesc_protocol::audio_smoke::BEEP_RESPONSE_BYTES]; + match crate::audio::handle_audio_smoke_command( + firmware.audio(), + packet.as_bytes(), + &mut audio_response, + ) { + Ok(Some(response_len)) => { + let _ = audio_response + .get(..response_len) + .is_some_and(|bytes| reply.write(bytes).is_ok()); + return; + } + Ok(None) => {} + Err(_) => return, + } let now_ms = u64::from(firmware.clock().now().as_ticks()) / 10; if let Ok((bytes, response_len)) = handle_loopback_frame(packet.as_bytes(), now_ms) { let _ = bytes diff --git a/examples/loopback/src/audio.rs b/examples/loopback/src/audio.rs new file mode 100644 index 000000000..00e71bc8b --- /dev/null +++ b/examples/loopback/src/audio.rs @@ -0,0 +1,103 @@ +//! Restrained, fixed-output FOC-audio hardware seam. + +use vesc_protocol::audio_smoke::{ + BEEP_COMMAND, BEEP_RESPONSE_BYTES, BeepStatus, encode_beep_response, +}; +use vescpkg_rs::{ + AudioDuration, AudioFrequency, AudioVoltage, FocAudio, FocAudioError, Frequency, VescSeconds, + Voltage, +}; + +/// Error returned before the fixed audio request reaches firmware. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AudioSmokeCommandError { + /// The recognized command carried unexpected payload bytes. + InvalidLength, + /// The caller's response storage cannot hold the fixed acknowledgement. + ResponseTooShort, +} + +/// Handle the fixed short-beep command, or return `None` for another protocol. +/// +/// # Errors +/// +/// Returns an error when the recognized request has the wrong length or the +/// response buffer is too short. +pub fn handle_audio_smoke_command( + audio: FocAudio, + packet: &[u8], + response: &mut [u8], +) -> Result, AudioSmokeCommandError> { + if packet.first().copied() != Some(BEEP_COMMAND) { + return Ok(None); + } + if packet.len() != 1 { + return Err(AudioSmokeCommandError::InvalidLength); + } + let output = response + .get_mut(..BEEP_RESPONSE_BYTES) + .ok_or(AudioSmokeCommandError::ResponseTooShort)?; + let status = match audio.beep( + AudioFrequency::new(Frequency::from_hertz(440.0)), + AudioDuration::new(VescSeconds::from_seconds(0.05)), + AudioVoltage::new(Voltage::from_volts(0.5)), + ) { + Ok(()) => BeepStatus::Played, + Err(FocAudioError::Unavailable) => BeepStatus::Unavailable, + Err(_) => BeepStatus::Rejected, + }; + output.copy_from_slice(&encode_beep_response(status)); + Ok(Some(BEEP_RESPONSE_BYTES)) +} + +#[cfg(test)] +mod tests { + use vesc_protocol::audio_smoke::{BEEP_COMMAND, BeepResponse, BeepStatus, encode_beep_command}; + use vescpkg_rs::test_support::FirmwareTest; + + use super::{AudioSmokeCommandError, handle_audio_smoke_command}; + + #[test] + fn fixed_beep_reports_played_or_unavailable() { + let firmware = FirmwareTest::new(); + let mut response = [0_u8; 2]; + + let len = + handle_audio_smoke_command(firmware.audio(), &encode_beep_command(), &mut response) + .expect("valid command") + .expect("audio command"); + assert_eq!(len, response.len()); + assert_eq!( + BeepResponse::decode(&response).map(BeepResponse::status), + Ok(BeepStatus::Played) + ); + + firmware.set_audio_available(false); + handle_audio_smoke_command(firmware.audio(), &encode_beep_command(), &mut response) + .expect("valid command") + .expect("audio command"); + assert_eq!( + BeepResponse::decode(&response).map(BeepResponse::status), + Ok(BeepStatus::Unavailable) + ); + } + + #[test] + fn fixed_beep_rejects_malformed_requests_without_claiming_other_packets() { + let firmware = FirmwareTest::new(); + let mut response = [0_u8; 2]; + + assert_eq!( + handle_audio_smoke_command(firmware.audio(), &[BEEP_COMMAND, 0], &mut response), + Err(AudioSmokeCommandError::InvalidLength) + ); + assert_eq!( + handle_audio_smoke_command(firmware.audio(), &[0], &mut response), + Ok(None) + ); + assert_eq!( + handle_audio_smoke_command(firmware.audio(), &[BEEP_COMMAND], &mut []), + Err(AudioSmokeCommandError::ResponseTooShort) + ); + } +} diff --git a/examples/loopback/src/main.rs b/examples/loopback/src/main.rs index 4ab33c6a6..7f35752ca 100644 --- a/examples/loopback/src/main.rs +++ b/examples/loopback/src/main.rs @@ -41,6 +41,8 @@ fn main() {} mod app_data; #[cfg(any(test, target_arch = "arm"))] +pub mod audio; +#[cfg(any(test, target_arch = "arm"))] pub mod config; #[cfg(any(test, target_arch = "arm"))] pub mod custom_data; diff --git a/tools/safe-example-check.sh b/tools/safe-example-check.sh index f2c1397fe..ba8f3592e 100755 --- a/tools/safe-example-check.sh +++ b/tools/safe-example-check.sh @@ -19,7 +19,7 @@ for example in "${examples[@]}"; do fi done -for module in config custom_data display sync threads; do +for module in audio config custom_data display sync threads; do if ! rg -q "^pub mod ${module};$" examples/loopback/src/main.rs; then printf 'loopback example is missing public module: %s\n' "$module" >&2 exit 1 From 082262500179c754cefeafc531522da35ca71094 Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Sun, 2 Aug 2026 13:43:03 -0600 Subject: [PATCH 03/12] Add typed audio beep probe --- crates/cargo-vescpkg/src/lib.rs | 44 +++++++++++++++++++++++++++++++++ docs/cargo-vescpkg-command.md | 3 +++ 2 files changed, 47 insertions(+) diff --git a/crates/cargo-vescpkg/src/lib.rs b/crates/cargo-vescpkg/src/lib.rs index 5e7e2b609..65e0d52d6 100644 --- a/crates/cargo-vescpkg/src/lib.rs +++ b/crates/cargo-vescpkg/src/lib.rs @@ -27,6 +27,7 @@ struct Cli { #[derive(Debug, Clone, PartialEq, Subcommand)] enum Command { Build(BuildArgs), + AudioBeep(DeviceArgs), #[command(name = "loopback")] Probe(DeviceArgs), CustomAppData(CustomAppDataArgs), @@ -133,6 +134,7 @@ where { match parse_args(args) { Ok(Command::Build(args)) => run_build(args), + Ok(Command::AudioBeep(command)) => run_audio_beep(command), Ok(Command::Probe(command)) => run_probe(command), Ok(Command::CustomAppData(command)) => run_custom_app_data(command), Ok(Command::CustomConfig(command)) => run_custom_config(command), @@ -314,6 +316,37 @@ fn run_custom_app_data(command: CustomAppDataArgs) -> ExitCode { } } +fn run_audio_beep(command: DeviceArgs) -> ExitCode { + use vesc_protocol::audio_smoke::{BeepResponse, BeepStatus, encode_beep_command}; + + let target = command.into_target(); + match deploy::run_custom_app_data_probe(target, &encode_beep_command()) { + Ok(report) => match BeepResponse::decode(report.response()).map(BeepResponse::status) { + Ok(BeepStatus::Played) => { + let version = report.firmware_version(); + println!( + "audio beep accepted on firmware={}.{}", + version.major(), + version.minor() + ); + ExitCode::SUCCESS + } + Ok(status) => { + eprintln!("audio beep was not played: {status:?}"); + ExitCode::from(1) + } + Err(error) => { + eprintln!("audio beep returned an invalid response: {error:?}"); + ExitCode::from(1) + } + }, + Err(error) => { + eprintln!("audio beep failed: {error}"); + ExitCode::from(1) + } + } +} + fn print_loopback_report(report: &loopback::LoopbackReport) { println!( "loopback ok on device={} service={}: {:?}", @@ -607,6 +640,17 @@ mod tests { assert_eq!(args.device_name.as_deref(), Some("VESC BLE UART")); } + #[test] + fn parse_args_builds_a_fixed_audio_beep_probe() { + let command = parse_args(["cargo-vescpkg", "audio-beep", "--device", "VESC BLE UART"]) + .expect("parse audio-beep probe"); + + let Command::AudioBeep(args) = command else { + panic!("expected audio-beep command"); + }; + assert_eq!(args.device_name.as_deref(), Some("VESC BLE UART")); + } + #[test] fn parse_args_builds_a_read_only_lisp_stats_probe() { let command = parse_args(["cargo-vescpkg", "lisp-stats", "--device", "VESC BLE UART"]) diff --git a/docs/cargo-vescpkg-command.md b/docs/cargo-vescpkg-command.md index 65b1166a8..dfb341580 100644 --- a/docs/cargo-vescpkg-command.md +++ b/docs/cargo-vescpkg-command.md @@ -30,6 +30,8 @@ The classification names the most important controller-side boundary: controller state; - **package-mutating** installs, replaces, or erases controller package data; - **firmware-config-mutating** changes live or stored firmware configuration; +- **physical-output** asks the installed package to produce a bounded physical + effect without changing stored configuration; - **package-specific** sends a protocol understood only by a particular installed package. Its payload determines whether it reads or mutates. @@ -38,6 +40,7 @@ This table is checked against the live Clap subcommand list by a unit test. | Command | Classification | What it does | | --- | --- | --- | | `build` | read-only on the controller; host output only | Build, link, validate, and assemble one Cargo package below `target/vescpkg/` | +| `audio-beep` | physical-output, package-specific | Ask the installed loopback package for one fixed, short FOC-audio beep and validate its typed response | | `loopback` | read-only, package-specific | Probe the installed loopback package and validate its response sequence | | `custom-app-data` | package-specific | Send decimal payload bytes to the installed package and wait for app-data | | `custom-config` | read-only | Fetch custom-config index 0 as raw bytes | From fbe26c18584899c91ca782ebcc0c12ac697fc1c5 Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Sun, 2 Aug 2026 13:43:49 -0600 Subject: [PATCH 04/12] Document restrained audio hardware workflow --- docs/cargo-vescpkg-command.md | 16 ++++++++++++++++ examples/loopback/README.md | 24 +++++++++++++++++++----- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/docs/cargo-vescpkg-command.md b/docs/cargo-vescpkg-command.md index dfb341580..483e4a7fe 100644 --- a/docs/cargo-vescpkg-command.md +++ b/docs/cargo-vescpkg-command.md @@ -98,6 +98,22 @@ each sample, validates the package/command prefix, and writes host elapsed milliseconds plus the complete response bytes. It does not decode away unknown future fields. +## Restrained audio probe + +With the loopback package installed, `audio-beep` requests one 440 Hz, +50-millisecond FOC-audio beep at 0.5 V and requires the package to return a +typed `Played` result: + +```console +$ cargo run -p cargo-vescpkg -- audio-beep --device "VESC BLE UART" +``` + +> **Warning:** This is physical motor output even though it does not issue a +> torque command. Restrain the controller, keep the wheel clear, and use the +> command only for the focused hardware check. The command does not install a +> package; install the loopback package first and restore the prior package +> afterward. + ## Package-specific app-data `custom-app-data` accepts one or more decimal bytes. Include the package ID and diff --git a/examples/loopback/README.md b/examples/loopback/README.md index 53796e838..350caab43 100644 --- a/examples/loopback/README.md +++ b/examples/loopback/README.md @@ -12,11 +12,25 @@ The package also includes usage-shaped public-API examples: a port of VESC's official `examples/extension` `ext-test` callback plus a typed diagnostic extension in `src/extensions.rs`, app-data transport in `src/app_data.rs`, an official-shape custom application-data codec in `src/custom_data.rs`, an -explicit signature-checked custom-EEPROM probe image in `src/config.rs`, scoped synchronization and -clock reads, and a display-style GPIO bus plus bounded SSD1306 framebuffer in -`src/display.rs`. The framebuffer follows the page layout and clipping behavior -of the vendored official `examples/ssd1306` port. The EEPROM helper only writes -when its caller asks and never reaches into `vescpkg-rs-sys`. +explicit signature-checked custom-EEPROM probe image in `src/config.rs`, scoped +synchronization and clock reads, and a display-style GPIO bus plus bounded +SSD1306 framebuffer in `src/display.rs`. The framebuffer follows the page +layout and clipping behavior of upstream `c_libs/examples/ssd1306`; the source +mapping is recorded in the module documentation and the upstream source is not +vendored. The EEPROM helper only writes when its caller asks and never reaches +into `vescpkg-rs-sys`. + +The package also exposes one fixed audio-smoke command through `src/audio.rs`. +After installing this example on a physically restrained controller, run: + +```bash +cargo run -p cargo-vescpkg -- audio-beep --device "VESC BLE UART" +``` + +The command requests a short 440 Hz, 0.5 V FOC-audio beep, validates the typed +package response, and changes no stored configuration. It is physical motor +output, so keep the wheel clear and restore the prior package after the focused +check. Build the package ELF: From 69dadc30d062a98e661519d0a287b3d71369d442 Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Sun, 2 Aug 2026 13:52:39 -0600 Subject: [PATCH 05/12] Correct official extension source path --- examples/loopback/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/loopback/README.md b/examples/loopback/README.md index 350caab43..128d61660 100644 --- a/examples/loopback/README.md +++ b/examples/loopback/README.md @@ -9,7 +9,7 @@ package library and package entrypoint into the final ELF. its binary payload into the BLE loopback `.vescpkg` artifact. The package also includes usage-shaped public-API examples: a port of VESC's -official `examples/extension` `ext-test` callback plus a typed diagnostic +official `c_libs/examples/extension` `ext-test` callback plus a typed diagnostic extension in `src/extensions.rs`, app-data transport in `src/app_data.rs`, an official-shape custom application-data codec in `src/custom_data.rs`, an explicit signature-checked custom-EEPROM probe image in `src/config.rs`, scoped From 7cf7fddd189757a88569ae7c28faba4e644714e3 Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Sun, 2 Aug 2026 13:52:58 -0600 Subject: [PATCH 06/12] Correct host protocol ownership documentation --- docs/sdk-compatibility.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/sdk-compatibility.md b/docs/sdk-compatibility.md index 9887c6a8f..65820b2e2 100644 --- a/docs/sdk-compatibility.md +++ b/docs/sdk-compatibility.md @@ -45,6 +45,7 @@ remains in examples instead of becoming an SDK contract. | Persistent storage | `eeprom` covers native word codecs, typed word offsets distinct from ABI addresses, checked offset byte images, owned fixed-size image reads/writes, partial-word padding, and typed `EepromError` results for missing words, address overflow, interrupted reads, and firmware write rejection; the loopback example uses a signature-checked fixed image, while Float Out Boy keeps its validated 276-byte configuration distinct from its deterministic 320-byte EEPROM image; `nvm` covers bounded byte ranges, wipe, capacity, and firmware failures | EEPROM is the package custom range; package-specific signatures, layouts, padding, and persistence policy remain package-owned; NVM capability and capacity remain firmware-provided | | Safe package API | `make safe-example-check` verifies the representative examples import no `vescpkg-rs-sys` surface and declare `#![forbid(unsafe_code)]`; raw sys calls stay behind crate-private bindings | Open-loop FOC, STM32 pads, variadic `printf`, and raw pointers stay unsafe/internal | | LispBM values and lists | The pinned LispBM contract exposes byte arrays, explicit string capability through `LispValue::is_string` plus callback-scoped access through `with_str`, checked proper-list validation/traversal through `is_list`, `list().next_value()`, or its fallible iterator, and typed `LispFlatValueError` results for flat-value append/finish operations; improper tails return `LispListError::ImproperTail` | The header has no distinct string/list predicates or length-bearing array-data accessor, so strings remain a scoped byte-array borrow and list checks walk the pinned cons ABI; flat-value ownership still transfers only through successful `unblock_flat` | +| Host protocol | `vesc-protocol` supplies `no_std` packet, buffer, BLE-loopback, control-loop, and audio-smoke codecs shared by host tools and packages | Firmware setup-value parsing stays in `cargo-vescpkg`; package app-data IDs and semantics remain package-owned | | VESC Express | `vescpkg_rs_sys::express` provides the pinned v1 constants, named 32-bit slot map, independent `ExpressLibInfo`/`express_native_start!` loader entry contract, target metadata/name parsing and XIP-versus-relocatable load-kind contract, target-selected XIP/relocatable image views, fail-closed loader, unsafe raw resolver, checked runtime clocks/get-arg boundary, RAII synchronization, owned allocation handles, typed LispBM/evaluator/registration calls, ownership-scoped flat values whose append/finish rejection is a typed error, and no-alloc image/container validators | The package builder and CI matrix intentionally target ARM32 (`thumbv7em-none-eabihf`) only; Express compiler/linker/package integration is deferred separately, and hardware proof remains open. The Express table/image formats must not share STM32 slot order or loader assumptions | ## Reproduce the matrix From 13fbd39848b1dc0e80b58649c239942520d474a9 Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Sun, 2 Aug 2026 13:56:18 -0600 Subject: [PATCH 07/12] Document loopback thread example port --- examples/loopback/README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/examples/loopback/README.md b/examples/loopback/README.md index 128d61660..d849a11b6 100644 --- a/examples/loopback/README.md +++ b/examples/loopback/README.md @@ -20,6 +20,9 @@ mapping is recorded in the module documentation and the upstream source is not vendored. The EEPROM helper only writes when its caller asks and never reaches into `vescpkg-rs-sys`. +`src/threads.rs` ports the official thread example through the SDK-owned +thread lifecycle and stops its sleeping worker during package teardown. + The package also exposes one fixed audio-smoke command through `src/audio.rs`. After installing this example on a physically restrained controller, run: From deb4e7e804fca52b1655987c3ef62859de50c685 Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Sun, 2 Aug 2026 13:57:51 -0600 Subject: [PATCH 08/12] Cover audio command in docs inventory test --- crates/cargo-vescpkg/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/cargo-vescpkg/src/lib.rs b/crates/cargo-vescpkg/src/lib.rs index 65e0d52d6..7cbdca738 100644 --- a/crates/cargo-vescpkg/src/lib.rs +++ b/crates/cargo-vescpkg/src/lib.rs @@ -551,6 +551,7 @@ mod tests { let reference = include_str!("../../../docs/cargo-vescpkg-command.md"); for command in [ "build", + "audio-beep", "loopback", "custom-app-data", "custom-config", From 35025d153f9ce11b0ac877bd5d17da83ec6e3452 Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Sun, 2 Aug 2026 13:59:08 -0600 Subject: [PATCH 09/12] Remove stale roadmap workspace inventory --- docs/rust-package-api-roadmap.md | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/docs/rust-package-api-roadmap.md b/docs/rust-package-api-roadmap.md index 24665bde1..d32a51013 100644 --- a/docs/rust-package-api-roadmap.md +++ b/docs/rust-package-api-roadmap.md @@ -8,12 +8,9 @@ official VESC project or endorsed Rust package API. ## Current workspace shape -- `crates/vescpkg-rs-sys` — raw firmware ABI (`no_std`, unsafe table calls) -- `crates/vescpkg-rs` — target-side SDK linked into native packages -- `examples/loopback` — BLE loopback reference package ELF -- `crates/cargo-vescpkg` — `cargo vescpkg` host command surface for `.vescpkg` - format/build/install -- `crates/vesc-protocol` — shared wire protocol types +The authoritative crate and example inventory lives in the +[workspace layout](workspace-layout.md). This roadmap records migration +principles rather than duplicating that changing inventory. ## Validation @@ -21,11 +18,12 @@ official VESC project or endorsed Rust package API. - `make check-full` — strict host checks, target checks, package ELF build, and `.vescpkg` emission -## Deferred: +## Deferred Hardware-in-the-loop validation is intentionally out of the default CI path. -Symbol resolution, and semantic instruction audits against device-proven fixtures; -`cargo vescpkg` exercises install/loopback against real hardware manually. +The default gates cover symbol resolution and semantic instruction audits +against device-proven fixtures. `cargo vescpkg` device commands provide the +manual hardware path. The feature-gated, ignored sketch lives in `crates/cargo-vescpkg/tests/hil_loopback.rs` and is filtered by the `hil` From b6f7efff68ce8e4666ad48a8b9f1b8bbad2c4374 Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Sun, 2 Aug 2026 13:59:45 -0600 Subject: [PATCH 10/12] Refresh authoritative workspace inventory --- docs/workspace-layout.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/workspace-layout.md b/docs/workspace-layout.md index 5a14575f4..440144b83 100644 --- a/docs/workspace-layout.md +++ b/docs/workspace-layout.md @@ -8,14 +8,22 @@ This repo is organized around layered responsibilities: `.vescpkg` artifacts. `vesc-*` names are reserved here for host/protocol communication surfaces, matching the broader Rust ecosystem naming split. - `crates/vescpkg-rs-sys/` — raw firmware ABI (`no_std`, unsafe table calls). See [vescpkg-rs-sys testing](testing/vescpkg-rs-sys.md). +- `crates/vescpkg-rs-units/` — reusable `no_std` physical-unit newtypes. - `crates/vescpkg-rs/` — target-side SDK linked into native VESC packages. +- `crates/vescpkg-build-support/` — Cargo build-script support for package + assets and ARM linking. - `crates/vesc-protocol/` — shared wire types for host and target. - `crates/cargo-vescpkg/` — the `cargo vescpkg` command and its host-side - `.vescpkg` format, build, install, and loopback support. + `.vescpkg` format, build, install, and device probes. - `examples/loopback/` — reference BLE loopback package library plus Cargo-owned final ELF target. -- `scripts/` — small workspace helpers outside Rust crates. +- `examples/alloc-smoke/` — firmware-allocation smoke package. +- `examples/control-loop-smoke/` — no-actuation shared-state and periodic-loop + package. +- `examples/float-out-boy/` — full Rust Refloat port, renamed to distinguish it + from the upstream project. +- `scripts/` and `tools/` — small workspace helpers outside Rust crates. Host-only dependencies stay in `cargo-vescpkg`. Target code stays in -`vescpkg-rs`, `vescpkg-rs-sys`, and examples. Host tools must not depend -on `vescpkg-rs` except when building examples. +`vescpkg-rs`, `vescpkg-rs-sys`, `vescpkg-rs-units`, and examples. Host tools +must not depend on `vescpkg-rs` except when building examples. From c2174569cecd1684b80060470f796b2eb6c0c19b Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Sun, 2 Aug 2026 14:01:57 -0600 Subject: [PATCH 11/12] Make alloc smoke ARM lint clean --- examples/alloc-smoke/src/main.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/examples/alloc-smoke/src/main.rs b/examples/alloc-smoke/src/main.rs index ac7c49ef8..a2497fc68 100644 --- a/examples/alloc-smoke/src/main.rs +++ b/examples/alloc-smoke/src/main.rs @@ -108,13 +108,17 @@ fn alloc_smoke_loopback( now_ms: u64, ) -> Result<([u8; MAX_LOOPBACK_FRAME_BYTES], usize), LoopbackError> { let (response, response_len) = handle_loopback_frame(packet, now_ms)?; + let response = response + .get(..response_len) + .ok_or(LoopbackError::BufferTooShort { + len: response.len(), + required: response_len, + })?; let mut candidates = Vec::with_capacity(ALLOC_SMOKE_CANDIDATES); for _ in 0..ALLOC_SMOKE_CANDIDATES { - candidates.push(response[..response_len].to_vec()); + candidates.push(response.to_vec()); } - let rotation = response_len % candidates.len(); - candidates.rotate_left(rotation); let selected = candidates.first().map(Vec::as_slice).unwrap_or_default(); let mut output = [0_u8; MAX_LOOPBACK_FRAME_BYTES]; for (destination, source) in output.iter_mut().zip(selected) { From c94f50cadd6699202e3181a9204758569dcc2fdf Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Sun, 2 Aug 2026 14:02:53 -0600 Subject: [PATCH 12/12] Harden control loop example arithmetic --- examples/control-loop-smoke/src/lib.rs | 46 ++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/examples/control-loop-smoke/src/lib.rs b/examples/control-loop-smoke/src/lib.rs index ed1e43178..1ae6c5083 100644 --- a/examples/control-loop-smoke/src/lib.rs +++ b/examples/control-loop-smoke/src/lib.rs @@ -41,6 +41,7 @@ impl Default for ControlLoopState { impl ControlLoopState { /// Create an idle, no-actuation control state. + #[must_use] pub const fn new() -> Self { Self { setpoint: 0, @@ -51,21 +52,25 @@ impl ControlLoopState { } /// Return the requested setpoint. + #[must_use] pub const fn setpoint(self) -> i16 { self.setpoint } /// Return the synthetic sampled input. + #[must_use] pub const fn sampled_input(self) -> i16 { self.sampled_input } /// Return the computed, non-actuating control output. + #[must_use] pub const fn output(self) -> i16 { self.output } /// Return the number of completed loop ticks. + #[must_use] pub const fn tick_count(self) -> u32 { self.tick_count } @@ -78,13 +83,26 @@ impl ControlLoopState { /// Advance the deliberately simple proportional control step. pub fn tick(&mut self) { let error = i32::from(self.setpoint) - i32::from(self.sampled_input); - self.sampled_input = self.sampled_input.saturating_add((error / 2) as i16); - self.output = error.clamp(i32::from(i16::MIN), i32::from(i16::MAX)) as i16; + self.sampled_input = self.sampled_input.saturating_add(saturating_i16(error / 2)); + self.output = saturating_i16(error); self.tick_count = self.tick_count.wrapping_add(1); } } +fn saturating_i16(value: i32) -> i16 { + i16::try_from(value).unwrap_or(if value.is_negative() { + i16::MIN + } else { + i16::MAX + }) +} + /// Handle one host command without touching firmware or performing I/O. +/// +/// # Errors +/// +/// Returns a typed command error for an unknown command, malformed request, or +/// response buffer that cannot hold the selected reply. pub fn handle_command( state: &mut ControlLoopState, packet: &[u8], @@ -168,13 +186,18 @@ vescpkg_rs::firmware_stateful_app_data_callback!( vescpkg_rs::package_start!(crate::start, ControlLoopState); /// Initialize the example package. +/// +/// # Errors +/// +/// Returns an error when runtime-state installation, thread creation, or +/// app-data callback registration fails. #[cfg(any(test, all(not(test), target_arch = "arm")))] pub fn start(start: &mut vescpkg_rs::PackageStart) -> Result<(), vescpkg_rs::PackageStartError> { start.install_runtime_state(ControlLoopState::new())?; #[cfg(all(not(test), target_arch = "arm"))] { let stack = vescpkg_rs::ThreadWorkingAreaSize::try_from_bytes(1_024) - .expect("control-loop thread stack satisfies ChibiOS alignment"); + .map_err(|_| vescpkg_rs::PackageStartError::ThreadSpawnFailed)?; start.spawn_threads([vescpkg_rs::ThreadSpec::::new::< ControlLoopThread, >(stack, vescpkg_rs::thread_name!("Control Loop"))])?; @@ -204,6 +227,23 @@ mod tests { assert_eq!(state.tick_count(), 1); } + #[test] + fn control_step_saturates_extreme_errors() { + let mut state = ControlLoopState { + setpoint: i16::MAX, + sampled_input: i16::MIN, + output: 0, + tick_count: 0, + }; + state.tick(); + assert_eq!(state.output(), i16::MAX); + + state.setpoint = i16::MIN; + state.sampled_input = i16::MAX; + state.tick(); + assert_eq!(state.output(), i16::MIN); + } + #[test] fn setpoint_and_status_commands_share_state() { let mut state = ControlLoopState::new();