From a993d32f48b9543c6fcdb00c92f4548acb8e149b Mon Sep 17 00:00:00 2001 From: Ihor Kalnytskyi Date: Sun, 22 Feb 2026 02:24:42 +0200 Subject: [PATCH] Add FwVersion command support FwVersion is a protocol message in VESC firmware and establishes the basic command surface for controller discovery and capability checks. --- README.md | 2 + src/command.rs | 134 +++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 5 ++ src/packer.rs | 18 ++++++ tests/command.rs | 8 +++ tests/command_reply.rs | 49 ++++++++++++++- 6 files changed, 215 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 715c111..6746dc8 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ application that needs to communicate with VESC motor controllers. | Command ID | Command Name | Status | |:----------:|-----------------------------------|--------| +| `0` | `FwVersion` | ✅ | | `4` | `GetValues` | ✅ | | `6` | `SetCurrent` | ✅ | | `7` | `SetCurrentBrake` | ✅ | @@ -32,6 +33,7 @@ application that needs to communicate with VESC motor controllers. | Command ID | Command Name | Status | |------------|-----------------------------------|--------| +| `0` | `FwVersion` | ✅ | | `4` | `GetValues` | ✅ | | `50` | `GetValuesSelective` | ✅ | diff --git a/src/command.rs b/src/command.rs index b8eb721..a977c67 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1,4 +1,5 @@ use bitflags::bitflags; +use core::ffi::CStr; use super::packer::{Packer, Unpacker}; @@ -6,6 +7,11 @@ const CRC16: crc::Crc = crc::Crc::::new(&crc::CRC_16_XMODEM); const FRAME_END: u8 = 3; const FRAME_START_SHORT: u8 = 2; +// The VESC firmware caps the FwVersion payload at 65 bytes. +// This decoder allows each name buffer to hold up to 39 bytes (including NUL). +// The two names still share the same 65-byte payload budget with fixed fields. +const FW_VERSION_NAME_MAX_LEN: usize = 39; + /// Errors that can occur during command encoding. #[derive(Debug, PartialEq, Eq, thiserror::Error)] #[cfg_attr(feature = "defmt", derive(defmt::Format))] @@ -35,6 +41,7 @@ pub enum DecodeError { #[repr(u8)] enum CommandId { + FwVersion = 0, GetValues = 4, SetCurrent = 6, SetCurrentBrake = 7, @@ -49,6 +56,7 @@ impl TryFrom for CommandId { fn try_from(value: u8) -> Result { match value { + id if id == CommandId::FwVersion as u8 => Ok(CommandId::FwVersion), id if id == CommandId::GetValues as u8 => Ok(CommandId::GetValues), id if id == CommandId::SetCurrent as u8 => Ok(CommandId::SetCurrent), id if id == CommandId::SetCurrentBrake as u8 => Ok(CommandId::SetCurrentBrake), @@ -120,6 +128,9 @@ bitflags! { #[derive(Debug, Copy, Clone)] #[cfg_attr(feature = "defmt", derive(defmt::Format))] pub enum Command<'a> { + /// Requests firmware version information from the VESC. + FwVersion, + /// Requests the complete set of telemetry data from the VESC. GetValues, @@ -155,6 +166,9 @@ pub enum Command<'a> { impl<'a> Command<'a> { fn pack_into(&self, packer: &mut Packer) -> Result<(), EncodeError> { match self { + Self::FwVersion => { + packer.pack_u8(CommandId::FwVersion as u8)?; + } Self::GetValues => { packer.pack_u8(CommandId::GetValues as u8)?; } @@ -310,6 +324,102 @@ impl From for FaultCode { } } +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "defmt", derive(defmt::Format))] +#[repr(u8)] +pub enum HwType { + Vesc = 0, + VescBms = 1, + CustomModule = 2, + Unknown = 255, +} + +impl From for HwType { + fn from(value: u8) -> Self { + match value { + v if v == HwType::Vesc as u8 => HwType::Vesc, + v if v == HwType::VescBms as u8 => HwType::VescBms, + v if v == HwType::CustomModule as u8 => HwType::CustomModule, + _ => HwType::Unknown, + } + } +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "defmt", derive(defmt::Format))] +#[repr(u8)] +pub enum QmlHw { + None = 0, + Embedded = 1, + Fullscreen = 2, + Unknown = 255, +} + +impl From for QmlHw { + fn from(value: u8) -> Self { + match value { + v if v == QmlHw::None as u8 => QmlHw::None, + v if v == QmlHw::Embedded as u8 => QmlHw::Embedded, + v if v == QmlHw::Fullscreen as u8 => QmlHw::Fullscreen, + _ => QmlHw::Unknown, + } + } +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "defmt", derive(defmt::Format))] +pub struct QmlAppFlags(u8); + +bitflags! { + impl QmlAppFlags: u8 { + const EMBEDDED = 1 << 0; + const FULLSCREEN = 1 << 1; + const _ = !0; + } +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "defmt", derive(defmt::Format))] +pub struct NrfFlags(u8); + +bitflags! { + impl NrfFlags: u8 { + const _ = !0; + } +} + +/// Firmware version data returned by the motor controller. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "defmt", derive(defmt::Format))] +pub struct FwVersion { + pub major: u8, + pub minor: u8, + pub hw_name: [u8; FW_VERSION_NAME_MAX_LEN], + pub uuid: [u8; 12], + pub pairing_done: bool, + pub test_version_number: u8, + pub hw_type: HwType, + pub custom_config_num: u8, + pub has_phase_filters: bool, + pub qml_hw: QmlHw, + pub qml_app: QmlAppFlags, + pub nrf_flags: NrfFlags, + pub fw_name: [u8; FW_VERSION_NAME_MAX_LEN], + pub hw_crc: u32, +} + +impl FwVersion { + pub fn hw_name(&self) -> Option<&str> { + let cstr = CStr::from_bytes_until_nul(&self.hw_name).ok()?; + cstr.to_str().ok() + } + + pub fn fw_name(&self) -> Option<&str> { + let cstr = CStr::from_bytes_until_nul(&self.fw_name).ok()?; + cstr.to_str().ok() + } +} + /// Telemetry data returned by the motor controller. /// /// Contains temperatures, currents, voltages, rpm, and so on. Returned by @@ -353,6 +463,9 @@ pub struct Values { #[derive(Debug, Copy, Clone)] #[cfg_attr(feature = "defmt", derive(defmt::Format))] pub enum CommandReply { + /// Firmware version in response to [`Command::FwVersion`]. + FwVersion(FwVersion), + /// Complete telemetry data in response to [`Command::GetValues`]. Contains /// all available sensor readings and status information. GetValues(Values), @@ -366,12 +479,33 @@ pub enum CommandReply { impl CommandReply { fn unpack_from(unpacker: &mut Unpacker) -> Result { Ok(match unpacker.unpack_u8()?.try_into()? { + CommandId::FwVersion => Self::unpack_fw_version(unpacker)?, CommandId::GetValues => Self::unpack_get_values(unpacker)?, CommandId::GetValuesSelective => Self::unpack_get_values_selective(unpacker)?, id => return Err(DecodeError::UnknownPacket { id: id as u8 }), }) } + fn unpack_fw_version(unpacker: &mut Unpacker) -> Result { + let fw_version = FwVersion { + major: unpacker.unpack_u8()?, + minor: unpacker.unpack_u8()?, + hw_name: unpacker.unpack_c_string::()?, + uuid: unpacker.unpack_uuid()?, + pairing_done: unpacker.unpack_u8()? != 0, + test_version_number: unpacker.unpack_u8()?, + hw_type: unpacker.unpack_u8()?.into(), + custom_config_num: unpacker.unpack_u8()?, + has_phase_filters: unpacker.unpack_u8()? != 0, + qml_hw: unpacker.unpack_u8()?.into(), + qml_app: QmlAppFlags::from_bits_retain(unpacker.unpack_u8()?), + nrf_flags: NrfFlags::from_bits_retain(unpacker.unpack_u8()?), + fw_name: unpacker.unpack_c_string::()?, + hw_crc: unpacker.unpack_u32()?, + }; + Ok(CommandReply::FwVersion(fw_version)) + } + fn unpack_get_values(unpacker: &mut Unpacker) -> Result { let values = Values { temp_mosfet: unpacker.unpack_f16(10.0)?, diff --git a/src/lib.rs b/src/lib.rs index e32cc45..5073dbe 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -60,6 +60,11 @@ pub use command::{ DecodeError, EncodeError, FaultCode, + FwVersion, + HwType, + NrfFlags, + QmlAppFlags, + QmlHw, Values, ValuesMask, decode, diff --git a/src/packer.rs b/src/packer.rs index da3c31b..d7d3caf 100644 --- a/src/packer.rs +++ b/src/packer.rs @@ -104,6 +104,24 @@ impl<'a> Unpacker<'a> { Ok(self.unpack_i16()? as f32 / scale) } + #[inline] + pub fn unpack_c_string(&mut self) -> Result<[u8; N], DecodeError> { + let mut buf = [0u8; N]; + for slot in buf.iter_mut() { + *slot = self.unpack_u8()?; + if *slot == 0 { + return Ok(buf); + } + } + Err(DecodeError::InvalidFrame) + } + + #[inline] + pub fn unpack_uuid(&mut self) -> Result<[u8; 12], DecodeError> { + // SAFETY: `consume(12)` returns a slice with exactly 12 bytes. + Ok(self.consume(12)?.try_into().unwrap()) + } + #[inline] fn consume(&mut self, amount: usize) -> Result<&[u8], DecodeError> { if self.pos + amount > self.buf.len() { diff --git a/tests/command.rs b/tests/command.rs index 28ca076..c43dd7d 100644 --- a/tests/command.rs +++ b/tests/command.rs @@ -2,6 +2,14 @@ use googletest::prelude::*; use vesc::{self, Command, EncodeError, ValuesMask}; +#[test] +fn encode_fw_version() { + let mut buf = [0u8; 16]; + + let size = vesc::encode(Command::FwVersion, &mut buf).unwrap(); + assert_that!(buf[..size], eq([2, 1, 0, 0, 0, 3])); +} + #[test] fn encode_get_values() { let mut buf = [0u8; 16]; diff --git a/tests/command_reply.rs b/tests/command_reply.rs index 3ddc797..8fe5eb8 100644 --- a/tests/command_reply.rs +++ b/tests/command_reply.rs @@ -1,6 +1,53 @@ use googletest::prelude::*; -use vesc::{CommandReply, DecodeError, FaultCode, Values}; +use vesc::{CommandReply, DecodeError, FaultCode, HwType, NrfFlags, QmlAppFlags, QmlHw, Values}; + +#[test] +fn decode_fw_version_incomplete_data() { + let input = [2, 3, 0, 7, 0, 153, 151, 3]; + assert_that!(vesc::decode(&input), err(eq(&DecodeError::IncompleteData))); +} + +#[test] +fn decode_fw_version_incomplete_uuid() { + let input = [2, 5, 0, 7, 0, 97, 0, 105, 54, 3]; + assert_that!(vesc::decode(&input), err(eq(&DecodeError::IncompleteData))); +} + +#[test] +fn decode_fw_version_full_reply() { + let input = [ + 2, 41, 0, 7, 0, 86, 69, 83, 67, 54, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 1, 0, 0, 2, + 1, 2, 3, 3, 100, 101, 102, 97, 117, 108, 116, 0, 18, 52, 86, 120, 225, 223, 3, + ]; + + let expected = ( + eq(&46), + pat!(&CommandReply::FwVersion(pat!(vesc::FwVersion { + major: eq(7), + minor: eq(0), + uuid: eq([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]), + pairing_done: eq(true), + test_version_number: eq(0), + hw_type: eq(HwType::Vesc), + custom_config_num: eq(2), + has_phase_filters: eq(true), + qml_hw: eq(QmlHw::Fullscreen), + qml_app: eq(QmlAppFlags::from_bits_retain(3)), + nrf_flags: eq(NrfFlags::from_bits_retain(3)), + hw_crc: eq(0x12345678), + .. + }))), + ); + assert_that!(vesc::decode(&input), ok(expected)); + + let (_, reply) = vesc::decode(&input).unwrap(); + let CommandReply::FwVersion(version) = reply else { + panic!("expected fw version reply"); + }; + assert_that!(version.hw_name(), some(eq("VESC6"))); + assert_that!(version.fw_name(), some(eq("default"))); +} #[test] fn decode_get_values_zero_rpm() {