SDK
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
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 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.
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
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.
- Step 0, is there a chip. In
Haptic_absentthe 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. - 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. - 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
Example code
This buzzes the motor for 200 ms at half strength, once a second. You feel a short pulse every second.
/*
* 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:haptic_ao.c, the state machine, the feed and the row.kode.h:kode_haptic_play().components/kdrv/motion: the AW86233 driver beside the IMU's; seesdk/kdrv/aw86233.