Guidance for AI assistants working in this repo. Keep it short.
MicroPythonOS: GUI + OS for microcontrollers. Source: internal_filesystem/ (1:1 filesystem). C modules: c_mpos/. Built on lvgl_micropython/ (LVGL + MicroPython).
Desktop builds run MicroPython, NOT CPython. sys.implementation.name == 'micropython'. Stdlib is minimal — no uuid, no machine.unique_id().
- Build:
./scripts/build_mpos.sh <target>(mutates tracked files — those edits persist). - Prefer Makefile targets:
make build-mpos-unix,make lint,make lint-fix,make tests,make help. mpy-cross:./lvgl_micropython/lib/micropython/mpy-cross/build/mpy-cross. Needs-march=x64for native/viper on desktop.- Unix/macOS:
build_mpos.shcreates symlinks inlvgl_micropython/ext_mod/forc_mposandsecp256k1-embedded-ecdh.
To keep it easy to manage/sync with upsteam lvgl_micropython, we keep the changes in either:
- Patch files (
.patchapplied bybuild_mpos.sh): commit directly onintegration. No topic branch needed — the patch file is the topic. - Direct edits: must go on a
topic/<name>branch, then merge intointegration. Branch naming:topic/<kebab-case>(e.g.topic/wifi-country-japan).
Run: ./scripts/test_runner.py tests/<test_file> tests/<other_test_file> [--ondevice] [--port <port>] [--reset]
All tests: make tests (20–35 min). CI runs them on push. Syntax: make syntax-tests
CPython controller tests: python3 tests/cpython_mpos_controller.py, python3 tests/cpython_kill_orphaned.py, python3 tests/cpython_kill_orphaned_linux.py (not run by test_runner.py).
Prefs wipe: test_runner.py deletes internal_filesystem/prefs/ (all app settings) in the checkout it runs from (_cleanup_config). Run tests from a worktree if that checkout holds settings you need.
Details: tests/README.md
Options:
--ondevice — run on connected device (serial). --no-install-test-apps skips test-app install.
--port <port> — serial port (overrides MPOS_TEST_PORT env).
--relayport <port> — USB power relay port; with --reset, power-cycles device instead of machine.reset().
--reset — hard-reset device before each test.
--resume — resume suite from first provided test file (alphabetical order).
--cpulimit <pct> — throttle desktop MicroPython to simulate slow CI (requires cpulimit installed).
--logserial <file> — log all serial traffic to file; watch with tail -f.
--coverage — line coverage (desktop only, requires mpcov build). --coverage-save/--coverage-load for merged runs.
--usb-unbind — cleanly unbind/rebind USB between resets to reduce xhci wedging.
--usb-reset-hub — reset xhci host controller after relay power-on.
--usb-settle <s> — USB settle time after unbind/bind (default: 5).
--no-usb-recovery — disable automatic USB recovery on I/O error.
--timeout <s> — per-test timeout. --tests-dir <dir> — add dir to sys.path for test helpers.
Frozen modules in the firmware shadow filesystem .py files even though sys.path = ['lib', '', '.frozen']. To test a modified framework file at internal_filesystem/lib/mpos/ui/*.py on device:
./scripts/install.sh— copies the entirelib/directory to device flash (lib/mpos/→:/lib/mpos/), overriding frozen modules. Slow but comprehensive; run once to populate the tree.python3 lvgl_micropython/lib/micropython/tools/mpremote/mpremote.py connect /dev/ttyACM0 cp internal_filesystem/<path> :<path>— copies a single file. Fast but only works afterinstall.shhas already created the target directory structure on flash.
After deploying, relay-reset so the device boots with the new files (the module cache is cleared on reset).
WARNING — flash shadows replace whole directories, not single files: once :/lib/mpos/ exists on flash, its modules come from flash ONLY. Deleting one flash file (e.g. after instrumenting it) does NOT fall back to frozen — you get ImportError. Never fs rm a shadow file; re-copy the original from internal_filesystem/ instead. Also keep vintages consistent: mixing current-repo copies with an older install causes subtle crashes — if in doubt, re-run full install.sh.
USB/IP + relay reset: the /dev/ttyACM0 node can appear before USB enumeration finishes; opening serial too early wedges the usbip session (URB -104 unlinks, reads return nothing). _relay_reset settles 8s after first seeing the node. Manual recovery: relay-cycle, wait ≥20s, poke with raw pyserial \r\n until >>>.
Build with coverage: ./scripts/build_mpos.sh unix coverage or make build-mpos-unix-coverage
Enables sys.settrace in the mpcov variant. Standard build overwritten — re-run ./scripts/build_mpos.sh unix to restore.
Run tests with coverage:
python3 scripts/test_runner.py --coverage tests/test_a.py tests/test_b.py # terminal report
python3 scripts/test_runner.py --coverage --coverage-save cov.json tests/test_*.py # save JSON
python3 scripts/test_runner.py --coverage --coverage-load cov.json --coverage-save cov.json tests/test_extra.py # merge
Generate HTML report: python3 scripts/coverage_report.py cov.json -o coverage/index.html
Self-contained HTML — overview stats, per-file coverage %, expand inline source with line coloring (red=untested, green=tested, shade intensity by hit count).
When running multiple tests for coverage: prefer test_runner.py with multiple positional args (e.g. tests/test_a.py tests/test_b.py) over one-by-one invocations — the merged coverage report will aggregate all runs.
- Graphical tests (filename contains
graphical): LVGL boot injected. Non-graphical: no boot, no LVGL init — importlvgllazily. - Test CWD =
internal_filesystem/.sys.path.insert(0, ".")assumes that root. --reset(device only): hard-resets viamachine.reset(), waits for"Starting asyncio REPL..."(NOT just>>>). Boot: 2–40s.- USB/IP passthrough: close serial fd before reset; retry open up to 20× (3s apart).
- Serial (paste-mode) test runs strip the file's
if __name__ == "__main__": unittest.main()block — in paste mode__name__ == "__main__", so without stripping,unittest.main()AND the appended TextTestRunner would run the suite twice on already-mutated UI state. Desktop import-runner (_build_import_runner_code) is immune (__name__= module name), which is why such bugs only appear on device. - Use helpers in
internal_filesystem/lib/mpos/ui/testing.py. Followtests/README.mdfor graphical tests.
After any code change: grep for tests importing changed modules, run them. Do NOT skip.
If you modify a test: run it to verify.
Capturing logger output in tests: add custom handler to logger, access record.message in emit(). Restore handlers in finally:.
Test decorators: @unittest.skipIf must be directly above the function — no module-level code between.
Most common test segfault cause: passing a non-LVGL Python object (mock, plain instance) as parent to any LVGL widget constructor. Mock the calling method instead, or use real lv.obj() (graphical test). Known live one: AppStore detail-navigation tests segfault ESP32 asynchronously right after _launch_activity returns (breadcrumbs: N3 present/N4 missing); quarantined via _skip_detail_navigation_on_esp32 in tests/test_graphical_appstore_focus.py.
Never cache asyncio.get_event_loop() in setUp. The test runner may create a fresh event loop between setUp and the test method, making a cached reference stale. Get the loop fresh at call time — inside the test method or inside the task-capture callback:
# WRONG — loop may be stale
def setUp(self):
self._loop = asyncio.get_event_loop()
def _capture_task(coro):
self._loop.run_until_complete(coro) # boom
# RIGHT — fresh lookup
def _capture_task(coro):
import asyncio as _asyncio
_asyncio.get_event_loop().run_until_complete(coro)Task capture triggers cascading side effects. When TaskManager.create_task is mocked to run_until_complete synchronously, any task that internally spawns more tasks also runs synchronously in the same call. E.g. mocking _update_all_click reaches _run_update_all → refresh_list → download_app_index → create_apps_list → _stop_all_timers. Every link in the chain needs a mock (_raw_timer, refresh_list, etc.) even if your test goal is narrow. The crash surfaces far from the mocked entry point — watch the stack trace for the full cascade.
asyncio.core stores the event loop in module-level globals. _task_queue, _io_queue, cur_task, and the SingletonGenerator (created once as a default-argument in sleep_ms) are all shared. asyncio.new_event_loop() replaces _task_queue and _io_queue globally — if called from a test setUp running inside the TaskManager's event loop (paste mode via aiorepl), the outer loop's orphaned queue is GC'd and its tasks become dangling pointers. asyncio.run() also shares these globals; when the inner run_until_complete sets cur_task = None on exit, the outer loop resumes with a stale task pointer → SIGSEGV. For tests that need asyncio.run() inside a running event loop: save/restore cur_task around the call. For tests that call new_event_loop(), restore all three globals after unittest.main(). See _build_import_runner_code() in mpos_controller.py for the standard monkeypatches applied to all tests.
- TDD: write failing test → fix → test passes.
- Lint: every change must pass
make lint. Ruff, double quotes. - Comments/docstrings: never add/remove/modify unless explicitly asked.
- GitHub comments: always very polite and gentle (issues, PRs, reviews).
- Batch edits: constrain to exact patterns. Broad edits can silently delete unrelated code. If damage occurs, restore from git and re-apply a precise script.
- Implement missing functionality rather than working around it.
- USB host: DisplayLink display + HID support lives in
c_mpos/usb/— read itsREADME.mdbefore touching USB, display, touch, or topmenu code; build with./scripts/build_mpos.sh esp32s3 --usb. - Observe before fixing, not after. When a bug involves UI state or runtime data, use
mpos-controller(exec,get_widget_tree,eval) to inspect the actual running system before coding a fix. Do NOT spend more than 2 rounds of static code analysis without validating assumptions against the live process.
The AppStore and AppManager share App objects by reference — self.apps, _by_fullname, and _app_list may all point to the same instance. Mutating any attribute on one path silently changes it everywhere. When tracing a bug where a value is "wrong" despite looking correct in isolation:
- Check object identity first:
a is bon the suspected candidates. Usempos-controller execto test. - Mock objects hide identity bugs. A test mock that creates a fresh object won't catch mutations propagated through shared references. Write at least one test that uses the real objects from the cache (
AppManager._by_fullname) — not mock copies. - Prefer filesystem reads over cache reads for installed state. The cache can go stale or be corrupted by shared-object writes. For installed-app data (version, installed status), reading MANIFEST.JSON directly from
apps/orbuiltin/apps/is more reliable than_by_fullname. - Don't overwrite fields on shared objects. If an App object lives in both the AppManager cache and the store list, use a separate attribute (e.g.,
_remote_version) for store-specific data instead of mutating shared fields likeversion.
import logging; logger = logging.getLogger(__name__)
if __debug__: logger.debug("fmt %s", var) # eliminated by mpy-cross -O3
logger.warning("msg: %s", e) # always include %s placeholder- Prefer
%sover f-strings.mpy-cross -O3strips__debug__blocks entirely (strings + bytecode). - Trap:
logger.error("msg: ", e)→TypeError(no%sin format string). Always:"msg: %s". f"{var:spec}"→"%spec"format + var as positional arg.f"{var=}"→ strip=. Compound; print(...)needs line-level matching.__debug__guard trap:if __debug__: logger.debug("x=%s", x)—xmust be defined before this line. Assign first, log second.
- Desktop:
timeout -s 9 30 ./scripts/run_desktop.sh - Kill processes:
killall <name>(notpkill -f). - Temp files:
tmp/in project root (not/tmp). - MPOSController cleans up on exit. Manual
killall -9 lvgl_micropy_unix run_desktop.shonly for SIGKILL orphans. - Debug scripts: write to
tmp/, run withpython3 tmp/script.py.
Drives MicroPythonOS from CPython. backend='process' (local PTY) or backend='serial', port='/dev/ttyACM0' (device).
Must call mpos.start() then wait ~8s before any other method.
Key methods: exec(), eval(), startapp(), run_app_with_file(), run_test_file(), screenshot(), screenshot_image(), screenshot_pixels(), save_screenshot(), press(x,y), press_key(), click_button(), find_widget(), press_widget(), wait_for_text(), expect_text(), get_visible_text(), get_widget_tree(), read_file(), write_file().
exec()/exec_multiline()use paste mode — equivalent, multi-line safe. Timeout supported.get_visible_text():lv.screen_active()only (NOTlv.layer_top()— misses popups/msgboxes).get_widget_tree(): includeslayer_top, returns JSON (type, text, coords in content-space, flags, states). Off-screen children included.click_button("text"): matches own or child-label text, clicks center.screenshot()/save_screenshot()capturescreen_active()ONLY (nolayer_top()— drawer/bar/popups invisible). For overlays,execsave_screenshot_bmp('/tmp/x.bmp', all_layers=True)in-target (desktop shares/tmpwith host; on device write under/thenread_file()). Serial takes ~40s.drag()on the process backend is discrete taps (simulate_clickat interpolated points), NOT a continuous swipe — gesture/swipe tests mustexecsimulate_drag()in-target. (Serialdrag()is a real drag.)wait_for_text("text", timeout=10),expect_text("text").startapp(name, intent={...}),run_app_with_file(app, file).- Notification bar:
mpos.eval("mpos.ui.topmenu.bar_open"), height 24px. - Coordinates are LOGICAL (rotated content-space) everywhere: on-device
simulate_click/simulate_dragand hostpress()/drag()all take the same logical coords; the rotation transform (e.g. 270°) is applied once, inside_touch_read_cb(testing.py). Do NOT pre-transform in callers — THIS SEEMS WRONG in older notes that said press() transforms; it no longer does.
Debugging workflow:
- Widget tree first (
get_widget_tree()) — layout, types, coords, states. - PIL + numpy from BMP for exact pixel checks.
- ppq-vision skill for text/visuals from screenshots.
get_visible_text()for active-screen labels only.
Pitfalls:
lv_color_thas.red/.green/.blueonly (no.full).lv.snapshot_take()on hidden obj leaks theme artifacts. For truly empty images, constructlv.image_dsc_t()manually with zeroedbytearray().- Snapshot scaled image: wrap in container, snapshot the container. See
font_manager.py:_render_scaled_image_src(). except Exception: passin image paths hides failures silently.lv.msgbox()with no parent = modal. Parent = non-modal (no backdrop).- Stale processes after SIGKILL?
killall -9 lvgl_micropy_unix run_desktop.sh. - When output should appear but doesn't: add temporary
print()— genericexcept Exception: passoften hides bugs.
Template:
import sys; sys.path.insert(0, '.')
from scripts.mpos_controller import MPOSController
with MPOSController(backend='process') as mpos:
mpos.run_app_with_file('com.micropythonos.imageview', 'data/images/test.bmp')
mpos.save_screenshot('tmp/screenshot.bmp')- Soft reset broken →
machine.reset(). hashlib.sha1(...).hexdigest()missing →ubinascii.hexlify(hashlib.sha1(data).digest()).decode().os.uname()absent on Unix port →os.getenv('HOSTNAME', '')orsocket.gethostname().unittestlacksassertGreater/Less→assertTrue(a > b)._threadcooperative: secondary loops must yield (time.sleep_ms(1), notsleep_us()).- No
bytearray * int→bytearray(); [out.extend(buf) for _ in range(n)]. - Some builds lack
random.Random/shuffle→ Fisher-Yates withrandint. Prefer tiny LCG for deterministic jitter. logging.Logger.log()formats viamsg % args— always include%swhen passing variables.asyncio.TimeoutErroris NOT anOSErrorsubclass on MicroPython. Catchingexcept OSErrorfor socket operations will silently miss timeouts — useexcept (OSError, asyncio.TimeoutError).
Use: lv.screen_active() (not scr_act), lv.button/lv.image, lv.obj.FLAG.*, lv.EVENT.VALUE_CHANGED, lv.label.LONG_MODE.WRAP, lv.buttonmatrix.CTRL.*, lv.palette_main(lv.PALETTE.RED), lv.color_hex(0x...).
Critical gotchas:
lv.style_t()must call.init()before setters, or device hangs.- New labels default to
"Text"— alwayslabel.set_text(""). - LVGL wrappers can't hold Python attributes → closures/lambdas:
lambda e, i=idx: cb(e, i). event.get_target_obj()notget_current_target()(returns blob → hang).lv.timer_create()defaults to infinite.set_repeat_count(0)= one-shot + auto-delete → double-free on.delete(). Use-1for infinite,1for one-shot.buttonmatrix: noset_button_text/set_button_ctrl→ useset_map().set_map()fires asyncVALUE_CHANGED→ 50ms debounce.- Keyboard: always
MposKeyboardfrommpos, never rawlv.keyboard(). - Flex layout + alignment: add
lv.obj.FLAG.FLOATINGto escape flow. - SDL keyboard: no key-release event. Use timeout-based approach for games.
- Don't hardcode sizes >5px — use
DisplayMetricsfrommpos. - Styles: setter takes value only.
style.set_bg_color(c); obj.add_style(style, lv.PART.ITEMS | lv.STATE.CHECKED). - Animation: pass
True/False, notlv.ANIM.OFF. - Disable/enable:
obj.add_state(lv.STATE.DISABLED)/remove_state(...), NOTobj.FLAG.DISABLED. - Hide/show:
obj.add_flag(lv.obj.FLAG.HIDDEN)/remove_flag(...). - Event callbacks: 3 args,
def method(self, event=None)for dual use. - Use
remove_flag()/remove_state()notclear_flag()/clear_state(). lv.OPAvalues at steps of 10 only. No_5— use nearest step or raw int.- Make UI uniform: buttons in same flex row = same height.
@micropython.viperis compile-time:hasattr(micropython, "viper")returnsFalse. Checkhasattr(micropython, "native").- Guard programmatic decorators:
if hasattr(micropython, "native"): micropython.native(func). - Viper:
ba[i]returnsobject→int()cast. int16 sign extension:v = int(ba[i]) | (int(ba[i+1]) << 8); if v & 0x8000: v -= 65536.
- Remember that MicroPythonOS often runs on resource constrained hardware
sys.platformalways'esp32'(S3, C3, etc.).Pin.init(Pin.OUT)silently overrides peripheral GPIO routing → no output, no error. Fix: deinit + re-create peripheral.- Shared RMT pin: re-create RMT driver (not just
pin.init). asyncio.create_task()from outside the event loop (timer callbacks, thread, boot services) does NOT wake the event loop — tasks queue up and execute with unpredictable delay (~6s typical on ESP32-S3). Architectures that rely on acreate_task()-spawned loop polling a flag will fail. Instead, callcreate_task()directly from each callback (seeappstore_core._network_changedfor the proven pattern).TaskManager.create_task()crashes at C level whenTaskManager.disable()is True (the test runner disables TaskManager). If any activityonResumecallback callscreate_task(), the device reboots silently — no Python traceback, no issue on desktop. Fix: guardcreate_taskwithif cls.disabled: return Nonein source AND mirror inmpos_controller.py:_build_test_coderuntime patch (frozen modules shadow flash files).- Services that call
start()→register_callback()→create_task(_run_loop())at boot: the callback path works (fired via timer ISR on main thread), but the run-loop task may face scheduling delays. Don't rely on the run-loop for time-sensitive work — treat it as best-effort periodic background check.
- Button→key mapping differs per board. Most keypad boards (2026, odroid_go, m5stack_fire, lilygo_t4, unphone, unihiker) map secondary buttons to
lv.KEY.NEXT/PREV, which LVGL consumes for focus tabbing — the focused object never receives them asEVENT.KEY. Only fri3d 2024 + desktop SDL send action buttons asord("B")/ord("Y")letters that reach apps. - fri3d 2026 (
drivers/indev/fri3d_2026_expander.py) dual-delivers: B→NEXT (tab) +send_data(ord("B")), Y→PREV (tab) +send_data(ord("Y")). So apps can rely onord("B")/ord("Y")KEY events on both badges + desktop. Every B/Y press still tabs focus — apps that care must repair focus next frame (seequasibird.py:_repair_focus). - For held-state (not edge) input, poll
mpos.io_expander.digitaldirectly (indices:[6]=B [7]=A [8]=Y [9]=X); seecom.casiegames.runner._fire_nav_hookin the expander driver is aKeypadDriverhook running in the LVGL read tick — side effects there (back_screen, send_data) follow existing precedent.
- 31-byte advertising cap (NimBLE). No extended advertising. Scan response = separate 31 bytes.
- IRQ handlers run in main thread — LVGL calls are thread-safe.
- Every var assigned in IRQ handler needs
global. bytes(addr)after scan/connect — stack reuses buffer.- Sync event dispatch: guard
_ble_irq_handlerwith recursion depth counter (~8). - Peripheral dicts: never
clear(). Remove stale entries by timestamp. - GATT busy flag: clear at end of idle-exit path, not just disconnect.
- Bug at commit Y, worked at X:
git diff X..Y --name-only, then trace every changed line. Don't assume the bug is in the most recent file. - PTY I/O error (
OSError 5) = binary crash. 139=SIGSEGV, 134=SIGABRT. Run binary directly with-cto reproduce. - Trust visual reality over code intent with UI bugs. Use
mpos.get_widget_tree()to inspect actual coordinates. - A crash is ALWAYS a bug: fix the crash site first, work around it second — never avoidance alone. "Upstream code" is not immunity: this repo already patches IDF (
patches/*.patchviabuild_mpos.sh), and a decodeable PC (xtensa-*-elf-addr2line -e <elf>) turns a mystery abort into a patchable line. Avoidance is only acceptable together with a fix attempt, or when the crash is in upstream code AND the workaround is genuinely easy (no permanent UX tax).
Full guide: ../docs/docs/apps/app-performance.md. Load-bearing rules:
- Measure via serial
sysmon:(render Xms | flush Yms) with overlay off. FPS counts refresh cycles (~300ms window), capped ~30 byLV_DEF_REFR_PERIOD=33ms.change_task_handler(period)is the outer pump, not the refresh rate. - ESP32-S3 SPI: 80MHz max (integer divider, 60 rounds down to 40). Freq is per-device; flush is DMA-async.
- No floats in hot loops (heap objects → GC jitter). Fixed-point milli-pixels with accumulators (naive
SPEED*dt//1000truncation freezes slow movers); modulo-bound counters so they never outgrow small ints. set_text()/set_offset_x()always invalidate → guard on change.set_x/set_yare free when unchanged.- Permanent
set_rotation()= per-pixel transform forever → ship pre-rotated assets.TILE= 1 draw call per tile → pre-compose strips. Translucent+rounded panels = blend+mask+overdraw. ruff/mpy-crosson the real path: symlinked external-app files are NOT covered bymake lint/syntax-tests.
- Install:
./scripts/install.sh com.micropythonos.appnamethenAppManager.refresh_apps(). - Deploy files:
mpremote cp source :/dest/thenmachine.reset(). self.appFullNameauto-set by ActivityNavigator — use forSharedPreferences(self.appFullName).- Prefer
mpos.ui.SettingsActivity/SettingActivityover custom dialogs. - Follow symlinks in
internal_filesystem/apps/when searching. nostr_service.pyshared between nostr/displaywallet — update both copies.- Docs: sibling
../docs/.mkdocs buildto check. Some.mdfiles intentionally excluded from nav (included by other pages) — don't add them.