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 crate::model::{Motor, Profile};
use crate::names::validate_name;
use serde::Deserialize;
use std::collections::BTreeMap;
use std::fs;
use std::path::Path;

const BUILTIN_LIBRARY_PROFILES_TOML: &str =
    include_str!("../../../assets/phh-library/motor_profiles.toml");

#[derive(Debug, Deserialize)]
struct ProfilesToml {
    #[serde(default)]
    profiles: BTreeMap<String, ProfileToml>,
}

#[derive(Debug, Default, Deserialize, Clone)]
struct ProfileToml {
    motor: Option<String>,
    kick_ms: Option<u16>,
    kick_level: Option<f32>,
    min_level: Option<f32>,
    max_level: Option<f32>,
    gamma: Option<String>,
    ramp_step_ms: Option<u16>,
    min_dt_ms: Option<u16>,
    duty_step: Option<f32>,
}

pub(crate) fn load_profiles_toml(path: Option<&Path>) -> Result<Vec<Profile>, String> {
    let mut profiles = builtin_profiles()?;

    let Some(path) = path else {
        return Ok(profiles);
    };

    let source =
        fs::read_to_string(path).map_err(|error| format!("{}: {}", path.display(), error))?;
    let overlay = parse_profiles_toml_raw(&source)?;
    profiles = merge_profile_overlays(profiles, overlay)?;
    Ok(profiles)
}

pub(crate) fn parse_profiles_toml_source(source: &str) -> Result<Vec<Profile>, String> {
    let raw = parse_profiles_toml_raw(source)?;
    let mut profiles = Vec::with_capacity(raw.len());
    for (name, fields) in raw {
        validate_name(&name, 0, "profile")?;
        let profile = profile_from_partial(&name, &fields)?;
        profiles.push(profile);
    }
    Ok(profiles)
}

fn parse_profiles_toml_raw(source: &str) -> Result<BTreeMap<String, ProfileToml>, String> {
    let parsed: ProfilesToml =
        toml::from_str(source).map_err(|error| format!("profiles toml: {error}"))?;
    Ok(parsed.profiles)
}

/// Resolve which motor family a profile is tuned for.
///
/// An explicit `motor = "erm" | "lra"` wins. Otherwise we fall back to the
/// historical `lra_` name-prefix convention, which is why `motor` is worth
/// setting: a profile named `my_lra_tuning` silently gets ERM defaults.
fn resolve_motor(name: &str, raw: &ProfileToml, base: Option<Motor>) -> Result<Motor, String> {
    match raw.motor.as_deref() {
        Some("erm") => Ok(Motor::Erm),
        Some("lra") => Ok(Motor::Lra),
        Some(other) => Err(format!(
            "profile `{name}`: invalid motor `{other}` (expected `erm` or `lra`)"
        )),
        None => Ok(base.unwrap_or(if name.starts_with("lra_") {
            Motor::Lra
        } else {
            Motor::Erm
        })),
    }
}

fn default_profile_for(name: &str, motor: Motor) -> Profile {
    match motor {
        Motor::Erm => Profile::default_erm_named(name),
        Motor::Lra => Profile::default_lra_named(name),
    }
}

fn apply_overlay(base: Profile, raw: &ProfileToml, motor: Motor) -> Profile {
    Profile {
        name: base.name,
        motor,
        kick_ms: raw.kick_ms.unwrap_or(base.kick_ms),
        kick_level: raw.kick_level.unwrap_or(base.kick_level),
        min_level: raw.min_level.unwrap_or(base.min_level),
        max_level: raw.max_level.unwrap_or(base.max_level),
        gamma: raw.gamma.clone().or(base.gamma),
        ramp_step_ms: raw.ramp_step_ms.unwrap_or(base.ramp_step_ms),
        min_dt_ms: raw.min_dt_ms.unwrap_or(base.min_dt_ms),
        duty_step: raw.duty_step.unwrap_or(base.duty_step),
    }
}

fn profile_from_partial(name: &str, raw: &ProfileToml) -> Result<Profile, String> {
    let motor = resolve_motor(name, raw, None)?;
    let profile = apply_overlay(default_profile_for(name, motor), raw, motor);
    validate_profile(&profile)?;
    Ok(profile)
}

fn validate_profile(profile: &Profile) -> Result<(), String> {
    let name = &profile.name;
    for (field, value) in [
        ("kick_level", profile.kick_level),
        ("min_level", profile.min_level),
        ("max_level", profile.max_level),
        ("duty_step", profile.duty_step),
    ] {
        if !(0.0..=100.0).contains(&value) {
            return Err(format!(
                "profile `{name}`: {field} = {value} is outside 0..100"
            ));
        }
    }
    if profile.min_level > profile.max_level {
        return Err(format!(
            "profile `{name}`: min_level ({}) > max_level ({})",
            profile.min_level, profile.max_level
        ));
    }
    Ok(())
}

fn builtin_profiles() -> Result<Vec<Profile>, String> {
    parse_profiles_toml_source(BUILTIN_LIBRARY_PROFILES_TOML)
}

fn merge_profile_overlays(
    base: Vec<Profile>,
    overlay: BTreeMap<String, ProfileToml>,
) -> Result<Vec<Profile>, String> {
    let mut merged = BTreeMap::new();
    for profile in base {
        merged.insert(profile.name.clone(), profile);
    }
    for (name, raw) in overlay {
        validate_name(&name, 0, "profile")?;
        let profile = if let Some(existing) = merged.remove(&name) {
            let motor = resolve_motor(&name, &raw, Some(existing.motor))?;
            // Motor flip must retune from that family's defaults; otherwise
            // kick/min/gamma stay on the old motor's values under a new label.
            let base = if motor != existing.motor {
                default_profile_for(&name, motor)
            } else {
                existing
            };
            let profile = apply_overlay(base, &raw, motor);
            validate_profile(&profile)?;
            profile
        } else {
            profile_from_partial(&name, &raw)?
        };
        merged.insert(name, profile);
    }
    Ok(merged.into_values().collect())
}

#[cfg(test)]
mod tests {
    use super::*;
    use ph_haptics::{DEFAULT_ERM_PROFILE, DEFAULT_LRA_PROFILE};

    #[test]
    fn profiles_toml_uses_erm_defaults() {
        let toml = "\
[profiles.mobile]
max_level = 86.3
";
        let profiles = parse_profiles_toml_source(toml).unwrap();
        assert_eq!(profiles.len(), 1);
        let profile = &profiles[0];
        assert!((profile.max_level - 86.3).abs() < 0.01);
        assert_eq!(profile.kick_ms, DEFAULT_ERM_PROFILE.kick_ms);
    }

    #[test]
    fn profiles_toml_lra_prefix_uses_lra_defaults() {
        let toml = "\
[profiles.lra_custom]
max_level = 70.0
";
        let profiles = parse_profiles_toml_source(toml).unwrap();
        assert_eq!(profiles.len(), 1);
        let profile = &profiles[0];
        assert!((profile.max_level - 70.0).abs() < 0.01);
        assert_eq!(profile.kick_ms, DEFAULT_LRA_PROFILE.kick_ms);
    }

    #[test]
    fn explicit_motor_field_overrides_name_prefix() {
        // `cross_lra` is an LRA profile whose name does not start with `lra_`;
        // the prefix heuristic alone silently gave it ERM defaults.
        let toml = "\
[profiles.cross_lra]
motor = \"lra\"
max_level = 85.0
";
        let profiles = parse_profiles_toml_source(toml).unwrap();
        assert_eq!(profiles[0].motor, Motor::Lra);
        assert_eq!(profiles[0].kick_ms, DEFAULT_LRA_PROFILE.kick_ms);
    }

    #[test]
    fn rejects_invalid_motor_value() {
        let toml = "\
[profiles.weird]
motor = \"piezo\"
";
        let error = parse_profiles_toml_source(toml).unwrap_err();
        assert!(error.contains("invalid motor"), "error: {error}");
    }

    #[test]
    fn builtin_profiles_all_declare_a_motor_kind() {
        let profiles = builtin_profiles().unwrap();
        for profile in &profiles {
            let expected = if profile.name.contains("lra") {
                Motor::Lra
            } else {
                Motor::Erm
            };
            assert_eq!(profile.motor, expected, "profile `{}`", profile.name);
        }
    }

    #[test]
    fn builtin_profile_pack_is_loaded_by_default() {
        let profiles = load_profiles_toml(None).unwrap();
        assert!(profiles.len() >= 10);
        assert!(profiles.iter().any(|profile| profile.name == "erm_ui_soft"));
        assert!(
            profiles
                .iter()
                .any(|profile| profile.name == "lra_ui_precision")
        );
    }

    #[test]
    fn overlay_only_max_level_preserves_builtin_fields() {
        let base = builtin_profiles().unwrap();
        let builtin = base
            .iter()
            .find(|profile| profile.name == "erm_ui_soft")
            .cloned()
            .unwrap();
        let overlay = parse_profiles_toml_raw(
            "\
[profiles.erm_ui_soft]
max_level = 78.0
",
        )
        .unwrap();
        let merged = merge_profile_overlays(base, overlay).unwrap();
        let profile = merged
            .iter()
            .find(|profile| profile.name == "erm_ui_soft")
            .unwrap();
        assert!((profile.max_level - 78.0).abs() < 0.01);
        assert_eq!(profile.gamma, builtin.gamma);
        assert_eq!(profile.kick_ms, builtin.kick_ms);
        assert!((profile.min_level - builtin.min_level).abs() < 0.01);
        assert_eq!(profile.gamma.as_deref(), Some("smoothstep"));
        assert_eq!(profile.kick_ms, 7);
        assert!((profile.min_level - 20.0).abs() < 0.01);
    }

    #[test]
    fn explicit_toml_adds_new_profile() {
        let base = builtin_profiles().unwrap();
        let overlay = parse_profiles_toml_raw(
            "\
[profiles.custom_mobile]
max_level = 70.6
",
        )
        .unwrap();
        let merged = merge_profile_overlays(base, overlay).unwrap();
        assert!(merged.iter().any(|profile| profile.name == "custom_mobile"));
    }

    #[test]
    fn overlay_motor_flip_retunes_from_target_defaults() {
        let base = builtin_profiles().unwrap();
        let overlay = parse_profiles_toml_raw(
            "\
[profiles.erm_ui_soft]
motor = \"lra\"
",
        )
        .unwrap();
        let merged = merge_profile_overlays(base, overlay).unwrap();
        let profile = merged
            .iter()
            .find(|profile| profile.name == "erm_ui_soft")
            .unwrap();
        let expected = Profile::default_lra_named("erm_ui_soft");
        assert_eq!(profile.motor, Motor::Lra);
        assert_eq!(profile.kick_ms, expected.kick_ms);
        assert!((profile.min_level - expected.min_level).abs() < 0.01);
        assert_eq!(profile.gamma, expected.gamma);
        // Must not keep ERM builtin kick (7ms) after flipping to LRA.
        assert_ne!(profile.kick_ms, 7);
    }
}