Skip to main content

ph_haptics/
motor.rs

1use ph_curves::MonotonicCurveLut256;
2
3/// Supported motor families.
4#[derive(Copy, Clone, Debug, Eq, PartialEq)]
5pub enum MotorKind {
6    /// Eccentric rotating mass motor.
7    Erm,
8    /// Linear resonant actuator.
9    Lra,
10}
11
12/// Motor behavior profile shared by host-side generation and runtime playback.
13///
14/// Kick, run-range, and gamma fields affect runtime command evaluation.
15/// `ramp_step_ms`, `min_dt_ms`, and `duty_step` are generator defaults copied
16/// into emitted ramp instructions; changing them after generation does not
17/// change an existing program's schedule.
18#[derive(Copy, Clone, Debug)]
19pub struct MotorProfile {
20    /// Kick duration in milliseconds.
21    pub kick_ms: u16,
22    /// Kick output fraction (`0..=255`).
23    pub kick_frac: u8,
24    /// Minimum running output fraction (`0..=255`).
25    ///
26    /// A non-zero runtime level below this floor is snapped to off.
27    pub min_run_frac: u8,
28    /// Maximum runtime output fraction (`0..=255`).
29    pub max_frac: u8,
30    /// Optional gamma curve reference for perceptual mapping.
31    pub gamma_curve: Option<&'static MonotonicCurveLut256>,
32    /// Generator fallback cadence for ramps in milliseconds.
33    pub ramp_step_ms: u16,
34    /// Generator default minimum time between ramp deadlines.
35    pub min_dt_ms: u16,
36    /// Generator default output quantization step (`0..=255`).
37    pub duty_step: u8,
38}
39
40impl MotorProfile {
41    /// Create a motor profile without validating relationships between fields.
42    #[allow(clippy::too_many_arguments)]
43    pub const fn new(
44        kick_ms: u16,
45        kick_frac: u8,
46        min_run_frac: u8,
47        max_frac: u8,
48        gamma_curve: Option<&'static MonotonicCurveLut256>,
49        ramp_step_ms: u16,
50        min_dt_ms: u16,
51        duty_step: u8,
52    ) -> Self {
53        Self {
54            kick_ms,
55            kick_frac,
56            min_run_frac,
57            max_frac,
58            gamma_curve,
59            ramp_step_ms,
60            min_dt_ms,
61            duty_step,
62        }
63    }
64
65    /// Default ERM-oriented profile values.
66    pub const fn default_erm() -> Self {
67        Self {
68            kick_ms: 8,
69            kick_frac: 255,
70            min_run_frac: 64,
71            max_frac: 255,
72            gamma_curve: None,
73            ramp_step_ms: 2,
74            min_dt_ms: 1,
75            duty_step: 8,
76        }
77    }
78
79    /// Default LRA-oriented profile values.
80    pub const fn default_lra() -> Self {
81        Self {
82            kick_ms: 5,
83            kick_frac: 230,
84            min_run_frac: 51,
85            max_frac: 230,
86            gamma_curve: None,
87            ramp_step_ms: 1,
88            min_dt_ms: 1,
89            duty_step: 6,
90        }
91    }
92}
93
94impl Default for MotorProfile {
95    fn default() -> Self {
96        Self::default_erm()
97    }
98}
99
100/// Built-in default profile tuned for ERM behavior.
101pub const DEFAULT_ERM_PROFILE: MotorProfile = MotorProfile::default_erm();
102
103/// Built-in default profile tuned for LRA behavior.
104pub const DEFAULT_LRA_PROFILE: MotorProfile = MotorProfile::default_lra();
105
106/// ERM driver scaling config.
107#[derive(Copy, Clone, Debug, Eq, PartialEq)]
108pub struct ErmConfig {
109    /// Maximum duty value exposed by the driver.
110    pub max_duty: u16,
111}
112
113impl ErmConfig {
114    /// Create ERM config.
115    pub const fn new(max_duty: u16) -> Self {
116        Self { max_duty }
117    }
118}
119
120/// LRA driver scaling config.
121#[derive(Copy, Clone, Debug, Eq, PartialEq)]
122pub struct LraConfig {
123    /// Maximum amplitude value exposed by the driver.
124    pub max_amplitude: u16,
125    /// Default resonant drive frequency.
126    pub resonant_hz: u16,
127}
128
129impl LraConfig {
130    /// Create LRA config.
131    pub const fn new(max_amplitude: u16, resonant_hz: u16) -> Self {
132        Self {
133            max_amplitude,
134            resonant_hz,
135        }
136    }
137}
138
139/// Motor configuration used by the runtime.
140#[derive(Copy, Clone, Debug, Eq, PartialEq)]
141pub enum MotorConfig {
142    /// ERM motor config.
143    Erm(ErmConfig),
144    /// LRA motor config.
145    Lra(LraConfig),
146}
147
148impl MotorConfig {
149    /// Configured motor type.
150    pub const fn kind(&self) -> MotorKind {
151        match self {
152            Self::Erm(_) => MotorKind::Erm,
153            Self::Lra(_) => MotorKind::Lra,
154        }
155    }
156
157    /// Map a logical level onto a driver command.
158    ///
159    /// A non-zero level that scales down to a zero duty/amplitude reports
160    /// [`DriveCommand::Off`] rather than a zero-valued drive command, so callers
161    /// can treat `Off` as the single "power down the driver" signal.
162    pub(crate) fn drive(&self, level: u16, lra_frequency_hz: Option<u16>) -> DriveCommand {
163        if level == 0 {
164            return DriveCommand::Off;
165        }
166
167        match self {
168            Self::Erm(config) => match scale(level, config.max_duty) {
169                0 => DriveCommand::Off,
170                duty => DriveCommand::Erm { duty },
171            },
172            Self::Lra(config) => match scale(level, config.max_amplitude) {
173                0 => DriveCommand::Off,
174                amplitude => DriveCommand::Lra {
175                    amplitude,
176                    frequency_hz: lra_frequency_hz.unwrap_or(config.resonant_hz),
177                },
178            },
179        }
180    }
181}
182
183/// Runtime command to apply to the motor driver.
184#[derive(Copy, Clone, Debug, Eq, PartialEq)]
185pub enum DriveCommand {
186    /// Disable output.
187    Off,
188    /// ERM duty command.
189    Erm {
190        /// Driver duty value in `0..=ErmConfig::max_duty`.
191        duty: u16,
192    },
193    /// LRA amplitude + frequency command.
194    Lra {
195        /// Driver amplitude value in `0..=LraConfig::max_amplitude`.
196        amplitude: u16,
197        /// Drive frequency in Hz.
198        frequency_hz: u16,
199    },
200}
201
202fn scale(level: u16, max: u16) -> u16 {
203    let value = (u32::from(level) * u32::from(max) + u32::from(u16::MAX) / 2) / u32::from(u16::MAX);
204    value as u16
205}
206
207/// Convert a 0..=255 fraction to a u16 level.
208pub(crate) fn frac_to_level(frac: u8) -> u16 {
209    (((u32::from(frac) * u32::from(u16::MAX)) + 127) / 255) as u16
210}