# AO_Haptic

Owns the Kode Dot's vibration motor: how a buzz is fed tick by tick, why a tail timer ends it, and the self-test that uses the IMU as witness.

## What it is

**AO_Haptic** makes a Kode Dot vibrate. The motor is a linear resonant actuator, an LRA, driven by an AW86233 chip. Both sit on the antenna board. A buzz on this board is fed: the chip plays from an RTP FIFO that empties in about 5 ms, so the waveform exists only while somebody keeps pushing samples in.

Two writers would mix into noise nobody asked for, so one Active Object owns the whole chip and everything else asks by event.

You ask for a buzz with `kode_haptic_play(ms, gain)` and learn that it ended from a published `HAPTIC_DONE_SIG`. AO_Haptic also runs the production row that proves the motor moves, using the IMU (`sdk/kaos/imu`) as witness: a chip with no motor on its output answers every register write and passes every check it can make on itself.

The chip, the RTP stream and the clamp are on `sdk/kdrv/aw86233`.

## How it is connected

```mermaid
flowchart LR
  app["kode_haptic_play()"] -- "HAPTIC_PLAY_REQ_SIG" --> ao["AO_Haptic<br/>priority 22"]
  con["AO_Console"] -- "HAPTIC_FIELD_REQ_SIG" --> ao
  ao -- "kdrv_aw86233_*()<br/>I2C 0x5A" --> chip["AW86233 + LRA"]
  ao -- "HAPTIC_DONE_SIG" --> app
  ao -- "arm, drain<br/>(the row)" --> imu["AO_Imu"]
  imu -- "IMU_ARMED_SIG<br/>IMU_ENERGY_SIG" --> ao
  class app app
  class ao,con,imu ao
  class chip chip
```

*AO_Haptic and the witness*

AO_Haptic in numbers:

| Thing | Value |
| --- | --- |
| Priority | 22 (`KODE_PRIO_HAPTIC`), the level above AO_Imu |
| Queue | 16 events, the system default |
| Stack | 3072 bytes, the smallest tier |
| Claims | `KBSP_RES_HAPTIC`: the chip at `0x5A` on the antenna PCB and the LRA behind it |
| Buffer | One 64-byte burst, a file-level static |

The priority is above AO_Imu because the feed has a deadline and the IMU's drain does not.

`kode_init()` starts it right after AO_Imu, since the row posts to AO_Imu from its first step. There is no Kconfig gate: something has to own the chip even when the antenna PCB is not fitted.

```mermaid
stateDiagram-v2
  [*] --> Haptic_absent: no AW86233 found
  [*] --> Haptic_idle: chip created
  Haptic_idle --> Haptic_playing: HAPTIC_PLAY_REQ_SIG
  Haptic_playing --> Haptic_idle: tail expired, STOP, HAPTIC_DONE_SIG
  Haptic_idle --> Haptic_row: HAPTIC_TEST_REQ_SIG
  Haptic_row --> Haptic_playing: a gain's window is armed
  Haptic_playing --> Haptic_row: that buzz is over
  Haptic_row --> Haptic_idle: verdict
```

*AO_Haptic states*

`Haptic_up` is the parent of `Haptic_idle`, `Haptic_playing` and `Haptic_row` and handles what all three answer alike: `CONFIG_REQ_SIG`, `HAPTIC_FIELD_REQ_SIG` and the stale-timer drops. In `Haptic_absent` the AO logs once that there is nothing to play and answers every later request from there.

### Standby

Bit 6 of `SYSCTRL2` puts the chip's output stage to sleep, and the chip rests there. Entering `Haptic_playing` clears the bit after a 1 ms wait (`HAPTIC_WAKE_MS`, in the step, with `khal_delay_ms()`). The tail that ends the buzz sets it again.

Every way a buzz can end goes through the tail, failures included, so `play_fail()` arms the tail and does not stop the buzz itself. The I2C interface stays alive and every register keeps its value, so nothing is configured again at wake. Awinic quotes 0.4 ms to start, and a one-millisecond block at priority 22 costs less than a tick of latency on every click. A console write of `standby` is overridden by the next buzz.

### The feed

One burst is 64 samples, 5.3 ms of playback at 12 kHz. A tick every 10 ms (`KODE_HAPTIC_TICK_MS`) offers up to two bursts (`KODE_HAPTIC_BURSTS_PER_TICK`), each a status read and a 64-byte write, about 3 ms in all. The step that issues GO pushes two bursts at once, so the chip holds about 10 ms of samples before the first tick.

When the chip refuses a burst, the phase of the sine wave is put back to its value before the burst. Without that the waveform would jump and the motor would play the jump as a click. A tick that takes nothing is correct, since the chip is ahead of the feed. Twenty such ticks in a row (`HAPTIC_STALL_TICKS`) end the buzz with `KODE_TIMEOUT`. A feed the bus refuses ends it the same way.

### The tail

The chip takes samples far faster than it plays them: 200 ms of waveform is accepted in about 70 ms. Writing STOP with the last sample would end the buzz early. So the end of the feed arms a one-shot tail timer set to the playback the chip still holds, the playback time of the accepted samples minus the time since GO, at least one tick.

Only the tail's expiry calls `kdrv_aw86233_rtp_end()` and publishes `HAPTIC_DONE_SIG` (`rc`, `samples`, `starved`). `starved` says the FIFO refused a burst at least once, which means the chip was ahead of the feed.

### Busy is refused early

Only AO_Haptic writes the busy flag. `haptic_play_request()` answers `KODE_DENIED` before it allocates an event, so a caller that gets `KODE_OK` has a promise of a buzz. The row holds the flag across its own buzzes. A board with no antenna PCB takes the request and logs once, because a missing chip is a fact about the board and the caller did nothing wrong.

### Stale timers

A timer that is disarmed can still have its expiry in the queue. AO_Haptic counts the timers it stopped, in `Haptic_up`, and drops that many expiries. The count lives in the parent because the stale event arrives after the row has already entered `Haptic_playing`, where a check local to `Haptic_row` would never see it.

### The self-test

`test component=haptic` asked while a buzz plays waits for the buzz to end and then runs. It asks AO_Imu, the witness, to collect windows of accelerometer samples and compares the energy at 235 Hz. The order finds the cause of a fault.

1. **Step 0, is there a chip.** In `Haptic_absent` the row is one record, `result=absent`, and a failed verdict. On a main-board verifier with no antenna PCB that is expected. On a finished unit the fault is the connector between the boards or the chip.
2. **Step 1, the floor.** One 140 ms window (`KODE_HAPTIC_STILL_MS`) with the motor off sets the floor at 235 Hz for the whole run. A floor above 1.2 mg (`KODE_HAPTIC_FLOOR_MAX_X10`, 12 in tenths) means the bench already vibrates, and the row would judge the bench and say nothing about the board.
3. **Step 2, three gains.** It buzzes 200 ms at 25 %, 50 % and 80 % of full scale, weakest first, into a fresh window each. It passes on the first gain whose ratio of driven to floor amplitude reaches 5.2 (`KODE_HAPTIC_TEST_RATIO_X10`, 52).

The records carry the `PLAYCFG2` byte the driver reports back, `0x3F`, `0x7F` and `0xCC`, because the driver clamps at the LRA's rating and then truncates. All levels are integers in tenths of a mg and the ratio is ten times the real one, so the row uses no floating point.

Four conditions end the row with `result=noverdict` and a reason (`mute`, `short` or `loud`):

- a window with fewer than 120 samples (`KODE_HAPTIC_MIN_SAMPLES`);
- a witness that does not answer;
- a chip that stops taking samples;
- a contaminated floor.

The console shows `timeout` for it, since the row cannot measure and does not blame the motor.

When the chip answers and nothing moves at any gain, the log says the AW86233 answers and the LRA does not move: not fitted, or stuck.

### Resting with the board

With `CONFIG_KODE_LOWPOWER`, AO_Haptic joins the rounds of AO_LowPower as `haptic` (`sdk/kaos/lowpower`). On a round it writes the chip into standby, the state it sits in between buzzes, so a round that finds it there only makes sure. A buzz or the row owns the motor until it ends, so then the round is answered busy with `haptic busy` and no buzz is cut short. A board with no antenna PCB has nothing to rest and answers ready.

Nothing is restored when the board wakes. The next buzz takes the chip out of standby itself, so AO_Haptic listens to the rounds and not to `LOWPOWER_STATE_SIG`.

kodeOS asks for the buzzes of its interface through `kode_haptic_play()`, as any client does (`internals/gui/buttons-and-gestures`). kgui keeps silent while the microphone or the IMU records, so a buzz is never in what they measure.

## Specifications

### Signals

| Signal | Event | Direction | Meaning |
| --- | --- | --- | --- |
| `HAPTIC_PLAY_REQ_SIG` | `HapticPlayEvt`: `ms`, `gain_pct` | in, posted | One buzz. The gain is scaled by the `haptic` setting |
| `HAPTIC_FIELD_REQ_SIG` | `HapticFieldReqEvt`: `op`, `field`, `value` | in, posted | Get, set or dump one AW86233 field |
| `HAPTIC_TEST_REQ_SIG` | none | in, posted by AO_Autotest | The production row |
| `LOWPOWER_ROUND_SIG` | `LowPowerRoundEvt` | in, subscribed | With `CONFIG_KODE_LOWPOWER`: the chip to standby, or busy `haptic busy` |
| `CONFIG_REQ_SIG` | `KodeConfigReqEvt` | in | The key `haptic` |
| `IMU_ARMED_SIG`, `IMU_ENERGY_SIG` | from AO_Imu | in | The two replies the row waits for |
| `HAPTIC_TICK_SIG` | none | self, periodic | The feed, only while a buzz is fed |
| `HAPTIC_TAIL_SIG` | none | self, one-shot | The end of the buzz |
| `HAPTIC_WAIT_SIG` | none | self, one-shot | A reply deadline of the row, or the 140 ms floor window |
| `HAPTIC_DONE_SIG` | `HapticDoneEvt`: `rc`, `samples`, `starved` | out, published | How a buzz ended. Published, not posted, so the asker is not known |
| `HAPTIC_FIELD_SIG` | `HapticFieldEvt`: `op`, `field`, `rc`, `value` | out, published | One field value or the refusal |
| `HAPTIC_TEST_SIG` | `HapticTestEvt` | out, published | Phases `HAPTIC_TEST_ABSENT`, `_FLOOR`, `_GAIN`, `_NOVERDICT`; flags `HAPTIC_TF_PASS`, `_LOUD`, `_SHORT`, `_MUTE` |
| `CONFIG_ANS_SIG` | `KodeConfigAnsEvt` | out | The value of `haptic` |
| `TEST_RESULT_SIG` | named `haptic` | out, published | The verdict |

### Numbers, calls and settings

| Thing | Value |
| --- | --- |
| Buzz length | 1 to 2000 ms (`KODE_HAPTIC_MAX_MS`) |
| Gain | 0 to 100 %; the driver clamps at the LRA's rating, 80 % |
| Feed | 10 ms tick, 2 bursts of 64 samples; stall limit 20 ticks |
| Row | 200 ms buzz (`KODE_HAPTIC_TEST_MS`), reply wait 500 ms (`KODE_HAPTIC_REPLY_MS`), console budget 6.36 s (`KODE_HAPTIC_TEST_BUDGET_MS`) |
| Facade | `kode_haptic_play(ms, gain_pct)`: `KODE_OK` once posted, `KODE_DENIED` for a bad argument or while busy, `KODE_NO_RESOURCES` when the pool is dry |
| Calls | `haptic_play_request()`, `haptic_field_request()`, `haptic_test_request()` |
| Console | `haptic`, `haptic_help`, `haptic_play ms= gain=` (defaults 200 ms at 50 %, the row's own buzz), `test component=haptic`. The only refusal on the wire is `>err code=5 msg=busy` |
| Stored setting | `kset/haptic`, a `u8`, 0 to 100, default 100: a scale on every app and console buzz. At 0 nothing plays and the request still ends with `HAPTIC_DONE_SIG`. The row ignores it |
| Kconfig | None |

## Gotchas

> **WARNING**
>
> **`kode_haptic_play()` returns `KODE_OK` and nothing moves.** `KODE_OK` means the request is posted. A board with no antenna PCB accepts it and logs once that there is nothing to play, and `haptic=0` plays nothing by design. The AW86233 answers at `0x5A` whether or not an LRA is fitted, so a bus ACK proves nothing about the motor.

> **NOTE**
>
> **A second buzz is refused while one plays, and during the whole row.** The refusal is `KODE_DENIED`. Wait for `HAPTIC_DONE_SIG` before you ask again.

> **NOTE**
>
> **The top gain of the row is 80 %.** That is the LRA's rating, and the row's table stops there so nothing is clamped silently. The ratio falls at a true `0xFF`, a drive this firmware cannot produce.

> **NOTE**
>
> **A row that reads `timeout` did not fail the motor.** `noverdict` records carry the reason: move the bench or fix the witness. Only a measured window that stays quiet at every gain blames the motor.

## Example code

This buzzes the motor for 200 ms at half strength, once a second. You feel a short pulse every second.

```c
/*
 * haptic_pulse.c - 2026-10-28 - from Kode with love
 *
 * Asks AO_Haptic, the owner of the Kode Dot's vibration motor, for a 200 ms
 * buzz at 50 % strength once a second. You feel a short pulse in your hand
 * every second for as long as the app runs.
 */
#include "kode.h"

/* Bring the system up, then buzz once a second. */
void app_main(void)
{
    kode_init();

    while (1) {
        kode_haptic_play(200, 50);
        kode_delay_ms(1000);
    }
}
```

Compiled against the SDK with ESP-IDF v6.1 on 2026-10-28. **TBD**: a run on a Kode Dot.

## In the SDK

- [`components/kaos/haptic`](https://github.com/kodediy/kode-sdk/tree/main/components/kaos/haptic): `haptic_ao.c`, the state machine, the feed and the row.
- [`kode.h`](https://github.com/kodediy/kode-sdk/blob/main/components/kode/include/kode.h): `kode_haptic_play()`.
- [`components/kdrv/motion`](https://github.com/kodediy/kode-sdk/tree/main/components/kdrv/motion): the AW86233 driver beside the IMU's; see `sdk/kdrv/aw86233`.
