Agent guide
Read this before changing code. AGENTS.md is the rule list. PLAN.md is the
locked design and the multi-month roadmap — much of it is not implemented yet.
This file is the map of the code that exists today.
What is real vs planned
Implemented and exercised by host tests:
- Cooperative scheduler (
kernel/scheduler.c): up to 16 tasks, priority ready lists,task_sleep, host fibers viakernel/host_stack.c. - Time and a bump allocator (
kernel/os_time.c,kernel/alloc.c). Wall clock iskernel/os_clock.h.apps/clock_service.cstores it in/flash/clock.datthrough the VFS and restores it when the board clock is unset.apps/sensor_service.cregisters sensors from the device config (key,typetemporadc,refresh_ms) andapp_helper_sensor()reads one by key.apps/stdapps/sensors/lists every registered sensor.apps/notify_service.cis the notification card. It is not an app:stdapps_install()does not register it and it never takes focus.notify_post()queues a title and optional body that slides over the focused app, either full-screen or as a top band. A button press dismisses it; otherwise it leaves three seconds after the show animation. Covered bytests/unit/test_notify_service.c.link/is the host/device session (frame, AES-128-GCM, four calls, plus channelLINK_CH_TTY).apps/link_bridge.cdelivers the calls on the device:notify.posttonotify_post(),app.messageinto a queue (link_message_take),app.eventontoapp.<name>.<event>,input.keythroughsim_gpio_handle_key().apps/tty_service.cis the USB shell, started when Hello succeeds. It is not the panel app inapps/stdapps/shell/. Sensor samples are not pushed;sensorsandsensor <key>read them.apps,switch,fg,run,kill, andkeycontrol installed apps.runuses a text hook when one is registered (tty_cli_register); otherwise it focuses the app and attaches the keyboard. The Python SDK isscripts/link_codec.py(Link.open_usb,Link.open_tcp,Link.call,Link.send_tty). Whennotifications.forward_from_hostis set,make usbstays up after a successful flash as a raw terminal (scripts/link_monitor.py) untilexit,close, or Ctrl+].make ttyopens that terminal again without flashing. Opening the port can reset the chip, so the host keeps sending Hello for a few seconds until the board answers.make tty-run <command> [args...]runs one line and exits. Ctrl+Z and Ctrl+C go to the shell. The 16-byte key islink.keyindevice_secrets.yaml, not the device config. - HAL interfaces in
hal/include/hal_*.hwith a working sim backend inhal/arch/sim/plus SDL/device models insim/. - Board backends under
hal/arch/{esp32,esp8266,avr}/exist as per-arch files; they are not the day-to-day development target. - App runtime: manifests,
app_kit(APP_DEFINE, focus, key bind, open/exit), plus UI components underapps/ui/components/(canvas,screen,menu,catalog,icons,status,display).apps/header_app.cis the status band (battery and clock) on every standard app, including the launcher..gameand.fullscreenomit it. Guides:docs/appkit.md,docs/apps.md. - Built-in apps under
apps/stdapps/<name>/:launcher,counter,info,stopwatch(three worker tasks: sec/min/hour),pong(Up/Down paddle),widgets,pomodoro,taskmgr,clock,sensors, plussettings,fileman,shell, anddemo. The last four are compiled into the simulator and are not installed, so the launcher does not list them.stdapps_install()is the only install list.stdapps_start_name()boots sensors, then info, clock, pomodoro, or the launcher. The sensors app also lists CPU busy percent, heap used percent, and the CPU clock (pwr, in MHz). - VFS (
fs/vfs.c) with LittleFS/FatFS backends (fs/littlefs/,fs/fatfs/), config KV store (fs/config_store.c), and OTA with A/B partitions + ed25519 signature verification (fs/ota.c,fs/ed25519.c). - Power management (
kernel/power.c,kernel/power_governor.c) — sleep modes, wake sources, CPU frequency scaling, per-driver suspend/resume, and an OS-wide governor. Apps publish a demand hint; the governor is the only writer of the clock and the backlight cap. Seedocs/power.md. Covered bytests/unit/test_power.candtests/unit/test_power_governor.c. The tickless-idle path inkernel/scheduler.c(scheduler_enter_idle/scheduler_tickless_idle/scheduler_exit_idle) is covered directly intests/unit/test_scheduler.c.idle_task()still never runs, becausescheduler_step()/task_yield()skipTASK_PRIO_IDLE; those skipped steps are what the governor counts as idle. Automatic deep sleep is not part of the governor. - Driver framework (
drivers/) — core (driver.c, open-handle dispatch, owned-vs-caller-owned device lifetime), the device registry with I2C/SPI hotplug scan (device_registry.c), the dynamic.ardmodmodule loader (module.c, CRC-verified headers, symbol table and dependency resolution still stubs), and all six built-in bus drivers (GPIO/I2C/SPI/UART/WiFi/ display) against their sim HAL backends. Covered bytests/unit/test_driver.c,test_device_registry.c,test_module.c,test_bus_drivers.c. - Simulator CLI: headless mode,
--test=all, JUnit, coverage (sim/sim_main.c,sim/sim_args.c). The sim main loop advances one scheduler tick per wall-clock millisecond (catch-up when a frame takes longer than 1 ms), sotask_sleep(1000)is ~1 real second. Interactive sim uses a 320×172 panel (the ESP32-C6 LCD). The NodeMCU profile indevice_config.yamlstays 128×32 with Up/Select (long Select = back).
Still roadmap (do not assume these work, and do not invent them while fixing
something else): game engine, Lua/WASM, dynamic .ardmod loading as a product,
Arduino IDE packaging, deep-sleep power targets, tests/hardware/.
fs/fatfs/fatfs_vfs.c is a flat raw-storage passthrough, not a real FAT
filesystem: no FAT table, no directory entries, two different paths opened
at the same time alias the same underlying bytes. See the comment at the
top of that file before extending it - there's no per-file state to extend,
it needs replacing when a real SD card (PlatformIO FatFs) lands.
All host tests in ctest --test-dir build are the suite. The build is
warning-clean (-Wall -Wextra -Wpedantic -Werror). If you hit a failure or
warning that looks unrelated to your change, it is a regression - root-cause
it rather than assuming it is "pre-existing."
Dependency direction
Portable code calls hardware only through hal_*.h.
apps/ → kernel/, fs/, drivers/, hal/include/
drivers/ → hal/include/
sim/ + hal/arch/* → implement hal/include/
apps/app_framework.h also includes sim headers (sim_gpio.h, device models).
That coupling is current reality. New portable APIs still belong on the HAL,
not as extra includes of sim/ from kernel or drivers.
Build and test (host only)
cmake -B build -DARDUBOT_BUILD_SIM=ON -DCMAKE_BUILD_TYPE=Debug
cmake --build build -j
ctest --test-dir build --output-on-failure
Or via the Makefile wrapper: make, make test, make run (SDL emulator).
make compile-commands writes the repo-root compile_commands.json that clangd and
Cursor use for completions and Find All References. It merges the simulator
database with PlatformIO's esp32-c6, esp32-c6-kernel, and nodemcu databases
so board sketches are indexed too. Reload the editor window after generating it.
Wi-Fi icons in the emulator use device_secrets.yaml when that file exists
(copy device_secrets.yaml.example). Without it the status bar shows Wi-Fi off.
See README.md.
USB / hardware (make usb → PlatformIO)
device_config.yaml at the repo root describes the connected board (arch, serial
port, LCD, buttons, pio_env). device_secrets.yaml (gitignored) holds
wifi.ssid and wifi.password; make usb will not flash hardware until both
are set. make usb asks which target to compile for (unless DEVICE= is set),
generates build/generated/device_config.h and device_secrets.h, then builds
and flashes with PlatformIO (platformio run -e nodemcu -t upload).
make run # SDL2 emulator
make device-config # validate YAML + emit headers
make usb-ports # list /dev/cu.usb* etc.
make usb # interactive → PlatformIO build + upload
make usb DEVICE=nodemcu PORT=/dev/cu.wchusbserial1410
python3 tests/unit/test_device_config.py
python3 tests/unit/test_device_secrets.py
Files: platformio.ini. Builtin apps are only apps/stdapps/; board
entry points install them through apps/stdapps_register.c. The ESP32-C6
image ([env:esp32-c6]) boots that set. Its ST7789 path composites into
hal/display_fb.c and presents dirty rows from hal_display_flush()
(called by app_display_flush / app_ui_end_frame) so a clear-then-draw
is not visible on the glass. SPI stays at 40 MHz; the panel scan stays at
the driver's ~60 Hz. The NodeMCU image installs the same apps/stdapps set. Its panel size and
controller come from the active profile's lcd.width, lcd.height, and
lcd.type (build/generated/device_config.h), not a fixed 128×32. The
default profile is an SSD1306 on I2C (SCL=D1, SDA=D2).
device_config_nodemcu_rotator.yaml is the IdeaElec 1.3" 128×64 SH1106
(I2C, column offset 2). Driving that glass with SSD1306 horizontal
addressing only clears the top page. A 64-row panel uses alternative COM
(0x12); sequential COM (0x02) draws the picture at the wrong height. UP is D5, SELECT is D6 (other side GND;
long-press SELECT = back to launcher).
On Apple Silicon, ESP8266 builds need Rosetta (Bad CPU type in executable means
it is missing): softwareupdate --install-rosetta --agree-to-license.
NodeMCU upload uses scripts/nodemcu_upload.py (Arduino-style DTR/RTS auto-reset)
so you should not need to hold FLASH; CH340 port drops during reset are retried.
One unit test binary:
cmake --build build -j --target test_scheduler
./build/tests/unit/test_scheduler
tests/unit/CMakeLists.txt globs test_*.c. A new tests/unit/test_foo.c
is picked up on the next CMake configure. Re-run cmake -B build ... if the
new file is not a target yet.
test_headless_exit and test_sim_args link sim_args instead of the sim
library so they do not pull a second main. Follow that if a test must call
argument parsing without the simulator entry point.
TDD gate
scripts/tdd_check.py (pre-commit) rejects a staged .c file outside tests/
unless one of these already exists and contains RUN_TEST, TEST_ASSERT,
void test_, UNITY_BEGIN, or UNITY_END:
tests/unit/test_<stem>.ctests/unit/test_<parent>_<stem>.ctests/integration/test_<stem>.c
Write that test file before the implementation file. Do not bypass the hook.
Unity tests look like the files already in tests/unit/: setUp/tearDown,
void test_..., RUN_TEST, UNITY_BEGIN/UNITY_END, return UNITY_END().
Warnings are errors (-Wall -Wextra -Wpedantic -Werror). Unused parameters need
(void)param;.
Add a built-in app
Use app_framework.h and APP_HELPER for a new screen — see docs/apps.md
and apps/stdapps/counter/counter_app.c. Pass .on_view for a scene,
.every_ms for a timer, .keys for a custom map, .on_load for RAM-heavy
setup that runs once during the boot splash, .on_ready to start a
worker, .live when the screen must repaint every tick, and .state /
.state_size for the session restored on start and resume and stored on quit.
app_kit.h / APP_DEFINE is the lower-level path when those fields are not enough.
Files and pins are fw/io.h.
- Create
apps/stdapps/<name>/and implementon_event/on_view(and optionalon_load,on_ready,on_tick,on_cleanup) in<name>_app.c. Useon_drawwhen the screen places its own pixels. - Fill the scene with
app_scene_row,app_scene_hero,app_scene_clock,app_scene_bar,app_scene_gauge, orapp_scene_panel. - End the file with
APP_HELPER(my_app, "my_app", .on_event = ..., .on_view = ...). Put name, version, author, description, title, and help inapp.json; the compile reads that file into the manifest. The icon ismy_app_icon. Leave.typeoff unless the app is not a tool..fpsapplies only with.liveor.game. - Add the directory to
apps/stdapps/CMakeLists.txt(create-appdoes this). To install it, add anARDUBOT_APP_<NAME>_ENABLEDblock insidestdapps_install()inapps/stdapps_register.c. Boot callsstdapps_start_name(), not a name hardcoded insim/sim_main.c.
Lower-level helpers remain in apps/app_framework.h if you need them.
Add a HAL operation
- Declare it on the matching
hal/include/hal_*.h(opaque handle, open/close, suspend/resume already exist on GPIO — match that style). - Implement it in
hal/arch/sim/first. That is what tests run. - Add a
tests/unit/test_hal_<name>_sim.c(or extend the existing one) before the new.cbody, so the TDD hook is satisfied. - Stub or implement the same symbol on esp32, esp8266, and avr if the header is part of the common HAL. A missing symbol breaks those targets even when sim CI is green.
Scheduler constraints
MAX_TASKSis 16. Names areTASK_NAME_LEN(16) bytes.- Host context switches use
host_fiberon the TCB. Bare-metal fields (jmp_buf,stack_ptr) are separate. Do not assumesetjmpis the sim path. - Public entry type is
task_entry_t(void (*)(void* arg)), declared inkernel/scheduler.h.
Docs to update in the same change
| Change | Update |
|---|---|
| Public HAL, syscall, CLI flag, CMake option | docs/ and, if agents need a new command, AGENTS.md |
| Locked architecture or phase scope | PLAN.md |
| "Where is this code and how do I extend it" | this file |
Leave PLAN.md phase tables alone unless the user is changing the roadmap.