Skip to content

Latest commit

 

History

History
857 lines (673 loc) · 29.1 KB

File metadata and controls

857 lines (673 loc) · 29.1 KB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Project Overview

MicroPythonOS is an embedded operating system that runs on ESP32 hardware (particularly the Waveshare ESP32-S3-Touch-LCD-2) and desktop Linux/macOS. It provides an LVGL-based UI framework with an Android-inspired app architecture featuring Activities, Intents, and a PackageManager.

The OS supports:

  • Touch and non-touch input devices (keyboard/joystick navigation)
  • Camera with QR decoding (using quirc)
  • WiFi connectivity
  • Over-the-air (OTA) firmware updates
  • App installation via MPK packages
  • Bitcoin Lightning and Nostr protocols

Repository Structure

Core Directories

  • internal_filesystem/: The runtime filesystem containing the OS and apps

    • boot.py: Hardware initialization for ESP32-S3-Touch-LCD-2
    • boot_unix.py: Desktop-specific boot initialization
    • main.py: UI initialization, theme setup, and launcher start
    • lib/mpos/: Core OS library (apps, config, UI, content management)
    • apps/: User-installed apps (symlinks to external app repos)
    • builtin/: System apps frozen into the firmware (launcher, appstore, settings, etc.)
    • data/: Static data files
    • sdcard/: SD card mount point
  • lvgl_micropython/: Submodule containing LVGL bindings for MicroPython

  • micropython-camera-API/: Submodule for camera support

  • micropython-nostr/: Submodule for Nostr protocol

  • c_mpos/: C extension modules (includes quirc for QR decoding)

  • secp256k1-embedded-ecdh/: Submodule for cryptographic operations

  • manifests/: Build manifests defining what gets frozen into firmware

  • freezeFS/: Files to be frozen into the built-in filesystem

  • scripts/: Build and deployment scripts

  • tests/: Test suite (both unit tests and manual tests)

Key Architecture Components

App System: Similar to Android

  • Apps are identified by reverse-domain names (e.g., com.micropythonos.camera)
  • Each app has a META-INF/MANIFEST.JSON with metadata and activity definitions
  • Activities extend mpos.app.activity.Activity class (import: from mpos.app.activity import Activity)
  • Apps implement onCreate() to set up their UI and onDestroy() for cleanup
  • Activity lifecycle: onCreate()onStart()onResume()onPause()onStop()onDestroy()
  • Apps are packaged as .mpk files (zip archives)
  • Built-in system apps (frozen into firmware): launcher, appstore, settings, wifi, osupdate, about

UI Framework: Built on LVGL 9.3.0

  • mpos.ui.topmenu: Notification bar and drawer (top menu)
  • mpos.ui.display: Root screen initialization
  • Gesture support: left-edge swipe for back, top-edge swipe for menu
  • Theme system with configurable colors and light/dark modes
  • Focus groups for keyboard/joystick navigation

Content Management:

  • PackageManager: Install/uninstall/query apps
  • Intent: Launch activities with action/category filters
  • SharedPreferences: Per-app key-value storage (similar to Android)

Hardware Abstraction:

  • boot.py configures SPI, I2C, display (ST7789), touchscreen (CST816S), and battery ADC
  • Platform detection via sys.platform ("esp32" vs others)
  • Different boot files per hardware variant (boot_fri3d-2024.py, etc.)

Build System

Building Firmware

The main build script is scripts/build_mpos.sh:

# Development build (no frozen filesystem, requires ./scripts/install.sh after flashing)
./scripts/build_mpos.sh unix dev

# Production build (with frozen filesystem)
./scripts/build_mpos.sh unix prod

# ESP32 builds (specify hardware variant)
./scripts/build_mpos.sh esp32 dev waveshare-esp32-s3-touch-lcd-2
./scripts/build_mpos.sh esp32 prod fri3d-2024

Build types:

  • dev: No preinstalled files or builtin filesystem. Boots to black screen until you run ./scripts/install.sh
  • prod: Files from manifest*.py are frozen into firmware. Run ./scripts/freezefs_mount_builtin.sh before building

Targets:

  • esp32: ESP32-S3 hardware (requires subtarget: waveshare-esp32-s3-touch-lcd-2 or fri3d-2024)
  • unix: Linux desktop
  • macOS: macOS desktop

The build system uses lvgl_micropython/make.py which wraps MicroPython's build system. It:

  1. Fetches SDL tags for desktop builds
  2. Patches manifests to include camera and asyncio support
  3. Creates symlinks for C modules (secp256k1, c_mpos)
  4. Runs the lvgl_micropython build with appropriate flags

ESP32 build configuration:

  • Board: ESP32_GENERIC_S3 with SPIRAM_OCT variant
  • Display driver: st7789
  • Input device: cst816s
  • OTA enabled with 4MB partition size (16MB total flash)
  • Dual-core threading enabled (no GIL)
  • User C modules: camera, secp256k1, c_mpos/quirc

Desktop build configuration:

  • Display: sdl_display
  • Input: sdl_pointer, sdl_keyboard
  • Compiler flags: -g -O0 -ggdb -ljpeg (debug symbols enabled)
  • STRIP is disabled to keep debug symbols

Building and Bundling Apps

Apps can be bundled into .mpk files:

./scripts/bundle_apps.sh

Running on Desktop

# Run normally (starts launcher)
./scripts/run_desktop.sh

# Run a specific Python script directly
./scripts/run_desktop.sh path/to/script.py

# Run a specific app by name
./scripts/run_desktop.sh com.micropythonos.camera

Important environment variables:

  • HEAPSIZE: Set heap size (default 8M, matches ESP32-S3 PSRAM). Increase for memory-intensive apps
  • SDL_WINDOW_FULLSCREEN: Set to true for fullscreen mode

The script automatically selects the correct binary (lvgl_micropy_unix or lvgl_micropy_macOS) and runs from the internal_filesystem/ directory.

Deploying to Hardware

Flashing Firmware

# Flash firmware over USB
./scripts/flash_over_usb.sh

Installing Files to Device

# Install all files to device (boot.py, main.py, lib/, apps/, builtin/)
./scripts/install.sh waveshare-esp32-s3-touch-lcd-2

# Install a single app to device
./scripts/install.sh waveshare-esp32-s3-touch-lcd-2 camera

Uses mpremote from MicroPython tools to copy files over serial connection.

Testing

Running Tests

Tests are in the tests/ directory. There are two types: unit tests and manual tests.

Unit tests (automated, run on desktop or device):

# Run all unit tests on desktop
./tests/unittest.sh

# Run a specific test file on desktop
./tests/unittest.sh tests/test_shared_preferences.py
./tests/unittest.sh tests/test_intent.py
./tests/unittest.sh tests/test_package_manager.py
./tests/unittest.sh tests/test_graphical_start_app.py

# Run a specific test on connected device (via mpremote)
./tests/unittest.sh tests/test_shared_preferences.py --ondevice

# Run all tests on connected device
./tests/unittest.sh --ondevice

The unittest.sh script:

  • Automatically detects the platform (Linux/macOS) and uses the correct binary
  • Sets up the proper paths and heapsize
  • Can run tests on device using mpremote with the --ondevice flag
  • Runs all test_*.py files when no argument is provided
  • On device, assumes the OS is already running (boot.py and main.py already executed), so tests run against the live system
  • Test infrastructure (graphical_test_helper.py) is automatically installed by scripts/install.sh

Available unit test modules:

  • test_shared_preferences.py: Tests for mpos.config.SharedPreferences (configuration storage)
  • test_intent.py: Tests for mpos.content.intent.Intent (intent creation, extras, flags)
  • test_package_manager.py: Tests for PackageManager (version comparison, app discovery)
  • test_graphical_start_app.py: Tests for app launching (graphical test with proper boot/main initialization)
  • test_graphical_about_app.py: Graphical test that verifies About app UI and captures screenshots

Graphical tests (UI verification with screenshots):

# Run graphical tests on desktop
./tests/unittest.sh tests/test_graphical_about_app.py

# Run graphical tests on device
./tests/unittest.sh tests/test_graphical_about_app.py --ondevice

# Convert screenshots from raw RGB565 to PNG
cd tests/screenshots
./convert_to_png.sh  # Converts all .raw files in the directory

Graphical tests use tests/graphical_test_helper.py which provides utilities like:

  • wait_for_render(): Wait for LVGL to process UI events
  • capture_screenshot(): Take screenshot as RGB565 raw data
  • find_label_with_text(): Find labels containing specific text
  • verify_text_present(): Verify expected text is on screen

Screenshots are saved as .raw files (RGB565 format) and can be converted to PNG using tests/screenshots/convert_to_png.sh

Manual tests (interactive, for hardware-specific features):

  • manual_test_camera.py: Camera and QR scanning
  • manual_test_nostr_asyncio.py: Nostr protocol
  • manual_test_nwcwallet*.py: Lightning wallet connectivity (Alby, Cashu)
  • manual_test_lnbitswallet.py: LNbits wallet integration
  • test_websocket.py: WebSocket functionality
  • test_multi_connect.py: Multiple concurrent connections

Run manual tests with:

./scripts/run_desktop.sh tests/manual_test_camera.py

Writing New Tests

Unit test guidelines:

  • Use Python's unittest module (compatible with MicroPython)
  • Place tests in tests/ directory with test_*.py naming
  • Use setUp() and tearDown() for test fixtures
  • Clean up any created files/directories in tearDown()
  • Tests should be runnable on desktop (unix build) without hardware dependencies
  • Use descriptive test names: test_<what_is_being_tested>
  • Group related tests in test classes
  • IMPORTANT: Do NOT end test files with if __name__ == '__main__': unittest.main() - the ./tests/unittest.sh script handles running tests and capturing exit codes. Including this will interfere with test execution.

Example test structure:

import unittest
from mpos.some_module import SomeClass

class TestSomeClass(unittest.TestCase):
    def setUp(self):
        # Initialize test fixtures
        pass

    def tearDown(self):
        # Clean up after test
        pass

    def test_some_functionality(self):
        # Arrange
        obj = SomeClass()
        # Act
        result = obj.some_method()
        # Assert
        self.assertEqual(result, expected_value)

Development Workflow

Creating a New App

  1. Create app directory: internal_filesystem/apps/com.example.myapp/
  2. Create META-INF/MANIFEST.JSON with app metadata and activities
  3. Create assets/ directory for Python code
  4. Create main activity file extending Activity class
  5. Implement onCreate() method to build UI
  6. Optional: Create res/ directory for resources (icons, images)

Minimal app structure:

com.example.myapp/
├── META-INF/
│   └── MANIFEST.JSON
├── assets/
│   └── main_activity.py
└── res/
    └── mipmap-mdpi/
        └── icon_64x64.png

Minimal Activity code:

from mpos.app.activity import Activity
import lvgl as lv

class MainActivity(Activity):
    def onCreate(self):
        screen = lv.obj()
        label = lv.label(screen)
        label.set_text('Hello World!')
        label.center()
        self.setContentView(screen)

See internal_filesystem/apps/com.micropythonos.helloworld/ for a minimal example and built-in apps in internal_filesystem/builtin/apps/ for more complex examples.

Testing App Changes

For rapid iteration on desktop:

# Build desktop version (only needed once)
./scripts/build_mpos.sh unix dev

# Install filesystem to device (run after code changes)
./scripts/install.sh waveshare-esp32-s3-touch-lcd-2

# Or run directly on desktop
./scripts/run_desktop.sh com.example.myapp

Debugging

Desktop builds include debug symbols by default. Use GDB:

gdb --args ./lvgl_micropython/build/lvgl_micropy_unix -X heapsize=8M -v -i -c "$(cat boot_unix.py main.py)"

For ESP32 debugging, enable core dumps:

./scripts/core_dump_activate.sh

Important Constraints

Memory Management

ESP32-S3 has 8MB PSRAM. Memory-intensive operations:

  • Camera images consume ~2.5MB per frame
  • LVGL image cache must be managed with lv.image.cache_drop(None)
  • Large UI components should be created/destroyed rather than hidden
  • Use gc.collect() strategically after deallocating large objects

Threading

  • Main UI/LVGL operations must run on main thread
  • Background tasks use _thread.start_new_thread()
  • Stack size: 16KB for ESP32, 24KB for desktop (see mpos.apps.good_stack_size())
  • Use mpos.ui.async_call() to safely invoke UI operations from background threads

Async Operations

  • OS uses uasyncio for networking (WebSockets, HTTP, Nostr)
  • WebSocket library is custom websocket.py using uasyncio
  • HTTP uses aiohttp package (in lib/aiohttp/)
  • Async tasks are throttled per frame to prevent memory overflow

File Paths

  • Use M:/path/to/file prefix for LVGL file operations (registered in main.py)
  • Absolute paths for Python imports
  • Apps run with their directory added to sys.path

Build Dependencies

The build requires all git submodules checked out recursively:

git submodule update --init --recursive

Desktop dependencies: See .github/workflows/build.yml for full list including:

  • SDL2 development libraries
  • Mesa/EGL libraries
  • libjpeg
  • Python 3.8+
  • cmake, ninja-build

Manifest System

Manifests define what gets frozen into firmware:

  • manifests/manifest.py: ESP32 production builds
  • manifests/manifest_fri3d-2024.py: Fri3d Camp 2024 Badge variant
  • manifests/manifest_unix.py: Desktop builds

Manifests use freeze() directives to include files in the frozen filesystem. Frozen files are baked into the firmware and cannot be modified at runtime.

Version Management

Versions are tracked in:

  • CHANGELOG.md: User-facing changelog with release history
  • App versions in META-INF/MANIFEST.JSON files
  • OS update system checks hardware_id from mpos.info.get_hardware_id()

Current stable version: 0.3.3 (as of latest CHANGELOG entry)

Critical Code Locations

  • App lifecycle: internal_filesystem/lib/mpos/apps.py:execute_script()
  • Activity base class: internal_filesystem/lib/mpos/app/activity.py
  • Package management: internal_filesystem/lib/mpos/content/package_manager.py
  • Intent system: internal_filesystem/lib/mpos/content/intent.py
  • UI initialization: internal_filesystem/main.py
  • Hardware init: internal_filesystem/boot.py
  • Config/preferences: internal_filesystem/lib/mpos/config.py
  • Top menu/drawer: internal_filesystem/lib/mpos/ui/topmenu.py
  • Activity navigation: internal_filesystem/lib/mpos/activity_navigator.py

Common Utilities and Helpers

SharedPreferences: Persistent key-value storage per app

from mpos.config import SharedPreferences

# Load preferences
prefs = SharedPreferences("com.example.myapp")
value = prefs.get_string("key", "default_value")
number = prefs.get_int("count", 0)
data = prefs.get_dict("data", {})

# Save preferences
editor = prefs.edit()
editor.put_string("key", "value")
editor.put_int("count", 42)
editor.put_dict("data", {"key": "value"})
editor.commit()

Intent system: Launch activities and pass data

from mpos.content.intent import Intent

# Launch activity by name
intent = Intent()
intent.setClassName("com.micropythonos.camera", "Camera")
self.startActivity(intent)

# Launch with extras
intent.putExtra("key", "value")
self.startActivityForResult(intent, self.handle_result)

def handle_result(self, result):
    if result["result_code"] == Activity.RESULT_OK:
        data = result["data"]

UI utilities:

  • mpos.ui.async_call(func, *args, **kwargs): Safely call UI operations from background threads
  • mpos.ui.back_screen(): Navigate back to previous screen
  • mpos.ui.focus_direction: Keyboard/joystick navigation helpers
  • mpos.ui.anim: Animation utilities

Keyboard and Focus Navigation

MicroPythonOS supports keyboard/joystick navigation through LVGL's focus group system. This allows users to navigate apps using arrow keys and select items with Enter.

Basic focus handling pattern:

def onCreate(self):
    # Get the default focus group
    focusgroup = lv.group_get_default()
    if not focusgroup:
        print("WARNING: could not get default focusgroup")

    # Create a clickable object
    button = lv.button(screen)

    # Add focus/defocus event handlers
    button.add_event_cb(lambda e, b=button: self.focus_handler(b), lv.EVENT.FOCUSED, None)
    button.add_event_cb(lambda e, b=button: self.defocus_handler(b), lv.EVENT.DEFOCUSED, None)

    # Add to focus group (enables keyboard navigation)
    if focusgroup:
        focusgroup.add_obj(button)

def focus_handler(self, obj):
    """Called when object receives focus"""
    obj.set_style_border_color(lv.theme_get_color_primary(None), lv.PART.MAIN)
    obj.set_style_border_width(2, lv.PART.MAIN)
    obj.scroll_to_view(True)  # Scroll into view if needed

def defocus_handler(self, obj):
    """Called when object loses focus"""
    obj.set_style_border_width(0, lv.PART.MAIN)

Key principles:

  • Get the default focus group with lv.group_get_default()
  • Add objects to the focus group to make them keyboard-navigable
  • Use lv.EVENT.FOCUSED to highlight focused elements (usually with a border)
  • Use lv.EVENT.DEFOCUSED to remove highlighting
  • Use theme color for consistency: lv.theme_get_color_primary(None)
  • Call scroll_to_view(True) to auto-scroll focused items into view
  • The focus group automatically handles arrow key navigation between objects

Example apps with focus handling:

  • Launcher (builtin/apps/com.micropythonos.launcher/assets/launcher.py): App icons are focusable
  • Settings (builtin/apps/com.micropythonos.settings/assets/settings_app.py): Settings items are focusable
  • Connect 4 (apps/com.micropythonos.connect4/assets/connect4.py): Game columns are focusable

Other utilities:

  • mpos.apps.good_stack_size(): Returns appropriate thread stack size for platform (16KB ESP32, 24KB desktop)
  • mpos.wifi: WiFi management utilities
  • mpos.sdcard.SDCardManager: SD card mounting and management
  • mpos.clipboard: System clipboard access
  • mpos.battery_voltage: Battery level reading (ESP32 only)

Animations and Game Loops

MicroPythonOS supports frame-based animations and game loops using the TaskHandler event system. This pattern is used for games, particle effects, and smooth animations.

The update_frame() Pattern

The core pattern involves:

  1. Registering a callback that fires every frame
  2. Calculating delta time for framerate-independent physics
  3. Updating object positions and properties
  4. Rendering to LVGL objects
  5. Unregistering when animation completes

Basic structure:

from mpos.apps import Activity
import mpos.ui
import time
import lvgl as lv

class MyAnimatedApp(Activity):
    last_time = 0

    def onCreate(self):
        # Set up your UI
        self.screen = lv.obj()
        # ... create objects ...
        self.setContentView(self.screen)

    def onResume(self, screen):
        # Register the frame callback
        self.last_time = time.ticks_ms()
        mpos.ui.task_handler.add_event_cb(self.update_frame, 1)

    def onPause(self, screen):
        # Unregister when app goes to background
        mpos.ui.task_handler.remove_event_cb(self.update_frame)

    def update_frame(self, a, b):
        # Calculate delta time for framerate independence
        current_time = time.ticks_ms()
        delta_ms = time.ticks_diff(current_time, self.last_time)
        delta_time = delta_ms / 1000.0  # Convert to seconds
        self.last_time = current_time

        # Update your animation/game logic here
        # Use delta_time to make physics framerate-independent

Framerate-Independent Physics

All movement and physics should be multiplied by delta_time to ensure consistent behavior regardless of framerate:

# Example from QuasiBird game
GRAVITY = 200  # pixels per second²
PIPE_SPEED = 100  # pixels per second

def update_frame(self, a, b):
    current_time = time.ticks_ms()
    delta_time = time.ticks_diff(current_time, self.last_time) / 1000.0
    self.last_time = current_time

    # Update velocity with gravity
    self.bird_velocity += self.GRAVITY * delta_time

    # Update position with velocity
    self.bird_y += self.bird_velocity * delta_time

    # Update bird sprite position
    self.bird_img.set_y(int(self.bird_y))

    # Move pipes
    for pipe in self.pipes:
        pipe.x -= self.PIPE_SPEED * delta_time

Key principles:

  • Constants define rates in "per second" units (pixels/second, degrees/second)
  • Multiply all rates by delta_time when applying them
  • This ensures objects move at the same speed regardless of framerate
  • Use time.ticks_ms() and time.ticks_diff() for timing (handles rollover correctly)

Object Pooling for Performance

Pre-create LVGL objects and reuse them instead of creating/destroying during animation:

# Example from LightningPiggy confetti animation
MAX_CONFETTI = 21
confetti_images = []
confetti_pieces = []
used_img_indices = set()

def onStart(self, screen):
    # Pre-create all image objects (hidden initially)
    for i in range(self.MAX_CONFETTI):
        img = lv.image(lv.layer_top())
        img.set_src(f"{self.ASSET_PATH}confetti{i % 5}.png")
        img.add_flag(lv.obj.FLAG.HIDDEN)
        self.confetti_images.append(img)

def _spawn_one(self):
    # Find a free image slot
    for idx, img in enumerate(self.confetti_images):
        if img.has_flag(lv.obj.FLAG.HIDDEN) and idx not in self.used_img_indices:
            break
    else:
        return  # No free slot

    # Create particle data (not LVGL object)
    piece = {
        'img_idx': idx,
        'x': random.uniform(0, self.SCREEN_WIDTH),
        'y': 0,
        'vx': random.uniform(-80, 80),
        'vy': random.uniform(-150, 0),
        'rotation': 0,
        'scale': 1.0,
        'age': 0.0
    }
    self.confetti_pieces.append(piece)
    self.used_img_indices.add(idx)

def update_frame(self, a, b):
    delta_time = time.ticks_diff(time.ticks_ms(), self.last_time) / 1000.0
    self.last_time = time.ticks_ms()

    new_pieces = []
    for piece in self.confetti_pieces:
        # Update physics
        piece['x'] += piece['vx'] * delta_time
        piece['y'] += piece['vy'] * delta_time
        piece['vy'] += self.GRAVITY * delta_time
        piece['rotation'] += piece['spin'] * delta_time
        piece['age'] += delta_time

        # Update LVGL object
        img = self.confetti_images[piece['img_idx']]
        img.remove_flag(lv.obj.FLAG.HIDDEN)
        img.set_pos(int(piece['x']), int(piece['y']))
        img.set_rotation(int(piece['rotation'] * 10))
        img.set_scale(int(256 * piece['scale']))

        # Check if particle should die
        if piece['y'] > self.SCREEN_HEIGHT or piece['age'] > piece['lifetime']:
            img.add_flag(lv.obj.FLAG.HIDDEN)
            self.used_img_indices.discard(piece['img_idx'])
        else:
            new_pieces.append(piece)

    self.confetti_pieces = new_pieces

Object pooling benefits:

  • Avoid memory allocation/deallocation during animation
  • Reuse LVGL image objects (expensive to create)
  • Hide/show objects instead of create/delete
  • Track which slots are in use with a set
  • Separate particle data (Python dict) from rendering (LVGL object)

Particle Systems and Effects

Staggered spawning (spawn particles over time instead of all at once):

def start_animation(self):
    self.spawn_timer = 0
    self.spawn_interval = 0.15  # seconds between spawns
    mpos.ui.task_handler.add_event_cb(self.update_frame, 1)

def update_frame(self, a, b):
    delta_time = time.ticks_diff(time.ticks_ms(), self.last_time) / 1000.0

    # Staggered spawning
    self.spawn_timer += delta_time
    if self.spawn_timer >= self.spawn_interval:
        self.spawn_timer = 0
        for _ in range(random.randint(1, 2)):
            if len(self.particles) < self.MAX_PARTICLES:
                self._spawn_one()

Particle lifecycle (age, scale, death):

piece = {
    'x': x, 'y': y,
    'vx': random.uniform(-80, 80),
    'vy': random.uniform(-150, 0),
    'spin': random.uniform(-500, 500),  # degrees/sec
    'age': 0.0,
    'lifetime': random.uniform(5.0, 10.0),
    'rotation': random.uniform(0, 360),
    'scale': 1.0
}

# In update_frame
piece['age'] += delta_time
piece['scale'] = max(0.3, 1.0 - (piece['age'] / piece['lifetime']) * 0.7)

# Death check
dead = (
    piece['x'] < -60 or piece['x'] > SCREEN_WIDTH + 60 or
    piece['y'] > SCREEN_HEIGHT + 60 or
    piece['age'] > piece['lifetime']
)

Game Loop Patterns

Scrolling backgrounds (parallax and tiling):

# Parallax clouds (multiple layers at different speeds)
CLOUD_SPEED = 30  # pixels/sec (slower than foreground)
cloud_positions = [50, 180, 320]

for i, cloud_img in enumerate(self.cloud_images):
    self.cloud_positions[i] -= self.CLOUD_SPEED * delta_time

    # Wrap around when off-screen
    if self.cloud_positions[i] < -60:
        self.cloud_positions[i] = SCREEN_WIDTH + 20

    cloud_img.set_x(int(self.cloud_positions[i]))

# Tiled ground (infinite scrolling)
self.ground_x -= self.PIPE_SPEED * delta_time
self.ground_img.set_offset_x(int(self.ground_x))  # LVGL handles wrapping

Object pooling for game entities:

# Pre-create pipe images
MAX_PIPES = 4
pipe_images = []

for i in range(MAX_PIPES):
    top_pipe = lv.image(screen)
    top_pipe.set_src("M:path/to/pipe.png")
    top_pipe.set_rotation(1800)  # 180 degrees * 10
    top_pipe.add_flag(lv.obj.FLAG.HIDDEN)

    bottom_pipe = lv.image(screen)
    bottom_pipe.set_src("M:path/to/pipe.png")
    bottom_pipe.add_flag(lv.obj.FLAG.HIDDEN)

    pipe_images.append({"top": top_pipe, "bottom": bottom_pipe, "in_use": False})

# Update visible pipes
def update_pipe_images(self):
    for pipe_img in self.pipe_images:
        pipe_img["in_use"] = False

    for i, pipe in enumerate(self.pipes):
        if i < self.MAX_PIPES:
            pipe_imgs = self.pipe_images[i]
            pipe_imgs["in_use"] = True
            pipe_imgs["top"].remove_flag(lv.obj.FLAG.HIDDEN)
            pipe_imgs["top"].set_pos(int(pipe.x), int(pipe.gap_y - 200))
            pipe_imgs["bottom"].remove_flag(lv.obj.FLAG.HIDDEN)
            pipe_imgs["bottom"].set_pos(int(pipe.x), int(pipe.gap_y + pipe.gap_size))

    # Hide unused slots
    for pipe_img in self.pipe_images:
        if not pipe_img["in_use"]:
            pipe_img["top"].add_flag(lv.obj.FLAG.HIDDEN)
            pipe_img["bottom"].add_flag(lv.obj.FLAG.HIDDEN)

Collision detection:

def check_collision(self):
    # Boundaries
    if self.bird_y <= 0 or self.bird_y >= SCREEN_HEIGHT - 40 - self.bird_size:
        return True

    # AABB (Axis-Aligned Bounding Box) collision
    bird_left = self.BIRD_X
    bird_right = self.BIRD_X + self.bird_size
    bird_top = self.bird_y
    bird_bottom = self.bird_y + self.bird_size

    for pipe in self.pipes:
        pipe_left = pipe.x
        pipe_right = pipe.x + pipe.width

        # Check horizontal overlap
        if bird_right > pipe_left and bird_left < pipe_right:
            # Check if bird is outside the gap
            if bird_top < pipe.gap_y or bird_bottom > pipe.gap_y + pipe.gap_size:
                return True

    return False

Animation Control and Cleanup

Starting/stopping animations:

def start_animation(self):
    self.animation_running = True
    self.last_time = time.ticks_ms()
    mpos.ui.task_handler.add_event_cb(self.update_frame, 1)

    # Optional: auto-stop after duration
    lv.timer_create(self.stop_animation, 15000, None).set_repeat_count(1)

def stop_animation(self, timer=None):
    self.animation_running = False
    # Don't remove callback yet - let it clean up and remove itself

def update_frame(self, a, b):
    # ... update logic ...

    # Stop when animation completes
    if not self.animation_running and len(self.particles) == 0:
        mpos.ui.task_handler.remove_event_cb(self.update_frame)
        print("Animation finished")

Lifecycle integration:

def onResume(self, screen):
    # Only start if needed (e.g., game in progress)
    if self.game_started and not self.game_over:
        self.last_time = time.ticks_ms()
        mpos.ui.task_handler.add_event_cb(self.update_frame, 1)

def onPause(self, screen):
    # Always stop when app goes to background
    mpos.ui.task_handler.remove_event_cb(self.update_frame)

Performance Tips

  1. Pre-create LVGL objects: Creating objects during animation causes lag
  2. Use object pools: Reuse objects instead of create/destroy
  3. Limit particle counts: Use MAX_PARTICLES constant (21 is a good default)
  4. Integer positions: Convert float positions to int before setting: img.set_pos(int(x), int(y))
  5. Delta time: Always use delta time for framerate independence
  6. Layer management: Use lv.layer_top() for overlays (confetti, popups)
  7. Rotation units: LVGL rotation is in 1/10 degrees: set_rotation(int(degrees * 10))
  8. Scale units: LVGL scale is 256 = 100%: set_scale(int(256 * scale_factor))
  9. Hide vs destroy: Hide objects with add_flag(lv.obj.FLAG.HIDDEN) instead of deleting
  10. Cleanup: Always unregister callbacks in onPause() to prevent memory leaks

Example Apps

  • QuasiBird (MPOS-QuasiBird/assets/quasibird.py): Full game with physics, scrolling, object pooling
  • LightningPiggy (LightningPiggyApp/.../displaywallet.py): Confetti particle system with staggered spawning