kodeOS internals
Arduino
Run a sketch from the Arduino IDE on a Kode Dot as a kodeOS app: the Kode Dot board entry, what Upload does, the pin names and what a sketch can use.
What it is
A sketch built for the Kode Dot is a complete ESP-IDF image, the same kind of file as any other kodeOS app, so kodeOS needs no special code for Arduino. The Kode Dot board entry makes the IDE build it for the right chip and upload it to the right place. You pick the board, write setup() and loop(), press Upload and open the Serial Monitor.
The entry is not in the Boards Manager. The upstream Arduino-ESP32 core has its own kodedot entry, for an ESP32-S3 board. The entry for the Kode Dot comes from a git clone of arduino-esp32 in your sketchbook, with the kodedot block of boards.txt and the folder variants/kodedot/ (the pin map and the code specific to the Kode Dot). The board's FQBN, which only arduino-cli needs, is espressif:esp32:kodedot: the vendor espressif is the name of the directory that holds the clone.
While a sketch runs it owns every peripheral. kodeOS does not run beside it. To return to kodeOS, hold BOT and LEFT together for about one second.
How it is connected
flowchart LR subgraph pc["On your computer"] ide["Arduino IDE<br/>Upload"] --> es["esptool_py_app_only"] end subgraph board["On the Kode Dot"] mb["app_pending<br/>0x1A00000"] am["AO_AppMgr<br/>adopts at boot"] reg["apps region"] boot["AO_Boot<br/>ALWAYS request"] bl["Bootloader<br/>ARMED, jump"] mb --> am am -- "install" --> reg am -- "request" --> boot boot --> bl end es -- "one write" --> mb class ide ext class mb,reg store class am,boot ao
Upload writes the sketch and nothing else upload.tool=esptool_py_app_only and build.flash_offset=0x1A00000 give one esptool call that writes one file at one address with --flash-size 32MB. There is no bootloader at 0x2000, no partition table at 0x8000 and no boot_app0.bin, so Upload cannot touch kodeOS, and the entry has no EraseFlash menu.
What happens at the next boot
At the next boot AO_AppMgr installs the image into the apps region and verifies the copy. A sketch with the name of an installed app replaces it. If the region is full it verifies the image in the mailbox first and then evicts: the old copy of that sketch first, then the oldest apps. Then AO_AppMgr launches the sketch with a sticky request (internals/boot-model).
The sticky request is why the sketch survives the Serial Monitor: opening the port is a reset on the bootloader's keep list.
The sketch keeps running after:
- the Serial Monitor opening the port;
- the debugger's
reset halt; - a software restart;
- a panic's reboot;
- a deep-sleep wake.
The Kode Dot returns to kodeOS after:
- a power-on (BOT and LEFT);
- a hardware watchdog;
- a hardware brownout reset;
- the next Upload.
A sketch that crashes in setup() restarts again and again until Upload or BOT and LEFT, because the system does not tell a deliberate restart from a crash. A second Upload while a sketch runs works with no button: the bootloader finds the mailbox full and starts kodeOS, which adopts the new image.
Back in kodeOS the sketch is an installed app. Apps shows it as a loose row at the root, with no folder, named after the sketch. Starting it from its row is a one-shot run like any other app, so the Serial Monitor opening the port returns to kodeOS then. Only mailbox adoption writes a sticky request.
The launcher names a sketch by its .ino
kodeOS identifies an app by the project_name in esp_app_desc_t, and the core's prebuilt libraries give every sketch the same one, arduino-lib-builder. Without a fix, two sketches would be one app.
variants/kodedot/kode_app_desc.cpp defines a strong esp_app_desc that overrides the libraries' weak one. project_name is the sketch name without the extension (the entry passes -DKODE_SKETCH_NAME="{build.project_name}"), version is the core's, and date and time are those of the first build in that sketch's build folder, which kodeOS never compares.
All other fields are copied from the libraries' descriptor, because the bootloader checks some of them. The manifest stays empty, since a sketch cannot declare KODE_APP_MANIFEST.
A flash guard lets the sketch write its own data
The sketch runs from the apps region, a data partition. ESP-IDF aborts every flash write when esp_ota_get_running_partition() finds no app partition. The entry adds three link wraps in compiler.c.elf.extra_flags, implemented in variants/kodedot/kode_flash_guard.cpp:
| Wrapped symbol | Answer | Effect |
|---|---|---|
esp_ota_get_running_partition | A synthetic partition describing the sketch's own image | A write to nvs, storage or anywhere outside the sketch is allowed. A write into the running image is refused. Preferences, EEPROM, FFat and LittleFS work |
esp_ota_get_next_update_partition | NULL | A sketch has no OTA slot. Update.begin() fails cleanly with UPDATE_ERROR_NO_PARTITION. Nothing a sketch does reaches the kodeOS slots. The rollback check of initArduino() also finds no OTA state and marks nothing |
esp_image_verify | The real call below 16 MB; above it, an answer from esp_image_get_metadata() | ESP.getSketchSize() and ESP.getSketchMD5() work for a sketch that reaches the 16 MB mark. The SHA check is skipped there because the bootloader verified the image at adoption and at boot |
Specifications
The board entry
| Thing | Value |
|---|---|
| Chip and clock | build.mcu=esp32p4, build.f_cpu=400000000L |
| Flash | flash_size=32MB, flash_mode=dio, 80 MHz |
| Serial | usb_mode=1, cdc_on_boot=1: Serial is the USB-Serial/JTAG, the same port as the console |
| Upload | Tool esptool_py_app_only, 921600 baud, offset 0x1A00000, upload.maximum_size=6287360 (6 MB less one 4 KB sector) |
| Defines | -DBOARD_HAS_PSRAM -DKODE_SKETCH_NAME="{build.project_name}". build.board=KODE_DOT, so #ifdef ARDUINO_KODE_DOT marks code only the Kode Dot runs |
| Menus | Only Core Debug Level. PartitionScheme, PSRAM, FlashMode, FlashSize and ChipVariant are not choices on this board |
platform.txt | tools.esptool_py_app_only.path must be {runtime.platform.path}/tools/esptool, not the tool-registry variable, which resolves to nothing in a clone |
| Partition table | The core's prebuilt libraries find nvs and phy_init because the table is at 0x8000 (internals/boot-model) |
Pin names
These come from variants/kodedot/pins_arduino.h and match the board's pin map (sdk/kbsp).
| Name | Value |
|---|---|
Serial | USB-Serial/JTAG |
Serial0, TX/RX, SOC_TX0/SOC_RX0 | UART0 on J3: transmit on GPIO8 (J3 pin 3), receive on GPIO9 (J3 pin 4). The C5 is flashed through this pair |
Serial1 | TX1 and RX1 are -1, so begin() needs explicit pins. The core's own default, GPIO10 and GPIO11, would drive the C5's download strap on J3 |
Wire, SDA, SCL | The internal I2C bus every on-board chip uses: GPIO4 and GPIO5, 400 kHz |
KODE_IOEXP_INT | 6, the expander's active-low interrupt |
SPI: SCK, MOSI, MISO, SS, NFC_IRQ | 30, 31, 32, 28, 29: the NFC reader |
A0 to A5 | GPIO16 to GPIO21, the header pins with ADC1 |
EXP1 to EXP14 | The J3 positions in connector order: 8, 9, 10, 11, 12, 13, 14, 21, 19, 20, 17, 18, 15, 16. PIN3 to PIN18 are the same pins by J3 pin number |
BUTTON_TOP | 35, the only button on a GPIO. The other five are behind the expander at 0x20, on P0 to P4, and a sketch reads them over Wire |
| SD card | BOARD_HAS_SDMMC slot 0; SDMMC_CLK 43, SDMMC_CMD 44, SDMMC_D0 to D3 39 to 42; IO domain on the internal LDO channel 4; the card's own supply on GPIO22, active low. A bare SD_MMC.begin() powers the card itself. The core reads BOARD_HAS_SDMMC, and BOARD_HAS_SD_SDMMC does nothing |
| RFID | RFID_PULL 45, RFID_CARRIER 46 (input only), RFID_OUT 47, RFID_RF_IN 54 (ADC2 channel 5). R11 holds RFID_PULL high at idle, and high stops the receiver, so a sketch that reads tags drives it low first |
DISPLAY_RST, C5_EN | 7 (shared with touch) and 23. The panel is MIPI-DSI and gets no pin defines |
| Not defined | LED_BUILTIN, IR_TX and IR_RX. The RGB LED is a KTD2026 on Wire, and both infrared lines are IOs of the C5 |
The I2C addresses are defined as names:
| Name | Address | Chip |
|---|---|---|
KODE_I2C_IMU | 0x6A | QMI8658A |
KODE_I2C_MAG | 0x2C | QMC5883P |
KODE_I2C_PMIC | 0x6B | BQ25896 |
KODE_I2C_GAUGE | 0x64 | CW2217 |
KODE_I2C_LED | 0x30 | KTD2026 |
KODE_I2C_IOEXP | 0x20 | TCA6408A |
KODE_I2C_CODEC | 0x18 | ES8311 |
KODE_I2C_HAPTIC | 0x5A | AW86233 (antenna PCB) |
KODE_I2C_TOUCH | 0x15 | CST820 |
What works and what does not
| Thing | State |
|---|---|
Serial, Wire, BUTTON_TOP, analogReadMilliVolts() on A0 to A5 and GPIO54 | Work |
Preferences, EEPROM, FFat, LittleFS | Work through the flash guard. LittleFS.begin() and FFat.begin() default to partition labels spiffs and ffat, which this table does not have, so name the partition: FFat.begin(false, "/ffat", 10, "storage") |
SD_MMC.begin() | Works |
ESP.getSketchSize() | Returns the exact image length |
| Wi-Fi and BLE | Not available with the core's prebuilt libraries: they carry esp-hosted configured for an ESP32-C6 slave, and the radio here is an ESP32-C5. The variant names the Kode Dot's SDIO pins (CLK 50, CMD 51, D0 to D3 49, 48, 53, 52, reset on C5_EN) so WiFi.begin() does not drive the pins of the development board. TBD: a run with libraries built for the C5 with esp32-arduino-lib-builder |
A raw esp_partition_write into the sketch's own image | Aborts. The guard's refusal becomes an abort under DANGEROUS_WRITE_ABORTS |
spi_flash_mmap() or esp_partition_mmap() at or above 0x1000000 | Returns ESP_ERR_INVALID_ARG. Read with esp_partition_read() or esp_flash_read() instead |
| Standard Blink | Does not compile: no LED_BUILTIN |
A scan of Wire on a Kode Dot with the antenna PCB | Answers at every address of the table above, and an acknowledge at 0x7E that none of those chips explains |
Debugging
Start Debugging works as on any board the core supports. OpenOCD attaches over the built-in USB-JTAG and GDB stops on the first line of setup(). Stop and a plain detach leave the sketch running. A JTAG reset returns to the sketch only while a sticky request is in force, and Upload arms one.
| Thing | Value |
|---|---|
| The check | The OpenOCD script (kodedot-debug.cfg) compares the app descriptor of the running image with that of the .bin you debug before it resets. A mismatch ends the session with Kode Dot: the chip is not running this build of the sketch; press Upload, then Start Debugging again and the board keeps running what it ran |
| Breakpoints | Hardware only. The flash probe cannot describe the apps region, so the script turns off OpenOCD's memory map and GDB trusts the ELF. Two of each core's three triggers may be watchpoints |
| Several boards | OpenOCD attaches to the first USB-JTAG it finds, not the one Upload wrote to. Keep one Kode Dot plugged in |
| Flash writes | Never write the flash of the Kode Dot with OpenOCD. The stub in the core's build addresses erase and write with 24 bits, so a write for the mailbox at 28 MB lands 16 MB lower |
Gotchas
Example code
Set up the core once, then upload a sketch.
- Clone the core into the sketchbook and fetch its tools (about 9 GB):
mkdir -p ~/Documents/Arduino/hardware/espressif && cd ~/Documents/Arduino/hardware/espressif && git clone https://github.com/espressif/arduino-esp32.git esp32 && cd esp32/tools && python3 get.py. TBD: the repository and branch that carry thekodedotentry and variant. - Restart the Arduino IDE. Open Tools → Board → ESP32 Arduino (in sketchbook) and choose Kode Dot.
- Connect the Kode Dot over USB-C. In Tools → Port choose the port shown as ESP32 Family Device (the USB numbers
0x303A:0x1001belong to Espressif's USB-Serial/JTAG), and pick Kode Dot for it once. - Open the sketch below and press Upload. The board resets, kodeOS adopts the sketch and it starts.
- Open Tools → Serial Monitor. One line arrives every 500 ms and the sketch keeps running.
- Hold BOT and LEFT together for about one second. kodeOS counts the boot as the return from an app, so Apps is on the glass with the sketch in focus.
The sketch prints the uptime, the top button and whether the battery gauge answers, over the real internal bus.
/*
* arduino_status.ino - 2026-10-28 - from Kode with love
*
* Runs on the Kode Dot as an app, with the board set to Kode Dot in the
* Arduino IDE. Twice a second it prints the uptime, the state of the top
* button and whether the battery gauge answers on the internal I2C bus.
* In the Serial Monitor you see one line every 500 ms, and the button value
* changes while you hold the top button.
*/
#include <Wire.h>
/* The battery gauge sits on the internal bus; 1 means it acknowledged. */
static int gaugeAnswers()
{
Wire.beginTransmission(KODE_I2C_GAUGE);
if ( Wire.endTransmission() == 0 )
{
return 1;
}
return 0;
}
/* Serial is the USB-C port; Wire uses the board's own SDA and SCL. */
void setup()
{
Serial.begin(115200);
Wire.begin(SDA, SCL, 400000);
pinMode(BUTTON_TOP, INPUT_PULLUP);
}
/* Print one status line every half second. */
void loop()
{
Serial.printf("uptime=%lu ms top_button=%d gauge=%d\n",
(unsigned long)millis(), digitalRead(BUTTON_TOP), gaugeAnswers());
delay(500);
}TBD: this example has not been compiled or run on a Kode Dot yet.
In the SDK
examples/arduino_hello: the example sketch, which blinks the LED overWire.components/kdrv/apps/include/kapps_fmt.h: the mailbox, the intent word and the request states the upload relies on.bootloader_components/main/bootloader_start.c: the closed list of resets that keep a sketch running.