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)
}
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))?;
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() {
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);
assert_ne!(profile.kick_ms, 7);
}
}