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_curves::MonotonicCurveLut256;

/// Supported motor families.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum MotorKind {
    /// Eccentric rotating mass motor.
    Erm,
    /// Linear resonant actuator.
    Lra,
}

/// Motor behavior profile shared by host-side generation and runtime playback.
///
/// Kick, run-range, and gamma fields affect runtime command evaluation.
/// `ramp_step_ms`, `min_dt_ms`, and `duty_step` are generator defaults copied
/// into emitted ramp instructions; changing them after generation does not
/// change an existing program's schedule.
#[derive(Copy, Clone, Debug)]
pub struct MotorProfile {
    /// Kick duration in milliseconds.
    pub kick_ms: u16,
    /// Kick output fraction (`0..=255`).
    pub kick_frac: u8,
    /// Minimum running output fraction (`0..=255`).
    ///
    /// A non-zero runtime level below this floor is snapped to off.
    pub min_run_frac: u8,
    /// Maximum runtime output fraction (`0..=255`).
    pub max_frac: u8,
    /// Optional gamma curve reference for perceptual mapping.
    pub gamma_curve: Option<&'static MonotonicCurveLut256>,
    /// Generator fallback cadence for ramps in milliseconds.
    pub ramp_step_ms: u16,
    /// Generator default minimum time between ramp deadlines.
    pub min_dt_ms: u16,
    /// Generator default output quantization step (`0..=255`).
    pub duty_step: u8,
}

impl MotorProfile {
    /// Create a motor profile without validating relationships between fields.
    #[allow(clippy::too_many_arguments)]
    pub const fn new(
        kick_ms: u16,
        kick_frac: u8,
        min_run_frac: u8,
        max_frac: u8,
        gamma_curve: Option<&'static MonotonicCurveLut256>,
        ramp_step_ms: u16,
        min_dt_ms: u16,
        duty_step: u8,
    ) -> Self {
        Self {
            kick_ms,
            kick_frac,
            min_run_frac,
            max_frac,
            gamma_curve,
            ramp_step_ms,
            min_dt_ms,
            duty_step,
        }
    }

    /// Default ERM-oriented profile values.
    pub const fn default_erm() -> Self {
        Self {
            kick_ms: 8,
            kick_frac: 255,
            min_run_frac: 64,
            max_frac: 255,
            gamma_curve: None,
            ramp_step_ms: 2,
            min_dt_ms: 1,
            duty_step: 8,
        }
    }

    /// Default LRA-oriented profile values.
    pub const fn default_lra() -> Self {
        Self {
            kick_ms: 5,
            kick_frac: 230,
            min_run_frac: 51,
            max_frac: 230,
            gamma_curve: None,
            ramp_step_ms: 1,
            min_dt_ms: 1,
            duty_step: 6,
        }
    }
}

impl Default for MotorProfile {
    fn default() -> Self {
        Self::default_erm()
    }
}

/// Built-in default profile tuned for ERM behavior.
pub const DEFAULT_ERM_PROFILE: MotorProfile = MotorProfile::default_erm();

/// Built-in default profile tuned for LRA behavior.
pub const DEFAULT_LRA_PROFILE: MotorProfile = MotorProfile::default_lra();

/// ERM driver scaling config.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub struct ErmConfig {
    /// Maximum duty value exposed by the driver.
    pub max_duty: u16,
}

impl ErmConfig {
    /// Create ERM config.
    pub const fn new(max_duty: u16) -> Self {
        Self { max_duty }
    }
}

/// LRA driver scaling config.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub struct LraConfig {
    /// Maximum amplitude value exposed by the driver.
    pub max_amplitude: u16,
    /// Default resonant drive frequency.
    pub resonant_hz: u16,
}

impl LraConfig {
    /// Create LRA config.
    pub const fn new(max_amplitude: u16, resonant_hz: u16) -> Self {
        Self {
            max_amplitude,
            resonant_hz,
        }
    }
}

/// Motor configuration used by the runtime.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum MotorConfig {
    /// ERM motor config.
    Erm(ErmConfig),
    /// LRA motor config.
    Lra(LraConfig),
}

impl MotorConfig {
    /// Configured motor type.
    pub const fn kind(&self) -> MotorKind {
        match self {
            Self::Erm(_) => MotorKind::Erm,
            Self::Lra(_) => MotorKind::Lra,
        }
    }

    /// Map a logical level onto a driver command.
    ///
    /// A non-zero level that scales down to a zero duty/amplitude reports
    /// [`DriveCommand::Off`] rather than a zero-valued drive command, so callers
    /// can treat `Off` as the single "power down the driver" signal.
    pub(crate) fn drive(&self, level: u16, lra_frequency_hz: Option<u16>) -> DriveCommand {
        if level == 0 {
            return DriveCommand::Off;
        }

        match self {
            Self::Erm(config) => match scale(level, config.max_duty) {
                0 => DriveCommand::Off,
                duty => DriveCommand::Erm { duty },
            },
            Self::Lra(config) => match scale(level, config.max_amplitude) {
                0 => DriveCommand::Off,
                amplitude => DriveCommand::Lra {
                    amplitude,
                    frequency_hz: lra_frequency_hz.unwrap_or(config.resonant_hz),
                },
            },
        }
    }
}

/// Runtime command to apply to the motor driver.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum DriveCommand {
    /// Disable output.
    Off,
    /// ERM duty command.
    Erm {
        /// Driver duty value in `0..=ErmConfig::max_duty`.
        duty: u16,
    },
    /// LRA amplitude + frequency command.
    Lra {
        /// Driver amplitude value in `0..=LraConfig::max_amplitude`.
        amplitude: u16,
        /// Drive frequency in Hz.
        frequency_hz: u16,
    },
}

fn scale(level: u16, max: u16) -> u16 {
    let value = (u32::from(level) * u32::from(max) + u32::from(u16::MAX) / 2) / u32::from(u16::MAX);
    value as u16
}

/// Convert a 0..=255 fraction to a u16 level.
pub(crate) fn frac_to_level(frac: u8) -> u16 {
    (((u32::from(frac) * u32::from(u16::MAX)) + 127) / 255) as u16
}