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
# ph-haptics


`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.

## Release


Version `0.1.0` is available on
[`crates.io`](https://crates.io/crates/ph-haptics), with API documentation on
[`docs.rs`](https://docs.rs/ph-haptics/0.1.0/ph_haptics/). The host-side mock
machine in [`mock/`](https://github.com/photon-circus/ph-haptics/tree/main/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](docs/contract.md).

- Curve shaping comes from [`ph-curves`]https://crates.io/crates/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).

## Installation


Add the runtime to embedded consumers with default features only:

```sh
cargo add ph-haptics@0.1.0
```

The `gen` feature is reserved for host-side generation commands and tooling;
do not enable it in a firmware dependency.

## Layout


| 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]docs/contract.md, [compiler]docs/compiler-design.md, [public API]docs/public-api.md, [architecture]docs/architecture.md) |
| `scripts/` | Canonical CI (`ci.sh`) and mock verify (`mock-verify.sh`) |

## `ph-haptics-gen`


All textual/script compilation lives in the generator binary. Install the
matching published version for host-side use:

```sh
cargo install ph-haptics --version 0.1.0 --features gen --bin ph-haptics-gen
```

From a source checkout, run the generator with Cargo. This example uses a real
library asset:

```sh
cargo run --features gen --bin ph-haptics-gen -- \
  --input assets/phh-library/erm_ui_micro.phh \
  --output src/haptics_compiled.rs \
  --curves-module crate::curves
```

Optional `--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 `MotorProfile` static per resolved profile (built-in + optional overlay)
- one instruction array + `Program` + `CompiledHapticDef` per `haptic`
- `COMPILED_HAPTICS` array containing all compiled haptics
- `COMPILED_CATALOG` ready-to-use `CompiledCatalog` over `COMPILED_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.

### Motor profiles TOML overlay format


Profiles are defined in TOML:

```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.1
```

All 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.

### Haptics text format


Top-level directives:

- `haptic <name> motor=<erm|lra> [profile=<name>] [loop=<once|forever|N>]`
- `include "path/to/file.phh"` — inline another `.phh` file (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:

```text
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 synthetic `Hold` instructions
- `@gamma` resolves from the selected profile's `gamma`
- ramp default `step` comes from `duty_step`
- ramp default `min_dt` comes from `min_dt_ms` (or `ramp_step_ms` if `min_dt_ms=0`)
- non-zero levels are clamped to profile `[min_level, max_level]`
- ERM haptics without `profile=...` use `DEFAULT_ERM_PROFILE`
- LRA haptics without `profile=...` use `DEFAULT_LRA_PROFILE`
- when a profile provides `gamma`, output level is remapped through `profile.gamma_curve`
- `lra_hz_to` enables linear frequency sweeps across a ramp segment

## Pattern Library


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.

## Compiled command path


Runtime construction is intentionally centered on generated compiled assets:

```rust
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;
```

## Hardware boundary


`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/`](https://github.com/photon-circus/ph-haptics/tree/main/mock)
(`bash scripts/mock-verify.sh`). Hardware
integrations live in downstream consumer repositories and are not evidence
for the crate contract.

## `no_std`/`no_alloc`


Embedded runtime contract (default features):

- `#![no_std]` — only `core` + `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`](docs/architecture.md) for the layer table and package
boundary rules. Generator internals: [`docs/compiler-design.md`](docs/compiler-design.md).
Public surface: [`docs/public-api.md`](docs/public-api.md).

## Contributing / local CI


Run the complete contributor validation suite from the repository root:

```sh
bash scripts/ci.sh
```

On Windows, use Git Bash (or any bash). Details and gate list:
[`CONTRIBUTING.md`](CONTRIBUTING.md). Mock-only proof command:
[`mock/README.md`](https://github.com/photon-circus/ph-haptics/blob/main/mock/README.md).

Report security issues via GitHub Security Advisories or a private maintainer
contact; do not file public issues for undisclosed vulnerabilities.