This file provides guidance to agents when working with code in this repository.
Introduction:
This repo contains MicroPythonOS, a graphical user interface and operating system for microcontrollers, complete with appstore, over-the-air updates, and lots of apps. The main code is in the internal_filesystem/ folder, which is a one-to-one filesystem layout. It's built on top of the lvgl_micropython/ submodule project, with itself builds on submodules like lvgl_micropython/lib/lvgl and lvgl_micropython/lib/micropython MicroPythonOS also contains some C/C++ modules with MicroPython bindings in c_mpos/
- Build is driven by
./scripts/build_mpos.sh <target>; it mutates tracked files (patcheslvgl_micropython/lib/micropython/ports/esp32/main/idf_component.yml, appends include tomicropython-camera-API/src/manifest.py). Re-run builds expecting these edits to persist unless reverted. - A root
Makefilenow provides the preferred entry points for common tasks. Prefer these targets over direct script calls when an equivalent target exists:make build-mpos-unix,make syntax-tests,make unittest-tests,make tests,make lint,make lint-fix(usemake helpto list all targets). - Unix/macOS builds rely on symlinks created by
build_mpos.shinlvgl_micropython/ext_mod/forc_mposandsecp256k1-embedded-ecdhbecauseUSER_C_MODULEis unreliable on those targets. - Syntax tests run via
./tests/syntax.shand compile everyinternal_filesystem/**/*.pywithmpy-crossbut remove the .mpy files afterwards; failing files are reported by path. mpy-crossbinary lives at./lvgl_micropython/lib/micropython/mpy-cross/build/mpy-cross.- Unit tests run via
./tests/unittest.sh [test_file] [--ondevice]; runner injectsmain.pyand disablesmpos.TaskManagerfor desktop, but on-device runs must NOT re-run boot/main (the script handles this). - Running all unit tests takes a very long time (20 to 35 minutes) so better to run a broad selection of what might be impacted by the change. The build server will run all of them upon git push anyway.
- Graphical tests are detected by filename containing
graphicaland run with LVGL boot/main injected; non-graphical tests run without boot files. - To run a single test, pass a file path to
./tests/unittest.sh(absolute path is resolved inside the script). - Testing workflow details and examples live in
tests/README.md; check it before adding new tests. - To install an app on a physical device:
./scripts/install.sh com.micropythonos.appname - After installing an app, call
AppManager.refresh_apps()to reload the app registry beforestart_app()can find it. - To deploy updated files to a physical device (e.g. updated
testing.py):python3 lvgl_micropython/lib/micropython/tools/mpremote/mpremote.py cp internal_filesystem/lib/mpos/ui/testing.py :/lib/mpos/ui/testing.pythenimport machine; machine.reset()and wait 30 seconds for the device to boot. - Code formatting for Python in this repo is ruff with double quotes configured in
ruff.toml(quote-style = "double").
Guidelines:
- If something is incomplete or lacks functionality that is needed to finish the task, then implement the missing functionality, rather than working around it.
- Every code change must pass
make lint. - Never add, remove or modify inline comments or docstrings on your own initiative, unless the task explicitly asks for it. Comments and docstrings preserve intent and are treated as critical documentation. Refactoring or logging-conversion tasks must target only the specific code elements they're asked about and leave all other content untouched.
- Danger: batch-editing agents that operate on many files at once MUST be strictly constrained to only touch the specific patterns requested. Without tight boundary rules (e.g. "only edit lines matching
print("), agents may delete unrelated code: docstrings, constant definitions, function bodies, inline comments, imports, etc. If damage occurs, the safest fix might be restoring from git and running a precise targeted script, but request user confirmation before doing so. - Debug logging: use
if __debug__: logger.debug("fmt %s", var)(one line,ruff.tomlignores E701).mpy-cross -O3eliminates these blocks entirely at compile time — strings AND bytecode gone. Uselogger.warning/error/criticalwithout__debug__guard. Alwaysimport logging; logger = logging.getLogger(__name__)per file. Prefer%sformatting over f-strings for lazy eval. - When converting
print()calls to structured logging, watch for f-string edge cases: (a){var:format_spec}→%format_specin the format string with the var as positional arg, (b){var=}debug syntax → strip the trailing=, (c) compound statements (; print(...)) need line-level matching, not just line-start matching. - Logging trap:
logger.error("msg: ", e)— MicroPython's logging module formats messages withmsg % args. A format string without%s(or similar) combined with a non-emptyargsraisesTypeError: format string didn't convert all arguments. Always include a%splaceholder when passing variables:logger.error("msg: %s", e). The same applies tologger.warning(),logger.info(), andlogger.debug(). - Always add a timeout -s 9 30 to ./scripts/run_desktop.sh so run: timeout -s 9 30 ./scripts/run_desktop.sh
- Write temporary files to a
tmp/folder in the CWD, not/or/tmp, due to permissions constraints. - To kill processes, use
killall <name>instead ofpkill -f <pattern>—pkill -fmatches the pkill command's own argv and can kill itself. MPOSControllerkills stale desktop binaries automatically instart()and cleans up onSIGINT/SIGTERM/normal exit. Only runkillall -9 lvgl_micropy_unix run_desktop.shmanually if a hardSIGKILLleft orphans behind.- When using mpos-controller for debugging, write all scripts to
tmp/in the project root (not/tmp). Run them withpython3 tmp/script.py.
Guidelines for writing or updating tests:
- Use the testing facilities in ./internal_filesystem/lib/mpos/ui/testing.py and feel free to add new ones there, NOT ad hoc in the test itself.
- When adding graphical tests, follow the helpers and conventions described in tests/README.md.
- To capture logger output in tests: the logging module's
StreamHandlerstoressys.stderrat import time in_stream. Replacingsys.stderrat runtime does NOT redirect existing loggers. Instead, add a custom handler to the specific logger:logging.getLogger("mpos.net.download_manager").addHandler(handler). In the handler'semit(), access the formatted message viarecord.message(notself.format(record), which requires a formatter to be set). Restore the original handlers list infinally:.
ESP32 hardware tips:
- ESP32 GPIO matrix:
Pin.init(Pin.OUT)reconfigures the GPIO matrix to route CPU/software GPIO to a pin, which overrides any peripheral (RMT, SPI, I2C, etc.) routing previously established. If a pin was claimed by a peripheral (e.g.RMT(0, pin=pin, ...)), callingpin.init(Pin.OUT)afterward disconnects the peripheral silently — no error, but no output. The peripheral appears to work (no exception, correct timing) but produces nothing. The fix is to deinit and re-create the peripheral driver after re-asserting the pin direction, so the peripheral re-acquires the GPIO matrix routing. - ESP32 RMT + shared pins: if a pin is shared between an RMT TX driver and another peripheral (e.g. battery ADC on the Fri3d 2024 badge, GPIO 13), the correct recovery after the ADC reconfigures the pin is to deinit the RMT driver and re-create it — NOT to call
pin.init(Pin.OUT)alone. Re-creating the driver callsRMT(0, pin=pin, ...)which re-establishes the GPIO matrix routing correctly. sys.platformon all ESP32 variants (including S3, C3, etc.) returns'esp32'in MicroPython, not'esp32s3'etc.
Debugging tips:
- When a user says "it worked at commit X but broke at commit Y", do
git diff X..Y --name-onlyfirst, thengit diff X..Y -- <relevant files>to read every changed line. Do NOT assume the bug is in the most recently touched file — trace the exact execution path through all changed files. A one-line addition in an unrelated-looking file can break something silently (e.g.pin.init(Pin.OUT)added for one board breaking RMT on all boards). - Silent failures with no exception and correct-looking log output (e.g. "transmit done" in expected time) usually mean the operation ran but its output was routed/discarded somewhere. For hardware peripherals: check GPIO matrix routing, DMA channel conflicts, and whether a pin was reclaimed by another peripheral since the driver was initialized.
MicroPython BLE tips:
- BLE advertising data capped at 31 bytes. Enforced at NimBLE HCI level (
BLE_HCI_MAX_ADV_DATA_LEN = 31inlib/mynewt-nimble/nimble/include/nimble/hci_common.h). Extended advertising NOT compiled in (MYNEWT_VAL_BLE_EXT_ADV = 0inextmod/nimble/syscfg/syscfg.h). Pack service data + nickname carefully; use short name (0x08) over complete name (0x09) to save bytes. Scan response data has a separate 31-byte budget. - BLE IRQ handlers run in the main MicroPython thread (NimBLE events dispatched via
mp_bluetooth_nimble_poll()from VM main loop). LVGL widget manipulation from BLE event handlers is thread-safe — no need forupdate_ui_threadsafe_if_foreground()wrappers. - Module-level BLE state machines: every variable assigned in an IRQ handler needs
global. Forgetting it creates a silent local shadow variable (no error, state just disappears). Check every=across every handler function. Reading module-level vars withoutglobalis fine. - UUID comparison mismatch mock vs real: mock IRQ events must pass raw integers for UUID fields (
uuid == 0xB2E4), but on real hardware UUIDs arebluetooth.UUID()objects. A_uuid(val)helper that returns raw int in mock mode and_bt.UUID(val)on device handles both paths. addr = bytes(addr)after_IRQ_SCAN_RESULT/_IRQ_PERIPHERAL_CONNECT— the BLE stack reuses the address buffer after the IRQ handler returns. Copy withbytes()before storing for later use (e.g. in dicts or GATT connect calls).- ESP32 BLE uses synchronous event dispatch (
MICROPY_PY_BLUETOOTH_USE_SYNC_EVENTS = 1inmpconfigport.h).gap_connect(),gattc_discover_services(),gattc_write()etc. dispatch IRQ events synchronously inside the call, causing deep re-entrant nesting of_ble_irq_handler. Guard with a recursion-depth counter (_irq_depth); bail if depth exceeds ~8 to preventmaximum recursion depth exceeded. - When a BLE peripheral dict (e.g.
_devices) tracks both scan data (rssi, last_seen) and ephemeral state (friend-request relation), neverclear()the dict — it wipes state that must survive between scan cycles. Instead, tracklast_seentimestamps per entry and remove only entries stale for N cycles at_on_scan_done. - GATT busy-flag deadlock: if a
_process_gatt_queue()function sets a_gatt_busy = Trueflag at entry and only clears it in a disconnect handler, then any exit path that finds nothing to send leaves the flag stuck True, permanently blocking all future GATT operations. Always clear the busy flag at the end of the idle-exit path.
LVGL tips:
- the LVGL docs are available in lvgl_micropython/lib/lvgl/docs/ for example lvgl_micropython/lib/lvgl/docs/src/details/widgets/msgbox.rst
lv.OPAenum only has values at steps of 10:TRANSP(0),_10,_20, ...,_100, andCOVER(255). Values like_5do NOT exist — use the nearest step or a raw integer (0–255).- import lvgl as
lvand uselv.to access it lv.screen_active()(notlv.scr_act())- use
buttoninstead ofbtn,imageinstead ofimg - use
lv.EVENT.VALUE_CHANGEDinstead oflv.EVENT_VALUE_CHANGED - instead of
lv.OBJ_FLAG.CLICKABLE, uselv.obj.FLAG.CLICKABLE(same pattern for other flags) - instead of
.set_hidden(True)use.add_flag(lv.obj.FLAG.HIDDEN); instead of.set_hidden(False)use.remove_flag(lv.obj.FLAG.HIDDEN) - use
.remove_flag()instead of.clear_flag() - use
obj.remove_state(...)notobj.clear_state(...) - to disable/enable a widget use
obj.add_state(lv.STATE.DISABLED)/obj.remove_state(lv.STATE.DISABLED); do NOT useobj.add_flag(lv.obj.FLAG.DISABLED) - event handlers need 3 arguments:
button.add_event_cb(button_cb, lv.EVENT.CLICKED, None) - if you pass a method as an event callback, it must accept the event argument:
def callback(self, event). Using the same method as both a direct call and an event callback requires a default:def method(self, event=None). - don't hard-code display resolution; use
lv.pct(100)or other techniques to scale the interface - use
DisplayMetrics(from mpos import DisplayMetrics) for widget sizes and spacing; avoid hard-coding pixel values greater than 5. PreferDisplayMetrics.pct_of_width(...),DisplayMetrics.pct_of_height(...),DisplayMetrics.width(), etc. DRAW_PART_BEGINdoes not exist anymore- don't use
get_child_by_type(); use a global variable with the child you want instead - msgbox:
msgbox = lv.msgbox()thenmsgbox.add_title("title") - use
lv.buttonmatrix.CTRL.CHECKABLEinstead oflv.BUTTONMATRIX_CTRL_CHECKABLE - use
lv.buttonmatrix.CTRL.CHECKEDinstead oflv.BUTTONMATRIX_CTRL_CHECKED - colors:
RED = lv.palette_main(lv.PALETTE.RED)orDARKPINK = lv.color_hex(0xEC048C) - use
lv.anim_t.path_ease_in_outnotlv.anim_path_ease_in_out - instead of
label.set_long_mode(lv.label.LONG.WRAP)uselabel.set_long_mode(lv.label.LONG_MODE.WRAP) - use
style_obj = lv.style_t()thenstyle_obj.init()instead oflv.style() - always call
style.init()afterlv.style_t()before calling setters likeset_bg_color()— without it the device may hang - don't leave label text uninitialized; it defaults to
"Text"instead of being empty. Always set text explicitly withlabel.set_text("")if you want an empty label - In LVGL 9.x style setters take only the value (no selector). The selector goes in
add_style(). E.g.style.set_bg_color(lv.color_hex(0x...))thenobj.add_style(style, lv.PART.ITEMS | lv.STATE.CHECKED). lv.buttonmatrixhas noset_button_text()orset_button_ctrl()in this binding. To update text, rebuild and callset_map(). To mark buttons visually (e.g. solved state), change the text symbol itself (e.g. append "!").lv.buttonmatrix.set_map()firesLV_EVENT_VALUE_CHANGEDasynchronously (next LVGL tick), causing phantom second-selection events. Guard with a time-based debounce (time.ticks_diff(now, last_ts) < 50) rather than a simple flag.- use
obj.scroll_to_view_recursive(True)to scroll something into view or widget.scroll_to_y(0x7FFFFFFF, True) to scroll all the way down in a widget - Don't use lv.ANIM.OFF nor lv.ANIM_OFF - just pass False if you dont want animation or True if you do want animation
- LVGL object wrappers (e.g.
lv.button(),lv.obj()) do NOT support arbitrary Python attribute assignment (btn.idx = 5raisesAttributeError). To associate data with a widget, use closures/lambdas (lambda e, i=idx: callback(e, i)) or maintain parallel lists keyed by list index. - In event callbacks, use
event.get_target_obj()instead ofevent.get_current_target(). The latter returns a genericBlobthat can hang when passed to typed LVGL methods (e.g.lv.list.get_button_text()).get_target_obj()returns a properly typedlv.obj. lv.obj.set_style_scrollbar_mode()does NOT exist in this binding. Useobj.remove_flag(lv.obj.FLAG.SCROLLABLE)to hide scrollbars.- Always call
label.set_text("")on newly created labels — they default to displaying the literal text"Text"otherwise. - Use
align_to(existing_widget, lv.ALIGN.OUT_RIGHT_MID, offset, 0)to position a widget relative to another. - On a parent with
lv.FLEX_FLOW.COLUMN(or any flex layout),lv.ALIGN.TOP_RIGHTalone won't float a widget in the top-right corner — flex layout still pushes it into the flow. Addlv.obj.FLAG.FLOATINGto remove the widget from the flex flow so alignments work as expected. - The lvgl_micropython SDL keyboard driver processes each key event as an instantaneous press+release pair via
LV_INDEV_MODE_EVENT. It callslv_indev_read()twice per SDL_KEYDOWN (once returning PRESSED, once RELEASED). SDL_KEYUP is completely ignored — noLV_EVENT_KEYfires on key release. To detect key release in games, use a timeout-based approach: on first press set a long deadline (~600ms to cover SDL's initial repeat delay), on repeat events set a short extension (~100ms to cover the steady-state repeat interval), and reset movement direction when the deadline expires. Track the deadline with_player_dir_untiland checktime.ticks_diff(now, _player_dir_until) > 0in the game loop. - On-screen keyboards: always use
MposKeyboardfrommpos.ui.keyboard(already exported frommpos). Never use the rawlv.keyboard()widget. IfMposKeyboardis missing a feature you need, extendMposKeyboardinstead of falling back tolv.keyboard(). lv.timer_create()creates timers withrepeat_count = -1(infinite/periodic). Callingset_repeat_count(0)does not make a timer infinite; in LVGL 9.x it means "fire once and auto-delete". If Python code later calls.delete()on that wrapper, it will double-free the timer struct and corrupttimer_ll, typically producing a SIGSEGV insidelv_timer_delete->lv_ll_removewith a dangling next-node pointer. Leave the default alone for an infinite periodic timer, or make the intent explicit withset_repeat_count(-1). Useset_repeat_count(1)only for genuine one-shot timers.
MicroPythonOS tips:
self.appFullNameis automatically set by the ActivityNavigator when launching an Activity. Use it instead of hard-coding the app's package name (e.g. forSharedPreferences(self.appFullName)).- Use
mpos.ui.SettingsActivityto edit multiple related settings andmpos.ui.SettingActivityto edit a single setting. Prefer these shared activities over building custom dialogs or activities. - When investigating a UI bug, trust visual reality over the code's intent. Especially with LVGL flex layouts, aligning a column to the bottom of its parent does not reverse child paint order: the first child created still renders at the visual top. Inspect the actual widget coordinates — for example with
mpos.get_widget_tree()— instead of reasoning only from the internal data model. And when a user keeps insisting after an initial disagreement, they are likely seeing something you are missing. - Debug-log trap with
__debug__guards:if __debug__: logger.debug("x=%s", x)only compiles away under-O3(__debug__ = False). When__debug__isTrue(default), the statement executes normally — variables referenced in the format string must be defined before the log line, not after. Order matters: assign first, log second.
MicroPython compatibility:
- Soft reset is broken on lvgl_micropython and therefore also on MicroPythonOS. Use
machine.reset()to do a hard reset.
MicroPython compatibility:
- Some builds ship a minimal
randommodule withoutrandom.Randomorrandom.shuffle. For shuffling, implement Fisher-Yates manually withrandom.randint. - For deterministic jitter in apps, prefer a tiny local LCG (linear congruential generator) instead of
random.Random. - MicroPython's
logging.Logger.log()(and by extensionerror(),warning(), etc.) formats messages viamsg % args. Passing a variable to a format string without a%splaceholder raisesTypeError. Always include a%sin the format string:logger.error("msg: %s", e). - MicroPython's
unittestmodule lacksassertGreater,assertGreaterEqual,assertLess,assertLessEqual. UseassertTrue(a > b, msg)instead ofassertGreater(a, b, msg). - MicroPython's
_threadmodule is cooperative: a tight loop in a secondary thread can prevent the main thread (and therefore LVGL'slv_timer_handler) from running. Long-running secondary-thread loops should yield occasionally, e.g.time.sleep_ms(1). Do not usetime.sleep_us()for this — it busy-waits and does not yield. - MicroPython does NOT support
bytearray * int(e.g.b = bytearray(4); b * 3raisesTypeError: unsupported types for __mul__). To repeat abytearray, create a new one and extend it in a loop:out = bytearray(); [out.extend(buf) for _ in range(n)]. (bytes * intworks in CPython but is also not guaranteed on MicroPython; prefer an explicit loop.)
MPOS Controller (scripts/mpos_controller.py):
MPOSControllerdrives MicroPythonOS from CPython via PTY/aioREPL or serial/UART.MPOSController()does NOT auto-start a subprocess. Callmpos.start()to launchrun_desktop.sh, then wait at least~8sfor boot before callingstartapp()or any other method. Without.start()the internalreplisNoneand you getAttributeError: 'NoneType' object has no attribute 'exec'.- Two backends:
MPOSController()for local desktop process,MPOSController(backend="serial", port="/dev/ttyACM0")for physical device. - Use
mpos.exec("code"),mpos.eval("expr"),mpos.screenshot(),mpos.save_screenshot(path),mpos.screenshot_pixels(),mpos.screenshot_image(),mpos.press(x,y),mpos.press_key("text"),mpos.click_button("text"),mpos.find_widget(type=..., text=...),mpos.press_widget(type=..., text=...),mpos.wait_for_text("text"),mpos.expect_text("text"),mpos.get_visible_text(),mpos.get_widget_tree(),mpos.read_file(path),mpos.write_file(path, data). exec()andexec_multiline()both use paste mode (Ctrl-E / Ctrl-D) internally — multi-line code, quotes, and special chars need no escaping. They're equivalent; use whichever is convenient.get_visible_text()usesexec_multiline()iterating individualrepr()prints — critical for serial whereprint(repr(big_list))corrupts for large lists with escape sequences.exec()auto-drains input buffer before sending then enters paste mode (Ctrl-E).SerialBackend.wait_for_boot()uses Ctrl-C to break into aioREPL (device may be running apps).- The CLI supports
--serial-port <port>and--baudrate <rate>for serial connections. To pipe code without quoting:cat <<'EOF' | python3 scripts/mpos_controller.py --serial-port /dev/ttyACM0 exec - When no args are given and stdin is not a TTY,
execandevalread from stdin automatically — enabling heredoc/pipe usage. - Rotation handling: SerialBackend caches
_rotationfrom the display on connect. If rotation is 270° (value 3, common for landscape badges),press(x, y)auto-transforms coordinates:simulate_click(height - 1 - y, x)so caller always uses LVGL logical coordinates. mpos.get_widget_tree()dumps the full LVGL widget tree for bothlv.screen_active()andlv.layer_top(). Returns JSON with type, text, coordinates, flags (clickable, hidden, scrollable, floating, event_bubble, etc.), states (checked, disabled, focused, pressed, etc.), scroll position, opacity, and widget-specific fields (slider value, dropdown options, textarea state, etc.). Usesmpos.ui.testing.get_screen_widget_tree()directly — no file I/O on desktop; on serial the JSON is written to a temp file then read viampremote cpto avoid serial corruption of large outputs.- IMPORTANT:
get_widget_tree()andget_visible_text()include ALL children of scrollable parents, including off-screen items. y1/y2 coordinates are in content space, not screen space. To know what's actually visible, combine a screenshot (mpos.screenshot()) with the ppq-vision skill. _read_remote_file/write_remote_file: ProcessBackend uses base64 (works over PTY), SerialBackend usesmpremote cp(reliable over USB).mpos.screenshot()captures viacapture_screenshot()on device, then reads raw file and converts to BMP via_build_bmp(). Over serial (/dev/ttyACM0) this takes ~40s total (~6s connect, ~34s transfer).- The notification bar (top status bar) is NOT always present. It's controlled by the
bar_openglobal ininternal_filesystem/lib/mpos/ui/topmenu.py. Check it withmpos.eval("mpos.ui.topmenu.bar_open")(the module is already imported bymain.py). When open, its height isAppearanceManager.NOTIFICATION_BAR_HEIGHT(24px). mpos.startapp(appname)launches an installed app; passintent={"data": filename, "action": ..., "extras": {...}}to start an app with anmpos.Intent(e.g. opening a specific file in ImageView).mpos.run_app_with_file(appname, filename)boots and launches appname pre-populated with a file intent — convenient for testing "Open With" flows.mpos.click_button("text")finds a clickable widget whose own text or child-label text matches and clicks its center, avoiding manual coordinate math. For buttons, use this instead offind_widgetbecause button text lives on a child label.mpos.wait_for_text("text", timeout=10)polls the screen until the text appears (ordisappear=True);mpos.expect_text("text")raises if it doesn't appear.mpos.screenshot_image()returns a PILImage(requirespillow), andmpos.screenshot_pixels()returns(width, height, rgb888_bytes)for direct pixel checks.- The CLI also supports
startapp <appname>(launches an app) andcheckfreespace(reports free disk space and whether a screenshot fits). - All tests pass covering exec, eval, screenshot, input simulation, screen introspection, file I/O, and physical device control.
- Host-side controller tests in
tests/cpython_mpos_controller.pyrun viapython3 tests/cpython_mpos_controller.py(desktop) orpython3 tests/cpython_mpos_controller.py --serial /dev/ttyACM0(device); they are NOT run byunittest.sh(which targets MicroPython-side tests).
Write all scripts to tmp/ in the project root:
python3 tmp/my_debug_script.py
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')
pixels = mpos.screenshot_pixels() # (width, height, rgb888_bytes)- PIL + numpy is the most reliable technique. Load the BMP, convert to numpy array, then check specific pixel coordinates for exact RGB values.
- Widget tree (
mpos.get_widget_tree()): gives layout, types, text, coordinates, states, flags for every widget. Use this FIRST to understand screen structure. - Visible text (
mpos.get_visible_text()): extracts all text from all labels on screen. Use for text-content verification. - PPQ vision skill (
ppq-vision): use for reading text content from screenshots or understanding visual layout when coordinates alone are insufficient. - ASCII art conversion: NOT reliable for precise color analysis. Only use for quick visual structure checks when other methods aren't available.
- When a code path should produce output but doesn't, add a temporary
print()diagnostic to see if the code even executes — genericexcept Exception: passblocks often hide bugs. - When a function should return a specific type (e.g.,
lv.image_dsc_t), check what it actually returns withprint(type(result)). - For color/image issues, inspect raw pixel buffer data (bytearray at known offsets) rather than relying on visual appearance.
- Temporarily add detailed diagnostics (buffer dumps, type checks, hex printing) to
tmp/files on-device, then retrieve viampos.read_file().
lv_color_tin this MicroPython LVGL binding has ONLY.red,.green,.blueattributes — there is NO.fullattribute.lv.snapshot_take()on a hiddenlv.obj()still captures non-transparent pixel data because inherited theme styles (borders, shadows, background from parent theme) leak through the hidden object into the snapshot. For truly empty images, construct anlv.image_dsc_t()manually with a zeroedbytearray().- To snapshot a scaled
lv.image, wrap it in a container (lv.obj()).lv.snapshot_take_to_buf()on the image alone won't see that the image size changed due toset_scale()and only captures a middle crop. Create a hidden container sized to the target dimensions, place the image inside it withcenter()andset_size(target_w,target_h), then snapshot the container. Don't forgetset_scrollbar_mode(lv.SCROLLBAR_MODE.OFF)and opaque style resets to avoid theme leakage. Seefont_manager.py:_render_scaled_image_src()for the canonical pattern. except Exception: passis especially dangerous in image rendering paths — it silently falls back to unscaled/unprocessed source data, making it appear that transformations run when they don't.- To create a manual empty image descriptor:
buf = bytearray(4) dsc = lv.image_dsc_t() dsc.data = buf dsc.header.w = 1 dsc.header.h = target_height dsc.header.cf = lv.COLOR_FORMAT.ARGB8888
- Use
lv.msgbox()with no parent for a true modal dialog. Passing a parent such aslv.layer_top()creates a non-modal msgbox and skips the dimming backdrop that blocks input to the rest of the screen. - Add custom input widgets (e.g.
lv.textarea) to the content container returned bymbox.get_content(). - Default
mpos.screenshot()andcapture_screenshot()do not includelv.layer_top()overlays. Passall_layers=Trueto capture popups/notifications in screenshots — it's slower but necessary for verifying dialog appearance. - If screenshots or MPOSController behave oddly after hard-killing debug scripts (e.g.
timeout -s 9 ...), check for stalelvgl_micropy_unix/run_desktop.shprocesses withps aux | grep lvgl_micropy_unix. Clean them up withkillall -9 lvgl_micropy_unix run_desktop.sh.
- The app internal_filesystem/apps/com_micropythonos_nostr and the app internal_filessytem/apps/com.lightningpiggy.displaywallet (symlinked) use the same copy of nostr_service.py so when you update it, make sure to also update the other copy of that file and make sure it doesn't break the other app either.
- Some apps in internal_filesystem/apps/ are symlinks to outside repo's like all the com.quasikili.* apps and com.lightningpiggy.displaywallet so be sure to follow symlinks when grepping and finding etc
Public documentation source lives in the sibling ../docs/ directory (/home/user/projects/MicroPythonOS/claude/docs). It is a separate MkDocs site from the MicroPythonOS code repository.
When editing docs:
- Run
python3 -m mkdocs build(or./build.sh) from the../docs/directory to check for errors. - Some Markdown files are intentionally included into other pages instead of being listed directly in
mkdocs.ymlnav. Examples from../docs/docs/os-development/:compiling.mdis included bylinux.mdandmacos.md.running-on-desktop.mdis included bylinux.mdandmacos.md.
- This is why
mkdocs buildwarns "The following pages exist ... but are not included in the nav" for those files. Do not add them tonavunless explicitly requested.