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 and the witness

AO_Haptic in numbers:

ThingValue
Priority22 (KODE_PRIO_HAPTIC), the level above AO_Imu
Queue16 events, the system default
Stack3072 bytes, the smallest tier
ClaimsKBSP_RES_HAPTIC: the chip at 0x5A on the antenna PCB and the LRA behind it
BufferOne 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
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):

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

SignalEventDirectionMeaning
HAPTIC_PLAY_REQ_SIGHapticPlayEvt: ms, gain_pctin, postedOne buzz. The gain is scaled by the haptic setting
HAPTIC_FIELD_REQ_SIGHapticFieldReqEvt: op, field, valuein, postedGet, set or dump one AW86233 field
HAPTIC_TEST_REQ_SIGnonein, posted by AO_AutotestThe production row
LOWPOWER_ROUND_SIGLowPowerRoundEvtin, subscribedWith CONFIG_KODE_LOWPOWER: the chip to standby, or busy haptic busy
CONFIG_REQ_SIGKodeConfigReqEvtinThe key haptic
IMU_ARMED_SIG, IMU_ENERGY_SIGfrom AO_ImuinThe two replies the row waits for
HAPTIC_TICK_SIGnoneself, periodicThe feed, only while a buzz is fed
HAPTIC_TAIL_SIGnoneself, one-shotThe end of the buzz
HAPTIC_WAIT_SIGnoneself, one-shotA reply deadline of the row, or the 140 ms floor window
HAPTIC_DONE_SIGHapticDoneEvt: rc, samples, starvedout, publishedHow a buzz ended. Published, not posted, so the asker is not known
HAPTIC_FIELD_SIGHapticFieldEvt: op, field, rc, valueout, publishedOne field value or the refusal
HAPTIC_TEST_SIGHapticTestEvtout, publishedPhases HAPTIC_TEST_ABSENT, _FLOOR, _GAIN, _NOVERDICT; flags HAPTIC_TF_PASS, _LOUD, _SHORT, _MUTE
CONFIG_ANS_SIGKodeConfigAnsEvtoutThe value of haptic
TEST_RESULT_SIGnamed hapticout, publishedThe verdict

Numbers, calls and settings

ThingValue
Buzz length1 to 2000 ms (KODE_HAPTIC_MAX_MS)
Gain0 to 100 %; the driver clamps at the LRA's rating, 80 %
Feed10 ms tick, 2 bursts of 64 samples; stall limit 20 ticks
Row200 ms buzz (KODE_HAPTIC_TEST_MS), reply wait 500 ms (KODE_HAPTIC_REPLY_MS), console budget 6.36 s (KODE_HAPTIC_TEST_BUDGET_MS)
Facadekode_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
Callshaptic_play_request(), haptic_field_request(), haptic_test_request()
Consolehaptic, 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 settingkset/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
KconfigNone

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