use std::fmt;
use std::str::FromStr;
use crate::protocol::{Frame, ReplyKind, raw_to_deg};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(u8)]
pub enum Mode {
Current = 0x01,
Velocity = 0x02,
Position = 0x03,
}
impl Mode {
pub fn from_byte(b: u8) -> Option<Self> {
match b {
0x01 => Some(Mode::Current),
0x02 => Some(Mode::Velocity),
0x03 => Some(Mode::Position),
_ => None,
}
}
pub fn as_byte(self) -> u8 {
self as u8
}
}
impl fmt::Display for Mode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Mode::Current => "Current",
Mode::Velocity => "Velocity",
Mode::Position => "Position",
})
}
}
impl FromStr for Mode {
type Err = String;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s.to_ascii_lowercase().as_str() {
"current" => Ok(Mode::Current),
"velocity" => Ok(Mode::Velocity),
"position" => Ok(Mode::Position),
_ => Err(format!("unknown mode {s:?} (current|velocity|position)")),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
pub struct Faults(pub u8);
impl Faults {
pub const SENSOR_ERR: u8 = 0x01;
pub const OVERCURRENT: u8 = 0x02;
pub const PHASE_OVERCURRENT: u8 = 0x04;
pub const STALL: u8 = 0x08;
pub const OVERHEAT: u8 = 0x10;
pub const KNOWN_MASK: u8 = Self::SENSOR_ERR
| Self::OVERCURRENT
| Self::PHASE_OVERCURRENT
| Self::STALL
| Self::OVERHEAT;
const NAMES: [(u8, &'static str); 5] = [
(Self::SENSOR_ERR, "SensorErr"),
(Self::OVERCURRENT, "Overcurrent"),
(Self::PHASE_OVERCURRENT, "PhaseOvercurrent"),
(Self::STALL, "Stall"),
(Self::OVERHEAT, "Overheat"),
];
pub fn is_ok(self) -> bool {
self.0 == 0
}
pub fn sensor_err(self) -> bool {
self.0 & Self::SENSOR_ERR != 0
}
pub fn overcurrent(self) -> bool {
self.0 & Self::OVERCURRENT != 0
}
pub fn phase_overcurrent(self) -> bool {
self.0 & Self::PHASE_OVERCURRENT != 0
}
pub fn stall(self) -> bool {
self.0 & Self::STALL != 0
}
pub fn overheat(self) -> bool {
self.0 & Self::OVERHEAT != 0
}
pub fn unknown_bits(self) -> u8 {
self.0 & !Self::KNOWN_MASK
}
}
impl fmt::Display for Faults {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.is_ok() {
return f.write_str("OK");
}
let mut any = false;
for (bit, name) in Self::NAMES {
if self.0 & bit != 0 {
if any {
f.write_str(" | ")?;
}
f.write_str(name)?;
any = true;
}
}
let unknown = self.unknown_bits();
if unknown != 0 {
if any {
f.write_str(" | ")?;
}
write!(f, "0x{unknown:02X}")?;
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[non_exhaustive]
pub struct Feedback {
pub id: u8,
pub kind: ReplyKind,
pub mode: Option<Mode>,
pub mode_raw: u8,
pub current_a: f32,
pub speed_rpm: i16,
pub temp_c: Option<u8>,
pub position_deg: f32,
pub faults: Faults,
pub crc_ok: bool,
pub raw: Frame,
}
impl Feedback {
pub fn raw_hex(&self) -> String {
let mut s = String::with_capacity(self.raw.len() * 3 - 1);
for (i, b) in self.raw.iter().enumerate() {
if i > 0 {
s.push(' ');
}
let _ = fmt::Write::write_fmt(&mut s, format_args!("{b:02X}"));
}
s
}
pub fn mode_name(&self) -> String {
match self.mode {
Some(m) => m.to_string(),
None => format!("0x{:02X}", self.mode_raw),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq)]
#[non_exhaustive]
pub struct Telemetry {
pub fb: Option<Feedback>,
pub temp_c: Option<u8>,
pub position_deg: Option<f32>,
}
impl Telemetry {
pub fn absorb(&mut self, fb: Feedback) {
if let Some(t) = fb.temp_c {
self.temp_c = Some(t);
}
if fb.kind == ReplyKind::Drive {
self.position_deg = Some(fb.position_deg);
}
self.fb = Some(fb);
}
}
#[derive(Debug, Clone, Default)]
pub struct PositionAccumulator {
last: Option<f32>,
cumulative: f64,
}
impl PositionAccumulator {
pub fn new() -> Self {
Self::default()
}
pub fn update(&mut self, sample_deg: f32) -> f64 {
if !sample_deg.is_finite() {
return self.cumulative;
}
match self.last {
None => {
self.last = Some(sample_deg);
}
Some(prev) => {
let mut delta = (f64::from(sample_deg) - f64::from(prev)).rem_euclid(360.0);
if delta > 180.0 {
delta -= 360.0;
}
self.cumulative += delta;
self.last = Some(sample_deg);
}
}
self.cumulative
}
pub fn update_raw(&mut self, raw: u16) -> f64 {
self.update(raw_to_deg(raw))
}
pub fn cumulative_deg(&self) -> f64 {
self.cumulative
}
pub fn revolutions(&self) -> f64 {
self.cumulative / 360.0
}
pub fn reset(&mut self) {
self.last = None;
self.cumulative = 0.0;
}
pub fn max_unaliased_rpm(gap: std::time::Duration) -> f64 {
let secs = gap.as_secs_f64();
if secs <= 0.0 {
f64::INFINITY
} else {
30.0 / secs
}
}
}
#[cfg(test)]
mod tests {
use super::{PositionAccumulator, Telemetry};
use crate::Feedback;
use crate::protocol::{ReplyKind, parse_feedback};
fn fb(kind: ReplyKind) -> Feedback {
parse_feedback(&[0x01, 0x02, 0, 0, 0, 0x64, 0x28, 0x80, 0, 0], kind).expect("valid frame")
}
#[test]
fn absorb_retains_temperature_across_drive_replies() {
let mut t = Telemetry::default();
t.absorb(fb(ReplyKind::Drive));
assert_eq!(t.temp_c, None, "no query reply seen yet");
t.absorb(fb(ReplyKind::Query));
assert_eq!(t.temp_c, Some(40));
t.absorb(fb(ReplyKind::Drive));
assert_eq!(t.temp_c, Some(40), "a drive reply must not clear it");
assert_eq!(t.fb.map(|fb| fb.kind), Some(ReplyKind::Drive));
}
#[test]
fn absorb_keeps_hi_res_drive_angle_across_a_query_reply() {
let mut t = Telemetry::default();
t.absorb(fb(ReplyKind::Query));
assert_eq!(t.position_deg, None, "no hi-res drive reply seen yet");
t.absorb(fb(ReplyKind::Drive));
let hi_res = t.position_deg.expect("drive reply sets the hi-res angle");
t.absorb(fb(ReplyKind::Query));
assert_eq!(
t.position_deg,
Some(hi_res),
"a query reply must not downgrade the retained hi-res angle"
);
}
#[test]
fn first_sample_is_the_zero_reference() {
let mut acc = PositionAccumulator::new();
assert_eq!(acc.update(123.5), 0.0, "first sample yields 0 cumulative");
assert_eq!(acc.cumulative_deg(), 0.0);
assert_eq!(acc.revolutions(), 0.0);
}
#[test]
fn monotonic_forward_accumulates_past_a_full_turn() {
let mut acc = PositionAccumulator::new();
acc.update(0.0);
for deg in [90.0, 180.0, 270.0, 0.0, 90.0] {
acc.update(deg);
}
assert!((acc.cumulative_deg() - 450.0).abs() < 1e-3);
assert!(acc.cumulative_deg() > 360.0);
assert!((acc.revolutions() - 1.25).abs() < 1e-4);
}
#[test]
fn reverse_past_zero_goes_negative() {
let mut acc = PositionAccumulator::new();
acc.update(0.0);
for deg in [270.0, 180.0, 90.0, 0.0, 270.0] {
acc.update(deg);
}
assert!((acc.cumulative_deg() + 450.0).abs() < 1e-3);
assert!(acc.cumulative_deg() < 0.0);
assert!((acc.revolutions() + 1.25).abs() < 1e-4);
}
#[test]
fn seam_crossing_takes_the_short_arc() {
let mut acc = PositionAccumulator::new();
acc.update(359.0);
assert!((acc.update(1.0) - 2.0).abs() < 1e-3);
let mut acc = PositionAccumulator::new();
acc.update(1.0);
assert!((acc.update(359.0) + 2.0).abs() < 1e-3);
}
#[test]
fn reset_clears_reference_and_total() {
let mut acc = PositionAccumulator::new();
acc.update(10.0);
acc.update(100.0);
assert!(acc.cumulative_deg() > 0.0);
acc.reset();
assert_eq!(acc.cumulative_deg(), 0.0);
assert_eq!(acc.update(200.0), 0.0);
assert_eq!(acc.update(210.0), 10.0);
}
#[test]
fn non_finite_sample_is_ignored() {
let mut acc = PositionAccumulator::new();
acc.update(10.0);
acc.update(40.0); let before = acc.cumulative_deg();
assert_eq!(acc.update(f32::NAN), before);
assert_eq!(acc.update(f32::INFINITY), before);
assert_eq!(acc.update(f32::NEG_INFINITY), before);
assert_eq!(acc.cumulative_deg(), before);
assert!((acc.update(70.0) - (before + 30.0)).abs() < 1e-3);
}
#[test]
fn update_raw_matches_the_drive_reply_scale() {
let mut acc = PositionAccumulator::new();
assert_eq!(acc.update_raw(0), 0.0);
let via_raw = acc.update_raw(8_192);
let mut acc2 = PositionAccumulator::new();
acc2.update(crate::protocol::raw_to_deg(0));
let via_deg = acc2.update(crate::protocol::raw_to_deg(8_192));
assert_eq!(via_raw, via_deg);
assert!((via_raw - 90.0).abs() < 0.01);
}
#[test]
fn known_mask_is_the_union_of_the_named_bits() {
use super::Faults;
let named = Faults::NAMES.iter().fold(0u8, |acc, (bit, _)| acc | bit);
assert_eq!(
Faults::KNOWN_MASK,
named,
"KNOWN_MASK must cover exactly the named bits"
);
assert_eq!(Faults::KNOWN_MASK, 0x1F);
}
#[test]
fn unknown_bits_reports_only_undefined_bits() {
use super::Faults;
assert_eq!(Faults(0x00).unknown_bits(), 0x00);
assert_eq!(
Faults(0x1F).unknown_bits(),
0x00,
"every named bit is known"
);
assert_eq!(Faults(0x20).unknown_bits(), 0x20);
assert_eq!(
Faults(0x21).unknown_bits(),
0x20,
"a known bit alongside an unknown one"
);
}
#[test]
fn max_unaliased_rpm_is_the_180_deg_per_gap_ceiling() {
use std::time::Duration;
let ceiling = PositionAccumulator::max_unaliased_rpm(Duration::from_millis(100));
assert!((ceiling - 300.0).abs() < 1e-9);
assert_eq!(
PositionAccumulator::max_unaliased_rpm(Duration::ZERO),
f64::INFINITY
);
}
}