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
What Upload does

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 Kode Dot returns to kodeOS after:

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 symbolAnswerEffect
esp_ota_get_running_partitionA synthetic partition describing the sketch's own imageA 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_partitionNULLA 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_verifyThe 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

ThingValue
Chip and clockbuild.mcu=esp32p4, build.f_cpu=400000000L
Flashflash_size=32MB, flash_mode=dio, 80 MHz
Serialusb_mode=1, cdc_on_boot=1: Serial is the USB-Serial/JTAG, the same port as the console
UploadTool 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
MenusOnly Core Debug Level. PartitionScheme, PSRAM, FlashMode, FlashSize and ChipVariant are not choices on this board
platform.txttools.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 tableThe 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).

NameValue
SerialUSB-Serial/JTAG
Serial0, TX/RX, SOC_TX0/SOC_RX0UART0 on J3: transmit on GPIO8 (J3 pin 3), receive on GPIO9 (J3 pin 4). The C5 is flashed through this pair
Serial1TX1 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, SCLThe internal I2C bus every on-board chip uses: GPIO4 and GPIO5, 400 kHz
KODE_IOEXP_INT6, the expander's active-low interrupt
SPI: SCK, MOSI, MISO, SS, NFC_IRQ30, 31, 32, 28, 29: the NFC reader
A0 to A5GPIO16 to GPIO21, the header pins with ADC1
EXP1 to EXP14The 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_TOP35, 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 cardBOARD_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
RFIDRFID_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_EN7 (shared with touch) and 23. The panel is MIPI-DSI and gets no pin defines
Not definedLED_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:

NameAddressChip
KODE_I2C_IMU0x6AQMI8658A
KODE_I2C_MAG0x2CQMC5883P
KODE_I2C_PMIC0x6BBQ25896
KODE_I2C_GAUGE0x64CW2217
KODE_I2C_LED0x30KTD2026
KODE_I2C_IOEXP0x20TCA6408A
KODE_I2C_CODEC0x18ES8311
KODE_I2C_HAPTIC0x5AAW86233 (antenna PCB)
KODE_I2C_TOUCH0x15CST820

What works and what does not

ThingState
Serial, Wire, BUTTON_TOP, analogReadMilliVolts() on A0 to A5 and GPIO54Work
Preferences, EEPROM, FFat, LittleFSWork 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 BLENot 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 imageAborts. The guard's refusal becomes an abort under DANGEROUS_WRITE_ABORTS
spi_flash_mmap() or esp_partition_mmap() at or above 0x1000000Returns ESP_ERR_INVALID_ARG. Read with esp_partition_read() or esp_flash_read() instead
Standard BlinkDoes not compile: no LED_BUILTIN
A scan of Wire on a Kode Dot with the antenna PCBAnswers 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.

ThingValue
The checkThe 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
BreakpointsHardware 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 boardsOpenOCD attaches to the first USB-JTAG it finds, not the one Upload wrote to. Keep one Kode Dot plugged in
Flash writesNever 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.

    1. 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 the kodedot entry and variant.
    2. Restart the Arduino IDE. Open Tools → Board → ESP32 Arduino (in sketchbook) and choose Kode Dot.
    3. Connect the Kode Dot over USB-C. In Tools → Port choose the port shown as ESP32 Family Device (the USB numbers 0x303A:0x1001 belong to Espressif's USB-Serial/JTAG), and pick Kode Dot for it once.
    4. Open the sketch below and press Upload. The board resets, kodeOS adopts the sketch and it starts.
    5. Open Tools → Serial Monitor. One line arrives every 500 ms and the sketch keeps running.
    6. 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