ph-haptics is a host-compiled haptics DSL and deterministic no_std runtime
that turns declarative ERM/LRA patterns into timed motor-control commands for
embedded applications.
Version 0.1.0 is available on
crates.io, with API documentation on
docs.rs. The host-side mock
machine in mock/
provides the crate's contract proof.
It replaces hand-written timing state machines: patterns are validated and
compiled into static Rust data on the host, then evaluated on the target
without parsing, allocation, or fixed-rate polling. It is a scheduling and
motor-modeling layer, not a hardware layer: firmware maps the emitted
commands onto its own PWM, GPIO, bus, or motor-driver hardware. See the
formal ph-haptics contract.
- Curve shaping comes from
ph-curves. - Works with both ERM and LRA motor types.
- Runtime is tickless for ramp segments (via
ph-curves::Tickless). - Text/script compilation is handled by
ph-haptics-gen(not the runtime crate).
Add the runtime to embedded consumers with default features only:
cargo add ph-haptics@0.1.0The gen feature is reserved for host-side generation commands and tooling;
do not enable it in a firmware dependency.
| Path | Role |
|---|---|
src/ |
Runtime library (Runner, motors, compiled catalog types) |
src/bin/ph-haptics-gen.rs |
Host-only .phh → Rust generator (--features gen) |
assets/phh-library/ |
Curated .phh catalog + builtin motor_profiles.toml |
mock/ |
Host-side deterministic contract proof (virtual clock + golden traces) |
docs/ |
Contract and design notes (contract, compiler, public API, architecture) |
scripts/ |
Canonical CI (ci.sh) and mock verify (mock-verify.sh) |
All textual/script compilation lives in the generator binary. Install the matching published version for host-side use:
cargo install ph-haptics --version 0.1.0 --features gen --bin ph-haptics-genFrom a source checkout, run the generator with Cargo. This example uses a real library asset:
cargo run --features gen --bin ph-haptics-gen -- \
--input assets/phh-library/erm_ui_micro.phh \
--output src/haptics_compiled.rs \
--curves-module crate::curvesOptional --curves-file <path> enables strict curve-symbol validation against a
ph-curves-gen output file. Omit it unless you have such a generated file.
This takes one text file with multiple definitions and emits Rust constants:
- one
MotorProfilestatic per resolved profile (built-in + optional overlay) - one instruction array +
Program+CompiledHapticDefperhaptic COMPILED_HAPTICSarray containing all compiled hapticsCOMPILED_CATALOGready-to-useCompiledCatalogoverCOMPILED_HAPTICS
By default, ph-haptics-gen includes the built-in profile pack from
assets/phh-library/motor_profiles.toml.
Use --profiles-toml <path> to add or override profiles.
Profiles are defined in TOML:
[profiles.handheld_erm]
motor = "erm"
kick_ms = 8
kick_level = 100.0
min_level = 25.1
max_level = 100.0
gamma = "ease_in_quad"
ramp_step_ms = 2
min_dt_ms = 1
duty_step = 3.1All level/duty fields are percentages (0..100). At codegen they become runtime
MotorProfile fraction fields (kick_frac, min_run_frac, max_frac,
duty_step).
motor ("erm" or "lra") selects which built-in defaults fill in omitted
fields, and is checked against every haptic that references the profile — a
motor=lra haptic cannot use an ERM-tuned profile. Prefer setting it
explicitly. If omitted, names starting with lra_ inherit
DEFAULT_LRA_PROFILE and everything else inherits DEFAULT_ERM_PROFILE, which
is easy to get wrong: a profile named cross_lra or my_lra_tuning silently
inherits the ERM defaults.
Top-level directives:
haptic <name> motor=<erm|lra> [profile=<name>] [loop=<once|forever|N>]include "path/to/file.phh"— inline another.phhfile (recursive, jailed under the top-level input directory). Each file is expanded at most once, so a file reached through two different paths (a diamond) is inlined a single time. A file that includes itself, directly or transitively, is a cycle and is an error.
Inside each haptic block:
ramp <duration_ms> <from%> <to%> <curve|@gamma> [step=<u16>] [rounding=<nearest|floor|ceil>] [min_dt=<u32>] [lra_hz=<u16>] [lra_hz_to=<u16>]hold <duration_ms> <level%> [lra_hz=<u16>]pause <duration_ms>use <name>— inline instructions from a previously defined haptic (same motor kind)repeat N/endrepeat— duplicate enclosed instructions N times (no nesting; N ≤ 1024)end
All level values (from, to, level) are percentages 0..100 (decimals allowed,
e.g. 45.78). They map to the internal 0..=u16::MAX range.
lra_hz / lra_hz_to are LRA-only and are rejected on a motor=erm haptic
(the runtime would silently ignore them). loop=N is rejected when N cycles
would exceed the runtime's u32 millisecond clock.
Example:
include "shared.phh"
haptic click motor=erm profile=handheld_erm loop=once
ramp 16 0 100 @gamma
pause 8
end
haptic buzz motor=lra loop=forever
hold 20 68.7 lra_hz=210
pause 10
end
haptic double_click motor=erm
repeat 2
use click
endrepeat
end
haptic alert motor=lra loop=3
ramp 30 0 100 ease_in_quad lra_hz=180 lra_hz_to=240
pause 20
end
Profile integration in generated catalogs and runtime command scheduling:
- kick is applied at runtime when the motor starts from rest or recovers from level 0 (
kick_ms+kick_level); the generator does not inject syntheticHoldinstructions @gammaresolves from the selected profile'sgamma- ramp default
stepcomes fromduty_step - ramp default
min_dtcomes frommin_dt_ms(orramp_step_msifmin_dt_ms=0) - non-zero levels are clamped to profile
[min_level, max_level] - ERM haptics without
profile=...useDEFAULT_ERM_PROFILE - LRA haptics without
profile=...useDEFAULT_LRA_PROFILE - when a profile provides
gamma, output level is remapped throughprofile.gamma_curve lra_hz_toenables linear frequency sweeps across a ramp segment
A curated .phh catalog is available in assets/phh-library/ with ERM/LRA
UI, notifications, alerts, gameplay, ambient loops, accessibility, wearable,
and cross-device pattern sets. A matching preset profile pack is included at
assets/phh-library/motor_profiles.toml. See assets/phh-library/README.md
for usage.
Runtime construction is intentionally centered on generated compiled assets:
use ph_haptics::{MotorConfig, ErmConfig};
include!("haptics_compiled.rs");
let mut runner = COMPILED_CATALOG
.runner_started("click", MotorConfig::Erm(ErmConfig::new(255)), 0)
.unwrap();
let frame = runner.poll(0);
// Firmware applies this abstract command through its own hardware adapter.
let command = frame.command;ph-haptics determines the abstract motor command that should be active and
the next time it may change. It does not configure peripherals, implement a
motor driver, or claim physical actuator behavior. Those responsibilities
belong to a firmware-owned hardware adapter.
The crate-level proof is the host-side mock machine in
mock/
(bash scripts/mock-verify.sh). Hardware
integrations live in downstream consumer repositories and are not evidence
for the crate contract.
Embedded runtime contract (default features):
#![no_std]— onlycore+ph-curves(default features off)no_alloc— no runtime allocation; no global allocator required- No parsing, peripheral control, or hardware-driver implementation in the library
ph-haptics-gen (--features gen) is host-only. Never enable gen on a
firmware dependency. Consumers use default-feature ph-haptics and map
DriveCommand values through their own hardware adapters.
See docs/architecture.md for the layer table and package
boundary rules. Generator internals: docs/compiler-design.md.
Public surface: docs/public-api.md.
Run the complete contributor validation suite from the repository root:
bash scripts/ci.shOn Windows, use Git Bash (or any bash). Details and gate list:
CONTRIBUTING.md. Mock-only proof command:
mock/README.md.
Report security issues via GitHub Security Advisories or a private maintainer contact; do not file public issues for undisclosed vulnerabilities.