ph-haptics 0.1.0

Host-compiled haptics DSL and no-std, no-alloc scheduling runtime modeling ERM and LRA motors using ph-curves
Documentation
use ph_haptics::{DEFAULT_ERM_PROFILE, DEFAULT_LRA_PROFILE};

#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Profile {
    pub(crate) name: String,
    /// Motor family this profile is tuned for. Set explicitly via `motor` in
    /// TOML, or inferred from an `lra_` name prefix for backwards compatibility.
    pub(crate) motor: Motor,
    pub(crate) kick_ms: u16,
    /// Kick level as a percentage (0.0..=100.0).
    pub(crate) kick_level: f32,
    /// Minimum running level as a percentage (0.0..=100.0).
    pub(crate) min_level: f32,
    /// Maximum level as a percentage (0.0..=100.0).
    pub(crate) max_level: f32,
    pub(crate) gamma: Option<String>,
    pub(crate) ramp_step_ms: u16,
    pub(crate) min_dt_ms: u16,
    /// Duty quantization step as a percentage (0.0..=100.0).
    pub(crate) duty_step: f32,
}

impl Profile {
    pub(crate) fn default_erm_named(name: &str) -> Self {
        let base = DEFAULT_ERM_PROFILE;
        Self {
            name: name.to_owned(),
            motor: Motor::Erm,
            kick_ms: base.kick_ms,
            kick_level: frac_u8_to_percent(base.kick_frac),
            min_level: frac_u8_to_percent(base.min_run_frac),
            max_level: frac_u8_to_percent(base.max_frac),
            gamma: None,
            ramp_step_ms: base.ramp_step_ms,
            min_dt_ms: base.min_dt_ms,
            duty_step: frac_u8_to_percent(base.duty_step),
        }
    }

    pub(crate) fn default_lra_named(name: &str) -> Self {
        let base = DEFAULT_LRA_PROFILE;
        Self {
            name: name.to_owned(),
            motor: Motor::Lra,
            kick_ms: base.kick_ms,
            kick_level: frac_u8_to_percent(base.kick_frac),
            min_level: frac_u8_to_percent(base.min_run_frac),
            max_level: frac_u8_to_percent(base.max_frac),
            gamma: None,
            ramp_step_ms: base.ramp_step_ms,
            min_dt_ms: base.min_dt_ms,
            duty_step: frac_u8_to_percent(base.duty_step),
        }
    }

    /// Convert a percentage field (0..100) to a u8 fraction (0..255).
    pub(crate) fn percent_to_frac_u8(pct: f32) -> u8 {
        (pct / 100.0 * 255.0).round() as u8
    }
}

fn frac_u8_to_percent(frac: u8) -> f32 {
    frac as f32 / 255.0 * 100.0
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum Motor {
    Erm,
    Lra,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum LoopMode {
    Once,
    Forever,
    Count(u32),
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum Rounding {
    Nearest,
    Floor,
    Ceil,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum CurveRef {
    Named(String),
    Gamma,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum Inst {
    Ramp {
        line: u32,
        duration_ms: u32,
        from: u16,
        to: u16,
        curve: CurveRef,
        step: Option<u16>,
        rounding: Option<Rounding>,
        min_dt_ms: Option<u32>,
        lra_hz: Option<u16>,
        lra_hz_to: Option<u16>,
    },
    Hold {
        line: u32,
        duration_ms: u32,
        level: u16,
        lra_hz: Option<u16>,
    },
    Pause {
        line: u32,
        duration_ms: u32,
    },
}

impl Inst {
    /// Source line this instruction was parsed from.
    pub(crate) fn line(&self) -> u32 {
        match self {
            Self::Ramp { line, .. } | Self::Hold { line, .. } | Self::Pause { line, .. } => *line,
        }
    }

    /// Segment duration in milliseconds.
    pub(crate) fn duration_ms(&self) -> u32 {
        match self {
            Self::Ramp { duration_ms, .. }
            | Self::Hold { duration_ms, .. }
            | Self::Pause { duration_ms, .. } => *duration_ms,
        }
    }

    /// LRA-only options set on this instruction, if any.
    pub(crate) fn lra_option(&self) -> Option<&'static str> {
        match self {
            Self::Ramp {
                lra_hz, lra_hz_to, ..
            } => {
                if lra_hz.is_some() {
                    Some("lra_hz")
                } else if lra_hz_to.is_some() {
                    Some("lra_hz_to")
                } else {
                    None
                }
            }
            Self::Hold { lra_hz, .. } => lra_hz.is_some().then_some("lra_hz"),
            Self::Pause { .. } => None,
        }
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct Haptic {
    pub(crate) line: u32,
    pub(crate) name: String,
    pub(crate) motor: Motor,
    pub(crate) loop_mode: LoopMode,
    pub(crate) profile: Option<String>,
    pub(crate) instructions: Vec<Inst>,
}