SDK
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
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 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 issuesGO, so the chip already holds about ten milliseconds before the first feed. - The chip rests in standby.
SYSCTRL2bit 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. STOPbelongs 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 aSTOPissued when the last sample is written cuts the buzz off in the middle. The end of the feed arms a one-shot forkdrv_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()returnsKODE_DENIEDwhile a buzz or the production row plays, before it allocates an event. A caller toldKODE_OKhas 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
hapticsetting 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:
SYSCTRL2is at0x46. On the related AW8622X chips the standby bit and the sample rate are in0x44. A driver copied from that family writes the wrong register, the chip acknowledges every write and never leaves standby.- Writing
SYSCTRL2cannot 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. RTPDATAdoes not auto-increment. Register0x32is 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
GOis confirmed. After it the driver readsGLB_STATEback to 8 (RTP), up to 16 times, before it feeds. A chip with a parked output stage acknowledgesGOand 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:
- Probe
0x5A. - Read and check
CHIPID. - Write
0xAAtoRSTCFGfor a soft reset and wait 3 ms. - Take the chip out of standby at 12 kHz.
- 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, ®_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
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.
/*
* 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: the haptic driver, beside the IMU's and the magnetometer's.kdrv_aw86233.h: the calls, the constants and the field enum.components/kaos/haptic: AO_Haptic, the owner, with the feed tick and the production row.