# AW86233 haptic driver

The chip and the motor that make a Kode Dot buzz: the 235 Hz resonant actuator, RTP playback, the amplitude limit, and the driver and owner over them.

## What it is

A Kode Dot buzzes in your hand. The motor is a **linear resonant actuator** (LRA), a small mass on a spring, the same kind of part a phone vibrates with. It is the LD0832BC-0391F from Leader. The chip that drives it is the **AW86233** from Awinic, a haptic controller.

An LRA moves only near its resonance, 235 Hz here (`KDRV_AW_F0_HZ`), and a steady voltage does not move it at all. So the signal that drives it is a sine wave at 235 Hz. The chip plays in **RTP mode**, real-time playback: it does not hold a stored effect. The ESP32-P4 streams the waveform to it as signed 8-bit samples, and the chip moves the motor as they say. Everything on this page follows from that mode and from that motor.

The motor is rated 1.8 Vrms (1.9 at most) and measures 21 Ω. The driver limits every amplitude to 80 % of full scale for that rating.

## How it is connected

```mermaid
flowchart LR
  ao["AO_Haptic"] -- "RTP samples<br/>10 ms feed" --> p4["ESP32-P4"]
  subgraph ant["Antenna PCB"]
    aw["AW86233, U7"] -- "drive" --> lra["LRA, 235 Hz"]
  end
  p4 -- "I2C 0x5A<br/>through J10" --> aw
  lra -. "felt by" .-> imu["QMI8658A"]
  class ao ao
  class p4 chip
  class aw chip
  class lra chip
  class imu chip
```

*The haptic chain on the Kode Dot*

The chip is U7 on the **Antenna PCB**, a board that connects to the main board through connector J10, and the LRA plugs in beside it. The shared I2C bus reaches the chip through J10. The address is `0x5A` (`KBSP_I2C_ADDR_HAPTIC`) and it is fixed in the chip.

**Another part answers at `0x5A` too.** The CST916E touch controller of the panel module from supplier B uses the same address, on a different board. An acknowledge at `0x5A` therefore says nothing about which board is there. `kdrv_aw86233_create()` reads the 16-bit `CHIPID` at `0x57` and `0x69`, which reads `0x2330` on an AW86233, and refuses anything whose high byte is not `0x23`, the AW8623X family. kodeOS probes the touch controller at `0x15` and never at `0x5A`.

**An acknowledge does not prove a motor.** The chip answers on I2C with or without the LRA fitted, and acknowledges every register write whether or not anything moves. Only vibration at 235 Hz proves the motor, which is why the production row uses the accelerometer as its witness. See `sdk/kdrv/qmi8658a`.

### Who feeds the chip

**AO_Haptic** owns the chip (`KBSP_RES_HAPTIC`) at priority 22. The chip drains its RTP FIFO in about five milliseconds, so the waveform exists only while someone keeps pushing it, and two pushers would interleave two waveforms into noise. One owner feeds it.

- **The feed is a 10 ms tick** (`KODE_HAPTIC_TICK_MS`) offering up to two 64-byte bursts (`KODE_HAPTIC_BURSTS_PER_TICK`). A burst is 5.3 ms of playback at 12 kHz. Two bursts are pushed in the step that issues `GO`, so the chip already holds about ten milliseconds before the first feed.
- **The chip rests in standby.** `SYSCTRL2` bit 6 parks the driver stage. A buzz clears it and the tail that ends the buzz sets it again, one site each. Awinic quotes 0.4 ms to start up; the owner spends a 1 ms busy-wait (`HAPTIC_WAKE_MS`) in the step, since the shortest QP timer is a whole tick.
- **`STOP` belongs to the tail timer, never to the last feed.** The chip takes samples much faster than it plays them. Handing it 200 ms of waveform returns in about 70 ms, so a `STOP` issued when the last sample is written cuts the buzz off in the middle. The end of the feed arms a one-shot for `kdrv_aw86233_playing_ms()` of what the chip accepted, and only that timer ends the buzz.
- **A feed that finds the FIFO full for 200 ms** (20 ticks, `HAPTIC_STALL_TICKS`) declares the buzz dead. One or two full ticks are the chip being ahead and are normal.
- **One buzz at a time, refused at the door.** `kode_haptic_play()` returns `KODE_DENIED` while a buzz or the production row plays, before it allocates an event. A caller told `KODE_OK` has been promised a buzz. A board without an Antenna PCB takes the request and logs once that there is nothing to play, since absence is a fact about the board.
- **The `haptic` setting is a scale from 0 to 100** on every buzz an app asks for. It never scales the production row.

### How the driver works

**The driver is written by hand.** Awinic's driver is for Linux, so there is nothing to wrap. The register map comes from the AW8623X datasheet (V1.4) and from the sequence that moves the motor on this board. Nine registers are used. The datasheet describes much more, such as the stored effect memory, continuous mode and trimming, and the driver leaves all of it out rather than implement it partly.

Five facts decide whether the motor moves:

- **`SYSCTRL2` is at `0x46`.** On the related AW8622X chips the standby bit and the sample rate are in `0x44`. A driver copied from that family writes the wrong register, the chip acknowledges every write and never leaves standby.
- **Writing `SYSCTRL2` cannot be skipped.** It takes the chip out of standby at a 12 kHz sample rate. Without it the chip acknowledges everything and never drives the motor.
- **`RTPDATA` does not auto-increment.** Register `0x32` is the FIFO input. A burst is one address followed by as many bytes as the chip takes, and each byte lands behind the previous one in the FIFO.
- **Every `GO` is confirmed.** After it the driver reads `GLB_STATE` back to 8 (RTP), up to 16 times, before it feeds. A chip with a parked output stage acknowledges `GO` and does nothing. The confirmation turns that into a reported error instead of a buzz that never starts.
- **The FIFO fills faster than it plays**, so the buzz must last as long as the waveform plays, not as long as the writing takes.

`kdrv_aw86233_create()` runs five steps:

1. Probe `0x5A`.
2. Read and check `CHIPID`.
3. Write `0xAA` to `RSTCFG` for a soft reset and wait 3 ms.
4. Take the chip out of standby at 12 kHz.
5. Put it back into standby.

The run write proves the part can leave standby at all, so the first buzz does not find a chip that cannot.

It calls `khal_delay_ms()`, so it belongs to a task's start-up and never to an AO's step. AO_Haptic calls it in its initial transition. On a board with no Antenna PCB it returns `KODE_IO_ERROR`, which is the expected result there.

**The sine is generated.** The FIFO is fed in bursts, and a stored table would have to be as long as the buzz. At 12 kHz, 12000 samples are exactly 235 whole cycles, so the phase is kept as a sample index modulo 12000 and two bursts join without a jump, which would be a click the motor plays faithfully. A float accumulator would drift.

**The amplitude limit comes from the motor.** `KDRV_AW_GAIN_MAX_PCT` is 80 %, where the LD0832BC reaches its rating. The driver applies it twice. `kdrv_aw86233_rtp_begin()` clamps the gain register, so 100 % goes out as `0xCC`, and `kdrv_aw86233_sine_fill()` gives the samples a peak at the same fraction. Each alone keeps the motor within its rating. A request for more is not an error, and the extra is not granted.

## Specifications

### The board

| Thing | Value |
| --- | --- |
| Chip | AW86233QNR, U7 on the Antenna PCB, I2C `0x5A` (`KBSP_I2C_ADDR_HAPTIC`) fixed |
| Identity | `CHIPID` `0x2330` at `0x57` and `0x69` (`KDRV_AW_CHIPID`). The high byte `0x23` is the AW8623X family. AW86234 reads `0x2340`, AW86235 `0x2350` |
| Motor | LD0832BC-0391F, resonance 235 Hz, rated 1.8 Vrms (1.9 maximum), 21 Ω |
| Play mode | RTP: signed 8-bit PCM, 12 kHz (`KDRV_AW_RATE_HZ`), 64-byte bursts (`KDRV_AW_BURST`) |
| Gain | 0 to 255 in `PLAYCFG2`. Clamped to 80 % (`KDRV_AW_GAIN_MAX_PCT`), which is `0xCC` |
| One buzz | 1 to 2000 ms (`KODE_HAPTIC_MAX_MS`) |
| Standby start-up | 0.4 ms (Awinic). The owner busy-waits 1 ms |
| Reset | Write `0xAA` to `RSTCFG`, wait 3 ms |

### What the witness measured

The production row buzzes the motor and the QMI8658A measures the board at 235 Hz in two windows, motor off and motor on. Measured on a Kode Dot with a validation example that drives the coil without the 80 % clamp:

| Gain | Energy off | Energy on | On over off | Samples |
| --- | --- | --- | --- | --- |
| `0x40` | 2.6 | 640.8 | 246.1x | 256 of 256 |
| `0x80` | 111.2 | 18340.7 | 165.0x | 256 of 256 |
| `0xFF` | 1141.8 | 41768.1 | 36.6x | 256 of 256 |

The motor moves 65 times more at `0xFF` than at `0x40`, exactly as asked. The ratio falls to a seventh because the motor-off window rises by 400 times with the gain. The cause is not settled: the bench may still be shaking from the earlier buzzes of the same run, or the chip's drive may reach the window measured with the motor off. **TBD**: a run with a settling pause between windows to tell the two apart. What the data does show is that the weakest gain gives the cleanest ratio.

So the row buzzes at 25 %, 50 % and 80 % (`0x3F`, `0x7F`, `0xCC`), weakest first, and stops at the first that passes. It reports an amplitude ratio, the square root of an energy ratio, and the threshold is 5.2 (`KODE_HAPTIC_TEST_RATIO_X10` is 52), a third of what the board delivered at `0x40`. The row also measures the motor-off window first and refuses to judge a bench above 1.2 mg at 235 Hz (`KODE_HAPTIC_FLOOR_MAX_X10`): a hand resting on the bench reads 123.7 mg. A quiet bench reads 0.1 to 0.6 mg.

### The registers

The nine lines the driver uses (ten registers).

| Reg | Name | Used for |
| --- | --- | --- |
| `0x00` | `RSTCFG` | Write `0xAA`: soft reset |
| `0x01` | `SYSST` | Bit 4 `FF_AES`, bit 3 `FF_AFS` (the RTP FIFO is almost full), bit 0 `DONES` |
| `0x07` | `PLAYCFG2` | The gain, 0 to 255 |
| `0x08` | `PLAYCFG3` | Bits [1:0] the play mode; 1 is RTP |
| `0x09` | `PLAYCFG4` | Bit 0 `GO`, bit 1 `STOP` |
| `0x32` | `RTPDATA` | The FIFO input: signed 8-bit PCM, one address, no increment |
| `0x3F` | `GLBRD5` | Bits [3:0] `GLB_STATE`: 0 standby, 8 RTP, 11 brake |
| `0x46` | `SYSCTRL2` | Bit 6 standby; bits [1:0] `WAVDAT_MODE`, the sample rate. `0x02` is out of standby at 12 kHz |
| `0x57`, `0x69` | `CHIPID_H`, `CHIPID_L` | The 16-bit identity |

### The field table

Nine fields for the console's `haptic`. The table is small on purpose: the waveform comes from this board and not from the chip's memory. The driver writes the registers of all four writable fields itself, so a value written here lasts only until the next buzz overwrites it; the standby bit is the first to go.

| Field | Where | What it is |
| --- | --- | --- |
| `present` | driver | RO. `create` found the chip |
| `chipid` | driver | RO. The 16 bits read at bring-up, served from a copy with no bus access |
| `sysst` | `0x01` | RO. The whole status byte |
| `ff_afs` | `0x01` [3] | RO. The RTP FIFO is almost full |
| `gain` | `0x07` | The amplitude, 0 to 255 |
| `play_mode` | `0x08` [1:0] | 1 is RTP |
| `glb_state` | `0x3F` [3:0] | RO. 0 standby, 8 RTP, 11 brake |
| `sysctrl2` | `0x46` | Standby and the sample rate |
| `standby` | `0x46` [6] | Nothing plays while set |

### The driver

| Call | What it does |
| --- | --- |
| `kdrv_aw86233_create()` | The five steps above. `KODE_IO_ERROR` when nothing answers, the family byte is wrong or a write did not take |
| `kdrv_aw86233_present()` | `true` when the last `create` returned `KODE_OK` |
| `kdrv_aw86233_standby(on)` | A read-modify-write of `SYSCTRL2` bit 6, so the sample rate stays as it was. No other field changes |
| `kdrv_aw86233_rtp_begin(gain_pct, &reg_written)` | Clamps and writes the gain, sets RTP, sends `GO` before any sample exists (the chip idles on an empty FIFO), and confirms `GLB_STATE` is 8. `reg_written` is the `PLAYCFG2` byte the chip got; pass `NULL` if you do not need it. The chip must be out of standby first |
| `kdrv_aw86233_rtp_feed(pcm, n, &accepted)` | One status read and at most one 64-byte write, then returns. `accepted` is 0 and the result `KODE_OK` when the FIFO is nearly full. Never waits |
| `kdrv_aw86233_rtp_end()` | `STOP`, unconditionally. Call it even after an error, or the motor keeps vibrating with what the FIFO holds |
| `kdrv_aw86233_sine_fill(out, n, &phase)` | A 235 Hz sine at the 80 % peak, continuing from `phase` |
| `kdrv_aw86233_playing_ms(samples)` | How long the samples take to play, rounded up. The owner waits this long before `rtp_end()` |
| `kdrv_aw86233_get()`, `_set()`, `_field_info()`, `_field_by_name()`, `_dump()` | The field table |

One buzz uses them in this order: `create` once at start; `standby(false)` then `rtp_begin()`; `rtp_feed()` again and again until the samples are queued; wait `playing_ms()`; `rtp_end()` then `standby(true)`.

### The files

| File | What it does |
| --- | --- |
| `motion/include/kdrv_aw86233.h`, `motion/src/kdrv_aw86233.c` | The driver: bring-up, standby, RTP, the sine, the field table |
| `kaos/haptic/src/haptic_ao.c` | AO_Haptic: the feed tick, the tail timer, the busy flag, the production row |

## Gotchas

> **WARNING**
>
> **The motor is silent and every register write is acknowledged.** The chip answers with or without a motor, and acknowledges a write whether or not it acts on it. Check `SYSCTRL2` is at `0x46` and was written, and that `GLB_STATE` reads 8 after `GO`. An acknowledge proves the Antenna PCB is plugged in and nothing else.

> **WARNING**
>
> **A buzz that stops early.** `STOP` was issued when the last sample was written, not when the last sample played. Wait `kdrv_aw86233_playing_ms()` of the samples the chip accepted before you call `kdrv_aw86233_rtp_end()`. AO_Haptic does this with a one-shot.

> **NOTE**
>
> **`kode_haptic_play()` returns `KODE_DENIED`.** A buzz or the production row is playing. The request is refused at the door and not queued. Ask again after the buzz ends.

> **NOTE**
>
> **Gain 100 is not full scale.** The driver clamps at 80 %, `0xCC`, to keep the motor within its rating, and the `haptic` setting scales whatever an app asks for. Ask for the feel you want, and expect the ceiling.

## Example code

Buzz the motor for 200 ms at half strength every two seconds. You feel a short tick in your hand, and the serial log prints a line each time the request was taken.

```c
/*
 * buzz.c - 2026-10-28 - from Kode with love
 *
 * Makes the Kode Dot's vibration motor buzz for 200 ms at half strength every
 * two seconds. You feel a short tick in your hand, and the serial log prints
 * "buzz" each time the request was taken.
 */
#include "kode.h"

/* Bring the system up, then ask AO_Haptic for a buzz every two seconds. */
void app_main(void)
{
    kode_init();

    while (1) {
        if (kode_haptic_play(200, 50) == KODE_OK) {
            KLOG_I("buzz", "buzz");
        }
        kode_delay_ms(2000);
    }
}
```

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

## In the SDK

- [`components/kdrv/motion`](https://github.com/kodediy/kode-sdk/tree/main/components/kdrv/motion): the haptic driver, beside the IMU's and the magnetometer's.
- [`kdrv_aw86233.h`](https://github.com/kodediy/kode-sdk/blob/main/components/kdrv/motion/include/kdrv_aw86233.h): the calls, the constants and the field enum.
- [`components/kaos/haptic`](https://github.com/kodediy/kode-sdk/tree/main/components/kaos/haptic): AO_Haptic, the owner, with the feed tick and the production row.
