Skip to content
45 changes: 45 additions & 0 deletions crates/cargo-vescpkg/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ struct Cli {
#[derive(Debug, Clone, PartialEq, Subcommand)]
enum Command {
Build(BuildArgs),
AudioBeep(DeviceArgs),
#[command(name = "loopback")]
Probe(DeviceArgs),
CustomAppData(CustomAppDataArgs),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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={}: {:?}",
Expand Down Expand Up @@ -518,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",
Expand Down Expand Up @@ -607,6 +641,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"])
Expand Down
118 changes: 118 additions & 0 deletions crates/vesc-protocol/src/audio_smoke.rs
Original file line number Diff line number Diff line change
@@ -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<Self, BeepResponseError> {
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)
);
}
}
2 changes: 2 additions & 0 deletions crates/vesc-protocol/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 19 additions & 0 deletions docs/cargo-vescpkg-command.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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 |
Expand Down Expand Up @@ -95,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
Expand Down
8 changes: 8 additions & 0 deletions docs/express-abi.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 7 additions & 9 deletions docs/rust-package-api-roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,22 @@ 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

- `make check`
- `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`
Expand Down
1 change: 1 addition & 0 deletions docs/sdk-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 12 additions & 4 deletions docs/workspace-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
10 changes: 7 additions & 3 deletions examples/alloc-smoke/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading