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::dsl::Program;
use crate::error::Error;
use crate::motor::MotorConfig;
use crate::motor::MotorProfile;
use crate::runtime::Runner;
use ph_curves::MonotonicCurveLut256;

/// A named, compile-time haptics definition emitted by code generation.
#[derive(Copy, Clone, Debug)]
pub struct CompiledHapticDef<'a> {
    /// Symbolic name from the source haptics definition file.
    pub name: &'a str,
    /// Typed DSL program ready for [`crate::Runner`].
    pub program: Program<'a, MonotonicCurveLut256>,
    /// Optional motor profile associated with this haptic.
    ///
    /// The runtime uses it for kick injection, level floors and ceilings, and
    /// gamma mapping. `None` disables all profile-driven runtime transforms.
    pub profile: Option<&'a MotorProfile>,
}

impl<'a> CompiledHapticDef<'a> {
    /// Build a compiled haptic definition.
    ///
    /// Program validity and motor compatibility are checked when constructing a
    /// runner, not here.
    pub const fn new(
        name: &'a str,
        program: Program<'a, MonotonicCurveLut256>,
        profile: Option<&'a MotorProfile>,
    ) -> Self {
        Self {
            name,
            program,
            profile,
        }
    }

    /// Build a [`Runner`] for this compiled haptic.
    pub fn runner(&'a self, motor: MotorConfig) -> Result<Runner<'a, MonotonicCurveLut256>, Error> {
        Runner::from_compiled(self, motor)
    }

    /// Build and immediately start a [`Runner`] for this compiled haptic.
    pub fn runner_started(
        &'a self,
        motor: MotorConfig,
        now_ms: u32,
    ) -> Result<Runner<'a, MonotonicCurveLut256>, Error> {
        Runner::from_compiled_started(self, motor, now_ms)
    }
}

/// Helper around a generated `COMPILED_HAPTICS` slice.
#[derive(Copy, Clone, Debug)]
pub struct CompiledCatalog<'a> {
    defs: &'a [CompiledHapticDef<'a>],
}

/// Lookup/runner errors for [`CompiledCatalog`].
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum CompiledCatalogError {
    /// Named haptic was not found.
    NotFound,
    /// Runner construction failed (e.g. motor mismatch).
    Runner(Error),
}

impl From<Error> for CompiledCatalogError {
    fn from(value: Error) -> Self {
        Self::Runner(value)
    }
}

impl core::fmt::Display for CompiledCatalogError {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        match self {
            Self::NotFound => f.write_str("no compiled haptic with that name"),
            Self::Runner(error) => write!(f, "runner construction failed: {error}"),
        }
    }
}

impl core::error::Error for CompiledCatalogError {
    fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
        match self {
            Self::NotFound => None,
            Self::Runner(error) => Some(error),
        }
    }
}

impl<'a> CompiledCatalog<'a> {
    /// Create a catalog from generated definitions.
    pub const fn new(defs: &'a [CompiledHapticDef<'a>]) -> Self {
        Self { defs }
    }

    /// Return all compiled definitions.
    pub const fn definitions(&self) -> &'a [CompiledHapticDef<'a>] {
        self.defs
    }

    /// Whether the catalog is empty.
    pub const fn is_empty(&self) -> bool {
        self.defs.is_empty()
    }

    /// Find the first compiled haptic with an ASCII case-insensitive name match.
    pub fn find(&self, name: &str) -> Option<&'a CompiledHapticDef<'a>> {
        let mut index = 0usize;
        while index < self.defs.len() {
            let def = &self.defs[index];
            if def.name.eq_ignore_ascii_case(name) {
                return Some(def);
            }
            index += 1;
        }
        None
    }

    /// Find a compiled haptic by name and return an error if missing.
    pub fn require(&self, name: &str) -> Result<&'a CompiledHapticDef<'a>, CompiledCatalogError> {
        self.find(name).ok_or(CompiledCatalogError::NotFound)
    }

    /// Build a [`Runner`] for a named compiled haptic.
    pub fn runner(
        &self,
        name: &str,
        motor: MotorConfig,
    ) -> Result<Runner<'a, MonotonicCurveLut256>, CompiledCatalogError> {
        let def = self.require(name)?;
        def.runner(motor).map_err(CompiledCatalogError::from)
    }

    /// Build and start a [`Runner`] for a named compiled haptic.
    pub fn runner_started(
        &self,
        name: &str,
        motor: MotorConfig,
        now_ms: u32,
    ) -> Result<Runner<'a, MonotonicCurveLut256>, CompiledCatalogError> {
        let def = self.require(name)?;
        def.runner_started(motor, now_ms)
            .map_err(CompiledCatalogError::from)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::dsl::Instruction;
    use crate::motor::{ErmConfig, LraConfig, MotorKind};

    #[test]
    fn compiled_definition_runner_helpers_work() {
        let instructions: [Instruction<MonotonicCurveLut256>; 1] = [Instruction::hold(10, 20_000)];
        let program = Program::new(MotorKind::Erm, &instructions);
        let def = CompiledHapticDef::new("tap", program, None);

        let mut runner = def
            .runner_started(MotorConfig::Erm(ErmConfig::new(255)), 10)
            .unwrap();
        let frame = runner.poll(10);
        assert!(frame.is_active());
    }

    #[test]
    fn compiled_catalog_lookup_and_runner_work() {
        let a_instructions: [Instruction<MonotonicCurveLut256>; 1] =
            [Instruction::hold(10, 30_000)];
        let b_instructions: [Instruction<MonotonicCurveLut256>; 1] =
            [Instruction::hold(10, 40_000)];
        let a = CompiledHapticDef::new("tap", Program::new(MotorKind::Erm, &a_instructions), None);
        let b = CompiledHapticDef::new("buzz", Program::new(MotorKind::Lra, &b_instructions), None);
        let defs = [a, b];
        let catalog = CompiledCatalog::new(&defs);

        let found = catalog.require("TAP").unwrap();
        assert_eq!(found.name, "tap");

        let mut runner = catalog
            .runner_started("tap", MotorConfig::Erm(ErmConfig::new(255)), 0)
            .unwrap();
        assert!(runner.poll(0).is_active());

        let err = catalog
            .runner("buzz", MotorConfig::Erm(ErmConfig::new(255)))
            .unwrap_err();
        assert_eq!(
            err,
            CompiledCatalogError::Runner(Error::MotorKindMismatch {
                expected: MotorKind::Lra,
                got: MotorKind::Erm
            })
        );

        let err = catalog
            .runner("missing", MotorConfig::Lra(LraConfig::new(100, 200)))
            .unwrap_err();
        assert_eq!(err, CompiledCatalogError::NotFound);
    }
}