Skip to content

Latest commit

 

History

History
285 lines (220 loc) · 24.7 KB

AGENTS.md

File metadata and controls

285 lines (220 loc) · 24.7 KB

AGENTS.md

Guidance for AI assistants working in this repo. Keep it short.

Project & build

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=x64 for native/viper on desktop.
  • Unix/macOS: build_mpos.sh creates symlinks in lvgl_micropython/ext_mod/ for c_mpos and secp256k1-embedded-ecdh.

lvgl_micropython submodule branching

To keep it easy to manage/sync with upsteam lvgl_micropython, we keep the changes in either:

  • Patch files (.patch applied by build_mpos.sh): commit directly on integration. No topic branch needed — the patch file is the topic.
  • Direct edits: must go on a topic/<name> branch, then merge into integration. Branch naming: topic/<kebab-case> (e.g. topic/wifi-country-japan).

Testing

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.

Deploying lib/ changes to device

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 entire lib/ 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 after install.sh has 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 >>>.

Code coverage (desktop only)

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 — import lvgl lazily.
  • Test CWD = internal_filesystem/. sys.path.insert(0, ".") assumes that root.
  • --reset (device only): hard-resets via machine.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. Follow tests/README.md for 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.

Development rules

  • 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 its README.md before 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.

Debugging shared-object / identity bugs

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:

  1. Check object identity first: a is b on the suspected candidates. Use mpos-controller exec to test.
  2. 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.
  3. 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/ or builtin/apps/ is more reliable than _by_fullname.
  4. 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 like version.

Logging

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 %s over f-strings. mpy-cross -O3 strips __debug__ blocks entirely (strings + bytecode).
  • Trap: logger.error("msg: ", e) → TypeError (no %s in 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) — x must be defined before this line. Assign first, log second.

Process & temp files

  • Desktop: timeout -s 9 30 ./scripts/run_desktop.sh
  • Kill processes: killall <name> (not pkill -f).
  • Temp files: tmp/ in project root (not /tmp).
  • MPOSController cleans up on exit. Manual killall -9 lvgl_micropy_unix run_desktop.sh only for SIGKILL orphans.
  • Debug scripts: write to tmp/, run with python3 tmp/script.py.

MPOS Controller (scripts/mpos_controller.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 (NOT lv.layer_top() — misses popups/msgboxes).
  • get_widget_tree(): includes layer_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() capture screen_active() ONLY (no layer_top() — drawer/bar/popups invisible). For overlays, exec save_screenshot_bmp('/tmp/x.bmp', all_layers=True) in-target (desktop shares /tmp with host; on device write under / then read_file()). Serial takes ~40s.
  • drag() on the process backend is discrete taps (simulate_click at interpolated points), NOT a continuous swipe — gesture/swipe tests must exec simulate_drag() in-target. (Serial drag() 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_drag and host press()/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:

  1. Widget tree first (get_widget_tree()) — layout, types, coords, states.
  2. PIL + numpy from BMP for exact pixel checks.
  3. ppq-vision skill for text/visuals from screenshots.
  4. get_visible_text() for active-screen labels only.

Pitfalls:

  • lv_color_t has .red/.green/.blue only (no .full).
  • lv.snapshot_take() on hidden obj leaks theme artifacts. For truly empty images, construct lv.image_dsc_t() manually with zeroed bytearray().
  • Snapshot scaled image: wrap in container, snapshot the container. See font_manager.py:_render_scaled_image_src().
  • except Exception: pass in 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() — generic except Exception: pass often 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')

Platform constraints

MicroPython compatibility

  • 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', '') or socket.gethostname().
  • unittest lacks assertGreater/Less → assertTrue(a > b).
  • _thread cooperative: secondary loops must yield (time.sleep_ms(1), not sleep_us()).
  • No bytearray * int → bytearray(); [out.extend(buf) for _ in range(n)].
  • Some builds lack random.Random/shuffle → Fisher-Yates with randint. Prefer tiny LCG for deterministic jitter.
  • logging.Logger.log() formats via msg % args — always include %s when passing variables.
  • asyncio.TimeoutError is NOT an OSError subclass on MicroPython. Catching except OSError for socket operations will silently miss timeouts — use except (OSError, asyncio.TimeoutError).

LVGL (import as lv, docs at lvgl_micropython/lib/lvgl/docs/)

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" — always label.set_text("").
  • LVGL wrappers can't hold Python attributes → closures/lambdas: lambda e, i=idx: cb(e, i).
  • event.get_target_obj() not get_current_target() (returns blob → hang).
  • lv.timer_create() defaults to infinite. set_repeat_count(0) = one-shot + auto-delete → double-free on .delete(). Use -1 for infinite, 1 for one-shot.
  • buttonmatrix: no set_button_text/set_button_ctrl → use set_map(). set_map() fires async VALUE_CHANGED → 50ms debounce.
  • Keyboard: always MposKeyboard from mpos, never raw lv.keyboard().
  • Flex layout + alignment: add lv.obj.FLAG.FLOATING to escape flow.
  • SDL keyboard: no key-release event. Use timeout-based approach for games.
  • Don't hardcode sizes >5px — use DisplayMetrics from mpos.
  • Styles: setter takes value only. style.set_bg_color(c); obj.add_style(style, lv.PART.ITEMS | lv.STATE.CHECKED).
  • Animation: pass True/False, not lv.ANIM.OFF.
  • Disable/enable: obj.add_state(lv.STATE.DISABLED) / remove_state(...), NOT obj.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() not clear_flag()/clear_state().
  • lv.OPA values at steps of 10 only. No _5 — use nearest step or raw int.
  • Make UI uniform: buttons in same flex row = same height.

MicroPython @native / @viper

  • @micropython.viper is compile-time: hasattr(micropython, "viper") returns False. Check hasattr(micropython, "native").
  • Guard programmatic decorators: if hasattr(micropython, "native"): micropython.native(func).
  • Viper: ba[i] returns object → int() cast. int16 sign extension: v = int(ba[i]) | (int(ba[i+1]) << 8); if v & 0x8000: v -= 65536.

ESP32

  • Remember that MicroPythonOS often runs on resource constrained hardware
  • sys.platform always '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 a create_task()-spawned loop polling a flag will fail. Instead, call create_task() directly from each callback (see appstore_core._network_changed for the proven pattern).
  • TaskManager.create_task() crashes at C level when TaskManager.disable() is True (the test runner disables TaskManager). If any activity onResume callback calls create_task(), the device reboots silently — no Python traceback, no issue on desktop. Fix: guard create_task with if cls.disabled: return None in source AND mirror in mpos_controller.py:_build_test_code runtime 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.

Badge buttons (fri3d 2024 vs 2026)

  • 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 as EVENT.KEY. Only fri3d 2024 + desktop SDL send action buttons as ord("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 on ord("B")/ord("Y") KEY events on both badges + desktop. Every B/Y press still tabs focus — apps that care must repair focus next frame (see quasibird.py:_repair_focus).
  • For held-state (not edge) input, poll mpos.io_expander.digital directly (indices: [6]=B [7]=A [8]=Y [9]=X); see com.casiegames.runner. _fire_nav_hook in the expander driver is a KeypadDriver hook running in the LVGL read tick — side effects there (back_screen, send_data) follow existing precedent.

BLE

  • 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_handler with 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.

Debugging

  • 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 -c to 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/*.patch via build_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).

Performance

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 by LV_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//1000 truncation 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_y are 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-cross on the real path: symlinked external-app files are NOT covered by make lint/syntax-tests.

Apps & docs

  • Install: ./scripts/install.sh com.micropythonos.appname then AppManager.refresh_apps().
  • Deploy files: mpremote cp source :/dest/ then machine.reset().
  • self.appFullName auto-set by ActivityNavigator — use for SharedPreferences(self.appFullName).
  • Prefer mpos.ui.SettingsActivity/SettingActivity over custom dialogs.
  • Follow symlinks in internal_filesystem/apps/ when searching.
  • nostr_service.py shared between nostr/displaywallet — update both copies.
  • Docs: sibling ../docs/. mkdocs build to check. Some .md files intentionally excluded from nav (included by other pages) — don't add them.