diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index e16b8a2e..0d546813 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -67,90 +67,46 @@ jobs: echo "OS_VERSION=$OS_VERSION" >> $GITHUB_OUTPUT echo "Extracted version: $OS_VERSION" - - name: Build LVGL MicroPython for unix dev + - name: Build LVGL MicroPython for unix run: | - ./scripts/build_mpos.sh unix dev + ./scripts/build_mpos.sh unix - - name: Run syntax tests on unix dev + - name: Run syntax tests on unix run: | ./tests/syntax.sh continue-on-error: true - - name: Run unit tests on unix dev + - name: Run unit tests on unix run: | ./tests/unittest.sh - mv lvgl_micropython/build/lvgl_micropy_unix lvgl_micropython/build/MicroPythonOS_amd64_linux_dev_${{ steps.version.outputs.OS_VERSION }}.elf + mv lvgl_micropython/build/lvgl_micropy_unix lvgl_micropython/build/MicroPythonOS_amd64_linux_${{ steps.version.outputs.OS_VERSION }}.elf continue-on-error: true - name: Upload built binary as artifact uses: actions/upload-artifact@v4 with: - name: MicroPythonOS_amd64_linux_dev_${{ steps.version.outputs.OS_VERSION }}.elf - path: lvgl_micropython/build/MicroPythonOS_amd64_linux_dev_${{ steps.version.outputs.OS_VERSION }}.elf + name: MicroPythonOS_amd64_linux_${{ steps.version.outputs.OS_VERSION }}.elf + path: lvgl_micropython/build/MicroPythonOS_amd64_linux_${{ steps.version.outputs.OS_VERSION }}.elf retention-days: 7 - - name: Build LVGL MicroPython esp32 prod fri3d-2024 + - name: Build LVGL MicroPython esp32 run: | - ./scripts/build_mpos.sh esp32 prod fri3d-2024 - mv lvgl_micropython/build/lvgl_micropy_ESP32_GENERIC_S3-SPIRAM_OCT-16.bin lvgl_micropython/build/MicroPythonOS_fri3d-2024_prod_${{ steps.version.outputs.OS_VERSION }}.bin - mv lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC_S3-SPIRAM_OCT/micropython.bin lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC_S3-SPIRAM_OCT/MicroPythonOS_fri3d-2024_prod_${{ steps.version.outputs.OS_VERSION }}.ota + ./scripts/build_mpos.sh esp32 + mv lvgl_micropython/build/lvgl_micropy_ESP32_GENERIC_S3-SPIRAM_OCT-16.bin lvgl_micropython/build/MicroPythonOS_esp32_${{ steps.version.outputs.OS_VERSION }}.bin + mv lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC_S3-SPIRAM_OCT/micropython.bin lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC_S3-SPIRAM_OCT/MicroPythonOS_esp32_${{ steps.version.outputs.OS_VERSION }}.ota - name: Upload built binary as artifact uses: actions/upload-artifact@v4 with: - name: MicroPythonOS_fri3d-2024_prod_${{ steps.version.outputs.OS_VERSION }}.bin - path: lvgl_micropython/build/MicroPythonOS_fri3d-2024_prod_${{ steps.version.outputs.OS_VERSION }}.bin + name: MicroPythonOS_esp32_${{ steps.version.outputs.OS_VERSION }}.bin + path: lvgl_micropython/build/MicroPythonOS_esp32_${{ steps.version.outputs.OS_VERSION }}.bin retention-days: 7 - name: Upload built binary as artifact uses: actions/upload-artifact@v4 with: - name: MicroPythonOS_fri3d-2024_prod_${{ steps.version.outputs.OS_VERSION }}.ota - path: lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC_S3-SPIRAM_OCT/MicroPythonOS_fri3d-2024_prod_${{ steps.version.outputs.OS_VERSION }}.ota - retention-days: 7 - - - name: Build LVGL MicroPython esp32 dev fri3d-2024 - run: | - ./scripts/build_mpos.sh esp32 dev fri3d-2024 - mv lvgl_micropython/build/lvgl_micropy_ESP32_GENERIC_S3-SPIRAM_OCT-16.bin lvgl_micropython/build/MicroPythonOS_fri3d-2024_dev_${{ steps.version.outputs.OS_VERSION }}.bin - - - name: Upload built binary as artifact - uses: actions/upload-artifact@v4 - with: - name: MicroPythonOS_fri3d-2024_dev_${{ steps.version.outputs.OS_VERSION }}.bin - path: lvgl_micropython/build/MicroPythonOS_fri3d-2024_dev_${{ steps.version.outputs.OS_VERSION }}.bin - retention-days: 7 - - - name: Build LVGL MicroPython esp32 prod waveshare-esp32-s3-touch-lcd-2 - run: | - ./scripts/build_mpos.sh esp32 prod waveshare-esp32-s3-touch-lcd-2 - mv lvgl_micropython/build/lvgl_micropy_ESP32_GENERIC_S3-SPIRAM_OCT-16.bin lvgl_micropython/build/MicroPythonOS_waveshare-esp32-s3-touch-lcd-2_prod_${{ steps.version.outputs.OS_VERSION }}.bin - mv lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC_S3-SPIRAM_OCT/micropython.bin lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC_S3-SPIRAM_OCT/MicroPythonOS_waveshare-esp32-s3-touch-lcd-2_prod_${{ steps.version.outputs.OS_VERSION }}.ota - - - name: Upload built binary as artifact - uses: actions/upload-artifact@v4 - with: - name: MicroPythonOS_waveshare-esp32-s3-touch-lcd-2_prod_${{ steps.version.outputs.OS_VERSION }}.bin - path: lvgl_micropython/build/MicroPythonOS_waveshare-esp32-s3-touch-lcd-2_prod_${{ steps.version.outputs.OS_VERSION }}.bin - retention-days: 7 - - - name: Upload built binary as artifact - uses: actions/upload-artifact@v4 - with: - name: MicroPythonOS_waveshare-esp32-s3-touch-lcd-2_prod_${{ steps.version.outputs.OS_VERSION }}.ota - path: lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC_S3-SPIRAM_OCT/MicroPythonOS_waveshare-esp32-s3-touch-lcd-2_prod_${{ steps.version.outputs.OS_VERSION }}.ota - retention-days: 7 - - - name: Build LVGL MicroPython esp32 dev waveshare-esp32-s3-touch-lcd-2 - run: | - ./scripts/build_mpos.sh esp32 dev waveshare-esp32-s3-touch-lcd-2 - mv lvgl_micropython/build/lvgl_micropy_ESP32_GENERIC_S3-SPIRAM_OCT-16.bin lvgl_micropython/build/MicroPythonOS_waveshare-esp32-s3-touch-lcd-2_dev_${{ steps.version.outputs.OS_VERSION }}.bin - - - name: Upload built binary as artifact - uses: actions/upload-artifact@v4 - with: - name: MicroPythonOS_waveshare-esp32-s3-touch-lcd-2_dev_${{ steps.version.outputs.OS_VERSION }}.bin - path: lvgl_micropython/build/MicroPythonOS_waveshare-esp32-s3-touch-lcd-2_dev_${{ steps.version.outputs.OS_VERSION }}.bin + name: MicroPythonOS_esp32_${{ steps.version.outputs.OS_VERSION }}.ota + path: lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC_S3-SPIRAM_OCT/MicroPythonOS_esp32_${{ steps.version.outputs.OS_VERSION }}.ota retention-days: 7 diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index 51eacb0c..7e53cab1 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -31,14 +31,14 @@ jobs: - name: Build LVGL MicroPython for macOS dev run: | - ./scripts/build_mpos.sh macOS dev + ./scripts/build_mpos.sh macOS - - name: Run syntax tests on macOS dev + - name: Run syntax tests on macOS run: | ./tests/syntax.sh continue-on-error: true - - name: Run unit tests on macOS dev + - name: Run unit tests on macOS run: | ./tests/unittest.sh continue-on-error: true @@ -46,19 +46,19 @@ jobs: - name: Upload built binary as artifact uses: actions/upload-artifact@v4 with: - name: lvgl_micropy_macOS + name: lvgl_micropy_macOS.bin path: lvgl_micropython/build/lvgl_micropy_macOS compression-level: 0 # don't zip it retention-days: 7 - - name: Build LVGL MicroPython esp32 prod fri3d-2024 + - name: Build LVGL MicroPython esp32 run: | - ./scripts/build_mpos.sh esp32 prod fri3d-2024 + ./scripts/build_mpos.sh esp32 - name: Upload built binary as artifact uses: actions/upload-artifact@v4 with: - name: MicroPythonOS_fri3d-2024_prod + name: MicroPythonOS_esp32.bin path: lvgl_micropython/build/lvgl_micropy_ESP32_GENERIC_S3-SPIRAM_OCT-16.bin compression-level: 0 # don't zip it retention-days: 7 @@ -66,7 +66,7 @@ jobs: - name: Upload built binary as artifact uses: actions/upload-artifact@v4 with: - name: MicroPythonOS_fri3d-2024_prod.ota + name: MicroPythonOS_esp32.ota path: lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC_S3-SPIRAM_OCT/micropython.bin compression-level: 0 # don't zip it retention-days: 7 @@ -76,57 +76,4 @@ jobs: rm lvgl_micropython/build/lvgl_micropy_ESP32_GENERIC_S3-SPIRAM_OCT-16.bin rm lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC_S3-SPIRAM_OCT/micropython.bin - - name: Build LVGL MicroPython esp32 dev fri3d-2024 - run: | - ./scripts/build_mpos.sh esp32 dev fri3d-2024 - - - name: Upload built binary as artifact - uses: actions/upload-artifact@v4 - with: - name: MicroPythonOS_fri3d-2024_dev - path: lvgl_micropython/build/lvgl_micropy_ESP32_GENERIC_S3-SPIRAM_OCT-16.bin - compression-level: 0 # don't zip it - retention-days: 7 - - - name: Cleanup - run: | - rm lvgl_micropython/build/lvgl_micropy_ESP32_GENERIC_S3-SPIRAM_OCT-16.bin - rm lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC_S3-SPIRAM_OCT/micropython.bin - - - name: Build LVGL MicroPython esp32 prod waveshare-esp32-s3-touch-lcd-2 - run: | - ./scripts/build_mpos.sh esp32 prod waveshare-esp32-s3-touch-lcd-2 - - - name: Upload built binary as artifact - uses: actions/upload-artifact@v4 - with: - name: MicroPythonOS_waveshare-esp32-s3-touch-lcd-2_prod - path: lvgl_micropython/build/lvgl_micropy_ESP32_GENERIC_S3-SPIRAM_OCT-16.bin - compression-level: 0 # don't zip it - retention-days: 7 - - - name: Upload built binary as artifact - uses: actions/upload-artifact@v4 - with: - name: MicroPythonOS_waveshare-esp32-s3-touch-lcd-2_prod.ota - path: lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC_S3-SPIRAM_OCT/micropython.bin - compression-level: 0 # don't zip it - retention-days: 7 - - - name: Cleanup - run: | - rm lvgl_micropython/build/lvgl_micropy_ESP32_GENERIC_S3-SPIRAM_OCT-16.bin - rm lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC_S3-SPIRAM_OCT/micropython.bin - - - name: Build LVGL MicroPython esp32 dev waveshare-esp32-s3-touch-lcd-2 - run: | - ./scripts/build_mpos.sh esp32 dev waveshare-esp32-s3-touch-lcd-2 - - - name: Upload built binary as artifact - uses: actions/upload-artifact@v4 - with: - name: MicroPythonOS_waveshare-esp32-s3-touch-lcd-2_dev - path: lvgl_micropython/build/lvgl_micropy_ESP32_GENERIC_S3-SPIRAM_OCT-16.bin - compression-level: 0 # don't zip it - retention-days: 7 diff --git a/.gitignore b/.gitignore index cab0b726..64910910 100644 --- a/.gitignore +++ b/.gitignore @@ -2,10 +2,30 @@ trash/ conf.json* +# macOS file: +.DS_Store + # auto created when running on desktop: internal_filesystem/SDLPointer_2 internal_filesystem/SDLPointer_3 # config files etc: internal_filesystem/data +internal_filesystem/sdcard +internal_filesystem/tests + +internal_filesystem_excluded/ + +# these tests contain actual NWC URLs: +tests/manual_test_nwcwallet_alby.py +tests/manual_test_nwcwallet_cashu.py + +# Python cache files (created by CPython when testing imports) +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +# these get created: +c_mpos/c_mpos diff --git a/.gitmodules b/.gitmodules index 7ea092ac..36f11e8a 100644 --- a/.gitmodules +++ b/.gitmodules @@ -10,7 +10,8 @@ url = https://github.com/MicroPythonOS/lvgl_micropython [submodule "micropython-camera-API"] path = micropython-camera-API - url = https://github.com/cnadler86/micropython-camera-API + #url = https://github.com/cnadler86/micropython-camera-API + url = https://github.com/MicroPythonOS/micropython-camera-API [submodule "micropython-nostr"] path = micropython-nostr url = https://github.com/MicroPythonOS/micropython-nostr diff --git a/CHANGELOG.md b/CHANGELOG.md index d2b4be05..05d59f0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,113 @@ +0.5.2 +===== +- AudioFlinger: optimize WAV volume scaling for speed and immediately set volume +- AudioFlinger: add support for I2S microphone recording to WAV +- AppStore app: eliminate all thread by using TaskManager +- AppStore app: add support for BadgeHub backend +- OSUpdate app: show download speed +- API: add TaskManager that wraps asyncio +- API: add DownloadManager that uses TaskManager +- API: use aiorepl to eliminate another thread + + +0.5.1 +===== +- Fri3d Camp 2024 Board: add startup light and sound +- Fri3d Camp 2024 Board: workaround ADC2+WiFi conflict by temporarily disable WiFi to measure battery level +- Fri3d Camp 2024 Board: improve battery monitor calibration to fix 0.1V delta +- Fri3d Camp 2024 Board: add WSEN-ISDS 6-Axis Inertial Measurement Unit (IMU) support (including temperature) +- API: improve and cleanup animations +- API: SharedPreferences: add erase_all() function +- API: add defaults handling to SharedPreferences and only save non-defaults +- API: restore sys.path after starting app +- API: add AudioFlinger for audio playback (i2s DAC and buzzer) +- API: add LightsManager for multicolor LEDs +- API: add SensorManager for generic handling of IMUs and temperature sensors +- UI: back swipe gesture closes topmenu when open (thanks, @Mark19000 !) +- About app: add free, used and total storage space info +- AppStore app: remove unnecessary scrollbar over publisher's name +- Camera app: massive overhaul! + - Lots of settings (basic, advanced, expert) + - Enable decoding of high density QR codes (like Nostr Wallet Connect) from small sizes (like mobile phone screens) + - Even dotted, logo-ridden and scratched *pictures* of QR codes are now decoded properly! +- ImageView app: add delete functionality +- ImageView app: add support for grayscale images +- OSUpdate app: pause download when wifi is lost, resume when reconnected +- Settings app: fix un-checking of radio button +- Settings app: add IMU calibration +- Wifi app: simplify on-screen keyboard handling, fix cancel button handling + +0.5.0 +===== +- ESP32: one build to rule them all; instead of 2 builds per supported board, there is now one single build that identifies and initializes the board at runtime! +- MposKeyboard: fix q, Q, 1 and ~ button unclickable bug +- MposKeyboard: increase font size from 16 to 20 +- MposKeyboard: use checkbox instead of newline symbol for 'OK, Ready' +- MposKeyboard: bigger space bar +- OSUpdate app: simplify by using ConnectivityManager +- OSUpdate app: adapt to new device IDs +- ImageView app: improve error handling +- Settings app: tweak font size +- Settings app: add 'format internal data partition' option +- Settings app: fix checkbox handling with buttons +- UI: pass clicks on invisible 'gesture swipe start' are to underlying widget +- UI: only show back and down gesture icons on swipe, not on tap +- UI: double size of back and down swipe gesture starting areas for easier gestures +- UI: increase navigation gesture sensitivity +- UI: prevent visual glitches in animations +- API: add facilities for instrumentation (screengrabs, mouse clicks) +- API: move WifiService to mpos.net +- API: remove fonts to reduce size +- API: replace font_montserrat_28 with font_montserrat_28_compressed to reduce size +- API: improve SD card error handling +- WifiService: connect to strongest networks first + +0.4.0 +===== +- Add custom MposKeyboard with more than 50% bigger buttons, great for tiny touch screens! +- Apply theme changes (dark mode, color) immediately after saving +- About app: add a bit more info +- Camera app: fix one-in-two 'camera image stays blank' issue +- OSUpdate app: enable scrolling with joystick/arrow keys +- OSUpdate app: Major rework with improved reliability and user experience + - add WiFi monitoring - shows 'Waiting for WiFi...' instead of error when no connection + - add automatic pause/resume on WiFi loss during downloads using HTTP Range headers + - add user-friendly error messages with specific guidance for each error type + - add 'Check Again' button for easy retry after errors + - add state machine for better app state management + - add comprehensive test coverage (42 tests: 31 unit tests + 11 graphical tests) + - refactor code into testable components (NetworkMonitor, UpdateChecker, UpdateDownloader) + - improve download error recovery with progress preservation + - improve timeout handling (5-minute wait for WiFi with clear messaging) +- Tests: add test infrastructure with mock classes for network, HTTP, and partition operations +- Tests: add graphical test helper utilities for UI verification and screenshot capture +- API: change 'display' to mpos.ui.main_display +- API: change mpos.ui.th to mpos.ui.task_handler +- waveshare-esp32-s3-touch-lcd-2: power off camera at boot to conserve power +- waveshare-esp32-s3-touch-lcd-2: increase touch screen input clock frequency from 100kHz to 400kHz + +0.3.2 +===== +- Settings app: add 'Auto Start App' setting +- Tweak gesture navigation to trigger back and top menu more easily +- Rollback OTA update if launcher fails to start +- Rename 'Home' to 'Launch' in top menu drawer +- Fri3d-2024 Badge: use same SPI freq as Waveshare 2 inch for uniformity +- ESP32: reduce drawing frequency by increasing task_handler duration from 1ms to 5ms +- Rework MicroPython WebSocketApp websocket-client library using uasyncio +- Rework MicroPython python-nostr library using uasyncio +- Update aiohttp_ws library to 0.0.6 +- Add fragmentation support for aiohttp_ws library + +Known issues: +- Fri3d-2024 Badge: joystick arrow up ticks a radio button (workaround: un-tick the radio button) + +0.3.1 +===== +- OSUpdate app: fix typo that prevented update rollback from being cancelled +- Fix 'Home' button in top menu not stopping all apps +- Update micropython-nostr library to fix epoch time on ESP32 and NWC event kind + 0.3.0 ===== - OSUpdate app: now gracefully handles the user closing the app mid-update instead of freezing @@ -6,7 +116,7 @@ - API: Add SDCardManager for SD Card support - API: add PackageManager to (un)install MPK packages - API: split mpos.ui into logical components -- Remove "long press pin 0" for bootloader mode; either use the Settings app or keep it pressed while pressing and releasing the "RESET" button +- Remove 'long press IO0 button' to activate bootloader mode; either use the Settings app (very convenient) or keep it pressed while plugging in the USB cable (or briefly pressing the reset button) - Increase framerate on ESP32 by lowering task_handler duration from 5ms to 1ms - Throttle per-frame async_call() to prevent apps from overflowing memory - Overhaul build system and docs: much simplier (single clone and script run), add MacOS support, build with GitHub Workflow, automatic tests, etc. @@ -31,7 +141,7 @@ ===== - Update to MicroPython 1.25.0 and LVGL 9.3.0 - About app: add info about over-the-air partitions -- OSUpdate app: check update depending on current hardware identifier, add "force update" option, improve user feedback +- OSUpdate app: check update depending on current hardware identifier, add 'force update' option, improve user feedback - AppStore, Camera, Launcher, Settings: adjust for compatibility with LVGL 9.3.0 0.0.11 @@ -50,7 +160,7 @@ - UI: prevent menu drawer button clicks while swiping - Settings: add Timezone configuration - Draw: new app for simple drawing on a canvas -- IMU: new app for showing data from the Intertial Measurement Unit ("Accellerometer") +- IMU: new app for showing data from the Intertial Measurement Unit ('Accellerometer') - Camera: speed up QR decoding 4x - thanks @kdmukai! @@ -63,15 +173,15 @@ 0.0.7 ===== - Update battery icon every 5 seconds depending on VBAT/BAT_ADC -- Add "Power" off button in menu drawer +- Add 'Power' off button in menu drawer 0.0.6 ===== - Scale button size in drawer for bigger screens -- Show "Brightness" text in drawer -- Add builtin "Settings" app with settings for Light/Dark Theme, Theme Color, Restart to Bootloader -- Add "Settings" button to drawer that opens settings app -- Save and restore "Brightness" setting +- Show 'Brightness' text in drawer +- Add builtin 'Settings' app with settings for Light/Dark Theme, Theme Color, Restart to Bootloader +- Add 'Settings' button to drawer that opens settings app +- Save and restore 'Brightness' setting - AppStore: speed up app installs - Camera: scale camera image to fit screen on bigger displays - Camera: show decoded result on-display if QR decoded @@ -112,7 +222,7 @@ 0.0.2 ===== -- Handle IO0 "BOOT button" so long-press starts bootloader mode for updating firmware over USB +- Handle IO0 'BOOT button' so long-press starts bootloader mode for updating firmware over USB 0.0.1 ===== diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..b00e3722 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,1088 @@ +# 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) - see [docs/frameworks/preferences.md](../docs/docs/frameworks/preferences.md) + +**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.) + +### Webcam Module (Desktop Only) + +The `c_mpos/src/webcam.c` module provides webcam support for desktop builds using the V4L2 API. + +**Resolution Adaptation**: +- Automatically queries supported YUYV resolutions from the webcam using V4L2 API +- Supports all 23 ESP32 camera resolutions via intelligent cropping/padding +- **Center cropping**: When requesting smaller than available (e.g., 240x240 from 320x240) +- **Black border padding**: When requesting larger than maximum supported +- Always returns exactly the requested dimensions for API consistency + +**Behavior**: +- On first init, queries device for supported resolutions using `VIDIOC_ENUM_FRAMESIZES` +- Selects smallest capture resolution ≥ requested dimensions (minimizes memory/bandwidth) +- Converts YUYV to RGB565 (color) or grayscale during capture +- Caches supported resolutions to avoid re-querying device + +**Examples**: + +*Cropping (common case)*: +- Request: 240x240 (not natively supported) +- Capture: 320x240 (nearest supported YUYV resolution) +- Process: Extract center 240x240 region +- Result: 240x240 frame with centered content + +*Padding (rare case)*: +- Request: 1920x1080 +- Capture: 1280x720 (webcam maximum) +- Process: Center 1280x720 content in 1920x1080 buffer with black borders +- Result: 1920x1080 frame (API contract maintained) + +**Performance**: +- Exact matches use fast path (no cropping overhead) +- Cropped resolutions add ~5-10% CPU overhead +- Padded resolutions add ~3-5% CPU overhead (memset + center placement) +- V4L2 buffers sized for capture resolution, conversion buffers sized for output + +**Implementation Details**: +- YUYV format: 2 pixels per macropixel (4 bytes: Y0 U Y1 V) +- Crop offsets must be even for proper YUYV alignment +- Center crop formula: `offset = (capture_dim - output_dim) / 2`, then align to even +- Supported resolutions cached in `supported_resolutions_t` structure +- Separate tracking of `capture_width/height` (from V4L2) vs `output_width/height` (user requested) + +**File Location**: `c_mpos/src/webcam.c` (C extension module) + +## Build System + +### Development Workflow (IMPORTANT) + +**⚠️ CRITICAL: Desktop vs Hardware Testing** + +📖 **See**: [docs/os-development/running-on-desktop.md](../docs/docs/os-development/running-on-desktop.md) for complete guide. + +**Desktop testing (recommended for ALL Python development):** +```bash +# 1. Edit files in internal_filesystem/ +nano internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py + +# 2. Run on desktop - changes are IMMEDIATELY active! +./scripts/run_desktop.sh + +# That's it! NO build, NO install needed. +``` + +**❌ DO NOT run `./scripts/install.sh` for desktop testing!** It's only for hardware deployment. + +The desktop binary runs **directly from `internal_filesystem/`**, so any Python file changes are instantly available. This is the fastest development cycle. + +**Hardware deployment (only after desktop testing):** +```bash +# Deploy to physical ESP32 device via USB/serial +./scripts/install.sh waveshare-esp32-s3-touch-lcd-2 +``` + +This copies files from `internal_filesystem/` to device storage, which overrides the frozen filesystem. + +**When you need to rebuild firmware (`./scripts/build_mpos.sh`):** +- Modifying C extension modules (`c_mops/`, `secp256k1-embedded-ecdh/`) +- Changing MicroPython core or LVGL bindings +- Testing frozen filesystem for production releases +- Creating firmware for distribution + +**For 99% of development work on Python code**: Just edit `internal_filesystem/` and run `./scripts/run_desktop.sh`. + +### Building Firmware + +The main build script is `scripts/build_mpos.sh`: + +```bash +# Build for desktop (Linux) +./scripts/build_mpos.sh unix + +# Build for desktop (macOS) +./scripts/build_mpos.sh macOS + +# Build for ESP32-S3 hardware (works on both waveshare and fri3d variants) +./scripts/build_mpos.sh esp32 +``` + +**Targets**: +- `esp32`: ESP32-S3 hardware (supports waveshare-esp32-s3-touch-lcd-2 and fri3d-2024) +- `unix`: Linux desktop +- `macOS`: macOS desktop + +**Note**: The build system automatically includes the frozen filesystem with all built-in apps and libraries. No separate dev/prod distinction exists anymore. + +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: +```bash +./scripts/bundle_apps.sh +``` + +### Running on Desktop + +```bash +# 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 + +```bash +# Flash firmware over USB +./scripts/flash_over_usb.sh +``` + +### Installing Files to Device + +```bash +# 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): +```bash +# 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): +```bash +# 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: +```bash +./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_` +- 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**: +```python +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**: +```python +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: +```bash +# Build desktop version (only needed once) +./scripts/build_mpos.sh unix + +# Install filesystem to device (run after code changes) +./scripts/install.sh + +# Or run directly on desktop +./scripts/run_desktop.sh com.example.myapp +``` + +### Debugging + +Desktop builds include debug symbols by default. Use GDB: +```bash +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: +```bash +./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: +```bash +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` +- Task manager: `internal_filesystem/lib/mpos/task_manager.py` - see [docs/frameworks/task-manager.md](../docs/docs/frameworks/task-manager.md) +- Download manager: `internal_filesystem/lib/mpos/net/download_manager.py` - see [docs/frameworks/download-manager.md](../docs/docs/frameworks/download-manager.md) +- Config/preferences: `internal_filesystem/lib/mpos/config.py` - see [docs/frameworks/preferences.md](../docs/docs/frameworks/preferences.md) +- Audio system: `internal_filesystem/lib/mpos/audio/audioflinger.py` - see [docs/frameworks/audioflinger.md](../docs/docs/frameworks/audioflinger.md) +- LED control: `internal_filesystem/lib/mpos/lights.py` - see [docs/frameworks/lights-manager.md](../docs/docs/frameworks/lights-manager.md) +- Sensor management: `internal_filesystem/lib/mpos/sensor_manager.py` - see [docs/frameworks/sensor-manager.md](../docs/docs/frameworks/sensor-manager.md) +- Top menu/drawer: `internal_filesystem/lib/mpos/ui/topmenu.py` +- Activity navigation: `internal_filesystem/lib/mpos/activity_navigator.py` +- IMU drivers: `internal_filesystem/lib/mpos/hardware/drivers/qmi8658.py` and `wsen_isds.py` + +## Common Utilities and Helpers + +**SharedPreferences**: Persistent key-value storage per app + +📖 User Documentation: See [docs/frameworks/preferences.md](../docs/docs/frameworks/preferences.md) for complete guide with constructor defaults, multi-mode patterns, and auto-cleanup behavior. + +**Implementation**: `lib/mpos/config.py` - SharedPreferences class with get/put methods for strings, ints, bools, lists, and dicts. Values matching constructor defaults are automatically removed from storage (space optimization). + +**Intent system**: Launch activities and pass data +```python +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**: +```python +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.TaskManager`: Async task management - see [docs/frameworks/task-manager.md](../docs/docs/frameworks/task-manager.md) +- `mpos.DownloadManager`: HTTP download utilities - see [docs/frameworks/download-manager.md](../docs/docs/frameworks/download-manager.md) +- `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) +- `mpos.sensor_manager`: Unified sensor access - see [docs/frameworks/sensor-manager.md](../docs/docs/frameworks/sensor-manager.md) +- `mpos.audio.audioflinger`: Audio playback service - see [docs/frameworks/audioflinger.md](../docs/docs/frameworks/audioflinger.md) +- `mpos.lights`: LED control - see [docs/frameworks/lights-manager.md](../docs/docs/frameworks/lights-manager.md) + +## Task Management (TaskManager) + +MicroPythonOS provides a centralized async task management service called **TaskManager** for managing background operations. + +**📖 User Documentation**: See [docs/frameworks/task-manager.md](../docs/docs/frameworks/task-manager.md) for complete API reference, patterns, and examples. + +### Implementation Details (for Claude Code) + +- **Location**: `lib/mpos/task_manager.py` +- **Pattern**: Wrapper around `uasyncio` module +- **Key methods**: `create_task()`, `sleep()`, `sleep_ms()`, `wait_for()`, `notify_event()` +- **Thread model**: All tasks run on main asyncio event loop (cooperative multitasking) + +### Quick Example + +```python +from mpos import TaskManager, DownloadManager + +class MyActivity(Activity): + def onCreate(self): + # Launch background task + TaskManager.create_task(self.download_data()) + + async def download_data(self): + # Download with timeout + try: + data = await TaskManager.wait_for( + DownloadManager.download_url(url), + timeout=10 + ) + self.update_ui(data) + except asyncio.TimeoutError: + print("Download timed out") +``` + +### Critical Code Locations + +- Task manager: `lib/mpos/task_manager.py` +- Used throughout OS for async operations (downloads, WebSockets, sensors) + +## HTTP Downloads (DownloadManager) + +MicroPythonOS provides a centralized HTTP download service called **DownloadManager** for async file downloads. + +**📖 User Documentation**: See [docs/frameworks/download-manager.md](../docs/docs/frameworks/download-manager.md) for complete API reference, patterns, and examples. + +### Implementation Details (for Claude Code) + +- **Location**: `lib/mpos/net/download_manager.py` +- **Pattern**: Module-level singleton (similar to `audioflinger.py`, `battery_voltage.py`) +- **Session management**: Automatic lifecycle (lazy init, auto-cleanup when idle) +- **Thread-safe**: Uses `_thread.allocate_lock()` for session access +- **Three output modes**: Memory (bytes), File (bool), Streaming (callbacks) +- **Features**: Retry logic (3 attempts), progress tracking, resume support (Range headers) + +### Quick Example + +```python +from mpos import DownloadManager + +# Download to memory +data = await DownloadManager.download_url("https://api.example.com/data.json") + +# Download to file with progress +async def on_progress(percent): + print(f"Progress: {percent}%") + +success = await DownloadManager.download_url( + "https://example.com/file.bin", + outfile="/sdcard/file.bin", + progress_callback=on_progress +) +``` + +### Critical Code Locations + +- Download manager: `lib/mpos/net/download_manager.py` +- Used by: AppStore, OSUpdate, and any app needing HTTP downloads + +## Audio System (AudioFlinger) + +MicroPythonOS provides a centralized audio service called **AudioFlinger** for managing audio playback. + +**📖 User Documentation**: See [docs/frameworks/audioflinger.md](../docs/docs/frameworks/audioflinger.md) for complete API reference, examples, and troubleshooting. + +### Implementation Details (for Claude Code) + +- **Location**: `lib/mpos/audio/audioflinger.py` +- **Pattern**: Module-level singleton (similar to `battery_voltage.py`) +- **Thread-safe**: Uses locks for concurrent access +- **Hardware abstraction**: Supports I2S (GPIO 2, 47, 16) and Buzzer (GPIO 46 on Fri3d) +- **Audio focus**: 3-tier priority system (ALARM > NOTIFICATION > MUSIC) +- **Configuration**: `data/com.micropythonos.settings/config.json` key: `audio_device` + +### Critical Code Locations + +- Audio service: `lib/mpos/audio/audioflinger.py` +- I2S implementation: `lib/mpos/audio/i2s_audio.py` +- Buzzer implementation: `lib/mpos/audio/buzzer.py` +- RTTTL parser: `lib/mpos/audio/rtttl.py` +- Board init (Waveshare): `lib/mpos/board/waveshare_esp32_s3_touch_lcd_2.py` (line ~105) +- Board init (Fri3d): `lib/mpos/board/fri3d_2024.py` (line ~300) + +## LED Control (LightsManager) + +MicroPythonOS provides LED control for NeoPixel RGB LEDs (Fri3d badge only). + +**📖 User Documentation**: See [docs/frameworks/lights-manager.md](../docs/docs/frameworks/lights-manager.md) for complete API reference, animation patterns, and examples. + +### Implementation Details (for Claude Code) + +- **Location**: `lib/mpos/lights.py` +- **Pattern**: Module-level singleton (similar to `battery_voltage.py`) +- **Hardware**: 5 NeoPixel RGB LEDs on GPIO 12 (Fri3d badge only) +- **Buffered**: LED colors buffered until `write()` is called +- **Thread-safe**: No locking (single-threaded usage recommended) +- **Desktop**: Functions return `False` (no-op) on desktop builds + +### Critical Code Locations + +- LED service: `lib/mpos/lights.py` +- Board init (Fri3d): `lib/mpos/board/fri3d_2024.py` (line ~290) +- NeoPixel dependency: Uses `neopixel` module from MicroPython + +## Sensor System (SensorManager) + +MicroPythonOS provides a unified sensor framework called **SensorManager** for motion sensors (accelerometer, gyroscope) and temperature sensors. + +📖 User Documentation: See [docs/frameworks/sensor-manager.md](../docs/docs/frameworks/sensor-manager.md) for complete API reference, calibration guide, game examples, and troubleshooting. + +### Implementation Details (for Claude Code) + +- **Location**: `lib/mpos/sensor_manager.py` +- **Pattern**: Module-level singleton (similar to `battery_voltage.py`) +- **Units**: Standard SI (m/s² for acceleration, deg/s for gyroscope, °C for temperature) +- **Calibration**: Persistent via SharedPreferences (`data/com.micropythonos.sensors/config.json`) +- **Thread-safe**: Uses locks for concurrent access +- **Auto-detection**: Identifies IMU type via chip ID registers + - QMI8658: chip_id=0x05 at reg=0x00 + - WSEN_ISDS: chip_id=0x6A at reg=0x0F +- **Desktop**: Functions return `None` (graceful fallback) on desktop builds +- **Important**: Driver constants defined with `const()` cannot be imported at runtime - SensorManager uses hardcoded values instead + +### Critical Code Locations + +- Sensor service: `lib/mpos/sensor_manager.py` +- QMI8658 driver: `lib/mpos/hardware/drivers/qmi8658.py` +- WSEN_ISDS driver: `lib/mpos/hardware/drivers/wsen_isds.py` +- Board init (Waveshare): `lib/mpos/board/waveshare_esp32_s3_touch_lcd_2.py` (line ~130) +- Board init (Fri3d): `lib/mpos/board/fri3d_2024.py` (line ~320) +- Board init (Linux): `lib/mpos/board/linux.py` (line ~115) + +## 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**: +```python +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: + +```python +# 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: + +```python +# 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): +```python +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): +```python +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): +```python +# 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**: +```python +# 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**: +```python +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**: +```python +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**: +```python +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 diff --git a/c_mpos/micropython.cmake b/c_mpos/micropython.cmake index e2c80627..900a9f78 100644 --- a/c_mpos/micropython.cmake +++ b/c_mpos/micropython.cmake @@ -12,6 +12,7 @@ set(MPOS_C_SOURCES ${CMAKE_CURRENT_LIST_DIR}/quirc/lib/version_db.c ${CMAKE_CURRENT_LIST_DIR}/quirc/lib/decode.c ${CMAKE_CURRENT_LIST_DIR}/quirc/lib/quirc.c +# ${CMAKE_CURRENT_LIST_DIR}/src/font_Noto_Sans_sat_emojis_compressed.c ) # Add our source files to the lib diff --git a/c_mpos/micropython.mk b/c_mpos/micropython.mk index 66dfa1f0..bace41a2 100644 --- a/c_mpos/micropython.mk +++ b/c_mpos/micropython.mk @@ -13,11 +13,12 @@ ifneq ($(UNAME_S),Darwin) endif SRC_USERMOD_C += $(MOD_DIR)/src/quirc_decode.c - SRC_USERMOD_C += $(MOD_DIR)/quirc/lib/identify.c SRC_USERMOD_C += $(MOD_DIR)/quirc/lib/version_db.c SRC_USERMOD_C += $(MOD_DIR)/quirc/lib/decode.c SRC_USERMOD_C += $(MOD_DIR)/quirc/lib/quirc.c +#SRC_USERMOD_C += $(MOD_DIR)/src/font_Noto_Sans_sat_emojis_compressed.c + CFLAGS+= -I/usr/include diff --git a/c_mpos/quirc/lib/quirc.c b/c_mpos/quirc/lib/quirc.c index 208746ec..8f9da73e 100644 --- a/c_mpos/quirc/lib/quirc.c +++ b/c_mpos/quirc/lib/quirc.c @@ -64,7 +64,7 @@ int quirc_resize(struct quirc *q, int w, int h) /* * alloc a new buffer for q->image. We avoid realloc(3) because we want - * on failure to be leave `q` in a consistant, unmodified state. + * on failure to be leaving `q` in a consistent, unmodified state. */ image = ps_malloc(w * h); if (!image) @@ -72,7 +72,7 @@ int quirc_resize(struct quirc *q, int w, int h) /* compute the "old" (i.e. currently allocated) and the "new" (i.e. requested) image dimensions */ - size_t olddim = q->w * q->h; + size_t olddim = q->w * q->h; // these are initialized to 0 by quirc_new() size_t newdim = w * h; size_t min = (olddim < newdim ? olddim : newdim); diff --git a/c_mpos/src/quirc_decode.c b/c_mpos/src/quirc_decode.c index 68bcccb9..06c0e3c4 100644 --- a/c_mpos/src/quirc_decode.c +++ b/c_mpos/src/quirc_decode.c @@ -17,12 +17,13 @@ size_t uxTaskGetStackHighWaterMark(void * unused) { #endif #include "../quirc/lib/quirc.h" +#include "../quirc/lib/quirc_internal.h" // Exposes full struct quirc #define QRDECODE_DEBUG_PRINT(...) mp_printf(&mp_plat_print, __VA_ARGS__) static mp_obj_t qrdecode(mp_uint_t n_args, const mp_obj_t *args) { - QRDECODE_DEBUG_PRINT("qrdecode: Starting\n"); - QRDECODE_DEBUG_PRINT("qrdecode: Stack high-water mark: %u bytes\n", uxTaskGetStackHighWaterMark(NULL)); + //QRDECODE_DEBUG_PRINT("qrdecode: Starting\n"); + //QRDECODE_DEBUG_PRINT("qrdecode: Stack high-water mark: %u bytes\n", uxTaskGetStackHighWaterMark(NULL)); if (n_args != 3) { mp_raise_ValueError(MP_ERROR_TEXT("quirc_decode expects 3 arguments: buffer, width, height")); @@ -33,36 +34,55 @@ static mp_obj_t qrdecode(mp_uint_t n_args, const mp_obj_t *args) { mp_int_t width = mp_obj_get_int(args[1]); mp_int_t height = mp_obj_get_int(args[2]); - QRDECODE_DEBUG_PRINT("qrdecode: Width=%u, Height=%u\n", width, height); + //QRDECODE_DEBUG_PRINT("qrdecode: Width=%u, Height=%u\n", width, height); if (width <= 0 || height <= 0) { mp_raise_ValueError(MP_ERROR_TEXT("width and height must be positive")); } if (bufinfo.len != (size_t)(width * height)) { + QRDECODE_DEBUG_PRINT("qrdecode wrong bufsize: %u bytes\n", bufinfo.len); mp_raise_ValueError(MP_ERROR_TEXT("buffer size must match width * height")); } - struct quirc *qr = quirc_new(); if (!qr) { mp_raise_OSError(MP_ENOMEM); } - QRDECODE_DEBUG_PRINT("qrdecode: Allocated quirc object\n"); + //QRDECODE_DEBUG_PRINT("qrdecode: Allocated quirc object\n"); if (quirc_resize(qr, width, height) < 0) { quirc_destroy(qr); mp_raise_OSError(MP_ENOMEM); } - QRDECODE_DEBUG_PRINT("qrdecode: Resized quirc object\n"); + //QRDECODE_DEBUG_PRINT("qrdecode: Resized quirc object\n"); - uint8_t *image; - image = quirc_begin(qr, NULL, NULL); + uint8_t *image = quirc_begin(qr, NULL, NULL); memcpy(image, bufinfo.buf, width * height); + // would be nice to be able to use the existing buffer (bufinfo.buf) here, avoiding memcpy, + // but that buffer is also being filled by image capture and displayed by lvgl + // and that becomes unstable... it shows black artifacts and crashes sometimes... + //uint8_t *temp_image = image; + //image = bufinfo.buf; + //qr->image = bufinfo.buf; // if this works then we can also eliminate quirc's ps_alloc() quirc_end(qr); + //qr->image = temp_image; // restore, because quirc will try to free it + + /* + // Pointer swap - NO memcpy, NO internal.h needed + uint8_t *quirc_buffer = quirc_begin(qr, NULL, NULL); + uint8_t *saved_bufinfo = bufinfo.buf; + bufinfo.buf = quirc_buffer; // quirc now uses your buffer + quirc_end(qr); // QR detection works! + // Restore your buffer pointer + //bufinfo.buf = saved_bufinfo; + */ + + // now num_grids is set, as well as others, probably int count = quirc_count(qr); if (count == 0) { + // Restore your buffer pointer quirc_destroy(qr); - QRDECODE_DEBUG_PRINT("qrdecode: No QR code found, freed quirc object\n"); + //QRDECODE_DEBUG_PRINT("qrdecode: No QR code found, freed quirc object\n"); mp_raise_ValueError(MP_ERROR_TEXT("no QR code found")); } @@ -71,8 +91,10 @@ static mp_obj_t qrdecode(mp_uint_t n_args, const mp_obj_t *args) { quirc_destroy(qr); mp_raise_OSError(MP_ENOMEM); } - QRDECODE_DEBUG_PRINT("qrdecode: Allocated quirc_code\n"); + //QRDECODE_DEBUG_PRINT("qrdecode: Allocated quirc_code\n"); quirc_extract(qr, 0, code); + // the code struct now contains the corners of the QR code, as well as the bitmap of the values + // this could be used to display debug info to the user - they might even be able to see which modules are being misidentified! struct quirc_data *data = (struct quirc_data *)malloc(sizeof(struct quirc_data)); if (!data) { @@ -80,14 +102,14 @@ static mp_obj_t qrdecode(mp_uint_t n_args, const mp_obj_t *args) { quirc_destroy(qr); mp_raise_OSError(MP_ENOMEM); } - QRDECODE_DEBUG_PRINT("qrdecode: Allocated quirc_data\n"); + //QRDECODE_DEBUG_PRINT("qrdecode: Allocated quirc_data\n"); int err = quirc_decode(code, data); if (err != QUIRC_SUCCESS) { free(data); free(code); quirc_destroy(qr); - QRDECODE_DEBUG_PRINT("qrdecode: Decode failed, freed data, code, and quirc object\n"); + //QRDECODE_DEBUG_PRINT("qrdecode: Decode failed, freed data, code, and quirc object\n"); mp_raise_TypeError(MP_ERROR_TEXT("failed to decode QR code")); } @@ -96,12 +118,12 @@ static mp_obj_t qrdecode(mp_uint_t n_args, const mp_obj_t *args) { free(data); free(code); quirc_destroy(qr); - QRDECODE_DEBUG_PRINT("qrdecode: Freed data, code, and quirc object, returning result\n"); + //QRDECODE_DEBUG_PRINT("qrdecode: Freed data, code, and quirc object, returning result\n"); return result; } static mp_obj_t qrdecode_rgb565(mp_uint_t n_args, const mp_obj_t *args) { - QRDECODE_DEBUG_PRINT("qrdecode_rgb565: Starting\n"); + //QRDECODE_DEBUG_PRINT("qrdecode_rgb565: Starting\n"); if (n_args != 3) { mp_raise_ValueError(MP_ERROR_TEXT("qrdecode_rgb565 expects 3 arguments: buffer, width, height")); @@ -112,12 +134,13 @@ static mp_obj_t qrdecode_rgb565(mp_uint_t n_args, const mp_obj_t *args) { mp_int_t width = mp_obj_get_int(args[1]); mp_int_t height = mp_obj_get_int(args[2]); - QRDECODE_DEBUG_PRINT("qrdecode_rgb565: Width=%u, Height=%u\n", width, height); + //QRDECODE_DEBUG_PRINT("qrdecode_rgb565: Width=%u, Height=%u\n", width, height); if (width <= 0 || height <= 0) { mp_raise_ValueError(MP_ERROR_TEXT("width and height must be positive")); } if (bufinfo.len != (size_t)(width * height * 2)) { + QRDECODE_DEBUG_PRINT("qrdecode_rgb565 wrong bufsize: %u bytes\n", bufinfo.len); mp_raise_ValueError(MP_ERROR_TEXT("buffer size must match width * height * 2 for RGB565")); } @@ -125,7 +148,7 @@ static mp_obj_t qrdecode_rgb565(mp_uint_t n_args, const mp_obj_t *args) { if (!gray_buffer) { mp_raise_OSError(MP_ENOMEM); } - QRDECODE_DEBUG_PRINT("qrdecode_rgb565: Allocated gray_buffer (%u bytes)\n", width * height * sizeof(uint8_t)); + //QRDECODE_DEBUG_PRINT("qrdecode_rgb565: Allocated gray_buffer (%u bytes)\n", width * height * sizeof(uint8_t)); uint16_t *rgb565 = (uint16_t *)bufinfo.buf; for (size_t i = 0; i < (size_t)(width * height); i++) { @@ -147,17 +170,37 @@ static mp_obj_t qrdecode_rgb565(mp_uint_t n_args, const mp_obj_t *args) { if (nlr_push(&exception_handler) == 0) { result = qrdecode(3, gray_args); nlr_pop(); - QRDECODE_DEBUG_PRINT("qrdecode_rgb565: qrdecode succeeded, freeing gray_buffer\n"); + //QRDECODE_DEBUG_PRINT("qrdecode_rgb565: qrdecode succeeded, freeing gray_buffer\n"); free(gray_buffer); } else { - QRDECODE_DEBUG_PRINT("qrdecode_rgb565: Exception caught, freeing gray_buffer\n"); - free(gray_buffer); + //QRDECODE_DEBUG_PRINT("qrdecode_rgb565: Exception caught, freeing gray_buffer\n"); + // Cleanup + if (gray_buffer) { + free(gray_buffer); + gray_buffer = NULL; + } + //mp_raise_TypeError(MP_ERROR_TEXT("qrdecode_rgb565: failed to decode QR code")); // Re-raising the exception results in an Unhandled exception in thread started by // which isn't caught, even when catching Exception, so this looks like a bug in MicroPython... - //nlr_pop(); - //nlr_raise(exception_handler.ret_val); + nlr_pop(); + nlr_raise(exception_handler.ret_val); + // Re-raise the original exception with optional additional message + /* + mp_raise_msg_and_obj( + mp_obj_exception_get_type(exception_handler.ret_val), + MP_OBJ_NEW_QSTR(qstr_from_str("qrdecode_rgb565: failed during processing")), + exception_handler.ret_val + ); + */ + // Re-raise as new exception of same type, with message + original as arg + // (embeds original for traceback chaining) + // crashes: + //const mp_obj_type_t *exc_type = mp_obj_get_type(exception_handler.ret_val); + //mp_raise_msg_varg(exc_type, MP_ERROR_TEXT("qrdecode_rgb565: failed during processing: %q"), exception_handler.ret_val); } + //nlr_pop(); maybe it needs to be done after instead of before the re-raise? + return result; } diff --git a/c_mpos/src/webcam.c b/c_mpos/src/webcam.c index 4ae15994..6667b3b9 100644 --- a/c_mpos/src/webcam.c +++ b/c_mpos/src/webcam.c @@ -8,117 +8,320 @@ #include #include #include +#include #include "py/obj.h" #include "py/runtime.h" #include "py/mperrno.h" -#define WIDTH 640 -#define HEIGHT 480 #define NUM_BUFFERS 1 -#define OUTPUT_WIDTH 240 -#define OUTPUT_HEIGHT 240 +#define MAX_SUPPORTED_RESOLUTIONS 32 #define WEBCAM_DEBUG_PRINT(...) mp_printf(&mp_plat_print, __VA_ARGS__) static const mp_obj_type_t webcam_type; +// Resolution structure for storing supported formats +typedef struct { + int width; + int height; +} resolution_t; + +// Cache of supported resolutions from V4L2 device +typedef struct { + resolution_t resolutions[MAX_SUPPORTED_RESOLUTIONS]; + int count; +} supported_resolutions_t; + typedef struct _webcam_obj_t { mp_obj_base_t base; int fd; + char device[64]; // Device path (e.g., "/dev/video0") void *buffers[NUM_BUFFERS]; size_t buffer_length; int frame_count; - unsigned char *gray_buffer; // For grayscale - uint16_t *rgb565_buffer; // For RGB565 -} webcam_obj_t; + unsigned char *gray_buffer; // For grayscale conversion + uint16_t *rgb565_buffer; // For RGB565 conversion -static void yuyv_to_rgb565_240x240(unsigned char *yuyv, uint16_t *rgb565, int in_width, int in_height) { - int crop_size = 480; - int crop_x_offset = (in_width - crop_size) / 2; - int crop_y_offset = (in_height - crop_size) / 2; - float x_ratio = (float)crop_size / OUTPUT_WIDTH; - float y_ratio = (float)crop_size / OUTPUT_HEIGHT; + // Separate capture and output dimensions + int capture_width; // What V4L2 actually captures + int capture_height; + int output_width; // What user requested + int output_height; - for (int y = 0; y < OUTPUT_HEIGHT; y++) { - for (int x = 0; x < OUTPUT_WIDTH; x++) { - int src_x = (int)(x * x_ratio) + crop_x_offset; - int src_y = (int)(y * y_ratio) + crop_y_offset; - int src_index = (src_y * in_width + src_x) * 2; - - int y0 = yuyv[src_index]; - int u = yuyv[src_index + 1]; - int v = yuyv[src_index + 3]; + // Supported resolutions cache + supported_resolutions_t supported_res; +} webcam_obj_t; - int c = y0 - 16; - int d = u - 128; - int e = v - 128; +// Helper function to convert single YUV pixel to RGB565 +static inline uint16_t yuv_to_rgb565(int y_val, int u, int v) { + int c = y_val - 16; + int d = u - 128; + int e = v - 128; - int r = (298 * c + 409 * e + 128) >> 8; - int g = (298 * c - 100 * d - 208 * e + 128) >> 8; - int b = (298 * c + 516 * d + 128) >> 8; + int r = (298 * c + 409 * e + 128) >> 8; + int g = (298 * c - 100 * d - 208 * e + 128) >> 8; + int b = (298 * c + 516 * d + 128) >> 8; - r = r < 0 ? 0 : (r > 255 ? 255 : r); - g = g < 0 ? 0 : (g > 255 ? 255 : g); - b = b < 0 ? 0 : (b > 255 ? 255 : b); + // Clamp to valid range + r = r < 0 ? 0 : (r > 255 ? 255 : r); + g = g < 0 ? 0 : (g > 255 ? 255 : g); + b = b < 0 ? 0 : (b > 255 ? 255 : b); - uint16_t r5 = (r >> 3) & 0x1F; - uint16_t g6 = (g >> 2) & 0x3F; - uint16_t b5 = (b >> 3) & 0x1F; + // Convert to RGB565 + return ((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3); +} - rgb565[y * OUTPUT_WIDTH + x] = (r5 << 11) | (g6 << 5) | b5; +static void yuyv_to_rgb565(unsigned char *yuyv, uint16_t *rgb565, + int capture_width, int capture_height, + int output_width, int output_height) { + // Convert YUYV to RGB565 with cropping or padding support + // YUYV format: Y0 U Y1 V (4 bytes for 2 pixels, chroma shared) + + // Clear entire output buffer to black (RGB565 0x0000) + memset(rgb565, 0, output_width * output_height * sizeof(uint16_t)); + + if (output_width <= capture_width && output_height <= capture_height) { + // Cropping case: extract center region from capture + int offset_x = (capture_width - output_width) / 2; + int offset_y = (capture_height - output_height) / 2; + offset_x = (offset_x / 2) * 2; // YUYV alignment (even offset) + + for (int y = 0; y < output_height; y++) { + for (int x = 0; x < output_width; x += 2) { + int src_y = offset_y + y; + int src_x = offset_x + x; + int src_index = (src_y * capture_width + src_x) * 2; + + int y0 = yuyv[src_index + 0]; + int u = yuyv[src_index + 1]; + int y1 = yuyv[src_index + 2]; + int v = yuyv[src_index + 3]; + + int dst_index = y * output_width + x; + rgb565[dst_index] = yuv_to_rgb565(y0, u, v); + rgb565[dst_index + 1] = yuv_to_rgb565(y1, u, v); + } + } + } else { + // Padding case: center capture in larger output buffer + int offset_x = (output_width - capture_width) / 2; + int offset_y = (output_height - capture_height) / 2; + offset_x = (offset_x / 2) * 2; // YUYV alignment (even offset) + + for (int y = 0; y < capture_height; y++) { + for (int x = 0; x < capture_width; x += 2) { + int src_index = (y * capture_width + x) * 2; + + int y0 = yuyv[src_index + 0]; + int u = yuyv[src_index + 1]; + int y1 = yuyv[src_index + 2]; + int v = yuyv[src_index + 3]; + + int dst_y = offset_y + y; + int dst_x = offset_x + x; + int dst_index = dst_y * output_width + dst_x; + rgb565[dst_index] = yuv_to_rgb565(y0, u, v); + rgb565[dst_index + 1] = yuv_to_rgb565(y1, u, v); + } } } } -static void yuyv_to_grayscale_240x240(unsigned char *yuyv, unsigned char *gray, int in_width, int in_height) { - int crop_size = 480; - int crop_x_offset = (in_width - crop_size) / 2; - int crop_y_offset = (in_height - crop_size) / 2; - float x_ratio = (float)crop_size / OUTPUT_WIDTH; - float y_ratio = (float)crop_size / OUTPUT_HEIGHT; - - for (int y = 0; y < OUTPUT_HEIGHT; y++) { - for (int x = 0; x < OUTPUT_WIDTH; x++) { - int src_x = (int)(x * x_ratio) + crop_x_offset; - int src_y = (int)(y * y_ratio) + crop_y_offset; - int src_index = (src_y * in_width + src_x) * 2; - gray[y * OUTPUT_WIDTH + x] = yuyv[src_index]; +static void yuyv_to_grayscale(unsigned char *yuyv, unsigned char *gray, + int capture_width, int capture_height, + int output_width, int output_height) { + // Extract Y (luminance) values from YUYV with cropping or padding support + // YUYV format: Y0 U Y1 V (4 bytes for 2 pixels) + + // Clear entire output buffer to black (0x00) + memset(gray, 0, output_width * output_height); + + if (output_width <= capture_width && output_height <= capture_height) { + // Cropping case: extract center region from capture + int offset_x = (capture_width - output_width) / 2; + int offset_y = (capture_height - output_height) / 2; + offset_x = (offset_x / 2) * 2; // YUYV alignment (even offset) + + for (int y = 0; y < output_height; y++) { + for (int x = 0; x < output_width; x++) { + int src_y = offset_y + y; + int src_x = offset_x + x; + // Y values are at even indices in YUYV + gray[y * output_width + x] = yuyv[(src_y * capture_width + src_x) * 2]; + } + } + } else { + // Padding case: center capture in larger output buffer + int offset_x = (output_width - capture_width) / 2; + int offset_y = (output_height - capture_height) / 2; + offset_x = (offset_x / 2) * 2; // YUYV alignment (even offset) + + for (int y = 0; y < capture_height; y++) { + for (int x = 0; x < capture_width; x++) { + int dst_y = offset_y + y; + int dst_x = offset_x + x; + // Y values are at even indices in YUYV + gray[dst_y * output_width + dst_x] = yuyv[(y * capture_width + x) * 2]; + } } } } -static void save_raw(const char *filename, unsigned char *data, int width, int height) { +static void save_raw_generic(const char *filename, void *data, size_t elem_size, int width, int height) { FILE *fp = fopen(filename, "wb"); if (!fp) { WEBCAM_DEBUG_PRINT("Cannot open file %s: %s\n", filename, strerror(errno)); return; } - fwrite(data, 1, width * height, fp); + fwrite(data, elem_size, width * height, fp); fclose(fp); } -static void save_raw_rgb565(const char *filename, uint16_t *data, int width, int height) { - FILE *fp = fopen(filename, "wb"); - if (!fp) { - WEBCAM_DEBUG_PRINT("Cannot open file %s: %s\n", filename, strerror(errno)); - return; +// Query supported YUYV resolutions from V4L2 device +static int query_supported_resolutions(int fd, supported_resolutions_t *supported) { + struct v4l2_fmtdesc fmt_desc; + struct v4l2_frmsizeenum frmsize; + int found_yuyv = 0; + + supported->count = 0; + + // First, check if device supports YUYV format + memset(&fmt_desc, 0, sizeof(fmt_desc)); + fmt_desc.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; + + for (fmt_desc.index = 0; ; fmt_desc.index++) { + if (ioctl(fd, VIDIOC_ENUM_FMT, &fmt_desc) < 0) { + break; + } + if (fmt_desc.pixelformat == V4L2_PIX_FMT_YUYV) { + found_yuyv = 1; + break; + } } - fwrite(data, sizeof(uint16_t), width * height, fp); - fclose(fp); + + if (!found_yuyv) { + WEBCAM_DEBUG_PRINT("Warning: YUYV format not found\n"); + return -1; + } + + // Enumerate frame sizes for YUYV + memset(&frmsize, 0, sizeof(frmsize)); + frmsize.pixel_format = V4L2_PIX_FMT_YUYV; + + for (frmsize.index = 0; supported->count < MAX_SUPPORTED_RESOLUTIONS; frmsize.index++) { + if (ioctl(fd, VIDIOC_ENUM_FRAMESIZES, &frmsize) < 0) { + break; + } + + if (frmsize.type == V4L2_FRMSIZE_TYPE_DISCRETE) { + supported->resolutions[supported->count].width = frmsize.discrete.width; + supported->resolutions[supported->count].height = frmsize.discrete.height; + supported->count++; + WEBCAM_DEBUG_PRINT(" Found resolution: %dx%d\n", + frmsize.discrete.width, frmsize.discrete.height); + } + } + + if (supported->count == 0) { + WEBCAM_DEBUG_PRINT("Warning: No discrete YUYV resolutions found, using common defaults\n"); + // Fallback to common resolutions if enumeration fails + const resolution_t defaults[] = { + {160, 120}, {320, 240}, {640, 480}, {1280, 720}, {1920, 1080} + }; + for (int i = 0; i < 5 && i < MAX_SUPPORTED_RESOLUTIONS; i++) { + supported->resolutions[i] = defaults[i]; + supported->count++; + } + } + + WEBCAM_DEBUG_PRINT("Total supported resolutions: %d\n", supported->count); + return 0; } -static int init_webcam(webcam_obj_t *self, const char *device) { - //WEBCAM_DEBUG_PRINT("webcam.c: init_webcam\n"); +// Find the best capture resolution for the requested output size +static resolution_t find_best_capture_resolution(int requested_width, int requested_height, + supported_resolutions_t *supported) { + resolution_t best; + int found_candidate = 0; + int min_area = INT_MAX; + + // Check for exact match first + for (int i = 0; i < supported->count; i++) { + if (supported->resolutions[i].width == requested_width && + supported->resolutions[i].height == requested_height) { + WEBCAM_DEBUG_PRINT("Found exact resolution match: %dx%d\n", + requested_width, requested_height); + return supported->resolutions[i]; + } + } + + // Find smallest resolution that contains the requested size + for (int i = 0; i < supported->count; i++) { + if (supported->resolutions[i].width >= requested_width && + supported->resolutions[i].height >= requested_height) { + int area = supported->resolutions[i].width * supported->resolutions[i].height; + if (area < min_area) { + min_area = area; + best = supported->resolutions[i]; + found_candidate = 1; + } + } + } + + if (found_candidate) { + WEBCAM_DEBUG_PRINT("Best capture resolution for %dx%d: %dx%d (will crop)\n", + requested_width, requested_height, best.width, best.height); + return best; + } + + // No containing resolution found, use largest available (will need padding) + best = supported->resolutions[0]; + for (int i = 1; i < supported->count; i++) { + int area = supported->resolutions[i].width * supported->resolutions[i].height; + int best_area = best.width * best.height; + if (area > best_area) { + best = supported->resolutions[i]; + } + } + + WEBCAM_DEBUG_PRINT("Warning: Requested %dx%d exceeds max supported, capturing at %dx%d (will pad with black)\n", + requested_width, requested_height, best.width, best.height); + return best; +} + +static int init_webcam(webcam_obj_t *self, const char *device, int requested_width, int requested_height) { + // Store device path for later use (e.g., reconfigure) + strncpy(self->device, device, sizeof(self->device) - 1); + self->device[sizeof(self->device) - 1] = '\0'; + self->fd = open(device, O_RDWR); if (self->fd < 0) { WEBCAM_DEBUG_PRINT("Cannot open device: %s\n", strerror(errno)); return -errno; } + // Query supported resolutions (first time only) + if (self->supported_res.count == 0) { + WEBCAM_DEBUG_PRINT("Querying supported resolutions...\n"); + if (query_supported_resolutions(self->fd, &self->supported_res) < 0) { + // Query failed, but continue with fallback defaults + WEBCAM_DEBUG_PRINT("Resolution query failed, continuing with defaults\n"); + } + } + + // Find best capture resolution for requested output + resolution_t best = find_best_capture_resolution(requested_width, requested_height, + &self->supported_res); + + // Store requested output dimensions + self->output_width = requested_width; + self->output_height = requested_height; + + // Configure V4L2 with capture resolution struct v4l2_format fmt = {0}; fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; - fmt.fmt.pix.width = WIDTH; - fmt.fmt.pix.height = HEIGHT; + fmt.fmt.pix.width = best.width; + fmt.fmt.pix.height = best.height; fmt.fmt.pix.pixelformat = V4L2_PIX_FMT_YUYV; fmt.fmt.pix.field = V4L2_FIELD_ANY; if (ioctl(self->fd, VIDIOC_S_FMT, &fmt) < 0) { @@ -127,6 +330,10 @@ static int init_webcam(webcam_obj_t *self, const char *device) { return -errno; } + // Store actual capture dimensions (driver may adjust) + self->capture_width = fmt.fmt.pix.width; + self->capture_height = fmt.fmt.pix.height; + struct v4l2_requestbuffers req = {0}; req.count = NUM_BUFFERS; req.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; @@ -144,6 +351,10 @@ static int init_webcam(webcam_obj_t *self, const char *device) { buf.index = i; if (ioctl(self->fd, VIDIOC_QUERYBUF, &buf) < 0) { WEBCAM_DEBUG_PRINT("Cannot query buffer: %s\n", strerror(errno)); + // Unmap any already-mapped buffers + for (int j = 0; j < i; j++) { + munmap(self->buffers[j], self->buffer_length); + } close(self->fd); return -errno; } @@ -151,6 +362,10 @@ static int init_webcam(webcam_obj_t *self, const char *device) { self->buffers[i] = mmap(NULL, buf.length, PROT_READ | PROT_WRITE, MAP_SHARED, self->fd, buf.m.offset); if (self->buffers[i] == MAP_FAILED) { WEBCAM_DEBUG_PRINT("Cannot map buffer: %s\n", strerror(errno)); + // Unmap any already-mapped buffers + for (int j = 0; j < i; j++) { + munmap(self->buffers[j], self->buffer_length); + } close(self->fd); return -errno; } @@ -174,10 +389,16 @@ static int init_webcam(webcam_obj_t *self, const char *device) { } self->frame_count = 0; - self->gray_buffer = (unsigned char *)malloc(OUTPUT_WIDTH * OUTPUT_HEIGHT * sizeof(unsigned char)); - self->rgb565_buffer = (uint16_t *)malloc(OUTPUT_WIDTH * OUTPUT_HEIGHT * sizeof(uint16_t)); + + WEBCAM_DEBUG_PRINT("Webcam initialized: capture=%dx%d, output=%dx%d\n", + self->capture_width, self->capture_height, + self->output_width, self->output_height); + + // Allocate conversion buffers based on OUTPUT dimensions + self->gray_buffer = (unsigned char *)malloc(self->output_width * self->output_height * sizeof(unsigned char)); + self->rgb565_buffer = (uint16_t *)malloc(self->output_width * self->output_height * sizeof(uint16_t)); if (!self->gray_buffer || !self->rgb565_buffer) { - WEBCAM_DEBUG_PRINT("Cannot allocate buffers: %s\n", strerror(errno)); + WEBCAM_DEBUG_PRINT("Cannot allocate conversion buffers: %s\n", strerror(errno)); free(self->gray_buffer); free(self->rgb565_buffer); close(self->fd); @@ -203,6 +424,9 @@ static void deinit_webcam(webcam_obj_t *self) { free(self->rgb565_buffer); self->rgb565_buffer = NULL; + // Clear resolution cache (device may change on reconnect) + self->supported_res.count = 0; + close(self->fd); self->fd = -1; } @@ -226,37 +450,45 @@ static mp_obj_t capture_frame(mp_obj_t self_in, mp_obj_t format) { mp_raise_OSError(-res); } - if (!self->gray_buffer) { - self->gray_buffer = (unsigned char *)malloc(OUTPUT_WIDTH * OUTPUT_HEIGHT * sizeof(unsigned char)); - if (!self->gray_buffer) { - mp_raise_OSError(MP_ENOMEM); - } - } - if (!self->rgb565_buffer) { - self->rgb565_buffer = (uint16_t *)malloc(OUTPUT_WIDTH * OUTPUT_HEIGHT * sizeof(uint16_t)); - if (!self->rgb565_buffer) { - mp_raise_OSError(MP_ENOMEM); - } + // Buffers should already be allocated in init_webcam + if (!self->gray_buffer || !self->rgb565_buffer) { + mp_raise_msg(&mp_type_RuntimeError, MP_ERROR_TEXT("Buffers not allocated")); } const char *fmt = mp_obj_str_get_str(format); if (strcmp(fmt, "grayscale") == 0) { - yuyv_to_grayscale_240x240(self->buffers[buf.index], self->gray_buffer, WIDTH, HEIGHT); - // char filename[32]; - // snprintf(filename, sizeof(filename), "frame_%03d.raw", self->frame_count++); - // save_raw(filename, self->gray_buffer, OUTPUT_WIDTH, OUTPUT_HEIGHT); - mp_obj_t result = mp_obj_new_memoryview('b', OUTPUT_WIDTH * OUTPUT_HEIGHT, self->gray_buffer); + // Pass all 6 dimensions: capture (source) and output (destination) + yuyv_to_grayscale( + self->buffers[buf.index], + self->gray_buffer, + self->capture_width, // Source dimensions + self->capture_height, + self->output_width, // Destination dimensions + self->output_height + ); + // Return memoryview with OUTPUT dimensions + mp_obj_t result = mp_obj_new_memoryview('b', + self->output_width * self->output_height, + self->gray_buffer); res = ioctl(self->fd, VIDIOC_QBUF, &buf); if (res < 0) { mp_raise_OSError(-res); } return result; } else { - yuyv_to_rgb565_240x240(self->buffers[buf.index], self->rgb565_buffer, WIDTH, HEIGHT); - // char filename[32]; - // snprintf(filename, sizeof(filename), "frame_%03d.rgb565", self->frame_count++); - // save_raw_rgb565(filename, self->rgb565_buffer, OUTPUT_WIDTH, OUTPUT_HEIGHT); - mp_obj_t result = mp_obj_new_memoryview('b', OUTPUT_WIDTH * OUTPUT_HEIGHT * 2, self->rgb565_buffer); + // Pass all 6 dimensions: capture (source) and output (destination) + yuyv_to_rgb565( + self->buffers[buf.index], + self->rgb565_buffer, + self->capture_width, // Source dimensions + self->capture_height, + self->output_width, // Destination dimensions + self->output_height + ); + // Return memoryview with OUTPUT dimensions + mp_obj_t result = mp_obj_new_memoryview('b', + self->output_width * self->output_height * 2, + self->rgb565_buffer); res = ioctl(self->fd, VIDIOC_QBUF, &buf); if (res < 0) { mp_raise_OSError(-res); @@ -265,27 +497,37 @@ static mp_obj_t capture_frame(mp_obj_t self_in, mp_obj_t format) { } } -static mp_obj_t webcam_init(size_t n_args, const mp_obj_t *args) { - mp_arg_check_num(n_args, 0, 0, 1, false); +static mp_obj_t webcam_init(size_t n_args, const mp_obj_t *pos_args, mp_map_t *kw_args) { + enum { ARG_device, ARG_width, ARG_height }; + static const mp_arg_t allowed_args[] = { + { MP_QSTR_device, MP_ARG_OBJ, {.u_obj = MP_OBJ_NULL} }, + { MP_QSTR_width, MP_ARG_REQUIRED | MP_ARG_KW_ONLY | MP_ARG_INT, {.u_int = 0} }, + { MP_QSTR_height, MP_ARG_REQUIRED | MP_ARG_KW_ONLY | MP_ARG_INT, {.u_int = 0} }, + }; + + mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; + mp_arg_parse_all(n_args, pos_args, kw_args, MP_ARRAY_SIZE(allowed_args), allowed_args, args); + const char *device = "/dev/video0"; - if (n_args == 1) { - device = mp_obj_str_get_str(args[0]); + if (args[ARG_device].u_obj != MP_OBJ_NULL) { + device = mp_obj_str_get_str(args[ARG_device].u_obj); } + int width = args[ARG_width].u_int; + int height = args[ARG_height].u_int; + webcam_obj_t *self = m_new_obj(webcam_obj_t); self->base.type = &webcam_type; self->fd = -1; - self->gray_buffer = NULL; - self->rgb565_buffer = NULL; - int res = init_webcam(self, device); + int res = init_webcam(self, device, width, height); if (res < 0) { mp_raise_OSError(-res); } return MP_OBJ_FROM_PTR(self); } -MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(webcam_init_obj, 0, 1, webcam_init); +MP_DEFINE_CONST_FUN_OBJ_KW(webcam_init_obj, 0, webcam_init); static mp_obj_t webcam_deinit(mp_obj_t self_in) { webcam_obj_t *self = MP_OBJ_TO_PTR(self_in); @@ -309,6 +551,62 @@ static mp_obj_t webcam_capture_frame(mp_obj_t self_in, mp_obj_t format) { } MP_DEFINE_CONST_FUN_OBJ_2(webcam_capture_frame_obj, webcam_capture_frame); +static mp_obj_t webcam_reconfigure(size_t n_args, const mp_obj_t *pos_args, mp_map_t *kw_args) { + /* + * Reconfigure webcam resolution by reinitializing. + * + * This elegantly reuses deinit_webcam() and init_webcam() instead of + * duplicating V4L2 setup code. + * + * Parameters: + * width, height: Resolution (optional, keeps current if not specified) + */ + + enum { ARG_self, ARG_width, ARG_height }; + static const mp_arg_t allowed_args[] = { + { MP_QSTR_self, MP_ARG_REQUIRED | MP_ARG_OBJ, {.u_obj = MP_OBJ_NULL} }, + { MP_QSTR_width, MP_ARG_KW_ONLY | MP_ARG_INT, {.u_int = 0} }, + { MP_QSTR_height, MP_ARG_KW_ONLY | MP_ARG_INT, {.u_int = 0} }, + }; + + mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; + mp_arg_parse_all(n_args, pos_args, kw_args, MP_ARRAY_SIZE(allowed_args), allowed_args, args); + + webcam_obj_t *self = MP_OBJ_TO_PTR(args[ARG_self].u_obj); + + // Get new dimensions (keep current if not specified) + int new_width = args[ARG_width].u_int; + int new_height = args[ARG_height].u_int; + + if (new_width == 0) new_width = self->output_width; + if (new_height == 0) new_height = self->output_height; + + // Validate dimensions + if (new_width <= 0 || new_height <= 0 || new_width > 3840 || new_height > 2160) { + mp_raise_ValueError(MP_ERROR_TEXT("Invalid dimensions")); + } + + // Check if anything changed + if (new_width == self->output_width && new_height == self->output_height) { + return mp_const_none; // Nothing to do + } + + WEBCAM_DEBUG_PRINT("Reconfiguring webcam: %dx%d -> %dx%d\n", + self->output_width, self->output_height, new_width, new_height); + + // Clean shutdown and reinitialize with new resolution + // Note: deinit_webcam doesn't touch self->device, so it's safe to use directly + deinit_webcam(self); + int res = init_webcam(self, self->device, new_width, new_height); + + if (res < 0) { + mp_raise_OSError(-res); + } + + return mp_const_none; +} +MP_DEFINE_CONST_FUN_OBJ_KW(webcam_reconfigure_obj, 1, webcam_reconfigure); + static const mp_obj_type_t webcam_type = { { &mp_type_type }, .name = MP_QSTR_Webcam, @@ -321,6 +619,7 @@ static const mp_rom_map_elem_t mp_module_webcam_globals_table[] = { { MP_ROM_QSTR(MP_QSTR_capture_frame), MP_ROM_PTR(&webcam_capture_frame_obj) }, { MP_ROM_QSTR(MP_QSTR_deinit), MP_ROM_PTR(&webcam_deinit_obj) }, { MP_ROM_QSTR(MP_QSTR_free_buffer), MP_ROM_PTR(&webcam_free_buffer_obj) }, + { MP_ROM_QSTR(MP_QSTR_reconfigure), MP_ROM_PTR(&webcam_reconfigure_obj) }, }; static MP_DEFINE_CONST_DICT(mp_module_webcam_globals, mp_module_webcam_globals_table); diff --git a/draft_code/buzzer.py b/draft_code/buzzer.py deleted file mode 100644 index d21a1ead..00000000 --- a/draft_code/buzzer.py +++ /dev/null @@ -1,24 +0,0 @@ -from machine import Pin, PWM -import time - -# Set up pin 46 as PWM output -buzzer = PWM(Pin(46)) - -# Function to play a tone (frequency in Hz, duration in seconds) -def play_tone(frequency, duration): - buzzer.freq(frequency) # Set frequency - buzzer.duty_u16(32768) # 50% duty cycle (32768 is half of 65536) - time.sleep(duration) # Play for specified duration - buzzer.duty_u16(0) # Stop the tone - -# Example: Play a 440 Hz tone (A4 note) for 1 second -play_tone(440, 1) - -# Optional: Play a sequence of notes -notes = [(261, 0.5), (293, 0.5), (329, 0.5), (349, 0.5)] # C4, D4, E4, F4 -for freq, duration in notes: - play_tone(freq, duration) - time.sleep(0.1) # Short pause between notes - -# Turn off the buzzer -buzzer.deinit() diff --git a/freezeFS b/freezeFS index 92f12eb1..5f211e3e 160000 --- a/freezeFS +++ b/freezeFS @@ -1 +1 @@ -Subproject commit 92f12eb1aec68cc9730ef479e655804ce7dbb9ac +Subproject commit 5f211e3ef1f189e271e9614e7a93f784aea243c0 diff --git a/internal_filesystem/apps/com.micropythonos.camera/META-INF/MANIFEST.JSON b/internal_filesystem/apps/com.micropythonos.camera/META-INF/MANIFEST.JSON index c0684692..0405e83b 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/META-INF/MANIFEST.JSON +++ b/internal_filesystem/apps/com.micropythonos.camera/META-INF/MANIFEST.JSON @@ -3,10 +3,10 @@ "publisher": "MicroPythonOS", "short_description": "Camera with QR decoding", "long_description": "Camera for both internal camera's and webcams, that includes QR decoding.", -"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.camera/icons/com.micropythonos.camera_0.0.9_64x64.png", -"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.camera/mpks/com.micropythonos.camera_0.0.9.mpk", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.camera/icons/com.micropythonos.camera_0.1.0_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.camera/mpks/com.micropythonos.camera_0.1.0.mpk", "fullname": "com.micropythonos.camera", -"version": "0.0.9", +"version": "0.1.0", "category": "camera", "activities": [ { @@ -22,6 +22,11 @@ "category": "default" } ] + }, + { + "entrypoint": "assets/camera_app.py", + "classname": "CameraSettingsActivity", + "intent_filters": [] } ] } diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index f243fb00..23675283 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -1,41 +1,48 @@ -# This code grabs images from the camera in RGB565 format (2 bytes per pixel) -# and sends that to the QR decoder if QR decoding is enabled. -# The QR decoder then converts the RGB565 to grayscale, as that's what quirc operates on. -# It would be slightly more efficient to capture the images from the camera in L8/grayscale format, -# or in YUV format and discarding the U and V planes, but then the image will be gray (not great UX) -# and the performance impact of converting RGB565 to grayscale is probably minimal anyway. - import lvgl as lv +import time try: import webcam except Exception as e: print(f"Info: could not import webcam module: {e}") -from mpos.apps import Activity import mpos.time +from mpos.apps import Activity +from mpos.content.intent import Intent + +from camera_settings import CameraSettingsActivity class CameraApp(Activity): - width = 240 - height = 240 + PACKAGE = "com.micropythonos.camera" + CONFIGFILE = "config.json" + SCANQR_CONFIG = "config_scanqr_mode.json" - status_label_text = "No camera found." - status_label_text_searching = "Searching QR codes...\n\nHold still and make them big!\n10cm for simple QR codes,\n20cm for complex." - status_label_text_found = "Decoding QR..." + button_width = 75 + button_height = 50 + + STATUS_NO_CAMERA = "No camera found." + STATUS_SEARCHING_QR = "Searching QR codes...\n\nHold still and try varying scan distance (10-25cm) and make the QR code big (4-12cm). Ensure proper lighting." + STATUS_FOUND_QR = "Found QR, trying to decode... hold still..." cam = None - current_cam_buffer = None # Holds the current memoryview to prevent garbage collection + current_cam_buffer = None # Holds the current memoryview to prevent garba + width = None + height = None + colormode = False - image = None image_dsc = None - scanqr_mode = None + scanqr_mode = False + scanqr_intent = False use_webcam = False - keepliveqrdecoding = False - capture_timer = None + + prefs = None # regular prefs + scanqr_prefs = None # qr code scanning prefs # Widgets: + main_screen = None + image = None qr_label = None qr_button = None snap_button = None @@ -43,94 +50,114 @@ class CameraApp(Activity): status_label_cont = None def onCreate(self): - self.scanqr_mode = self.getIntent().extras.get("scanqr_mode") - main_screen = lv.obj() - main_screen.set_style_pad_all(0, 0) - main_screen.set_style_border_width(0, 0) - main_screen.set_size(lv.pct(100), lv.pct(100)) - main_screen.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) - close_button = lv.button(main_screen) - close_button.set_size(60,60) + self.main_screen = lv.obj() + self.main_screen.set_style_pad_all(1, 0) + self.main_screen.set_style_border_width(0, 0) + self.main_screen.set_size(lv.pct(100), lv.pct(100)) + self.main_screen.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) + # Initialize LVGL image widget + self.image = lv.image(self.main_screen) + self.image.align(lv.ALIGN.LEFT_MID, 0, 0) + close_button = lv.button(self.main_screen) + close_button.set_size(self.button_width, self.button_height) close_button.align(lv.ALIGN.TOP_RIGHT, 0, 0) close_label = lv.label(close_button) close_label.set_text(lv.SYMBOL.CLOSE) close_label.center() close_button.add_event_cb(lambda e: self.finish(),lv.EVENT.CLICKED,None) - self.snap_button = lv.button(main_screen) - self.snap_button.set_size(60, 60) - self.snap_button.align(lv.ALIGN.RIGHT_MID, 0, 0) - self.snap_button.add_flag(lv.obj.FLAG.HIDDEN) - self.snap_button.add_event_cb(self.snap_button_click,lv.EVENT.CLICKED,None) - snap_label = lv.label(self.snap_button) - snap_label.set_text(lv.SYMBOL.OK) - snap_label.center() - self.qr_button = lv.button(main_screen) - self.qr_button.set_size(60, 60) + # Settings button + settings_button = lv.button(self.main_screen) + settings_button.set_size(self.button_width, self.button_height) + settings_button.align_to(close_button, lv.ALIGN.OUT_BOTTOM_MID, 0, 10) + settings_label = lv.label(settings_button) + settings_label.set_text(lv.SYMBOL.SETTINGS) + settings_label.center() + settings_button.add_event_cb(lambda e: self.open_settings(),lv.EVENT.CLICKED,None) + #self.zoom_button = lv.button(self.main_screen) + #self.zoom_button.set_size(self.button_width, self.button_height) + #self.zoom_button.align(lv.ALIGN.RIGHT_MID, 0, self.button_height + 5) + #self.zoom_button.add_event_cb(self.zoom_button_click,lv.EVENT.CLICKED,None) + #zoom_label = lv.label(self.zoom_button) + #zoom_label.set_text("Z") + #zoom_label.center() + self.qr_button = lv.button(self.main_screen) + self.qr_button.set_size(self.button_width, self.button_height) self.qr_button.add_flag(lv.obj.FLAG.HIDDEN) self.qr_button.align(lv.ALIGN.BOTTOM_RIGHT, 0, 0) self.qr_button.add_event_cb(self.qr_button_click,lv.EVENT.CLICKED,None) self.qr_label = lv.label(self.qr_button) self.qr_label.set_text(lv.SYMBOL.EYE_OPEN) self.qr_label.center() - # Initialize LVGL image widget - self.image = lv.image(main_screen) - self.image.align(lv.ALIGN.LEFT_MID, 0, 0) - # Create image descriptor once - self.image_dsc = lv.image_dsc_t({ - "header": { - "magic": lv.IMAGE_HEADER_MAGIC, - "w": self.width, - "h": self.height, - "stride": self.width * 2, - "cf": lv.COLOR_FORMAT.RGB565 - #"cf": lv.COLOR_FORMAT.L8 - }, - 'data_size': self.width * self.height * 2, - 'data': None # Will be updated per frame - }) - self.image.set_src(self.image_dsc) - self.status_label_cont = lv.obj(main_screen) - self.status_label_cont.set_size(lv.pct(66),lv.pct(60)) - self.status_label_cont.align(lv.ALIGN.LEFT_MID, lv.pct(5), 0) + + self.snap_button = lv.button(self.main_screen) + self.snap_button.set_size(self.button_width, self.button_height) + self.snap_button.align_to(self.qr_button, lv.ALIGN.OUT_TOP_MID, 0, -10) + self.snap_button.add_flag(lv.obj.FLAG.HIDDEN) + self.snap_button.add_event_cb(self.snap_button_click,lv.EVENT.CLICKED,None) + snap_label = lv.label(self.snap_button) + snap_label.set_text(lv.SYMBOL.OK) + snap_label.center() + + + self.status_label_cont = lv.obj(self.main_screen) + width = mpos.ui.pct_of_display_width(70) + height = mpos.ui.pct_of_display_width(60) + self.status_label_cont.set_size(width,height) + center_w = round((mpos.ui.pct_of_display_width(100) - self.button_width - 5 - width)/2) + center_h = round((mpos.ui.pct_of_display_height(100) - height)/2) + self.status_label_cont.set_pos(center_w,center_h) self.status_label_cont.set_style_bg_color(lv.color_white(), 0) self.status_label_cont.set_style_bg_opa(66, 0) self.status_label_cont.set_style_border_width(0, 0) self.status_label = lv.label(self.status_label_cont) - self.status_label.set_text("No camera found.") + self.status_label.set_text(self.STATUS_NO_CAMERA) self.status_label.set_long_mode(lv.label.LONG_MODE.WRAP) self.status_label.set_width(lv.pct(100)) self.status_label.center() - self.setContentView(main_screen) + self.setContentView(self.main_screen) def onResume(self, screen): - self.cam = init_internal_cam() + self.scanqr_intent = self.getIntent().extras.get("scanqr_intent") + self.status_label_cont.add_flag(lv.obj.FLAG.HIDDEN) + if self.scanqr_mode or self.scanqr_intent: + self.start_qr_decoding() + if not self.cam and self.scanqr_mode: + print("No camera found, stopping camera app") + self.finish() + else: + self.load_settings_cached() + self.start_cam() + self.qr_button.remove_flag(lv.obj.FLAG.HIDDEN) + self.snap_button.remove_flag(lv.obj.FLAG.HIDDEN) + + def onPause(self, screen): + print("camera app backgrounded, cleaning up...") + self.stop_cam() + print("camera app cleanup done.") + + def start_cam(self): + # Init camera: + self.cam = self.init_internal_cam(self.width, self.height) if self.cam: self.image.set_rotation(900) # internal camera is rotated 90 degrees + # Apply saved camera settings, only for internal camera for now: + self.apply_camera_settings(self.scanqr_prefs if self.scanqr_mode else self.prefs, self.cam, self.use_webcam) # needs to be done AFTER the camera is initialized else: print("camera app: no internal camera found, trying webcam on /dev/video0") try: - self.cam = webcam.init("/dev/video0") + # Initialize webcam with desired resolution directly + print(f"Initializing webcam at {self.width}x{self.height}") + self.cam = webcam.init("/dev/video0", width=self.width, height=self.height) self.use_webcam = True except Exception as e: print(f"camera app: webcam exception: {e}") + # Start refreshing: if self.cam: print("Camera app initialized, continuing...") - self.set_image_size() + self.update_preview_image() self.capture_timer = lv.timer_create(self.try_capture, 100, None) - self.status_label_cont.add_flag(lv.obj.FLAG.HIDDEN) - if self.scanqr_mode: - self.start_qr_decoding() - else: - self.qr_button.remove_flag(lv.obj.FLAG.HIDDEN) - self.snap_button.remove_flag(lv.obj.FLAG.HIDDEN) - else: - print("No camera found, stopping camera app") - if self.scanqr_mode: - self.finish() - - def onStop(self, screen): - print("camera app backgrounded, cleaning up...") + def stop_cam(self): if self.capture_timer: self.capture_timer.delete() if self.use_webcam: @@ -151,15 +178,58 @@ def onStop(self, screen): i2c.writeto(camera_addr, bytes([reg_high, reg_low, power_off_command])) except Exception as e: print(f"Warning: powering off camera got exception: {e}") - print("camera app cleanup done.") + self.cam = None + if self.image_dsc: # it's important to delete the image when stopping the camera, otherwise LVGL might try to display it and crash + print("emptying self.current_cam_buffer...") + self.image_dsc.data = None + + def load_settings_cached(self): + from mpos.config import SharedPreferences + if self.scanqr_mode: + print("loading scanqr settings...") + if not self.scanqr_prefs: + # Merge common and scanqr-specific defaults + scanqr_defaults = {} + scanqr_defaults.update(CameraSettingsActivity.COMMON_DEFAULTS) + scanqr_defaults.update(CameraSettingsActivity.SCANQR_DEFAULTS) + self.scanqr_prefs = SharedPreferences( + self.PACKAGE, + filename=self.SCANQR_CONFIG, + defaults=scanqr_defaults + ) + # Defaults come from constructor, no need to pass them here + self.width = self.scanqr_prefs.get_int("resolution_width") + self.height = self.scanqr_prefs.get_int("resolution_height") + self.colormode = self.scanqr_prefs.get_bool("colormode") + else: + if not self.prefs: + # Merge common and normal-specific defaults + normal_defaults = {} + normal_defaults.update(CameraSettingsActivity.COMMON_DEFAULTS) + normal_defaults.update(CameraSettingsActivity.NORMAL_DEFAULTS) + self.prefs = SharedPreferences(self.PACKAGE, defaults=normal_defaults) + # Defaults come from constructor, no need to pass them here + self.width = self.prefs.get_int("resolution_width") + self.height = self.prefs.get_int("resolution_height") + self.colormode = self.prefs.get_bool("colormode") - def set_image_size(self): + def update_preview_image(self): + self.image_dsc = lv.image_dsc_t({ + "header": { + "magic": lv.IMAGE_HEADER_MAGIC, + "w": self.width, + "h": self.height, + "stride": self.width * (2 if self.colormode else 1), + "cf": lv.COLOR_FORMAT.RGB565 if self.colormode else lv.COLOR_FORMAT.L8 + }, + 'data_size': self.width * self.height * (2 if self.colormode else 1), + 'data': None # Will be updated per frame + }) + self.image.set_src(self.image_dsc) disp = lv.display_get_default() target_h = disp.get_vertical_resolution() - target_w = target_h - if target_w == self.width and target_h == self.height: - print("Target width and height are the same as native image, no scaling required.") - return + #target_w = disp.get_horizontal_resolution() - self.button_width - 5 # leave 5px for border + target_w = target_h # square print(f"scaling to size: {target_w}x{target_h}") scale_factor_w = round(target_w * 256 / self.width) scale_factor_h = round(target_h * 256 / self.height) @@ -170,141 +240,371 @@ def set_image_size(self): def qrdecode_one(self): try: + result = None + before = time.ticks_ms() import qrdecode - import utime - before = utime.ticks_ms() - result = qrdecode.qrdecode_rgb565(self.current_cam_buffer, self.width, self.height) - after = utime.ticks_ms() - #result = bytearray("INSERT_QR_HERE", "utf-8") - if not result: - self.status_label.set_text(self.status_label_text_searching) + if self.colormode: + result = qrdecode.qrdecode_rgb565(self.current_cam_buffer, self.width, self.height) else: - print(f"SUCCESSFUL QR DECODE TOOK: {after-before}ms") - result = remove_bom(result) - result = print_qr_buffer(result) - print(f"QR decoding found: {result}") - if self.scanqr_mode: - self.setResult(True, result) - self.finish() - else: - self.status_label.set_text(result) # in the future, the status_label text should be copy-paste-able - self.stop_qr_decoding() + result = qrdecode.qrdecode(self.current_cam_buffer, self.width, self.height) + after = time.ticks_ms() + print(f"qrdecode took {after-before}ms") except ValueError as e: print("QR ValueError: ", e) - self.status_label.set_text(self.status_label_text_searching) + self.status_label.set_text(self.STATUS_SEARCHING_QR) except TypeError as e: print("QR TypeError: ", e) - self.status_label.set_text(self.status_label_text_found) + self.status_label.set_text(self.STATUS_FOUND_QR) except Exception as e: print("QR got other error: ", e) + #result = bytearray("INSERT_TEST_QR_DATA_HERE", "utf-8") + if result is None: + return + result = self.remove_bom(result) + result = self.print_qr_buffer(result) + print(f"QR decoding found: {result}") + self.stop_qr_decoding() + if self.scanqr_intent: + self.setResult(True, result) + self.finish() + else: + self.status_label.set_text(result) # in the future, the status_label text should be copy-paste-able def snap_button_click(self, e): - print("Picture taken!") + print("Taking picture...") + # Would be nice to check that there's enough free space here, and show an error if not... import os + path = "data/images" try: os.mkdir("data") except OSError: pass try: - os.mkdir("data/images") + os.mkdir(path) except OSError: pass - if self.current_cam_buffer is not None: - filename=f"data/images/camera_capture_{mpos.time.epoch_seconds()}_{self.width}x{self.height}_RGB565.raw" - try: - with open(filename, 'wb') as f: - f.write(self.current_cam_buffer) - print(f"Successfully wrote current_cam_buffer to {filename}") - except OSError as e: - print(f"Error writing to file: {e}") + if self.current_cam_buffer is None: + print("snap_button_click: won't save empty image") + return + # Check enough free space? + stat = os.statvfs("data/images") + free_space = stat[0] * stat[3] + size_needed = len(self.current_cam_buffer) + print(f"Free space {free_space} and size needed {size_needed}") + if free_space < size_needed: + self.status_label.set_text(f"Free storage space is {free_space}, need {size_needed}, not saving...") + self.status_label_cont.remove_flag(lv.obj.FLAG.HIDDEN) + return + colorname = "RGB565" if self.colormode else "GRAY" + filename=f"{path}/picture_{mpos.time.epoch_seconds()}_{self.width}x{self.height}_{colorname}.raw" + try: + with open(filename, 'wb') as f: + f.write(self.current_cam_buffer) # This takes around 17 seconds to store 921600 bytes, so ~50KB/s, so would be nice to show some progress bar + report = f"Successfully wrote image to {filename}" + print(report) + self.status_label.set_text(report) + self.status_label_cont.remove_flag(lv.obj.FLAG.HIDDEN) + except OSError as e: + print(f"Error writing to file: {e}") def start_qr_decoding(self): print("Activating live QR decoding...") - self.keepliveqrdecoding = True + self.scanqr_mode = True + oldwidth = self.width + oldheight = self.height + oldcolormode = self.colormode + # Activate QR mode settings + self.load_settings_cached() + # Check if it's necessary to restart the camera: + if not self.cam or self.width != oldwidth or self.height != oldheight or self.colormode != oldcolormode: + if self.cam: + self.stop_cam() + self.start_cam() self.qr_label.set_text(lv.SYMBOL.EYE_CLOSE) self.status_label_cont.remove_flag(lv.obj.FLAG.HIDDEN) - self.status_label.set_text(self.status_label_text_searching) + self.status_label.set_text(self.STATUS_SEARCHING_QR) def stop_qr_decoding(self): print("Deactivating live QR decoding...") - self.keepliveqrdecoding = False + self.scanqr_mode = False self.qr_label.set_text(lv.SYMBOL.EYE_OPEN) - self.status_label_text = self.status_label.get_text() - if self.status_label_text in (self.status_label_text_searching or self.status_label_text_found): # if it found a QR code, leave it + status_label_text = self.status_label.get_text() + if status_label_text in (self.STATUS_NO_CAMERA, self.STATUS_SEARCHING_QR, self.STATUS_FOUND_QR): # if it found a QR code, leave it self.status_label_cont.add_flag(lv.obj.FLAG.HIDDEN) + # Check if it's necessary to restart the camera: + oldwidth = self.width + oldheight = self.height + oldcolormode = self.colormode + # Activate non-QR mode settings + self.load_settings_cached() + # Check if it's necessary to restart the camera: + if self.width != oldwidth or self.height != oldheight or self.colormode != oldcolormode: + self.stop_cam() + self.start_cam() def qr_button_click(self, e): - if not self.keepliveqrdecoding: + if not self.scanqr_mode: self.start_qr_decoding() else: self.stop_qr_decoding() - + + def open_settings(self): + intent = Intent(activity_class=CameraSettingsActivity, extras={"prefs": self.prefs if not self.scanqr_mode else self.scanqr_prefs, "use_webcam": self.use_webcam, "scanqr_mode": self.scanqr_mode}) + self.startActivity(intent) + def try_capture(self, event): - #print("capturing camera frame") try: - if self.use_webcam: - self.current_cam_buffer = webcam.capture_frame(self.cam, "rgb565") - elif self.cam.frame_available(): + if self.use_webcam and self.cam: + self.current_cam_buffer = webcam.capture_frame(self.cam, "rgb565" if self.colormode else "grayscale") + elif self.cam and self.cam.frame_available(): self.current_cam_buffer = self.cam.capture() - if self.current_cam_buffer and len(self.current_cam_buffer): - self.image_dsc.data = self.current_cam_buffer - #image.invalidate() # does not work so do this: - self.image.set_src(self.image_dsc) - if not self.use_webcam: - self.cam.free_buffer() # Free the old buffer - if self.keepliveqrdecoding: - self.qrdecode_one() except Exception as e: print(f"Camera capture exception: {e}") + return + # Display the image: + self.image_dsc.data = self.current_cam_buffer + #self.image.invalidate() # does not work so do this: + self.image.set_src(self.image_dsc) + if self.scanqr_mode: + self.qrdecode_one() + if not self.use_webcam and self.cam: + self.cam.free_buffer() # After QR decoding, free the old buffer, otherwise the camera doesn't provide a new one + + def init_internal_cam(self, width, height): + """Initialize internal camera with specified resolution. + + Automatically retries once if initialization fails (to handle I2C poweroff issue). + """ + try: + from camera import Camera, GrabMode, PixelFormat, FrameSize, GainCeiling + + # Map resolution to FrameSize enum + # Format: (width, height): FrameSize + resolution_map = { + (96, 96): FrameSize.R96X96, + (160, 120): FrameSize.QQVGA, + (128, 128): FrameSize.R128X128, + (176, 144): FrameSize.QCIF, + (240, 176): FrameSize.HQVGA, + (240, 240): FrameSize.R240X240, + (320, 240): FrameSize.QVGA, + (320, 320): FrameSize.R320X320, + (400, 296): FrameSize.CIF, + (480, 320): FrameSize.HVGA, + (480, 480): FrameSize.R480X480, + (640, 480): FrameSize.VGA, + (640, 640): FrameSize.R640X640, + (720, 720): FrameSize.R720X720, + (800, 600): FrameSize.SVGA, + (800, 800): FrameSize.R800X800, + (960, 960): FrameSize.R960X960, + (1024, 768): FrameSize.XGA, + (1024,1024): FrameSize.R1024X1024, + (1280, 720): FrameSize.HD, + (1280, 1024): FrameSize.SXGA, + (1280, 1280): FrameSize.R1280X1280, + (1600, 1200): FrameSize.UXGA, + (1920, 1080): FrameSize.FHD, + } + + frame_size = resolution_map.get((width, height), FrameSize.QVGA) + print(f"init_internal_cam: Using FrameSize for {width}x{height}") + + # Try to initialize, with one retry for I2C poweroff issue + max_attempts = 3 + for attempt in range(max_attempts): + try: + cam = Camera( + data_pins=[12,13,15,11,14,10,7,2], + vsync_pin=6, + href_pin=4, + sda_pin=21, + scl_pin=16, + pclk_pin=9, + xclk_pin=8, + xclk_freq=20000000, + powerdown_pin=-1, + reset_pin=-1, + pixel_format=PixelFormat.RGB565 if self.colormode else PixelFormat.GRAYSCALE, + frame_size=frame_size, + #grab_mode=GrabMode.WHEN_EMPTY, + grab_mode=GrabMode.LATEST, + fb_count=1 + ) + cam.set_vflip(True) + return cam + except Exception as e: + if attempt < max_attempts-1: + print(f"init_cam attempt {attempt} failed: {e}, retrying...") + else: + print(f"init_cam final exception: {e}") + return None + except Exception as e: + print(f"init_cam exception: {e}") + return None + + def print_qr_buffer(self, buffer): + try: + # Try to decode buffer as a UTF-8 string + result = buffer.decode('utf-8') + # Check if the string is printable (ASCII printable characters) + if all(32 <= ord(c) <= 126 for c in result): + return result + except Exception as e: + pass + # If not a valid string or not printable, convert to hex + hex_str = ' '.join([f'{b:02x}' for b in buffer]) + return hex_str.lower() + + # Byte-Order-Mark is added sometimes + def remove_bom(self, buffer): + bom = b'\xEF\xBB\xBF' + if buffer.startswith(bom): + return buffer[3:] + return buffer + + + def apply_camera_settings(self, prefs, cam, use_webcam): + """Apply all saved camera settings to the camera. + + Only applies settings when use_webcam is False (ESP32 camera). + Settings are applied in dependency order (master switches before dependent values). + + Args: + cam: Camera object + use_webcam: Boolean indicating if using webcam + """ + if not cam or use_webcam: + print("apply_camera_settings: Skipping (no camera or webcam mode)") + return + + try: + # Basic image adjustments + brightness = prefs.get_int("brightness") + cam.set_brightness(brightness) + + contrast = prefs.get_int("contrast") + cam.set_contrast(contrast) + + saturation = prefs.get_int("saturation") + cam.set_saturation(saturation) + + # Orientation + hmirror = prefs.get_bool("hmirror") + cam.set_hmirror(hmirror) + vflip = prefs.get_bool("vflip") + cam.set_vflip(vflip) -# Non-class functions: -def init_internal_cam(): - try: - from camera import Camera, GrabMode, PixelFormat, FrameSize, GainCeiling - cam = Camera( - data_pins=[12,13,15,11,14,10,7,2], - vsync_pin=6, - href_pin=4, - sda_pin=21, - scl_pin=16, - pclk_pin=9, - xclk_pin=8, - xclk_freq=20000000, - powerdown_pin=-1, - reset_pin=-1, - pixel_format=PixelFormat.RGB565, - #pixel_format=PixelFormat.GRAYSCALE, - frame_size=FrameSize.R240X240, - grab_mode=GrabMode.LATEST - ) - #cam.init() automatically done when creating the Camera() - #cam.reconfigure(frame_size=FrameSize.HVGA) - #frame_size=FrameSize.HVGA, # 480x320 - #frame_size=FrameSize.QVGA, # 320x240 - #frame_size=FrameSize.QQVGA # 160x120 - cam.set_vflip(True) - return cam - except Exception as e: - print(f"init_cam exception: {e}") - return None - -def print_qr_buffer(buffer): - try: - # Try to decode buffer as a UTF-8 string - result = buffer.decode('utf-8') - # Check if the string is printable (ASCII printable characters) - if all(32 <= ord(c) <= 126 for c in result): - return result - except Exception as e: - pass - # If not a valid string or not printable, convert to hex - hex_str = ' '.join([f'{b:02x}' for b in buffer]) - return hex_str.lower() - -# Byte-Order-Mark is added sometimes -def remove_bom(buffer): - bom = b'\xEF\xBB\xBF' - if buffer.startswith(bom): - return buffer[3:] - return buffer + # Special effect + special_effect = prefs.get_int("special_effect") + cam.set_special_effect(special_effect) + + # Exposure control (apply master switch first, then manual value) + exposure_ctrl = prefs.get_bool("exposure_ctrl") + cam.set_exposure_ctrl(exposure_ctrl) + + if not exposure_ctrl: + aec_value = prefs.get_int("aec_value") + cam.set_aec_value(aec_value) + + # Mode-specific default comes from constructor + ae_level = prefs.get_int("ae_level") + cam.set_ae_level(ae_level) + + aec2 = prefs.get_bool("aec2") + cam.set_aec2(aec2) + + # Gain control (apply master switch first, then manual value) + gain_ctrl = prefs.get_bool("gain_ctrl") + cam.set_gain_ctrl(gain_ctrl) + + if not gain_ctrl: + agc_gain = prefs.get_int("agc_gain") + cam.set_agc_gain(agc_gain) + + gainceiling = prefs.get_int("gainceiling") + cam.set_gainceiling(gainceiling) + + # White balance (apply master switch first, then mode) + whitebal = prefs.get_bool("whitebal") + cam.set_whitebal(whitebal) + + if not whitebal: + wb_mode = prefs.get_int("wb_mode") + cam.set_wb_mode(wb_mode) + + awb_gain = prefs.get_bool("awb_gain") + cam.set_awb_gain(awb_gain) + + # Sensor-specific settings (try/except for unsupported sensors) + try: + sharpness = prefs.get_int("sharpness") + cam.set_sharpness(sharpness) + except: + pass # Not supported on OV2640? + + try: + denoise = prefs.get_int("denoise") + cam.set_denoise(denoise) + except: + pass # Not supported on OV2640? + + # Advanced corrections + colorbar = prefs.get_bool("colorbar") + cam.set_colorbar(colorbar) + + dcw = prefs.get_bool("dcw") + cam.set_dcw(dcw) + + bpc = prefs.get_bool("bpc") + cam.set_bpc(bpc) + + wpc = prefs.get_bool("wpc") + cam.set_wpc(wpc) + + # Mode-specific default comes from constructor + raw_gma = prefs.get_bool("raw_gma") + print(f"applying raw_gma: {raw_gma}") + cam.set_raw_gma(raw_gma) + + lenc = prefs.get_bool("lenc") + cam.set_lenc(lenc) + + # JPEG quality (only relevant for JPEG format) + #try: + # quality = prefs.get_int("quality", 85) + # cam.set_quality(quality) + #except: + # pass # Not in JPEG mode + + print("Camera settings applied successfully") + + except Exception as e: + print(f"Error applying camera settings: {e}") + + + + +""" + def zoom_button_click_unused(self, e): + print("zooming...") + if self.use_webcam: + print("zoom_button_click is not supported for webcam") + return + if self.cam: + startX = self.prefs.get_int("startX", CameraSettingsActivity.startX_default) + startY = self.prefs.get_int("startX", CameraSettingsActivity.startY_default) + endX = self.prefs.get_int("startX", CameraSettingsActivity.endX_default) + endY = self.prefs.get_int("startX", CameraSettingsActivity.endY_default) + offsetX = self.prefs.get_int("startX", CameraSettingsActivity.offsetX_default) + offsetY = self.prefs.get_int("startX", CameraSettingsActivity.offsetY_default) + totalX = self.prefs.get_int("startX", CameraSettingsActivity.totalX_default) + totalY = self.prefs.get_int("startX", CameraSettingsActivity.totalY_default) + outputX = self.prefs.get_int("startX", CameraSettingsActivity.outputX_default) + outputY = self.prefs.get_int("startX", CameraSettingsActivity.outputY_default) + scale = self.prefs.get_bool("scale", CameraSettingsActivity.scale_default) + binning = self.prefs.get_bool("binning", CameraSettingsActivity.binning_default) + result = self.cam.set_res_raw(startX,startY,endX,endY,offsetX,offsetY,totalX,totalY,outputX,outputY,scale,binning) + print(f"self.cam.set_res_raw returned {result}") +""" diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py new file mode 100644 index 00000000..8bf90ecc --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py @@ -0,0 +1,604 @@ +import lvgl as lv + +import mpos.ui +from mpos.apps import Activity +from mpos.config import SharedPreferences +from mpos.content.intent import Intent + +class CameraSettingsActivity(Activity): + + # Original: { 2560, 1920, 0, 0, 2623, 1951, 32, 16, 2844, 1968 } + # Worked for digital zoom in C: { 2560, 1920, 0, 0, 2623, 1951, 992, 736, 2844, 1968 } + startX_default=0 + startY_default=0 + endX_default=2623 + endY_default=1951 + offsetX_default=32 + offsetY_default=16 + totalX_default=2844 + totalY_default=1968 + outputX_default=640 + outputY_default=480 + scale_default=False + binning_default=False + + # Common defaults shared by both normal and scanqr modes (25 settings) + COMMON_DEFAULTS = { + # Basic image adjustments + "brightness": 0, + "contrast": 0, + "saturation": 0, + # Orientation + "hmirror": False, + "vflip": True, + # Visual effects + "special_effect": 0, + # Exposure control + "exposure_ctrl": True, + "aec_value": 300, + "aec2": False, + # Gain control + "gain_ctrl": True, + "agc_gain": 0, + "gainceiling": 0, + # White balance + "whitebal": True, + "wb_mode": 0, + "awb_gain": True, + # Sensor-specific + "sharpness": 0, + "denoise": 0, + # Advanced corrections + "colorbar": False, + "dcw": True, + "bpc": False, + "wpc": True, + "lenc": True, + } + + # Normal mode specific defaults + NORMAL_DEFAULTS = { + "resolution_width": 240, + "resolution_height": 240, + "colormode": True, + "ae_level": 0, + "raw_gma": True, + } + + # Scanqr mode specific defaults + SCANQR_DEFAULTS = { + "resolution_width": 960, + "resolution_height": 960, + "colormode": False, + "ae_level": 2, # Higher auto-exposure compensation + "raw_gma": False, # Disable raw gamma for better contrast + } + + # Resolution options for both ESP32 and webcam + # Webcam supports all ESP32 resolutions via automatic cropping/padding + RESOLUTIONS = [ + ("96x96", "96x96"), + ("160x120", "160x120"), + ("128x128", "128x128"), + ("176x144", "176x144"), + ("240x176", "240x176"), + ("240x240", "240x240"), + ("320x240", "320x240"), + ("320x320", "320x320"), + ("400x296", "400x296"), + ("480x320", "480x320"), + ("480x480", "480x480"), + ("640x480", "640x480"), + ("640x640", "640x640"), + ("720x720", "720x720"), + ("800x600", "800x600"), + ("800x800", "800x800"), + ("960x960", "960x960"), + ("1024x768", "1024x768"), + ("1024x1024","1024x1024"), + ("1280x720", "1280x720"), + ("1280x1024", "1280x1024"), + ("1280x1280", "1280x1280"), + ("1600x1200", "1600x1200"), + ("1920x1080", "1920x1080"), + ] + + # These are taken from the Intent: + use_webcam = False + prefs = None + scanqr_mode = False + + # Widgets: + button_cont = None + + def __init__(self): + super().__init__() + self.ui_controls = {} + self.control_metadata = {} # Store pref_key and option_values for each control + self.dependent_controls = {} + + def onCreate(self): + self.use_webcam = self.getIntent().extras.get("use_webcam") + self.prefs = self.getIntent().extras.get("prefs") + self.scanqr_mode = self.getIntent().extras.get("scanqr_mode") + + # Create main screen + screen = lv.obj() + screen.set_size(lv.pct(100), lv.pct(100)) + screen.set_style_pad_all(1, 0) + + # Create tabview + tabview = lv.tabview(screen) + tabview.set_tab_bar_size(mpos.ui.pct_of_display_height(15)) + #tabview.set_size(lv.pct(100), mpos.ui.pct_of_display_height(80)) + + # Create Basic tab (always) + basic_tab = tabview.add_tab("Basic") + self.create_basic_tab(basic_tab, self.prefs) + + # Create Advanced and Expert tabs only for ESP32 camera + if not self.use_webcam or True: # for now, show all tabs + advanced_tab = tabview.add_tab("Advanced") + self.create_advanced_tab(advanced_tab, self.prefs) + + expert_tab = tabview.add_tab("Expert") + self.create_expert_tab(expert_tab, self.prefs) + + #raw_tab = tabview.add_tab("Raw") + #self.create_raw_tab(raw_tab, self.prefs) + + self.setContentView(screen) + + def create_slider(self, parent, label_text, min_val, max_val, default_val, pref_key): + """Create slider with label showing current value.""" + cont = lv.obj(parent) + cont.set_size(lv.pct(100), 60) + cont.set_style_pad_all(3, 0) + + label = lv.label(cont) + label.set_text(f"{label_text}: {default_val}") + label.align(lv.ALIGN.TOP_LEFT, 0, 0) + + slider = lv.slider(cont) + slider.set_size(lv.pct(90), 15) + slider.set_range(min_val, max_val) + slider.set_value(default_val, False) + slider.align(lv.ALIGN.BOTTOM_MID, 0, -10) + + def slider_changed(e): + val = slider.get_value() + label.set_text(f"{label_text}: {val}") + + slider.add_event_cb(slider_changed, lv.EVENT.VALUE_CHANGED, None) + + return slider, label, cont + + def create_checkbox(self, parent, label_text, default_val, pref_key): + """Create checkbox with label.""" + cont = lv.obj(parent) + cont.set_size(lv.pct(100), 35) + cont.set_style_pad_all(3, 0) + + checkbox = lv.checkbox(cont) + checkbox.set_text(label_text) + if default_val: + checkbox.add_state(lv.STATE.CHECKED) + checkbox.align(lv.ALIGN.LEFT_MID, 0, 0) + + return checkbox, cont + + def create_dropdown(self, parent, label_text, options, default_idx, pref_key): + """Create dropdown with label.""" + cont = lv.obj(parent) + cont.set_size(lv.pct(100), lv.SIZE_CONTENT) + cont.set_style_pad_all(2, 0) + + label = lv.label(cont) + label.set_text(label_text) + label.set_size(lv.pct(50), lv.SIZE_CONTENT) + label.align(lv.ALIGN.LEFT_MID, 0, 0) + + dropdown = lv.dropdown(cont) + dropdown.set_size(lv.pct(50), lv.SIZE_CONTENT) + dropdown.align(lv.ALIGN.RIGHT_MID, 0, 0) + + options_str = "\n".join([text for text, _ in options]) + dropdown.set_options(options_str) + dropdown.set_selected(default_idx) + + # Store metadata separately + option_values = [val for _, val in options] + self.control_metadata[id(dropdown)] = { + "pref_key": pref_key, + "type": "dropdown", + "option_values": option_values + } + + return dropdown, cont + + def create_textarea(self, parent, label_text, min_val, max_val, default_val, pref_key): + cont = lv.obj(parent) + cont.set_size(lv.pct(100), lv.SIZE_CONTENT) + cont.set_style_pad_all(3, 0) + + label = lv.label(cont) + label.set_text(f"{label_text}:") + label.align(lv.ALIGN.TOP_LEFT, 0, 0) + + textarea = lv.textarea(cont) + textarea.set_width(lv.pct(50)) + textarea.set_one_line(True) # might not be good for all settings but it's good for most + textarea.set_text(str(default_val)) + textarea.align(lv.ALIGN.TOP_RIGHT, 0, 0) + + # Initialize keyboard (hidden initially) + from mpos.ui.keyboard import MposKeyboard + keyboard = MposKeyboard(parent) + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + keyboard.add_flag(lv.obj.FLAG.HIDDEN) + keyboard.set_textarea(textarea) + + return textarea, cont + + def add_buttons(self, parent): + # Save/Cancel buttons at bottom + button_cont = lv.obj(parent) + button_cont.set_size(lv.pct(100), mpos.ui.pct_of_display_height(20)) + button_cont.remove_flag(lv.obj.FLAG.SCROLLABLE) + button_cont.align(lv.ALIGN.BOTTOM_MID, 0, 0) + button_cont.set_style_border_width(0, 0) + + save_button = lv.button(button_cont) + save_button.set_size(lv.SIZE_CONTENT, lv.SIZE_CONTENT) + save_button.align(lv.ALIGN.BOTTOM_LEFT, 0, 0) + save_button.add_event_cb(lambda e: self.save_and_close(), lv.EVENT.CLICKED, None) + save_label = lv.label(save_button) + savetext = "Save" + if self.scanqr_mode: + savetext += " QR tweaks" + save_label.set_text(savetext) + save_label.center() + + cancel_button = lv.button(button_cont) + cancel_button.set_size(mpos.ui.pct_of_display_width(25), lv.SIZE_CONTENT) + if self.scanqr_mode: + cancel_button.align(lv.ALIGN.BOTTOM_MID, mpos.ui.pct_of_display_width(10), 0) + else: + cancel_button.align(lv.ALIGN.BOTTOM_MID, 0, 0) + cancel_button.add_event_cb(lambda e: self.finish(), lv.EVENT.CLICKED, None) + cancel_label = lv.label(cancel_button) + cancel_label.set_text("Cancel") + cancel_label.center() + + erase_button = lv.button(button_cont) + erase_button.set_size(mpos.ui.pct_of_display_width(20), lv.SIZE_CONTENT) + erase_button.align(lv.ALIGN.BOTTOM_RIGHT, 0, 0) + erase_button.add_event_cb(lambda e: self.erase_and_close(), lv.EVENT.CLICKED, None) + erase_label = lv.label(erase_button) + erase_label.set_text("Erase") + erase_label.center() + + + def create_basic_tab(self, tab, prefs): + """Create Basic settings tab.""" + tab.set_flex_flow(lv.FLEX_FLOW.COLUMN) + #tab.set_scrollbar_mode(lv.SCROLLBAR_MODE.AUTO) + tab.set_style_pad_all(1, 0) + + # Color Mode + colormode = prefs.get_bool("colormode") + checkbox, cont = self.create_checkbox(tab, "Color Mode (slower)", colormode, "colormode") + self.ui_controls["colormode"] = checkbox + + # Resolution dropdown + print(f"self.scanqr_mode: {self.scanqr_mode}") + current_resolution_width = prefs.get_int("resolution_width") + current_resolution_height = prefs.get_int("resolution_height") + dropdown_value = f"{current_resolution_width}x{current_resolution_height}" + print(f"looking for {dropdown_value}") + resolution_idx = 0 + for idx, (_, value) in enumerate(self.RESOLUTIONS): + print(f"got {value}") + if value == dropdown_value: + resolution_idx = idx + print(f"found it! {idx}") + break + + dropdown, cont = self.create_dropdown(tab, "Resolution:", self.RESOLUTIONS, resolution_idx, "resolution") + self.ui_controls["resolution"] = dropdown + + # Brightness + brightness = prefs.get_int("brightness") + slider, label, cont = self.create_slider(tab, "Brightness", -2, 2, brightness, "brightness") + self.ui_controls["brightness"] = slider + + # Contrast + contrast = prefs.get_int("contrast") + slider, label, cont = self.create_slider(tab, "Contrast", -2, 2, contrast, "contrast") + self.ui_controls["contrast"] = slider + + # Saturation + saturation = prefs.get_int("saturation") + slider, label, cont = self.create_slider(tab, "Saturation", -2, 2, saturation, "saturation") + self.ui_controls["saturation"] = slider + + # Horizontal Mirror + hmirror = prefs.get_bool("hmirror") + checkbox, cont = self.create_checkbox(tab, "Horizontal Mirror", hmirror, "hmirror") + self.ui_controls["hmirror"] = checkbox + + # Vertical Flip + vflip = prefs.get_bool("vflip") + checkbox, cont = self.create_checkbox(tab, "Vertical Flip", vflip, "vflip") + self.ui_controls["vflip"] = checkbox + + self.add_buttons(tab) + + def create_advanced_tab(self, tab, prefs): + """Create Advanced settings tab.""" + tab.set_flex_flow(lv.FLEX_FLOW.COLUMN) + tab.set_style_pad_all(1, 0) + + # Auto Exposure Control (master switch) + exposure_ctrl = prefs.get_bool("exposure_ctrl") + aec_checkbox, cont = self.create_checkbox(tab, "Auto Exposure", exposure_ctrl, "exposure_ctrl") + self.ui_controls["exposure_ctrl"] = aec_checkbox + + # Manual Exposure Value (dependent) + aec_value = prefs.get_int("aec_value") + me_slider, label, me_cont = self.create_slider(tab, "Manual Exposure", 0, 1200, aec_value, "aec_value") + self.ui_controls["aec_value"] = me_slider + + # Auto Exposure Level (dependent) + ae_level = prefs.get_int("ae_level") + ae_slider, label, ae_cont = self.create_slider(tab, "Auto Exposure Level", -2, 2, ae_level, "ae_level") + self.ui_controls["ae_level"] = ae_slider + + # Add dependency handler + def exposure_ctrl_changed(e=None): + is_auto = aec_checkbox.get_state() & lv.STATE.CHECKED + if is_auto: + mpos.ui.anim.smooth_hide(me_cont, duration=1000) + mpos.ui.anim.smooth_show(ae_cont, delay=1000) + else: + mpos.ui.anim.smooth_hide(ae_cont, duration=1000) + mpos.ui.anim.smooth_show(me_cont, delay=1000) + + aec_checkbox.add_event_cb(exposure_ctrl_changed, lv.EVENT.VALUE_CHANGED, None) + exposure_ctrl_changed() + + # Night Mode (AEC2) + aec2 = prefs.get_bool("aec2") + checkbox, cont = self.create_checkbox(tab, "Night Mode (AEC2)", aec2, "aec2") + self.ui_controls["aec2"] = checkbox + + # Auto Gain Control (master switch) + gain_ctrl = prefs.get_bool("gain_ctrl") + agc_checkbox, cont = self.create_checkbox(tab, "Auto Gain", gain_ctrl, "gain_ctrl") + self.ui_controls["gain_ctrl"] = agc_checkbox + + # Manual Gain Value (dependent) + agc_gain = prefs.get_int("agc_gain") + slider, label, agc_cont = self.create_slider(tab, "Manual Gain", 0, 30, agc_gain, "agc_gain") + self.ui_controls["agc_gain"] = slider + + def gain_ctrl_changed(e=None): + is_auto = agc_checkbox.get_state() & lv.STATE.CHECKED + gain_slider = self.ui_controls["agc_gain"] + if is_auto: + mpos.ui.anim.smooth_hide(agc_cont, duration=1000) + else: + mpos.ui.anim.smooth_show(agc_cont, duration=1000) + + agc_checkbox.add_event_cb(gain_ctrl_changed, lv.EVENT.VALUE_CHANGED, None) + gain_ctrl_changed() + + # Gain Ceiling + gainceiling_options = [ + ("2X", 0), ("4X", 1), ("8X", 2), ("16X", 3), + ("32X", 4), ("64X", 5), ("128X", 6) + ] + gainceiling = prefs.get_int("gainceiling") + dropdown, cont = self.create_dropdown(tab, "Gain Ceiling:", gainceiling_options, gainceiling, "gainceiling") + self.ui_controls["gainceiling"] = dropdown + + # Auto White Balance (master switch) + whitebal = prefs.get_bool("whitebal") + wbcheckbox, cont = self.create_checkbox(tab, "Auto White Balance", whitebal, "whitebal") + self.ui_controls["whitebal"] = wbcheckbox + + # White Balance Mode (dependent) + wb_mode_options = [ + ("Auto", 0), ("Sunny", 1), ("Cloudy", 2), ("Office", 3), ("Home", 4) + ] + wb_mode = prefs.get_int("wb_mode") + wb_dropdown, wb_cont = self.create_dropdown(tab, "WB Mode:", wb_mode_options, wb_mode, "wb_mode") + self.ui_controls["wb_mode"] = wb_dropdown + + def whitebal_changed(e=None): + is_auto = wbcheckbox.get_state() & lv.STATE.CHECKED + if is_auto: + mpos.ui.anim.smooth_hide(wb_cont, duration=1000) + else: + mpos.ui.anim.smooth_show(wb_cont, duration=1000) + wbcheckbox.add_event_cb(whitebal_changed, lv.EVENT.VALUE_CHANGED, None) + whitebal_changed() + + # AWB Gain + awb_gain = prefs.get_bool("awb_gain") + checkbox, cont = self.create_checkbox(tab, "AWB Gain", awb_gain, "awb_gain") + self.ui_controls["awb_gain"] = checkbox + + self.add_buttons(tab) + + # Special Effect + special_effect_options = [ + ("None", 0), ("Negative", 1), ("Grayscale", 2), + ("Reddish", 3), ("Greenish", 4), ("Blue", 5), ("Retro", 6) + ] + special_effect = prefs.get_int("special_effect") + dropdown, cont = self.create_dropdown(tab, "Special Effect:", special_effect_options, + special_effect, "special_effect") + self.ui_controls["special_effect"] = dropdown + + def create_expert_tab(self, tab, prefs): + """Create Expert settings tab.""" + #tab.set_scrollbar_mode(lv.SCROLLBAR_MODE.AUTO) + tab.set_flex_flow(lv.FLEX_FLOW.COLUMN) + tab.set_style_pad_all(1, 0) + + # Sharpness + sharpness = prefs.get_int("sharpness") + slider, label, cont = self.create_slider(tab, "Sharpness", -3, 3, sharpness, "sharpness") + self.ui_controls["sharpness"] = slider + + # Denoise + denoise = prefs.get_int("denoise") + slider, label, cont = self.create_slider(tab, "Denoise", 0, 8, denoise, "denoise") + self.ui_controls["denoise"] = slider + + # JPEG Quality + # Disabled because JPEG is not used right now + #quality = prefs.get_int("quality", 85) + #slider, label, cont = self.create_slider(tab, "JPEG Quality", 0, 100, quality, "quality") + #self.ui_controls["quality"] = slider + + # Color Bar + colorbar = prefs.get_bool("colorbar") + checkbox, cont = self.create_checkbox(tab, "Color Bar Test", colorbar, "colorbar") + self.ui_controls["colorbar"] = checkbox + + # DCW Mode + dcw = prefs.get_bool("dcw") + checkbox, cont = self.create_checkbox(tab, "Downsize Crop Window", dcw, "dcw") + self.ui_controls["dcw"] = checkbox + + # Black Point Compensation + bpc = prefs.get_bool("bpc") + checkbox, cont = self.create_checkbox(tab, "Black Point Compensation", bpc, "bpc") + self.ui_controls["bpc"] = checkbox + + # White Point Compensation + wpc = prefs.get_bool("wpc") + checkbox, cont = self.create_checkbox(tab, "White Point Compensation", wpc, "wpc") + self.ui_controls["wpc"] = checkbox + + # Raw Gamma Mode + raw_gma = prefs.get_bool("raw_gma") + checkbox, cont = self.create_checkbox(tab, "Raw Gamma Mode", raw_gma, "raw_gma") + self.ui_controls["raw_gma"] = checkbox + + # Lens Correction + lenc = prefs.get_bool("lenc") + checkbox, cont = self.create_checkbox(tab, "Lens Correction", lenc, "lenc") + self.ui_controls["lenc"] = checkbox + + self.add_buttons(tab) + + def create_raw_tab(self, tab, prefs): + tab.set_flex_flow(lv.FLEX_FLOW.COLUMN) + tab.set_style_pad_all(0, 0) + + # This would be nice but does not provide adequate resolution: + #startX, label, cont = self.create_slider(tab, "startX", 0, 2844, startX, "startX") + + startX = prefs.get_int("startX", self.startX_default) + textarea, cont = self.create_textarea(tab, "startX", 0, 2844, startX, "startX") + self.ui_controls["startX"] = textarea + + startY = prefs.get_int("startY", self.startY_default) + textarea, cont = self.create_textarea(tab, "startY", 0, 2844, startY, "startY") + self.ui_controls["startY"] = textarea + + endX = prefs.get_int("endX", self.endX_default) + textarea, cont = self.create_textarea(tab, "endX", 0, 2844, endX, "endX") + self.ui_controls["endX"] = textarea + + endY = prefs.get_int("endY", self.endY_default) + textarea, cont = self.create_textarea(tab, "endY", 0, 2844, endY, "endY") + self.ui_controls["endY"] = textarea + + offsetX = prefs.get_int("offsetX", self.offsetX_default) + textarea, cont = self.create_textarea(tab, "offsetX", 0, 2844, offsetX, "offsetX") + self.ui_controls["offsetX"] = textarea + + offsetY = prefs.get_int("offsetY", self.offsetY_default) + textarea, cont = self.create_textarea(tab, "offsetY", 0, 2844, offsetY, "offsetY") + self.ui_controls["offsetY"] = textarea + + totalX = prefs.get_int("totalX", self.totalX_default) + textarea, cont = self.create_textarea(tab, "totalX", 0, 2844, totalX, "totalX") + self.ui_controls["totalX"] = textarea + + totalY = prefs.get_int("totalY", self.totalY_default) + textarea, cont = self.create_textarea(tab, "totalY", 0, 2844, totalY, "totalY") + self.ui_controls["totalY"] = textarea + + outputX = prefs.get_int("outputX", self.outputX_default) + textarea, cont = self.create_textarea(tab, "outputX", 0, 2844, outputX, "outputX") + self.ui_controls["outputX"] = textarea + + outputY = prefs.get_int("outputY", self.outputY_default) + textarea, cont = self.create_textarea(tab, "outputY", 0, 2844, outputY, "outputY") + self.ui_controls["outputY"] = textarea + + scale = prefs.get_bool("scale", self.scale_default) + checkbox, cont = self.create_checkbox(tab, "Scale?", scale, "scale") + self.ui_controls["scale"] = checkbox + + binning = prefs.get_bool("binning", self.binning_default) + checkbox, cont = self.create_checkbox(tab, "Binning?", binning, "binning") + self.ui_controls["binning"] = checkbox + + self.add_buttons(tab) + + def erase_and_close(self): + self.prefs.edit().remove_all().commit() + self.setResult(True, {"settings_changed": True}) + self.finish() + + def save_and_close(self): + """Save all settings to SharedPreferences and return result.""" + editor = self.prefs.edit() + + # Save all UI control values + for pref_key, control in self.ui_controls.items(): + print(f"saving {pref_key} with {control}") + control_id = id(control) + metadata = self.control_metadata.get(control_id, {}) + + if isinstance(control, lv.slider): + value = control.get_value() + editor.put_int(pref_key, value) + elif isinstance(control, lv.checkbox): + is_checked = control.get_state() & lv.STATE.CHECKED + editor.put_bool(pref_key, bool(is_checked)) + elif isinstance(control, lv.textarea): + try: + value = int(control.get_text()) + editor.put_int(pref_key, value) + except Exception as e: + print(f"Error while trying to save {pref_key}: {e}") + elif isinstance(control, lv.dropdown): + selected_idx = control.get_selected() + option_values = metadata.get("option_values", []) + if pref_key == "resolution": + try: + # Resolution stored as 2 ints + value = option_values[selected_idx] + width_str, height_str = value.split('x') + editor.put_int("resolution_width", int(width_str)) + editor.put_int("resolution_height", int(height_str)) + except Exception as e: + print(f"Error parsing resolution '{value}': {e}") + else: + # Other dropdowns store integer enum values + value = option_values[selected_idx] + editor.put_int(pref_key, value) + + editor.commit() + print("Camera settings saved") + + # Return success result + self.setResult(True, {"settings_changed": True}) + self.finish() diff --git a/internal_filesystem/apps/com.micropythonos.confetti/META-INF/MANIFEST.JSON b/internal_filesystem/apps/com.micropythonos.confetti/META-INF/MANIFEST.JSON new file mode 100644 index 00000000..a4f2363e --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.confetti/META-INF/MANIFEST.JSON @@ -0,0 +1,24 @@ +{ +"name": "Confetti", +"publisher": "MicroPythonOS", +"short_description": "Just shows confetti", +"long_description": "Nothing special, just a demo.", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.confetti/icons/com.micropythonos.confetti_0.0.2_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.confetti/mpks/com.micropythonos.confetti_0.0.2.mpk", +"fullname": "com.micropythonos.confetti", +"version": "0.0.2", +"category": "games", +"activities": [ + { + "entrypoint": "assets/confetti.py", + "classname": "Confetti", + "intent_filters": [ + { + "action": "main", + "category": "launcher" + } + ] + } + ] +} + diff --git a/internal_filesystem/apps/com.micropythonos.confetti/assets/confetti.py b/internal_filesystem/apps/com.micropythonos.confetti/assets/confetti.py new file mode 100644 index 00000000..5ec95d77 --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.confetti/assets/confetti.py @@ -0,0 +1,116 @@ +import time +import random +import lvgl as lv + +from mpos.apps import Activity, Intent +import mpos.config +import mpos.ui + +class Confetti(Activity): + # === CONFIG === + SCREEN_WIDTH = 320 + SCREEN_HEIGHT = 240 + ASSET_PATH = "M:apps/com.micropythonos.confetti/res/drawable-mdpi/" + MAX_CONFETTI = 21 + GRAVITY = 100 # pixels/sec² + + def onCreate(self): + print("Confetti Activity starting...") + + # Background + self.screen = lv.obj() + self.screen.set_style_bg_color(lv.color_hex(0x000033), 0) # Dark blue + self.screen.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) + self.screen.remove_flag(lv.obj.FLAG.SCROLLABLE) + + # Timing + self.last_time = time.ticks_ms() + + # Confetti state + self.confetti_pieces = [] + self.confetti_images = [] + self.used_img_indices = set() # Track which image slots are in use + + # Pre-create LVGL image objects + for i in range(self.MAX_CONFETTI): + img = lv.image(self.screen) + img.set_src(f"{self.ASSET_PATH}confetti{random.randint(1,3)}.png") + img.add_flag(lv.obj.FLAG.HIDDEN) + self.confetti_images.append(img) + + # Spawn initial confetti + for _ in range(self.MAX_CONFETTI): + self.spawn_confetti() + + self.setContentView(self.screen) + + def onResume(self, screen): + mpos.ui.task_handler.add_event_cb(self.update_frame, 1) + + def onPause(self, screen): + mpos.ui.task_handler.remove_event_cb(self.update_frame) + + def spawn_confetti(self): + """Safely spawn a new confetti piece with unique img_idx""" + # 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 + + piece = { + 'img_idx': idx, + 'x': random.uniform(-10, self.SCREEN_WIDTH + 10), + 'y': random.uniform(50, 150), + 'vx': random.uniform(-100, 100), + 'vy': random.uniform(-150, -80), + 'spin': random.uniform(-400, 400), + 'age': 0.0, + 'lifetime': random.uniform(1.8, 5), + 'rotation': random.uniform(0, 360), + 'scale': 1.0 + } + self.confetti_pieces.append(piece) + self.used_img_indices.add(idx) + + def update_frame(self, a, b): + current_time = time.ticks_ms() + delta_ms = time.ticks_diff(current_time, self.last_time) + delta_time = delta_ms / 1000.0 + self.last_time = current_time + + new_pieces = [] + + for piece in self.confetti_pieces: + # === UPDATE PHYSICS === + piece['age'] += delta_time + 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['scale'] = max(0.3, 1.0 - (piece['age'] / piece['lifetime']) * 0.7) + + # === UPDATE LVGL IMAGE === + 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)) # LVGL: 0.1 degrees + img.set_scale(int(256 * piece['scale']* 2)) # 256 = 100% + + # === CHECK IF DEAD === + off_screen = ( + piece['x'] < -60 or piece['x'] > self.SCREEN_WIDTH + 60 or + piece['y'] > self.SCREEN_HEIGHT + 60 + ) + too_old = piece['age'] > piece['lifetime'] + + if off_screen or too_old: + img.add_flag(lv.obj.FLAG.HIDDEN) + self.used_img_indices.discard(piece['img_idx']) + self.spawn_confetti() # Replace immediately + else: + new_pieces.append(piece) + + # === APPLY NEW LIST === + self.confetti_pieces = new_pieces diff --git a/internal_filesystem/apps/com.micropythonos.confetti/res/drawable-mdpi/confetti1.png b/internal_filesystem/apps/com.micropythonos.confetti/res/drawable-mdpi/confetti1.png new file mode 100644 index 00000000..2a9639ec Binary files /dev/null and b/internal_filesystem/apps/com.micropythonos.confetti/res/drawable-mdpi/confetti1.png differ diff --git a/internal_filesystem/apps/com.micropythonos.confetti/res/drawable-mdpi/confetti2.png b/internal_filesystem/apps/com.micropythonos.confetti/res/drawable-mdpi/confetti2.png new file mode 100644 index 00000000..7a7b65a3 Binary files /dev/null and b/internal_filesystem/apps/com.micropythonos.confetti/res/drawable-mdpi/confetti2.png differ diff --git a/internal_filesystem/apps/com.micropythonos.confetti/res/drawable-mdpi/confetti3.png b/internal_filesystem/apps/com.micropythonos.confetti/res/drawable-mdpi/confetti3.png new file mode 100644 index 00000000..a8d82147 Binary files /dev/null and b/internal_filesystem/apps/com.micropythonos.confetti/res/drawable-mdpi/confetti3.png differ diff --git a/internal_filesystem/apps/com.micropythonos.confetti/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/apps/com.micropythonos.confetti/res/mipmap-mdpi/icon_64x64.png new file mode 100644 index 00000000..71203859 Binary files /dev/null and b/internal_filesystem/apps/com.micropythonos.confetti/res/mipmap-mdpi/icon_64x64.png differ diff --git a/internal_filesystem/apps/com.micropythonos.connect4/META-INF/MANIFEST.JSON b/internal_filesystem/apps/com.micropythonos.connect4/META-INF/MANIFEST.JSON new file mode 100644 index 00000000..1da4896b --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.connect4/META-INF/MANIFEST.JSON @@ -0,0 +1,23 @@ +{ +"name": "Connect 4", +"publisher": "MicroPythonOS", +"short_description": "Classic Connect 4 game", +"long_description": "Play Connect 4 against the computer with three difficulty levels: Easy, Medium, and Hard. Drop colored discs and try to connect four in a row!", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.connect4/icons/com.micropythonos.connect4_0.0.1_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.connect4/mpks/com.micropythonos.connect4_0.0.1.mpk", +"fullname": "com.micropythonos.connect4", +"version": "0.0.1", +"category": "games", +"activities": [ + { + "entrypoint": "assets/connect4.py", + "classname": "Connect4", + "intent_filters": [ + { + "action": "main", + "category": "launcher" + } + ] + } + ] +} diff --git a/internal_filesystem/apps/com.micropythonos.connect4/assets/connect4.py b/internal_filesystem/apps/com.micropythonos.connect4/assets/connect4.py new file mode 100644 index 00000000..70c07559 --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.connect4/assets/connect4.py @@ -0,0 +1,483 @@ +import time +import random + +from mpos.apps import Activity +import mpos.ui + +try: + import lvgl as lv +except ImportError: + pass # lv is already available as a global in MicroPython OS + + +class Connect4(Activity): + # Board dimensions + COLS = 7 + ROWS = 6 + + # Screen layout (dynamically set in onCreate) + SCREEN_WIDTH = 320 + SCREEN_HEIGHT = 240 + BOARD_TOP = 40 + CELL_SIZE = 30 + PIECE_RADIUS = 12 + + # Colors + COLOR_EMPTY = 0x2C3E50 + COLOR_PLAYER = 0xE74C3C # Red + COLOR_COMPUTER = 0xF1C40F # Yellow + COLOR_BOARD = 0x3498DB # Blue + COLOR_HIGHLIGHT = 0x2ECC71 # Green + COLOR_WIN = 0x9B59B6 # Purple + + # Game state + EMPTY = 0 + PLAYER = 1 + COMPUTER = 2 + + # Difficulty levels + DIFFICULTY_EASY = 0 + DIFFICULTY_MEDIUM = 1 + DIFFICULTY_HARD = 2 + + def __init__(self): + super().__init__() + self.board = [[self.EMPTY for _ in range(self.COLS)] for _ in range(self.ROWS)] + self.difficulty = self.DIFFICULTY_EASY + self.game_over = False + self.winner = None + self.winning_positions = [] + self.current_player = self.PLAYER + self.animating = False + + # UI elements + self.screen = None + self.pieces = [] # 2D array of LVGL objects + self.column_buttons = [] + self.status_label = None + self.difficulty_label = None + self.last_time = 0 + + def onCreate(self): + self.screen = lv.obj() + self.screen.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) + self.screen.remove_flag(lv.obj.FLAG.SCROLLABLE) + + # Get dynamic screen resolution + d = lv.display_get_default() + self.SCREEN_WIDTH = d.get_horizontal_resolution() + self.SCREEN_HEIGHT = d.get_vertical_resolution() + + # Calculate scaling based on available space + available_height = self.SCREEN_HEIGHT - 40 # Leave space for bottom bar only + max_cell_size = min(available_height // self.ROWS, (self.SCREEN_WIDTH - 20) // self.COLS) + self.CELL_SIZE = max_cell_size + self.PIECE_RADIUS = int(self.CELL_SIZE * 0.4) + self.BOARD_TOP = 5 + + # Status label (bottom left) + self.status_label = lv.label(self.screen) + self.status_label.set_text("Your turn!") + self.status_label.align(lv.ALIGN.BOTTOM_LEFT, 5, -8) + + # Difficulty button (bottom center) + difficulty_btn = lv.button(self.screen) + difficulty_btn.set_size(70, 26) + difficulty_btn.align(lv.ALIGN.BOTTOM_MID, 0, -5) + difficulty_btn.add_event_cb(self.cycle_difficulty, lv.EVENT.CLICKED, None) + + self.difficulty_label = lv.label(difficulty_btn) + self.difficulty_label.set_text("Easy") + self.difficulty_label.center() + + # New Game button (bottom right) + new_game_btn = lv.button(self.screen) + new_game_btn.set_size(70, 26) + new_game_btn.align(lv.ALIGN.BOTTOM_RIGHT, -5, -5) + new_game_btn.add_event_cb(lambda e: self.new_game(), lv.EVENT.CLICKED, None) + new_game_label = lv.label(new_game_btn) + new_game_label.set_text("New") + new_game_label.center() + + # Create board background + board_bg = lv.obj(self.screen) + board_bg.set_size(self.COLS * self.CELL_SIZE + 10, self.ROWS * self.CELL_SIZE + 10) + board_bg.set_pos( + (self.SCREEN_WIDTH - self.COLS * self.CELL_SIZE) // 2 - 5, + self.BOARD_TOP - 5 + ) + board_bg.set_style_bg_color(lv.color_hex(self.COLOR_BOARD), 0) + board_bg.set_style_radius(8, 0) + board_bg.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) + + # Create pieces (visual representation) + board_x = (self.SCREEN_WIDTH - self.COLS * self.CELL_SIZE) // 2 + for row in range(self.ROWS): + piece_row = [] + for col in range(self.COLS): + piece = lv.obj(self.screen) + piece.set_size(self.PIECE_RADIUS * 2, self.PIECE_RADIUS * 2) + x = board_x + col * self.CELL_SIZE + (self.CELL_SIZE - self.PIECE_RADIUS * 2) // 2 + y = self.BOARD_TOP + row * self.CELL_SIZE + (self.CELL_SIZE - self.PIECE_RADIUS * 2) // 2 + piece.set_pos(x, y) + piece.set_style_radius(lv.RADIUS_CIRCLE, 0) + piece.set_style_bg_color(lv.color_hex(self.COLOR_EMPTY), 0) + piece.set_style_border_width(1, 0) + piece.set_style_border_color(lv.color_hex(0x1C2833), 0) + piece.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) + piece_row.append(piece) + self.pieces.append(piece_row) + + # Create column buttons (invisible clickable areas) + focusgroup = lv.group_get_default() + if not focusgroup: + print("WARNING: could not get default focusgroup") + + for col in range(self.COLS): + btn = lv.obj(self.screen) + btn.set_size(self.CELL_SIZE, self.ROWS * self.CELL_SIZE) + x = board_x + col * self.CELL_SIZE + btn.set_pos(x, self.BOARD_TOP) + btn.set_style_bg_opa(0, 0) # Transparent + btn.set_style_border_width(0, 0) + btn.add_flag(lv.obj.FLAG.CLICKABLE) + btn.add_event_cb(lambda e, c=col: self.on_column_click(c), lv.EVENT.CLICKED, None) + btn.add_event_cb(lambda e, b=btn: self.focus_column(b), lv.EVENT.FOCUSED, None) + btn.add_event_cb(lambda e, b=btn: self.defocus_column(b), lv.EVENT.DEFOCUSED, None) + + if focusgroup: + focusgroup.add_obj(btn) + + self.column_buttons.append(btn) + + self.setContentView(self.screen) + + def onResume(self, screen): + self.last_time = time.ticks_ms() + + def focus_column(self, column_btn): + """Highlight column when focused""" + # Use white for focus border to contrast with blue board + column_btn.set_style_border_color(lv.color_hex(0xFFFFFF), lv.PART.MAIN) + column_btn.set_style_border_width(3, lv.PART.MAIN) + + def defocus_column(self, column_btn): + """Remove highlight when unfocused""" + column_btn.set_style_border_width(0, lv.PART.MAIN) + + def cycle_difficulty(self, event): + if self.animating: + return + self.difficulty = (self.difficulty + 1) % 3 + difficulty_names = ["Easy", "Medium", "Hard"] + self.difficulty_label.set_text(difficulty_names[self.difficulty]) + self.difficulty_label.center() + + def on_column_click(self, col): + if self.game_over or self.animating or self.current_player != self.PLAYER: + return + + if self.drop_piece(col, self.PLAYER): + self.animate_drop(col) + + def drop_piece(self, col, player): + """Try to drop a piece in the given column. Returns True if successful.""" + # Find the lowest empty row in this column + for row in range(self.ROWS - 1, -1, -1): + if self.board[row][col] == self.EMPTY: + self.board[row][col] = player + return True + return False + + def animate_drop(self, col): + """Animate the piece dropping and then check for win/computer move""" + self.animating = True + + # Find which row the piece landed in + row = -1 + player = self.EMPTY + for r in range(self.ROWS): + if self.board[r][col] != self.EMPTY: + row = r + player = self.board[r][col] + break + + if row == -1: + self.animating = False + return + + # Update the visual + color = self.COLOR_PLAYER if player == self.PLAYER else self.COLOR_COMPUTER + self.pieces[row][col].set_style_bg_color(lv.color_hex(color), 0) + + # Check for win or tie + if self.check_win(row, col): + self.game_over = True + self.winner = player + self.highlight_winning_pieces() + winner_text = "You win!" if player == self.PLAYER else "Computer wins!" + self.status_label.set_text(winner_text) + self.animating = False + return + + if self.is_board_full(): + self.game_over = True + self.status_label.set_text("It's a tie!") + self.animating = False + return + + # Switch player + self.current_player = self.COMPUTER if player == self.PLAYER else self.PLAYER + + if self.current_player == self.COMPUTER: + self.status_label.set_text("Thinking...") + # Delay computer move slightly for better UX + lv.timer_create(lambda t: self.computer_move(), 500, None).set_repeat_count(1) + else: + self.status_label.set_text("Your turn!") + self.animating = False + + def computer_move(self): + """Make a computer move based on difficulty""" + if self.game_over: + self.animating = False + return + + if self.difficulty == self.DIFFICULTY_EASY: + col = self.get_random_move() + elif self.difficulty == self.DIFFICULTY_MEDIUM: + col = self.get_medium_move() + else: # HARD + col = self.get_hard_move() + + if col is not None and self.drop_piece(col, self.COMPUTER): + self.animate_drop(col) + else: + self.animating = False + + def get_random_move(self): + """Easy: Random valid column""" + valid_cols = [c for c in range(self.COLS) if self.board[0][c] == self.EMPTY] + return random.choice(valid_cols) if valid_cols else None + + def get_medium_move(self): + """Medium: Block player wins, try to win, otherwise random""" + # First, try to win + for col in range(self.COLS): + if self.is_valid_move(col): + row = self.get_next_row(col) + self.board[row][col] = self.COMPUTER + if self.check_win(row, col): + self.board[row][col] = self.EMPTY + return col + self.board[row][col] = self.EMPTY + + # Second, block player from winning + for col in range(self.COLS): + if self.is_valid_move(col): + row = self.get_next_row(col) + self.board[row][col] = self.PLAYER + if self.check_win(row, col): + self.board[row][col] = self.EMPTY + return col + self.board[row][col] = self.EMPTY + + # Otherwise, random + return self.get_random_move() + + def get_hard_move(self): + """Hard: Minimax algorithm""" + best_score = -float('inf') + best_col = None + + for col in range(self.COLS): + if self.is_valid_move(col): + row = self.get_next_row(col) + self.board[row][col] = self.COMPUTER + score = self.minimax(3, False, -float('inf'), float('inf')) + self.board[row][col] = self.EMPTY + + if score > best_score: + best_score = score + best_col = col + + return best_col if best_col is not None else self.get_random_move() + + def minimax(self, depth, is_maximizing, alpha, beta): + """Minimax with alpha-beta pruning""" + # Check terminal states + for row in range(self.ROWS): + for col in range(self.COLS): + if self.board[row][col] != self.EMPTY: + if self.check_win(row, col): + if self.board[row][col] == self.COMPUTER: + return 1000 + else: + return -1000 + + if self.is_board_full(): + return 0 + + if depth == 0: + return self.evaluate_board() + + if is_maximizing: + max_score = -float('inf') + for col in range(self.COLS): + if self.is_valid_move(col): + row = self.get_next_row(col) + self.board[row][col] = self.COMPUTER + score = self.minimax(depth - 1, False, alpha, beta) + self.board[row][col] = self.EMPTY + max_score = max(max_score, score) + alpha = max(alpha, score) + if beta <= alpha: + break + return max_score + else: + min_score = float('inf') + for col in range(self.COLS): + if self.is_valid_move(col): + row = self.get_next_row(col) + self.board[row][col] = self.PLAYER + score = self.minimax(depth - 1, True, alpha, beta) + self.board[row][col] = self.EMPTY + min_score = min(min_score, score) + beta = min(beta, score) + if beta <= alpha: + break + return min_score + + def evaluate_board(self): + """Heuristic evaluation of board position""" + score = 0 + + # Evaluate all possible windows of 4 + for row in range(self.ROWS): + for col in range(self.COLS): + if col <= self.COLS - 4: + window = [self.board[row][col + i] for i in range(4)] + score += self.evaluate_window(window) + + if row <= self.ROWS - 4: + window = [self.board[row + i][col] for i in range(4)] + score += self.evaluate_window(window) + + if row <= self.ROWS - 4 and col <= self.COLS - 4: + window = [self.board[row + i][col + i] for i in range(4)] + score += self.evaluate_window(window) + + if row >= 3 and col <= self.COLS - 4: + window = [self.board[row - i][col + i] for i in range(4)] + score += self.evaluate_window(window) + + return score + + def evaluate_window(self, window): + """Evaluate a window of 4 positions""" + score = 0 + computer_count = window.count(self.COMPUTER) + player_count = window.count(self.PLAYER) + empty_count = window.count(self.EMPTY) + + if computer_count == 3 and empty_count == 1: + score += 5 + elif computer_count == 2 and empty_count == 2: + score += 2 + + if player_count == 3 and empty_count == 1: + score -= 4 + + return score + + def is_valid_move(self, col): + """Check if a column has space""" + return self.board[0][col] == self.EMPTY + + def get_next_row(self, col): + """Get the row where a piece would land in this column""" + for row in range(self.ROWS - 1, -1, -1): + if self.board[row][col] == self.EMPTY: + return row + return -1 + + def check_win(self, row, col): + """Check if the piece at (row, col) creates a winning connection""" + player = self.board[row][col] + if player == self.EMPTY: + return False + + # Check horizontal + positions = self.check_direction(row, col, 0, 1) + if len(positions) >= 4: + self.winning_positions = positions + return True + + # Check vertical + positions = self.check_direction(row, col, 1, 0) + if len(positions) >= 4: + self.winning_positions = positions + return True + + # Check diagonal (down-right) + positions = self.check_direction(row, col, 1, 1) + if len(positions) >= 4: + self.winning_positions = positions + return True + + # Check diagonal (down-left) + positions = self.check_direction(row, col, 1, -1) + if len(positions) >= 4: + self.winning_positions = positions + return True + + return False + + def check_direction(self, row, col, dr, dc): + """Count consecutive pieces in a direction (both ways)""" + player = self.board[row][col] + positions = [(row, col)] + + # Check positive direction + r, c = row + dr, col + dc + while 0 <= r < self.ROWS and 0 <= c < self.COLS and self.board[r][c] == player: + positions.append((r, c)) + r += dr + c += dc + + # Check negative direction + r, c = row - dr, col - dc + while 0 <= r < self.ROWS and 0 <= c < self.COLS and self.board[r][c] == player: + positions.append((r, c)) + r -= dr + c -= dc + + return positions + + def highlight_winning_pieces(self): + """Highlight the winning pieces""" + for row, col in self.winning_positions: + self.pieces[row][col].set_style_bg_color(lv.color_hex(self.COLOR_WIN), 0) + self.pieces[row][col].set_style_border_width(3, 0) + self.pieces[row][col].set_style_border_color(lv.color_hex(0xFFFFFF), 0) + + def is_board_full(self): + """Check if the board is full""" + return all(self.board[0][col] != self.EMPTY for col in range(self.COLS)) + + def new_game(self): + """Reset the game""" + self.board = [[self.EMPTY for _ in range(self.COLS)] for _ in range(self.ROWS)] + self.game_over = False + self.winner = None + self.winning_positions = [] + self.current_player = self.PLAYER + self.animating = False + self.status_label.set_text("Your turn!") + + # Reset visual pieces + for row in range(self.ROWS): + for col in range(self.COLS): + self.pieces[row][col].set_style_bg_color(lv.color_hex(self.COLOR_EMPTY), 0) + self.pieces[row][col].set_style_border_width(1, 0) + self.pieces[row][col].set_style_border_color(lv.color_hex(0x1C2833), 0) diff --git a/internal_filesystem/apps/com.micropythonos.connect4/generate_icon.py b/internal_filesystem/apps/com.micropythonos.connect4/generate_icon.py new file mode 100644 index 00000000..d4e55993 --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.connect4/generate_icon.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +from PIL import Image, ImageDraw + +# Create 64x64 icon +img = Image.new('RGB', (64, 64), color=(52, 73, 94)) +draw = ImageDraw.Draw(img) + +# Draw blue board +draw.rectangle([4, 4, 60, 60], fill=(52, 152, 219)) + +# Draw grid of circles with a pattern +colors = [(231, 76, 60), (241, 196, 15), (44, 62, 80)] # Red, Yellow, Empty + +pattern = [ + [2, 2, 2, 2, 2, 2, 2], + [2, 2, 2, 2, 2, 2, 2], + [2, 2, 0, 0, 2, 2, 2], + [2, 0, 0, 1, 2, 2, 2], + [0, 1, 0, 1, 0, 2, 2], + [0, 1, 1, 0, 0, 1, 2], +] + +cell_size = 8 +start_x = 8 +start_y = 8 + +for row in range(6): + for col in range(7): + x = start_x + col * cell_size + y = start_y + row * cell_size + draw.ellipse([x, y, x + 6, y + 6], fill=colors[pattern[row][col]]) + +img.save('res/mipmap-mdpi/icon_64x64.png') +print("Icon created: res/mipmap-mdpi/icon_64x64.png") diff --git a/internal_filesystem/apps/com.micropythonos.connect4/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/apps/com.micropythonos.connect4/res/mipmap-mdpi/icon_64x64.png new file mode 100644 index 00000000..ee77edb2 Binary files /dev/null and b/internal_filesystem/apps/com.micropythonos.connect4/res/mipmap-mdpi/icon_64x64.png differ diff --git a/internal_filesystem/apps/com.micropythonos.draw/META-INF/MANIFEST.JSON b/internal_filesystem/apps/com.micropythonos.draw/META-INF/MANIFEST.JSON index 36db3c5c..02c3b41c 100644 --- a/internal_filesystem/apps/com.micropythonos.draw/META-INF/MANIFEST.JSON +++ b/internal_filesystem/apps/com.micropythonos.draw/META-INF/MANIFEST.JSON @@ -3,10 +3,10 @@ "publisher": "MicroPythonOS", "short_description": "Simple drawing app", "long_description": "Draw simple shapes on the screen.", -"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.draw/icons/com.micropythonos.draw_0.0.2_64x64.png", -"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.draw/mpks/com.micropythonos.draw_0.0.2.mpk", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.draw/icons/com.micropythonos.draw_0.0.4_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.draw/mpks/com.micropythonos.draw_0.0.4.mpk", "fullname": "com.micropythonos.draw", -"version": "0.0.2", +"version": "0.0.4", "category": "graphics", "activities": [ { diff --git a/internal_filesystem/apps/com.micropythonos.draw/assets/draw.py b/internal_filesystem/apps/com.micropythonos.draw/assets/draw.py index f2be3d39..d341b94a 100644 --- a/internal_filesystem/apps/com.micropythonos.draw/assets/draw.py +++ b/internal_filesystem/apps/com.micropythonos.draw/assets/draw.py @@ -18,9 +18,9 @@ class Draw(Activity): def onCreate(self): screen = lv.obj() self.canvas = lv.canvas(screen) - disp = lv.display_get_default() - self.hor_res = disp.get_horizontal_resolution() - self.ver_res = disp.get_vertical_resolution() + d = lv.display_get_default() + self.hor_res = d.get_horizontal_resolution() + self.ver_res = d.get_vertical_resolution() self.canvas.set_size(self.hor_res, self.ver_res) self.canvas.set_style_bg_color(lv.color_white(), 0) buffer = bytearray(self.hor_res * self.ver_res * 4) @@ -61,7 +61,7 @@ def draw_line(self, x, y): lv.draw_line(self.layer,dsc) self.canvas.finish_layer(self.layer) - @micropython.viper # make it with native compilation + # @micropython.viper # "invalid micropython decorator" on macOS def draw_rect(self, x: int, y: int): draw_dsc = lv.draw_rect_dsc_t() lv.draw_rect_dsc_t.init(draw_dsc) diff --git a/internal_filesystem/apps/com.micropythonos.errortest/META-INF/MANIFEST.JSON b/internal_filesystem/apps/com.micropythonos.errortest/META-INF/MANIFEST.JSON new file mode 100644 index 00000000..02aef763 --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.errortest/META-INF/MANIFEST.JSON @@ -0,0 +1,24 @@ +{ +"name": "ErrorTest", +"publisher": "MicroPythonOS", +"short_description": "Test app with intentional error", +"long_description": "This app has an intentional import error for testing.", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.errortest/icons/com.micropythonos.errortest_0.0.1_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.errortest/mpks/com.micropythonos.errortest_0.0.1.mpk", +"fullname": "com.micropythonos.errortest", +"version": "0.0.1", +"category": "development", +"activities": [ + { + "entrypoint": "assets/error.py", + "classname": "Error", + "intent_filters": [ + { + "action": "main", + "category": "launcher" + } + ] + } + ] +} + diff --git a/internal_filesystem/apps/com.micropythonos.errortest/assets/error.py b/internal_filesystem/apps/com.micropythonos.errortest/assets/error.py new file mode 100644 index 00000000..db63482d --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.errortest/assets/error.py @@ -0,0 +1,10 @@ +from mpos.apps import ActivityDoesntExist # should fail here + +class Error(Activity): + + def onCreate(self): + screen = lv.obj() + label = lv.label(screen) + label.set_text('Hello World!') + label.center() + self.setContentView(screen) diff --git a/internal_filesystem/apps/com.micropythonos.filemanager/META-INF/MANIFEST.JSON b/internal_filesystem/apps/com.micropythonos.filemanager/META-INF/MANIFEST.JSON index 9c4b2a63..0888ef29 100644 --- a/internal_filesystem/apps/com.micropythonos.filemanager/META-INF/MANIFEST.JSON +++ b/internal_filesystem/apps/com.micropythonos.filemanager/META-INF/MANIFEST.JSON @@ -3,10 +3,10 @@ "publisher": "MicroPythonOS", "short_description": "Manage files", "long_description": "Traverse around the filesystem and manage files and folders you find..", -"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.filemanager/icons/com.micropythonos.filemanager_0.0.2_64x64.png", -"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.filemanager/mpks/com.micropythonos.filemanager_0.0.2.mpk", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.filemanager/icons/com.micropythonos.filemanager_0.0.3_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.filemanager/mpks/com.micropythonos.filemanager_0.0.3.mpk", "fullname": "com.micropythonos.filemanager", -"version": "0.0.2", +"version": "0.0.3", "category": "development", "activities": [ { diff --git a/internal_filesystem/apps/com.micropythonos.filemanager/assets/file_manager.py b/internal_filesystem/apps/com.micropythonos.filemanager/assets/file_manager.py index 3065cce2..39a0d868 100644 --- a/internal_filesystem/apps/com.micropythonos.filemanager/assets/file_manager.py +++ b/internal_filesystem/apps/com.micropythonos.filemanager/assets/file_manager.py @@ -11,13 +11,7 @@ def onCreate(self): screen = lv.obj() self.file_explorer = lv.file_explorer(screen) #self.file_explorer.set_root_path("M:data/images/") - #self.file_explorer.explorer_open_dir('/') - #self.file_explorer.explorer_open_dir('M:data/images/') - #self.file_explorer.explorer_open_dir('M:/') self.file_explorer.explorer_open_dir('M:/') - #self.file_explorer.explorer_open_dir('M:data/images/') - #self.file_explorer.explorer_open_dir('P:.') # POSIX works on desktop, fs_driver gives unicode error doesn't because it doesn't have dir_open, dir_read, dir_close but that's fixed in https://github.com/lvgl-micropython/lvgl_micropython/pull/399 - #self.file_explorer.explorer_open_dir('P:/tmp') # POSIX works, fs_driver doesn't because it doesn't have dir_open, dir_read, dir_close #file_explorer.explorer_open_dir('S:/') #self.file_explorer.set_size(lv.pct(100), lv.pct(100)) #file_explorer.set_mode(lv.FILE_EXPLORER.MODE.DEFAULT) # Default browsing mode diff --git a/internal_filesystem/apps/com.micropythonos.imageview/META-INF/MANIFEST.JSON b/internal_filesystem/apps/com.micropythonos.imageview/META-INF/MANIFEST.JSON index 705ae849..0ed67dcb 100644 --- a/internal_filesystem/apps/com.micropythonos.imageview/META-INF/MANIFEST.JSON +++ b/internal_filesystem/apps/com.micropythonos.imageview/META-INF/MANIFEST.JSON @@ -3,10 +3,10 @@ "publisher": "MicroPythonOS", "short_description": "Image Viewer", "long_description": "Opens and shows images on the display.", -"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.imageview/icons/com.micropythonos.imageview_0.0.3_64x64.png", -"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.imageview/mpks/com.micropythonos.imageview_0.0.3.mpk", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.imageview/icons/com.micropythonos.imageview_0.0.5_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.imageview/mpks/com.micropythonos.imageview_0.0.5.mpk", "fullname": "com.micropythonos.imageview", -"version": "0.0.3", +"version": "0.0.5", "category": "graphics", "activities": [ { diff --git a/internal_filesystem/apps/com.micropythonos.imageview/assets/imageview.py b/internal_filesystem/apps/com.micropythonos.imageview/assets/imageview.py index 55fac865..4433b503 100644 --- a/internal_filesystem/apps/com.micropythonos.imageview/assets/imageview.py +++ b/internal_filesystem/apps/com.micropythonos.imageview/assets/imageview.py @@ -21,6 +21,7 @@ class ImageView(Activity): def onCreate(self): screen = lv.obj() + screen.remove_flag(lv.obj.FLAG.SCROLLABLE) self.image = lv.image(screen) self.image.center() self.image.add_flag(lv.obj.FLAG.CLICKABLE) @@ -33,12 +34,14 @@ def onCreate(self): self.label = lv.label(screen) self.label.set_text(f"Loading images from\n{self.imagedir}") self.label.align(lv.ALIGN.TOP_MID,0,0) + self.label.set_width(lv.pct(80)) self.prev_button = lv.button(screen) self.prev_button.align(lv.ALIGN.BOTTOM_LEFT,0,0) self.prev_button.add_event_cb(lambda e: self.show_prev_image_if_fullscreen(),lv.EVENT.FOCUSED,None) self.prev_button.add_event_cb(lambda e: self.show_prev_image(),lv.EVENT.CLICKED,None) prev_label = lv.label(self.prev_button) prev_label.set_text(lv.SYMBOL.LEFT) + prev_label.set_style_text_font(lv.font_montserrat_16, 0) self.play_button = lv.button(screen) self.play_button.align(lv.ALIGN.BOTTOM_MID,0,0) self.play_button.set_style_opa(lv.OPA.TRANSP, 0) @@ -48,6 +51,12 @@ def onCreate(self): #self.play_button.add_event_cb(lambda e: self.play(),lv.EVENT.CLICKED,None) #play_label = lv.label(self.play_button) #play_label.set_text(lv.SYMBOL.PLAY) + self.delete_button = lv.button(screen) + self.delete_button.align(lv.ALIGN.BOTTOM_MID,0,0) + self.delete_button.add_event_cb(lambda e: self.delete_image(),lv.EVENT.CLICKED,None) + delete_label = lv.label(self.delete_button) + delete_label.set_text(lv.SYMBOL.TRASH) + delete_label.set_style_text_font(lv.font_montserrat_16, 0) self.next_button = lv.button(screen) self.next_button.align(lv.ALIGN.BOTTOM_RIGHT,0,0) #self.next_button.add_event_cb(self.print_events, lv.EVENT.ALL, None) @@ -55,16 +64,19 @@ def onCreate(self): self.next_button.add_event_cb(lambda e: self.show_next_image(),lv.EVENT.CLICKED,None) next_label = lv.label(self.next_button) next_label.set_text(lv.SYMBOL.RIGHT) + next_label.set_style_text_font(lv.font_montserrat_16, 0) #screen.add_event_cb(self.print_events, lv.EVENT.ALL, None) self.setContentView(screen) def onResume(self, screen): self.stopping = False self.images.clear() - for item in os.listdir(self.imagedir): - print(item) - lowercase = item.lower() - if lowercase.endswith(".jpg") or lowercase.endswith(".jpeg") or lowercase.endswith(".png") or lowercase.endswith(".raw") or lowercase.endswith(".gif"): + try: + for item in os.listdir(self.imagedir): + print(item) + lowercase = item.lower() + if not (lowercase.endswith(".jpg") or lowercase.endswith(".jpeg") or lowercase.endswith(".png") or lowercase.endswith(".raw") or lowercase.endswith(".gif")): + continue fullname = f"{self.imagedir}/{item}" size = os.stat(fullname)[6] print(f"size: {size}") @@ -72,11 +84,16 @@ def onResume(self, screen): print(f"Skipping file of size {size}") continue self.images.append(fullname) - self.images.sort() - # Begin with one image: - self.show_next_image() - self.stop_fullscreen() - #self.image_timer = lv.timer_create(self.show_next_image, 1000, None) + + self.images.sort() + if len(self.images) == 0: + self.no_image_mode() + else: + # Begin with one image: + self.show_next_image() + self.stop_fullscreen() + except Exception as e: + print(f"ImageView encountered exception for {self.imagedir}: {e}") def onStop(self, screen): print("ImageView stopping") @@ -85,9 +102,16 @@ def onStop(self, screen): print("ImageView: deleting image_timer") self.image_timer.delete() + def no_image_mode(self): + self.label.set_text(f"No images found in {self.imagedir}...") + mpos.ui.anim.smooth_hide(self.prev_button) + mpos.ui.anim.smooth_hide(self.delete_button) + mpos.ui.anim.smooth_hide(self.next_button) + def show_prev_image(self, event=None): print("showing previous image...") if len(self.images) < 1: + self.no_image_mode() return if self.image_nr is None or self.image_nr == 0: self.image_nr = len(self.images) - 1 @@ -111,6 +135,7 @@ def stop_fullscreen(self): print("stopping fullscreen") mpos.ui.anim.smooth_show(self.label) mpos.ui.anim.smooth_show(self.prev_button) + mpos.ui.anim.smooth_show(self.delete_button) #mpos.ui.anim.smooth_show(self.play_button) self.play_button.add_flag(lv.obj.FLAG.HIDDEN) # make it not accepting focus mpos.ui.anim.smooth_show(self.next_button) @@ -119,6 +144,7 @@ def start_fullscreen(self): print("starting fullscreen") mpos.ui.anim.smooth_hide(self.label) mpos.ui.anim.smooth_hide(self.prev_button, hide=False) + mpos.ui.anim.smooth_hide(self.delete_button, hide=False) #mpos.ui.anim.smooth_hide(self.play_button, hide=False) self.play_button.remove_flag(lv.obj.FLAG.HIDDEN) # make it accepting focus mpos.ui.anim.smooth_hide(self.next_button, hide=False) @@ -162,6 +188,7 @@ def unfocus(self): def show_next_image(self, event=None): print("showing next image...") if len(self.images) < 1: + self.no_image_mode() return if self.image_nr is None or self.image_nr >= len(self.images) - 1: self.image_nr = 0 @@ -171,6 +198,16 @@ def show_next_image(self, event=None): print(f"show_next_image showing {name}") self.show_image(name) + def delete_image(self, event=None): + filename = self.images[self.image_nr] + try: + os.remove(filename) + self.clear_image() + self.label.set_text(f"Deleted\n{filename}") + del self.images[self.image_nr] + except Exception as e: + print(f"Error deleting {filename}: {e}") + def extract_dimensions_and_format(self, filename): # Split the filename by '_' parts = filename.split('_') @@ -183,6 +220,7 @@ def extract_dimensions_and_format(self, filename): return width, height, color_format.upper() def show_image(self, name): + self.current_image = name try: self.label.set_text(name) self.clear_image() @@ -209,7 +247,10 @@ def show_image(self, name): print(f"Raw image has width: {width}, Height: {height}, Color Format: {color_format}") stride = width * 2 cf = lv.COLOR_FORMAT.RGB565 - if color_format != "RGB565": + if color_format == "GRAY": + cf = lv.COLOR_FORMAT.L8 + stride = width + elif color_format != "RGB565": print(f"WARNING: unknown color format {color_format}, assuming RGB565...") self.current_image_dsc = lv.image_dsc_t({ "header": { @@ -231,7 +272,7 @@ def scale_image(self): if self.fullscreen: pct = 100 else: - pct = 90 + pct = 70 lvgl_w = mpos.ui.pct_of_display_width(pct) lvgl_h = mpos.ui.pct_of_display_height(pct) print(f"scaling to size: {lvgl_w}x{lvgl_h}") @@ -254,6 +295,7 @@ def scale_image(self): def clear_image(self): """Clear current image or GIF source to free memory.""" + self.image.set_src(None) #if self.current_image_dsc: # self.current_image_dsc = None # Release reference to descriptor #self.image.set_src(None) # Clear image source diff --git a/internal_filesystem/apps/com.micropythonos.imu/META-INF/MANIFEST.JSON b/internal_filesystem/apps/com.micropythonos.imu/META-INF/MANIFEST.JSON index 21563c5e..2c4601e9 100644 --- a/internal_filesystem/apps/com.micropythonos.imu/META-INF/MANIFEST.JSON +++ b/internal_filesystem/apps/com.micropythonos.imu/META-INF/MANIFEST.JSON @@ -3,10 +3,10 @@ "publisher": "MicroPythonOS", "short_description": "Inertial Measurement Unit Visualization", "long_description": "Visualize data from the Intertial Measurement Unit, also known as the accellerometer.", -"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.imu/icons/com.micropythonos.imu_0.0.2_64x64.png", -"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.imu/mpks/com.micropythonos.imu_0.0.2.mpk", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.imu/icons/com.micropythonos.imu_0.0.3_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.imu/mpks/com.micropythonos.imu_0.0.3.mpk", "fullname": "com.micropythonos.imu", -"version": "0.0.2", +"version": "0.0.3", "category": "hardware", "activities": [ { diff --git a/internal_filesystem/apps/com.micropythonos.imu/assets/imu.py b/internal_filesystem/apps/com.micropythonos.imu/assets/imu.py index 569c47e1..4cf3cb51 100644 --- a/internal_filesystem/apps/com.micropythonos.imu/assets/imu.py +++ b/internal_filesystem/apps/com.micropythonos.imu/assets/imu.py @@ -1,8 +1,11 @@ from mpos.apps import Activity +import mpos.sensor_manager as SensorManager class IMU(Activity): - sensor = None + accel_sensor = None + gyro_sensor = None + temp_sensor = None refresh_timer = None # widgets: @@ -30,12 +33,16 @@ def onCreate(self): self.slidergz = lv.slider(screen) self.slidergz.align(lv.ALIGN.CENTER, 0, 90) try: - from machine import Pin, I2C - from qmi8658 import QMI8658 - import machine - self.sensor = QMI8658(I2C(0, sda=machine.Pin(48), scl=machine.Pin(47))) - print("IMU sensor initialized") - #print(f"{self.sensor.temperature=} {self.sensor.acceleration=} {self.sensor.gyro=}") + if SensorManager.is_available(): + self.accel_sensor = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) + self.gyro_sensor = SensorManager.get_default_sensor(SensorManager.TYPE_GYROSCOPE) + # Get IMU temperature (not MCU temperature) + self.temp_sensor = SensorManager.get_default_sensor(SensorManager.TYPE_IMU_TEMPERATURE) + print("IMU sensors initialized via SensorManager") + print(f"Available sensors: {SensorManager.get_sensor_list()}") + else: + print("Warning: No IMU sensors available") + self.templabel.set_text("No IMU sensors available") except Exception as e: warning = f"Warning: could not initialize IMU hardware:\n{e}" print(warning) @@ -68,22 +75,45 @@ def convert_percentage(self, value: float) -> int: def refresh(self, timer): #print("refresh timer") - if self.sensor: - #print(f"{self.sensor.temperature=} {self.sensor.acceleration=} {self.sensor.gyro=}") - temp = self.sensor.temperature - ax = self.sensor.acceleration[0] - axp = int((ax * 100 + 100)/2) - ay = self.sensor.acceleration[1] - ayp = int((ay * 100 + 100)/2) - az = self.sensor.acceleration[2] - azp = int((az * 100 + 100)/2) - # values between -200 and 200 => /4 becomes -50 and 50 => +50 becomes 0 and 100 - gx = self.convert_percentage(self.sensor.gyro[0]) - gy = self.convert_percentage(self.sensor.gyro[1]) - gz = self.convert_percentage(self.sensor.gyro[2]) - self.templabel.set_text(f"IMU chip temperature: {temp:.2f}°C") + if self.accel_sensor and self.gyro_sensor: + # Read sensor data via SensorManager (returns m/s² for accel, deg/s for gyro) + accel = SensorManager.read_sensor(self.accel_sensor) + gyro = SensorManager.read_sensor(self.gyro_sensor) + temp = SensorManager.read_sensor(self.temp_sensor) if self.temp_sensor else None + + if accel and gyro: + # Convert m/s² to G for display (divide by 9.80665) + # Range: ±8G → ±1G = ±10% of range → map to 0-100 + ax, ay, az = accel + ax_g = ax / 9.80665 # Convert m/s² to G + ay_g = ay / 9.80665 + az_g = az / 9.80665 + axp = int((ax_g * 100 + 100)/2) # Map ±1G to 0-100 + ayp = int((ay_g * 100 + 100)/2) + azp = int((az_g * 100 + 100)/2) + + # Gyro already in deg/s, map ±200 DPS to 0-100 + gx, gy, gz = gyro + gx = self.convert_percentage(gx) + gy = self.convert_percentage(gy) + gz = self.convert_percentage(gz) + + if temp is not None: + self.templabel.set_text(f"IMU chip temperature: {temp:.2f}°C") + else: + self.templabel.set_text("IMU active (no temperature sensor)") + else: + # Sensor read failed, show random data + import random + randomnr = random.randint(0,100) + axp = randomnr + ayp = 50 + azp = 75 + gx = 45 + gy = 50 + gz = 55 else: - #temp = 12.34 + # No sensors available, show random data import random randomnr = random.randint(0,100) axp = randomnr @@ -92,6 +122,7 @@ def refresh(self, timer): gx = 45 gy = 50 gz = 55 + self.sliderx.set_value(axp, False) self.slidery.set_value(ayp, False) self.sliderz.set_value(azp, False) diff --git a/internal_filesystem/apps/com.micropythonos.musicplayer/META-INF/MANIFEST.JSON b/internal_filesystem/apps/com.micropythonos.musicplayer/META-INF/MANIFEST.JSON index 7f5d7332..b1d428fc 100644 --- a/internal_filesystem/apps/com.micropythonos.musicplayer/META-INF/MANIFEST.JSON +++ b/internal_filesystem/apps/com.micropythonos.musicplayer/META-INF/MANIFEST.JSON @@ -3,10 +3,10 @@ "publisher": "MicroPythonOS", "short_description": "Player audio files", "long_description": "Traverse around the filesystem and play audio files that you select.", -"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.musicplayer/icons/com.micropythonos.musicplayer_0.0.1_64x64.png", -"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.musicplayer/mpks/com.micropythonos.musicplayer_0.0.1.mpk", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.musicplayer/icons/com.micropythonos.musicplayer_0.0.5_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.musicplayer/mpks/com.micropythonos.musicplayer_0.0.5.mpk", "fullname": "com.micropythonos.musicplayer", -"version": "0.0.1", +"version": "0.0.5", "category": "development", "activities": [ { diff --git a/internal_filesystem/apps/com.micropythonos.musicplayer/assets/audio_player.py b/internal_filesystem/apps/com.micropythonos.musicplayer/assets/audio_player.py deleted file mode 100644 index 9b9b287e..00000000 --- a/internal_filesystem/apps/com.micropythonos.musicplayer/assets/audio_player.py +++ /dev/null @@ -1,278 +0,0 @@ -import machine -import os -import time -import micropython - - -# ---------------------------------------------------------------------- -# AudioPlayer – robust, volume-controllable WAV player -# Supports 8 / 16 / 24 / 32-bit PCM, mono + stereo -# Auto-up-samples any rate < 22050 Hz to >=22050 Hz -# ---------------------------------------------------------------------- -class AudioPlayer: - _i2s = None - _volume = 50 # 0-100 - _keep_running = True - - # ------------------------------------------------------------------ - # WAV header parser – returns bit-depth - # ------------------------------------------------------------------ - @staticmethod - def find_data_chunk(f): - """Return (data_start, data_size, sample_rate, channels, bits_per_sample)""" - f.seek(0) - if f.read(4) != b'RIFF': - raise ValueError("Not a RIFF (standard .wav) file") - file_size = int.from_bytes(f.read(4), 'little') + 8 - if f.read(4) != b'WAVE': - raise ValueError("Not a WAVE (standard .wav) file") - - pos = 12 - sample_rate = None - channels = None - bits_per_sample = None - while pos < file_size: - f.seek(pos) - chunk_id = f.read(4) - if len(chunk_id) < 4: - break - chunk_size = int.from_bytes(f.read(4), 'little') - if chunk_id == b'fmt ': - fmt = f.read(chunk_size) - if len(fmt) < 16: - raise ValueError("Invalid fmt chunk") - if int.from_bytes(fmt[0:2], 'little') != 1: - raise ValueError("Only PCM supported") - channels = int.from_bytes(fmt[2:4], 'little') - if channels not in (1, 2): - raise ValueError("Only mono or stereo supported") - sample_rate = int.from_bytes(fmt[4:8], 'little') - bits_per_sample = int.from_bytes(fmt[14:16], 'little') - if bits_per_sample not in (8, 16, 24, 32): - raise ValueError("Only 8/16/24/32-bit PCM supported") - elif chunk_id == b'data': - return f.tell(), chunk_size, sample_rate, channels, bits_per_sample - pos += 8 + chunk_size - if chunk_size % 2: - pos += 1 - raise ValueError("No 'data' chunk found") - - # ------------------------------------------------------------------ - # Volume control - # ------------------------------------------------------------------ - @classmethod - def set_volume(cls, volume: int): - volume = max(0, min(100, volume)) - cls._volume = volume - - @classmethod - def get_volume(cls) -> int: - return cls._volume - - @classmethod - def stop_playing(cls): - print("stop_playing()") - cls._keep_running = False - - # ------------------------------------------------------------------ - # 1. Up-sample 16-bit buffer (zero-order-hold) - # ------------------------------------------------------------------ - @staticmethod - def _upsample_buffer(raw: bytearray, factor: int) -> bytearray: - if factor == 1: - return raw - upsampled = bytearray(len(raw) * factor) - out_idx = 0 - for i in range(0, len(raw), 2): - lo = raw[i] - hi = raw[i + 1] - for _ in range(factor): - upsampled[out_idx] = lo - upsampled[out_idx + 1] = hi - out_idx += 2 - return upsampled - - # ------------------------------------------------------------------ - # 2. Convert 8-bit to 16-bit (non-viper, Viper-safe) - # ------------------------------------------------------------------ - @staticmethod - def _convert_8_to_16(buf: bytearray) -> bytearray: - out = bytearray(len(buf) * 2) - j = 0 - for i in range(len(buf)): - u8 = buf[i] - s16 = (u8 - 128) << 8 - out[j] = s16 & 0xFF - out[j + 1] = (s16 >> 8) & 0xFF - j += 2 - return out - - # ------------------------------------------------------------------ - # 3. Convert 24-bit to 16-bit (non-viper) - # ------------------------------------------------------------------ - @staticmethod - def _convert_24_to_16(buf: bytearray) -> bytearray: - samples = len(buf) // 3 - out = bytearray(samples * 2) - j = 0 - for i in range(samples): - b0 = buf[j] - b1 = buf[j + 1] - b2 = buf[j + 2] - s24 = (b2 << 16) | (b1 << 8) | b0 - if b2 & 0x80: - s24 -= 0x1000000 - s16 = s24 >> 8 - out[i * 2] = s16 & 0xFF - out[i * 2 + 1] = (s16 >> 8) & 0xFF - j += 3 - return out - - # ------------------------------------------------------------------ - # 4. Convert 32-bit to 16-bit (non-viper) - # ------------------------------------------------------------------ - @staticmethod - def _convert_32_to_16(buf: bytearray) -> bytearray: - samples = len(buf) // 4 - out = bytearray(samples * 2) - j = 0 - for i in range(samples): - b0 = buf[j] - b1 = buf[j + 1] - b2 = buf[j + 2] - b3 = buf[j + 3] - s32 = (b3 << 24) | (b2 << 16) | (b1 << 8) | b0 - if b3 & 0x80: - s32 -= 0x100000000 - s16 = s32 >> 16 - out[i * 2] = s16 & 0xFF - out[i * 2 + 1] = (s16 >> 8) & 0xFF - j += 4 - return out - - # ------------------------------------------------------------------ - # Main playback routine - # ------------------------------------------------------------------ - @classmethod - def play_wav(cls, filename, result_callback=None): - cls._keep_running = True - try: - with open(filename, 'rb') as f: - st = os.stat(filename) - file_size = st[6] - print(f"File size: {file_size} bytes") - - # ----- parse header ------------------------------------------------ - data_start, data_size, original_rate, channels, bits_per_sample = \ - cls.find_data_chunk(f) - - # ----- decide playback rate (force >=22050 Hz) -------------------- - target_rate = 22050 - if original_rate >= target_rate: - playback_rate = original_rate - upsample_factor = 1 - else: - upsample_factor = (target_rate + original_rate - 1) // original_rate - playback_rate = original_rate * upsample_factor - - print(f"Original: {original_rate} Hz, {bits_per_sample}-bit, {channels}-ch " - f"to Playback: {playback_rate} Hz (factor {upsample_factor})") - - if data_size > file_size - data_start: - data_size = file_size - data_start - - # ----- I2S init (always 16-bit) ---------------------------------- - try: - i2s_format = machine.I2S.MONO if channels == 1 else machine.I2S.STEREO - cls._i2s = machine.I2S( - 0, - sck=machine.Pin(2, machine.Pin.OUT), - ws =machine.Pin(47, machine.Pin.OUT), - sd =machine.Pin(16, machine.Pin.OUT), - mode=machine.I2S.TX, - bits=16, - format=i2s_format, - rate=playback_rate, - ibuf=32000 - ) - except Exception as e: - print(f"Warning: simulating playback (I2S init failed): {e}") - - print(f"Playing {data_size} original bytes (vol {cls._volume}%) ...") - f.seek(data_start) - - # ----- Viper volume scaler (16-bit only) ------------------------- - @micropython.viper - def scale_audio(buf: ptr8, num_bytes: int, scale_fixed: int): - for i in range(0, num_bytes, 2): - lo = int(buf[i]) - hi = int(buf[i+1]) - sample = (hi << 8) | lo - if hi & 128: - sample -= 65536 - sample = (sample * scale_fixed) // 32768 - if sample > 32767: - sample = 32767 - elif sample < -32768: - sample = -32768 - buf[i] = sample & 255 - buf[i+1] = (sample >> 8) & 255 - - chunk_size = 4096 - bytes_per_original_sample = (bits_per_sample // 8) * channels - total_original = 0 - - while total_original < data_size: - if not cls._keep_running: - print("Playback stopped by user.") - break - - # ---- read a whole-sample chunk of original data ------------- - to_read = min(chunk_size, data_size - total_original) - to_read -= (to_read % bytes_per_original_sample) - if to_read <= 0: - break - - raw = bytearray(f.read(to_read)) - if not raw: - break - - # ---- 1. Convert bit-depth to 16-bit (non-viper) ------------- - if bits_per_sample == 8: - raw = cls._convert_8_to_16(raw) - elif bits_per_sample == 24: - raw = cls._convert_24_to_16(raw) - elif bits_per_sample == 32: - raw = cls._convert_32_to_16(raw) - # 16-bit to unchanged - - # ---- 2. Up-sample if needed --------------------------------- - if upsample_factor > 1: - raw = cls._upsample_buffer(raw, upsample_factor) - - # ---- 3. Volume scaling -------------------------------------- - scale = cls._volume / 100.0 - if scale < 1.0: - scale_fixed = int(scale * 32768) - scale_audio(raw, len(raw), scale_fixed) - - # ---- 4. Output --------------------------------------------- - if cls._i2s: - cls._i2s.write(raw) - else: - num_samples = len(raw) // (2 * channels) - time.sleep(num_samples / playback_rate) - - total_original += to_read - - print(f"Finished playing {filename}") - if result_callback: - result_callback(f"Finished playing {filename}") - except Exception as e: - print(f"Error: {e}\nwhile playing {filename}") - if result_callback: - result_callback(f"Error: {e}\nwhile playing {filename}") - finally: - if cls._i2s: - cls._i2s.deinit() - cls._i2s = None diff --git a/internal_filesystem/apps/com.micropythonos.musicplayer/assets/music_player.py b/internal_filesystem/apps/com.micropythonos.musicplayer/assets/music_player.py index 1e49c6af..428f773f 100644 --- a/internal_filesystem/apps/com.micropythonos.musicplayer/assets/music_player.py +++ b/internal_filesystem/apps/com.micropythonos.musicplayer/assets/music_player.py @@ -1,13 +1,11 @@ import machine import os -import _thread import time from mpos.apps import Activity, Intent import mpos.sdcard import mpos.ui - -from audio_player import AudioPlayer +import mpos.audio.audioflinger as AudioFlinger class MusicPlayer(Activity): @@ -63,23 +61,22 @@ class FullscreenPlayer(Activity): # Internal state: _filename = None - _keep_running = True def onCreate(self): self._filename = self.getIntent().extras.get("filename") qr_screen = lv.obj() self._slider_label=lv.label(qr_screen) - self._slider_label.set_text(f"Volume: {AudioPlayer.get_volume()}%") + self._slider_label.set_text(f"Volume: {AudioFlinger.get_volume()}%") self._slider_label.align(lv.ALIGN.TOP_MID,0,lv.pct(4)) self._slider=lv.slider(qr_screen) - self._slider.set_range(0,100) - self._slider.set_value(AudioPlayer.get_volume(), False) + self._slider.set_range(0,16) + self._slider.set_value(int(AudioFlinger.get_volume()/6.25), False) self._slider.set_width(lv.pct(90)) self._slider.align_to(self._slider_label,lv.ALIGN.OUT_BOTTOM_MID,0,10) def volume_slider_changed(e): - volume_int = self._slider.get_value() + volume_int = self._slider.get_value()*6.25 self._slider_label.set_text(f"Volume: {volume_int}%") - AudioPlayer.set_volume(volume_int) + AudioFlinger.set_volume(volume_int) self._slider.add_event_cb(volume_slider_changed,lv.EVENT.VALUE_CHANGED,None) self._filename_label = lv.label(qr_screen) self._filename_label.align(lv.ALIGN.CENTER,0,0) @@ -101,15 +98,27 @@ def volume_slider_changed(e): self.setContentView(qr_screen) def onResume(self, screen): - self._keep_running = True + super().onResume(screen) if not self._filename: print("Not playing any file...") else: - print("Starting thread to play file {self._filename}") - AudioPlayer.stop_playing() + print(f"Playing file {self._filename}") + AudioFlinger.stop() time.sleep(0.1) - _thread.stack_size(mpos.apps.good_stack_size()) - _thread.start_new_thread(AudioPlayer.play_wav, (self._filename,self.player_finished,)) + + success = AudioFlinger.play_wav( + self._filename, + stream_type=AudioFlinger.STREAM_MUSIC, + on_complete=self.player_finished + ) + + if not success: + error_msg = "Error: Audio device unavailable or busy" + print(error_msg) + self.update_ui_threadsafe_if_foreground( + self._filename_label.set_text, + error_msg + ) def focus_obj(self, obj): obj.set_style_border_color(lv.theme_get_color_primary(None),lv.PART.MAIN) @@ -119,15 +128,12 @@ def defocus_obj(self, obj): obj.set_style_border_width(0, lv.PART.MAIN) def stop_button_clicked(self, event): - self._keep_running = False - AudioPlayer.stop_playing() + AudioFlinger.stop() self.finish() def player_finished(self, result=None): - if not self._keep_running: - return # stop immediately text = f"Finished playing {self._filename}" if result: text = result print(f"AudioPlayer finished: {text}") - lv.async_call(lambda l: self._filename_label.set_text(text), None) + self.update_ui_threadsafe_if_foreground(self._filename_label.set_text, text) diff --git a/internal_filesystem/apps/com.micropythonos.showbattery/META-INF/MANIFEST.JSON b/internal_filesystem/apps/com.micropythonos.showbattery/META-INF/MANIFEST.JSON new file mode 100644 index 00000000..63fbca9e --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.showbattery/META-INF/MANIFEST.JSON @@ -0,0 +1,24 @@ +{ +"name": "ShowBattery", +"publisher": "MicroPythonOS", +"short_description": "Minimal app", +"long_description": "Demonstrates the simplest app.", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.helloworld/icons/com.micropythonos.helloworld_0.0.2_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.helloworld/mpks/com.micropythonos.helloworld_0.0.2.mpk", +"fullname": "com.micropythonos.showbattery", +"version": "0.0.2", +"category": "development", +"activities": [ + { + "entrypoint": "assets/hello.py", + "classname": "Hello", + "intent_filters": [ + { + "action": "main", + "category": "launcher" + } + ] + } + ] +} + diff --git a/internal_filesystem/apps/com.micropythonos.showbattery/assets/hello.py b/internal_filesystem/apps/com.micropythonos.showbattery/assets/hello.py new file mode 100644 index 00000000..7e0ac09e --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.showbattery/assets/hello.py @@ -0,0 +1,87 @@ +""" +8:44 4.15V +8:46 4.13V + +import time +v = mpos.battery_voltage.read_battery_voltage() +percent = mpos.battery_voltage.get_battery_percentage() +text = f"{time.localtime()}: {v}V is {percent}%" +text + +from machine import ADC, Pin # do this inside the try because it will fail on desktop +adc = ADC(Pin(13)) +# Set ADC to 11dB attenuation for 0–3.3V range (common for ESP32) +adc.atten(ADC.ATTN_11DB) +adc.read() + +scale factor 0.002 is (4.15 / 4095) * 2 +BUT shows 4.90 instead of 4.13 +BUT shows 5.018 instead of 4.65 (raw ADC read: 2366) +SO substract 0.77 +# at 2366 + +2506 is 4.71 (not 4.03) +scale factor 0.002 is (4.15 / 4095) * 2 +BUT shows 4.90 instead of 4.13 +BUT shows 5.018 instead of 4.65 (raw ADC read: 2366) +SO substract 0.77 +# at 2366 + +USB power: +2506 is 4.71 (not 4.03) +2498 +2491 + +battery power: +2482 is 4.180 +2470 is 4.170 +2457 is 4.147 +2433 is 4.109 +2429 is 4.102 +2393 is 4.044 +2369 is 4.000 +2343 is 3.957 +2319 is 3.916 +2269 is 3.831 + +""" + +import lvgl as lv +import time + +import mpos.battery_voltage +from mpos.apps import Activity + +class Hello(Activity): + + refresh_timer = None + + # Widgets: + raw_label = None + + def onCreate(self): + s = lv.obj() + self.raw_label = lv.label(s) + self.raw_label.set_text("starting...") + self.raw_label.center() + self.setContentView(s) + + def onResume(self, screen): + super().onResume(screen) + + def update_bat(timer): + #global l + r = mpos.battery_voltage.read_raw_adc() + v = mpos.battery_voltage.read_battery_voltage() + percent = mpos.battery_voltage.get_battery_percentage() + text = f"{time.localtime()}\n{r}\n{v}V\n{percent}%" + #text = f"{time.localtime()}: {r}" + print(text) + self.update_ui_threadsafe_if_foreground(self.raw_label.set_text, text) + + self.refresh_timer = lv.timer_create(update_bat,1000,None) #.set_repeat_count(10) + + def onPause(self, screen): + super().onPause(screen) + if self.refresh_timer: + self.refresh_timer.delete() diff --git a/internal_filesystem/apps/com.micropythonos.showfonts/META-INF/MANIFEST.JSON b/internal_filesystem/apps/com.micropythonos.showfonts/META-INF/MANIFEST.JSON new file mode 100644 index 00000000..85d27da8 --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.showfonts/META-INF/MANIFEST.JSON @@ -0,0 +1,24 @@ +{ +"name": "ShowFonts", +"publisher": "MicroPythonOS", +"short_description": "Show installed fonts", +"long_description": "Visualize the installed fonts so the user can check them out.", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.showfonts/icons/com.micropythonos.showfonts_0.0.2_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.showfonts/mpks/com.micropythonos.showfonts_0.0.2.mpk", +"fullname": "com.micropythonos.showfonts", +"version": "0.0.2", +"category": "development", +"activities": [ + { + "entrypoint": "assets/showfonts.py", + "classname": "ShowFonts", + "intent_filters": [ + { + "action": "main", + "category": "launcher" + } + ] + } + ] +} + diff --git a/internal_filesystem/apps/com.micropythonos.showfonts/assets/showfonts.py b/internal_filesystem/apps/com.micropythonos.showfonts/assets/showfonts.py new file mode 100644 index 00000000..d03e8119 --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.showfonts/assets/showfonts.py @@ -0,0 +1,133 @@ +from mpos.apps import Activity +import lvgl as lv + +class ShowFonts(Activity): + def onCreate(self): + screen = lv.obj() + + # Make the screen focusable so it can be scrolled with the arrow keys + focusgroup = lv.group_get_default() + if focusgroup: + focusgroup.add_obj(screen) + + y=0 + y = self.addAllFontsTitles(screen) + #self.addAllFonts(screen) + self.addAllGlyphs(screen, y) + self.setContentView(screen) + + + def addAllFontsTitles(self, screen): + fonts = [ + (lv.font_montserrat_8, "Montserrat 8"), # almost too small to read + (lv.font_montserrat_10, "Montserrat 10"), # +2 + (lv.font_montserrat_12, "Montserrat 12"), # +2 (default font, great for launcher and small labels) + (lv.font_unscii_8, "Unscii 8"), + (lv.font_montserrat_14, "Montserrat 14"), # +2 + (lv.font_montserrat_16, "Montserrat 16"), # +2 + #(lv.font_Noto_Sans_sat_emojis_compressed, + # "Noto Sans 16SF"), # 丰 and 😀 + (lv.font_montserrat_18, "Montserrat 18"), # +2 + (lv.font_montserrat_20, "Montserrat 20"), # +2 + (lv.font_montserrat_24, "Montserrat 24"), # +4 + (lv.font_unscii_16, "Unscii 16"), + (lv.font_montserrat_28_compressed, "Montserrat 28"), # +4 + (lv.font_montserrat_34, "Montserrat 34"), # +6 + (lv.font_montserrat_40, "Montserrat 40"), # +6 + (lv.font_montserrat_48, "Montserrat 48"), # +8 + ] + + y = 0 + for font, name in fonts: + title = lv.label(screen) + title.set_style_text_font(font, 0) + title.set_text(f"{name}: 2357 !@#$%^&*( {lv.SYMBOL.OK} {lv.SYMBOL.BACKSPACE} 丰 😀") + title.set_pos(0, y) + y += font.get_line_height() + 4 + + return y + + def addAllFonts(self, screen): + fonts = [ + (lv.font_montserrat_10, "Montserrat 10"), + (lv.font_unscii_8, "Unscii 8"), + (lv.font_montserrat_16, "Montserrat 16"), # +4 + (lv.font_montserrat_22, "Montserrat 22"), # +6 + (lv.font_unscii_16, "Unscii 16"), + (lv.font_montserrat_30, "Montserrat 30"), # +8 + (lv.font_montserrat_38, "Montserrat 38"), # +8 + (lv.font_montserrat_48, "Montserrat 48"), # +10 + ] + + dsc = lv.font_glyph_dsc_t() + + y = 0 + for font, name in fonts: + x = 0 + title = lv.label(screen) + title.set_text(name + ": 2357 !@#$%^&*(") + title.set_style_text_font(lv.font_montserrat_16, 0) + title.set_pos(x, y) + y += title.get_height() + 20 + + line_height = font.get_line_height() + 4 + + for cp in range(0x20, 0xFF): + if font.get_glyph_dsc(font, dsc, cp, cp+1): + lbl = lv.label(screen) + lbl.set_style_text_font(font, 0) + lbl.set_text(chr(cp)) + lbl.set_pos(x, y) + + width = font.get_glyph_width(cp, cp+1) + x += width + if x + width * 2 > screen.get_width(): + x = 0 + y += line_height + + y += line_height*2 + + screen.set_height(y + 20) + + + + def addAllGlyphs(self, screen, start_y): + fonts = [ + #(lv.font_Noto_Sans_sat_emojis_compressed, + # "Noto Sans 16SF"), # 丰 and 😀 + (lv.font_montserrat_16, "Montserrat 16"), + #(lv.font_unscii_16, "Unscii 16"), + #(lv.font_unscii_8, "Unscii 8"), + ] + + dsc = lv.font_glyph_dsc_t() + y = start_y + + for font, name in fonts: + title = lv.label(screen) + title.set_text(name) + title.set_style_text_font(lv.font_montserrat_16, 0) + title.set_pos(4, y) + y += title.get_height() + 20 + + line_height = font.get_line_height() + 4 + x = 4 + for cp in range(0x20, 0x1F9FF): + #for cp in range(0x20, 35920 + 1): + #for cp in range(0x20, 0xFFFF + 1): + if font.get_glyph_dsc(font, dsc, cp, cp): + #print(f"{cp} : {chr(cp)}", end="") + #print(f"{chr(cp)},", end="") + lbl = lv.label(screen) + lbl.set_style_text_font(font, 0) + lbl.set_text(chr(cp)) + lbl.set_pos(x, y) + + x += 20 + if x + 20 > screen.get_width(): + x = 4 + y += line_height + + y += line_height + + screen.set_height(y + 20) diff --git a/internal_filesystem/apps/com.micropythonos.showfonts/generate_icon.py b/internal_filesystem/apps/com.micropythonos.showfonts/generate_icon.py new file mode 100644 index 00000000..8c0797d3 --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.showfonts/generate_icon.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +""" +Generate a 64x64 PNG icon with transparent background for the "ShowFonts" app. +The icon features a stylized bold 'F' with a subtle font preview overlay, +using modern flat design with vibrant colors. +""" + +import cairo +from pathlib import Path + +def create_showfonts_icon(output_path: str = "ShowFonts_icon.png"): + # Icon dimensions + WIDTH, HEIGHT = 64, 64 + RADIUS = 12 # Corner radius for rounded square background + + # Create surface with alpha channel + surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, WIDTH, HEIGHT) + ctx = cairo.Context(surface) + + # Fully transparent background + ctx.set_source_rgba(0, 0, 0, 0) + ctx.paint() + + # === Draw subtle rounded background (optional soft glow base) === + ctx.save() + rounded_rect(ctx, 4, 4, 56, 56, RADIUS) + ctx.set_source_rgba(0.1, 0.1, 0.1, 0.15) # Very subtle dark overlay + ctx.fill() + ctx.restore() + + # === Main colorful gradient background === + ctx.save() + rounded_rect(ctx, 6, 6, 52, 52, RADIUS - 2) + + # Create radial gradient for depth + grad = cairo.RadialGradient(32, 20, 5, 32, 32, 30) + grad.add_color_stop_rgb(0, 0.25, 0.6, 1.0) # Bright blue center + grad.add_color_stop_rgb(0.7, 0.1, 0.4, 0.9) # Mid tone + grad.add_color_stop_rgb(1, 0.05, 0.25, 0.7) # Deep blue edge + ctx.set_source(grad) + ctx.fill() + ctx.restore() + + # === Draw bold stylized 'F' === + ctx.save() + ctx.select_font_face("Sans", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD) + ctx.set_font_size(38) + + # Position 'F' centered + x_bearing, y_bearing, text_width, text_height = ctx.text_extents("F")[:4] + x = 32 - text_width / 2 - x_bearing + y = 38 - text_height / 2 - y_bearing + + ctx.move_to(x, y) + ctx.set_source_rgb(1.0, 1.0, 1.0) # Pure white + ctx.show_text("F") + ctx.restore() + + # === Add small font preview overlay (Aa) === + ctx.save() + ctx.select_font_face("Serif", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL) + ctx.set_font_size(11) + + extents = ctx.text_extents("Aa") + x = 32 - extents.width / 2 - extents.x_bearing + y = 50 - extents.height / 2 - extents.y_bearing + + # Shadow for depth + ctx.move_to(x + 0.5, y + 0.5) + ctx.set_source_rgba(0, 0, 0, 0.3) + ctx.show_text("Aa") + + # Main text + ctx.move_to(x, y) + ctx.set_source_rgb(1.0, 1.0, 0.7) # Light yellow + ctx.show_text("Aa") + ctx.restore() + + # === Add subtle highlight on 'F' === + ctx.save() + ctx.set_line_width(1.5) + ctx.set_source_rgba(1, 1, 1, 0.4) + + # Top bar highlight + ctx.move_to(14, 20) + ctx.line_to(26, 20) + ctx.stroke() + + # Middle bar highlight + ctx.move_to(14, 29) + ctx.line_to(23, 29) + ctx.stroke() + ctx.restore() + + # Save to PNG + surface.write_to_png(output_path) + print(f"Icon saved to: {Path(output_path).resolve()}") + +def rounded_rect(ctx, x, y, width, height, radius): + """Draw a rounded rectangle path""" + from math import pi + ctx.move_to(x + radius, y) + ctx.arc(x + width - radius, y + radius, radius, pi * 1.5, pi * 2) + ctx.arc(x + width - radius, y + height - radius, radius, 0, pi * 0.5) + ctx.arc(x + radius, y + height - radius, radius, pi * 0.5, pi) + ctx.arc(x + radius, y + radius, radius, pi, pi * 1.5) + ctx.close_path() + +if __name__ == "__main__": + create_showfonts_icon() diff --git a/internal_filesystem/apps/com.micropythonos.showfonts/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/apps/com.micropythonos.showfonts/res/mipmap-mdpi/icon_64x64.png new file mode 100644 index 00000000..b848f8e6 Binary files /dev/null and b/internal_filesystem/apps/com.micropythonos.showfonts/res/mipmap-mdpi/icon_64x64.png differ diff --git a/internal_filesystem/apps/com.micropythonos.soundrecorder/META-INF/MANIFEST.JSON b/internal_filesystem/apps/com.micropythonos.soundrecorder/META-INF/MANIFEST.JSON new file mode 100644 index 00000000..eef5faf3 --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.soundrecorder/META-INF/MANIFEST.JSON @@ -0,0 +1,23 @@ +{ + "name": "Sound Recorder", + "publisher": "MicroPythonOS", + "short_description": "Record audio from microphone", + "long_description": "Record audio from the I2S microphone and save as WAV files. Recordings can be played back with the Music Player app.", + "icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.soundrecorder/icons/com.micropythonos.soundrecorder_0.0.1_64x64.png", + "download_url": "https://apps.micropythonos.com/apps/com.micropythonos.soundrecorder/mpks/com.micropythonos.soundrecorder_0.0.1.mpk", + "fullname": "com.micropythonos.soundrecorder", + "version": "0.0.1", + "category": "utilities", + "activities": [ + { + "entrypoint": "assets/sound_recorder.py", + "classname": "SoundRecorder", + "intent_filters": [ + { + "action": "main", + "category": "launcher" + } + ] + } + ] +} \ No newline at end of file diff --git a/internal_filesystem/apps/com.micropythonos.soundrecorder/assets/sound_recorder.py b/internal_filesystem/apps/com.micropythonos.soundrecorder/assets/sound_recorder.py new file mode 100644 index 00000000..3fe52476 --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.soundrecorder/assets/sound_recorder.py @@ -0,0 +1,407 @@ +# Sound Recorder App - Record audio from I2S microphone to WAV files +import os +import time + +from mpos.apps import Activity +import mpos.ui +import mpos.audio.audioflinger as AudioFlinger + + +def _makedirs(path): + """ + Create directory and all parent directories (like os.makedirs). + MicroPython doesn't have os.makedirs, so we implement it manually. + """ + if not path: + return + + parts = path.split('/') + current = '' + + for part in parts: + if not part: + continue + current = current + '/' + part if current else part + try: + os.mkdir(current) + except OSError: + pass # Directory may already exist + + +class SoundRecorder(Activity): + """ + Sound Recorder app for recording audio from I2S microphone. + Saves recordings as WAV files that can be played with Music Player. + """ + + # Constants + RECORDINGS_DIR = "data/recordings" + SAMPLE_RATE = 16000 # 16kHz + BYTES_PER_SAMPLE = 2 # 16-bit audio + BYTES_PER_SECOND = SAMPLE_RATE * BYTES_PER_SAMPLE # 32000 bytes/sec + MIN_DURATION_MS = 5000 # Minimum 5 seconds + MAX_DURATION_MS = 3600000 # Maximum 1 hour (absolute cap) + SAFETY_MARGIN = 0.80 # Use only 80% of available space + + # UI Widgets + _status_label = None + _timer_label = None + _record_button = None + _record_button_label = None + _play_button = None + _play_button_label = None + _delete_button = None + _last_file_label = None + + # State + _is_recording = False + _last_recording = None + _timer_task = None + _record_start_time = 0 + + def onCreate(self): + screen = lv.obj() + + # Calculate max duration based on available storage + self._current_max_duration_ms = self._calculate_max_duration() + + # Title + title = lv.label(screen) + title.set_text("Sound Recorder") + title.align(lv.ALIGN.TOP_MID, 0, 10) + title.set_style_text_font(lv.font_montserrat_20, 0) + + # Status label (shows microphone availability) + self._status_label = lv.label(screen) + self._status_label.align(lv.ALIGN.TOP_MID, 0, 40) + + # Timer display + self._timer_label = lv.label(screen) + self._timer_label.set_text(self._format_timer_text(0)) + self._timer_label.align(lv.ALIGN.CENTER, 0, -30) + self._timer_label.set_style_text_font(lv.font_montserrat_24, 0) + + # Record button + self._record_button = lv.button(screen) + self._record_button.set_size(120, 50) + self._record_button.align(lv.ALIGN.CENTER, 0, 30) + self._record_button.add_event_cb(self._on_record_clicked, lv.EVENT.CLICKED, None) + + self._record_button_label = lv.label(self._record_button) + self._record_button_label.set_text(lv.SYMBOL.AUDIO + " Record") + self._record_button_label.center() + + # Last recording info + self._last_file_label = lv.label(screen) + self._last_file_label.align(lv.ALIGN.BOTTOM_MID, 0, -70) + self._last_file_label.set_text("No recordings yet") + self._last_file_label.set_long_mode(lv.label.LONG_MODE.SCROLL_CIRCULAR) + self._last_file_label.set_width(lv.pct(90)) + + # Play button + self._play_button = lv.button(screen) + self._play_button.set_size(80, 40) + self._play_button.align(lv.ALIGN.BOTTOM_LEFT, 20, -20) + self._play_button.add_event_cb(self._on_play_clicked, lv.EVENT.CLICKED, None) + self._play_button.add_flag(lv.obj.FLAG.HIDDEN) + + self._play_button_label = lv.label(self._play_button) + self._play_button_label.set_text(lv.SYMBOL.PLAY + " Play") + self._play_button_label.center() + + # Delete button + self._delete_button = lv.button(screen) + self._delete_button.set_size(80, 40) + self._delete_button.align(lv.ALIGN.BOTTOM_RIGHT, -20, -20) + self._delete_button.add_event_cb(self._on_delete_clicked, lv.EVENT.CLICKED, None) + self._delete_button.add_flag(lv.obj.FLAG.HIDDEN) + + delete_label = lv.label(self._delete_button) + delete_label.set_text(lv.SYMBOL.TRASH + " Delete") + delete_label.center() + + self.setContentView(screen) + + def onResume(self, screen): + super().onResume(screen) + # Recalculate max duration (storage may have changed) + self._current_max_duration_ms = self._calculate_max_duration() + self._timer_label.set_text(self._format_timer_text(0)) + self._update_status() + self._find_last_recording() + + def onPause(self, screen): + super().onPause(screen) + # Stop recording if app goes to background + if self._is_recording: + self._stop_recording() + + def _update_status(self): + """Update status label based on microphone availability.""" + if AudioFlinger.has_microphone(): + self._status_label.set_text("Microphone ready") + self._status_label.set_style_text_color(lv.color_hex(0x00AA00), 0) + self._record_button.remove_flag(lv.obj.FLAG.HIDDEN) + else: + self._status_label.set_text("No microphone available") + self._status_label.set_style_text_color(lv.color_hex(0xAA0000), 0) + self._record_button.add_flag(lv.obj.FLAG.HIDDEN) + + def _find_last_recording(self): + """Find the most recent recording file.""" + try: + # Ensure recordings directory exists + _makedirs(self.RECORDINGS_DIR) + + # List recordings + files = os.listdir(self.RECORDINGS_DIR) + wav_files = [f for f in files if f.endswith('.wav')] + + if wav_files: + # Sort by name (which includes timestamp) + wav_files.sort(reverse=True) + self._last_recording = f"{self.RECORDINGS_DIR}/{wav_files[0]}" + self._last_file_label.set_text(f"Last: {wav_files[0]}") + self._play_button.remove_flag(lv.obj.FLAG.HIDDEN) + self._delete_button.remove_flag(lv.obj.FLAG.HIDDEN) + else: + self._last_recording = None + self._last_file_label.set_text("No recordings yet") + self._play_button.add_flag(lv.obj.FLAG.HIDDEN) + self._delete_button.add_flag(lv.obj.FLAG.HIDDEN) + + except Exception as e: + print(f"SoundRecorder: Error finding recordings: {e}") + self._last_recording = None + + def _calculate_max_duration(self): + """ + Calculate maximum recording duration based on available storage. + Returns duration in milliseconds. + """ + try: + # Ensure recordings directory exists + _makedirs(self.RECORDINGS_DIR) + + # Get filesystem stats for the recordings directory + stat = os.statvfs(self.RECORDINGS_DIR) + + # Calculate free space in bytes + # f_bavail = free blocks available to non-superuser + # f_frsize = fragment size (fundamental block size) + free_bytes = stat[0] * stat[4] # f_frsize * f_bavail + + # Apply safety margin (use only 80% of available space) + usable_bytes = int(free_bytes * self.SAFETY_MARGIN) + + # Calculate max duration in seconds + max_seconds = usable_bytes // self.BYTES_PER_SECOND + + # Convert to milliseconds + max_ms = max_seconds * 1000 + + # Clamp to min/max bounds + max_ms = max(self.MIN_DURATION_MS, min(max_ms, self.MAX_DURATION_MS)) + + print(f"SoundRecorder: Free space: {free_bytes} bytes, " + f"usable: {usable_bytes} bytes, max duration: {max_ms // 1000}s") + + return max_ms + + except Exception as e: + print(f"SoundRecorder: Error calculating max duration: {e}") + # Fall back to a conservative 60 seconds + return 60000 + + def _format_timer_text(self, elapsed_ms): + """Format timer display text showing elapsed / max time.""" + elapsed_sec = elapsed_ms // 1000 + max_sec = self._current_max_duration_ms // 1000 + + elapsed_min = elapsed_sec // 60 + elapsed_sec_display = elapsed_sec % 60 + max_min = max_sec // 60 + max_sec_display = max_sec % 60 + + return f"{elapsed_min:02d}:{elapsed_sec_display:02d} / {max_min:02d}:{max_sec_display:02d}" + + def _generate_filename(self): + """Generate a timestamped filename for the recording.""" + # Get current time + t = time.localtime() + timestamp = f"{t[0]:04d}-{t[1]:02d}-{t[2]:02d}_{t[3]:02d}-{t[4]:02d}-{t[5]:02d}" + return f"{self.RECORDINGS_DIR}/{timestamp}.wav" + + def _on_record_clicked(self, event): + """Handle record button click.""" + print(f"SoundRecorder: _on_record_clicked called, _is_recording={self._is_recording}") + if self._is_recording: + print("SoundRecorder: Stopping recording...") + self._stop_recording() + else: + print("SoundRecorder: Starting recording...") + self._start_recording() + + def _start_recording(self): + """Start recording audio.""" + print("SoundRecorder: _start_recording called") + print(f"SoundRecorder: has_microphone() = {AudioFlinger.has_microphone()}") + + if not AudioFlinger.has_microphone(): + print("SoundRecorder: No microphone available - aborting") + return + + # Generate filename + file_path = self._generate_filename() + print(f"SoundRecorder: Generated filename: {file_path}") + + # Recalculate max duration before starting (storage may have changed) + self._current_max_duration_ms = self._calculate_max_duration() + + if self._current_max_duration_ms < self.MIN_DURATION_MS: + print("SoundRecorder: Not enough storage space") + self._status_label.set_text("Not enough storage space") + self._status_label.set_style_text_color(lv.color_hex(0xAA0000), 0) + return + + # Start recording + print(f"SoundRecorder: Calling AudioFlinger.record_wav()") + print(f" file_path: {file_path}") + print(f" duration_ms: {self._current_max_duration_ms}") + print(f" sample_rate: {self.SAMPLE_RATE}") + + success = AudioFlinger.record_wav( + file_path=file_path, + duration_ms=self._current_max_duration_ms, + on_complete=self._on_recording_complete, + sample_rate=self.SAMPLE_RATE + ) + + print(f"SoundRecorder: record_wav returned: {success}") + + if success: + self._is_recording = True + self._record_start_time = time.ticks_ms() + self._last_recording = file_path + print(f"SoundRecorder: Recording started successfully") + + # Update UI + self._record_button_label.set_text(lv.SYMBOL.STOP + " Stop") + self._record_button.set_style_bg_color(lv.color_hex(0xAA0000), 0) + self._status_label.set_text("Recording...") + self._status_label.set_style_text_color(lv.color_hex(0xAA0000), 0) + + # Hide play/delete buttons during recording + self._play_button.add_flag(lv.obj.FLAG.HIDDEN) + self._delete_button.add_flag(lv.obj.FLAG.HIDDEN) + + # Start timer update + self._start_timer_update() + else: + print("SoundRecorder: record_wav failed!") + self._status_label.set_text("Failed to start recording") + self._status_label.set_style_text_color(lv.color_hex(0xAA0000), 0) + + def _stop_recording(self): + """Stop recording audio.""" + AudioFlinger.stop() + self._is_recording = False + + # Show "Saving..." status immediately (file finalization takes time on SD card) + self._status_label.set_text("Saving...") + self._status_label.set_style_text_color(lv.color_hex(0xFF8800), 0) # Orange + + # Disable record button while saving + self._record_button.add_flag(lv.obj.FLAG.HIDDEN) + + # Stop timer update but keep the elapsed time visible + if self._timer_task: + self._timer_task.delete() + self._timer_task = None + + def _on_recording_complete(self, message): + """Callback when recording finishes.""" + print(f"SoundRecorder: {message}") + + # Update UI on main thread + self.update_ui_threadsafe_if_foreground(self._recording_finished, message) + + def _recording_finished(self, message): + """Update UI after recording finishes (called on main thread).""" + self._is_recording = False + + # Re-enable and reset record button + self._record_button.remove_flag(lv.obj.FLAG.HIDDEN) + self._record_button_label.set_text(lv.SYMBOL.AUDIO + " Record") + self._record_button.set_style_bg_color(lv.theme_get_color_primary(None), 0) + + # Update status and find recordings + self._update_status() + self._find_last_recording() + + # Reset timer display + self._timer_label.set_text(self._format_timer_text(0)) + + def _start_timer_update(self): + """Start updating the timer display.""" + # Use LVGL timer for periodic updates + self._timer_task = lv.timer_create(self._update_timer, 100, None) + + def _stop_timer_update(self): + """Stop updating the timer display.""" + if self._timer_task: + self._timer_task.delete() + self._timer_task = None + self._timer_label.set_text(self._format_timer_text(0)) + + def _update_timer(self, timer): + """Update timer display (called periodically).""" + if not self._is_recording: + return + + elapsed_ms = time.ticks_diff(time.ticks_ms(), self._record_start_time) + self._timer_label.set_text(self._format_timer_text(elapsed_ms)) + + def _on_play_clicked(self, event): + """Handle play button click.""" + if self._last_recording and not self._is_recording: + # Stop any current playback + AudioFlinger.stop() + time.sleep_ms(100) + + # Play the recording + success = AudioFlinger.play_wav( + self._last_recording, + stream_type=AudioFlinger.STREAM_MUSIC, + on_complete=self._on_playback_complete, + volume=100 + ) + + if success: + self._status_label.set_text("Playing...") + self._status_label.set_style_text_color(lv.color_hex(0x0000AA), 0) + else: + self._status_label.set_text("Playback failed") + self._status_label.set_style_text_color(lv.color_hex(0xAA0000), 0) + + def _on_playback_complete(self, message): + """Callback when playback finishes.""" + self.update_ui_threadsafe_if_foreground(self._update_status) + + def _on_delete_clicked(self, event): + """Handle delete button click.""" + if self._last_recording and not self._is_recording: + try: + os.remove(self._last_recording) + print(f"SoundRecorder: Deleted {self._last_recording}") + self._find_last_recording() + + # Recalculate max duration (more space available now) + self._current_max_duration_ms = self._calculate_max_duration() + self._timer_label.set_text(self._format_timer_text(0)) + + self._status_label.set_text("Recording deleted") + except Exception as e: + print(f"SoundRecorder: Delete failed: {e}") + self._status_label.set_text("Delete failed") + self._status_label.set_style_text_color(lv.color_hex(0xAA0000), 0) \ No newline at end of file diff --git a/internal_filesystem/apps/com.micropythonos.soundrecorder/generate_icon.py b/internal_filesystem/apps/com.micropythonos.soundrecorder/generate_icon.py new file mode 100644 index 00000000..f2cfa66c --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.soundrecorder/generate_icon.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +""" +Generate a 64x64 icon for the Sound Recorder app. +Creates a microphone icon with transparent background. + +Run this script to generate the icon: + python3 generate_icon.py + +The icon will be saved to res/mipmap-mdpi/icon_64x64.png +""" + +import os +from PIL import Image, ImageDraw + +def generate_icon(): + # Create a 64x64 image with transparent background + size = 64 + img = Image.new('RGBA', (size, size), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + + # Colors + mic_color = (220, 50, 50, 255) # Red microphone + mic_dark = (180, 40, 40, 255) # Darker red for shading + stand_color = (80, 80, 80, 255) # Gray stand + highlight = (255, 100, 100, 255) # Light red highlight + + # Microphone head (rounded rectangle / ellipse) + mic_top = 8 + mic_bottom = 36 + mic_left = 20 + mic_right = 44 + + # Draw microphone body (rounded top) + draw.ellipse([mic_left, mic_top, mic_right, mic_top + 16], fill=mic_color) + draw.rectangle([mic_left, mic_top + 8, mic_right, mic_bottom], fill=mic_color) + draw.ellipse([mic_left, mic_bottom - 8, mic_right, mic_bottom + 8], fill=mic_color) + + # Microphone grille lines (horizontal lines on mic head) + for y in range(mic_top + 6, mic_bottom - 4, 4): + draw.line([(mic_left + 4, y), (mic_right - 4, y)], fill=mic_dark, width=1) + + # Highlight on left side of mic + draw.arc([mic_left + 2, mic_top + 2, mic_left + 10, mic_top + 18], + start=120, end=240, fill=highlight, width=2) + + # Microphone stand (curved arc under the mic) + stand_top = mic_bottom + 4 + stand_width = 8 + + # Vertical stem from mic + stem_x = size // 2 + draw.rectangle([stem_x - 2, mic_bottom, stem_x + 2, stand_top + 8], fill=stand_color) + + # Curved holder around mic bottom + draw.arc([mic_left - 4, mic_bottom - 8, mic_right + 4, mic_bottom + 16], + start=0, end=180, fill=stand_color, width=3) + + # Stand base + base_y = 54 + draw.rectangle([stem_x - 2, stand_top + 8, stem_x + 2, base_y], fill=stand_color) + draw.ellipse([stem_x - 12, base_y - 2, stem_x + 12, base_y + 6], fill=stand_color) + + # Recording indicator (red dot with glow effect) + dot_x, dot_y = 52, 12 + dot_radius = 5 + + # Glow effect + for r in range(dot_radius + 3, dot_radius, -1): + alpha = int(100 * (dot_radius + 3 - r) / 3) + glow_color = (255, 0, 0, alpha) + draw.ellipse([dot_x - r, dot_y - r, dot_x + r, dot_y + r], fill=glow_color) + + # Solid red dot + draw.ellipse([dot_x - dot_radius, dot_y - dot_radius, + dot_x + dot_radius, dot_y + dot_radius], + fill=(255, 50, 50, 255)) + + # White highlight on dot + draw.ellipse([dot_x - 2, dot_y - 2, dot_x, dot_y], fill=(255, 200, 200, 255)) + + # Ensure output directory exists + output_dir = 'res/mipmap-mdpi' + os.makedirs(output_dir, exist_ok=True) + + # Save the icon + output_path = os.path.join(output_dir, 'icon_64x64.png') + img.save(output_path, 'PNG') + print(f"Icon saved to {output_path}") + + return img + +if __name__ == '__main__': + generate_icon() \ No newline at end of file diff --git a/internal_filesystem/apps/com.micropythonos.soundrecorder/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/apps/com.micropythonos.soundrecorder/res/mipmap-mdpi/icon_64x64.png new file mode 100644 index 00000000..a301f72f Binary files /dev/null and b/internal_filesystem/apps/com.micropythonos.soundrecorder/res/mipmap-mdpi/icon_64x64.png differ diff --git a/internal_filesystem/boot.py b/internal_filesystem/boot.py deleted file mode 100644 index 078ac102..00000000 --- a/internal_filesystem/boot.py +++ /dev/null @@ -1,90 +0,0 @@ -# Hardware initialization for ESP32-S3-Touch-LCD-2 -# Manufacturer's website at https://www.waveshare.com/wiki/ESP32-S3-Touch-LCD-2 -from machine import Pin, SPI -import st7789 -import lcd_bus -import machine -import cst816s -import i2c - -import lvgl as lv -import task_handler - -import mpos.ui -import mpos.info - -mpos.info.set_hardware_id("waveshare-esp32-s3-touch-lcd-2") - -# Pin configuration -SPI_BUS = 2 -SPI_FREQ = 40000000 -LCD_SCLK = 39 -LCD_MOSI = 38 -LCD_MISO = 40 -LCD_DC = 42 -LCD_CS = 45 -LCD_BL = 1 - -I2C_BUS = 0 -I2C_FREQ = 100000 -TP_SDA = 48 -TP_SCL = 47 -TP_ADDR = 0x15 -TP_REGBITS = 8 - -TFT_HOR_RES=320 -TFT_VER_RES=240 - -spi_bus = machine.SPI.Bus( - host=SPI_BUS, - mosi=LCD_MOSI, - miso=LCD_MISO, - sck=LCD_SCLK -) -display_bus = lcd_bus.SPIBus( - spi_bus=spi_bus, - freq=SPI_FREQ, - dc=LCD_DC, - cs=LCD_CS, -) - - # lv.color_format_get_size(lv.COLOR_FORMAT.RGB565) = 2 bytes per pixel * 320 * 240 px = 153600 bytes - # The default was /10 so 15360 bytes. - # /2 = 76800 shows something on display and then hangs the board - # /2 = 38400 works and pretty high framerate but camera gets ESP_FAIL - # /2 = 19200 works, including camera at 9FPS - # 28800 is between the two and still works with camera! - # 30720 is /5 and is already too much -_BUFFER_SIZE = const(28800) -fb1 = display_bus.allocate_framebuffer(_BUFFER_SIZE, lcd_bus.MEMORY_INTERNAL | lcd_bus.MEMORY_DMA) -fb2 = display_bus.allocate_framebuffer(_BUFFER_SIZE, lcd_bus.MEMORY_INTERNAL | lcd_bus.MEMORY_DMA) - -display = st7789.ST7789( - data_bus=display_bus, - frame_buffer1=fb1, - frame_buffer2=fb2, - display_width=TFT_VER_RES, - display_height=TFT_HOR_RES, - backlight_pin=LCD_BL, - backlight_on_state=st7789.STATE_PWM, - color_space=lv.COLOR_FORMAT.RGB565, - color_byte_order=st7789.BYTE_ORDER_BGR, - rgb565_byte_swap=True, -) -display.init() -display.set_power(True) -display.set_backlight(100) - -# Touch handling: -i2c_bus = i2c.I2C.Bus(host=I2C_BUS, scl=TP_SCL, sda=TP_SDA, freq=I2C_FREQ, use_locks=False) -touch_dev = i2c.I2C.Device(bus=i2c_bus, dev_id=TP_ADDR, reg_bits=TP_REGBITS) -indev=cst816s.CST816S(touch_dev,startup_rotation=lv.DISPLAY_ROTATION._180) # button in top left, good - -lv.init() -display.set_rotation(lv.DISPLAY_ROTATION._90) # must be done after initializing display and creating the touch drivers, to ensure proper handling - -# Battery voltage ADC measuring -import mpos.battery_voltage -mpos.battery_voltage.init_adc(5, 262 / 100000) - -print("boot.py finished") diff --git a/internal_filesystem/builtin/apps/com.micropythonos.about/META-INF/MANIFEST.JSON b/internal_filesystem/builtin/apps/com.micropythonos.about/META-INF/MANIFEST.JSON index 2c6a491c..a09cd929 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.about/META-INF/MANIFEST.JSON +++ b/internal_filesystem/builtin/apps/com.micropythonos.about/META-INF/MANIFEST.JSON @@ -3,10 +3,10 @@ "publisher": "MicroPythonOS", "short_description": "Info about MicroPythonOS", "long_description": "Shows current MicroPythonOS version, MicroPython version, build date and other useful info..", -"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.about/icons/com.micropythonos.about_0.0.5_64x64.png", -"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.about/mpks/com.micropythonos.about_0.0.5.mpk", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.about/icons/com.micropythonos.about_0.0.7_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.about/mpks/com.micropythonos.about_0.0.7.mpk", "fullname": "com.micropythonos.about", -"version": "0.0.5", +"version": "0.0.7", "category": "development", "activities": [ { diff --git a/internal_filesystem/builtin/apps/com.micropythonos.about/assets/about.py b/internal_filesystem/builtin/apps/com.micropythonos.about/assets/about.py index bd2cee48..00c9767e 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.about/assets/about.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.about/assets/about.py @@ -25,10 +25,24 @@ def onCreate(self): label3.set_text(f"sys.implementation: {sys.implementation}") label4 = lv.label(screen) label4.set_text(f"sys.platform: {sys.platform}") + label15 = lv.label(screen) + label15.set_text(f"sys.path: {sys.path}") + import micropython + label16 = lv.label(screen) + label16.set_text(f"micropython.opt_level(): {micropython.opt_level()}") + import gc + label17 = lv.label(screen) + label17.set_text(f"Memory: {gc.mem_free()} free, {gc.mem_alloc()} allocated, {gc.mem_alloc()+gc.mem_free()} total") + # These are always written to sys.stdout + #label16.set_text(f"micropython.mem_info(): {micropython.mem_info()}") + #label18 = lv.label(screen) + #label18.set_text(f"micropython.qstr_info(): {micropython.qstr_info()}") + label19 = lv.label(screen) + label19.set_text(f"mpos.__path__: {mpos.__path__}") # this will show .frozen if the /lib folder is frozen (prod build) try: + from esp32 import Partition label5 = lv.label(screen) label5.set_text("") # otherwise it will show the default "Text" if there's an exception below - from esp32 import Partition current = Partition(Partition.RUNNING) label5.set_text(f"Partition.RUNNING: {current}") next_partition = current.get_next_update() @@ -38,9 +52,9 @@ def onCreate(self): print(f"Partition info got exception: {e}") try: print("Trying to find out additional board info, not available on every platform...") + import machine label7 = lv.label(screen) label7.set_text("") # otherwise it will show the default "Text" if there's an exception below - import machine label7.set_text(f"machine.freq: {machine.freq()}") label8 = lv.label(screen) label8.set_text(f"machine.unique_id(): {machine.unique_id()}") @@ -62,11 +76,41 @@ def onCreate(self): label14 = lv.label(screen) label14.set_text(f"freezefs_mount_builtin.version: {freezefs_mount_builtin.version}") except Exception as e: - # This will throw an exception if there is already a "/builtin" folder present + # This will throw an EEXIST exception if there is already a "/builtin" folder present + # It will throw "no module named 'freezefs_mount_builtin'" if there is no frozen filesystem + # It's possible that the user had a dev build with a non-frozen /buitin folder in the vfat storage partition, + # and then they install a prod build (with OSUpdate) that then is unable to mount the freezefs into /builtin + # BUT which will still have the frozen-inside /lib folder. So the user will be able to install apps into /builtin + # but they will not be able to install libraries into /lib. print("main.py: WARNING: could not import/run freezefs_mount_builtin: ", e) label11 = lv.label(screen) label11.set_text(f"freezefs_mount_builtin exception (normal on dev builds): {e}") - # TODO: - # - add total size, used and free space on internal storage - # - add total size, used and free space on SD card + # Disk usage: + import os + try: + stat = os.statvfs('/') + total_space = stat[0] * stat[2] + free_space = stat[0] * stat[3] + used_space = total_space - free_space + label20 = lv.label(screen) + label20.set_text(f"Total space in /: {total_space} bytes") + label21 = lv.label(screen) + label21.set_text(f"Free space in /: {free_space} bytes") + label22 = lv.label(screen) + label22.set_text(f"Used space in /: {used_space} bytes") + except Exception as e: + print(f"About app could not get info on / filesystem: {e}") + try: + stat = os.statvfs('/sdcard') + total_space = stat[0] * stat[2] + free_space = stat[0] * stat[3] + used_space = total_space - free_space + label23 = lv.label(screen) + label23.set_text(f"Total space /sdcard: {total_space} bytes") + label24 = lv.label(screen) + label24.set_text(f"Free space /sdcard: {free_space} bytes") + label25 = lv.label(screen) + label25.set_text(f"Used space /sdcard: {used_space} bytes") + except Exception as e: + print(f"About app could not get info on /sdcard filesystem: {e}") self.setContentView(screen) diff --git a/internal_filesystem/builtin/apps/com.micropythonos.appstore/META-INF/MANIFEST.JSON b/internal_filesystem/builtin/apps/com.micropythonos.appstore/META-INF/MANIFEST.JSON index 16713240..f7afe5a8 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.appstore/META-INF/MANIFEST.JSON +++ b/internal_filesystem/builtin/apps/com.micropythonos.appstore/META-INF/MANIFEST.JSON @@ -3,10 +3,10 @@ "publisher": "MicroPythonOS", "short_description": "Store for App(lication)s", "long_description": "This is the place to discover, find, install, uninstall and upgrade all the apps that make your device useless.", -"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.appstore/icons/com.micropythonos.appstore_0.0.8_64x64.png", -"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.appstore/mpks/com.micropythonos.appstore_0.0.8.mpk", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.appstore/icons/com.micropythonos.appstore_0.0.9_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.appstore/mpks/com.micropythonos.appstore_0.0.9.mpk", "fullname": "com.micropythonos.appstore", -"version": "0.0.8", +"version": "0.0.9", "category": "appstore", "activities": [ { diff --git a/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/appstore.py b/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/appstore.py index cdba28dc..d02a53e9 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/appstore.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/appstore.py @@ -3,18 +3,29 @@ import requests import gc import os -import time -import _thread from mpos.apps import Activity, Intent from mpos.app import App +from mpos import TaskManager, DownloadManager import mpos.ui from mpos.content.package_manager import PackageManager - class AppStore(Activity): + + _BADGEHUB_API_BASE_URL = "https://badgehub.p1m.nl/api/v3" + _BADGEHUB_LIST = "project-summaries?badge=fri3d_2024" + _BADGEHUB_DETAILS = "projects" + + _BACKEND_API_GITHUB = "github" + _BACKEND_API_BADGEHUB = "badgehub" + apps = [] - app_index_url = "https://apps.micropythonos.com/app_index.json" + # These might become configurations: + #backend_api = _BACKEND_API_BADGEHUB + backend_api = _BACKEND_API_GITHUB + app_index_url_github = "https://apps.micropythonos.com/app_index.json" + app_index_url_badgehub = _BADGEHUB_API_BASE_URL + "/" + _BADGEHUB_LIST + app_detail_url_badgehub = _BADGEHUB_API_BASE_URL + "/" + _BADGEHUB_DETAILS can_check_network = True # Widgets: @@ -43,41 +54,48 @@ def onResume(self, screen): if self.can_check_network and not network.WLAN(network.STA_IF).isconnected(): self.please_wait_label.set_text("Error: WiFi is not connected.") else: - _thread.stack_size(mpos.apps.good_stack_size()) - _thread.start_new_thread(self.download_app_index, (self.app_index_url,)) + if self.backend_api == self._BACKEND_API_BADGEHUB: + TaskManager.create_task(self.download_app_index(self.app_index_url_badgehub)) + else: + TaskManager.create_task(self.download_app_index(self.app_index_url_github)) - def download_app_index(self, json_url): + async def download_app_index(self, json_url): + response = await DownloadManager.download_url(json_url) + if not response: + self.please_wait_label.set_text(f"Could not download app index from\n{json_url}") + return + print(f"Got response text: {response[0:20]}") try: - response = requests.get(json_url, timeout=10) + parsed = json.loads(response) + print(f"parsed json: {parsed}") + for app in parsed: + try: + if self.backend_api == self._BACKEND_API_BADGEHUB: + self.apps.append(AppStore.badgehub_app_to_mpos_app(app)) + else: + self.apps.append(App(app["name"], app["publisher"], app["short_description"], app["long_description"], app["icon_url"], app["download_url"], app["fullname"], app["version"], app["category"], app["activities"])) + except Exception as e: + print(f"Warning: could not add app from {json_url} to apps list: {e}") except Exception as e: - print("Download failed:", e) - self.update_ui_threadsafe_if_foreground(self.please_wait_label.set_text, f"App index download \n{json_url}\ngot error: {e}") + self.please_wait_label.set_text(f"ERROR: could not parse reponse.text JSON: {e}") return - if response and response.status_code == 200: - #print(f"Got response text: {response.text}") - try: - for app in json.loads(response.text): - try: - self.apps.append(App(app["name"], app["publisher"], app["short_description"], app["long_description"], app["icon_url"], app["download_url"], app["fullname"], app["version"], app["category"], app["activities"])) - except Exception as e: - print(f"Warning: could not add app from {json_url} to apps list: {e}") - except Exception as e: - print(f"ERROR: could not parse reponse.text JSON: {e}") - finally: - response.close() - # Remove duplicates based on app.name - seen = set() - self.apps = [app for app in self.apps if not (app.fullname in seen or seen.add(app.fullname))] - # Sort apps by app.name - self.apps.sort(key=lambda x: x.name.lower()) # Use .lower() for case-insensitive sorting - time.sleep_ms(200) - self.update_ui_threadsafe_if_foreground(self.please_wait_label.add_flag, lv.obj.FLAG.HIDDEN) - self.update_ui_threadsafe_if_foreground(self.create_apps_list) - time.sleep(0.1) # give the UI time to display the app list before starting to download - self.download_icons() + self.please_wait_label.set_text(f"Download successful, building list...") + await TaskManager.sleep(0.1) # give the UI time to display the app list before starting to download + print("Remove duplicates based on app.name") + seen = set() + self.apps = [app for app in self.apps if not (app.fullname in seen or seen.add(app.fullname))] + print("Sort apps by app.name") + self.apps.sort(key=lambda x: x.name.lower()) # Use .lower() for case-insensitive sorting + print("Creating apps list...") + self.create_apps_list() + await TaskManager.sleep(0.1) # give the UI time to display the app list before starting to download + print("awaiting self.download_icons()") + await self.download_icons() def create_apps_list(self): print("create_apps_list") + print("Hiding please wait label...") + self.please_wait_label.add_flag(lv.obj.FLAG.HIDDEN) apps_list = lv.list(self.main_screen) apps_list.set_style_border_width(0, 0) apps_list.set_style_radius(0, 0) @@ -119,14 +137,19 @@ def create_apps_list(self): desc_label.set_style_text_font(lv.font_montserrat_12, 0) desc_label.add_event_cb(lambda e, a=app: self.show_app_detail(a), lv.EVENT.CLICKED, None) print("create_apps_list app done") - - def download_icons(self): + + async def download_icons(self): + print("Downloading icons...") for app in self.apps: if not self.has_foreground(): - print(f"App is stopping, aborting icon downloads.") + print(f"App is stopping, aborting icon downloads.") # maybe this can continue? but then update_ui_if_foreground is needed break if not app.icon_data: - app.icon_data = self.download_icon_data(app.icon_url) + try: + app.icon_data = await TaskManager.wait_for(DownloadManager.download_url(app.icon_url), 5) # max 5 seconds per icon + except Exception as e: + print(f"Download of {app.icon_url} got exception: {e}") + continue if app.icon_data: print("download_icons has icon_data, showing it...") image_icon_widget = None @@ -139,28 +162,95 @@ def download_icons(self): 'data_size': len(app.icon_data), 'data': app.icon_data }) - self.update_ui_threadsafe_if_foreground(image_icon_widget.set_src, image_dsc) # error: 'App' object has no attribute 'image' + image_icon_widget.set_src(image_dsc) # use some kind of new update_ui_if_foreground() ? print("Finished downloading icons.") def show_app_detail(self, app): intent = Intent(activity_class=AppDetail) intent.putExtra("app", app) + intent.putExtra("appstore", self) self.startActivity(intent) @staticmethod - def download_icon_data(url): - print(f"Downloading icon from {url}") + def badgehub_app_to_mpos_app(bhapp): + #print(f"Converting {bhapp} to MPOS app object...") + name = bhapp.get("name") + print(f"Got app name: {name}") + publisher = None + short_description = bhapp.get("description") + long_description = None try: - response = requests.get(url, timeout=5) - if response.status_code == 200: - image_data = response.content - print("Downloaded image, size:", len(image_data), "bytes") - return image_data - else: - print("Failed to download image: Status code", response.status_code) + icon_url = bhapp.get("icon_map").get("64x64").get("url") + except Exception as e: + icon_url = None + print("Could not find icon_map 64x64 url") + download_url = None + fullname = bhapp.get("slug") + version = None + try: + category = bhapp.get("categories")[0] + except Exception as e: + category = None + print("Could not parse category") + activities = None + return App(name, publisher, short_description, long_description, icon_url, download_url, fullname, version, category, activities) + + async def fetch_badgehub_app_details(self, app_obj): + details_url = self.app_detail_url_badgehub + "/" + app_obj.fullname + response = await DownloadManager.download_url(details_url) + if not response: + print(f"Could not download app details from from\n{details_url}") + return + print(f"Got response text: {response[0:20]}") + try: + parsed = json.loads(response) + print(f"parsed json: {parsed}") + print("Using short_description as long_description because backend doesn't support it...") + app_obj.long_description = app_obj.short_description + print("Finding version number...") + try: + version = parsed.get("version") + except Exception as e: + print(f"Could not get version object from appdetails: {e}") + return + print(f"got version object: {version}") + # Find .mpk download URL: + try: + files = version.get("files") + for file in files: + print(f"parsing file: {file}") + ext = file.get("ext").lower() + print(f"file has extension: {ext}") + if ext == ".mpk": + app_obj.download_url = file.get("url") + app_obj.download_url_size = file.get("size_of_content") + break # only one .mpk per app is supported + except Exception as e: + print(f"Could not get files from version: {e}") + try: + app_metadata = version.get("app_metadata") + except Exception as e: + print(f"Could not get app_metadata object from version object: {e}") + return + try: + author = app_metadata.get("author") + print("Using author as publisher because that's all the backend supports...") + app_obj.publisher = author + except Exception as e: + print(f"Could not get author from version object: {e}") + try: + app_version = app_metadata.get("version") + print(f"what: {version.get('app_metadata')}") + print(f"app has app_version: {app_version}") + app_obj.version = app_version + except Exception as e: + print(f"Could not get version from app_metadata: {e}") except Exception as e: - print(f"Exception during download of icon: {e}") - return None + err = f"ERROR: could not parse app details JSON: {e}" + print(err) + self.please_wait_label.set_text(err) + return + class AppDetail(Activity): @@ -174,10 +264,19 @@ class AppDetail(Activity): update_button = None progress_bar = None install_label = None + long_desc_label = None + version_label = None + buttoncont = None + publisher_label = None + + # Received from the Intent extras: + app = None + appstore = None def onCreate(self): print("Creating app detail screen...") - app = self.getIntent().extras.get("app") + self.app = self.getIntent().extras.get("app") + self.appstore = self.getIntent().extras.get("appstore") app_detail_screen = lv.obj() app_detail_screen.set_style_pad_all(5, 0) app_detail_screen.set_size(lv.pct(100), lv.pct(100)) @@ -192,10 +291,10 @@ def onCreate(self): headercont.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) icon_spacer = lv.image(headercont) icon_spacer.set_size(64, 64) - if app.icon_data: + if self.app.icon_data: image_dsc = lv.image_dsc_t({ - 'data_size': len(app.icon_data), - 'data': app.icon_data + 'data_size': len(self.app.icon_data), + 'data': self.app.icon_data }) icon_spacer.set_src(image_dsc) else: @@ -206,55 +305,82 @@ def onCreate(self): detail_cont.set_style_pad_all(0, 0) detail_cont.set_flex_flow(lv.FLEX_FLOW.COLUMN) detail_cont.set_size(lv.pct(75), lv.SIZE_CONTENT) + detail_cont.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) name_label = lv.label(detail_cont) - name_label.set_text(app.name) + name_label.set_text(self.app.name) name_label.set_style_text_font(lv.font_montserrat_24, 0) - publisher_label = lv.label(detail_cont) - publisher_label.set_text(app.publisher) - publisher_label.set_style_text_font(lv.font_montserrat_16, 0) + self.publisher_label = lv.label(detail_cont) + if self.app.publisher: + self.publisher_label.set_text(self.app.publisher) + else: + self.publisher_label.set_text("Unknown publisher") + self.publisher_label.set_style_text_font(lv.font_montserrat_16, 0) self.progress_bar = lv.bar(app_detail_screen) self.progress_bar.set_width(lv.pct(100)) self.progress_bar.set_range(0, 100) self.progress_bar.add_flag(lv.obj.FLAG.HIDDEN) # Always have this button: - buttoncont = lv.obj(app_detail_screen) - buttoncont.set_style_border_width(0, 0) - buttoncont.set_style_radius(0, 0) - buttoncont.set_style_pad_all(0, 0) - buttoncont.set_flex_flow(lv.FLEX_FLOW.ROW) - buttoncont.set_size(lv.pct(100), lv.SIZE_CONTENT) - buttoncont.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) - print(f"Adding (un)install button for url: {app.download_url}") + self.buttoncont = lv.obj(app_detail_screen) + self.buttoncont.set_style_border_width(0, 0) + self.buttoncont.set_style_radius(0, 0) + self.buttoncont.set_style_pad_all(0, 0) + self.buttoncont.set_flex_flow(lv.FLEX_FLOW.ROW) + self.buttoncont.set_size(lv.pct(100), lv.SIZE_CONTENT) + self.buttoncont.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) + self.add_action_buttons(self.buttoncont, self.app) + # version label: + self.version_label = lv.label(app_detail_screen) + self.version_label.set_width(lv.pct(100)) + if self.app.version: + self.version_label.set_text(f"Latest version: {self.app.version}") # would be nice to make this bold if this is newer than the currently installed one + else: + self.version_label.set_text(f"Unknown version") + self.version_label.set_style_text_font(lv.font_montserrat_12, 0) + self.version_label.align_to(self.install_button, lv.ALIGN.OUT_BOTTOM_MID, 0, lv.pct(5)) + self.long_desc_label = lv.label(app_detail_screen) + self.long_desc_label.align_to(self.version_label, lv.ALIGN.OUT_BOTTOM_MID, 0, lv.pct(5)) + if self.app.long_description: + self.long_desc_label.set_text(self.app.long_description) + else: + self.long_desc_label.set_text(self.app.short_description) + self.long_desc_label.set_style_text_font(lv.font_montserrat_12, 0) + self.long_desc_label.set_width(lv.pct(100)) + print("Loading app detail screen...") + self.setContentView(app_detail_screen) + + def onResume(self, screen): + if self.appstore.backend_api == self.appstore._BACKEND_API_BADGEHUB: + TaskManager.create_task(self.fetch_and_set_app_details()) + else: + print("No need to fetch app details as the github app index already contains all the app data.") + + def add_action_buttons(self, buttoncont, app): + buttoncont.clean() + print(f"Adding (un)install button for url: {self.app.download_url}") self.install_button = lv.button(buttoncont) - self.install_button.add_event_cb(lambda e, d=app.download_url, f=app.fullname: self.toggle_install(d,f), lv.EVENT.CLICKED, None) + self.install_button.add_event_cb(lambda e, a=self.app: self.toggle_install(a), lv.EVENT.CLICKED, None) self.install_button.set_size(lv.pct(100), 40) self.install_label = lv.label(self.install_button) self.install_label.center() - self.set_install_label(app.fullname) - if PackageManager.is_update_available(app.fullname, app.version): + self.set_install_label(self.app.fullname) + if app.version and PackageManager.is_update_available(self.app.fullname, app.version): self.install_button.set_size(lv.pct(47), 40) # make space for update button print("Update available, adding update button.") self.update_button = lv.button(buttoncont) self.update_button.set_size(lv.pct(47), 40) - self.update_button.add_event_cb(lambda e, d=app.download_url, f=app.fullname: self.update_button_click(d,f), lv.EVENT.CLICKED, None) + self.update_button.add_event_cb(lambda e, a=self.app: self.update_button_click(a), lv.EVENT.CLICKED, None) update_label = lv.label(self.update_button) update_label.set_text("Update") update_label.center() - # version label: - version_label = lv.label(app_detail_screen) - version_label.set_width(lv.pct(100)) - version_label.set_text(f"Latest version: {app.version}") # make this bold if this is newer than the currently installed one - version_label.set_style_text_font(lv.font_montserrat_12, 0) - version_label.align_to(self.install_button, lv.ALIGN.OUT_BOTTOM_MID, 0, lv.pct(5)) - long_desc_label = lv.label(app_detail_screen) - long_desc_label.align_to(version_label, lv.ALIGN.OUT_BOTTOM_MID, 0, lv.pct(5)) - long_desc_label.set_text(app.long_description) - long_desc_label.set_style_text_font(lv.font_montserrat_12, 0) - long_desc_label.set_width(lv.pct(100)) - print("Loading app detail screen...") - self.setContentView(app_detail_screen) - + + async def fetch_and_set_app_details(self): + await self.appstore.fetch_badgehub_app_details(self.app) + print(f"app has version: {self.app.version}") + self.version_label.set_text(self.app.version) + self.long_desc_label.set_text(self.app.long_description) + self.publisher_label.set_text(self.app.publisher) + self.add_action_buttons(self.buttoncont, self.app) def set_install_label(self, app_fullname): # Figure out whether to show: @@ -283,43 +409,37 @@ def set_install_label(self, app_fullname): action_label = self.action_label_install self.install_label.set_text(action_label) - def toggle_install(self, download_url, fullname): - print(f"Install button clicked for {download_url} and fullname {fullname}") + def toggle_install(self, app_obj): + print(f"Install button clicked for {app_obj}") + download_url = app_obj.download_url + fullname = app_obj.fullname + print(f"With {download_url} and fullname {fullname}") label_text = self.install_label.get_text() if label_text == self.action_label_install: - try: - _thread.stack_size(mpos.apps.good_stack_size()) - _thread.start_new_thread(self.download_and_install, (download_url, f"apps/{fullname}", fullname)) - except Exception as e: - print("Could not start download_and_install thread: ", e) + print("Starting install task...") + TaskManager.create_task(self.download_and_install(app_obj, f"apps/{fullname}")) elif label_text == self.action_label_uninstall or label_text == self.action_label_restore: - print("Uninstalling app....") - try: - _thread.stack_size(mpos.apps.good_stack_size()) - _thread.start_new_thread(self.uninstall_app, (fullname,)) - except Exception as e: - print("Could not start uninstall_app thread: ", e) + print("Starting uninstall task...") + TaskManager.create_task(self.uninstall_app(fullname)) - def update_button_click(self, download_url, fullname): + def update_button_click(self, app_obj): + download_url = app_obj.download_url + fullname = app_obj.fullname print(f"Update button clicked for {download_url} and fullname {fullname}") self.update_button.add_flag(lv.obj.FLAG.HIDDEN) self.install_button.set_size(lv.pct(100), 40) - try: - _thread.stack_size(mpos.apps.good_stack_size()) - _thread.start_new_thread(self.download_and_install, (download_url, f"apps/{fullname}", fullname)) - except Exception as e: - print("Could not start download_and_install thread: ", e) + TaskManager.create_task(self.download_and_install(app_obj, f"apps/{fullname}")) - def uninstall_app(self, app_fullname): + async def uninstall_app(self, app_fullname): self.install_button.add_state(lv.STATE.DISABLED) self.install_label.set_text("Please wait...") self.progress_bar.remove_flag(lv.obj.FLAG.HIDDEN) self.progress_bar.set_value(21, True) - time.sleep(1) # seems silly but otherwise it goes so quickly that the user can't tell something happened and gets confused + await TaskManager.sleep(1) # seems silly but otherwise it goes so quickly that the user can't tell something happened and gets confused self.progress_bar.set_value(42, True) - time.sleep(1) # seems silly but otherwise it goes so quickly that the user can't tell something happened and gets confused + await TaskManager.sleep(1) # seems silly but otherwise it goes so quickly that the user can't tell something happened and gets confused PackageManager.uninstall_app(app_fullname) - time.sleep(1) # seems silly but otherwise it goes so quickly that the user can't tell something happened and gets confused + await TaskManager.sleep(1) # seems silly but otherwise it goes so quickly that the user can't tell something happened and gets confused self.progress_bar.set_value(100, False) self.progress_bar.add_flag(lv.obj.FLAG.HIDDEN) self.progress_bar.set_value(0, False) @@ -329,48 +449,54 @@ def uninstall_app(self, app_fullname): self.update_button.remove_flag(lv.obj.FLAG.HIDDEN) self.install_button.set_size(lv.pct(47), 40) # if a builtin app was removed, then it was overridden, and a new version is available, so make space for update button - def download_and_install(self, zip_url, dest_folder, app_fullname): + async def pcb(self, percent): + print(f"pcb called: {percent}") + scaled_percent_start = 5 # before 5% is preparation + scaled_percent_finished = 60 # after 60% is unzip + scaled_percent_diff = scaled_percent_finished - scaled_percent_start + scale = 100 / scaled_percent_diff # 100 / 55 = 1.81 + scaled_percent = round(percent / scale) + scaled_percent += scaled_percent_start + self.progress_bar.set_value(scaled_percent, True) + + async def download_and_install(self, app_obj, dest_folder): + zip_url = app_obj.download_url + app_fullname = app_obj.fullname + download_url_size = None + if hasattr(app_obj, "download_url_size"): + download_url_size = app_obj.download_url_size self.install_button.add_state(lv.STATE.DISABLED) self.install_label.set_text("Please wait...") self.progress_bar.remove_flag(lv.obj.FLAG.HIDDEN) - self.progress_bar.set_value(20, True) - time.sleep(1) # seems silly but otherwise it goes so quickly that the user can't tell something happened and gets confused + self.progress_bar.set_value(5, True) + await TaskManager.sleep(1) # seems silly but otherwise it goes so quickly that the user can't tell something happened and gets confused + # Download the .mpk file to temporary location try: - # Step 1: Download the .mpk file - print(f"Downloading .mpk file from: {zip_url}") - response = requests.get(zip_url, timeout=10) # TODO: use stream=True and do it in chunks like in OSUpdate - if response.status_code != 200: - print("Download failed: Status code", response.status_code) - response.close() - self.set_install_label(app_fullname) - self.progress_bar.set_value(40, True) - # Save the .mpk file to a temporary location - try: - os.remove(temp_zip_path) - except Exception: - pass - try: - os.mkdir("tmp") - except Exception: - pass - temp_zip_path = "tmp/temp.mpk" - print(f"Writing to temporary mpk path: {temp_zip_path}") - # TODO: check free available space first! - with open(temp_zip_path, "wb") as f: - f.write(response.content) - self.progress_bar.set_value(60, True) - response.close() + # Make sure there's no leftover file filling the storage + os.remove(temp_zip_path) + except Exception: + pass + try: + os.mkdir("tmp") + except Exception: + pass + temp_zip_path = "tmp/temp.mpk" + print(f"Downloading .mpk file from: {zip_url} to {temp_zip_path}") + result = await DownloadManager.download_url(zip_url, outfile=temp_zip_path, total_size=download_url_size, progress_callback=self.pcb) + if result is not True: + print("Download failed...") # Would be good to show an error to the user if this failed... + else: print("Downloaded .mpk file, size:", os.stat(temp_zip_path)[6], "bytes") - except Exception as e: - print("Download failed:", str(e)) - # Would be good to show error message here if it fails... - finally: - if 'response' in locals(): - response.close() - # Step 2: install it: - PackageManager.install_mpk(temp_zip_path, dest_folder) + # Install it: + PackageManager.install_mpk(temp_zip_path, dest_folder) # 60 until 90 percent is the unzip but no progress there... + self.progress_bar.set_value(90, True) + # Make sure there's no leftover file filling the storage: + try: + os.remove(temp_zip_path) + except Exception: + pass # Success: - time.sleep(1) # seems silly but otherwise it goes so quickly that the user can't tell something happened and gets confused + await TaskManager.sleep(1) # seems silly but otherwise it goes so quickly that the user can't tell something happened and gets confused self.progress_bar.set_value(100, False) self.progress_bar.add_flag(lv.obj.FLAG.HIDDEN) self.progress_bar.set_value(0, False) diff --git a/internal_filesystem/builtin/apps/com.micropythonos.osupdate/META-INF/MANIFEST.JSON b/internal_filesystem/builtin/apps/com.micropythonos.osupdate/META-INF/MANIFEST.JSON index c26d44f2..e4d62404 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.osupdate/META-INF/MANIFEST.JSON +++ b/internal_filesystem/builtin/apps/com.micropythonos.osupdate/META-INF/MANIFEST.JSON @@ -3,10 +3,10 @@ "publisher": "MicroPythonOS", "short_description": "Operating System Updater", "long_description": "Updates the operating system in a safe way, to a secondary partition. After the update, the device is restarted. If the system starts up successfully, it is marked as valid and kept. Otherwise, a rollback to the old, primary partition is performed.", -"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.osupdate/icons/com.micropythonos.osupdate_0.0.7_64x64.png", -"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.osupdate/mpks/com.micropythonos.osupdate_0.0.7.mpk", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.osupdate/icons/com.micropythonos.osupdate_0.0.11_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.osupdate/mpks/com.micropythonos.osupdate_0.0.11.mpk", "fullname": "com.micropythonos.osupdate", -"version": "0.0.7", +"version": "0.0.11", "category": "osupdate", "activities": [ { diff --git a/internal_filesystem/builtin/apps/com.micropythonos.osupdate/assets/osupdate.py b/internal_filesystem/builtin/apps/com.micropythonos.osupdate/assets/osupdate.py index 79630de4..20b05794 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.osupdate/assets/osupdate.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.osupdate/assets/osupdate.py @@ -2,9 +2,9 @@ import requests import ujson import time -import _thread from mpos.apps import Activity +from mpos import PackageManager, ConnectivityManager, TaskManager, DownloadManager import mpos.info import mpos.ui @@ -16,13 +16,38 @@ class OSUpdate(Activity): status_label = None install_button = None force_update = None + check_again_button = None main_screen = None progress_label = None progress_bar = None + speed_label = None + + # State management + current_state = None + + def __init__(self): + super().__init__() + # Initialize business logic components with dependency injection + self.update_checker = UpdateChecker() + self.update_downloader = UpdateDownloader() + self.current_state = UpdateState.IDLE + self.connectivity_manager = None # Will be initialized in onStart + + # This function gets called from both the main thread as the update_with_lvgl() thread + def set_state(self, new_state): + """Change app state and update UI accordingly.""" + print(f"OSUpdate: state change {self.current_state} -> {new_state}") + self.current_state = new_state + self.update_ui_threadsafe_if_foreground(self._update_ui_for_state) # Since called from both threads, be threadsafe def onCreate(self): self.main_screen = lv.obj() self.main_screen.set_style_pad_all(mpos.ui.pct_of_display_width(2), 0) + + # Make the screen focusable so it can be scrolled with the arrow keys + if focusgroup := lv.group_get_default(): + focusgroup.add_obj(self.main_screen) + self.current_version_label = lv.label(self.main_screen) self.current_version_label.align(lv.ALIGN.TOP_LEFT,0,0) self.current_version_label.set_text(f"Installed OS version: {mpos.info.CURRENT_OS_VERSION}") @@ -39,67 +64,174 @@ def onCreate(self): install_label = lv.label(self.install_button) install_label.set_text("Update OS") install_label.center() + + # Check Again button (hidden initially, shown on errors) + self.check_again_button = lv.button(self.main_screen) + self.check_again_button.align(lv.ALIGN.BOTTOM_MID, 0, -10) + self.check_again_button.set_size(lv.SIZE_CONTENT, lv.pct(15)) + self.check_again_button.add_event_cb(lambda e: self.check_again_click(), lv.EVENT.CLICKED, None) + self.check_again_button.add_flag(lv.obj.FLAG.HIDDEN) # Initially hidden + check_again_label = lv.label(self.check_again_button) + check_again_label.set_text("Check Again") + check_again_label.center() + self.status_label = lv.label(self.main_screen) self.status_label.align_to(self.force_update, lv.ALIGN.OUT_BOTTOM_LEFT, 0, mpos.ui.pct_of_display_height(5)) self.setContentView(self.main_screen) - def onStart(self, screen): - network_connected = True - try: - import network - network_connected = network.WLAN(network.STA_IF).isconnected() - except Exception as e: - print("Warning: could not check WLAN status:", str(e)) - - if not network_connected: - self.status_label.set_text("Error: WiFi is not connected.") + def _update_ui_for_state(self): + """Update UI elements based on current state.""" + if self.current_state == UpdateState.WAITING_WIFI: + self.status_label.set_text("Waiting for WiFi connection...") + self.check_again_button.add_flag(lv.obj.FLAG.HIDDEN) + elif self.current_state == UpdateState.CHECKING_UPDATE: + self.status_label.set_text("Checking for OS updates...") + self.check_again_button.add_flag(lv.obj.FLAG.HIDDEN) + elif self.current_state == UpdateState.DOWNLOADING: + self.status_label.set_text("Update in progress.\nNavigate away to cancel.") + self.check_again_button.add_flag(lv.obj.FLAG.HIDDEN) + elif self.current_state == UpdateState.DOWNLOAD_PAUSED: + self.status_label.set_text("Download paused - waiting for WiFi...") + self.check_again_button.add_flag(lv.obj.FLAG.HIDDEN) + elif self.current_state == UpdateState.ERROR: + # Show "Check Again" button on errors + self.check_again_button.remove_flag(lv.obj.FLAG.HIDDEN) + + def onResume(self, screen): + """Register for connectivity callbacks when app resumes.""" + super().onResume(screen) + # Get connectivity manager instance + self.connectivity_manager = ConnectivityManager.get() + self.connectivity_manager.register_callback(self.network_changed) + # Start, based on network state: + self.network_changed(self.connectivity_manager.is_online()) + + def onPause(self, screen): + """Unregister connectivity callbacks when app pauses.""" + if self.connectivity_manager: + self.connectivity_manager.unregister_callback(self.network_changed) + super().onPause(screen) + + def network_changed(self, online): + """Callback when network connectivity changes. + + Args: + online: True if network is online, False if offline + """ + print(f"OSUpdate: network_changed, now: {'ONLINE' if online else 'OFFLINE'}") + + if not online: + # Went offline + if self.current_state == UpdateState.DOWNLOADING: + # Download will automatically pause due to connectivity check + pass + elif self.current_state == UpdateState.IDLE or self.current_state == UpdateState.CHECKING_UPDATE: + # Was checking for updates when network dropped + self.set_state(UpdateState.WAITING_WIFI) + elif self.current_state == UpdateState.ERROR: + # Was in error state, might be network-related + # Update UI to show we're waiting for network + self.set_state(UpdateState.WAITING_WIFI) else: - print("Showing update info...") - self.show_update_info() + # Went online + if self.current_state == UpdateState.IDLE or self.current_state == UpdateState.WAITING_WIFI: + # Was waiting for network, now can check for updates + self.set_state(UpdateState.CHECKING_UPDATE) + self.schedule_show_update_info() + elif self.current_state == UpdateState.ERROR: + # Was in error state (possibly network error), retry now that network is back + print("OSUpdate: Retrying update check after network came back online") + self.set_state(UpdateState.CHECKING_UPDATE) + self.schedule_show_update_info() + elif self.current_state == UpdateState.DOWNLOAD_PAUSED: + # Download was paused, will auto-resume in download thread + pass - def show_update_info(self): - self.status_label.set_text("Checking for OS updates...") - hwid = mpos.info.get_hardware_id() - if (hwid == "waveshare-esp32-s3-touch-lcd-2"): - infofile = "osupdate.json" - # Device that was first supported did not have the hardware ID in the URL, so it's special: + def _get_user_friendly_error(self, error): + """Convert technical errors into user-friendly messages with guidance.""" + error_str = str(error).lower() + + # HTTP errors + if "404" in error_str: + return ("Update information not found for your device.\n\n" + "This hardware may not yet be supported.\n" + "Check https://micropythonos.com for updates.") + elif "500" in error_str or "502" in error_str or "503" in error_str: + return ("Update server is temporarily unavailable.\n\n" + "Please try again in a few minutes.") + elif "timeout" in error_str: + return ("Connection timeout.\n\n" + "Check your internet connection and try again.") + elif "connection refused" in error_str: + return ("Cannot connect to update server.\n\n" + "Check your internet connection.") + + # JSON/Data errors + elif "invalid json" in error_str or "syntax error" in error_str: + return ("Server returned invalid data.\n\n" + "The update server may be experiencing issues.\n" + "Try again later.") + elif "missing required fields" in error_str: + return ("Update information is incomplete.\n\n" + "The update server may be experiencing issues.\n" + "Try again later.") + + # Storage errors + elif "enospc" in error_str or "no space" in error_str: + return ("Not enough storage space.\n\n" + "Free up space and try again.") + + # Generic errors else: - infofile = f"osupdate_{hwid}.json" - url = f"https://updates.micropythonos.com/{infofile}" - print(f"OSUpdate: fetching {url}") + return f"An error occurred:\n{str(error)}\n\nPlease try again." + + # Show update info with a delay, to ensure ordering of multiple lv.async_call() + def schedule_show_update_info(self): + timer = lv.timer_create(self.show_update_info, 150, None) + timer.set_repeat_count(1) + + def show_update_info(self, timer=None): + hwid = mpos.info.get_hardware_id() + try: - print("doing requests.get()") - # Download the JSON - response = requests.get(url) - # Check if request was successful - if response.status_code == 200: - # Parse JSON - osupdate = ujson.loads(response.text) - # Access attributes - version = osupdate["version"] - download_url = osupdate["download_url"] - changelog = osupdate["changelog"] - # Print the values - print("Version:", version) - print("Download URL:", download_url) - print("Changelog:", changelog) - self.handle_update_info(version, download_url, changelog) - else: - self.status_label.set_text(f"Error: {response.status_code} while checking\nfile: {infofile}\nat: {url}") - print("Failed to download JSON. Status code:", response.status_code) - # Close response - response.close() + # Use UpdateChecker to fetch update info + update_info = self.update_checker.fetch_update_info(hwid) + self.handle_update_info( + update_info["version"], + update_info["download_url"], + update_info["changelog"] + ) + except ValueError as e: + # JSON parsing or validation error (not network related) + self.set_state(UpdateState.ERROR) + self.status_label.set_text(self._get_user_friendly_error(e)) + except RuntimeError as e: + # Network or HTTP error + self.set_state(UpdateState.ERROR) + self.status_label.set_text(self._get_user_friendly_error(e)) except Exception as e: - print("Error:", str(e)) + print(f"show_update_info got exception: {e}") + # Check if this is a network connectivity error + if self.update_downloader._is_network_error(e): + # Network not available - wait for it to come back + print("OSUpdate: Network error while checking for updates, waiting for WiFi") + self.set_state(UpdateState.WAITING_WIFI) + else: + # Other unexpected error + self.set_state(UpdateState.ERROR) + self.status_label.set_text(self._get_user_friendly_error(e)) def handle_update_info(self, version, download_url, changelog): self.download_update_url = download_url - if compare_versions(version, mpos.info.CURRENT_OS_VERSION): - #if True: # for testing + + # Use UpdateChecker to determine if update is available + is_newer = self.update_checker.is_update_available(version, mpos.info.CURRENT_OS_VERSION) + + if is_newer: label = "New" self.install_button.remove_state(lv.STATE.DISABLED) else: - label = "Same latest" + label = "No new" if (self.force_update.get_state() & lv.STATE.CHECKED): self.install_button.remove_state(lv.STATE.DISABLED) label += f" version: {version}\n\nDetails:\n\n{changelog}" @@ -112,21 +244,26 @@ def install_button_click(self): return else: print(f"install_button_click for url {self.download_update_url}") - self.install_button.add_state(lv.STATE.DISABLED) # button will be enabled if there is an update available - self.status_label.set_text("Update in progress.\nNavigate away to cancel.") + + self.install_button.add_state(lv.STATE.DISABLED) + self.set_state(UpdateState.DOWNLOADING) + self.progress_label = lv.label(self.main_screen) self.progress_label.set_text("OS Update: 0.00%") - self.progress_label.align(lv.ALIGN.CENTER, 0, 0) + self.progress_label.align(lv.ALIGN.CENTER, 0, -15) + + self.speed_label = lv.label(self.main_screen) + self.speed_label.set_text("Speed: -- KB/s") + self.speed_label.align(lv.ALIGN.CENTER, 0, 10) + self.progress_bar = lv.bar(self.main_screen) self.progress_bar.set_size(200, 20) self.progress_bar.align(lv.ALIGN.BOTTOM_MID, 0, -50) self.progress_bar.set_range(0, 100) self.progress_bar.set_value(0, False) - try: - _thread.stack_size(mpos.apps.good_stack_size()) - _thread.start_new_thread(self.update_with_lvgl, (self.download_update_url,)) - except Exception as e: - print("Could not start update_with_lvgl thread: ", e) + + # Use TaskManager instead of _thread for async download + TaskManager.create_task(self.perform_update()) def force_update_clicked(self): if self.download_update_url and (self.force_update.get_state() & lv.STATE.CHECKED): @@ -134,92 +271,540 @@ def force_update_clicked(self): else: self.install_button.add_state(lv.STATE.DISABLED) - def progress_callback(self, percent): - print(f"OTA Update: {percent:.1f}%") - self.update_ui_threadsafe_if_foreground(self.progress_bar.set_value, int(percent), True) - self.update_ui_threadsafe_if_foreground(self.progress_label.set_text, f"OTA Update: {percent:.2f}%") - time.sleep_ms(100) + def check_again_click(self): + """Handle 'Check Again' button click - retry update check.""" + print("OSUpdate: Check Again button clicked") + self.check_again_button.add_flag(lv.obj.FLAG.HIDDEN) + self.set_state(UpdateState.CHECKING_UPDATE) + self.schedule_show_update_info() - # Custom OTA update with LVGL progress - def update_with_lvgl(self, url): - simulate = False + async def async_progress_callback(self, percent): + """Async progress callback for DownloadManager. + + Args: + percent: Progress percentage with 2 decimal places (0.00 - 100.00) + """ + print(f"OTA Update: {percent:.2f}%") + # UI updates are safe from async context in MicroPythonOS (runs on main thread) + if self.has_foreground(): + self.progress_bar.set_value(int(percent), True) + self.progress_label.set_text(f"OTA Update: {percent:.2f}%") + await TaskManager.sleep_ms(50) + + async def async_speed_callback(self, bytes_per_second): + """Async speed callback for DownloadManager. + + Args: + bytes_per_second: Download speed in bytes per second + """ + # Convert to human-readable format + if bytes_per_second >= 1024 * 1024: + speed_str = f"{bytes_per_second / (1024 * 1024):.1f} MB/s" + elif bytes_per_second >= 1024: + speed_str = f"{bytes_per_second / 1024:.1f} KB/s" + else: + speed_str = f"{bytes_per_second:.0f} B/s" + + print(f"Download speed: {speed_str}") + if self.has_foreground() and self.speed_label: + self.speed_label.set_text(f"Speed: {speed_str}") + + async def perform_update(self): + """Download and install update using async patterns. + + Supports automatic pause/resume on wifi loss. + """ + url = self.download_update_url + try: - from esp32 import Partition - #current_partition = Partition(Partition.RUNNING) - #print(f"Current partition: {current_partition}") - #next_partition = current_partition.get_next_update() - #print(f"Next partition: {next_partition}") - current = Partition(Partition.RUNNING) - next_partition = current.get_next_update() - #import ota.update - #import ota.status - #ota.status.status() + # Loop to handle pause/resume cycles + while self.has_foreground(): + # Use UpdateDownloader to handle the download (now async) + result = await self.update_downloader.download_and_install( + url, + progress_callback=self.async_progress_callback, + speed_callback=self.async_speed_callback, + should_continue_callback=self.has_foreground + ) + + if result['success']: + # Update succeeded - set boot partition and restart + self.status_label.set_text("Update finished! Restarting...") + await TaskManager.sleep(5) + self.update_downloader.set_boot_partition_and_restart() + return + + elif result.get('paused', False): + # Download paused due to wifi loss + bytes_written = result.get('bytes_written', 0) + total_size = result.get('total_size', 0) + percent = (bytes_written / total_size * 100) if total_size > 0 else 0 + + print(f"OSUpdate: Download paused at {percent:.1f}% ({bytes_written}/{total_size} bytes)") + self.set_state(UpdateState.DOWNLOAD_PAUSED) + + # Wait for wifi to return using async sleep + print("OSUpdate: Waiting for network to return...") + check_interval = 2 # Check every 2 seconds + max_wait = 300 # 5 minutes timeout + elapsed = 0 + + while elapsed < max_wait and self.has_foreground(): + if self.connectivity_manager.is_online(): + print("OSUpdate: Network reconnected, waiting for stabilization...") + await TaskManager.sleep(2) # Let routing table and DNS fully stabilize + print("OSUpdate: Resuming download") + self.set_state(UpdateState.DOWNLOADING) + break # Exit wait loop and retry download + + await TaskManager.sleep(check_interval) + elapsed += check_interval + + if elapsed >= max_wait: + # Timeout waiting for network + msg = f"Network timeout during download.\n{bytes_written}/{total_size} bytes written.\nPress 'Update OS' to retry." + self.status_label.set_text(msg) + self.install_button.remove_state(lv.STATE.DISABLED) + self.set_state(UpdateState.ERROR) + return + + # If we're here, network is back - continue to next iteration to resume + + else: + # Update failed with error (not pause) + self._handle_update_error(result) + return + except Exception as e: - print("Warning: could not import esp32.Partition, simulating update...") - simulate = True - response = requests.get(url, stream=True) - total_size = int(response.headers.get('Content-Length', 0)) - bytes_written = 0 - chunk_size = 4096 - i = 0 - total_size = round_up_to_multiple(total_size, chunk_size) - print(f"Starting OTA update of size: {total_size}") - while self.has_foreground(): # stop if the user navigates away - time.sleep_ms(100) # don't hog the CPU - chunk = response.raw.read(chunk_size) - if not chunk: - print("No chunk, breaking...") - break - if len(chunk) < chunk_size: - print(f"Padding chunk {i} from {len(chunk)} to {chunk_size} bytes") - chunk = chunk + b'\xFF' * (chunk_size - len(chunk)) - print(f"Writing chunk {i} with length {len(chunk)}") - if not simulate: - next_partition.writeblocks(i, chunk) - bytes_written += len(chunk) - i += 1 - if total_size: - self.progress_callback(bytes_written / total_size * 100) - response.close() + self._handle_update_exception(e) + + def _handle_update_error(self, result): + """Handle update error result - extracted for DRY.""" + error_msg = result.get('error', 'Unknown error') + bytes_written = result.get('bytes_written', 0) + total_size = result.get('total_size', 0) + + if "cancelled" in error_msg.lower(): + msg = ("Update cancelled by user.\n\n" + f"{bytes_written}/{total_size} bytes downloaded.\n" + "Press 'Update OS' to resume.") + else: + # Use friendly error message + friendly_msg = self._get_user_friendly_error(Exception(error_msg)) + progress_info = f"\n\nProgress: {bytes_written}/{total_size} bytes" + if bytes_written > 0: + progress_info += "\n\nPress 'Update OS' to resume." + msg = friendly_msg + progress_info + + self.set_state(UpdateState.ERROR) + self.status_label.set_text(msg) + self.install_button.remove_state(lv.STATE.DISABLED) # allow retry + + def _handle_update_exception(self, e): + """Handle update exception - extracted for DRY.""" + msg = self._get_user_friendly_error(e) + "\n\nPress 'Update OS' to retry." + self.set_state(UpdateState.ERROR) + self.status_label.set_text(msg) + self.install_button.remove_state(lv.STATE.DISABLED) # allow retry + +# Business Logic Classes: + +class UpdateState: + """State machine states for OSUpdate app.""" + IDLE = "idle" + WAITING_WIFI = "waiting_wifi" + CHECKING_UPDATE = "checking_update" + UPDATE_AVAILABLE = "update_available" + NO_UPDATE = "no_update" + DOWNLOADING = "downloading" + DOWNLOAD_PAUSED = "download_paused" + COMPLETED = "completed" + ERROR = "error" + +class UpdateDownloader: + """Handles downloading and installing OS updates using async DownloadManager.""" + + # Chunk size for partition writes (must be 4096 for ESP32 flash) + CHUNK_SIZE = 4096 + + def __init__(self, partition_module=None, connectivity_manager=None, download_manager=None): + """Initialize with optional dependency injection for testing. + + Args: + partition_module: ESP32 Partition module (defaults to esp32.Partition if available) + connectivity_manager: ConnectivityManager instance for checking network during download + download_manager: DownloadManager module for async downloads (defaults to mpos.DownloadManager) + """ + self.partition_module = partition_module + self.connectivity_manager = connectivity_manager + self.download_manager = download_manager # For testing injection + self.simulate = False + + # Download state for pause/resume + self.is_paused = False + self.bytes_written_so_far = 0 + self.total_size_expected = 0 + + # Internal state for chunk processing + self._current_partition = None + self._block_index = 0 + self._chunk_buffer = b'' + self._should_continue = True + self._progress_callback = None + + # Try to import Partition if not provided + if self.partition_module is None: + try: + from esp32 import Partition + self.partition_module = Partition + except ImportError: + print("UpdateDownloader: Partition module not available, will simulate") + self.simulate = True + + def _is_network_error(self, exception): + """Check if exception is a network connectivity error that should trigger pause. + + Args: + exception: Exception to check + + Returns: + bool: True if this is a recoverable network error + """ + error_str = str(exception).lower() + error_repr = repr(exception).lower() + + # Check for common network error codes and messages + # -113 = ECONNABORTED (connection aborted) + # -104 = ECONNRESET (connection reset by peer) + # -110 = ETIMEDOUT (connection timed out) + # -118 = EHOSTUNREACH (no route to host) + network_indicators = [ + '-113', '-104', '-110', '-118', # Error codes + 'econnaborted', 'econnreset', 'etimedout', 'ehostunreach', # Error names + 'connection reset', 'connection aborted', # Error messages + 'broken pipe', 'network unreachable', 'host unreachable' + ] + + return any(indicator in error_str or indicator in error_repr + for indicator in network_indicators) + + def _setup_partition(self): + """Initialize the OTA partition for writing.""" + if not self.simulate and self._current_partition is None: + current = self.partition_module(self.partition_module.RUNNING) + self._current_partition = current.get_next_update() + print(f"UpdateDownloader: Writing to partition: {self._current_partition}") + + async def _process_chunk(self, chunk): + """Process a downloaded chunk - buffer and write to partition. + + Note: Progress reporting is handled by DownloadManager, not here. + This method only handles buffering and writing to partition. + + Args: + chunk: bytes data received from download + """ + # Check if we should continue (user cancelled) + if not self._should_continue: + return + + # Check network connection + if self.connectivity_manager: + is_online = self.connectivity_manager.is_online() + elif ConnectivityManager._instance: + is_online = ConnectivityManager._instance.is_online() + else: + is_online = True + + if not is_online: + print("UpdateDownloader: Network lost during chunk processing") + self.is_paused = True + raise OSError(-113, "Network lost during download") + + # Track total bytes received + self._total_bytes_received += len(chunk) + + # Add chunk to buffer + self._chunk_buffer += chunk + + # Write complete 4096-byte blocks + while len(self._chunk_buffer) >= self.CHUNK_SIZE: + block = self._chunk_buffer[:self.CHUNK_SIZE] + self._chunk_buffer = self._chunk_buffer[self.CHUNK_SIZE:] + + if not self.simulate: + self._current_partition.writeblocks(self._block_index, block) + + self._block_index += 1 + self.bytes_written_so_far += len(block) + + # Note: Progress is reported by DownloadManager via progress_callback parameter + # We don't calculate progress here to avoid duplicate/incorrect progress updates + + async def _flush_buffer(self): + """Flush remaining buffer with padding to complete the download.""" + if self._chunk_buffer: + # Pad the last chunk to 4096 bytes + remaining = len(self._chunk_buffer) + padded = self._chunk_buffer + b'\xFF' * (self.CHUNK_SIZE - remaining) + print(f"UpdateDownloader: Padding final chunk from {remaining} to {self.CHUNK_SIZE} bytes") + + if not self.simulate: + self._current_partition.writeblocks(self._block_index, padded) + + self.bytes_written_so_far += self.CHUNK_SIZE + self._chunk_buffer = b'' + + # Final progress update + if self._progress_callback and self.total_size_expected > 0: + percent = (self.bytes_written_so_far / self.total_size_expected) * 100 + await self._progress_callback(min(percent, 100.0)) + + async def download_and_install(self, url, progress_callback=None, speed_callback=None, should_continue_callback=None): + """Download firmware and install to OTA partition using async DownloadManager. + + Supports pause/resume on wifi loss using HTTP Range headers. + + Args: + url: URL to download firmware from + progress_callback: Optional async callback function(percent: float) + Called by DownloadManager with progress 0.00-100.00 (2 decimal places) + speed_callback: Optional async callback function(bytes_per_second: float) + Called periodically with download speed + should_continue_callback: Optional callback function() -> bool + Returns False to cancel download + + Returns: + dict: Result with keys: + - 'success': bool + - 'bytes_written': int + - 'total_size': int + - 'error': str (if success=False) + - 'paused': bool (if paused due to wifi loss) + """ + result = { + 'success': False, + 'bytes_written': 0, + 'total_size': 0, + 'error': None, + 'paused': False + } + + # Store callbacks for use in _process_chunk + self._progress_callback = progress_callback + self._should_continue = True + self._total_bytes_received = 0 + try: - if bytes_written >= total_size: - lv.update_ui_threadsafe_if_foreground(self.status_label.set_text, "Update finished! Please restart.") - if not simulate: # if the update was completely installed - next_partition.set_boot() - import machine - machine.reset() - # self.install_button stays disabled to prevent the user from installing the same update twice - else: - print("This is an OSUpdate simulation, not attempting to restart the device.") + # Setup partition + self._setup_partition() + + # Initialize block index from resume position + self._block_index = self.bytes_written_so_far // self.CHUNK_SIZE + + # Build headers for resume + headers = None + if self.bytes_written_so_far > 0: + headers = {'Range': f'bytes={self.bytes_written_so_far}-'} + print(f"UpdateDownloader: Resuming from byte {self.bytes_written_so_far}") + + # Get the download manager (use injected one for testing, or global) + dm = self.download_manager if self.download_manager else DownloadManager + + # Create wrapper for chunk callback that checks should_continue + async def chunk_handler(chunk): + if should_continue_callback and not should_continue_callback(): + self._should_continue = False + raise Exception("Download cancelled by user") + await self._process_chunk(chunk) + + # For initial download, we need to get total size first + # DownloadManager doesn't expose Content-Length directly, so we estimate + if self.bytes_written_so_far == 0: + # We'll update total_size_expected as we download + # For now, set a placeholder that will be updated + self.total_size_expected = 0 + + # Download with streaming chunk callback + # Progress and speed are reported by DownloadManager via callbacks + print(f"UpdateDownloader: Starting async download from {url}") + success = await dm.download_url( + url, + chunk_callback=chunk_handler, + progress_callback=progress_callback, # Let DownloadManager handle progress + speed_callback=speed_callback, # Let DownloadManager handle speed + headers=headers + ) + + if success: + # Flush any remaining buffered data + await self._flush_buffer() + + result['success'] = True + result['bytes_written'] = self.bytes_written_so_far + result['total_size'] = self.bytes_written_so_far # Actual size downloaded + + # Final 100% progress callback + if self._progress_callback: + await self._progress_callback(100.0) + + # Reset state for next download + self.is_paused = False + self.bytes_written_so_far = 0 + self.total_size_expected = 0 + self._current_partition = None + self._block_index = 0 + self._chunk_buffer = b'' + self._total_bytes_received = 0 + + print(f"UpdateDownloader: Download complete ({result['bytes_written']} bytes)") + else: + # Download failed but not due to exception + result['error'] = "Download failed" + result['bytes_written'] = self.bytes_written_so_far + result['total_size'] = self.total_size_expected + + except Exception as e: + error_msg = str(e) + + # Check if cancelled by user + if "cancelled" in error_msg.lower(): + result['error'] = error_msg + result['bytes_written'] = self.bytes_written_so_far + result['total_size'] = self.total_size_expected + # Check if this is a network error that should trigger pause + elif self._is_network_error(e): + print(f"UpdateDownloader: Network error ({e}), pausing download") + self.is_paused = True + result['paused'] = True + result['bytes_written'] = self.bytes_written_so_far + result['total_size'] = self.total_size_expected else: - self.update_ui_threadsafe_if_foreground(self.status_label.set_text, f"Wrote {bytes_written} < {total_size} so not enough!") - self.update_ui_threadsafe_if_foreground(self.install_button.remove_state, lv.STATE.DISABLED) # allow retry + # Non-network error + result['error'] = error_msg + result['bytes_written'] = self.bytes_written_so_far + result['total_size'] = self.total_size_expected + print(f"UpdateDownloader: Error during download: {e}") + + return result + + def set_boot_partition_and_restart(self): + """Set the updated partition as boot partition and restart device. + + Only works on ESP32 hardware. On desktop, just prints a message. + """ + if self.simulate: + print("UpdateDownloader: Simulating restart (desktop mode)") + return + + try: + current = self.partition_module(self.partition_module.RUNNING) + next_partition = current.get_next_update() + next_partition.set_boot() + print("UpdateDownloader: Boot partition set, restarting...") + + import machine + machine.reset() + except Exception as e: + print(f"UpdateDownloader: Error setting boot partition: {e}") + raise + + +class UpdateChecker: + """Handles checking for OS updates from remote server.""" + + def __init__(self, requests_module=None, json_module=None): + """Initialize with optional dependency injection for testing. + + Args: + requests_module: HTTP requests module (defaults to requests) + json_module: JSON parsing module (defaults to ujson) + """ + self.requests = requests_module if requests_module else requests + self.json = json_module if json_module else ujson + + def get_update_url(self, hardware_id): + """Determine the update JSON URL based on hardware ID. + + Args: + hardware_id: Hardware identifier string + + Returns: + str: Full URL to update JSON file + """ + if hardware_id == "waveshare_esp32_s3_touch_lcd_2": + # First supported device - no hardware ID in URL + infofile = "osupdate.json" + else: + infofile = f"osupdate_{hardware_id}.json" + return f"https://updates.micropythonos.com/{infofile}" + + def fetch_update_info(self, hardware_id): + """Fetch and parse update information from server. + + Args: + hardware_id: Hardware identifier string + + Returns: + dict: Update info with keys 'version', 'download_url', 'changelog' + or None if error occurred + + Raises: + ValueError: If JSON is malformed or missing required fields + ConnectionError: If network request fails + """ + url = self.get_update_url(hardware_id) + print(f"OSUpdate: fetching {url}") + + try: + response = self.requests.get(url) + + if response.status_code != 200: + # Use RuntimeError instead of ConnectionError (not available in MicroPython) + raise RuntimeError( + f"HTTP {response.status_code} while checking {url}" + ) + + # Parse JSON + try: + update_data = self.json.loads(response.text) + except Exception as e: + raise ValueError(f"Invalid JSON in update file: {e}") + finally: + response.close() + + # Validate required fields + required_fields = ['version', 'download_url', 'changelog'] + missing_fields = [f for f in required_fields if f not in update_data] + if missing_fields: + raise ValueError( + f"Update file missing required fields: {', '.join(missing_fields)}" + ) + + print("Version:", update_data["version"]) + print("Download URL:", update_data["download_url"]) + print("Changelog:", update_data["changelog"]) + + return update_data + except Exception as e: - self.update_ui_threadsafe_if_foreground(self.status_label.set_text, f"Update error: {e}") - self.update_ui_threadsafe_if_foreground(self.install_button.remove_state, lv.STATE.DISABLED) + print(f"Error fetching update info: {e}") + raise + + def is_update_available(self, remote_version, current_version): + """Check if remote version is newer than current version. + + Args: + remote_version: Version string from update server + current_version: Currently installed version string + + Returns: + bool: True if remote version is newer + """ + return PackageManager.compare_versions(remote_version, current_version) + # Non-class functions: def round_up_to_multiple(n, multiple): return ((n + multiple - 1) // multiple) * multiple - -def compare_versions(ver1: str, ver2: str) -> bool: - """Compare two version numbers (e.g., '1.2.3' vs '4.5.6'). - Returns True if ver1 is greater than ver2, False otherwise.""" - print(f"Comparing versions: {ver1} vs {ver2}") - v1_parts = [int(x) for x in ver1.split('.')] - v2_parts = [int(x) for x in ver2.split('.')] - print(f"Version 1 parts: {v1_parts}") - print(f"Version 2 parts: {v2_parts}") - for i in range(max(len(v1_parts), len(v2_parts))): - v1 = v1_parts[i] if i < len(v1_parts) else 0 - v2 = v2_parts[i] if i < len(v2_parts) else 0 - print(f"Comparing part {i}: {v1} vs {v2}") - if v1 > v2: - print(f"{ver1} is greater than {ver2}") - return True - if v1 < v2: - print(f"{ver1} is less than {ver2}") - return False - print(f"Versions are equal or {ver1} is not greater than {ver2}") - return False diff --git a/internal_filesystem/builtin/apps/com.micropythonos.settings/META-INF/MANIFEST.JSON b/internal_filesystem/builtin/apps/com.micropythonos.settings/META-INF/MANIFEST.JSON index 5ce9a645..65bce842 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.settings/META-INF/MANIFEST.JSON +++ b/internal_filesystem/builtin/apps/com.micropythonos.settings/META-INF/MANIFEST.JSON @@ -3,10 +3,10 @@ "publisher": "MicroPythonOS", "short_description": "View and change MicroPythonOS settings.", "long_description": "This is the official settings app for MicroPythonOS. It allows you to configure all aspects of MicroPythonOS.", -"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.settings/icons/com.micropythonos.settings_0.0.5_64x64.png", -"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.settings/mpks/com.micropythonos.settings_0.0.5.mpk", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.settings/icons/com.micropythonos.settings_0.0.9_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.settings/mpks/com.micropythonos.settings_0.0.9.mpk", "fullname": "com.micropythonos.settings", -"version": "0.0.5", +"version": "0.0.9", "category": "development", "activities": [ { diff --git a/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/calibrate_imu.py b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/calibrate_imu.py new file mode 100644 index 00000000..750fa5c3 --- /dev/null +++ b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/calibrate_imu.py @@ -0,0 +1,230 @@ +"""Calibrate IMU Activity. + +Guides user through IMU calibration process: +1. Show calibration instructions +2. Check stationarity when user clicks "Calibrate Now" +3. Perform calibration +4. Show results +""" + +import lvgl as lv +import time +import sys +from mpos.app.activity import Activity +import mpos.ui +import mpos.sensor_manager as SensorManager +from mpos.ui.testing import wait_for_render + + +class CalibrationState: + """Enum for calibration states.""" + READY = 0 + CALIBRATING = 1 + COMPLETE = 2 + ERROR = 3 + + +class CalibrateIMUActivity(Activity): + """Guide user through IMU calibration process.""" + + # State + current_state = CalibrationState.READY + calibration_thread = None + + # Widgets + title_label = None + status_label = None + detail_label = None + action_button = None + action_button_label = None + cancel_button = None + + def __init__(self): + super().__init__() + self.is_desktop = sys.platform != "esp32" + + def onCreate(self): + screen = lv.obj() + screen.set_style_pad_all(mpos.ui.pct_of_display_width(3), 0) + screen.set_flex_flow(lv.FLEX_FLOW.COLUMN) + screen.set_flex_align(lv.FLEX_ALIGN.CENTER, lv.FLEX_ALIGN.START, lv.FLEX_ALIGN.CENTER) + focusgroup = lv.group_get_default() + if focusgroup: + focusgroup.add_obj(screen) + + # Title + self.title_label = lv.label(screen) + self.title_label.set_text("IMU Calibration") + self.title_label.set_style_text_font(lv.font_montserrat_16, 0) + + # Status label + self.status_label = lv.label(screen) + self.status_label.set_text("Initializing...") + self.status_label.set_style_text_font(lv.font_montserrat_12, 0) + self.status_label.set_long_mode(lv.label.LONG_MODE.WRAP) + self.status_label.set_width(lv.pct(100)) + + # Detail label (for additional info) + self.detail_label = lv.label(screen) + self.detail_label.set_text("") + self.detail_label.set_style_text_font(lv.font_montserrat_10, 0) + self.detail_label.set_style_text_color(lv.color_hex(0x888888), 0) + self.detail_label.set_long_mode(lv.label.LONG_MODE.WRAP) + self.detail_label.set_width(lv.pct(90)) + + # Button container + btn_cont = lv.obj(screen) + btn_cont.set_width(lv.pct(100)) + btn_cont.set_height(lv.SIZE_CONTENT) + btn_cont.set_style_border_width(0, 0) + btn_cont.set_flex_flow(lv.FLEX_FLOW.ROW) + btn_cont.set_style_flex_main_place(lv.FLEX_ALIGN.SPACE_BETWEEN, 0) + + # Action button + self.action_button = lv.button(btn_cont) + self.action_button.set_size(lv.pct(45), lv.SIZE_CONTENT) + self.action_button_label = lv.label(self.action_button) + self.action_button_label.set_text("Start") + self.action_button_label.center() + self.action_button.add_event_cb(self.action_button_clicked, lv.EVENT.CLICKED, None) + + # Cancel button + self.cancel_button = lv.button(btn_cont) + self.cancel_button.set_size(lv.pct(45), lv.SIZE_CONTENT) + cancel_label = lv.label(self.cancel_button) + cancel_label.set_text("Cancel") + cancel_label.center() + self.cancel_button.add_event_cb(lambda e: self.finish(), lv.EVENT.CLICKED, None) + + self.setContentView(screen) + + def onResume(self, screen): + super().onResume(screen) + + # Check if IMU is available + if not self.is_desktop and not SensorManager.is_available(): + self.set_state(CalibrationState.ERROR) + self.status_label.set_text("IMU not available on this device") + self.action_button.add_state(lv.STATE.DISABLED) + return + + # Show calibration instructions + self.set_state(CalibrationState.READY) + + def onPause(self, screen): + # Stop any running calibration + if self.current_state == CalibrationState.CALIBRATING: + # Calibration will detect activity is no longer in foreground + pass + super().onPause(screen) + + def set_state(self, new_state): + """Update state and UI accordingly.""" + self.current_state = new_state + self.update_ui_for_state() + + def update_ui_for_state(self): + """Update UI based on current state.""" + if self.current_state == CalibrationState.READY: + self.status_label.set_text("Place device on flat, stable surface\n\nKeep device completely still during calibration") + self.detail_label.set_text("Calibration will take ~1 seconds\nUI will freeze during calibration") + self.action_button_label.set_text("Calibrate Now") + self.action_button.remove_state(lv.STATE.DISABLED) + self.cancel_button.remove_flag(lv.obj.FLAG.HIDDEN) + + elif self.current_state == CalibrationState.CALIBRATING: + self.status_label.set_text("Calibrating IMU...") + self.detail_label.set_text("Do not move device!") + self.action_button.add_state(lv.STATE.DISABLED) + self.cancel_button.add_flag(lv.obj.FLAG.HIDDEN) + + elif self.current_state == CalibrationState.COMPLETE: + # Status text will be set by calibration results + self.action_button_label.set_text("Done") + self.action_button.remove_state(lv.STATE.DISABLED) + self.cancel_button.add_flag(lv.obj.FLAG.HIDDEN) + + elif self.current_state == CalibrationState.ERROR: + # Status text will be set by error handler + self.action_button_label.set_text("Retry") + self.action_button.remove_state(lv.STATE.DISABLED) + self.cancel_button.add_flag(lv.obj.FLAG.HIDDEN) + + def action_button_clicked(self, event): + """Handle action button clicks based on current state.""" + if self.current_state == CalibrationState.READY: + self.start_calibration_process() + elif self.current_state == CalibrationState.COMPLETE: + self.finish() + elif self.current_state == CalibrationState.ERROR: + self.set_state(CalibrationState.READY) + + + def start_calibration_process(self): + """Start the calibration process. + + Note: Runs in main thread - UI will freeze during calibration (~2 seconds). + This avoids threading issues with I2C/sensor access. + """ + try: + # Step 1: Check stationarity + self.set_state(CalibrationState.CALIBRATING) + wait_for_render() # Let UI update + + if self.is_desktop: + stationarity = {'is_stationary': True, 'message': 'Mock: Stationary'} + else: + stationarity = SensorManager.check_stationarity(samples=25) + + if stationarity is None or not stationarity['is_stationary']: + msg = stationarity['message'] if stationarity else "Stationarity check failed" + self.handle_calibration_error( + f"Device not stationary!\n\n{msg}\n\nPlace on flat surface and try again.") + return + + # Step 2: Perform calibration + if self.is_desktop: + time.sleep(2) + accel_offsets = (0.1, -0.05, 0.15) + gyro_offsets = (0.2, -0.1, 0.05) + else: + # Real calibration - UI will freeze here + accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) + gyro = SensorManager.get_default_sensor(SensorManager.TYPE_GYROSCOPE) + + if accel: + accel_offsets = SensorManager.calibrate_sensor(accel, samples=50) + else: + accel_offsets = None + + if gyro: + gyro_offsets = SensorManager.calibrate_sensor(gyro, samples=50) + else: + gyro_offsets = None + + # Step 3: Show results + result_msg = "Calibration successful!" + if accel_offsets: + result_msg += f"\n\nAccel offsets: X:{accel_offsets[0]:.3f} Y:{accel_offsets[1]:.3f} Z:{accel_offsets[2]:.3f}" + if gyro_offsets: + result_msg += f"\n\nGyro offsets: X:{gyro_offsets[0]:.3f} Y:{gyro_offsets[1]:.3f} Z:{gyro_offsets[2]:.3f}" + + self.show_calibration_complete(result_msg) + + except Exception as e: + import sys + sys.print_exception(e) + self.handle_calibration_error(str(e)) + + def show_calibration_complete(self, result_msg): + """Show calibration completion message.""" + self.status_label.set_text(result_msg) + self.detail_label.set_text("Calibration saved to storage.") + self.set_state(CalibrationState.COMPLETE) + + def handle_calibration_error(self, error_msg): + """Handle error during calibration.""" + self.set_state(CalibrationState.ERROR) + self.status_label.set_text(f"Calibration failed:\n\n{error_msg}") + self.detail_label.set_text("") + diff --git a/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/check_imu_calibration.py b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/check_imu_calibration.py new file mode 100644 index 00000000..097aa75e --- /dev/null +++ b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/check_imu_calibration.py @@ -0,0 +1,268 @@ +"""Check IMU Calibration Activity. + +Shows current IMU calibration quality with real-time sensor values, +variance, expected value comparison, and overall quality score. +""" + +import lvgl as lv +import time +import sys +from mpos.app.activity import Activity +import mpos.ui +import mpos.sensor_manager as SensorManager + + +class CheckIMUCalibrationActivity(Activity): + """Display IMU calibration quality with real-time monitoring.""" + + # Update interval for real-time display (milliseconds) + UPDATE_INTERVAL = 100 + + # State + updating = False + update_timer = None + + # Widgets + status_label = None + quality_label = None + accel_labels = [] # [x_label, y_label, z_label] + gyro_labels = [] + issues_label = None + quality_score_label = None + + def __init__(self): + super().__init__() + self.is_desktop = sys.platform != "esp32" + + def onCreate(self): + screen = lv.obj() + screen.set_style_pad_all(mpos.ui.pct_of_display_width(1), 0) + #screen.set_style_pad_all(0, 0) + screen.set_flex_flow(lv.FLEX_FLOW.COLUMN) + focusgroup = lv.group_get_default() + if focusgroup: + focusgroup.add_obj(screen) + self.setContentView(screen) + + def onResume(self, screen): + super().onResume(screen) + + # Clear the screen and recreate UI (to avoid stale widget references) + screen.clean() + + # Reset widget lists + self.accel_labels = [] + self.gyro_labels = [] + + # Status label + self.status_label = lv.label(screen) + self.status_label.set_text("Checking...") + self.status_label.set_style_text_font(lv.font_montserrat_14, 0) + + # Separator + sep1 = lv.obj(screen) + sep1.set_size(lv.pct(100), 2) + sep1.set_style_bg_color(lv.color_hex(0x666666), 0) + + # Quality score (large, prominent) + self.quality_score_label = lv.label(screen) + self.quality_score_label.set_text("Quality: --") + self.quality_score_label.set_style_text_font(lv.font_montserrat_16, 0) + + data_cont = lv.obj(screen) + data_cont.set_width(lv.pct(100)) + data_cont.set_height(lv.SIZE_CONTENT) + data_cont.set_style_pad_all(0, 0) + data_cont.set_style_bg_opa(lv.OPA.TRANSP, 0) + data_cont.set_style_border_width(0, 0) + data_cont.set_flex_flow(lv.FLEX_FLOW.ROW) + data_cont.set_style_flex_main_place(lv.FLEX_ALIGN.SPACE_BETWEEN, 0) + + # Accelerometer section + acc_cont = lv.obj(data_cont) + acc_cont.set_height(lv.SIZE_CONTENT) + acc_cont.set_width(lv.pct(45)) + acc_cont.set_style_border_width(0, 0) + acc_cont.set_style_pad_all(0, 0) + acc_cont.set_flex_flow(lv.FLEX_FLOW.COLUMN) + + accel_title = lv.label(acc_cont) + accel_title.set_text("Accel. (m/s^2)") + accel_title.set_style_text_font(lv.font_montserrat_12, 0) + + for axis in ['X', 'Y', 'Z']: + label = lv.label(acc_cont) + label.set_text(f"{axis}: --") + label.set_style_text_font(lv.font_montserrat_10, 0) + self.accel_labels.append(label) + + # Gyroscope section + gyro_cont = lv.obj(data_cont) + gyro_cont.set_width(mpos.ui.pct_of_display_width(45)) + gyro_cont.set_height(lv.SIZE_CONTENT) + gyro_cont.set_style_border_width(0, 0) + gyro_cont.set_style_pad_all(0, 0) + gyro_cont.set_flex_flow(lv.FLEX_FLOW.COLUMN) + + gyro_title = lv.label(gyro_cont) + gyro_title.set_text("Gyro (deg/s)") + gyro_title.set_style_text_font(lv.font_montserrat_12, 0) + + for axis in ['X', 'Y', 'Z']: + label = lv.label(gyro_cont) + label.set_text(f"{axis}: --") + label.set_style_text_font(lv.font_montserrat_10, 0) + self.gyro_labels.append(label) + + # Separator + #sep2 = lv.obj(screen) + #sep2.set_size(lv.pct(100), 2) + #sep2.set_style_bg_color(lv.color_hex(0x666666), 0) + + # Issues label + self.issues_label = lv.label(screen) + self.issues_label.set_text("Issues: None") + self.issues_label.set_style_text_font(lv.font_montserrat_12, 0) + self.issues_label.set_style_text_color(lv.color_hex(0xFF6666), 0) + self.issues_label.set_long_mode(lv.label.LONG_MODE.WRAP) + self.issues_label.set_width(lv.pct(95)) + + # Button container + btn_cont = lv.obj(screen) + btn_cont.set_style_pad_all(5, 0) + btn_cont.set_width(lv.pct(100)) + btn_cont.set_height(lv.SIZE_CONTENT) + btn_cont.set_style_border_width(0, 0) + btn_cont.set_flex_flow(lv.FLEX_FLOW.ROW) + btn_cont.set_style_flex_main_place(lv.FLEX_ALIGN.SPACE_BETWEEN, 0) + + # Back button + back_btn = lv.button(btn_cont) + back_btn.set_size(lv.pct(45), lv.SIZE_CONTENT) + back_label = lv.label(back_btn) + back_label.set_text("Back") + back_label.center() + back_btn.add_event_cb(lambda e: self.finish(), lv.EVENT.CLICKED, None) + + # Calibrate button + calibrate_btn = lv.button(btn_cont) + calibrate_btn.set_size(lv.pct(45), lv.SIZE_CONTENT) + calibrate_label = lv.label(calibrate_btn) + calibrate_label.set_text("Calibrate") + calibrate_label.center() + calibrate_btn.add_event_cb(self.start_calibration, lv.EVENT.CLICKED, None) + + # Check if IMU is available + if not self.is_desktop and not SensorManager.is_available(): + self.status_label.set_text("IMU not available on this device") + self.quality_score_label.set_text("N/A") + return + + # Start real-time updates + self.updating = True + self.update_timer = lv.timer_create(self.update_display, self.UPDATE_INTERVAL, None) + + def onPause(self, screen): + # Stop updates + self.updating = False + if self.update_timer: + self.update_timer.delete() + self.update_timer = None + super().onPause(screen) + + def update_display(self, timer=None): + """Update display with current sensor values and quality.""" + if not self.updating: + return + + try: + # Get quality check (desktop or hardware) + if self.is_desktop: + quality = self.get_mock_quality() + else: + # Use only 5 samples for real-time display (faster, less blocking) + quality = SensorManager.check_calibration_quality(samples=5) + + if quality is None: + self.status_label.set_text("Error reading IMU") + return + + # Update quality score + score = quality['quality_score'] + rating = quality['quality_rating'] + self.quality_score_label.set_text(f"Quality: {rating} ({score*100:.0f}%)") + + # Color based on rating + if rating == "Good": + color = 0x66FF66 # Green + elif rating == "Fair": + color = 0xFFFF66 # Yellow + else: + color = 0xFF6666 # Red + self.quality_score_label.set_style_text_color(lv.color_hex(color), 0) + + # Update accelerometer values + accel_mean = quality['accel_mean'] + accel_var = quality['accel_variance'] + for i, (mean, var) in enumerate(zip(accel_mean, accel_var)): + axis = ['X', 'Y', 'Z'][i] + self.accel_labels[i].set_text(f"{axis}: {mean:6.2f} (var: {var:.3f})") + + # Update gyroscope values + gyro_mean = quality['gyro_mean'] + gyro_var = quality['gyro_variance'] + for i, (mean, var) in enumerate(zip(gyro_mean, gyro_var)): + axis = ['X', 'Y', 'Z'][i] + self.gyro_labels[i].set_text(f"{axis}: {mean:6.2f} (var: {var:.3f})") + + # Update issues + issues = quality['issues'] + if issues: + issues_text = "Issues:\n" + "\n".join(f"- {issue}" for issue in issues) + else: + issues_text = "Issues: None - calibration looks good!" + self.issues_label.set_text(issues_text) + + self.status_label.set_text("Real-time monitoring (place on flat surface)") + except Exception as e: + # If widgets were deleted (activity closed), stop updating silently + self.updating = False + + def get_mock_quality(self): + """Generate mock quality data for desktop testing.""" + import random + + # Simulate good calibration with small random noise + return { + 'accel_mean': ( + random.uniform(-0.2, 0.2), + random.uniform(-0.2, 0.2), + 9.8 + random.uniform(-0.3, 0.3) + ), + 'accel_variance': ( + random.uniform(0.01, 0.1), + random.uniform(0.01, 0.1), + random.uniform(0.01, 0.1) + ), + 'gyro_mean': ( + random.uniform(-0.5, 0.5), + random.uniform(-0.5, 0.5), + random.uniform(-0.5, 0.5) + ), + 'gyro_variance': ( + random.uniform(0.1, 1.0), + random.uniform(0.1, 1.0), + random.uniform(0.1, 1.0) + ), + 'quality_score': random.uniform(0.75, 0.95), + 'quality_rating': "Good", + 'issues': [] + } + + def start_calibration(self, event): + """Navigate to calibration activity.""" + from mpos.content.intent import Intent + from calibrate_imu import CalibrateIMUActivity + + intent = Intent(activity_class=CalibrateIMUActivity) + self.startActivity(intent) diff --git a/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py index ed53bea2..05acca6a 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py @@ -1,10 +1,17 @@ +import lvgl as lv from mpos.apps import Activity, Intent from mpos.activity_navigator import ActivityNavigator +from mpos.ui.keyboard import MposKeyboard +from mpos import PackageManager import mpos.config import mpos.ui import mpos.time +# Import IMU calibration activities +from check_imu_calibration import CheckIMUCalibrationActivity +from calibrate_imu import CalibrateIMUActivity + # Used to list and edit all settings: class SettingsActivity(Activity): def __init__(self): @@ -36,10 +43,18 @@ def __init__(self): ("Turquoise", "40e0d0") ] self.settings = [ + # Basic settings, alphabetically: {"title": "Light/Dark Theme", "key": "theme_light_dark", "value_label": None, "cont": None, "ui": "radiobuttons", "ui_options": [("Light", "light"), ("Dark", "dark")]}, {"title": "Theme Color", "key": "theme_primary_color", "value_label": None, "cont": None, "placeholder": "HTML hex color, like: EC048C", "ui": "dropdown", "ui_options": theme_colors}, - {"title": "Restart to Bootloader", "key": "boot_mode", "value_label": None, "cont": None, "ui": "radiobuttons", "ui_options": [("Normal", "normal"), ("Bootloader", "bootloader")]}, # special that doesn't get saved {"title": "Timezone", "key": "timezone", "value_label": None, "cont": None, "ui": "dropdown", "ui_options": self.get_timezone_tuples(), "changed_callback": lambda : mpos.time.refresh_timezone_preference()}, + # Advanced settings, alphabetically: + #{"title": "Audio Output Device", "key": "audio_device", "value_label": None, "cont": None, "ui": "radiobuttons", "ui_options": [("Auto-detect", "auto"), ("I2S (Digital Audio)", "i2s"), ("Buzzer (PWM Tones)", "buzzer"), ("Both I2S and Buzzer", "both"), ("Disabled", "null")], "changed_callback": self.audio_device_changed}, + {"title": "Auto Start App", "key": "auto_start_app", "value_label": None, "cont": None, "ui": "radiobuttons", "ui_options": [(app.name, app.fullname) for app in PackageManager.get_app_list()]}, + {"title": "Check IMU Calibration", "key": "check_imu_calibration", "value_label": None, "cont": None, "ui": "activity", "activity_class": "CheckIMUCalibrationActivity"}, + {"title": "Calibrate IMU", "key": "calibrate_imu", "value_label": None, "cont": None, "ui": "activity", "activity_class": "CalibrateIMUActivity"}, + # Expert settings, alphabetically + {"title": "Restart to Bootloader", "key": "boot_mode", "value_label": None, "cont": None, "ui": "radiobuttons", "ui_options": [("Normal", "normal"), ("Bootloader", "bootloader")]}, # special that doesn't get saved + {"title": "Format internal data partition", "key": "format_internal_data_partition", "value_label": None, "cont": None, "ui": "radiobuttons", "ui_options": [("No, do not format", "no"), ("Yes, erase all settings, files and non-builtin apps", "yes")]}, # special that doesn't get saved # This is currently only in the drawer but would make sense to have it here for completeness: #{"title": "Display Brightness", "key": "display_brightness", "value_label": None, "cont": None, "placeholder": "A value from 0 to 100."}, # Maybe also add font size (but ideally then all fonts should scale up/down) @@ -97,6 +112,20 @@ def onResume(self, screen): focusgroup.add_obj(setting_cont) def startSettingActivity(self, setting): + ui_type = setting.get("ui") + + # Handle activity-based settings (NEW) + if ui_type == "activity": + activity_class_name = setting.get("activity_class") + if activity_class_name == "CheckIMUCalibrationActivity": + intent = Intent(activity_class=CheckIMUCalibrationActivity) + self.startActivity(intent) + elif activity_class_name == "CalibrateIMUActivity": + intent = Intent(activity_class=CalibrateIMUActivity) + self.startActivity(intent) + return + + # Handle traditional settings (existing code) intent = Intent(activity_class=SettingActivity) intent.putExtra("setting", setting) self.startActivity(intent) @@ -105,6 +134,34 @@ def startSettingActivity(self, setting): def get_timezone_tuples(): return [(tz, tz) for tz in mpos.time.get_timezones()] + def audio_device_changed(self): + """ + Called when audio device setting changes. + Note: Changing device type at runtime requires a restart for full effect. + AudioFlinger initialization happens at boot. + """ + import mpos.audio.audioflinger as AudioFlinger + + new_value = self.prefs.get_string("audio_device", "auto") + print(f"Audio device setting changed to: {new_value}") + print("Note: Restart required for audio device change to take effect") + + # Map setting values to device types + device_map = { + "auto": AudioFlinger.get_device_type(), # Keep current + "i2s": AudioFlinger.DEVICE_I2S, + "buzzer": AudioFlinger.DEVICE_BUZZER, + "both": AudioFlinger.DEVICE_BOTH, + "null": AudioFlinger.DEVICE_NULL, + } + + desired_device = device_map.get(new_value, AudioFlinger.get_device_type()) + current_device = AudioFlinger.get_device_type() + + if desired_device != current_device: + print(f"Desired device type ({desired_device}) differs from current ({current_device})") + print("Full device type change requires restart - current session continues with existing device") + def focus_container(self, container): print(f"container {container} focused, setting border...") container.set_style_border_color(lv.theme_get_color_primary(None),lv.PART.MAIN) @@ -119,7 +176,7 @@ def defocus_container(self, container): # Used to edit one setting: class SettingActivity(Activity): - active_radio_index = 0 # Track active radio button index + active_radio_index = -1 # Track active radio button index # Widgets: keyboard = None @@ -146,11 +203,12 @@ def onCreate(self): top_cont.set_style_pad_all(mpos.ui.pct_of_display_width(1), 0) top_cont.set_flex_flow(lv.FLEX_FLOW.ROW) top_cont.set_style_flex_main_place(lv.FLEX_ALIGN.SPACE_BETWEEN, 0) + top_cont.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) setting_label = lv.label(top_cont) setting_label.set_text(setting["title"]) setting_label.align(lv.ALIGN.TOP_LEFT,0,0) - setting_label.set_style_text_font(lv.font_montserrat_26, 0) + setting_label.set_style_text_font(lv.font_montserrat_20, 0) ui = setting.get("ui") ui_options = setting.get("ui_options") @@ -161,7 +219,7 @@ def onCreate(self): self.radio_container.set_width(lv.pct(100)) self.radio_container.set_height(lv.SIZE_CONTENT) self.radio_container.set_flex_flow(lv.FLEX_FLOW.COLUMN) - self.radio_container.add_event_cb(self.radio_event_handler, lv.EVENT.CLICKED, None) + self.radio_container.add_event_cb(self.radio_event_handler, lv.EVENT.VALUE_CHANGED, None) # Create radio buttons and check the right one self.active_radio_index = -1 # none for i, (option_text, option_value) in enumerate(ui_options): @@ -198,9 +256,8 @@ def onCreate(self): self.textarea.add_event_cb(lambda *args: mpos.ui.anim.smooth_show(self.keyboard), lv.EVENT.CLICKED, None) # it might be focused, but keyboard hidden (because ready/cancel clicked) self.textarea.add_event_cb(lambda *args: mpos.ui.anim.smooth_hide(self.keyboard), lv.EVENT.DEFOCUSED, None) # Initialize keyboard (hidden initially) - self.keyboard = lv.keyboard(lv.layer_sys()) + self.keyboard = MposKeyboard(settings_screen_detail) self.keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) - self.keyboard.set_style_min_height(150, 0) self.keyboard.add_flag(lv.obj.FLAG.HIDDEN) self.keyboard.add_event_cb(lambda *args: mpos.ui.anim.smooth_hide(self.keyboard), lv.EVENT.READY, None) self.keyboard.add_event_cb(lambda *args: mpos.ui.anim.smooth_hide(self.keyboard), lv.EVENT.CANCEL, None) @@ -250,19 +307,22 @@ def onStop(self, screen): def radio_event_handler(self, event): print("radio_event_handler called") - if self.active_radio_index >= 0: - print(f"removing old CHECKED state from child {self.active_radio_index}") - old_cb = self.radio_container.get_child(self.active_radio_index) - old_cb.remove_state(lv.STATE.CHECKED) - self.active_radio_index = -1 - for childnr in range(self.radio_container.get_child_count()): - child = self.radio_container.get_child(childnr) - state = child.get_state() - print(f"radio_container child's state: {state}") - if state & lv.STATE.CHECKED: # State can be something like 19 = lv.STATE.HOVERED (16) & lv.STATE.FOCUSED (2) & lv.STATE.CHECKED (1) - self.active_radio_index = childnr - break - print(f"active_radio_index is now {self.active_radio_index}") + target_obj = event.get_target_obj() + target_obj_state = target_obj.get_state() + print(f"target_obj state {target_obj.get_text()} is {target_obj_state}") + checked = target_obj_state & lv.STATE.CHECKED + current_checkbox_index = target_obj.get_index() + print(f"current_checkbox_index: {current_checkbox_index}") + if not checked: + if self.active_radio_index == current_checkbox_index: + print(f"unchecking {current_checkbox_index}") + self.active_radio_index = -1 # nothing checked + return + else: + if self.active_radio_index >= 0: # is there something to uncheck? + old_checked = self.radio_container.get_child(self.active_radio_index) + old_checked.remove_state(lv.STATE.CHECKED) + self.active_radio_index = current_checkbox_index def create_radio_button(self, parent, text, index): cb = lv.checkbox(parent) @@ -291,12 +351,43 @@ def cambutton_cb_unused(self, event): self.startActivityForResult(Intent(activity_class=CameraApp).putExtra("scanqr_mode", True), self.gotqr_result_callback) def save_setting(self, setting): - if setting["key"] == "boot_mode" and self.radio_container: # special case that isn't saved - if self.active_radio_index == 1: + # Check special cases that aren't saved + if self.radio_container and self.active_radio_index == 1: + if setting["key"] == "boot_mode": from mpos.bootloader import ResetIntoBootloader intent = Intent(activity_class=ResetIntoBootloader) self.startActivity(intent) return + elif setting["key"] == "format_internal_data_partition": + # Inspired by lvgl_micropython/lib/micropython/ports/esp32/modules/inisetup.py + # Note: it would be nice to create a "FormatInternalDataPartition" activity with some progress or confirmation + try: + import vfs + from flashbdev import bdev + except Exception as e: + print(f"Could not format internal data partition because: {e}") + self.finish() # would be nice to show the error instead of silently returning + return + if bdev.info()[4] == "vfs": + print(f"Formatting {bdev} as LittleFS2") + vfs.VfsLfs2.mkfs(bdev) + fs = vfs.VfsLfs2(bdev) + elif bdev.info()[4] == "ffat": + print(f"Formatting {bdev} as FAT") + vfs.VfsFat.mkfs(bdev) + fs = vfs.VfsFat(bdev) + print(f"Mounting {fs} at /") + vfs.mount(fs, "/") + print("Done formatting, (re)mounting /builtin") + try: + import freezefs_mount_builtin + except Exception as e: + # This will throw an exception if there is already a "/builtin" folder present + print("settings.py: WARNING: could not import/run freezefs_mount_builtin: ", e) + print("Done mounting, refreshing apps") + PackageManager.refresh_apps() + self.finish() + return ui = setting.get("ui") ui_options = setting.get("ui_options") @@ -323,4 +414,6 @@ def save_setting(self, setting): if changed_callback and old_value != new_value: print(f"Setting {setting['key']} changed from {old_value} to {new_value}, calling changed_callback...") changed_callback() + if setting["key"] == "theme_light_dark" or setting["key"] == "theme_primary_color": + mpos.ui.set_theme(self.prefs) self.finish() diff --git a/internal_filesystem/builtin/apps/com.micropythonos.wifi/META-INF/MANIFEST.JSON b/internal_filesystem/builtin/apps/com.micropythonos.wifi/META-INF/MANIFEST.JSON index 1b97c7ca..6e23afc4 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.wifi/META-INF/MANIFEST.JSON +++ b/internal_filesystem/builtin/apps/com.micropythonos.wifi/META-INF/MANIFEST.JSON @@ -3,10 +3,10 @@ "publisher": "MicroPythonOS", "short_description": "WiFi Network Configuration", "long_description": "Scans for wireless networks, shows a list of SSIDs, allows for password entry, and connecting.", -"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.wifi/icons/com.micropythonos.wifi_0.0.8_64x64.png", -"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.wifi/mpks/com.micropythonos.wifi_0.0.8.mpk", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.wifi/icons/com.micropythonos.wifi_0.0.11_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.wifi/mpks/com.micropythonos.wifi_0.0.11.mpk", "fullname": "com.micropythonos.wifi", -"version": "0.0.8", +"version": "0.0.11", "category": "networking", "activities": [ { diff --git a/internal_filesystem/builtin/apps/com.micropythonos.wifi/assets/wifi.py b/internal_filesystem/builtin/apps/com.micropythonos.wifi/assets/wifi.py index 2d171926..82aeab89 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.wifi/assets/wifi.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.wifi/assets/wifi.py @@ -5,10 +5,12 @@ import _thread from mpos.apps import Activity, Intent +from mpos.ui.keyboard import MposKeyboard import mpos.config import mpos.ui.anim -import mpos.wifi +import mpos.ui.theme +from mpos.net.wifi_service import WifiService have_network = True try: @@ -67,8 +69,8 @@ def onResume(self, screen): global access_points access_points = mpos.config.SharedPreferences("com.micropythonos.system.wifiservice").get_dict("access_points") if len(self.ssids) == 0: - if mpos.wifi.WifiService.wifi_busy == False: - mpos.wifi.WifiService.wifi_busy = True + if WifiService.wifi_busy == False: + WifiService.wifi_busy = True self.start_scan_networks() else: self.show_error("Wifi is busy, please try again later.") @@ -105,7 +107,7 @@ def scan_networks_thread(self): self.show_error("Wi-Fi scan failed") # scan done: self.busy_scanning = False - mpos.wifi.WifiService.wifi_busy = False + WifiService.wifi_busy = False self.update_ui_threadsafe_if_foreground(self.scan_button_label.set_text,self.scan_button_scan_text) self.update_ui_threadsafe_if_foreground(self.scan_button.remove_state, lv.STATE.DISABLED) self.update_ui_threadsafe_if_foreground(self.refresh_list) @@ -158,7 +160,7 @@ def select_ssid_cb(self,ssid): def password_page_result_cb(self, result): print(f"PasswordPage finished, result: {result}") - if result.get("result_code"): + if result.get("result_code") is True: data = result.get("data") if data: self.start_attempt_connecting(data.get("ssid"), data.get("password")) @@ -228,14 +230,13 @@ def onCreate(self): password_page=lv.obj() print(f"show_password_page: Creating label for SSID: {self.selected_ssid}") label=lv.label(password_page) - label.set_text(f"Password for {self.selected_ssid}") + label.set_text(f"Password for: {self.selected_ssid}") label.align(lv.ALIGN.TOP_MID,0,5) print("PasswordPage: Creating password textarea") self.password_ta=lv.textarea(password_page) self.password_ta.set_width(lv.pct(90)) self.password_ta.set_one_line(True) - self.password_ta.align_to(label, lv.ALIGN.OUT_BOTTOM_MID, 0, 0) - self.password_ta.add_event_cb(lambda *args: self.show_keyboard(), lv.EVENT.CLICKED, None) + self.password_ta.align_to(label, lv.ALIGN.OUT_BOTTOM_MID, 0, 5) print("PasswordPage: Creating Connect button") self.connect_button=lv.button(password_page) self.connect_button.set_size(100,40) @@ -257,20 +258,13 @@ def onCreate(self): self.password_ta.set_text(pwd) self.password_ta.set_placeholder_text("Password") print("PasswordPage: Creating keyboard (hidden by default)") - self.keyboard=lv.keyboard(password_page) + self.keyboard=MposKeyboard(password_page) self.keyboard.align(lv.ALIGN.BOTTOM_MID,0,0) self.keyboard.set_textarea(self.password_ta) - self.keyboard.set_style_min_height(160, 0) - self.keyboard.add_event_cb(lambda *args: self.hide_keyboard(), lv.EVENT.READY, None) - self.keyboard.add_event_cb(lambda *args: self.hide_keyboard(), lv.EVENT.CANCEL, None) self.keyboard.add_flag(lv.obj.FLAG.HIDDEN) - self.keyboard.add_event_cb(self.handle_keyboard_events, lv.EVENT.VALUE_CHANGED, None) print("PasswordPage: Loading password page") self.setContentView(password_page) - def onStop(self, screen): - self.hide_keyboard() - def connect_cb(self, event): global access_points print("connect_cb: Connect button clicked") @@ -289,28 +283,6 @@ def cancel_cb(self, event): print("cancel_cb: Cancel button clicked") self.finish() - def show_keyboard(self): - self.connect_button.add_flag(lv.obj.FLAG.HIDDEN) - self.cancel_button.add_flag(lv.obj.FLAG.HIDDEN) - mpos.ui.anim.smooth_show(self.keyboard) - focusgroup = lv.group_get_default() - if focusgroup: - focusgroup.focus_next() # move the focus to the keyboard to save the user a "next" button press (optional but nice) - - def hide_keyboard(self): - mpos.ui.anim.smooth_hide(self.keyboard) - self.connect_button.remove_flag(lv.obj.FLAG.HIDDEN) - self.cancel_button.remove_flag(lv.obj.FLAG.HIDDEN) - - def handle_keyboard_events(self, event): - target_obj=event.get_target_obj() # keyboard - button = target_obj.get_selected_button() - text = target_obj.get_button_text(button) - #print(f"button {button} and text {text}") - if text == lv.SYMBOL.NEW_LINE: - print("Newline pressed, closing the keyboard...") - self.hide_keyboard() - @staticmethod def setPassword(ssid, password): global access_points diff --git a/internal_filesystem/lib/README.md b/internal_filesystem/lib/README.md index fe3eabff..a5d0eafc 100644 --- a/internal_filesystem/lib/README.md +++ b/internal_filesystem/lib/README.md @@ -8,4 +8,6 @@ This /lib folder contains: - mip.install("base64") # for nostr etc - mip.install("collections") # used by aiohttp - mip.install("unittest") +- mip.install("logging") +- mip.install("aiorepl") diff --git a/internal_filesystem/lib/aiohttp/__init__.py b/internal_filesystem/lib/aiohttp/__init__.py index f2e4f17d..1e6d89d0 100644 --- a/internal_filesystem/lib/aiohttp/__init__.py +++ b/internal_filesystem/lib/aiohttp/__init__.py @@ -27,7 +27,6 @@ def _get_header(self, keyname, default): def _decode(self, data): c_encoding = self._get_header("content-encoding", None) if c_encoding in ("gzip", "deflate", "gzip,deflate"): - print(f"__init__.py of aiohttp has to decompress {c_encoding}") try: import deflate import io @@ -43,7 +42,9 @@ def _decode(self, data): return data async def read(self, sz=-1): - return self._decode(await self.content.read(sz)) + return self._decode( + await (self.content.read(sz) if sz == -1 else self.content.readexactly(sz)) + ) async def text(self, encoding="utf-8"): return (await self.read(int(self._get_header("content-length", -1)))).decode(encoding) @@ -60,20 +61,20 @@ def __init__(self, reader): self.content = reader self.chunk_size = 0 - async def read(self, sz=2 * 1024 * 1024): # reduced from 4 to 2MB + async def read(self, sz=4 * 1024 * 1024): if self.chunk_size == 0: l = await self.content.readline() l = l.split(b";", 1)[0] self.chunk_size = int(l, 16) if self.chunk_size == 0: # End of message - sep = await self.content.read(2) + sep = await self.content.readexactly(2) assert sep == b"\r\n" return b"" - data = await self.content.read(min(sz, self.chunk_size)) + data = await self.content.readexactly(min(sz, self.chunk_size)) self.chunk_size -= len(data) if self.chunk_size == 0: - sep = await self.content.read(2) + sep = await self.content.readexactly(2) assert sep == b"\r\n" return self._decode(data) @@ -137,7 +138,6 @@ async def _request(self, method, url, data=None, json=None, ssl=None, params=Non break if chunked: - print("__init__.py of aiohttp received chunked, creating ChunkedClientResponse") resp = ChunkedClientResponse(reader) else: resp = ClientResponse(reader) diff --git a/internal_filesystem/lib/aiohttp/aiohttp_ws.py b/internal_filesystem/lib/aiohttp/aiohttp_ws.py index 6a8427ce..0510f494 100644 --- a/internal_filesystem/lib/aiohttp/aiohttp_ws.py +++ b/internal_filesystem/lib/aiohttp/aiohttp_ws.py @@ -96,8 +96,6 @@ def _process_websocket_frame(self, opcode, payload): return self.PONG, payload elif opcode == self.PONG: # pragma: no branch return None, None - else: - print(f"Warning: aiohttp_ws.py received unsupported opcode {opcode} with data {payload}") return None, payload @classmethod @@ -168,7 +166,10 @@ async def handshake(self, uri, ssl, req): async def receive(self): while True: - opcode, payload = await self._read_frame() + opcode, payload, final = await self._read_frame() + while not final: + _, morepayload, final = await self._read_frame() # original opcode must be preserved + payload += morepayload send_opcode, data = self._process_websocket_frame(opcode, payload) if send_opcode: # pragma: no cover await self.send(data, send_opcode) @@ -191,7 +192,7 @@ async def close(self): await self.send(b"", self.CLOSE) async def _read_frame(self): - header = await self.reader.read(2) + header = await self.reader.readexactly(2) if len(header) != 2: # pragma: no cover # raise OSError(32, "Websocket connection closed") opcode = self.CLOSE @@ -199,34 +200,16 @@ async def _read_frame(self): return opcode, payload fin, opcode, has_mask, length = self._parse_frame_header(header) if length == 126: # Magic number, length header is 2 bytes - length_data = await self.reader.read(2) - if len(length_data) != 2: - print("WARNING: aiohttp_ws.py failed to read 2-byte length, closing") - return self.CLOSE, b"" - (length,) = struct.unpack("!H", length_data) + (length,) = struct.unpack("!H", await self.reader.readexactly(2)) elif length == 127: # Magic number, length header is 8 bytes - length_data = await self.reader.read(8) - if len(length_data) != 8: - print("WARNING: aiohttp_ws.py failed to read 8-byte length, closing") - return self.CLOSE, b"" - (length,) = struct.unpack("!Q", length_data) + (length,) = struct.unpack("!Q", await self.reader.readexactly(8)) + if has_mask: # pragma: no cover - mask = await self.reader.read(4) - if len(mask) != 4: - print("WARNING: aiohttp_ws.py failed to read mask, closing") - return self.CLOSE, b"" - payload = b"" - remaining_length = length - while remaining_length > 0: - chunk = await self.reader.read(remaining_length) - if not chunk: # Connection closed or error - print(f"WARNING: aiohttp_ws.py connection closed while reading payload, got {len(payload)}/{length} bytes, closing") - return self.CLOSE, b"" - payload += chunk - remaining_length -= len(chunk) + mask = await self.reader.readexactly(4) + payload = await self.reader.readexactly(length) if has_mask: # pragma: no cover payload = bytes(x ^ mask[i % 4] for i, x in enumerate(payload)) - return opcode, payload + return opcode, payload, fin class ClientWebSocketResponse: diff --git a/internal_filesystem/lib/aiorepl.mpy b/internal_filesystem/lib/aiorepl.mpy new file mode 100644 index 00000000..a8689549 Binary files /dev/null and b/internal_filesystem/lib/aiorepl.mpy differ diff --git a/internal_filesystem/lib/logging.mpy b/internal_filesystem/lib/logging.mpy new file mode 100644 index 00000000..2a765951 Binary files /dev/null and b/internal_filesystem/lib/logging.mpy differ diff --git a/internal_filesystem/lib/mpos/__init__.py b/internal_filesystem/lib/mpos/__init__.py index eda6eba5..0746708d 100644 --- a/internal_filesystem/lib/mpos/__init__.py +++ b/internal_filesystem/lib/mpos/__init__.py @@ -1,9 +1,12 @@ # Core framework from .app.app import App from .app.activity import Activity +from .net.connectivity_manager import ConnectivityManager +from .net import download_manager as DownloadManager from .content.intent import Intent from .activity_navigator import ActivityNavigator from .content.package_manager import PackageManager +from .task_manager import TaskManager # Common activities (optional) from .app.activities.chooser import ChooserActivity @@ -11,7 +14,7 @@ from .app.activities.share import ShareActivity __all__ = [ - "App", "Activity", "Intent", - "ActivityNavigator", "PackageManager", + "App", "Activity", "ConnectivityManager", "DownloadManager", "Intent", + "ActivityNavigator", "PackageManager", "TaskManager", "ChooserActivity", "ViewActivity", "ShareActivity" ] diff --git a/internal_filesystem/lib/mpos/app/activity.py b/internal_filesystem/lib/mpos/app/activity.py index e7432100..e0cd71c2 100644 --- a/internal_filesystem/lib/mpos/app/activity.py +++ b/internal_filesystem/lib/mpos/app/activity.py @@ -19,7 +19,7 @@ def onStart(self, screen): def onResume(self, screen): # app goes to foreground self._has_foreground = True - mpos.ui.th.add_event_cb(self.task_handler_callback, 1) + mpos.ui.task_handler.add_event_cb(self.task_handler_callback, 1) def onPause(self, screen): # app goes to background self._has_foreground = False @@ -73,10 +73,12 @@ def task_handler_callback(self, a, b): self.throttle_async_call_counter = 0 # Execute a function if the Activity is in the foreground - def if_foreground(self, func, *args, **kwargs): + def if_foreground(self, func, *args, event=None, **kwargs): if self._has_foreground: - print(f"executing {func} with args {args} and kwargs {kwargs}") + #print(f"executing {func} with args {args} and kwargs {kwargs}") result = func(*args, **kwargs) + if event: + event.set() return result else: #print(f"[if_foreground] Skipped {func} because _has_foreground=False") @@ -84,11 +86,13 @@ def if_foreground(self, func, *args, **kwargs): # Update the UI in a threadsafe way if the Activity is in the foreground # The call may get throttled, unless important=True is added to it. - def update_ui_threadsafe_if_foreground(self, func, *args, important=False, **kwargs): + # The order of these update_ui calls are not guaranteed, so a UI update might be overwritten by an "earlier" update. + # To avoid this, use lv.timer_create() with .set_repeat_count(1) as examplified in osupdate.py + def update_ui_threadsafe_if_foreground(self, func, *args, important=False, event=None, **kwargs): self.throttle_async_call_counter += 1 if not important and self.throttle_async_call_counter > 100: # 250 seems to be okay, so 100 is on the safe side print(f"update_ui_threadsafe_if_foreground called more than 100 times for one UI frame, which can overflow - throttling!") return None # lv.async_call() is needed to update the UI from another thread than the main one (as LVGL is not thread safe) - result = lv.async_call(lambda _: self.if_foreground(func, *args, **kwargs),None) + result = lv.async_call(lambda _: self.if_foreground(func, *args, event=event, **kwargs), None) return result diff --git a/internal_filesystem/lib/mpos/app/app.py b/internal_filesystem/lib/mpos/app/app.py index 43b96a17..c1e10c34 100644 --- a/internal_filesystem/lib/mpos/app/app.py +++ b/internal_filesystem/lib/mpos/app/app.py @@ -32,7 +32,7 @@ def __init__( self.installed_path = installed_path self.icon_path = self._find_icon_path() - print(f"App constructor got icon_path: {self.icon_path}") + #print(f"App constructor got icon_path: {self.icon_path}") if self.icon_path: self.icon_data = self._load_icon_data(self.icon_path) else: @@ -43,21 +43,22 @@ def __str__(self): return f"App({self.name}, version {self.version}, {self.category})" def _load_icon_data(self, icon_path): - print(f"App _load_icon_data for {icon_path}") + #print(f"App _load_icon_data for {icon_path}") try: f = open(icon_path, 'rb') return f.read() except Exception as e: - print(f"open {icon_path} got error: {e}") + #print(f"open {icon_path} got error: {e}") + pass def _check_icon_path(self, tocheck): try: - print(f"checking {tocheck}") + #print(f"checking {tocheck}") st = os.stat(tocheck) - print(f"_find_icon_path for {tocheck} found {st}") + #print(f"_find_icon_path for {tocheck} found {st}") return tocheck except Exception as e: - print(f"No app icon found in {tocheck}: {e}") + #print(f"No app icon found in {tocheck}: {e}") return None def _find_icon_path(self): diff --git a/internal_filesystem/lib/mpos/apps.py b/internal_filesystem/lib/mpos/apps.py index ae5100d3..551e811a 100644 --- a/internal_filesystem/lib/mpos/apps.py +++ b/internal_filesystem/lib/mpos/apps.py @@ -10,13 +10,14 @@ from mpos.content.package_manager import PackageManager def good_stack_size(): - stacksize = 24*1024 + stacksize = 24*1024 # less than 20KB crashes on desktop when doing heavy apps, like LightningPiggy's Wallet connections import sys if sys.platform == "esp32": stacksize = 16*1024 return stacksize # Run the script in the current thread: +# Returns True if successful def execute_script(script_source, is_file, cwd=None, classname=None): import utime # for timing read and compile thread_id = _thread.get_ident() @@ -36,7 +37,7 @@ def execute_script(script_source, is_file, cwd=None, classname=None): } print(f"Thread {thread_id}: starting script") import sys - path_before = sys.path + path_before = sys.path[:] # Make a copy, not a reference if cwd: sys.path.append(cwd) try: @@ -55,26 +56,34 @@ def execute_script(script_source, is_file, cwd=None, classname=None): #print("Classes:", classes.keys()) #print("Functions:", functions.keys()) #print("Variables:", variables.keys()) - if classname: - main_activity = script_globals.get(classname) - if main_activity: - start_time = utime.ticks_ms() - Activity.startActivity(None, Intent(activity_class=main_activity)) - end_time = utime.ticks_diff(utime.ticks_ms(), start_time) - print(f"execute_script: Activity.startActivity took {end_time}ms") - else: - print("Warning: could not find main_activity") + if not classname: + print("Running without a classname isn't supported right now.") + return False + main_activity = script_globals.get(classname) + if main_activity: + start_time = utime.ticks_ms() + Activity.startActivity(None, Intent(activity_class=main_activity)) + end_time = utime.ticks_diff(utime.ticks_ms(), start_time) + print(f"execute_script: Activity.startActivity took {end_time}ms") + else: + print(f"Warning: could not find app's main_activity {main_activity}") + return False except Exception as e: print(f"Thread {thread_id}: exception during execution:") # Print stack trace with exception type, value, and traceback tb = getattr(e, '__traceback__', None) traceback.print_exception(type(e), e, tb) - print(f"Thread {thread_id}: script {compile_name} finished") - sys.path = path_before + return False + finally: + # Always restore sys.path, even if we return early or raise an exception + print(f"Thread {thread_id}: script {compile_name} finished, restoring sys.path from {sys.path} to {path_before}") + sys.path = path_before + return True except Exception as e: print(f"Thread {thread_id}: error:") tb = getattr(e, '__traceback__', None) traceback.print_exception(type(e), e, tb) + return False """ Unused: # Run the script in a new thread: @@ -104,6 +113,7 @@ def execute_script_new_thread(scriptname, is_file): print("main.py: execute_script_new_thread(): error starting new thread thread: ", e) """ +# Returns True if successful def start_app(fullname): mpos.ui.set_foreground_app(fullname) import utime @@ -119,7 +129,7 @@ def start_app(fullname): print(f"WARNING: start_app can't start {fullname} because it doesn't have a main_launcher_activity") return start_script_fullpath = f"{app.installed_path}/{app.main_launcher_activity.get('entrypoint')}" - execute_script(start_script_fullpath, True, app.installed_path + "/assets/", app.main_launcher_activity.get("classname")) + result = execute_script(start_script_fullpath, True, app.installed_path + "/assets/", app.main_launcher_activity.get("classname")) # Launchers have the bar, other apps don't have it if app.is_valid_launcher(): mpos.ui.topmenu.open_bar() @@ -127,15 +137,14 @@ def start_app(fullname): mpos.ui.topmenu.close_bar() end_time = utime.ticks_diff(utime.ticks_ms(), start_time) print(f"start_app() took {end_time}ms") + return result + # Starts the first launcher that's found def restart_launcher(): print("restart_launcher") - mpos.ui.empty_screen_stack() + # Stop all apps + mpos.ui.remove_and_stop_all_activities() # No need to stop the other launcher first, because it exits after building the screen - for app in PackageManager.get_app_list(): - if app.is_valid_launcher(): - print(f"Found launcher, starting {app.fullname}") - start_app(app.fullname) - break + return start_app(PackageManager.get_launcher().fullname) diff --git a/internal_filesystem/lib/mpos/audio/__init__.py b/internal_filesystem/lib/mpos/audio/__init__.py new file mode 100644 index 00000000..37be5058 --- /dev/null +++ b/internal_filesystem/lib/mpos/audio/__init__.py @@ -0,0 +1,60 @@ +# AudioFlinger - Centralized Audio Management Service for MicroPythonOS +# Android-inspired audio routing with priority-based audio focus +# Simple routing: play_wav() -> I2S, play_rtttl() -> buzzer, record_wav() -> I2S mic + +from . import audioflinger + +# Re-export main API +from .audioflinger import ( + # Stream types (for priority-based audio focus) + STREAM_MUSIC, + STREAM_NOTIFICATION, + STREAM_ALARM, + + # Core playback functions + init, + play_wav, + play_rtttl, + stop, + pause, + resume, + set_volume, + get_volume, + is_playing, + + # Recording functions + record_wav, + is_recording, + + # Hardware availability checks + has_i2s, + has_buzzer, + has_microphone, +) + +__all__ = [ + # Stream types + 'STREAM_MUSIC', + 'STREAM_NOTIFICATION', + 'STREAM_ALARM', + + # Playback functions + 'init', + 'play_wav', + 'play_rtttl', + 'stop', + 'pause', + 'resume', + 'set_volume', + 'get_volume', + 'is_playing', + + # Recording functions + 'record_wav', + 'is_recording', + + # Hardware checks + 'has_i2s', + 'has_buzzer', + 'has_microphone', +] diff --git a/internal_filesystem/lib/mpos/audio/audioflinger.py b/internal_filesystem/lib/mpos/audio/audioflinger.py new file mode 100644 index 00000000..031c3956 --- /dev/null +++ b/internal_filesystem/lib/mpos/audio/audioflinger.py @@ -0,0 +1,370 @@ +# AudioFlinger - Core Audio Management Service +# Centralized audio routing with priority-based audio focus (Android-inspired) +# Supports I2S (digital audio) and PWM buzzer (tones/ringtones) +# +# Simple routing: play_wav() -> I2S, play_rtttl() -> buzzer, record_wav() -> I2S mic +# Uses _thread for non-blocking background playback/recording (separate thread from UI) + +import _thread +import mpos.apps + +# Stream type constants (priority order: higher number = higher priority) +STREAM_MUSIC = 0 # Background music (lowest priority) +STREAM_NOTIFICATION = 1 # Notification sounds (medium priority) +STREAM_ALARM = 2 # Alarms/alerts (highest priority) + +# Module-level state (singleton pattern, follows battery_voltage.py) +_i2s_pins = None # I2S pin configuration dict (created per-stream) +_buzzer_instance = None # PWM buzzer instance +_current_stream = None # Currently playing stream +_current_recording = None # Currently recording stream +_volume = 50 # System volume (0-100) + + +def init(i2s_pins=None, buzzer_instance=None): + """ + Initialize AudioFlinger with hardware configuration. + + Args: + i2s_pins: Dict with 'sck', 'ws', 'sd' pin numbers (for I2S/WAV playback) + buzzer_instance: PWM instance for buzzer (for RTTTL playback) + """ + global _i2s_pins, _buzzer_instance + + _i2s_pins = i2s_pins + _buzzer_instance = buzzer_instance + + # Build status message + capabilities = [] + if i2s_pins: + capabilities.append("I2S (WAV)") + if buzzer_instance: + capabilities.append("Buzzer (RTTTL)") + + if capabilities: + print(f"AudioFlinger initialized: {', '.join(capabilities)}") + else: + print("AudioFlinger initialized: No audio hardware") + + +def has_i2s(): + """Check if I2S audio is available for WAV playback.""" + return _i2s_pins is not None + + +def has_buzzer(): + """Check if buzzer is available for RTTTL playback.""" + return _buzzer_instance is not None + + +def has_microphone(): + """Check if I2S microphone is available for recording.""" + return _i2s_pins is not None and 'sd_in' in _i2s_pins + + +def _check_audio_focus(stream_type): + """ + Check if a stream with the given type can start playback. + Implements priority-based audio focus (Android-inspired). + + Args: + stream_type: Stream type (STREAM_MUSIC, STREAM_NOTIFICATION, STREAM_ALARM) + + Returns: + bool: True if stream can start, False if rejected + """ + global _current_stream + + if not _current_stream: + return True # No stream playing, OK to start + + if not _current_stream.is_playing(): + return True # Current stream finished, OK to start + + # Check priority + if stream_type <= _current_stream.stream_type: + print(f"AudioFlinger: Stream rejected (priority {stream_type} <= current {_current_stream.stream_type})") + return False + + # Higher priority stream - interrupt current + print(f"AudioFlinger: Interrupting stream (priority {stream_type} > current {_current_stream.stream_type})") + _current_stream.stop() + return True + + +def _playback_thread(stream): + """ + Thread function for audio playback. + Runs in a separate thread to avoid blocking the UI. + + Args: + stream: Stream instance (WAVStream or RTTTLStream) + """ + global _current_stream + + _current_stream = stream + + try: + # Run synchronous playback in this thread + stream.play() + except Exception as e: + print(f"AudioFlinger: Playback error: {e}") + finally: + # Clear current stream + if _current_stream == stream: + _current_stream = None + + +def play_wav(file_path, stream_type=STREAM_MUSIC, volume=None, on_complete=None): + """ + Play WAV file via I2S. + + Args: + file_path: Path to WAV file (e.g., "M:/sdcard/music/song.wav") + stream_type: Stream type (STREAM_MUSIC, STREAM_NOTIFICATION, STREAM_ALARM) + volume: Override volume (0-100), or None to use system volume + on_complete: Callback function(message) called when playback finishes + + Returns: + bool: True if playback started, False if rejected or unavailable + """ + if not _i2s_pins: + print("AudioFlinger: play_wav() failed - I2S not configured") + return False + + # Check audio focus + if not _check_audio_focus(stream_type): + return False + + # Create stream and start playback in separate thread + try: + from mpos.audio.stream_wav import WAVStream + + stream = WAVStream( + file_path=file_path, + stream_type=stream_type, + volume=volume if volume is not None else _volume, + i2s_pins=_i2s_pins, + on_complete=on_complete + ) + + _thread.stack_size(mpos.apps.good_stack_size()) + _thread.start_new_thread(_playback_thread, (stream,)) + return True + + except Exception as e: + print(f"AudioFlinger: play_wav() failed: {e}") + return False + + +def play_rtttl(rtttl_string, stream_type=STREAM_NOTIFICATION, volume=None, on_complete=None): + """ + Play RTTTL ringtone via buzzer. + + Args: + rtttl_string: RTTTL format string (e.g., "Nokia:d=4,o=5,b=225:8e6,8d6...") + stream_type: Stream type (STREAM_MUSIC, STREAM_NOTIFICATION, STREAM_ALARM) + volume: Override volume (0-100), or None to use system volume + on_complete: Callback function(message) called when playback finishes + + Returns: + bool: True if playback started, False if rejected or unavailable + """ + if not _buzzer_instance: + print("AudioFlinger: play_rtttl() failed - buzzer not configured") + return False + + # Check audio focus + if not _check_audio_focus(stream_type): + return False + + # Create stream and start playback in separate thread + try: + from mpos.audio.stream_rtttl import RTTTLStream + + stream = RTTTLStream( + rtttl_string=rtttl_string, + stream_type=stream_type, + volume=volume if volume is not None else _volume, + buzzer_instance=_buzzer_instance, + on_complete=on_complete + ) + + _thread.stack_size(mpos.apps.good_stack_size()) + _thread.start_new_thread(_playback_thread, (stream,)) + return True + + except Exception as e: + print(f"AudioFlinger: play_rtttl() failed: {e}") + return False + + +def _recording_thread(stream): + """ + Thread function for audio recording. + Runs in a separate thread to avoid blocking the UI. + + Args: + stream: RecordStream instance + """ + global _current_recording + + _current_recording = stream + + try: + # Run synchronous recording in this thread + stream.record() + except Exception as e: + print(f"AudioFlinger: Recording error: {e}") + finally: + # Clear current recording + if _current_recording == stream: + _current_recording = None + + +def record_wav(file_path, duration_ms=None, on_complete=None, sample_rate=16000): + """ + Record audio from I2S microphone to WAV file. + + Args: + file_path: Path to save WAV file (e.g., "data/recording.wav") + duration_ms: Recording duration in milliseconds (None = 60 seconds default) + on_complete: Callback function(message) when recording finishes + sample_rate: Sample rate in Hz (default 16000 for voice) + + Returns: + bool: True if recording started, False if rejected or unavailable + """ + print(f"AudioFlinger.record_wav() called") + print(f" file_path: {file_path}") + print(f" duration_ms: {duration_ms}") + print(f" sample_rate: {sample_rate}") + print(f" _i2s_pins: {_i2s_pins}") + print(f" has_microphone(): {has_microphone()}") + + if not has_microphone(): + print("AudioFlinger: record_wav() failed - microphone not configured") + return False + + # Cannot record while playing (I2S can only be TX or RX, not both) + if is_playing(): + print("AudioFlinger: Cannot record while playing") + return False + + # Cannot start new recording while already recording + if is_recording(): + print("AudioFlinger: Already recording") + return False + + # Create stream and start recording in separate thread + try: + print("AudioFlinger: Importing RecordStream...") + from mpos.audio.stream_record import RecordStream + + print("AudioFlinger: Creating RecordStream instance...") + stream = RecordStream( + file_path=file_path, + duration_ms=duration_ms, + sample_rate=sample_rate, + i2s_pins=_i2s_pins, + on_complete=on_complete + ) + + print("AudioFlinger: Starting recording thread...") + _thread.stack_size(mpos.apps.good_stack_size()) + _thread.start_new_thread(_recording_thread, (stream,)) + print("AudioFlinger: Recording thread started successfully") + return True + + except Exception as e: + import sys + print(f"AudioFlinger: record_wav() failed: {e}") + sys.print_exception(e) + return False + + +def stop(): + """Stop current audio playback or recording.""" + global _current_stream, _current_recording + + stopped = False + + if _current_stream: + _current_stream.stop() + print("AudioFlinger: Playback stopped") + stopped = True + + if _current_recording: + _current_recording.stop() + print("AudioFlinger: Recording stopped") + stopped = True + + if not stopped: + print("AudioFlinger: No playback or recording to stop") + + +def pause(): + """ + Pause current audio playback (if supported by stream). + Note: Most streams don't support pause, only stop. + """ + if _current_stream and hasattr(_current_stream, 'pause'): + _current_stream.pause() + print("AudioFlinger: Playback paused") + else: + print("AudioFlinger: Pause not supported or no playback active") + + +def resume(): + """ + Resume paused audio playback (if supported by stream). + Note: Most streams don't support resume, only play. + """ + if _current_stream and hasattr(_current_stream, 'resume'): + _current_stream.resume() + print("AudioFlinger: Playback resumed") + else: + print("AudioFlinger: Resume not supported or no playback active") + + +def set_volume(volume): + """ + Set system volume (affects new streams, not current playback). + + Args: + volume: Volume level (0-100) + """ + global _volume + _volume = max(0, min(100, volume)) + if _current_stream: + _current_stream.set_volume(_volume) + + +def get_volume(): + """ + Get system volume. + + Returns: + int: Current system volume (0-100) + """ + return _volume + + +def is_playing(): + """ + Check if audio is currently playing. + + Returns: + bool: True if playback active, False otherwise + """ + return _current_stream is not None and _current_stream.is_playing() + + +def is_recording(): + """ + Check if audio is currently being recorded. + + Returns: + bool: True if recording active, False otherwise + """ + return _current_recording is not None and _current_recording.is_recording() diff --git a/internal_filesystem/lib/mpos/audio/stream_record.py b/internal_filesystem/lib/mpos/audio/stream_record.py new file mode 100644 index 00000000..3a4990f7 --- /dev/null +++ b/internal_filesystem/lib/mpos/audio/stream_record.py @@ -0,0 +1,349 @@ +# RecordStream - WAV File Recording Stream for AudioFlinger +# Records 16-bit mono PCM audio from I2S microphone to WAV file +# Uses synchronous recording in a separate thread for non-blocking operation +# On desktop (no I2S hardware), generates a 440Hz sine wave for testing + +import math +import os +import sys +import time + +# Try to import machine module (not available on desktop) +try: + import machine + _HAS_MACHINE = True +except ImportError: + _HAS_MACHINE = False + + +def _makedirs(path): + """ + Create directory and all parent directories (like os.makedirs). + MicroPython doesn't have os.makedirs, so we implement it manually. + """ + if not path: + return + + parts = path.split('/') + current = '' + + for part in parts: + if not part: + continue + current = current + '/' + part if current else part + try: + os.mkdir(current) + except OSError: + pass # Directory may already exist + + +class RecordStream: + """ + WAV file recording stream with I2S input. + Records 16-bit mono PCM audio from I2S microphone. + """ + + # Default recording parameters + DEFAULT_SAMPLE_RATE = 16000 # 16kHz - good for voice + DEFAULT_MAX_DURATION_MS = 60000 # 60 seconds max + DEFAULT_FILESIZE = 1024 * 1024 * 1024 # 1GB data size because it can't be quickly set after recording + + def __init__(self, file_path, duration_ms, sample_rate, i2s_pins, on_complete): + """ + Initialize recording stream. + + Args: + file_path: Path to save WAV file + duration_ms: Recording duration in milliseconds (None = until stop()) + sample_rate: Sample rate in Hz + i2s_pins: Dict with 'sck', 'ws', 'sd_in' pin numbers + on_complete: Callback function(message) when recording finishes + """ + self.file_path = file_path + self.duration_ms = duration_ms if duration_ms else self.DEFAULT_MAX_DURATION_MS + self.sample_rate = sample_rate if sample_rate else self.DEFAULT_SAMPLE_RATE + self.i2s_pins = i2s_pins + self.on_complete = on_complete + self._keep_running = True + self._is_recording = False + self._i2s = None + self._bytes_recorded = 0 + + def is_recording(self): + """Check if stream is currently recording.""" + return self._is_recording + + def stop(self): + """Stop recording.""" + self._keep_running = False + + def get_elapsed_ms(self): + """Get elapsed recording time in milliseconds.""" + # Calculate from bytes recorded: bytes / (sample_rate * 2 bytes per sample) * 1000 + if self.sample_rate > 0: + return int((self._bytes_recorded / (self.sample_rate * 2)) * 1000) + return 0 + + # ---------------------------------------------------------------------- + # WAV header generation + # ---------------------------------------------------------------------- + @staticmethod + def _create_wav_header(sample_rate, num_channels, bits_per_sample, data_size): + """ + Create WAV file header. + + Args: + sample_rate: Sample rate in Hz + num_channels: Number of channels (1 for mono) + bits_per_sample: Bits per sample (16) + data_size: Size of audio data in bytes + + Returns: + bytes: 44-byte WAV header + """ + byte_rate = sample_rate * num_channels * (bits_per_sample // 8) + block_align = num_channels * (bits_per_sample // 8) + file_size = data_size + 36 # Total file size minus 8 bytes for RIFF header + + header = bytearray(44) + + # RIFF header + header[0:4] = b'RIFF' + header[4:8] = file_size.to_bytes(4, 'little') + header[8:12] = b'WAVE' + + # fmt chunk + header[12:16] = b'fmt ' + header[16:20] = (16).to_bytes(4, 'little') # fmt chunk size + header[20:22] = (1).to_bytes(2, 'little') # PCM format + header[22:24] = num_channels.to_bytes(2, 'little') + header[24:28] = sample_rate.to_bytes(4, 'little') + header[28:32] = byte_rate.to_bytes(4, 'little') + header[32:34] = block_align.to_bytes(2, 'little') + header[34:36] = bits_per_sample.to_bytes(2, 'little') + + # data chunk + header[36:40] = b'data' + header[40:44] = data_size.to_bytes(4, 'little') + + return bytes(header) + + @staticmethod + def _update_wav_header(file_path, data_size): + """ + Update WAV header with final data size. + + Args: + f: File object (must be opened in r+b mode) + data_size: Final size of audio data in bytes + """ + file_size = data_size + 36 + + f = open(file_path, 'r+b') + + # Update file size at offset 4 + f.seek(4) + f.write(file_size.to_bytes(4, 'little')) + + # Update data size at offset 40 + f.seek(40) + f.write(data_size.to_bytes(4, 'little')) + + f.close() + + + # ---------------------------------------------------------------------- + # Desktop simulation - generate 440Hz sine wave + # ---------------------------------------------------------------------- + def _generate_sine_wave_chunk(self, chunk_size, sample_offset): + """ + Generate a chunk of 440Hz sine wave samples for desktop testing. + + Args: + chunk_size: Number of bytes to generate (must be even for 16-bit samples) + sample_offset: Current sample offset for phase continuity + + Returns: + tuple: (bytearray of samples, number of samples generated) + """ + frequency = 440 # A4 note + amplitude = 16000 # ~50% of max 16-bit amplitude + + num_samples = chunk_size // 2 + buf = bytearray(chunk_size) + + for i in range(num_samples): + # Calculate sine wave sample + t = (sample_offset + i) / self.sample_rate + sample = int(amplitude * math.sin(2 * math.pi * frequency * t)) + + # Clamp to 16-bit range + if sample > 32767: + sample = 32767 + elif sample < -32768: + sample = -32768 + + # Write as little-endian 16-bit + buf[i * 2] = sample & 0xFF + buf[i * 2 + 1] = (sample >> 8) & 0xFF + + return buf, num_samples + + # ---------------------------------------------------------------------- + # Main recording routine + # ---------------------------------------------------------------------- + def record(self): + """Main synchronous recording routine (runs in separate thread).""" + print(f"RecordStream.record() called") + print(f" file_path: {self.file_path}") + print(f" duration_ms: {self.duration_ms}") + print(f" sample_rate: {self.sample_rate}") + print(f" i2s_pins: {self.i2s_pins}") + print(f" _HAS_MACHINE: {_HAS_MACHINE}") + + self._is_recording = True + self._bytes_recorded = 0 + + try: + # Ensure directory exists + dir_path = '/'.join(self.file_path.split('/')[:-1]) + print(f"RecordStream: Creating directory: {dir_path}") + if dir_path: + _makedirs(dir_path) + print(f"RecordStream: Directory created/verified") + + # Create file with placeholder header + print(f"RecordStream: Creating WAV file with header") + with open(self.file_path, 'wb') as f: + # Write placeholder header (will be updated at end) + header = self._create_wav_header( + self.sample_rate, + num_channels=1, + bits_per_sample=16, + data_size=self.DEFAULT_FILESIZE + ) + f.write(header) + print(f"RecordStream: Header written ({len(header)} bytes)") + + print(f"RecordStream: Recording to {self.file_path}") + print(f"RecordStream: {self.sample_rate} Hz, 16-bit, mono") + print(f"RecordStream: Max duration {self.duration_ms}ms") + + # Check if we have real I2S hardware or need to simulate + use_simulation = not _HAS_MACHINE + + if not use_simulation: + # Initialize I2S in RX mode with correct pins for microphone + try: + # Use sck_in if available (separate clock for mic), otherwise fall back to sck + sck_pin = self.i2s_pins.get('sck_in', self.i2s_pins.get('sck')) + print(f"RecordStream: Initializing I2S RX with sck={sck_pin}, ws={self.i2s_pins['ws']}, sd={self.i2s_pins['sd_in']}") + + self._i2s = machine.I2S( + 0, + sck=machine.Pin(sck_pin, machine.Pin.OUT), + ws=machine.Pin(self.i2s_pins['ws'], machine.Pin.OUT), + sd=machine.Pin(self.i2s_pins['sd_in'], machine.Pin.IN), + mode=machine.I2S.RX, + bits=16, + format=machine.I2S.MONO, + rate=self.sample_rate, + ibuf=8000 # 8KB input buffer + ) + print(f"RecordStream: I2S initialized successfully") + except Exception as e: + print(f"RecordStream: I2S init failed: {e}") + print(f"RecordStream: Falling back to simulation mode") + use_simulation = True + + if use_simulation: + print(f"RecordStream: Using desktop simulation (440Hz sine wave)") + + # Calculate recording parameters + chunk_size = 1024 # Read 1KB at a time + max_bytes = int((self.duration_ms / 1000) * self.sample_rate * 2) + start_time = time.ticks_ms() + sample_offset = 0 # For sine wave phase continuity + + # Flush every ~2 seconds of audio (64KB at 16kHz 16-bit mono) + # This spreads out the filesystem write overhead + flush_interval_bytes = 64 * 1024 + bytes_since_flush = 0 + last_flush_time = start_time + + print(f"RecordStream: max_bytes={max_bytes}, chunk_size={chunk_size}, flush_interval={flush_interval_bytes}") + + # Open file for appending audio data (append mode to avoid seek issues) + print(f"RecordStream: Opening file for audio data...") + t0 = time.ticks_ms() + f = open(self.file_path, 'ab') + print(f"RecordStream: File opened in {time.ticks_diff(time.ticks_ms(), t0)}ms") + + buf = bytearray(chunk_size) + + try: + while self._keep_running and self._bytes_recorded < max_bytes: + # Check elapsed time + elapsed = time.ticks_diff(time.ticks_ms(), start_time) + if elapsed >= self.duration_ms: + print(f"RecordStream: Duration limit reached ({elapsed}ms)") + break + + if use_simulation: + # Generate sine wave samples for desktop testing + buf, num_samples = self._generate_sine_wave_chunk(chunk_size, sample_offset) + sample_offset += num_samples + num_read = chunk_size + + # Simulate real-time recording speed + time.sleep_ms(int((chunk_size / 2) / self.sample_rate * 1000)) + else: + # Read from I2S + try: + num_read = self._i2s.readinto(buf) + except Exception as e: + print(f"RecordStream: Read error: {e}") + break + + if num_read > 0: + f.write(buf[:num_read]) + self._bytes_recorded += num_read + bytes_since_flush += num_read + + # Periodic flush to spread out filesystem overhead + if bytes_since_flush >= flush_interval_bytes: + t0 = time.ticks_ms() + f.flush() + flush_time = time.ticks_diff(time.ticks_ms(), t0) + print(f"RecordStream: Flushed {bytes_since_flush} bytes in {flush_time}ms") + bytes_since_flush = 0 + last_flush_time = time.ticks_ms() + finally: + # Explicitly close the file and measure time + print(f"RecordStream: Closing audio data file (remaining {bytes_since_flush} bytes)...") + t0 = time.ticks_ms() + f.close() + print(f"RecordStream: File closed in {time.ticks_diff(time.ticks_ms(), t0)}ms") + + # Disabled because seeking takes too long on LittleFS2: + #self._update_wav_header(self.file_path, self._bytes_recorded) + + elapsed_ms = time.ticks_diff(time.ticks_ms(), start_time) + print(f"RecordStream: Finished recording {self._bytes_recorded} bytes ({elapsed_ms}ms)") + + if self.on_complete: + self.on_complete(f"Recorded: {self.file_path}") + + except Exception as e: + import sys + print(f"RecordStream: Error: {e}") + sys.print_exception(e) + if self.on_complete: + self.on_complete(f"Error: {e}") + + finally: + self._is_recording = False + if self._i2s: + self._i2s.deinit() + self._i2s = None + print(f"RecordStream: Recording thread finished") \ No newline at end of file diff --git a/internal_filesystem/lib/mpos/audio/stream_rtttl.py b/internal_filesystem/lib/mpos/audio/stream_rtttl.py new file mode 100644 index 00000000..d02761f5 --- /dev/null +++ b/internal_filesystem/lib/mpos/audio/stream_rtttl.py @@ -0,0 +1,235 @@ +# RTTTLStream - RTTTL Ringtone Playback Stream for AudioFlinger +# Ring Tone Text Transfer Language parser and player +# Uses synchronous playback in a separate thread for non-blocking operation + +import math +import time + + +class RTTTLStream: + """ + RTTTL (Ring Tone Text Transfer Language) parser and player. + Format: "name:defaults:notes" + Example: "Nokia:d=4,o=5,b=225:8e6,8d6,8f#,8g#,8c#6,8b,d" + + See: https://en.wikipedia.org/wiki/Ring_Tone_Text_Transfer_Language + """ + + # Note frequency table (A-G, with sharps) + _NOTES = [ + 440.0, # A + 493.9, # B or H + 261.6, # C + 293.7, # D + 329.6, # E + 349.2, # F + 392.0, # G + 0.0, # pad + + 466.2, # A# + 0.0, # pad + 277.2, # C# + 311.1, # D# + 0.0, # pad + 370.0, # F# + 415.3, # G# + 0.0, # pad + ] + + def __init__(self, rtttl_string, stream_type, volume, buzzer_instance, on_complete): + """ + Initialize RTTTL stream. + + Args: + rtttl_string: RTTTL format string (e.g., "Nokia:d=4,o=5,b=225:...") + stream_type: Stream type (STREAM_MUSIC, STREAM_NOTIFICATION, STREAM_ALARM) + volume: Volume level (0-100) + buzzer_instance: PWM buzzer instance + on_complete: Callback function(message) when playback finishes + """ + self.stream_type = stream_type + self.volume = volume + self.buzzer = buzzer_instance + self.on_complete = on_complete + self._keep_running = True + self._is_playing = False + + # Parse RTTTL format + tune_pieces = rtttl_string.split(':') + if len(tune_pieces) != 3: + raise ValueError('RTTTL should contain exactly 2 colons') + + self.name = tune_pieces[0] + self.tune = tune_pieces[2] + self.tune_idx = 0 + self._parse_defaults(tune_pieces[1]) + + def is_playing(self): + """Check if stream is currently playing.""" + return self._is_playing + + def stop(self): + """Stop playback.""" + self._keep_running = False + + def _parse_defaults(self, defaults): + """ + Parse default values from RTTTL format. + Example: "d=4,o=5,b=140" + """ + self.default_duration = 4 + self.default_octave = 5 + self.bpm = 120 + + for item in defaults.split(','): + setting = item.split('=') + if len(setting) != 2: + continue + + key = setting[0].strip() + value = int(setting[1].strip()) + + if key == 'o': + self.default_octave = value + elif key == 'd': + self.default_duration = value + elif key == 'b': + self.bpm = value + + # Calculate milliseconds per whole note + # 240000 = 60 sec/min * 4 beats/whole-note * 1000 msec/sec + self.msec_per_whole_note = 240000.0 / self.bpm + + def _next_char(self): + """Get next character from tune string.""" + if self.tune_idx < len(self.tune): + char = self.tune[self.tune_idx] + self.tune_idx += 1 + if char == ',': + char = ' ' + return char + return '|' # End marker + + def _notes(self): + """ + Generator that yields (frequency, duration_ms) tuples. + + Yields: + tuple: (frequency_hz, duration_ms) for each note + """ + while True: + # Skip blank characters and commas + char = self._next_char() + while char == ' ': + char = self._next_char() + + # Parse duration (if present) + # Duration of 1 = whole note, 8 = 1/8 note + duration = 0 + while char.isdigit(): + duration *= 10 + duration += ord(char) - ord('0') + char = self._next_char() + + if duration == 0: + duration = self.default_duration + + if char == '|': # End of tune + return + + # Parse note letter + note = char.lower() + if 'a' <= note <= 'g': + note_idx = ord(note) - ord('a') + elif note == 'h': + note_idx = 1 # H is equivalent to B + elif note == 'p': + note_idx = 7 # Pause + else: + note_idx = 7 # Unknown = pause + + char = self._next_char() + + # Check for sharp + if char == '#': + note_idx += 8 + char = self._next_char() + + # Check for duration modifier (dot) before octave + duration_multiplier = 1.0 + if char == '.': + duration_multiplier = 1.5 + char = self._next_char() + + # Check for octave + if '4' <= char <= '7': + octave = ord(char) - ord('0') + char = self._next_char() + else: + octave = self.default_octave + + # Check for duration modifier (dot) after octave + if char == '.': + duration_multiplier = 1.5 + char = self._next_char() + + # Calculate frequency and duration + freq = self._NOTES[note_idx] * (1 << (octave - 4)) + msec = (self.msec_per_whole_note / duration) * duration_multiplier + + yield freq, msec + + def play(self): + """Play RTTTL tune via buzzer (runs in separate thread).""" + self._is_playing = True + + # Calculate exponential duty cycle for perceptually linear volume + if self.volume <= 0: + duty = 0 + else: + volume = min(100, self.volume) + + # Exponential volume curve + # Maximum volume is at 50% duty cycle (32768 when using duty_u16) + # Minimum is 4 (absolute minimum for audible PWM) + divider = 10 + duty = int( + ((math.exp(volume / divider) - math.exp(0.1)) / + (math.exp(10) - math.exp(0.1)) * (32768 - 4)) + 4 + ) + + print(f"RTTTLStream: Playing '{self.name}' (volume {self.volume}%)") + + try: + for freq, msec in self._notes(): + if not self._keep_running: + print("RTTTLStream: Playback stopped by user") + break + + # Play tone + if freq > 0: + self.buzzer.freq(int(freq)) + self.buzzer.duty_u16(duty) + + # Play for 90% of duration, silent for 10% (note separation) + # Blocking sleep is OK - we're in a separate thread + time.sleep_ms(int(msec * 0.9)) + self.buzzer.duty_u16(0) + time.sleep_ms(int(msec * 0.1)) + + print(f"RTTTLStream: Finished playing '{self.name}'") + if self.on_complete: + self.on_complete(f"Finished: {self.name}") + + except Exception as e: + print(f"RTTTLStream: Error: {e}") + if self.on_complete: + self.on_complete(f"Error: {e}") + + finally: + # Ensure buzzer is off + self.buzzer.duty_u16(0) + self._is_playing = False + + def set_volume(self, vol): + self.volume = vol diff --git a/internal_filesystem/lib/mpos/audio/stream_wav.py b/internal_filesystem/lib/mpos/audio/stream_wav.py new file mode 100644 index 00000000..10e4801a --- /dev/null +++ b/internal_filesystem/lib/mpos/audio/stream_wav.py @@ -0,0 +1,434 @@ +# WAVStream - WAV File Playback Stream for AudioFlinger +# Supports 8/16/24/32-bit PCM, mono+stereo, auto-upsampling, volume control +# Uses synchronous playback in a separate thread for non-blocking operation + +import machine +import micropython +import os +import sys +import time + +# Volume scaling function - Viper-optimized for ESP32 performance +# NOTE: The line below is automatically commented out by build_mpos.sh during +# Unix/macOS builds (cross-compiler doesn't support Viper), then uncommented after build. +@micropython.viper +def _scale_audio(buf: ptr8, num_bytes: int, scale_fixed: int): + """Fast volume scaling for 16-bit audio samples using Viper (ESP32 native code emitter).""" + for i in range(0, num_bytes, 2): + lo = int(buf[i]) + hi = int(buf[i + 1]) + sample = (hi << 8) | lo + if hi & 128: + sample -= 65536 + sample = (sample * scale_fixed) // 32768 + if sample > 32767: + sample = 32767 + elif sample < -32768: + sample = -32768 + buf[i] = sample & 255 + buf[i + 1] = (sample >> 8) & 255 + +@micropython.viper +def _scale_audio_optimized(buf: ptr8, num_bytes: int, scale_fixed: int): + if scale_fixed >= 32768: + return + if scale_fixed <= 0: + for i in range(num_bytes): + buf[i] = 0 + return + + mask: int = scale_fixed + + for i in range(0, num_bytes, 2): + s: int = int(buf[i]) | (int(buf[i+1]) << 8) + if s >= 0x8000: + s -= 0x10000 + + r: int = 0 + if mask & 0x8000: r += s + if mask & 0x4000: r += s>>1 + if mask & 0x2000: r += s>>2 + if mask & 0x1000: r += s>>3 + if mask & 0x0800: r += s>>4 + if mask & 0x0400: r += s>>5 + if mask & 0x0200: r += s>>6 + if mask & 0x0100: r += s>>7 + if mask & 0x0080: r += s>>8 + if mask & 0x0040: r += s>>9 + if mask & 0x0020: r += s>>10 + if mask & 0x0010: r += s>>11 + if mask & 0x0008: r += s>>12 + if mask & 0x0004: r += s>>13 + if mask & 0x0002: r += s>>14 + if mask & 0x0001: r += s>>15 + + if r > 32767: r = 32767 + if r < -32768: r = -32768 + + buf[i] = r & 0xFF + buf[i+1] = (r >> 8) & 0xFF + +@micropython.viper +def _scale_audio_rough(buf: ptr8, num_bytes: int, scale_fixed: int): + """Rough volume scaling for 16-bit audio samples using right shifts for performance.""" + if scale_fixed >= 32768: + return + + # Determine the shift amount + shift: int = 0 + threshold: int = 32768 + while shift < 16 and scale_fixed < threshold: + shift += 1 + threshold >>= 1 + + # If shift is 16 or more, set buffer to zero (volume too low) + if shift >= 16: + for i in range(num_bytes): + buf[i] = 0 + return + + # Apply right shift to each 16-bit sample + for i in range(0, num_bytes, 2): + lo: int = int(buf[i]) + hi: int = int(buf[i + 1]) + sample: int = (hi << 8) | lo + if hi & 128: + sample -= 65536 + sample >>= shift + buf[i] = sample & 255 + buf[i + 1] = (sample >> 8) & 255 + +@micropython.viper +def _scale_audio_shift(buf: ptr8, num_bytes: int, shift: int): + """Rough volume scaling for 16-bit audio samples using right shifts for performance.""" + if shift <= 0: + return + + # If shift is 16 or more, set buffer to zero (volume too low) + if shift >= 16: + for i in range(num_bytes): + buf[i] = 0 + return + + # Apply right shift to each 16-bit sample + for i in range(0, num_bytes, 2): + lo: int = int(buf[i]) + hi: int = int(buf[i + 1]) + sample: int = (hi << 8) | lo + if hi & 128: + sample -= 65536 + sample >>= shift + buf[i] = sample & 255 + buf[i + 1] = (sample >> 8) & 255 + +@micropython.viper +def _scale_audio_powers_of_2(buf: ptr8, num_bytes: int, shift: int): + if shift <= 0: + return + if shift >= 16: + for i in range(num_bytes): + buf[i] = 0 + return + + # Unroll the sign-extend + shift into one tight loop with no inner branch + inv_shift: int = 16 - shift + for i in range(0, num_bytes, 2): + s: int = int(buf[i]) | (int(buf[i+1]) << 8) + if s & 0x8000: # only one branch, highly predictable when shift fixed shift + s |= -65536 # sign extend using OR (faster than subtract!) + s <<= inv_shift # bring the bits we want into lower 16 + s >>= 16 # arithmetic shift right by 'shift' amount + buf[i] = s & 0xFF + buf[i+1] = (s >> 8) & 0xFF + +class WAVStream: + """ + WAV file playback stream with I2S output. + Supports 8/16/24/32-bit PCM, mono and stereo, auto-upsampling to >=22050 Hz. + """ + + def __init__(self, file_path, stream_type, volume, i2s_pins, on_complete): + """ + Initialize WAV stream. + + Args: + file_path: Path to WAV file + stream_type: Stream type (STREAM_MUSIC, STREAM_NOTIFICATION, STREAM_ALARM) + volume: Volume level (0-100) + i2s_pins: Dict with 'sck', 'ws', 'sd' pin numbers + on_complete: Callback function(message) when playback finishes + """ + self.file_path = file_path + self.stream_type = stream_type + self.volume = volume + self.i2s_pins = i2s_pins + self.on_complete = on_complete + self._keep_running = True + self._is_playing = False + self._i2s = None + + def is_playing(self): + """Check if stream is currently playing.""" + return self._is_playing + + def stop(self): + """Stop playback.""" + self._keep_running = False + + # ---------------------------------------------------------------------- + # WAV header parser - returns bit-depth and format info + # ---------------------------------------------------------------------- + @staticmethod + def _find_data_chunk(f): + """ + Parse WAV header and find data chunk. + + Returns: + tuple: (data_start, data_size, sample_rate, channels, bits_per_sample) + """ + f.seek(0) + if f.read(4) != b'RIFF': + raise ValueError("Not a RIFF (standard .wav) file") + + file_size = int.from_bytes(f.read(4), 'little') + 8 + + if f.read(4) != b'WAVE': + raise ValueError("Not a WAVE (standard .wav) file") + + pos = 12 + sample_rate = None + channels = None + bits_per_sample = None + + while pos < file_size: + f.seek(pos) + chunk_id = f.read(4) + if len(chunk_id) < 4: + break + + chunk_size = int.from_bytes(f.read(4), 'little') + + if chunk_id == b'fmt ': + fmt = f.read(chunk_size) + if len(fmt) < 16: + raise ValueError("Invalid fmt chunk") + + if int.from_bytes(fmt[0:2], 'little') != 1: + raise ValueError("Only PCM supported") + + channels = int.from_bytes(fmt[2:4], 'little') + if channels not in (1, 2): + raise ValueError("Only mono or stereo supported") + + sample_rate = int.from_bytes(fmt[4:8], 'little') + bits_per_sample = int.from_bytes(fmt[14:16], 'little') + + if bits_per_sample not in (8, 16, 24, 32): + raise ValueError("Only 8/16/24/32-bit PCM supported") + + elif chunk_id == b'data': + return f.tell(), chunk_size, sample_rate, channels, bits_per_sample + + pos += 8 + chunk_size + if chunk_size % 2: + pos += 1 + + raise ValueError("No 'data' chunk found") + + # ---------------------------------------------------------------------- + # Bit depth conversion functions + # ---------------------------------------------------------------------- + @staticmethod + def _convert_8_to_16(buf): + """Convert 8-bit unsigned PCM to 16-bit signed PCM.""" + out = bytearray(len(buf) * 2) + j = 0 + for i in range(len(buf)): + u8 = buf[i] + s16 = (u8 - 128) << 8 + out[j] = s16 & 0xFF + out[j + 1] = (s16 >> 8) & 0xFF + j += 2 + return out + + @staticmethod + def _convert_24_to_16(buf): + """Convert 24-bit PCM to 16-bit PCM.""" + samples = len(buf) // 3 + out = bytearray(samples * 2) + j = 0 + for i in range(samples): + b0 = buf[j] + b1 = buf[j + 1] + b2 = buf[j + 2] + s24 = (b2 << 16) | (b1 << 8) | b0 + if b2 & 0x80: + s24 -= 0x1000000 + s16 = s24 >> 8 + out[i * 2] = s16 & 0xFF + out[i * 2 + 1] = (s16 >> 8) & 0xFF + j += 3 + return out + + @staticmethod + def _convert_32_to_16(buf): + """Convert 32-bit PCM to 16-bit PCM.""" + samples = len(buf) // 4 + out = bytearray(samples * 2) + j = 0 + for i in range(samples): + b0 = buf[j] + b1 = buf[j + 1] + b2 = buf[j + 2] + b3 = buf[j + 3] + s32 = (b3 << 24) | (b2 << 16) | (b1 << 8) | b0 + if b3 & 0x80: + s32 -= 0x100000000 + s16 = s32 >> 16 + out[i * 2] = s16 & 0xFF + out[i * 2 + 1] = (s16 >> 8) & 0xFF + j += 4 + return out + + # ---------------------------------------------------------------------- + # Upsampling (zero-order-hold) + # ---------------------------------------------------------------------- + @staticmethod + def _upsample_buffer(raw, factor): + """Upsample 16-bit buffer by repeating samples.""" + if factor == 1: + return raw + + upsampled = bytearray(len(raw) * factor) + out_idx = 0 + for i in range(0, len(raw), 2): + lo = raw[i] + hi = raw[i + 1] + for _ in range(factor): + upsampled[out_idx] = lo + upsampled[out_idx + 1] = hi + out_idx += 2 + return upsampled + + # ---------------------------------------------------------------------- + # Main playback routine + # ---------------------------------------------------------------------- + def play(self): + """Main synchronous playback routine (runs in separate thread).""" + self._is_playing = True + + try: + with open(self.file_path, 'rb') as f: + st = os.stat(self.file_path) + file_size = st[6] + print(f"WAVStream: Playing {self.file_path} ({file_size} bytes)") + + # Parse WAV header + data_start, data_size, original_rate, channels, bits_per_sample = \ + self._find_data_chunk(f) + + # Decide playback rate (force >=22050 Hz) - but why?! the DAC should support down to 8kHz! + target_rate = 22050 + if original_rate >= target_rate: + playback_rate = original_rate + upsample_factor = 1 + else: + upsample_factor = (target_rate + original_rate - 1) // original_rate + playback_rate = original_rate * upsample_factor + + print(f"WAVStream: {original_rate} Hz, {bits_per_sample}-bit, {channels}-ch") + print(f"WAVStream: Playback at {playback_rate} Hz (factor {upsample_factor})") + + if data_size > file_size - data_start: + data_size = file_size - data_start + + # Initialize I2S (always 16-bit output) + try: + i2s_format = machine.I2S.MONO if channels == 1 else machine.I2S.STEREO + self._i2s = machine.I2S( + 0, + sck=machine.Pin(self.i2s_pins['sck'], machine.Pin.OUT), + ws=machine.Pin(self.i2s_pins['ws'], machine.Pin.OUT), + sd=machine.Pin(self.i2s_pins['sd'], machine.Pin.OUT), + mode=machine.I2S.TX, + bits=16, + format=i2s_format, + rate=playback_rate, + ibuf=32000 + ) + except Exception as e: + print(f"WAVStream: I2S init failed: {e}") + return + + print(f"WAVStream: Playing {data_size} bytes (volume {self.volume}%)") + f.seek(data_start) + + # Chunk size tuning notes: + # - Smaller chunks = more responsive to stop() + # - Larger chunks = less overhead, smoother audio + # - The 32KB I2S buffer handles timing smoothness + chunk_size = 8192 + bytes_per_original_sample = (bits_per_sample // 8) * channels + total_original = 0 + + while total_original < data_size: + if not self._keep_running: + print("WAVStream: Playback stopped by user") + break + + # Read chunk of original data + to_read = min(chunk_size, data_size - total_original) + to_read -= (to_read % bytes_per_original_sample) + if to_read <= 0: + break + + raw = bytearray(f.read(to_read)) + if not raw: + break + + # 1. Convert bit-depth to 16-bit + if bits_per_sample == 8: + raw = self._convert_8_to_16(raw) + elif bits_per_sample == 24: + raw = self._convert_24_to_16(raw) + elif bits_per_sample == 32: + raw = self._convert_32_to_16(raw) + # 16-bit unchanged + + # 2. Upsample if needed + if upsample_factor > 1: + raw = self._upsample_buffer(raw, upsample_factor) + + # 3. Volume scaling + scale = self.volume / 100.0 + if scale < 1.0: + scale_fixed = int(scale * 32768) + _scale_audio_optimized(raw, len(raw), scale_fixed) + + # 4. Output to I2S (blocking write is OK - we're in a separate thread) + if self._i2s: + self._i2s.write(raw) + else: + # Simulate playback timing if no I2S + num_samples = len(raw) // (2 * channels) + time.sleep(num_samples / playback_rate) + + total_original += to_read + + print(f"WAVStream: Finished playing {self.file_path}") + if self.on_complete: + self.on_complete(f"Finished: {self.file_path}") + + except Exception as e: + print(f"WAVStream: Error: {e}") + if self.on_complete: + self.on_complete(f"Error: {e}") + + finally: + self._is_playing = False + if self._i2s: + self._i2s.deinit() + self._i2s = None + + def set_volume(self, vol): + self.volume = vol diff --git a/internal_filesystem/lib/mpos/battery_voltage.py b/internal_filesystem/lib/mpos/battery_voltage.py index e25aaf0c..ca284272 100644 --- a/internal_filesystem/lib/mpos/battery_voltage.py +++ b/internal_filesystem/lib/mpos/battery_voltage.py @@ -4,41 +4,150 @@ MAX_VOLTAGE = 4.15 adc = None -scale_factor = 0 +conversion_func = None # Conversion function: ADC value -> voltage +adc_pin = None + +# Cache to reduce WiFi interruptions (ADC2 requires WiFi to be disabled) +_cached_raw_adc = None +_last_read_time = 0 +CACHE_DURATION_ADC2_MS = 300000 # 300 seconds (expensive: requires WiFi disable) +CACHE_DURATION_ADC1_MS = 30000 # 30 seconds (cheaper: no WiFi interference) + + +def _is_adc2_pin(pin): + """Check if pin is on ADC2 (ESP32-S3: GPIO11-20).""" + return 11 <= pin <= 20 + + +def init_adc(pinnr, adc_to_voltage_func): + """ + Initialize ADC for battery voltage monitoring. + + IMPORTANT for ESP32-S3: ADC2 (GPIO11-20) doesn't work when WiFi is active! + Use ADC1 pins (GPIO1-10) for battery monitoring if possible. + If using ADC2, WiFi will be temporarily disabled during readings. + + Args: + pinnr: GPIO pin number + adc_to_voltage_func: Conversion function that takes raw ADC value (0-4095) + and returns battery voltage in volts + """ + global adc, conversion_func, adc_pin + + conversion_func = adc_to_voltage_func + adc_pin = pinnr -# This gets called by (the device-specific) boot*.py -def init_adc(pinnr, sf): - global adc, scale_factor try: - print(f"Initializing ADC pin {pinnr} with scale_factor {scale_factor}") - from machine import ADC, Pin # do this inside the try because it will fail on desktop + print(f"Initializing ADC pin {pinnr} with conversion function") + if _is_adc2_pin(pinnr): + print(f" WARNING: GPIO{pinnr} is on ADC2 - WiFi will be disabled during readings") + from machine import ADC, Pin adc = ADC(Pin(pinnr)) - # Set ADC to 11dB attenuation for 0–3.3V range (common for ESP32) - adc.atten(ADC.ATTN_11DB) - scale_factor = sf + adc.atten(ADC.ATTN_11DB) # 0-3.3V range except Exception as e: - print("Info: this platform has no ADC for measuring battery voltage") + print(f"Info: this platform has no ADC for measuring battery voltage: {e}") -def read_battery_voltage(): + initial_adc_value = read_raw_adc() + print("Reading ADC at init to fill cache: {initial_adc_value} => {read_battery_voltage(raw_adc_value=initial_adc_value)}V => {get_battery_percentage(raw_adc_value=initial_adc_value)}%") + + +def read_raw_adc(force_refresh=False): + """ + Read raw ADC value (0-4095) with adaptive caching. + + On ESP32-S3 with ADC2, WiFi is temporarily disabled during reading. + Raises RuntimeError if WifiService is busy (connecting/scanning) when using ADC2. + + Args: + force_refresh: Bypass cache and force fresh reading + + Returns: + float: Raw ADC value (0-4095) + + Raises: + RuntimeError: If WifiService is busy (only when using ADC2) + """ + global _cached_raw_adc, _last_read_time + + # Desktop mode - return random value in typical ADC range if not adc: import random - random_voltage = random.randint(round(MIN_VOLTAGE*100),round(MAX_VOLTAGE*100)) / 100 - #print(f"returning random voltage: {random_voltage}") - return random_voltage - # Read raw ADC value - total = 0 - # Read multiple times to try to reduce variability. - # Reading 10 times takes around 3ms so it's fine... - for _ in range(10): - total = total + adc.read() - raw_value = total / 10 - #print(f"read_battery_voltage raw_value: {raw_value}") - voltage = raw_value * scale_factor - # Clamp to 0–4.2V range for LiPo battery - voltage = max(0, min(voltage, MAX_VOLTAGE)) + return random.randint(1900, 2600) + + # Check if this is an ADC2 pin (requires WiFi disable) + needs_wifi_disable = adc_pin is not None and _is_adc2_pin(adc_pin) + + # Use different cache durations based on cost + cache_duration = CACHE_DURATION_ADC2_MS if needs_wifi_disable else CACHE_DURATION_ADC1_MS + + # Check cache + current_time = time.ticks_ms() + if not force_refresh and _cached_raw_adc is not None: + age = time.ticks_diff(current_time, _last_read_time) + if age < cache_duration: + return _cached_raw_adc + + # Import WifiService only if needed + WifiService = None + if needs_wifi_disable: + try: + from mpos.net.wifi_service import WifiService + except ImportError: + pass + + # Temporarily disable WiFi for ADC2 reading + was_connected = False + if needs_wifi_disable and WifiService: + # This will raise RuntimeError if WiFi is already busy + was_connected = WifiService.temporarily_disable() + time.sleep(0.05) # Brief delay for WiFi to fully disable + + try: + # Read ADC (average of 10 samples) + total = sum(adc.read() for _ in range(10)) + raw_value = total / 10.0 + + # Update cache + _cached_raw_adc = raw_value + _last_read_time = current_time + + return raw_value + + finally: + # Re-enable WiFi (only if we disabled it) + if needs_wifi_disable and WifiService: + WifiService.temporarily_enable(was_connected) + + +def read_battery_voltage(force_refresh=False, raw_adc_value=None): + """ + Read battery voltage in volts. + + Args: + force_refresh: Bypass cache and force fresh reading + + Returns: + float: Battery voltage in volts (clamped to 0-MAX_VOLTAGE) + """ + raw = raw_adc_value if raw_adc_value else read_raw_adc(force_refresh) + voltage = conversion_func(raw) if conversion_func else 0.0 return voltage -# Could be interesting to keep a "rolling average" of the percentage so that it doesn't fluctuate too quickly -def get_battery_percentage(): - return (read_battery_voltage() - MIN_VOLTAGE) * 100 / (MAX_VOLTAGE - MIN_VOLTAGE) +def get_battery_percentage(raw_adc_value=None): + """ + Get battery charge percentage. + + Returns: + float: Battery percentage (0-100) + """ + voltage = read_battery_voltage(raw_adc_value=raw_adc_value) + percentage = (voltage - MIN_VOLTAGE) * 100.0 / (MAX_VOLTAGE - MIN_VOLTAGE) + return max(0,min(100.0, percentage)) # limit to 100.0% and make sure it's positive + + +def clear_cache(): + """Clear the battery voltage cache to force fresh reading on next call.""" + global _cached_raw_adc, _last_read_time + _cached_raw_adc = None + _last_read_time = 0 diff --git a/internal_filesystem/boot_fri3d-2024.py b/internal_filesystem/lib/mpos/board/fri3d_2024.py similarity index 64% rename from internal_filesystem/boot_fri3d-2024.py rename to internal_filesystem/lib/mpos/board/fri3d_2024.py index 000a45b3..3f397cc5 100644 --- a/internal_filesystem/boot_fri3d-2024.py +++ b/internal_filesystem/lib/mpos/board/fri3d_2024.py @@ -13,16 +13,14 @@ import lvgl as lv import task_handler -import mpos.info import mpos.ui import mpos.ui.focus_direction -mpos.info.set_hardware_id("fri3d-2024") # Pin configuration SPI_BUS = 2 -#SPI_FREQ = 40000000 -SPI_FREQ = 20000000 # also works but I guess higher is better +SPI_FREQ = 40000000 +#SPI_FREQ = 20000000 # also works but I guess higher is better LCD_SCLK = 7 LCD_MOSI = 6 LCD_MISO = 8 @@ -63,7 +61,7 @@ STATE_LOW = 0 # see ./lvgl_micropython/api_drivers/py_api_drivers/frozen/display/display_driver_framework.py -display = st7789.ST7789( +mpos.ui.main_display = st7789.ST7789( data_bus=display_bus, frame_buffer1=fb1, frame_buffer2=fb2, @@ -72,19 +70,19 @@ color_space=lv.COLOR_FORMAT.RGB565, color_byte_order=st7789.BYTE_ORDER_BGR, rgb565_byte_swap=True, - reset_pin=LCD_RST, - reset_state=STATE_LOW + reset_pin=LCD_RST, # doesn't seem needed + reset_state=STATE_LOW # doesn't seem needed ) -display.init() -display.set_power(True) -display.set_backlight(100) +mpos.ui.main_display.init() +mpos.ui.main_display.set_power(True) +mpos.ui.main_display.set_backlight(100) -display.set_color_inversion(False) +mpos.ui.main_display.set_color_inversion(False) lv.init() -display.set_rotation(lv.DISPLAY_ROTATION._270) # must be done after initializing display and creating the touch drivers, to ensure proper handling -display.set_params(0x36, bytearray([0x28])) +mpos.ui.main_display.set_rotation(lv.DISPLAY_ROTATION._270) # must be done after initializing display and creating the touch drivers, to ensure proper handling +mpos.ui.main_display.set_params(0x36, bytearray([0x28])) # Button and joystick handling code: from machine import ADC, Pin @@ -260,10 +258,137 @@ def keypad_read_cb(indev, data): indev.enable(True) # NOQA # Battery voltage ADC measuring +# NOTE: GPIO13 is on ADC2, which requires WiFi to be disabled during reading on ESP32-S3. +# battery_voltage.py handles this automatically: disables WiFi, reads ADC, reconnects WiFi. import mpos.battery_voltage -mpos.battery_voltage.init_adc(13, 2 / 1000) +""" +best fit on battery power: +2482 is 4.180 +2470 is 4.170 +2457 is 4.147 +# 2444 is 4.12 +2433 is 4.109 +2429 is 4.102 +2393 is 4.044 +2369 is 4.000 +2343 is 3.957 +2319 is 3.916 +2269 is 3.831 +2227 is 3.769 +""" +def adc_to_voltage(adc_value): + """ + Convert raw ADC value to battery voltage using calibrated linear function. + Calibration data shows linear relationship: voltage = -0.0016237 * adc + 8.2035 + This is ~10x more accurate than simple scaling (error ~0.01V vs ~0.1V). + """ + return (0.001651* adc_value + 0.08709) + +mpos.battery_voltage.init_adc(13, adc_to_voltage) import mpos.sdcard mpos.sdcard.init(spi_bus, cs_pin=14) -print("boot.py finished") +# === AUDIO HARDWARE === +from machine import PWM, Pin +import mpos.audio.audioflinger as AudioFlinger + +# Initialize buzzer (GPIO 46) +buzzer = PWM(Pin(46), freq=550, duty=0) + +# I2S pin configuration for audio output (DAC) and input (microphone) +# Note: I2S is created per-stream, not at boot (only one instance can exist) +# The DAC uses BCK (bit clock) on GPIO 2, while the microphone uses SCLK on GPIO 17 +# See schematics: DAC has BCK=2, WS=47, SD=16; Microphone has SCLK=17, WS=47, DIN=15 +i2s_pins = { + # Output (DAC/speaker) pins + 'sck': 2, # BCK - Bit Clock for DAC output + 'ws': 47, # Word Select / LRCLK (shared between DAC and mic) + 'sd': 16, # Serial Data OUT (speaker/DAC) + # Input (microphone) pins + 'sck_in': 17, # SCLK - Serial Clock for microphone input + 'sd_in': 15, # DIN - Serial Data IN (microphone) +} + +# Initialize AudioFlinger with I2S and buzzer +AudioFlinger.init( + i2s_pins=i2s_pins, + buzzer_instance=buzzer +) + +# === LED HARDWARE === +import mpos.lights as LightsManager + +# Initialize 5 NeoPixel LEDs (GPIO 12) +LightsManager.init(neopixel_pin=12, num_leds=5) + +# === SENSOR HARDWARE === +import mpos.sensor_manager as SensorManager + +# Create I2C bus for IMU (different pins from display) +from machine import I2C +imu_i2c = I2C(0, sda=Pin(9), scl=Pin(18)) +SensorManager.init(imu_i2c, address=0x6B, mounted_position=SensorManager.FACING_EARTH) + +print("Fri3d hardware: Audio, LEDs, and sensors initialized") + +# === STARTUP "WOW" EFFECT === +import time +import _thread + +def startup_wow_effect(): + """ + Epic startup effect with rainbow LED chase and upbeat startup jingle. + Runs in background thread to avoid blocking boot. + """ + try: + # Startup jingle: Happy upbeat sequence (ascending scale with flourish) + startup_jingle = "Startup:d=8,o=6,b=200:c,d,e,g,4c7,4e,4c7" + + # Start the jingle + AudioFlinger.play_rtttl( + startup_jingle, + stream_type=AudioFlinger.STREAM_NOTIFICATION, + volume=60 + ) + + # Rainbow colors for the 5 LEDs + rainbow = [ + (255, 0, 0), # Red + (255, 128, 0), # Orange + (255, 255, 0), # Yellow + (0, 255, 0), # Green + (0, 0, 255), # Blue + ] + + # Rainbow sweep effect (3 passes, getting faster) + for pass_num in range(3): + for i in range(5): + # Light up LEDs progressively + for j in range(i + 1): + LightsManager.set_led(j, *rainbow[j]) + LightsManager.write() + time.sleep_ms(80 - pass_num * 20) # Speed up each pass + + # Flash all LEDs bright white + LightsManager.set_all(255, 255, 255) + LightsManager.write() + time.sleep_ms(150) + + # Rainbow finale + for i in range(5): + LightsManager.set_led(i, *rainbow[i]) + LightsManager.write() + time.sleep_ms(300) + + # Fade out + LightsManager.clear() + LightsManager.write() + + except Exception as e: + print(f"Startup effect error: {e}") + +_thread.stack_size(mpos.apps.good_stack_size()) # default stack size won't work, crashes! +_thread.start_new_thread(startup_wow_effect, ()) + +print("fri3d_2024.py finished") diff --git a/internal_filesystem/boot_unix.py b/internal_filesystem/lib/mpos/board/linux.py similarity index 61% rename from internal_filesystem/boot_unix.py rename to internal_filesystem/lib/mpos/board/linux.py index 16e3db28..9522344c 100644 --- a/internal_filesystem/boot_unix.py +++ b/internal_filesystem/lib/mpos/board/linux.py @@ -3,18 +3,11 @@ import lvgl as lv import sdl_display -# Add lib/ to the path for modules, otherwise it will only search in ~/.micropython/lib and /usr/lib/micropython -import sys -sys.path.append('lib/') - import mpos.clipboard import mpos.indev.mpos_sdl_keyboard -import mpos.info import mpos.ui import mpos.ui.focus_direction -mpos.info.set_hardware_id("linux-desktop") - # Same as Waveshare ESP32-S3-Touch-LCD-2 and Fri3d Camp 2026 Badge TFT_HOR_RES=320 TFT_VER_RES=240 @@ -47,10 +40,10 @@ buf1 = bus.allocate_framebuffer(TFT_HOR_RES * TFT_VER_RES * 2, 0) -display = sdl_display.SDLDisplay(data_bus=bus,display_width=TFT_HOR_RES,display_height=TFT_VER_RES,frame_buffer1=buf1,color_space=lv.COLOR_FORMAT.RGB565) -# display.set_dpi(65) # doesn't seem to change the default 130... -display.init() +mpos.ui.main_display = sdl_display.SDLDisplay(data_bus=bus,display_width=TFT_HOR_RES,display_height=TFT_VER_RES,frame_buffer1=buf1,color_space=lv.COLOR_FORMAT.RGB565) # display.set_dpi(65) # doesn't seem to change the default 130... +mpos.ui.main_display.init() +# main_display.set_dpi(65) # doesn't seem to change the default 130... import sdl_pointer mouse = sdl_pointer.SDLPointer() @@ -92,7 +85,44 @@ def catch_escape_key(indev, indev_data): # print(f"boot_unix: code={event_code}") # target={event.get_target()}, user_data={event.get_user_data()}, param={event.get_param()} #keyboard.add_event_cb(keyboard_cb, lv.EVENT.ALL, None) -print("boot_unix.py finished") + +# Simulated battery voltage ADC measuring +import mpos.battery_voltage + +def adc_to_voltage(adc_value): + """Convert simulated ADC value to voltage.""" + return adc_value * (3.3 / 4095) * 2 + +mpos.battery_voltage.init_adc(999, adc_to_voltage) + +# === AUDIO HARDWARE === +import mpos.audio.audioflinger as AudioFlinger + +# Desktop builds have no real audio hardware, but we simulate microphone +# recording with a 440Hz sine wave for testing WAV file generation +# The i2s_pins dict with 'sd_in' enables has_microphone() to return True +i2s_pins = { + 'sck': 0, # Simulated - not used on desktop + 'ws': 0, # Simulated - not used on desktop + 'sd': 0, # Simulated - not used on desktop + 'sck_in': 0, # Simulated - not used on desktop + 'sd_in': 0, # Simulated - enables microphone simulation +} +AudioFlinger.init(i2s_pins=i2s_pins) + +# === LED HARDWARE === +# Note: Desktop builds have no LED hardware +# LightsManager will not be initialized (functions will return False) + +# === SENSOR HARDWARE === +# Note: Desktop builds have no sensor hardware +import mpos.sensor_manager as SensorManager + +# Initialize with no I2C bus - will detect MCU temp if available +# (On Linux desktop, this will fail gracefully but set _initialized flag) +SensorManager.init(None) + +print("linux.py finished") diff --git a/internal_filesystem/lib/mpos/board/waveshare_esp32_s3_touch_lcd_2.py b/internal_filesystem/lib/mpos/board/waveshare_esp32_s3_touch_lcd_2.py new file mode 100644 index 00000000..15642eec --- /dev/null +++ b/internal_filesystem/lib/mpos/board/waveshare_esp32_s3_touch_lcd_2.py @@ -0,0 +1,130 @@ +# Hardware initialization for ESP32-S3-Touch-LCD-2 +# Manufacturer's website at https://www.waveshare.com/wiki/ESP32-S3-Touch-LCD-2 +import st7789 +import lcd_bus +import machine +import cst816s +import i2c + +import lvgl as lv +import task_handler + +import mpos.ui + +# Pin configuration +SPI_BUS = 2 +SPI_FREQ = 40000000 +LCD_SCLK = 39 +LCD_MOSI = 38 +LCD_MISO = 40 +LCD_DC = 42 +LCD_CS = 45 +LCD_BL = 1 + +I2C_BUS = 0 +I2C_FREQ = 400000 +TP_SDA = 48 +TP_SCL = 47 +TP_ADDR = 0x15 +TP_REGBITS = 8 + +TFT_HOR_RES=320 +TFT_VER_RES=240 + +spi_bus = machine.SPI.Bus( + host=SPI_BUS, + mosi=LCD_MOSI, + miso=LCD_MISO, + sck=LCD_SCLK +) +display_bus = lcd_bus.SPIBus( + spi_bus=spi_bus, + freq=SPI_FREQ, + dc=LCD_DC, + cs=LCD_CS, +) + + # lv.color_format_get_size(lv.COLOR_FORMAT.RGB565) = 2 bytes per pixel * 320 * 240 px = 153600 bytes + # The default was /10 so 15360 bytes. + # /2 = 76800 shows something on display and then hangs the board + # /2 = 38400 works and pretty high framerate but camera gets ESP_FAIL + # /2 = 19200 works, including camera at 9FPS + # 28800 is between the two and still works with camera! + # 30720 is /5 and is already too much +_BUFFER_SIZE = const(28800) +fb1 = display_bus.allocate_framebuffer(_BUFFER_SIZE, lcd_bus.MEMORY_INTERNAL | lcd_bus.MEMORY_DMA) +fb2 = display_bus.allocate_framebuffer(_BUFFER_SIZE, lcd_bus.MEMORY_INTERNAL | lcd_bus.MEMORY_DMA) + +mpos.ui.main_display = st7789.ST7789( + data_bus=display_bus, + frame_buffer1=fb1, + frame_buffer2=fb2, + display_width=TFT_VER_RES, + display_height=TFT_HOR_RES, + color_space=lv.COLOR_FORMAT.RGB565, + color_byte_order=st7789.BYTE_ORDER_BGR, + rgb565_byte_swap=True, + backlight_pin=LCD_BL, + backlight_on_state=st7789.STATE_PWM, +) +mpos.ui.main_display.init() +mpos.ui.main_display.set_power(True) +mpos.ui.main_display.set_backlight(100) + +# Touch handling: +i2c_bus = i2c.I2C.Bus(host=I2C_BUS, scl=TP_SCL, sda=TP_SDA, freq=I2C_FREQ, use_locks=False) +touch_dev = i2c.I2C.Device(bus=i2c_bus, dev_id=TP_ADDR, reg_bits=TP_REGBITS) +indev=cst816s.CST816S(touch_dev,startup_rotation=lv.DISPLAY_ROTATION._180) # button in top left, good + +lv.init() +mpos.ui.main_display.set_rotation(lv.DISPLAY_ROTATION._90) # must be done after initializing display and creating the touch drivers, to ensure proper handling + +# Battery voltage ADC measuring +import mpos.battery_voltage + +def adc_to_voltage(adc_value): + """ + Convert raw ADC value to battery voltage. + Currently uses simple linear scaling: voltage = adc * 0.00262 + + This could be improved with calibration data similar to Fri3d board. + To calibrate: measure actual battery voltages and corresponding ADC readings, + then fit a linear or polynomial function. + """ + return adc_value * 0.00262 + +mpos.battery_voltage.init_adc(5, adc_to_voltage) + +# On the Waveshare ESP32-S3-Touch-LCD-2, the camera is hard-wired to power on, +# so it needs a software power off to prevent it from staying hot all the time and quickly draining the battery. +try: + from machine import Pin, I2C + i2c = I2C(1, scl=Pin(16), sda=Pin(21)) # Adjust pins and frequency + # Warning: don't do an i2c scan because it confuses the camera! + camera_addr = 0x3C # for OV5640 + reg_addr = 0x3008 + reg_high = (reg_addr >> 8) & 0xFF # 0x30 + reg_low = reg_addr & 0xFF # 0x08 + power_off_command = 0x42 # Power off command + i2c.writeto(camera_addr, bytes([reg_high, reg_low, power_off_command])) +except Exception as e: + print(f"Warning: powering off camera got exception: {e}") + +# === AUDIO HARDWARE === +import mpos.audio.audioflinger as AudioFlinger + +# Note: Waveshare board has no buzzer or I2S audio +AudioFlinger.init() + +# === LED HARDWARE === +# Note: Waveshare board has no NeoPixel LEDs +# LightsManager will not be initialized (functions will return False) + +# === SENSOR HARDWARE === +import mpos.sensor_manager as SensorManager + +# IMU is on I2C0 (same bus as touch): SDA=48, SCL=47, addr=0x6B +# i2c_bus was created on line 75 for touch, reuse it for IMU +SensorManager.init(i2c_bus, address=0x6B, mounted_position=SensorManager.FACING_EARTH) + +print("waveshare_esp32_s3_touch_lcd_2.py finished") diff --git a/internal_filesystem/lib/mpos/config.py b/internal_filesystem/lib/mpos/config.py index 1331a595..e42f45e6 100644 --- a/internal_filesystem/lib/mpos/config.py +++ b/internal_filesystem/lib/mpos/config.py @@ -2,10 +2,11 @@ import os class SharedPreferences: - def __init__(self, appname, filename="config.json"): - """Initialize with appname and filename for preferences.""" + def __init__(self, appname, filename="config.json", defaults=None): + """Initialize with appname, filename, and optional defaults for preferences.""" self.appname = appname self.filename = filename + self.defaults = defaults if defaults is not None else {} self.filepath = f"data/{self.appname}/{self.filename}" self.data = {} self.load() @@ -28,7 +29,7 @@ def load(self): try: with open(self.filepath, 'r') as f: self.data = ujson.load(f) - print(f"load: Loaded preferences: {self.data}") + print(f"load: Loaded preferences from {self.filepath}: {self.data}") except Exception as e: print(f"SharedPreferences.load didn't find preferences: {e}") self.data = {} @@ -36,31 +37,80 @@ def load(self): def get_string(self, key, default=None): """Retrieve a string value for the given key, with a default if not found.""" to_return = self.data.get(key) - if to_return is None and default is not None: - to_return = default + if to_return is None: + # Method default takes precedence + if default is not None: + to_return = default + # Fall back to constructor default + elif key in self.defaults: + to_return = self.defaults[key] return to_return def get_int(self, key, default=0): """Retrieve an integer value for the given key, with a default if not found.""" - try: - return int(self.data.get(key, default)) - except (TypeError, ValueError): + if key in self.data: + try: + return int(self.data[key]) + except (TypeError, ValueError): + return default + # Key not in stored data, check defaults + # Method default takes precedence if explicitly provided (not the hardcoded 0) + # Otherwise use constructor default if exists + if default != 0: return default + if key in self.defaults: + try: + return int(self.defaults[key]) + except (TypeError, ValueError): + return 0 + return 0 def get_bool(self, key, default=False): """Retrieve a boolean value for the given key, with a default if not found.""" - try: - return bool(self.data.get(key, default)) - except (TypeError, ValueError): + if key in self.data: + try: + return bool(self.data[key]) + except (TypeError, ValueError): + return default + # Key not in stored data, check defaults + # Method default takes precedence if explicitly provided (not the hardcoded False) + # Otherwise use constructor default if exists + if default != False: return default + if key in self.defaults: + try: + return bool(self.defaults[key]) + except (TypeError, ValueError): + return False + return False def get_list(self, key, default=None): """Retrieve a list for the given key, with a default if not found.""" - return self.data.get(key, default if default is not None else []) + if key in self.data: + return self.data[key] + # Key not in stored data, check defaults + # Method default takes precedence if provided + if default is not None: + return default + # Fall back to constructor default + if key in self.defaults: + return self.defaults[key] + # Return empty list as hardcoded fallback + return [] def get_dict(self, key, default=None): """Retrieve a dictionary for the given key, with a default if not found.""" - return self.data.get(key, default if default is not None else {}) + if key in self.data: + return self.data[key] + # Key not in stored data, check defaults + # Method default takes precedence if provided + if default is not None: + return default + # Fall back to constructor default + if key in self.defaults: + return self.defaults[key] + # Return empty dict as hardcoded fallback + return {} def edit(self): """Return an Editor object to modify preferences.""" @@ -193,14 +243,35 @@ def remove_dict_item(self, dict_key, item_key): pass return self + def remove_all(self): + self.temp_data = {} + return self + + def _filter_defaults(self, data): + """Remove keys from data that match constructor defaults.""" + if not self.preferences.defaults: + return data + + filtered = {} + for key, value in data.items(): + if key in self.preferences.defaults: + if value != self.preferences.defaults[key]: + filtered[key] = value + # else: skip saving, matches default + else: + filtered[key] = value # No default, always save + return filtered + def apply(self): """Save changes to the file asynchronously (emulated).""" - self.preferences.data = self.temp_data.copy() + filtered_data = self._filter_defaults(self.temp_data) + self.preferences.data = filtered_data self.preferences.save_config() def commit(self): """Save changes to the file synchronously.""" - self.preferences.data = self.temp_data.copy() + filtered_data = self._filter_defaults(self.temp_data) + self.preferences.data = filtered_data self.preferences.save_config() return True diff --git a/internal_filesystem/lib/mpos/content/package_manager.py b/internal_filesystem/lib/mpos/content/package_manager.py index 5d52e992..7efdc2b7 100644 --- a/internal_filesystem/lib/mpos/content/package_manager.py +++ b/internal_filesystem/lib/mpos/content/package_manager.py @@ -73,8 +73,17 @@ def __class_getitem__(cls, fullname): @classmethod def get(cls, fullname): + if not cls._app_list: + cls.refresh_apps() return cls._by_fullname.get(fullname) + @classmethod + def get_launcher(cls): + for app in cls.get_app_list(): + if app.is_valid_launcher(): + print(f"Found launcher {app.fullname}") + return app + @classmethod def clear(cls): """Empty the internal caches. Call ``get_app_list()`` afterwards to repopulate.""" @@ -90,7 +99,7 @@ def refresh_apps(cls): apps_dir = "apps" apps_dir_builtin = "builtin/apps" - for base in (apps_dir, apps_dir_builtin): + for base in (apps_dir, apps_dir_builtin): # added apps override builtin apps try: # ---- does the directory exist? -------------------------------- st = os.stat(base) @@ -223,4 +232,3 @@ def is_installed_by_name(app_fullname): print(f"Checking if app {app_fullname} is installed...") return PackageManager.is_installed_by_path(f"apps/{app_fullname}") or PackageManager.is_installed_by_path(f"builtin/apps/{app_fullname}") - diff --git a/internal_filesystem/lib/mpos/fs_driver.py b/internal_filesystem/lib/mpos/fs_driver.py index 3637cfb2..3011cdda 100644 --- a/internal_filesystem/lib/mpos/fs_driver.py +++ b/internal_filesystem/lib/mpos/fs_driver.py @@ -79,10 +79,11 @@ def _fs_dir_open_cb(drv, path): #print(f"_fs_dir_open_cb for path '{path}'") try: import os # for ilistdir() - path = path.rstrip('/') # LittleFS handles trailing flashes fine, but vfs.VfsFat returns an [Errno 22] EINVAL + if path != "/": + path = path.rstrip('/') # LittleFS handles trailing flashes fine, but vfs.VfsFat returns an [Errno 22] EINVAL return {'iterator' : os.ilistdir(path)} except Exception as e: - print(f"_fs_dir_open_cb exception: {e}") + print(f"_fs_dir_open_cb exception for path {path}: {e}") return None def _fs_dir_read_cb(drv, lv_fs_dir_t, buf, btr): diff --git a/internal_filesystem/lib/mpos/hardware/drivers/__init__.py b/internal_filesystem/lib/mpos/hardware/drivers/__init__.py new file mode 100644 index 00000000..119fb43d --- /dev/null +++ b/internal_filesystem/lib/mpos/hardware/drivers/__init__.py @@ -0,0 +1 @@ +# IMU and sensor drivers for MicroPythonOS diff --git a/internal_filesystem/lib/qmi8658.py b/internal_filesystem/lib/mpos/hardware/drivers/qmi8658.py similarity index 100% rename from internal_filesystem/lib/qmi8658.py rename to internal_filesystem/lib/mpos/hardware/drivers/qmi8658.py diff --git a/internal_filesystem/lib/mpos/hardware/drivers/wsen_isds.py b/internal_filesystem/lib/mpos/hardware/drivers/wsen_isds.py new file mode 100644 index 00000000..7f6f7be9 --- /dev/null +++ b/internal_filesystem/lib/mpos/hardware/drivers/wsen_isds.py @@ -0,0 +1,340 @@ +"""WSEN_ISDS 6-axis IMU driver for MicroPython. + +This driver is for the Würth Elektronik WSEN-ISDS IMU sensor. +Source: https://github.com/Fri3dCamp/badge_2024_micropython/pull/10 + +MIT License + +Copyright (c) 2024 Fri3d Camp contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +import time + + +class Wsen_Isds: + """Driver for WSEN-ISDS 6-axis IMU (accelerometer + gyroscope).""" + + _ISDS_STATUS_REG = 0x1E # Status data register + _ISDS_WHO_AM_I = 0x0F # WHO_AM_I register + + _REG_TEMP_OUT_L = 0x20 + + _REG_G_X_OUT_L = 0x22 + _REG_G_Y_OUT_L = 0x24 + _REG_G_Z_OUT_L = 0x26 + + _REG_A_X_OUT_L = 0x28 + _REG_A_Y_OUT_L = 0x2A + _REG_A_Z_OUT_L = 0x2C + + _REG_A_TAP_CFG = 0x58 + + _options = { + 'acc_range': { + 'reg': 0x10, 'mask': 0b11110011, 'shift_left': 2, + 'val_to_bits': {"2g": 0b00, "4g": 0b10, "8g": 0b11, "16g": 0b01} + }, + 'acc_data_rate': { + 'reg': 0x10, 'mask': 0b00001111, 'shift_left': 4, + 'val_to_bits': { + "0": 0b0000, "1.6Hz": 0b1011, "12.5Hz": 0b0001, + "26Hz": 0b0010, "52Hz": 0b0011, "104Hz": 0b0100, + "208Hz": 0b0101, "416Hz": 0b0110, "833Hz": 0b0111, + "1.66kHz": 0b1000, "3.33kHz": 0b1001, "6.66kHz": 0b1010} + }, + 'gyro_range': { + 'reg': 0x11, 'mask': 0b11110000, 'shift_left': 0, + 'val_to_bits': { + "125dps": 0b0010, "250dps": 0b0000, + "500dps": 0b0100, "1000dps": 0b1000, "2000dps": 0b1100} + }, + 'gyro_data_rate': { + 'reg': 0x11, 'mask': 0b00001111, 'shift_left': 4, + 'val_to_bits': { + "0": 0b0000, "12.5Hz": 0b0001, "26Hz": 0b0010, + "52Hz": 0b0011, "104Hz": 0b0100, "208Hz": 0b0101, + "416Hz": 0b0110, "833Hz": 0b0111, "1.66kHz": 0b1000, + "3.33kHz": 0b1001, "6.66kHz": 0b1010} + }, + 'tap_double_enable': { + 'reg': 0x5B, 'mask': 0b01111111, 'shift_left': 7, + 'val_to_bits': {True: 0b01, False: 0b00} + }, + 'tap_threshold': { + 'reg': 0x59, 'mask': 0b11100000, 'shift_left': 0, + 'val_to_bits': {0: 0b00, 1: 0b01, 2: 0b10, 3: 0b11, 4: 0b100, 5: 0b101, + 6: 0b110, 7: 0b111, 8: 0b1000, 9: 0b1001} + }, + 'tap_quiet_time': { + 'reg': 0x5A, 'mask': 0b11110011, 'shift_left': 2, + 'val_to_bits': {0: 0b00, 1: 0b01, 2: 0b10, 3: 0b11} + }, + 'tap_duration_time': { + 'reg': 0x5A, 'mask': 0b00001111, 'shift_left': 2, + 'val_to_bits': {0: 0b00, 1: 0b01, 2: 0b10, 3: 0b11, 4: 0b100, 5: 0b101, + 6: 0b110, 7: 0b111, 8: 0b1000, 9: 0b1001} + }, + 'tap_shock_time': { + 'reg': 0x5A, 'mask': 0b11111100, 'shift_left': 0, + 'val_to_bits': {0: 0b00, 1: 0b01, 2: 0b10, 3: 0b11} + }, + 'tap_single_to_int0': { + 'reg': 0x5E, 'mask': 0b10111111, 'shift_left': 6, + 'val_to_bits': {0: 0b00, 1: 0b01} + }, + 'tap_double_to_int0': { + 'reg': 0x5E, 'mask': 0b11110111, 'shift_left': 3, + 'val_to_bits': {0: 0b00, 1: 0b01} + }, + 'int1_on_int0': { + 'reg': 0x13, 'mask': 0b11011111, 'shift_left': 5, + 'val_to_bits': {0: 0b00, 1: 0b01} + }, + 'ctrl_do_soft_reset': { + 'reg': 0x12, 'mask': 0b11111110, 'shift_left': 0, + 'val_to_bits': {True: 0b01, False: 0b00} + }, + 'ctrl_do_reboot': { + 'reg': 0x12, 'mask': 0b01111111, 'shift_left': 7, + 'val_to_bits': {True: 0b01, False: 0b00} + }, + } + + def __init__(self, i2c, address=0x6B, acc_range="2g", acc_data_rate="1.6Hz", + gyro_range="125dps", gyro_data_rate="12.5Hz"): + """Initialize WSEN-ISDS IMU. + + Args: + i2c: I2C bus instance + address: I2C address (default 0x6B) + acc_range: Accelerometer range ("2g", "4g", "8g", "16g") + acc_data_rate: Accelerometer data rate ("0", "1.6Hz", "12.5Hz", ...) + gyro_range: Gyroscope range ("125dps", "250dps", "500dps", "1000dps", "2000dps") + gyro_data_rate: Gyroscope data rate ("0", "12.5Hz", "26Hz", ...") + """ + self.i2c = i2c + self.address = address + + self.acc_range = 0 + self.acc_sensitivity = 0 + + self.gyro_range = 0 + self.gyro_sensitivity = 0 + + self.set_acc_range(acc_range) + self.set_acc_data_rate(acc_data_rate) + + self.set_gyro_range(gyro_range) + self.set_gyro_data_rate(gyro_data_rate) + + # Give sensors time to stabilize + time.sleep_ms(100) + + def get_chip_id(self): + """Get chip ID for detection. Returns WHO_AM_I register value.""" + try: + return self.i2c.readfrom_mem(self.address, self._ISDS_WHO_AM_I, 1)[0] + except: + return 0 + + def _write_option(self, option, value): + """Write configuration option to sensor register.""" + opt = Wsen_Isds._options[option] + try: + bits = opt["val_to_bits"][value] + old_value = self.i2c.readfrom_mem(self.address, opt["reg"], 1)[0] + config_value = old_value + config_value &= opt["mask"] + config_value |= (bits << opt["shift_left"]) + self.i2c.writeto_mem(self.address, opt["reg"], bytes([config_value])) + except KeyError as err: + print(f"Invalid option: {option}, or invalid option value: {value}.", err) + + def set_acc_range(self, acc_range): + """Set accelerometer range.""" + self._write_option('acc_range', acc_range) + self.acc_range = acc_range + self._acc_calc_sensitivity() + + def set_acc_data_rate(self, acc_rate): + """Set accelerometer data rate.""" + self._write_option('acc_data_rate', acc_rate) + + def set_gyro_range(self, gyro_range): + """Set gyroscope range.""" + self._write_option('gyro_range', gyro_range) + self.gyro_range = gyro_range + self._gyro_calc_sensitivity() + + def set_gyro_data_rate(self, gyro_rate): + """Set gyroscope data rate.""" + self._write_option('gyro_data_rate', gyro_rate) + + def _gyro_calc_sensitivity(self): + """Calculate gyroscope sensitivity based on range.""" + sensitivity_mapping = { + "125dps": 4.375, + "250dps": 8.75, + "500dps": 17.5, + "1000dps": 35, + "2000dps": 70 + } + + if self.gyro_range in sensitivity_mapping: + self.gyro_sensitivity = sensitivity_mapping[self.gyro_range] + else: + print("Invalid range value:", self.gyro_range) + + def soft_reset(self): + """Perform soft reset of the sensor.""" + self._write_option('ctrl_do_soft_reset', True) + + def reboot(self): + """Reboot the sensor.""" + self._write_option('ctrl_do_reboot', True) + + def set_interrupt(self, interrupts_enable=False, inact_en=False, slope_fds=False, + tap_x_en=True, tap_y_en=True, tap_z_en=True): + """Configure interrupt for tap gestures on INT0 pad.""" + config_value = 0b00000000 + + if interrupts_enable: + config_value |= (1 << 7) + if inact_en: + inact_en = 0x01 + config_value |= (inact_en << 5) + if slope_fds: + config_value |= (1 << 4) + if tap_x_en: + config_value |= (1 << 3) + if tap_y_en: + config_value |= (1 << 2) + if tap_z_en: + config_value |= (1 << 1) + + self.i2c.writeto_mem(self.address, Wsen_Isds._REG_A_TAP_CFG, + bytes([config_value])) + + self._write_option('tap_double_enable', False) + self._write_option('tap_threshold', 9) + self._write_option('tap_quiet_time', 1) + self._write_option('tap_duration_time', 5) + self._write_option('tap_shock_time', 2) + self._write_option('tap_single_to_int0', 1) + self._write_option('tap_double_to_int0', 1) + self._write_option('int1_on_int0', 1) + + def _acc_calc_sensitivity(self): + """Calculate accelerometer sensitivity based on range (in mg/digit).""" + sensitivity_mapping = { + "2g": 0.061, + "4g": 0.122, + "8g": 0.244, + "16g": 0.488 + } + if self.acc_range in sensitivity_mapping: + self.acc_sensitivity = sensitivity_mapping[self.acc_range] + else: + print("Invalid range value:", self.acc_range) + + def _read_raw_accelerations(self): + """Read raw accelerometer data.""" + if not self._acc_data_ready(): + raise Exception("sensor data not ready") + + raw = self.i2c.readfrom_mem(self.address, Wsen_Isds._REG_A_X_OUT_L, 6) + + raw_a_x = self._convert_from_raw(raw[0], raw[1]) + raw_a_y = self._convert_from_raw(raw[2], raw[3]) + raw_a_z = self._convert_from_raw(raw[4], raw[5]) + + return raw_a_x * self.acc_sensitivity, raw_a_y * self.acc_sensitivity, raw_a_z * self.acc_sensitivity + + + @property + def temperature(self) -> float: + temp_raw = self._read_raw_temperature() + return ((temp_raw / 256.0) + 25.0) + + def _read_raw_temperature(self): + """Read raw temperature data.""" + if not self._temp_data_ready(): + raise Exception("temp sensor data not ready") + + raw = self.i2c.readfrom_mem(self.address, Wsen_Isds._REG_TEMP_OUT_L, 2) + raw_temp = self._convert_from_raw(raw[0], raw[1]) + return raw_temp + + def _read_raw_angular_velocities(self): + """Read raw gyroscope data.""" + if not self._gyro_data_ready(): + raise Exception("sensor data not ready") + + raw = self.i2c.readfrom_mem(self.address, Wsen_Isds._REG_G_X_OUT_L, 6) + + raw_g_x = self._convert_from_raw(raw[0], raw[1]) + raw_g_y = self._convert_from_raw(raw[2], raw[3]) + raw_g_z = self._convert_from_raw(raw[4], raw[5]) + + return raw_g_x * self.gyro_sensitivity, raw_g_y * self.gyro_sensitivity, raw_g_z * self.gyro_sensitivity + + @staticmethod + def _convert_from_raw(b_l, b_h): + """Convert two bytes (little-endian) to signed 16-bit integer.""" + c = (b_h << 8) | b_l + if c & (1 << 15): + c -= 1 << 16 + return c + + def _acc_data_ready(self): + """Check if accelerometer data is ready.""" + return self._get_status_reg()[0] + + def _gyro_data_ready(self): + """Check if gyroscope data is ready.""" + return self._get_status_reg()[1] + + def _temp_data_ready(self): + """Check if accelerometer data is ready.""" + return self._get_status_reg()[2] + + def _acc_gyro_data_ready(self): + """Check if both accelerometer and gyroscope data are ready.""" + status_reg = self._get_status_reg() + return status_reg[0], status_reg[1] + + def _get_status_reg(self): + """Read status register. + + Returns: + Tuple (acc_data_ready, gyro_data_ready, temp_data_ready) + """ + # STATUS_REG (0x1E) is a single byte with bit flags: + # Bit 0: XLDA (accelerometer data available) + # Bit 1: GDA (gyroscope data available) + # Bit 2: TDA (temperature data available) + status = self.i2c.readfrom_mem(self.address, Wsen_Isds._ISDS_STATUS_REG, 1)[0] + + acc_data_ready = bool(status & 0x01) # Bit 0 + gyro_data_ready = bool(status & 0x02) # Bit 1 + temp_data_ready = bool(status & 0x04) # Bit 2 + + return acc_data_ready, gyro_data_ready, temp_data_ready diff --git a/internal_filesystem/lib/mpos/hardware/fri3d/__init__.py b/internal_filesystem/lib/mpos/hardware/fri3d/__init__.py new file mode 100644 index 00000000..18919b17 --- /dev/null +++ b/internal_filesystem/lib/mpos/hardware/fri3d/__init__.py @@ -0,0 +1,8 @@ +# Fri3d Camp 2024 Badge Hardware Drivers +# These are simple wrappers that can be used by services like AudioFlinger + +from .buzzer import BuzzerConfig +from .leds import LEDConfig +from .rtttl_data import RTTTL_SONGS + +__all__ = ['BuzzerConfig', 'LEDConfig', 'RTTTL_SONGS'] diff --git a/internal_filesystem/lib/mpos/hardware/fri3d/buzzer.py b/internal_filesystem/lib/mpos/hardware/fri3d/buzzer.py new file mode 100644 index 00000000..2ebfa98a --- /dev/null +++ b/internal_filesystem/lib/mpos/hardware/fri3d/buzzer.py @@ -0,0 +1,11 @@ +# Fri3d Camp 2024 Badge - Buzzer Configuration + +class BuzzerConfig: + """Configuration for PWM buzzer hardware.""" + + # GPIO pin for buzzer + PIN = 46 + + # Default PWM settings + DEFAULT_FREQ = 550 # Hz + DEFAULT_DUTY = 0 # Off by default diff --git a/internal_filesystem/lib/mpos/hardware/fri3d/leds.py b/internal_filesystem/lib/mpos/hardware/fri3d/leds.py new file mode 100644 index 00000000..f14b740d --- /dev/null +++ b/internal_filesystem/lib/mpos/hardware/fri3d/leds.py @@ -0,0 +1,10 @@ +# Fri3d Camp 2024 Badge - LED Configuration + +class LEDConfig: + """Configuration for NeoPixel RGB LED hardware.""" + + # GPIO pin for NeoPixel data line + PIN = 12 + + # Number of NeoPixel LEDs on badge + NUM_LEDS = 5 diff --git a/internal_filesystem/lib/mpos/hardware/fri3d/rtttl_data.py b/internal_filesystem/lib/mpos/hardware/fri3d/rtttl_data.py new file mode 100644 index 00000000..38174890 --- /dev/null +++ b/internal_filesystem/lib/mpos/hardware/fri3d/rtttl_data.py @@ -0,0 +1,18 @@ +# RTTTL Song Catalog +# Ring Tone Text Transfer Language songs for buzzer playback +# Format: "name:defaults:notes" +# Ported from Fri3d Camp 2024 Badge firmware + +RTTTL_SONGS = { + "nokia": "Nokia:d=4,o=5,b=225:8e6,8d6,8f#,8g#,8c#6,8b,d,8p,8b,8a,8c#,8e,8a,8p", + + "macarena": "Macarena:d=4,o=5,b=180:f,8f,8f,f,8f,8f,8f,8f,8f,8f,8f,8a,c,8c,f,8f,8f,f,8f,8f,8f,8f,8f,8f,8d,8c,p,f,8f,8f,f,8f,8f,8f,8f,8f,8f,8f,8a,p,2c,f,8f,8f,f,8f,8f,8f,8f,8f,8f,8d,8c", + + "takeonme": "TakeOnMe:d=4,o=4,b=160:8f#5,8f#5,8f#5,8d5,8p,8b,8p,8e5,8p,8e5,8p,8e5,8g#5,8g#5,8a5,8b5,8a5,8a5,8a5,8e5,8p,8d5,8p,8f#5,8p,8f#5,8p,8f#5,8e5,8e5,8f#5,8e5", + + "goodbadugly": "TheGoodTheBad:d=4,o=5,b=160:c,8d,8e,8d,c,8d,8e,8d,c,8d,e,8f,2g,8p,a,b,c6,8b,8a,8g,8f,e,8f,g,8e,8d,8c", + + "creeps": "Creeps:d=4,o=5,b=120:8c,8d,8e,8f,g,8e,8f,g,8f,8e,8d,c,8d,8e,f,8d,8e,f,8e,8d,8c,8b4", + + "william_tell": "WilliamTell:d=4,o=5,b=125:8e,8e,8e,2p,8e,8e,8e,2p,8e,8e,8e,8e,8e,8e,8e,8e,8e,8e,8e,8e,8e,8e,e" +} diff --git a/internal_filesystem/lib/mpos/info.py b/internal_filesystem/lib/mpos/info.py index e24724ea..84f78e00 100644 --- a/internal_filesystem/lib/mpos/info.py +++ b/internal_filesystem/lib/mpos/info.py @@ -1,4 +1,4 @@ -CURRENT_OS_VERSION = "0.3.0" +CURRENT_OS_VERSION = "0.5.2" # Unique string that defines the hardware, used by OSUpdate and the About app _hardware_id = "missing-hardware-info" diff --git a/internal_filesystem/lib/mpos/lights.py b/internal_filesystem/lib/mpos/lights.py new file mode 100644 index 00000000..2f0d7b7a --- /dev/null +++ b/internal_filesystem/lib/mpos/lights.py @@ -0,0 +1,153 @@ +# LightsManager - Simple LED Control Service for MicroPythonOS +# Provides one-shot LED control for NeoPixel RGB LEDs +# Apps implement custom animations using the update_frame() pattern + +# Module-level state (singleton pattern) +_neopixel = None +_num_leds = 0 + + +def init(neopixel_pin, num_leds=5): + """ + Initialize NeoPixel LEDs. + + Args: + neopixel_pin: GPIO pin number for NeoPixel data line + num_leds: Number of LEDs in the strip (default 5 for Fri3d badge) + """ + global _neopixel, _num_leds + + try: + from machine import Pin + from neopixel import NeoPixel + + _neopixel = NeoPixel(Pin(neopixel_pin, Pin.OUT), num_leds) + _num_leds = num_leds + + # Clear all LEDs on initialization + for i in range(num_leds): + _neopixel[i] = (0, 0, 0) + _neopixel.write() + + print(f"LightsManager initialized: {num_leds} LEDs on GPIO {neopixel_pin}") + except Exception as e: + print(f"LightsManager: Failed to initialize LEDs: {e}") + print(" - LED functions will return False (no-op)") + + +def is_available(): + """ + Check if LED hardware is available. + + Returns: + bool: True if LEDs are initialized and available + """ + return _neopixel is not None + + +def get_led_count(): + """ + Get the number of LEDs. + + Returns: + int: Number of LEDs, or 0 if not initialized + """ + return _num_leds + + +def set_led(index, r, g, b): + """ + Set a single LED color (buffered until write() is called). + + Args: + index: LED index (0 to num_leds-1) + r: Red value (0-255) + g: Green value (0-255) + b: Blue value (0-255) + + Returns: + bool: True if successful, False if LEDs unavailable or invalid index + """ + if not _neopixel: + return False + + if index < 0 or index >= _num_leds: + print(f"LightsManager: Invalid LED index {index} (valid range: 0-{_num_leds-1})") + return False + + _neopixel[index] = (r, g, b) + return True + + +def set_all(r, g, b): + """ + Set all LEDs to the same color (buffered until write() is called). + + Args: + r: Red value (0-255) + g: Green value (0-255) + b: Blue value (0-255) + + Returns: + bool: True if successful, False if LEDs unavailable + """ + if not _neopixel: + return False + + for i in range(_num_leds): + _neopixel[i] = (r, g, b) + return True + + +def clear(): + """ + Clear all LEDs (set to black, buffered until write() is called). + + Returns: + bool: True if successful, False if LEDs unavailable + """ + return set_all(0, 0, 0) + + +def write(): + """ + Update hardware with buffered LED colors. + Must be called after set_led(), set_all(), or clear() to make changes visible. + + Returns: + bool: True if successful, False if LEDs unavailable + """ + if not _neopixel: + return False + + _neopixel.write() + return True + + +def set_notification_color(color_name): + """ + Convenience method to set all LEDs to a common color and update immediately. + + Args: + color_name: Color name (red, green, blue, yellow, orange, purple, white) + + Returns: + bool: True if successful, False if LEDs unavailable or unknown color + """ + colors = { + "red": (255, 0, 0), + "green": (0, 255, 0), + "blue": (0, 0, 255), + "yellow": (255, 255, 0), + "orange": (255, 128, 0), + "purple": (128, 0, 255), + "white": (255, 255, 255), + } + + color = colors.get(color_name.lower()) + if not color: + print(f"LightsManager: Unknown color '{color_name}'") + print(f" - Available colors: {', '.join(colors.keys())}") + return False + + return set_all(*color) and write() diff --git a/internal_filesystem/lib/mpos/main.py b/internal_filesystem/lib/mpos/main.py new file mode 100644 index 00000000..e576195a --- /dev/null +++ b/internal_filesystem/lib/mpos/main.py @@ -0,0 +1,116 @@ +import task_handler +import _thread +import lvgl as lv +import mpos +import mpos.apps +import mpos.config +import mpos.ui +import mpos.ui.topmenu +from mpos.ui.display import init_rootscreen +from mpos.content.package_manager import PackageManager + +# Auto-detect and initialize hardware +import sys +if sys.platform == "linux" or sys.platform == "darwin": # linux and macOS + board = "linux" +elif sys.platform == "esp32": + from machine import Pin, I2C + i2c0 = I2C(0, sda=Pin(48), scl=Pin(47)) + if {0x15, 0x6B} <= set(i2c0.scan()): # touch screen and IMU (at least, possibly more) + board = "waveshare_esp32_s3_touch_lcd_2" + else: + i2c0 = I2C(0, sda=Pin(9), scl=Pin(18)) + if {0x6B} <= set(i2c0.scan()): # IMU (plus possibly the Communicator's LANA TNY at 0x38) + board = "fri3d_2024" + else: + print("Unable to identify board, defaulting...") + board = "fri3d_2024" # default fallback + +print(f"Initializing {board} hardware") +import mpos.info +mpos.info.set_hardware_id(board) +__import__(f"mpos.board.{board}") + +# Allow LVGL M:/path/to/file or M:relative/path/to/file to work for image set_src etc +import mpos.fs_driver +fs_drv = lv.fs_drv_t() +mpos.fs_driver.fs_register(fs_drv, 'M') + +prefs = mpos.config.SharedPreferences("com.micropythonos.settings") + +mpos.ui.set_theme(prefs) +init_rootscreen() +mpos.ui.topmenu.create_notification_bar() +mpos.ui.topmenu.create_drawer(mpos.ui.display) +mpos.ui.handle_back_swipe() +mpos.ui.handle_top_swipe() + +# Clear top menu, notification bar, swipe back and swipe down buttons +# Ideally, these would be stored in a different focusgroup that is used when the user opens the drawer +focusgroup = lv.group_get_default() +if focusgroup: # on esp32 this may not be set + focusgroup.remove_all_objs() # might be better to save and restore the group for "back" actions + +# Can be passed to TaskHandler, currently unused: +def custom_exception_handler(e): + print(f"custom_exception_handler called: {e}") + mpos.ui.task_handler.deinit() + # otherwise it does focus_next and then crashes while doing lv.deinit() + focusgroup.remove_all_objs() + focusgroup.delete() + lv.deinit() + +import sys +if sys.platform == "esp32": + mpos.ui.task_handler = task_handler.TaskHandler(duration=5) # 1ms gives highest framerate on esp32-s3's but might have side effects? +else: + mpos.ui.task_handler = task_handler.TaskHandler(duration=5) # 5ms is recommended for MicroPython+LVGL on desktop (less results in lower framerate) + +try: + import freezefs_mount_builtin +except Exception as e: + # This will throw an exception if there is already a "/builtin" folder present + print("main.py: WARNING: could not import/run freezefs_mount_builtin: ", e) + +try: + from mpos.net.wifi_service import WifiService + _thread.stack_size(mpos.apps.good_stack_size()) + _thread.start_new_thread(WifiService.auto_connect, ()) +except Exception as e: + print(f"Couldn't start WifiService.auto_connect thread because: {e}") + +# Start launcher so it's always at bottom of stack +launcher_app = PackageManager.get_launcher() +started_launcher = mpos.apps.start_app(launcher_app.fullname) +# Then start auto_start_app if configured +auto_start_app = prefs.get_string("auto_start_app", None) +if auto_start_app and launcher_app.fullname != auto_start_app: + result = mpos.apps.start_app(auto_start_app) + if result is not True: + print(f"WARNING: could not run {auto_start_app} app") + +# Create limited aiorepl because it's better than nothing: +import aiorepl +async def asyncio_repl(): + print("Starting very limited asyncio REPL task. To stop all asyncio tasks and go to real REPL, do: import mpos ; mpos.TaskManager.stop()") + await aiorepl.task() +mpos.TaskManager.create_task(asyncio_repl()) # only gets started when mpos.TaskManager.start() is created + +async def ota_rollback_cancel(): + try: + import ota.rollback + ota.rollback.cancel() + except Exception as e: + print("main.py: warning: could not mark this update as valid:", e) + +if not started_launcher: + print(f"WARNING: launcher {launcher_app} failed to start, not cancelling OTA update rollback") +else: + mpos.TaskManager.create_task(ota_rollback_cancel()) # only gets started when mpos.TaskManager() is created + +try: + mpos.TaskManager.start() # do this at the end because it doesn't return +except KeyboardInterrupt as k: + print(f"mpos.TaskManager() got KeyboardInterrupt, falling back to REPL shell...") # only works if no aiorepl is running +except Exception as e: + print(f"mpos.TaskManager() got exception: {e}") diff --git a/internal_filesystem/lib/mpos/net/__init__.py b/internal_filesystem/lib/mpos/net/__init__.py new file mode 100644 index 00000000..1af8d8e5 --- /dev/null +++ b/internal_filesystem/lib/mpos/net/__init__.py @@ -0,0 +1,3 @@ +# mpos.net module - Networking utilities for MicroPythonOS + +from . import download_manager diff --git a/internal_filesystem/lib/mpos/net/connectivity_manager.py b/internal_filesystem/lib/mpos/net/connectivity_manager.py new file mode 100644 index 00000000..ffb6dd3b --- /dev/null +++ b/internal_filesystem/lib/mpos/net/connectivity_manager.py @@ -0,0 +1,99 @@ +# connectivity.py — Universal ConnectivityManager for MicroPythonOS +# Works on ESP32, ESP8266, Unix/Desktop, and anything else + +import sys +import time +import requests +import usocket +from machine import Timer + +try: + import network + HAS_NETWORK_MODULE = True +except ImportError: + HAS_NETWORK_MODULE = False + +class ConnectivityManager: + _instance = None + + def __init__(self): + #print("connectivity_manager.py init") + if ConnectivityManager._instance: + return + ConnectivityManager._instance = self + + self.can_check_network = HAS_NETWORK_MODULE + + if self.can_check_network: + self.wlan = network.WLAN(network.STA_IF) + else: + self.wlan = None + + self.is_connected = False # Local network (Wi-Fi/AP) connected + self._is_online = False # Real internet reachability + self.callbacks = [] + + if not self.can_check_network: + self.is_connected = True # If there's no way to check, then assume we're always "connected" and online + + # Start periodic validation timer (only on real embedded targets) + self._check_timer = Timer(1) # 0 is already taken by task_handler.py + self._check_timer.init(period=8000, mode=Timer.PERIODIC, callback=self._periodic_check_connected) + + self._periodic_check_connected(notify=False) + #print("init done") + + @classmethod + def get(cls): + if cls._instance is None: + cls._instance = cls() + #print("returning...") + return cls._instance + + def register_callback(self, callback): + if callback not in self.callbacks: + self.callbacks.append(callback) + + def unregister_callback(self, callback): + self.callbacks = [cb for cb in self.callbacks if cb != callback] + + def _notify(self, now_online): + for cb in self.callbacks: + try: + cb(now_online) + except Exception as e: + print("[Connectivity] Callback error:", e) + + def _periodic_check_connected(self, notify=True): + #print("_periodic_check_connected") + was_online = self._is_online + if not self.can_check_network: + self._is_online = True + else: + if self.wlan.isconnected(): + self._is_online = True + else: + self._is_online = False + + if self._is_online != was_online: + status = "ONLINE" if self._is_online else "OFFLINE" + print(f"[Connectivity] Internet => {status}") + if notify: + self._notify(self._is_online) + + # === Public Android-like API === + def is_online(self): + return self._is_online + + def is_wifi_connected(self): + return self.is_connected + + def wait_until_online(self, timeout=60): + if not self.can_check_network: + return True + start = time.time() + while time.time() - start < timeout: + if self.is_online: + return True + time.sleep(1) + return False diff --git a/internal_filesystem/lib/mpos/net/download_manager.py b/internal_filesystem/lib/mpos/net/download_manager.py new file mode 100644 index 00000000..ed9db2a6 --- /dev/null +++ b/internal_filesystem/lib/mpos/net/download_manager.py @@ -0,0 +1,397 @@ +""" +download_manager.py - Centralized download management for MicroPythonOS + +Provides async HTTP download with flexible output modes: +- Download to memory (returns bytes) +- Download to file (returns bool) +- Streaming with chunk callback (returns bool) + +Features: +- Shared aiohttp.ClientSession for performance +- Automatic session lifecycle management +- Thread-safe session access +- Retry logic (3 attempts per chunk, 10s timeout) +- Progress tracking with 2-decimal precision +- Download speed reporting +- Resume support via Range headers + +Example: + from mpos import DownloadManager + + # Download to memory + data = await DownloadManager.download_url("https://api.example.com/data.json") + + # Download to file with progress and speed + async def on_progress(pct): + print(f"{pct:.2f}%") # e.g., "45.67%" + + async def on_speed(speed_bps): + print(f"{speed_bps / 1024:.1f} KB/s") + + success = await DownloadManager.download_url( + "https://example.com/file.bin", + outfile="/sdcard/file.bin", + progress_callback=on_progress, + speed_callback=on_speed + ) + + # Stream processing + async def process_chunk(chunk): + # Process each chunk as it arrives + pass + + success = await DownloadManager.download_url( + "https://example.com/stream", + chunk_callback=process_chunk + ) +""" + +# Constants +_DEFAULT_CHUNK_SIZE = 1024 # 1KB chunks +_DEFAULT_TOTAL_SIZE = 100 * 1024 # 100KB default if Content-Length missing +_MAX_RETRIES = 3 # Retry attempts per chunk +_CHUNK_TIMEOUT_SECONDS = 10 # Timeout per chunk read +_SPEED_UPDATE_INTERVAL_MS = 1000 # Update speed every 1 second + +# Module-level state (singleton pattern) +_session = None +_session_lock = None +_session_refcount = 0 + + +def _init(): + """Initialize DownloadManager (called automatically on first use).""" + global _session_lock + + if _session_lock is not None: + return # Already initialized + + try: + import _thread + _session_lock = _thread.allocate_lock() + print("DownloadManager: Initialized with thread safety") + except ImportError: + # Desktop mode without threading support (or MicroPython without _thread) + _session_lock = None + print("DownloadManager: Initialized without thread safety") + + +def _get_session(): + """Get or create the shared aiohttp session (thread-safe). + + Returns: + aiohttp.ClientSession or None: The session instance, or None if aiohttp unavailable + """ + global _session, _session_lock + + # Lazy init lock + if _session_lock is None: + _init() + + # Thread-safe session creation + if _session_lock: + _session_lock.acquire() + + try: + if _session is None: + try: + import aiohttp + _session = aiohttp.ClientSession() + print("DownloadManager: Created new aiohttp session") + except ImportError: + print("DownloadManager: aiohttp not available") + return None + return _session + finally: + if _session_lock: + _session_lock.release() + + +async def _close_session_if_idle(): + """Close session if no downloads are active (thread-safe). + + Note: MicroPythonOS aiohttp implementation doesn't require explicit session closing. + Sessions are automatically closed via "Connection: close" header. + This function is kept for potential future enhancements. + """ + global _session, _session_refcount, _session_lock + + if _session_lock: + _session_lock.acquire() + + try: + if _session and _session_refcount == 0: + # MicroPythonOS aiohttp doesn't have close() method + # Sessions close automatically, so just clear the reference + _session = None + print("DownloadManager: Cleared idle session reference") + finally: + if _session_lock: + _session_lock.release() + + +def is_session_active(): + """Check if a session is currently active. + + Returns: + bool: True if session exists and is open + """ + global _session, _session_lock + + if _session_lock: + _session_lock.acquire() + + try: + return _session is not None + finally: + if _session_lock: + _session_lock.release() + + +async def close_session(): + """Explicitly close the session (optional, normally auto-managed). + + Useful for testing or forced cleanup. Session will be recreated + on next download_url() call. + + Note: MicroPythonOS aiohttp implementation doesn't require explicit session closing. + Sessions are automatically closed via "Connection: close" header. + This function clears the session reference to allow garbage collection. + """ + global _session, _session_lock + + if _session_lock: + _session_lock.acquire() + + try: + if _session: + # MicroPythonOS aiohttp doesn't have close() method + # Just clear the reference to allow garbage collection + _session = None + print("DownloadManager: Explicitly cleared session reference") + finally: + if _session_lock: + _session_lock.release() + + +async def download_url(url, outfile=None, total_size=None, + progress_callback=None, chunk_callback=None, headers=None, + speed_callback=None): + """Download a URL with flexible output modes. + + This async download function can be used in 3 ways: + - with just a url => returns the content + - with a url and an outfile => writes the content to the outfile + - with a url and a chunk_callback => calls the chunk_callback(chunk_data) for each chunk + + Args: + url (str): URL to download + outfile (str, optional): Path to write file. If None, returns bytes. + total_size (int, optional): Expected size in bytes for progress tracking. + If None, uses Content-Length header or defaults to 100KB. + progress_callback (coroutine, optional): async def callback(percent: float) + Called with progress 0.00-100.00 (2 decimal places). + Only called when progress changes by at least 0.01%. + chunk_callback (coroutine, optional): async def callback(chunk: bytes) + Called for each chunk. Cannot use with outfile. + headers (dict, optional): HTTP headers (e.g., {'Range': 'bytes=1000-'}) + speed_callback (coroutine, optional): async def callback(bytes_per_second: float) + Called periodically (every ~1 second) with download speed. + + Returns: + bytes: Downloaded content (if outfile and chunk_callback are None) + bool: True if successful, False if failed (when using outfile or chunk_callback) + + Raises: + ValueError: If both outfile and chunk_callback are provided + + Example: + # Download to memory + data = await DownloadManager.download_url("https://example.com/file.json") + + # Download to file with progress and speed + async def on_progress(percent): + print(f"Progress: {percent:.2f}%") + + async def on_speed(bps): + print(f"Speed: {bps / 1024:.1f} KB/s") + + success = await DownloadManager.download_url( + "https://example.com/large.bin", + outfile="/sdcard/large.bin", + progress_callback=on_progress, + speed_callback=on_speed + ) + + # Stream processing + async def on_chunk(chunk): + process(chunk) + + success = await DownloadManager.download_url( + "https://example.com/stream", + chunk_callback=on_chunk + ) + """ + # Validate parameters + if outfile and chunk_callback: + raise ValueError( + "Cannot use both outfile and chunk_callback. " + "Use outfile for saving to disk, or chunk_callback for streaming." + ) + + # Lazy init + if _session_lock is None: + _init() + + # Get/create session + session = _get_session() + if session is None: + print("DownloadManager: Cannot download, aiohttp not available") + return False if (outfile or chunk_callback) else None + + # Increment refcount + global _session_refcount + if _session_lock: + _session_lock.acquire() + _session_refcount += 1 + if _session_lock: + _session_lock.release() + + print(f"DownloadManager: Downloading {url}") + + fd = None + try: + # Ensure headers is a dict (aiohttp expects dict, not None) + if headers is None: + headers = {} + + async with session.get(url, headers=headers) as response: + if response.status < 200 or response.status >= 400: + print(f"DownloadManager: HTTP error {response.status}") + return False if (outfile or chunk_callback) else None + + # Figure out total size + print("DownloadManager: Response headers:", response.headers) + if total_size is None: + # response.headers is a dict (after parsing) or None/list (before parsing) + try: + if isinstance(response.headers, dict): + content_length = response.headers.get('Content-Length') + if content_length: + total_size = int(content_length) + except (AttributeError, TypeError, ValueError) as e: + print(f"DownloadManager: Could not parse Content-Length: {e}") + + if total_size is None: + print(f"DownloadManager: WARNING: Unable to determine total_size, assuming {_DEFAULT_TOTAL_SIZE} bytes") + total_size = _DEFAULT_TOTAL_SIZE + + # Setup output + if outfile: + fd = open(outfile, 'wb') + if not fd: + print(f"DownloadManager: WARNING: could not open {outfile} for writing!") + return False + + chunks = [] + partial_size = 0 + chunk_size = _DEFAULT_CHUNK_SIZE + + # Progress tracking with 2-decimal precision + last_progress_pct = -1.0 # Track last reported progress to avoid duplicates + + # Speed tracking + speed_bytes_since_last_update = 0 + speed_last_update_time = None + try: + import time + speed_last_update_time = time.ticks_ms() + except ImportError: + pass # time module not available + + print(f"DownloadManager: {'Writing to ' + outfile if outfile else 'Downloading'} {total_size} bytes in chunks of size {chunk_size}") + + # Download loop with retry logic + while True: + tries_left = _MAX_RETRIES + chunk_data = None + while tries_left > 0: + try: + # Import TaskManager here to avoid circular imports + from mpos import TaskManager + chunk_data = await TaskManager.wait_for( + response.content.read(chunk_size), + _CHUNK_TIMEOUT_SECONDS + ) + break + except Exception as e: + print(f"DownloadManager: Chunk read error: {e}") + tries_left -= 1 + + if tries_left == 0: + print("DownloadManager: ERROR: failed to download chunk after retries!") + if fd: + fd.close() + return False if (outfile or chunk_callback) else None + + if chunk_data: + # Output chunk + if fd: + fd.write(chunk_data) + elif chunk_callback: + await chunk_callback(chunk_data) + else: + chunks.append(chunk_data) + + # Track bytes for speed calculation + chunk_len = len(chunk_data) + partial_size += chunk_len + speed_bytes_since_last_update += chunk_len + + # Report progress with 2-decimal precision + # Only call callback if progress changed by at least 0.01% + progress_pct = round((partial_size * 100) / int(total_size), 2) + if progress_callback and progress_pct != last_progress_pct: + print(f"DownloadManager: Progress: {partial_size} / {total_size} bytes = {progress_pct:.2f}%") + await progress_callback(progress_pct) + last_progress_pct = progress_pct + + # Report speed periodically + if speed_callback and speed_last_update_time is not None: + import time + current_time = time.ticks_ms() + elapsed_ms = time.ticks_diff(current_time, speed_last_update_time) + if elapsed_ms >= _SPEED_UPDATE_INTERVAL_MS: + # Calculate bytes per second + bytes_per_second = (speed_bytes_since_last_update * 1000) / elapsed_ms + await speed_callback(bytes_per_second) + # Reset for next interval + speed_bytes_since_last_update = 0 + speed_last_update_time = current_time + else: + # Chunk is None, download complete + print(f"DownloadManager: Finished downloading {url}") + if fd: + fd.close() + fd = None + return True + elif chunk_callback: + return True + else: + return b''.join(chunks) + + except Exception as e: + print(f"DownloadManager: Exception during download: {e}") + if fd: + fd.close() + return False if (outfile or chunk_callback) else None + finally: + # Decrement refcount + if _session_lock: + _session_lock.acquire() + _session_refcount -= 1 + if _session_lock: + _session_lock.release() + + # Close session if idle + await _close_session_if_idle() diff --git a/internal_filesystem/lib/mpos/net/wifi_service.py b/internal_filesystem/lib/mpos/net/wifi_service.py new file mode 100644 index 00000000..25d777a7 --- /dev/null +++ b/internal_filesystem/lib/mpos/net/wifi_service.py @@ -0,0 +1,382 @@ +""" +WiFi Service for MicroPythonOS. + +Manages WiFi connections including: +- Auto-connect to saved networks on boot +- Network scanning +- Connection management with saved credentials +- Concurrent access locking + +This service works alongside ConnectivityManager which monitors connection status. +""" + +import ujson +import os +import time + +import mpos.config +import mpos.time + +# Try to import network module (not available on desktop) +HAS_NETWORK_MODULE = False +try: + import network + HAS_NETWORK_MODULE = True +except ImportError: + print("WifiService: network module not available (desktop mode)") + + +class WifiService: + """ + Service for managing WiFi connections. + + This class handles connecting to saved WiFi networks and managing + the WiFi hardware state. It's typically started in a background thread + on boot to auto-connect to known networks. + """ + + # Class-level lock to prevent concurrent WiFi operations + # Used by WiFi app when scanning to avoid conflicts with connection attempts + wifi_busy = False + + # Dictionary of saved access points {ssid: {password: "..."}} + access_points = {} + + @staticmethod + def connect(network_module=None): + """ + Scan for available networks and connect to the first saved network found. + Networks are tried in order of signal strength (strongest first). + + Args: + network_module: Network module for dependency injection (testing) + + Returns: + bool: True if successfully connected, False otherwise + """ + net = network_module if network_module else network + wlan = net.WLAN(net.STA_IF) + + # Restart WiFi hardware in case it's in a bad state + wlan.active(False) + wlan.active(True) + + # Scan for available networks + networks = wlan.scan() + + # Sort networks by RSSI (signal strength) in descending order + # RSSI is at index 3, higher values (less negative) = stronger signal + networks = sorted(networks, key=lambda n: n[3], reverse=True) + + for n in networks: + ssid = n[0].decode() + rssi = n[3] + print(f"WifiService: Found network '{ssid}' (RSSI: {rssi} dBm)") + + if ssid in WifiService.access_points: + password = WifiService.access_points.get(ssid).get("password") + print(f"WifiService: Attempting to connect to saved network '{ssid}'") + + if WifiService.attempt_connecting(ssid, password, network_module=network_module): + print(f"WifiService: Connected to '{ssid}'") + return True + else: + print(f"WifiService: Failed to connect to '{ssid}'") + else: + print(f"WifiService: Skipping '{ssid}' (not configured)") + + print("WifiService: No saved networks found or connected") + return False + + @staticmethod + def attempt_connecting(ssid, password, network_module=None, time_module=None): + """ + Attempt to connect to a specific WiFi network. + + Args: + ssid: Network SSID to connect to + password: Network password + network_module: Network module for dependency injection (testing) + time_module: Time module for dependency injection (testing) + + Returns: + bool: True if successfully connected, False otherwise + """ + print(f"WifiService: Connecting to SSID: {ssid}") + + net = network_module if network_module else network + time_mod = time_module if time_module else time + + try: + wlan = net.WLAN(net.STA_IF) + wlan.connect(ssid, password) + + # Wait up to 10 seconds for connection + for i in range(10): + if wlan.isconnected(): + print(f"WifiService: Connected to '{ssid}' after {i+1} seconds") + + # Sync time from NTP server if possible + try: + mpos.time.sync_time() + except Exception as e: + print(f"WifiService: Could not sync time: {e}") + + return True + + elif not wlan.active(): + # WiFi was disabled during connection attempt + print("WifiService: WiFi disabled during connection, aborting") + return False + + print(f"WifiService: Waiting for connection, attempt {i+1}/10") + time_mod.sleep(1) + + print(f"WifiService: Connection timeout for '{ssid}'") + return False + + except Exception as e: + print(f"WifiService: Connection error: {e}") + return False + + @staticmethod + def auto_connect(network_module=None, time_module=None): + """ + Auto-connect to a saved WiFi network on boot. + + This is typically called in a background thread from main.py. + It loads saved networks and attempts to connect to the first one found. + + Args: + network_module: Network module for dependency injection (testing) + time_module: Time module for dependency injection (testing) + """ + print("WifiService: Auto-connect thread starting") + + # Load saved access points from config + WifiService.access_points = mpos.config.SharedPreferences( + "com.micropythonos.system.wifiservice" + ).get_dict("access_points") + + if not len(WifiService.access_points): + print("WifiService: No access points configured, exiting") + return + + # Check if WiFi is busy (e.g., WiFi app is scanning) + if WifiService.wifi_busy: + print("WifiService: WiFi busy, auto-connect aborted") + return + + WifiService.wifi_busy = True + + try: + if not HAS_NETWORK_MODULE and network_module is None: + # Desktop mode - simulate connection delay + print("WifiService: Desktop mode, simulating connection...") + time_mod = time_module if time_module else time + time_mod.sleep(2) + print("WifiService: Simulated connection complete") + else: + # Attempt to connect to saved networks + if WifiService.connect(network_module=network_module): + print("WifiService: Auto-connect successful") + else: + print("WifiService: Auto-connect failed") + + # Disable WiFi to conserve power if connection failed + if network_module: + net = network_module + else: + net = network + + wlan = net.WLAN(net.STA_IF) + wlan.active(False) + print("WifiService: WiFi disabled to conserve power") + + finally: + WifiService.wifi_busy = False + print("WifiService: Auto-connect thread finished") + + @staticmethod + def temporarily_disable(network_module=None): + """ + Temporarily disable WiFi for operations that require it (e.g., ESP32-S3 ADC2). + + This method sets wifi_busy flag and disconnects WiFi if connected. + Caller must call temporarily_enable() in a finally block. + + Args: + network_module: Network module for dependency injection (testing) + + Returns: + bool: True if WiFi was connected before disabling, False otherwise + + Raises: + RuntimeError: If WiFi operations are already in progress + """ + if WifiService.wifi_busy: + raise RuntimeError("Cannot disable WiFi: WifiService is already busy") + + # Check actual connection status BEFORE setting wifi_busy + was_connected = False + if HAS_NETWORK_MODULE or network_module: + try: + net = network_module if network_module else network + wlan = net.WLAN(net.STA_IF) + was_connected = wlan.isconnected() + except Exception as e: + print(f"WifiService: Error checking connection: {e}") + + # Now set busy flag and disconnect + WifiService.wifi_busy = True + WifiService.disconnect(network_module=network_module) + + return was_connected + + @staticmethod + def temporarily_enable(was_connected, network_module=None): + """ + Re-enable WiFi after temporary disable operation. + + Must be called in a finally block after temporarily_disable(). + + Args: + was_connected: Return value from temporarily_disable() + network_module: Network module for dependency injection (testing) + """ + WifiService.wifi_busy = False + + # Only reconnect if WiFi was connected before we disabled it + if was_connected: + try: + import _thread + _thread.start_new_thread(WifiService.auto_connect, ()) + except Exception as e: + print(f"WifiService: Failed to start reconnect thread: {e}") + + @staticmethod + def is_connected(network_module=None): + """ + Check if WiFi is currently connected. + + This is a simple connection check. For comprehensive connectivity + monitoring with callbacks, use ConnectivityManager instead. + + Args: + network_module: Network module for dependency injection (testing) + + Returns: + bool: True if connected, False otherwise + """ + # If WiFi operations are in progress, report not connected + if WifiService.wifi_busy: + return False + + # Desktop mode - always report connected + if not HAS_NETWORK_MODULE and network_module is None: + return True + + # Check actual connection status + try: + net = network_module if network_module else network + wlan = net.WLAN(net.STA_IF) + return wlan.isconnected() + except Exception as e: + print(f"WifiService: Error checking connection: {e}") + return False + + @staticmethod + def disconnect(network_module=None): + """ + Disconnect from current WiFi network and disable WiFi. + + Args: + network_module: Network module for dependency injection (testing) + """ + if not HAS_NETWORK_MODULE and network_module is None: + print("WifiService: Desktop mode, cannot disconnect") + return + + try: + net = network_module if network_module else network + wlan = net.WLAN(net.STA_IF) + wlan.disconnect() + wlan.active(False) + print("WifiService: Disconnected and WiFi disabled") + except Exception as e: + #print(f"WifiService: Error disconnecting: {e}") # probably "Wifi Not Started" so harmless + pass + + @staticmethod + def get_saved_networks(): + """ + Get list of saved network SSIDs. + + Returns: + list: List of saved SSIDs + """ + if not WifiService.access_points: + WifiService.access_points = mpos.config.SharedPreferences( + "com.micropythonos.system.wifiservice" + ).get_dict("access_points") + + return list(WifiService.access_points.keys()) + + @staticmethod + def save_network(ssid, password): + """ + Save a new WiFi network credential. + + Args: + ssid: Network SSID + password: Network password + """ + # Load current saved networks + prefs = mpos.config.SharedPreferences("com.micropythonos.system.wifiservice") + access_points = prefs.get_dict("access_points") + + # Add or update the network + access_points[ssid] = {"password": password} + + # Save back to config + editor = prefs.edit() + editor.put_dict("access_points", access_points) + editor.commit() + + # Update class-level cache + WifiService.access_points = access_points + + print(f"WifiService: Saved network '{ssid}'") + + @staticmethod + def forget_network(ssid): + """ + Remove a saved WiFi network. + + Args: + ssid: Network SSID to forget + + Returns: + bool: True if network was found and removed, False otherwise + """ + # Load current saved networks + prefs = mpos.config.SharedPreferences("com.micropythonos.system.wifiservice") + access_points = prefs.get_dict("access_points") + + # Remove the network if it exists + if ssid in access_points: + del access_points[ssid] + + # Save back to config + editor = prefs.edit() + editor.put_dict("access_points", access_points) + editor.commit() + + # Update class-level cache + WifiService.access_points = access_points + + print(f"WifiService: Forgot network '{ssid}'") + return True + else: + print(f"WifiService: Network '{ssid}' not found in saved networks") + return False diff --git a/internal_filesystem/lib/mpos/sdcard.py b/internal_filesystem/lib/mpos/sdcard.py index 0b52dd42..0f7c93bb 100644 --- a/internal_filesystem/lib/mpos/sdcard.py +++ b/internal_filesystem/lib/mpos/sdcard.py @@ -21,6 +21,7 @@ def _try_mount(self, mount_point): print(f"SD card mounted successfully at {mount_point}") return True except OSError as e: + import errno if e.errno == errno.EPERM: # EPERM is 1, meaning already mounted print(f"Got mount error {e} which means already mounted.") return True @@ -132,7 +133,7 @@ def get(): """Get the global SD card manager instance.""" if _manager is None: print("ERROR: SDCardManager not initialized") - print(" - Call init(spi_bus, cs_pin) first in boot.py or main.py") + print(" - Call init(spi_bus, cs_pin) first in lib/mpos/board/*.py") return _manager def mount(mount_point): diff --git a/internal_filesystem/lib/mpos/sensor_manager.py b/internal_filesystem/lib/mpos/sensor_manager.py new file mode 100644 index 00000000..8068c73c --- /dev/null +++ b/internal_filesystem/lib/mpos/sensor_manager.py @@ -0,0 +1,898 @@ +"""Android-inspired SensorManager for MicroPythonOS. + +Provides unified access to IMU sensors (QMI8658, WSEN_ISDS) and other sensors. +Follows module-level singleton pattern (like AudioFlinger, LightsManager). + +Example usage: + import mpos.sensor_manager as SensorManager + + # In board init file: + SensorManager.init(i2c_bus, address=0x6B) + + # In app: + if SensorManager.is_available(): + accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) + ax, ay, az = SensorManager.read_sensor(accel) # Returns m/s² + +MIT License +Copyright (c) 2024 MicroPythonOS contributors +""" + +import time +try: + import _thread + _lock = _thread.allocate_lock() +except ImportError: + _lock = None + + +# Sensor type constants (matching Android SensorManager) +TYPE_ACCELEROMETER = 1 # Units: m/s² (meters per second squared) +TYPE_GYROSCOPE = 4 # Units: deg/s (degrees per second) +TYPE_TEMPERATURE = 13 # Units: °C (generic, returns first available - deprecated) +TYPE_IMU_TEMPERATURE = 14 # Units: °C (IMU chip temperature) +TYPE_SOC_TEMPERATURE = 15 # Units: °C (MCU/SoC internal temperature) + +# mounted_position: +FACING_EARTH = 20 # underside of PCB, like fri3d_2024 +FACING_SKY = 21 # top of PCB, like waveshare_esp32_s3_lcd_touch_2 (default) + +# Gravity constant for unit conversions +_GRAVITY = 9.80665 # m/s² + +IMU_CALIBRATION_FILENAME = "imu_calibration.json" + +# Module state +_initialized = False +_imu_driver = None +_sensor_list = [] +_i2c_bus = None +_i2c_address = None +_mounted_position = FACING_SKY +_has_mcu_temperature = False + + +class Sensor: + """Sensor metadata (lightweight data class, Android-inspired).""" + + def __init__(self, name, sensor_type, vendor, version, max_range, resolution, power_ma): + """Initialize sensor metadata. + + Args: + name: Human-readable sensor name + sensor_type: Sensor type constant (TYPE_ACCELEROMETER, etc.) + vendor: Sensor vendor/manufacturer + version: Driver version + max_range: Maximum measurement range (with units) + resolution: Measurement resolution (with units) + power_ma: Power consumption in mA (or 0 if unknown) + """ + self.name = name + self.type = sensor_type + self.vendor = vendor + self.version = version + self.max_range = max_range + self.resolution = resolution + self.power = power_ma + + def __repr__(self): + return f"Sensor({self.name}, type={self.type})" + + +def init(i2c_bus, address=0x6B, mounted_position=FACING_SKY): + """Initialize SensorManager. MCU temperature initializes immediately, IMU initializes on first use. + + Args: + i2c_bus: machine.I2C instance (can be None if only MCU temperature needed) + address: I2C address (default 0x6B for both QMI8658 and WSEN_ISDS) + + Returns: + bool: True if initialized successfully + """ + global _i2c_bus, _i2c_address, _initialized, _has_mcu_temperature, _mounted_position + + _i2c_bus = i2c_bus + _i2c_address = address + _mounted_position = mounted_position + + # Initialize MCU temperature sensor immediately (fast, no I2C needed) + try: + import esp32 + _ = esp32.mcu_temperature() + _has_mcu_temperature = True + _register_mcu_temperature_sensor() + except: + pass + + _initialized = True + return True + + +def _ensure_imu_initialized(): + """Perform IMU initialization on first use (lazy initialization). + + Tries to detect QMI8658 (chip ID 0x05) or WSEN_ISDS (WHO_AM_I 0x6A). + Loads calibration from SharedPreferences if available. + + Returns: + bool: True if IMU detected and initialized successfully + """ + global _imu_driver, _sensor_list + + if not _initialized or _imu_driver is not None: + return _imu_driver is not None + + # Try QMI8658 first (Waveshare board) + if _i2c_bus: + try: + from mpos.hardware.drivers.qmi8658 import QMI8658 + chip_id = _i2c_bus.readfrom_mem(_i2c_address, 0x00, 1)[0] # PARTID register + if chip_id == 0x05: # QMI8685_PARTID + _imu_driver = _QMI8658Driver(_i2c_bus, _i2c_address) + _register_qmi8658_sensors() + _load_calibration() + return True + except: + pass + + # Try WSEN_ISDS (Fri3d badge) + try: + from mpos.hardware.drivers.wsen_isds import Wsen_Isds + chip_id = _i2c_bus.readfrom_mem(_i2c_address, 0x0F, 1)[0] # WHO_AM_I register + if chip_id == 0x6A: # WSEN_ISDS WHO_AM_I + _imu_driver = _WsenISDSDriver(_i2c_bus, _i2c_address) + _register_wsen_isds_sensors() + _load_calibration() + return True + except: + pass + + return False + + +def is_available(): + """Check if sensors are available. + + Does NOT trigger IMU initialization (to avoid boot-time initialization). + Use get_default_sensor() or read_sensor() to lazily initialize IMU. + + Returns: + bool: True if SensorManager is initialized (may only have MCU temp, not IMU) + """ + return _initialized + + +def get_sensor_list(): + """Get list of all available sensors. + + Performs lazy IMU initialization on first call. + + Returns: + list: List of Sensor objects + """ + _ensure_imu_initialized() + return _sensor_list.copy() if _sensor_list else [] + + +def get_default_sensor(sensor_type): + """Get default sensor of given type. + + Performs lazy IMU initialization on first call. + + Args: + sensor_type: Sensor type constant (TYPE_ACCELEROMETER, etc.) + + Returns: + Sensor object or None if not available + """ + # Only initialize IMU if requesting IMU sensor types + if sensor_type in (TYPE_ACCELEROMETER, TYPE_GYROSCOPE): + _ensure_imu_initialized() + + for sensor in _sensor_list: + if sensor.type == sensor_type: + return sensor + return None + + +def read_sensor(sensor): + """Read sensor data synchronously. + + Performs lazy IMU initialization on first call for IMU sensors. + + Args: + sensor: Sensor object from get_default_sensor() + + Returns: + For motion sensors: tuple (x, y, z) in appropriate units + For scalar sensors: single value + None if sensor not available or error + """ + if sensor is None: + return None + + # Only initialize IMU if reading IMU sensor + if sensor.type in (TYPE_ACCELEROMETER, TYPE_GYROSCOPE): + _ensure_imu_initialized() + + if _lock: + _lock.acquire() + + try: + # Retry logic for "sensor data not ready" (WSEN_ISDS needs time after init) + max_retries = 3 + retry_delay_ms = 20 # Wait 20ms between retries + + for attempt in range(max_retries): + try: + if sensor.type == TYPE_ACCELEROMETER: + if _imu_driver: + ax, ay, az = _imu_driver.read_acceleration() + if _mounted_position == FACING_EARTH: + az *= -1 + return (ax, ay, az) + elif sensor.type == TYPE_GYROSCOPE: + if _imu_driver: + return _imu_driver.read_gyroscope() + elif sensor.type == TYPE_IMU_TEMPERATURE: + if _imu_driver: + return _imu_driver.read_temperature() + elif sensor.type == TYPE_SOC_TEMPERATURE: + if _has_mcu_temperature: + import esp32 + return esp32.mcu_temperature() + elif sensor.type == TYPE_TEMPERATURE: + # Generic temperature - return first available (backward compatibility) + if _imu_driver: + temp = _imu_driver.read_temperature() + if temp is not None: + return temp + if _has_mcu_temperature: + import esp32 + return esp32.mcu_temperature() + return None + except Exception as e: + error_msg = str(e) + # Retry if sensor data not ready, otherwise fail immediately + if "data not ready" in error_msg and attempt < max_retries - 1: + import time + time.sleep_ms(retry_delay_ms) + continue + else: + return None + + return None + finally: + if _lock: + _lock.release() + + +def calibrate_sensor(sensor, samples=100): + """Calibrate sensor and save to SharedPreferences. + + Performs lazy IMU initialization on first call. + Device must be stationary for accelerometer/gyroscope calibration. + + Args: + sensor: Sensor object to calibrate + samples: Number of samples to average (default 100) + + Returns: + tuple: Calibration offsets (x, y, z) or None if failed + """ + _ensure_imu_initialized() + if not is_available() or sensor is None: + return None + + if _lock: + _lock.acquire() + + try: + if sensor.type == TYPE_ACCELEROMETER: + offsets = _imu_driver.calibrate_accelerometer(samples) + elif sensor.type == TYPE_GYROSCOPE: + offsets = _imu_driver.calibrate_gyroscope(samples) + else: + return None + + if offsets: + _save_calibration() + + return offsets + except Exception as e: + print(f"[SensorManager] Calibration error: {e}") + return None + finally: + if _lock: + _lock.release() + + +# Helper functions for calibration quality checking (module-level to avoid nested def issues) +def _calc_mean_variance(samples_list): + """Calculate mean and variance for a list of samples.""" + if not samples_list: + return 0.0, 0.0 + n = len(samples_list) + mean = sum(samples_list) / n + variance = sum((x - mean) ** 2 for x in samples_list) / n + return mean, variance + + +def _calc_variance(samples_list): + """Calculate variance for a list of samples.""" + if not samples_list: + return 0.0 + n = len(samples_list) + mean = sum(samples_list) / n + return sum((x - mean) ** 2 for x in samples_list) / n + + +def check_calibration_quality(samples=50): + """Check quality of current calibration. + + Performs lazy IMU initialization on first call. + + Args: + samples: Number of samples to collect (default 50) + + Returns: + dict with: + - accel_mean: (x, y, z) mean values in m/s² + - accel_variance: (x, y, z) variance values + - gyro_mean: (x, y, z) mean values in deg/s + - gyro_variance: (x, y, z) variance values + - quality_score: float 0.0-1.0 (1.0 = perfect) + - quality_rating: string ("Good", "Fair", "Poor") + - issues: list of strings describing problems + None if IMU not available + """ + _ensure_imu_initialized() + if not is_available(): + return None + + # Don't acquire lock here - let read_sensor() handle it per-read + # (avoids deadlock since read_sensor also acquires the lock) + try: + accel = get_default_sensor(TYPE_ACCELEROMETER) + gyro = get_default_sensor(TYPE_GYROSCOPE) + + # Collect samples + accel_samples = [[], [], []] # x, y, z lists + gyro_samples = [[], [], []] + + for _ in range(samples): + if accel: + data = read_sensor(accel) + if data: + ax, ay, az = data + accel_samples[0].append(ax) + accel_samples[1].append(ay) + accel_samples[2].append(az) + if gyro: + data = read_sensor(gyro) + if data: + gx, gy, gz = data + gyro_samples[0].append(gx) + gyro_samples[1].append(gy) + gyro_samples[2].append(gz) + time.sleep_ms(10) + + # Calculate statistics using module-level helper + accel_stats = [_calc_mean_variance(s) for s in accel_samples] + gyro_stats = [_calc_mean_variance(s) for s in gyro_samples] + + accel_mean = tuple(s[0] for s in accel_stats) + accel_variance = tuple(s[1] for s in accel_stats) + gyro_mean = tuple(s[0] for s in gyro_stats) + gyro_variance = tuple(s[1] for s in gyro_stats) + + # Calculate quality score (0.0 - 1.0) + issues = [] + scores = [] + + # Check accelerometer + if accel: + # Variance check (lower is better) + accel_max_variance = max(accel_variance) + variance_score = max(0.0, 1.0 - (accel_max_variance / 1.0)) # 1.0 m/s² variance threshold + scores.append(variance_score) + if accel_max_variance > 0.5: + issues.append(f"High accelerometer variance: {accel_max_variance:.3f} m/s²") + + # Expected values check (X≈0, Y≈0, Z≈9.8) + ax, ay, az = accel_mean + xy_error = (abs(ax) + abs(ay)) / 2.0 + z_error = abs(az - _GRAVITY) + expected_score = max(0.0, 1.0 - ((xy_error + z_error) / 5.0)) # 5.0 m/s² error threshold + scores.append(expected_score) + if xy_error > 1.0: + issues.append(f"Accel X/Y not near zero: X={ax:.2f}, Y={ay:.2f} m/s²") + if z_error > 1.0: + issues.append(f"Accel Z not near 9.8: Z={az:.2f} m/s²") + + # Check gyroscope + if gyro: + # Variance check + gyro_max_variance = max(gyro_variance) + variance_score = max(0.0, 1.0 - (gyro_max_variance / 10.0)) # 10 deg/s variance threshold + scores.append(variance_score) + if gyro_max_variance > 5.0: + issues.append(f"High gyroscope variance: {gyro_max_variance:.3f} deg/s") + + # Expected values check (all ≈0) + gx, gy, gz = gyro_mean + error = (abs(gx) + abs(gy) + abs(gz)) / 3.0 + expected_score = max(0.0, 1.0 - (error / 10.0)) # 10 deg/s error threshold + scores.append(expected_score) + if error > 2.0: + issues.append(f"Gyro not near zero: X={gx:.2f}, Y={gy:.2f}, Z={gz:.2f} deg/s") + + # Overall quality score + quality_score = sum(scores) / len(scores) if scores else 0.0 + + # Rating + if quality_score >= 0.8: + quality_rating = "Good" + elif quality_score >= 0.5: + quality_rating = "Fair" + else: + quality_rating = "Poor" + + return { + 'accel_mean': accel_mean, + 'accel_variance': accel_variance, + 'gyro_mean': gyro_mean, + 'gyro_variance': gyro_variance, + 'quality_score': quality_score, + 'quality_rating': quality_rating, + 'issues': issues + } + + except Exception as e: + print(f"[SensorManager] Error checking calibration quality: {e}") + return None + + +def check_stationarity(samples=30, variance_threshold_accel=0.5, variance_threshold_gyro=5.0): + """Check if device is stationary (required for calibration). + + Args: + samples: Number of samples to collect (default 30) + variance_threshold_accel: Max acceptable accel variance in m/s² (default 0.5) + variance_threshold_gyro: Max acceptable gyro variance in deg/s (default 5.0) + + Returns: + dict with: + - is_stationary: bool + - accel_variance: max variance across axes + - gyro_variance: max variance across axes + - message: string describing result + None if IMU not available + """ + _ensure_imu_initialized() + if not is_available(): + return None + + # Don't acquire lock here - let read_sensor() handle it per-read + # (avoids deadlock since read_sensor also acquires the lock) + try: + accel = get_default_sensor(TYPE_ACCELEROMETER) + gyro = get_default_sensor(TYPE_GYROSCOPE) + + # Collect samples + accel_samples = [[], [], []] + gyro_samples = [[], [], []] + + for _ in range(samples): + if accel: + data = read_sensor(accel) + if data: + ax, ay, az = data + accel_samples[0].append(ax) + accel_samples[1].append(ay) + accel_samples[2].append(az) + if gyro: + data = read_sensor(gyro) + if data: + gx, gy, gz = data + gyro_samples[0].append(gx) + gyro_samples[1].append(gy) + gyro_samples[2].append(gz) + time.sleep_ms(10) + + # Calculate variance using module-level helper + accel_var = [_calc_variance(s) for s in accel_samples] + gyro_var = [_calc_variance(s) for s in gyro_samples] + + max_accel_var = max(accel_var) if accel_var else 0.0 + max_gyro_var = max(gyro_var) if gyro_var else 0.0 + + # Check thresholds + accel_stationary = max_accel_var < variance_threshold_accel + gyro_stationary = max_gyro_var < variance_threshold_gyro + is_stationary = accel_stationary and gyro_stationary + + # Generate message + if is_stationary: + message = "Device is stationary - ready to calibrate" + else: + problems = [] + if not accel_stationary: + problems.append(f"movement detected (accel variance: {max_accel_var:.3f})") + if not gyro_stationary: + problems.append(f"rotation detected (gyro variance: {max_gyro_var:.3f})") + message = f"Device NOT stationary: {', '.join(problems)}" + + return { + 'is_stationary': is_stationary, + 'accel_variance': max_accel_var, + 'gyro_variance': max_gyro_var, + 'message': message + } + + except Exception as e: + print(f"[SensorManager] Error checking stationarity: {e}") + return None + + +# ============================================================================ +# Internal driver abstraction layer +# ============================================================================ + +class _IMUDriver: + """Base class for IMU drivers (internal use only).""" + + def read_acceleration(self): + """Returns (x, y, z) in m/s²""" + raise NotImplementedError + + def read_gyroscope(self): + """Returns (x, y, z) in deg/s""" + raise NotImplementedError + + def read_temperature(self): + """Returns temperature in °C""" + raise NotImplementedError + + def calibrate_accelerometer(self, samples): + """Calibrate accel, return (x, y, z) offsets in m/s²""" + raise NotImplementedError + + def calibrate_gyroscope(self, samples): + """Calibrate gyro, return (x, y, z) offsets in deg/s""" + raise NotImplementedError + + def get_calibration(self): + """Return dict with 'accel_offsets' and 'gyro_offsets' keys""" + raise NotImplementedError + + def set_calibration(self, accel_offsets, gyro_offsets): + """Set calibration offsets from saved values""" + raise NotImplementedError + + +class _QMI8658Driver(_IMUDriver): + """Wrapper for QMI8658 IMU (Waveshare board).""" + + def __init__(self, i2c_bus, address): + from mpos.hardware.drivers.qmi8658 import QMI8658 + # QMI8658 scale constants (can't import const() values) + _ACCELSCALE_RANGE_8G = 0b10 + _GYROSCALE_RANGE_256DPS = 0b100 + self.sensor = QMI8658( + i2c_bus, + address=address, + accel_scale=_ACCELSCALE_RANGE_8G, + gyro_scale=_GYROSCALE_RANGE_256DPS + ) + # Software calibration offsets (QMI8658 has no built-in calibration) + self.accel_offset = [0.0, 0.0, 0.0] + self.gyro_offset = [0.0, 0.0, 0.0] + + def read_acceleration(self): + """Read acceleration in m/s² (converts from G).""" + ax, ay, az = self.sensor.acceleration + # Convert G to m/s² and apply calibration + return ( + (ax * _GRAVITY) - self.accel_offset[0], + (ay * _GRAVITY) - self.accel_offset[1], + (az * _GRAVITY) - self.accel_offset[2] + ) + + def read_gyroscope(self): + """Read gyroscope in deg/s (already in correct units).""" + gx, gy, gz = self.sensor.gyro + # Apply calibration + return ( + gx - self.gyro_offset[0], + gy - self.gyro_offset[1], + gz - self.gyro_offset[2] + ) + + def read_temperature(self): + """Read temperature in °C.""" + return self.sensor.temperature + + def calibrate_accelerometer(self, samples): + """Calibrate accelerometer (device must be stationary).""" + sum_x, sum_y, sum_z = 0.0, 0.0, 0.0 + + for _ in range(samples): + ax, ay, az = self.sensor.acceleration + sum_x += ax * _GRAVITY + sum_y += ay * _GRAVITY + sum_z += az * _GRAVITY + time.sleep_ms(10) + + if _mounted_position == FACING_EARTH: + sum_z *= -1 + + # Average offsets (assuming Z-axis should read +9.8 m/s²) + self.accel_offset[0] = sum_x / samples + self.accel_offset[1] = sum_y / samples + self.accel_offset[2] = (sum_z / samples) - _GRAVITY + + return tuple(self.accel_offset) + + def calibrate_gyroscope(self, samples): + """Calibrate gyroscope (device must be stationary).""" + sum_x, sum_y, sum_z = 0.0, 0.0, 0.0 + + for _ in range(samples): + gx, gy, gz = self.sensor.gyro + sum_x += gx + sum_y += gy + sum_z += gz + time.sleep_ms(10) + + # Average offsets (should be 0 when stationary) + self.gyro_offset[0] = sum_x / samples + self.gyro_offset[1] = sum_y / samples + self.gyro_offset[2] = sum_z / samples + + return tuple(self.gyro_offset) + + def get_calibration(self): + """Get current calibration.""" + return { + 'accel_offsets': self.accel_offset, + 'gyro_offsets': self.gyro_offset + } + + def set_calibration(self, accel_offsets, gyro_offsets): + """Set calibration from saved values.""" + if accel_offsets: + self.accel_offset = list(accel_offsets) + if gyro_offsets: + self.gyro_offset = list(gyro_offsets) + + +class _WsenISDSDriver(_IMUDriver): + """Wrapper for WSEN_ISDS IMU (Fri3d badge).""" + + def __init__(self, i2c_bus, address): + from mpos.hardware.drivers.wsen_isds import Wsen_Isds + self.sensor = Wsen_Isds( + i2c_bus, + address=address, + acc_range="8g", + acc_data_rate="104Hz", + gyro_range="500dps", + gyro_data_rate="104Hz" + ) + # Software calibration offsets + self.accel_offset = [0.0, 0.0, 0.0] + self.gyro_offset = [0.0, 0.0, 0.0] + + + def read_acceleration(self): + + """Read acceleration in m/s² (converts from mg).""" + ax, ay, az = self.sensor._read_raw_accelerations() + + # Convert G to m/s² and apply calibration + return ( + ((ax / 1000) * _GRAVITY) - self.accel_offset[0], + ((ay / 1000) * _GRAVITY) - self.accel_offset[1], + ((az / 1000) * _GRAVITY) - self.accel_offset[2] + ) + + + def read_gyroscope(self): + """Read gyroscope in deg/s (converts from mdps).""" + gx, gy, gz = self.sensor._read_raw_angular_velocities() + # Convert mdps to deg/s and apply calibration + return ( + gx / 1000.0 - self.gyro_offset[0], + gy / 1000.0 - self.gyro_offset[1], + gz / 1000.0 - self.gyro_offset[2] + ) + + def read_temperature(self): + return self.sensor.temperature + + def calibrate_accelerometer(self, samples): + """Calibrate accelerometer (device must be stationary).""" + sum_x, sum_y, sum_z = 0.0, 0.0, 0.0 + + for _ in range(samples): + ax, ay, az = self.sensor._read_raw_accelerations() + sum_x += (ax / 1000.0) * _GRAVITY + sum_y += (ay / 1000.0) * _GRAVITY + sum_z += (az / 1000.0) * _GRAVITY + time.sleep_ms(10) + + print(f"sumz: {sum_z}") + z_offset = 0 + if _mounted_position == FACING_EARTH: + sum_z *= -1 + print(f"sumz: {sum_z}") + + # Average offsets (assuming Z-axis should read +9.8 m/s²) + self.accel_offset[0] = sum_x / samples + self.accel_offset[1] = sum_y / samples + self.accel_offset[2] = (sum_z / samples) - _GRAVITY + print(f"offsets: {self.accel_offset}") + + return tuple(self.accel_offset) + + def calibrate_gyroscope(self, samples): + """Calibrate gyroscope (device must be stationary).""" + sum_x, sum_y, sum_z = 0.0, 0.0, 0.0 + + for _ in range(samples): + gx, gy, gz = self.sensor._read_raw_angular_velocities() + sum_x += gx / 1000.0 + sum_y += gy / 1000.0 + sum_z += gz / 1000.0 + time.sleep_ms(10) + + # Average offsets (should be 0 when stationary) + self.gyro_offset[0] = sum_x / samples + self.gyro_offset[1] = sum_y / samples + self.gyro_offset[2] = sum_z / samples + + return tuple(self.gyro_offset) + + def get_calibration(self): + """Get current calibration.""" + return { + 'accel_offsets': self.accel_offset, + 'gyro_offsets': self.gyro_offset + } + + def set_calibration(self, accel_offsets, gyro_offsets): + """Set calibration from saved values.""" + if accel_offsets: + self.accel_offset = list(accel_offsets) + if gyro_offsets: + self.gyro_offset = list(gyro_offsets) + + +# ============================================================================ +# Sensor registration (internal) +# ============================================================================ + +def _register_qmi8658_sensors(): + """Register QMI8658 sensors in sensor list.""" + global _sensor_list + _sensor_list = [ + Sensor( + name="QMI8658 Accelerometer", + sensor_type=TYPE_ACCELEROMETER, + vendor="QST Corporation", + version=1, + max_range="±8G (78.4 m/s²)", + resolution="0.0024 m/s²", + power_ma=0.2 + ), + Sensor( + name="QMI8658 Gyroscope", + sensor_type=TYPE_GYROSCOPE, + vendor="QST Corporation", + version=1, + max_range="±256 deg/s", + resolution="0.002 deg/s", + power_ma=0.7 + ), + Sensor( + name="QMI8658 Temperature", + sensor_type=TYPE_IMU_TEMPERATURE, + vendor="QST Corporation", + version=1, + max_range="-40°C to +85°C", + resolution="0.004°C", + power_ma=0 + ) + ] + + +def _register_wsen_isds_sensors(): + """Register WSEN_ISDS sensors in sensor list.""" + global _sensor_list + _sensor_list = [ + Sensor( + name="WSEN_ISDS Accelerometer", + sensor_type=TYPE_ACCELEROMETER, + vendor="Würth Elektronik", + version=1, + max_range="±8G (78.4 m/s²)", + resolution="0.0024 m/s²", + power_ma=0.2 + ), + Sensor( + name="WSEN_ISDS Gyroscope", + sensor_type=TYPE_GYROSCOPE, + vendor="Würth Elektronik", + version=1, + max_range="±500 deg/s", + resolution="0.0175 deg/s", + power_ma=0.65 + ), + Sensor( + name="WSEN_ISDS Temperature", + sensor_type=TYPE_IMU_TEMPERATURE, + vendor="Würth Elektronik", + version=1, + max_range="-40°C to +85°C", + resolution="0.004°C", + power_ma=0 + ) + ] + + +def _register_mcu_temperature_sensor(): + """Register MCU internal temperature sensor in sensor list.""" + global _sensor_list + _sensor_list.append( + Sensor( + name="ESP32 MCU Temperature", + sensor_type=TYPE_SOC_TEMPERATURE, + vendor="Espressif", + version=1, + max_range="-40°C to +125°C", + resolution="0.5°C", + power_ma=0 + ) + ) + + +# ============================================================================ +# Calibration persistence (internal) +# ============================================================================ + +def _load_calibration(): + """Load calibration from SharedPreferences (with migration support).""" + if not _imu_driver: + return + + try: + from mpos.config import SharedPreferences + + # Try NEW location first + prefs_new = SharedPreferences("com.micropythonos.settings", filename=IMU_CALIBRATION_FILENAME) + accel_offsets = prefs_new.get_list("accel_offsets") + gyro_offsets = prefs_new.get_list("gyro_offsets") + + if accel_offsets or gyro_offsets: + _imu_driver.set_calibration(accel_offsets, gyro_offsets) + except: + pass + + +def _save_calibration(): + """Save calibration to SharedPreferences.""" + if not _imu_driver: + return + + try: + from mpos.config import SharedPreferences + prefs = SharedPreferences("com.micropythonos.settings", filename=IMU_CALIBRATION_FILENAME) + editor = prefs.edit() + + cal = _imu_driver.get_calibration() + editor.put_list("accel_offsets", list(cal['accel_offsets'])) + editor.put_list("gyro_offsets", list(cal['gyro_offsets'])) + editor.commit() + except: + pass diff --git a/internal_filesystem/lib/mpos/task_manager.py b/internal_filesystem/lib/mpos/task_manager.py new file mode 100644 index 00000000..995bb5b1 --- /dev/null +++ b/internal_filesystem/lib/mpos/task_manager.py @@ -0,0 +1,72 @@ +import asyncio # this is the only place where asyncio is allowed to be imported - apps should not use it directly but use this TaskManager +import _thread +import mpos.apps + +class TaskManager: + + task_list = [] # might be good to periodically remove tasks that are done, to prevent this list from growing huge + keep_running = None + disabled = False + + @classmethod + async def _asyncio_thread(cls, sleep_ms): + print("asyncio_thread started") + while cls.keep_running is True: + #print(f"asyncio_thread tick because cls.keep_running:{cls.keep_running}") + # According to the docs, lv.timer_handler should be called periodically, but everything seems to work fine without it. + # Perhaps lvgl_micropython is doing this somehow, although I can't find it... I guess the task_handler...? + # sleep_ms can't handle too big values, so limit it to 30 ms, which equals 33 fps + # sleep_ms = min(lv.timer_handler(), 30) # lv.timer_handler() will return LV_NO_TIMER_READY (UINT32_MAX) if there are no running timers + await asyncio.sleep_ms(sleep_ms) + print("WARNING: asyncio_thread exited, now asyncio.create_task() won't work anymore") + + @classmethod + def start(cls): + if cls.disabled is True: + print("Not starting TaskManager because it's been disabled.") + return + cls.keep_running = True + # New thread works but LVGL isn't threadsafe so it's preferred to do this in the same thread: + #_thread.stack_size(mpos.apps.good_stack_size()) + #_thread.start_new_thread(asyncio.run, (self._asyncio_thread(100), )) + # Same thread works, although it blocks the real REPL, but aiorepl works: + asyncio.run(TaskManager._asyncio_thread(10)) # 100ms is too high, causes lag. 10ms is fine. not sure if 1ms would be better... + + @classmethod + def stop(cls): + cls.keep_running = False + + @classmethod + def enable(cls): + cls.disabled = False + + @classmethod + def disable(cls): + cls.disabled = True + + @classmethod + def create_task(cls, coroutine): + task = asyncio.create_task(coroutine) + cls.task_list.append(task) + return task + + @classmethod + def list_tasks(cls): + for index, task in enumerate(cls.task_list): + print(f"task {index}: ph_key:{task.ph_key} done:{task.done()} running {task.coro}") + + @staticmethod + def sleep_ms(ms): + return asyncio.sleep_ms(ms) + + @staticmethod + def sleep(s): + return asyncio.sleep(s) + + @staticmethod + def notify_event(): + return asyncio.Event() + + @staticmethod + def wait_for(awaitable, timeout): + return asyncio.wait_for(awaitable, timeout) diff --git a/internal_filesystem/lib/mpos/testing/__init__.py b/internal_filesystem/lib/mpos/testing/__init__.py new file mode 100644 index 00000000..cb0d219a --- /dev/null +++ b/internal_filesystem/lib/mpos/testing/__init__.py @@ -0,0 +1,85 @@ +""" +MicroPythonOS Testing Module + +Provides mock implementations for testing without actual hardware. +These mocks work on both desktop (unit tests) and device (integration tests). + +Usage: + from mpos.testing import MockMachine, MockTaskManager, MockNetwork + + # Inject mocks before importing modules that use hardware + import sys + sys.modules['machine'] = MockMachine() + + # Or use the helper function + from mpos.testing import inject_mocks + inject_mocks(['machine', 'mpos.task_manager']) +""" + +from .mocks import ( + # Hardware mocks + MockMachine, + MockPin, + MockPWM, + MockI2S, + MockTimer, + MockSocket, + + # MPOS mocks + MockTaskManager, + MockTask, + MockDownloadManager, + + # Threading mocks + MockThread, + MockApps, + + # Network mocks + MockNetwork, + MockRequests, + MockResponse, + MockRaw, + + # Utility mocks + MockTime, + MockJSON, + MockModule, + + # Helper functions + inject_mocks, + create_mock_module, +) + +__all__ = [ + # Hardware mocks + 'MockMachine', + 'MockPin', + 'MockPWM', + 'MockI2S', + 'MockTimer', + 'MockSocket', + + # MPOS mocks + 'MockTaskManager', + 'MockTask', + 'MockDownloadManager', + + # Threading mocks + 'MockThread', + 'MockApps', + + # Network mocks + 'MockNetwork', + 'MockRequests', + 'MockResponse', + 'MockRaw', + + # Utility mocks + 'MockTime', + 'MockJSON', + 'MockModule', + + # Helper functions + 'inject_mocks', + 'create_mock_module', +] \ No newline at end of file diff --git a/internal_filesystem/lib/mpos/testing/mocks.py b/internal_filesystem/lib/mpos/testing/mocks.py new file mode 100644 index 00000000..df650a51 --- /dev/null +++ b/internal_filesystem/lib/mpos/testing/mocks.py @@ -0,0 +1,783 @@ +""" +Mock implementations for MicroPythonOS testing. + +This module provides mock implementations of hardware and system modules +for testing without actual hardware. Works on both desktop and device. +""" + +import sys + + +# ============================================================================= +# Helper Functions +# ============================================================================= + +class MockModule: + """ + Simple class that acts as a module container. + MicroPython doesn't have types.ModuleType, so we use this instead. + """ + pass + + +def create_mock_module(name, **attrs): + """ + Create a mock module with the given attributes. + + Args: + name: Module name (for debugging) + **attrs: Attributes to set on the module + + Returns: + MockModule instance with attributes set + """ + module = MockModule() + module.__name__ = name + for key, value in attrs.items(): + setattr(module, key, value) + return module + + +def inject_mocks(mock_specs): + """ + Inject mock modules into sys.modules. + + Args: + mock_specs: Dict mapping module names to mock instances/classes + e.g., {'machine': MockMachine(), 'mpos.task_manager': mock_tm} + """ + for name, mock in mock_specs.items(): + sys.modules[name] = mock + + +# ============================================================================= +# Hardware Mocks - machine module +# ============================================================================= + +class MockPin: + """Mock machine.Pin for testing GPIO operations.""" + + IN = 0 + OUT = 1 + PULL_UP = 2 + PULL_DOWN = 3 + + def __init__(self, pin_number, mode=None, pull=None): + self.pin_number = pin_number + self.mode = mode + self.pull = pull + self._value = 0 + + def value(self, val=None): + """Get or set pin value.""" + if val is None: + return self._value + self._value = val + + def on(self): + """Set pin high.""" + self._value = 1 + + def off(self): + """Set pin low.""" + self._value = 0 + + +class MockPWM: + """Mock machine.PWM for testing PWM operations (buzzer, etc.).""" + + def __init__(self, pin, freq=0, duty=0): + self.pin = pin + self.last_freq = freq + self.last_duty = duty + + def freq(self, value=None): + """Get or set frequency.""" + if value is not None: + self.last_freq = value + return self.last_freq + + def duty_u16(self, value=None): + """Get or set duty cycle (16-bit).""" + if value is not None: + self.last_duty = value + return self.last_duty + + def duty(self, value=None): + """Get or set duty cycle (10-bit).""" + if value is not None: + self.last_duty = value * 64 # Convert to 16-bit + return self.last_duty // 64 + + def deinit(self): + """Deinitialize PWM.""" + self.last_freq = 0 + self.last_duty = 0 + + +class MockI2S: + """Mock machine.I2S for testing audio I2S operations.""" + + TX = 0 + RX = 1 + MONO = 0 + STEREO = 1 + + def __init__(self, id, sck=None, ws=None, sd=None, mode=None, + bits=16, format=None, rate=44100, ibuf=None): + self.id = id + self.sck = sck + self.ws = ws + self.sd = sd + self.mode = mode + self.bits = bits + self.format = format + self.rate = rate + self.ibuf = ibuf + self._write_buffer = bytearray(1024) + self._bytes_written = 0 + + def write(self, buf): + """Write audio data (blocking).""" + self._bytes_written += len(buf) + return len(buf) + + def write_readinto(self, write_buf, read_buf): + """Non-blocking write with readback.""" + self._bytes_written += len(write_buf) + return len(write_buf) + + def deinit(self): + """Deinitialize I2S.""" + pass + + +class MockTimer: + """Mock machine.Timer for testing periodic callbacks.""" + + _all_timers = {} + + PERIODIC = 1 + ONE_SHOT = 0 + + def __init__(self, timer_id=-1): + self.timer_id = timer_id + self.callback = None + self.period = None + self.mode = None + self.active = False + if timer_id >= 0: + MockTimer._all_timers[timer_id] = self + + def init(self, period=None, mode=None, callback=None): + """Initialize/configure the timer.""" + self.period = period + self.mode = mode + self.callback = callback + self.active = True + + def deinit(self): + """Deinitialize the timer.""" + self.active = False + self.callback = None + + def trigger(self, *args, **kwargs): + """Manually trigger the timer callback (for testing).""" + if self.callback and self.active: + self.callback(*args, **kwargs) + + @classmethod + def get_timer(cls, timer_id): + """Get a timer by ID.""" + return cls._all_timers.get(timer_id) + + @classmethod + def trigger_all(cls): + """Trigger all active timers (for testing).""" + for timer in cls._all_timers.values(): + if timer.active: + timer.trigger() + + @classmethod + def reset_all(cls): + """Reset all timers (clear registry).""" + cls._all_timers.clear() + + +class MockMachine: + """ + Mock machine module containing all hardware mocks. + + Usage: + sys.modules['machine'] = MockMachine() + """ + + Pin = MockPin + PWM = MockPWM + I2S = MockI2S + Timer = MockTimer + + @staticmethod + def freq(freq=None): + """Get or set CPU frequency.""" + return 240000000 # 240 MHz + + @staticmethod + def reset(): + """Reset the device (no-op in mock).""" + pass + + @staticmethod + def soft_reset(): + """Soft reset the device (no-op in mock).""" + pass + + +# ============================================================================= +# MPOS Mocks - TaskManager +# ============================================================================= + +class MockTask: + """Mock asyncio Task for testing.""" + + def __init__(self): + self.ph_key = 0 + self._done = False + self.coro = None + self._result = None + self._exception = None + + def done(self): + """Check if task is done.""" + return self._done + + def cancel(self): + """Cancel the task.""" + self._done = True + + def result(self): + """Get task result.""" + if self._exception: + raise self._exception + return self._result + + +class MockTaskManager: + """ + Mock TaskManager for testing async operations. + + Usage: + mock_tm = create_mock_module('mpos.task_manager', TaskManager=MockTaskManager) + sys.modules['mpos.task_manager'] = mock_tm + """ + + task_list = [] + + @classmethod + def create_task(cls, coroutine): + """Create a mock task from a coroutine.""" + task = MockTask() + task.coro = coroutine + cls.task_list.append(task) + return task + + @staticmethod + async def sleep(seconds): + """Mock async sleep (no actual delay).""" + pass + + @staticmethod + async def sleep_ms(milliseconds): + """Mock async sleep in milliseconds (no actual delay).""" + pass + + @staticmethod + async def wait_for(awaitable, timeout): + """Mock wait_for with timeout.""" + return await awaitable + + @staticmethod + def notify_event(): + """Create a mock async event.""" + class MockEvent: + def __init__(self): + self._set = False + + async def wait(self): + pass + + def set(self): + self._set = True + + def is_set(self): + return self._set + + return MockEvent() + + @classmethod + def clear_tasks(cls): + """Clear all tracked tasks (for test cleanup).""" + cls.task_list = [] + + +# ============================================================================= +# Network Mocks +# ============================================================================= + +class MockNetwork: + """Mock network module for testing network connectivity.""" + + STA_IF = 0 + AP_IF = 1 + + class MockWLAN: + """Mock WLAN interface.""" + + def __init__(self, interface, connected=True): + self.interface = interface + self._connected = connected + self._active = True + self._config = {} + self._scan_results = [] + + def isconnected(self): + """Return whether the WLAN is connected.""" + return self._connected + + def active(self, is_active=None): + """Get/set whether the interface is active.""" + if is_active is None: + return self._active + self._active = is_active + + def connect(self, ssid, password): + """Simulate connecting to a network.""" + self._connected = True + self._config['ssid'] = ssid + + def disconnect(self): + """Simulate disconnecting from network.""" + self._connected = False + + def config(self, param): + """Get configuration parameter.""" + return self._config.get(param) + + def ifconfig(self): + """Get IP configuration.""" + if self._connected: + return ('192.168.1.100', '255.255.255.0', '192.168.1.1', '8.8.8.8') + return ('0.0.0.0', '0.0.0.0', '0.0.0.0', '0.0.0.0') + + def scan(self): + """Scan for available networks.""" + return self._scan_results + + def __init__(self, connected=True): + self._connected = connected + self._wlan_instances = {} + + def WLAN(self, interface): + """Create or return a WLAN interface.""" + if interface not in self._wlan_instances: + self._wlan_instances[interface] = self.MockWLAN(interface, self._connected) + return self._wlan_instances[interface] + + def set_connected(self, connected): + """Change the connection state of all WLAN interfaces.""" + self._connected = connected + for wlan in self._wlan_instances.values(): + wlan._connected = connected + + +class MockRaw: + """Mock raw HTTP response for streaming.""" + + def __init__(self, content, fail_after_bytes=None): + self.content = content + self.position = 0 + self.fail_after_bytes = fail_after_bytes + + def read(self, size): + """Read a chunk of data.""" + if self.fail_after_bytes is not None and self.position >= self.fail_after_bytes: + raise OSError(-113, "ECONNABORTED") + + chunk = self.content[self.position:self.position + size] + self.position += len(chunk) + return chunk + + +class MockResponse: + """Mock HTTP response.""" + + def __init__(self, status_code=200, text='', headers=None, content=b'', fail_after_bytes=None): + self.status_code = status_code + self.text = text + self.headers = headers or {} + self.content = content + self._closed = False + self.raw = MockRaw(content, fail_after_bytes=fail_after_bytes) + + def close(self): + """Close the response.""" + self._closed = True + + def json(self): + """Parse response as JSON.""" + import json + return json.loads(self.text) + + +class MockRequests: + """Mock requests module for testing HTTP operations.""" + + def __init__(self): + self.last_url = None + self.last_headers = None + self.last_timeout = None + self.last_stream = None + self.last_request = None + self.next_response = None + self.raise_exception = None + self.call_history = [] + + def get(self, url, stream=False, timeout=None, headers=None): + """Mock GET request.""" + self.last_url = url + self.last_headers = headers + self.last_timeout = timeout + self.last_stream = stream + + self.last_request = { + 'method': 'GET', + 'url': url, + 'stream': stream, + 'timeout': timeout, + 'headers': headers or {} + } + self.call_history.append(self.last_request.copy()) + + if self.raise_exception: + exc = self.raise_exception + self.raise_exception = None + raise exc + + if self.next_response: + response = self.next_response + self.next_response = None + return response + + return MockResponse() + + def post(self, url, data=None, json=None, timeout=None, headers=None): + """Mock POST request.""" + self.last_url = url + self.last_headers = headers + self.last_timeout = timeout + + self.call_history.append({ + 'method': 'POST', + 'url': url, + 'data': data, + 'json': json, + 'timeout': timeout, + 'headers': headers + }) + + if self.raise_exception: + exc = self.raise_exception + self.raise_exception = None + raise exc + + if self.next_response: + response = self.next_response + self.next_response = None + return response + + return MockResponse() + + def set_next_response(self, status_code=200, text='', headers=None, content=b'', fail_after_bytes=None): + """Configure the next response to return.""" + self.next_response = MockResponse(status_code, text, headers, content, fail_after_bytes=fail_after_bytes) + return self.next_response + + def set_exception(self, exception): + """Configure an exception to raise on the next request.""" + self.raise_exception = exception + + def clear_history(self): + """Clear the call history.""" + self.call_history = [] + + +class MockSocket: + """Mock socket for testing socket operations.""" + + AF_INET = 2 + SOCK_STREAM = 1 + + def __init__(self, af=None, sock_type=None): + self.af = af + self.sock_type = sock_type + self.connected = False + self.bound = False + self.listening = False + self.address = None + self._send_exception = None + self._recv_data = b'' + self._recv_position = 0 + + def connect(self, address): + """Simulate connecting to an address.""" + self.connected = True + self.address = address + + def bind(self, address): + """Simulate binding to an address.""" + self.bound = True + self.address = address + + def listen(self, backlog): + """Simulate listening for connections.""" + self.listening = True + + def send(self, data): + """Simulate sending data.""" + if self._send_exception: + exc = self._send_exception + self._send_exception = None + raise exc + return len(data) + + def recv(self, size): + """Simulate receiving data.""" + chunk = self._recv_data[self._recv_position:self._recv_position + size] + self._recv_position += len(chunk) + return chunk + + def close(self): + """Close the socket.""" + self.connected = False + + def set_send_exception(self, exception): + """Configure an exception to raise on next send().""" + self._send_exception = exception + + def set_recv_data(self, data): + """Configure data to return from recv().""" + self._recv_data = data + self._recv_position = 0 + + +# ============================================================================= +# Utility Mocks +# ============================================================================= + +class MockTime: + """Mock time module for testing time-dependent code.""" + + def __init__(self, start_time=0): + self._current_time_ms = start_time + self._sleep_calls = [] + + def ticks_ms(self): + """Get current time in milliseconds.""" + return self._current_time_ms + + def ticks_diff(self, ticks1, ticks2): + """Calculate difference between two tick values.""" + return ticks1 - ticks2 + + def sleep(self, seconds): + """Simulate sleep (doesn't actually sleep).""" + self._sleep_calls.append(seconds) + + def sleep_ms(self, milliseconds): + """Simulate sleep in milliseconds.""" + self._sleep_calls.append(milliseconds / 1000.0) + + def advance(self, milliseconds): + """Advance the mock time.""" + self._current_time_ms += milliseconds + + def get_sleep_calls(self): + """Get history of sleep calls.""" + return self._sleep_calls + + def clear_sleep_calls(self): + """Clear the sleep call history.""" + self._sleep_calls = [] + + +class MockJSON: + """Mock JSON module for testing JSON parsing.""" + + def __init__(self): + self.raise_exception = None + + def loads(self, text): + """Parse JSON string.""" + if self.raise_exception: + exc = self.raise_exception + self.raise_exception = None + raise exc + + import json + return json.loads(text) + + def dumps(self, obj): + """Serialize object to JSON string.""" + import json + return json.dumps(obj) + + def set_exception(self, exception): + """Configure an exception to raise on the next loads() call.""" + self.raise_exception = exception + + +class MockDownloadManager: + """Mock DownloadManager for testing async downloads.""" + + def __init__(self): + self.download_data = b'' + self.should_fail = False + self.fail_after_bytes = None + self.headers_received = None + self.url_received = None + self.call_history = [] + self.chunk_size = 1024 + self.simulated_speed_bps = 100 * 1024 + + async def download_url(self, url, outfile=None, total_size=None, + progress_callback=None, chunk_callback=None, headers=None, + speed_callback=None): + """Mock async download with flexible output modes.""" + self.url_received = url + self.headers_received = headers + + self.call_history.append({ + 'url': url, + 'outfile': outfile, + 'total_size': total_size, + 'headers': headers, + 'has_progress_callback': progress_callback is not None, + 'has_chunk_callback': chunk_callback is not None, + 'has_speed_callback': speed_callback is not None + }) + + if self.should_fail: + if outfile or chunk_callback: + return False + return None + + if self.fail_after_bytes is not None and self.fail_after_bytes == 0: + raise OSError(-113, "ECONNABORTED") + + bytes_sent = 0 + chunks = [] + total_data_size = len(self.download_data) + effective_total_size = total_size if total_size else total_data_size + last_progress_pct = -1.0 + bytes_since_speed_update = 0 + speed_update_threshold = 1000 + + while bytes_sent < total_data_size: + if self.fail_after_bytes is not None and bytes_sent >= self.fail_after_bytes: + raise OSError(-113, "ECONNABORTED") + + chunk = self.download_data[bytes_sent:bytes_sent + self.chunk_size] + + if chunk_callback: + await chunk_callback(chunk) + elif outfile: + pass + else: + chunks.append(chunk) + + bytes_sent += len(chunk) + bytes_since_speed_update += len(chunk) + + if progress_callback and effective_total_size > 0: + percent = round((bytes_sent * 100) / effective_total_size, 2) + if percent != last_progress_pct: + await progress_callback(percent) + last_progress_pct = percent + + if speed_callback and bytes_since_speed_update >= speed_update_threshold: + await speed_callback(self.simulated_speed_bps) + bytes_since_speed_update = 0 + + if outfile or chunk_callback: + return True + else: + return b''.join(chunks) + + def set_download_data(self, data): + """Configure the data to return from downloads.""" + self.download_data = data + + def set_should_fail(self, should_fail): + """Configure whether downloads should fail.""" + self.should_fail = should_fail + + def set_fail_after_bytes(self, bytes_count): + """Configure network failure after specified bytes.""" + self.fail_after_bytes = bytes_count + + def clear_history(self): + """Clear the call history.""" + self.call_history = [] + + +# ============================================================================= +# Threading Mocks +# ============================================================================= + +class MockThread: + """ + Mock _thread module for testing threaded operations. + + Usage: + sys.modules['_thread'] = MockThread + """ + + _started_threads = [] + _stack_size = 0 + + @classmethod + def start_new_thread(cls, func, args): + """Record thread start but don't actually start a thread.""" + cls._started_threads.append((func, args)) + return len(cls._started_threads) + + @classmethod + def stack_size(cls, size=None): + """Mock stack_size.""" + if size is not None: + cls._stack_size = size + return cls._stack_size + + @classmethod + def clear_threads(cls): + """Clear recorded threads (for test cleanup).""" + cls._started_threads = [] + + @classmethod + def get_started_threads(cls): + """Get list of started threads (for test assertions).""" + return cls._started_threads + + +class MockApps: + """ + Mock mpos.apps module for testing. + + Usage: + sys.modules['mpos.apps'] = MockApps + """ + + @staticmethod + def good_stack_size(): + """Return a reasonable stack size for testing.""" + return 8192 \ No newline at end of file diff --git a/internal_filesystem/lib/mpos/ui/__init__.py b/internal_filesystem/lib/mpos/ui/__init__.py index a5738a5c..0a7ce711 100644 --- a/internal_filesystem/lib/mpos/ui/__init__.py +++ b/internal_filesystem/lib/mpos/ui/__init__.py @@ -1,9 +1,9 @@ -# lib/mpos/ui/__init__.py from .view import ( - setContentView, back_screen, empty_screen_stack, - screen_stack, remove_and_stop_current_activity + setContentView, back_screen, + screen_stack, remove_and_stop_current_activity, remove_and_stop_all_activities ) from .gesture_navigation import handle_back_swipe, handle_top_swipe +from .theme import set_theme from .topmenu import open_bar, close_bar, open_drawer, drawer_open, NOTIFICATION_BAR_HEIGHT from .focus import save_and_clear_current_focusgroup from .display import ( @@ -13,11 +13,12 @@ get_pointer_xy # ← now correct ) from .event import get_event_name, print_event -from .util import shutdown, set_foreground_app, get_foreground_app, show_launcher +from .util import shutdown, set_foreground_app, get_foreground_app __all__ = [ - "setContentView", "back_screen", "empty_screen_stack", "remove_and_stop_current_activity" + "setContentView", "back_screen", "remove_and_stop_current_activity", "remove_and_stop_all_activities" "handle_back_swipe", "handle_top_swipe", + "set_theme", "open_bar", "close_bar", "open_drawer", "drawer_open", "NOTIFICATION_BAR_HEIGHT", "save_and_clear_current_focusgroup", "get_display_width", "get_display_height", @@ -25,5 +26,5 @@ "min_resolution", "max_resolution", "get_pointer_xy", "get_event_name", "print_event", - "shutdown", "set_foreground_app", "get_foreground_app", "show_launcher" + "shutdown", "set_foreground_app", "get_foreground_app" ] diff --git a/internal_filesystem/lib/mpos/ui/anim.py b/internal_filesystem/lib/mpos/ui/anim.py index abe78f48..1f8310ac 100644 --- a/internal_filesystem/lib/mpos/ui/anim.py +++ b/internal_filesystem/lib/mpos/ui/anim.py @@ -1,5 +1,31 @@ import lvgl as lv + +def safe_widget_access(callback): + """ + Wrapper to safely access a widget, catching LvReferenceError. + + If the widget has been deleted, the callback is silently skipped. + This prevents crashes when animations try to access deleted widgets. + + Args: + callback: Function to call (should access a widget) + + Returns: + None (always, even if callback returns a value) + """ + try: + callback() + except Exception as e: + # Check if it's an LvReferenceError (widget was deleted) + if "LvReferenceError" in str(type(e).__name__) or "Referenced object was deleted" in str(e): + # Widget was deleted - silently ignore + pass + else: + # Some other error - re-raise it + raise + + class WidgetAnimator: # def __init__(self): @@ -15,106 +41,86 @@ class WidgetAnimator: # show_widget and hide_widget could have a (lambda) callback that sets the final state (eg: drawer_open) at the end @staticmethod def show_widget(widget, anim_type="fade", duration=500, delay=0): - """Show a widget with an animation (fade or slide).""" - # Clear HIDDEN flag to make widget visible for animation - widget.remove_flag(lv.obj.FLAG.HIDDEN) + lv.anim_delete(widget, None) # stop all ongoing animations to prevent visual glitches + anim = lv.anim_t() + anim.init() + anim.set_var(widget) + anim.set_delay(delay) + anim.set_duration(duration) + # Clear HIDDEN flag to make widget visible for animation: + anim.set_start_cb(lambda *args: safe_widget_access(lambda: widget.remove_flag(lv.obj.FLAG.HIDDEN))) if anim_type == "fade": # Create fade-in animation (opacity from 0 to 255) - anim = lv.anim_t() - anim.init() - anim.set_var(widget) anim.set_values(0, 255) - anim.set_duration(duration) - anim.set_delay(delay) - anim.set_custom_exec_cb(lambda anim, value: widget.set_style_opa(value, 0)) + anim.set_custom_exec_cb(lambda anim, value: safe_widget_access(lambda: widget.set_style_opa(value, 0))) anim.set_path_cb(lv.anim_t.path_ease_in_out) # Ensure opacity is reset after animation - anim.set_completed_cb(lambda *args: widget.set_style_opa(255, 0)) + anim.set_completed_cb(lambda *args: safe_widget_access(lambda: widget.set_style_opa(255, 0))) elif anim_type == "slide_down": print("doing slide_down") # Create slide-down animation (y from -height to original y) original_y = widget.get_y() height = widget.get_height() - anim = lv.anim_t() - anim.init() - anim.set_var(widget) anim.set_values(original_y - height, original_y) - anim.set_duration(duration) - anim.set_delay(delay) - anim.set_custom_exec_cb(lambda anim, value: widget.set_y(value)) + anim.set_custom_exec_cb(lambda anim, value: safe_widget_access(lambda: widget.set_y(value))) anim.set_path_cb(lv.anim_t.path_ease_in_out) # Reset y position after animation - anim.set_completed_cb(lambda *args: widget.set_y(original_y)) - elif anim_type == "slide_up": + anim.set_completed_cb(lambda *args: safe_widget_access(lambda: widget.set_y(original_y))) + else: # "slide_up": # Create slide-up animation (y from +height to original y) # Seems to cause scroll bars to be added somehow if done to a keyboard at the bottom of the screen... original_y = widget.get_y() height = widget.get_height() - anim = lv.anim_t() - anim.init() - anim.set_var(widget) anim.set_values(original_y + height, original_y) - anim.set_duration(duration) - anim.set_delay(delay) - anim.set_custom_exec_cb(lambda anim, value: widget.set_y(value)) + anim.set_custom_exec_cb(lambda anim, value: safe_widget_access(lambda: widget.set_y(value))) anim.set_path_cb(lv.anim_t.path_ease_in_out) # Reset y position after animation - anim.set_completed_cb(lambda *args: widget.set_y(original_y)) + anim.set_completed_cb(lambda *args: safe_widget_access(lambda: widget.set_y(original_y))) - # Store and start animation - #self.animations[widget] = anim anim.start() + return anim @staticmethod def hide_widget(widget, anim_type="fade", duration=500, delay=0, hide=True): + lv.anim_delete(widget, None) # stop all ongoing animations to prevent visual glitches + anim = lv.anim_t() + anim.init() + anim.set_var(widget) + anim.set_duration(duration) + anim.set_delay(delay) + """Hide a widget with an animation (fade or slide).""" if anim_type == "fade": # Create fade-out animation (opacity from 255 to 0) - anim = lv.anim_t() - anim.init() - anim.set_var(widget) anim.set_values(255, 0) - anim.set_duration(duration) - anim.set_delay(delay) - anim.set_custom_exec_cb(lambda anim, value: widget.set_style_opa(value, 0)) + anim.set_custom_exec_cb(lambda anim, value: safe_widget_access(lambda: widget.set_style_opa(value, 0))) anim.set_path_cb(lv.anim_t.path_ease_in_out) # Set HIDDEN flag after animation - anim.set_completed_cb(lambda *args: WidgetAnimator.hide_complete_cb(widget, hide=hide)) + anim.set_completed_cb(lambda *args: safe_widget_access(lambda: WidgetAnimator.hide_complete_cb(widget, hide=hide))) elif anim_type == "slide_down": # Create slide-down animation (y from original y to +height) # Seems to cause scroll bars to be added somehow if done to a keyboard at the bottom of the screen... original_y = widget.get_y() height = widget.get_height() - anim = lv.anim_t() - anim.init() - anim.set_var(widget) anim.set_values(original_y, original_y + height) - anim.set_duration(duration) - anim.set_delay(delay) - anim.set_custom_exec_cb(lambda anim, value: widget.set_y(value)) + anim.set_custom_exec_cb(lambda anim, value: safe_widget_access(lambda: widget.set_y(value))) anim.set_path_cb(lv.anim_t.path_ease_in_out) # Set HIDDEN flag after animation - anim.set_completed_cb(lambda *args: WidgetAnimator.hide_complete_cb(widget, original_y, hide)) - elif anim_type == "slide_up": + anim.set_completed_cb(lambda *args: safe_widget_access(lambda: WidgetAnimator.hide_complete_cb(widget, original_y, hide))) + else: # "slide_up": print("hide with slide_up") # Create slide-up animation (y from original y to -height) original_y = widget.get_y() height = widget.get_height() - anim = lv.anim_t() - anim.init() - anim.set_var(widget) anim.set_values(original_y, original_y - height) - anim.set_duration(duration) - anim.set_delay(delay) - anim.set_custom_exec_cb(lambda anim, value: widget.set_y(value)) + anim.set_custom_exec_cb(lambda anim, value: safe_widget_access(lambda: widget.set_y(value))) anim.set_path_cb(lv.anim_t.path_ease_in_out) # Set HIDDEN flag after animation - anim.set_completed_cb(lambda *args: WidgetAnimator.hide_complete_cb(widget, original_y, hide)) + anim.set_completed_cb(lambda *args: safe_widget_access(lambda: WidgetAnimator.hide_complete_cb(widget, original_y, hide))) - # Store and start animation - #self.animations[widget] = anim anim.start() + return anim @staticmethod def hide_complete_cb(widget, original_y=None, hide=True): @@ -125,8 +131,8 @@ def hide_complete_cb(widget, original_y=None, hide=True): widget.set_y(original_y) # in case it shifted slightly due to rounding etc -def smooth_show(widget): - WidgetAnimator.show_widget(widget, anim_type="fade", duration=500, delay=0) +def smooth_show(widget, duration=500, delay=0): + return WidgetAnimator.show_widget(widget, anim_type="fade", duration=duration, delay=delay) -def smooth_hide(widget, hide=True): - WidgetAnimator.hide_widget(widget, anim_type="fade", duration=500, delay=0, hide=hide) +def smooth_hide(widget, hide=True, duration=500, delay=0): + return WidgetAnimator.hide_widget(widget, anim_type="fade", duration=duration, delay=delay, hide=hide) diff --git a/internal_filesystem/lib/mpos/ui/display.py b/internal_filesystem/lib/mpos/ui/display.py index e148dd9b..991e1657 100644 --- a/internal_filesystem/lib/mpos/ui/display.py +++ b/internal_filesystem/lib/mpos/ui/display.py @@ -11,19 +11,6 @@ def init_rootscreen(): _horizontal_resolution = disp.get_horizontal_resolution() _vertical_resolution = disp.get_vertical_resolution() print(f"init_rootscreen set _vertical_resolution to {_vertical_resolution}") - - style = lv.style_t() - style.init() - style.set_bg_opa(lv.OPA.TRANSP) - style.set_border_width(0) - style.set_outline_width(0) - style.set_shadow_width(0) - style.set_pad_all(0) - style.set_radius(0) - screen.add_style(style, 0) - screen.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) - screen.set_scroll_dir(lv.DIR.NONE) - label = lv.label(screen) label.set_text("Welcome to MicroPythonOS") label.center() @@ -37,9 +24,13 @@ def get_pointer_xy(): return -1, -1 def pct_of_display_width(pct): + if pct == 100: + return _horizontal_resolution return round(_horizontal_resolution * pct / 100) def pct_of_display_height(pct): + if pct == 100: + return _vertical_resolution return round(_vertical_resolution * pct / 100) def min_resolution(): diff --git a/internal_filesystem/lib/mpos/ui/focus_direction.py b/internal_filesystem/lib/mpos/ui/focus_direction.py index 96490fa7..f99a00a0 100644 --- a/internal_filesystem/lib/mpos/ui/focus_direction.py +++ b/internal_filesystem/lib/mpos/ui/focus_direction.py @@ -141,15 +141,7 @@ def process_object(obj, depth=0): for objnr in range(focus_group.get_obj_count()): obj = focus_group.get_obj_by_index(objnr) process_object(obj) - - # Result - if closest_obj: - print(f"Closest object in direction {direction_degrees}°:") - mpos.util.print_lvgl_widget(closest_obj) - else: - #print(f"No object found in direction {direction_degrees}°") - pass - + return closest_obj @@ -198,11 +190,17 @@ def move_focus_direction(angle): if isinstance(current_focused, lv.keyboard): print("focus is on a keyboard, which has its own move_focus_direction: NOT moving") return + if False and isinstance(current_focused, lv.checkbox): # arrow up/down or left/right is the toggle + print("focus is on a checkbox, which has its own move_focus_direction: NOT moving") + return + if False and isinstance(current_focused, lv.slider): # arrows change the slider + print("focus is on a slider, which has its own move_focus_direction: NOT moving") + return if isinstance(current_focused, lv.dropdown) and current_focused.is_open(): print("focus is on an open dropdown, which has its own move_focus_direction: NOT moving") return o = find_closest_obj_in_direction(focus_group, current_focused, angle, False) if o: - print("move_focus_direction: moving focus to:") - mpos.util.print_lvgl_widget(o) + #print("move_focus_direction: moving focus to:") + #mpos.util.print_lvgl_widget(o) emulate_focus_obj(focus_group, o) diff --git a/internal_filesystem/lib/mpos/ui/gesture_navigation.py b/internal_filesystem/lib/mpos/ui/gesture_navigation.py index 456a5958..df95f6ed 100644 --- a/internal_filesystem/lib/mpos/ui/gesture_navigation.py +++ b/internal_filesystem/lib/mpos/ui/gesture_navigation.py @@ -1,22 +1,37 @@ import lvgl as lv +from lvgl import LvReferenceError from .anim import smooth_show, smooth_hide from .view import back_screen -from .topmenu import open_drawer, drawer_open, NOTIFICATION_BAR_HEIGHT +from mpos.ui import topmenu as topmenu from .display import get_display_width, get_display_height downbutton = None backbutton = None down_start_x = 0 +down_start_y = 0 back_start_y = 0 +back_start_x = 0 +short_movement_threshold = 10 +backbutton_visible = False +downbutton_visible = False +def is_short_movement(dx, dy): + return dx < short_movement_threshold and dy < short_movement_threshold -# Would be better to somehow save other events, like clicks, and pass them down to the layers below if released with x < 60 -def _back_swipe_cb(event): - if drawer_open: - print("ignoring back gesture because drawer is open") - return +def _passthrough_click(x, y, indev): + obj = lv.indev_search_obj(lv.screen_active(), lv.point_t({'x': x, 'y': y})) + # print(f"Found object: {obj}") + if obj: + try: + # print(f"Simulating press/click/release on {obj}") + obj.send_event(lv.EVENT.PRESSED, indev) + obj.send_event(lv.EVENT.CLICKED, indev) + obj.send_event(lv.EVENT.RELEASED, indev) # gets lost + except LvReferenceError as e: + print(f"Object to click is gone: {e}") - global backbutton, back_start_y +def _back_swipe_cb(event): + global backbutton, back_start_y, back_start_x, backbutton_visible event_code = event.get_code() indev = lv.indev_active() if not indev: @@ -25,26 +40,37 @@ def _back_swipe_cb(event): indev.get_point(point) x = point.x y = point.y + dx = abs(x - back_start_x) + dy = abs(y - back_start_y) #print(f"visual_back_swipe_cb event_code={event_code} and event_name={name} and pos: {x}, {y}") if event_code == lv.EVENT.PRESSED: - smooth_show(backbutton) back_start_y = y + back_start_x = x elif event_code == lv.EVENT.PRESSING: - magnetic_x = round(x / 10) - backbutton.set_pos(magnetic_x,back_start_y) + should_show = not is_short_movement(dx, dy) + if should_show != backbutton_visible: + backbutton_visible = should_show + smooth_show(backbutton) if should_show else smooth_hide(backbutton) + backbutton.set_pos(round(x / 10), back_start_y) elif event_code == lv.EVENT.RELEASED: - smooth_hide(backbutton) - if x > min(100, get_display_width() / 3): - back_screen() + if backbutton_visible: + backbutton_visible = False + smooth_hide(backbutton) + if x > get_display_width() / 5: + if topmenu.drawer_open : + topmenu.close_drawer() + else : + back_screen() + elif is_short_movement(dx, dy): + # print("Short movement - treating as tap") + _passthrough_click(x, y, indev) - -# Would be better to somehow save other events, like clicks, and pass them down to the layers below if released with x < 60 def _top_swipe_cb(event): - if drawer_open: + if topmenu.drawer_open: print("ignoring top swipe gesture because drawer is open") return - global downbutton, down_start_x + global downbutton, down_start_x, down_start_y, downbutton_visible event_code = event.get_code() indev = lv.indev_active() if not indev: @@ -53,43 +79,51 @@ def _top_swipe_cb(event): indev.get_point(point) x = point.x y = point.y - #print(f"visual_back_swipe_cb event_code={event_code} and event_name={name} and pos: {x}, {y}") + dx = abs(x - down_start_x) + dy = abs(y - down_start_y) + # print(f"visual_back_swipe_cb event_code={event_code} and event_name={name} and pos: {x}, {y}") if event_code == lv.EVENT.PRESSED: - smooth_show(downbutton) down_start_x = x + down_start_y = y elif event_code == lv.EVENT.PRESSING: - magnetic_y = round(y/ 10) - downbutton.set_pos(down_start_x,magnetic_y) + should_show = not is_short_movement(dx, dy) + if should_show != downbutton_visible: + downbutton_visible = should_show + smooth_show(downbutton) if should_show else smooth_hide(downbutton) + downbutton.set_pos(down_start_x, round(y / 10)) elif event_code == lv.EVENT.RELEASED: - smooth_hide(downbutton) - if y > min(80, get_display_height() / 3): - open_drawer() - + if downbutton_visible: + downbutton_visible = False + smooth_hide(downbutton) + dx = abs(x - down_start_x) + dy = abs(y - down_start_y) + if y > get_display_height() / 5: + topmenu.open_drawer() + elif is_short_movement(dx, dy): + # print("Short movement - treating as tap") + _passthrough_click(x, y, indev) def handle_back_swipe(): global backbutton rect = lv.obj(lv.layer_top()) - rect.set_size(round(NOTIFICATION_BAR_HEIGHT/2), lv.layer_top().get_height()-NOTIFICATION_BAR_HEIGHT) # narrow because it overlaps buttons + rect.set_size(topmenu.NOTIFICATION_BAR_HEIGHT, lv.layer_top().get_height()-topmenu.NOTIFICATION_BAR_HEIGHT) # narrow because it overlaps buttons rect.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) rect.set_scroll_dir(lv.DIR.NONE) - rect.set_pos(0, NOTIFICATION_BAR_HEIGHT) + rect.set_pos(0, topmenu.NOTIFICATION_BAR_HEIGHT) style = lv.style_t() style.init() style.set_bg_opa(lv.OPA.TRANSP) style.set_border_width(0) style.set_radius(0) - if False: # debug the back swipe zone with a red border + if False: # debug the swipe zone with a red border style.set_bg_opa(15) style.set_border_width(4) style.set_border_color(lv.color_hex(0xFF0000)) # Red border for visibility style.set_border_opa(lv.OPA._50) # 50% opacity for the border rect.add_style(style, 0) - #rect.add_flag(lv.obj.FLAG.CLICKABLE) # Make the object clickable - #rect.add_flag(lv.obj.FLAG.GESTURE_BUBBLE) # Allow dragging rect.add_event_cb(_back_swipe_cb, lv.EVENT.PRESSED, None) rect.add_event_cb(_back_swipe_cb, lv.EVENT.PRESSING, None) rect.add_event_cb(_back_swipe_cb, lv.EVENT.RELEASED, None) - #rect.add_event_cb(back_swipe_cb, lv.EVENT.ALL, None) # button with label that shows up during the dragging: backbutton = lv.button(lv.layer_top()) backbutton.set_pos(0, round(lv.layer_top().get_height() / 2)) @@ -103,20 +137,20 @@ def handle_back_swipe(): def handle_top_swipe(): global downbutton rect = lv.obj(lv.layer_top()) - rect.set_size(lv.pct(100), round(NOTIFICATION_BAR_HEIGHT*2/3)) + rect.set_size(lv.pct(100), topmenu.NOTIFICATION_BAR_HEIGHT) rect.set_pos(0, 0) rect.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) style = lv.style_t() style.init() style.set_bg_opa(lv.OPA.TRANSP) - #style.set_bg_opa(15) style.set_border_width(0) style.set_radius(0) - #style.set_border_color(lv.color_hex(0xFF0000)) # White border for visibility - #style.set_border_opa(lv.OPA._50) # 50% opacity for the border + if False: # debug the swipe zone with a red border + style.set_bg_opa(15) + style.set_border_width(4) + style.set_border_color(lv.color_hex(0xFF0000)) # Red border for visibility + style.set_border_opa(lv.OPA._50) # 50% opacity for the border rect.add_style(style, 0) - #rect.add_flag(lv.obj.FLAG.CLICKABLE) # Make the object clickable - #rect.add_flag(lv.obj.FLAG.GESTURE_BUBBLE) # Allow dragging rect.add_event_cb(_top_swipe_cb, lv.EVENT.PRESSED, None) rect.add_event_cb(_top_swipe_cb, lv.EVENT.PRESSING, None) rect.add_event_cb(_top_swipe_cb, lv.EVENT.RELEASED, None) diff --git a/internal_filesystem/lib/mpos/ui/keyboard.py b/internal_filesystem/lib/mpos/ui/keyboard.py new file mode 100644 index 00000000..50164b4b --- /dev/null +++ b/internal_filesystem/lib/mpos/ui/keyboard.py @@ -0,0 +1,257 @@ +""" +Custom keyboard for MicroPythonOS. + +This module provides an enhanced on-screen keyboard with better layout, +more characters (including emoticons), and improved usability compared +to the default LVGL keyboard. + +Usage: + from mpos.ui.keyboard import MposKeyboard + + # Create keyboard + keyboard = MposKeyboard(parent_obj) + keyboard.set_textarea(my_textarea) + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + +""" + +import lvgl as lv +import mpos.ui.theme + +class MposKeyboard: + """ + Enhanced keyboard widget with multiple layouts and emoticons. + + Features: + - Lowercase and uppercase letter modes + - Numbers and special characters + - Additional special characters with emoticons + - Automatic mode switching + - Compatible with LVGL keyboard API + """ + + # Keyboard layout labels + LABEL_NUMBERS_SPECIALS = "?123" + LABEL_SPECIALS = "=\<" + LABEL_LETTERS = "Abc" + LABEL_SPACE = " " + + # Keyboard modes - use USER modes for our API + # We'll also register to standard modes to catch LVGL's internal switches + MODE_LOWERCASE = lv.keyboard.MODE.USER_1 + MODE_UPPERCASE = lv.keyboard.MODE.USER_2 + MODE_NUMBERS = lv.keyboard.MODE.USER_3 + MODE_SPECIALS = lv.keyboard.MODE.USER_4 + + # Lowercase letters + _lowercase_map = [ + "q", "w", "e", "r", "t", "y", "u", "i", "o", "p", "\n", + "a", "s", "d", "f", "g", "h", "j", "k", "l", "\n", + lv.SYMBOL.UP, "z", "x", "c", "v", "b", "n", "m", lv.SYMBOL.BACKSPACE, "\n", + LABEL_NUMBERS_SPECIALS, ",", LABEL_SPACE, ".", lv.SYMBOL.OK, None + ] + _lowercase_ctrl = [lv.buttonmatrix.CTRL.WIDTH_10] * len(_lowercase_map) + _lowercase_ctrl[29] = lv.buttonmatrix.CTRL.WIDTH_5 # comma + _lowercase_ctrl[30] = lv.buttonmatrix.CTRL.WIDTH_15 # space + _lowercase_ctrl[31] = lv.buttonmatrix.CTRL.WIDTH_5 # dot + + # Uppercase letters + _uppercase_map = [ + "Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P", "\n", + "A", "S", "D", "F", "G", "H", "J", "K", "L", "\n", + lv.SYMBOL.DOWN, "Z", "X", "C", "V", "B", "N", "M", lv.SYMBOL.BACKSPACE, "\n", + LABEL_NUMBERS_SPECIALS, ",", LABEL_SPACE, ".", lv.SYMBOL.OK, None + ] + _uppercase_ctrl = [lv.buttonmatrix.CTRL.WIDTH_10] * len(_uppercase_map) + _uppercase_ctrl[29] = lv.buttonmatrix.CTRL.WIDTH_5 # comma + _uppercase_ctrl[30] = lv.buttonmatrix.CTRL.WIDTH_15 # space + _uppercase_ctrl[31] = lv.buttonmatrix.CTRL.WIDTH_5 # dot + + # Numbers and common special characters + _numbers_map = [ + "1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "\n", + "@", "#", "$", "_", "&", "-", "+", "(", ")", "/", "\n", + LABEL_SPECIALS, "*", "\"", "'", ":", ";", "!", "?", lv.SYMBOL.BACKSPACE, "\n", + LABEL_LETTERS, ",", LABEL_SPACE, ".", lv.SYMBOL.OK, None + ] + _numbers_ctrl = [lv.buttonmatrix.CTRL.WIDTH_10] * len(_numbers_map) + _numbers_ctrl[30] = lv.buttonmatrix.CTRL.WIDTH_5 # comma + _numbers_ctrl[31] = lv.buttonmatrix.CTRL.WIDTH_15 # space + _numbers_ctrl[32] = lv.buttonmatrix.CTRL.WIDTH_5 # dot + + # Additional special characters with emoticons + _specials_map = [ + "~", "`", "|", "•", ":-)", ";-)", ":-D", "\n", + ":-(" , ":'-(", "^", "°", "=", "{", "}", "\\", "\n", + LABEL_NUMBERS_SPECIALS, "%", ":-o", ":-P", "[", "]", lv.SYMBOL.BACKSPACE, "\n", + LABEL_LETTERS, "<", LABEL_SPACE, ">", lv.SYMBOL.OK, None + ] + _specials_ctrl = [lv.buttonmatrix.CTRL.WIDTH_10] * len(_specials_map) + _specials_ctrl[15] = lv.buttonmatrix.CTRL.WIDTH_15 # LABEL_NUMBERS_SPECIALS is pretty wide + _specials_ctrl[23] = lv.buttonmatrix.CTRL.WIDTH_5 # < + _specials_ctrl[24] = lv.buttonmatrix.CTRL.WIDTH_15 # space + _specials_ctrl[25] = lv.buttonmatrix.CTRL.WIDTH_5 # > + + # Map modes to their layouts + mode_info = { + MODE_LOWERCASE: (_lowercase_map, _lowercase_ctrl), + MODE_UPPERCASE: (_uppercase_map, _uppercase_ctrl), + MODE_NUMBERS: (_numbers_map, _numbers_ctrl), + MODE_SPECIALS: (_specials_map, _specials_ctrl), + } + + _current_mode = None + + def __init__(self, parent): + # Create underlying LVGL keyboard widget + self._keyboard = lv.keyboard(parent) + # self._keyboard.set_popovers(True) # disabled for now because they're quite ugly on LVGL 9.3 - maybe better on 9.4? + self._keyboard.set_style_text_font(lv.font_montserrat_20,0) + + # Store textarea reference (we DON'T pass it to LVGL to avoid double-typing) + self._textarea = None + + self.set_mode(self.MODE_LOWERCASE) + + # Remove default event handler(s) + for index in range(self._keyboard.get_event_count()): + self._keyboard.remove_event(index) + self._keyboard.add_event_cb(self._handle_events, lv.EVENT.ALL, None) + + # Apply theme fix for light mode visibility + mpos.ui.theme.fix_keyboard_button_style(self._keyboard) + + # Set good default height + self._keyboard.set_style_min_height(175, 0) + + def _handle_events(self, event): + code = event.get_code() + #print(f"keyboard event code = {code}") + if code == lv.EVENT.READY or code == lv.EVENT.CANCEL: + self.hide_keyboard() + return + # Process VALUE_CHANGED events for actual typing + if code != lv.EVENT.VALUE_CHANGED: + return + + # Get the pressed button and its text + target_obj=event.get_target_obj() # keyboard + if not target_obj: + return + button = target_obj.get_selected_button() + if button is None: + return + text = target_obj.get_button_text(button) + #print(f"[KBD] btn={button}, mode={self._current_mode}, text='{text}'") + + # Ignore if no valid button text (can happen during mode switching) + if text is None: + return + + # Get current textarea content (from our own reference, not LVGL's) + ta = self._textarea + if not ta: + return + + current_text = ta.get_text() + new_text = current_text + + # Handle special keys + if text == lv.SYMBOL.BACKSPACE: + # Delete last character + new_text = current_text[:-1] + elif text == lv.SYMBOL.UP: + # Switch to uppercase + self.set_mode(self.MODE_UPPERCASE) + return # Don't modify text + elif text == lv.SYMBOL.DOWN or text == self.LABEL_LETTERS: + # Switch to lowercase + self.set_mode(self.MODE_LOWERCASE) + return # Don't modify text + elif text == self.LABEL_NUMBERS_SPECIALS: + # Switch to numbers/specials + self.set_mode(self.MODE_NUMBERS) + return # Don't modify text + elif text == self.LABEL_SPECIALS: + # Switch to additional specials + self.set_mode(self.MODE_SPECIALS) + return # Don't modify text + elif text == self.LABEL_SPACE: + # Space bar + new_text = current_text + " " + elif text == lv.SYMBOL.OK: + self._keyboard.send_event(lv.EVENT.READY, None) + return + elif text == lv.SYMBOL.NEW_LINE: + # Handle newline (only for multi-line textareas) + if ta.get_one_line(): + # For single-line, trigger READY event + self._keyboard.send_event(lv.EVENT.READY, None) + return + else: + new_text = current_text + "\n" + else: + # Regular character + new_text = current_text + text + + # Update textarea + ta.set_text(new_text) + + def set_textarea(self, textarea): + """ + Set the textarea that this keyboard types into. + + IMPORTANT: We store the textarea reference ourselves and DON'T pass + it to the underlying LVGL keyboard. This prevents LVGL's built-in + automatic character insertion, which would cause double-character bugs + (LVGL inserts + our handler inserts = double characters). + + Args: + textarea: The lv.textarea widget to type into, or None to disconnect + """ + self._textarea = textarea + # NOTE: We deliberately DO NOT call self._keyboard.set_textarea() + # to avoid LVGL's automatic character insertion + self._textarea.add_event_cb(lambda *args: self.show_keyboard(), lv.EVENT.CLICKED, None) + + def get_textarea(self): + """ + Get the textarea that this keyboard types into. + + Returns: + The lv.textarea widget, or None if not connected + """ + return self._textarea + + def set_mode(self, mode): + #print(f"[kbc] setting mode to {mode}") + self._current_mode = mode + key_map, ctrl_map = self.mode_info[mode] + self._keyboard.set_map(mode, key_map, ctrl_map) + self._keyboard.set_mode(mode) + + + # Python magic method for automatic method forwarding + def __getattr__(self, name): + #print(f"[kbd] __getattr__ {name}") + """ + Forward any undefined method/attribute to the underlying LVGL keyboard. + + This allows MposKeyboard to support ALL lv.keyboard methods automatically + without needing to manually wrap each one. Any method not defined on + MposKeyboard will be forwarded to self._keyboard. + + Examples: + keyboard.set_textarea(ta) # Works + keyboard.align(lv.ALIGN.CENTER) # Works + keyboard.set_style_opa(128, 0) # Works + keyboard.any_lvgl_method() # Works! + """ + # Forward to the underlying keyboard object + return getattr(self._keyboard, name) + + def show_keyboard(self): + mpos.ui.anim.smooth_show(self._keyboard) + + def hide_keyboard(self): + mpos.ui.anim.smooth_hide(self._keyboard) diff --git a/internal_filesystem/lib/mpos/ui/testing.py b/internal_filesystem/lib/mpos/ui/testing.py new file mode 100644 index 00000000..1f660b2e --- /dev/null +++ b/internal_filesystem/lib/mpos/ui/testing.py @@ -0,0 +1,776 @@ +""" +Graphical testing utilities for MicroPythonOS. + +This module provides utilities for graphical/visual testing and UI automation +that work on both desktop (unix/macOS) and device (ESP32). These functions can +be used by: +- Unit tests for verifying UI behavior +- Apps that want to implement automation or testing features +- Integration tests and end-to-end testing + +Important: Functions in this module assume the display, theme, and UI +infrastructure are already initialized (boot.py and main.py executed). + +Usage in tests: + from mpos.ui.testing import wait_for_render, capture_screenshot + + # Start your app + mpos.apps.start_app("com.example.myapp") + + # Wait for UI to render + wait_for_render() + + # Verify content + assert verify_text_present(lv.screen_active(), "Expected Text") + + # Capture screenshot + capture_screenshot("tests/screenshots/mytest.raw") + + # Simulate user interaction + simulate_click(160, 120) # Click at center of 320x240 screen + +Usage in apps: + from mpos.ui.testing import simulate_click, find_label_with_text + + # Automated demo mode + label = find_label_with_text(self.screen, "Start") + if label: + area = lv.area_t() + label.get_coords(area) + simulate_click(area.x1 + 10, area.y1 + 10) +""" + +import lvgl as lv +import time + +# Simulation globals for touch input +_touch_x = 0 +_touch_y = 0 +_touch_pressed = False +_touch_indev = None + + +def wait_for_render(iterations=10): + """ + Wait for LVGL to process UI events and render. + + This processes the LVGL task handler multiple times to ensure + all UI updates, animations, and layout changes are complete. + Essential for tests to avoid race conditions. + + Args: + iterations: Number of task handler iterations to run (default: 10) + + Example: + mpos.apps.start_app("com.example.myapp") + wait_for_render() # Ensure UI is ready + assert verify_text_present(lv.screen_active(), "Welcome") + """ + import time + for _ in range(iterations): + lv.task_handler() + time.sleep(0.01) # Small delay between iterations + + +def capture_screenshot(filepath, width=320, height=240, color_format=lv.COLOR_FORMAT.RGB565): + """ + Capture screenshot of current screen using LVGL snapshot. + + The screenshot is saved as raw binary data in the specified color format. + Useful for visual regression testing or documentation. + + To convert RGB565 to PNG: + ffmpeg -vcodec rawvideo -f rawvideo -pix_fmt rgb565 -s 320x240 -i file.raw file.png + + Or use the conversion script: + cd tests/screenshots + ./convert_to_png.sh + + Args: + filepath: Path where to save the raw screenshot data + width: Screen width in pixels (default: 320) + height: Screen height in pixels (default: 240) + color_format: LVGL color format (default: RGB565 for memory efficiency) + + Returns: + bytearray: The screenshot buffer + + Raises: + Exception: If screenshot capture fails + + Example: + from mpos.ui.testing import capture_screenshot + capture_screenshot("tests/screenshots/home.raw") + """ + print(f"capture_screenshot writing to {filepath}") + + # Calculate buffer size based on color format + if color_format == lv.COLOR_FORMAT.RGB565: + bytes_per_pixel = 2 + elif color_format == lv.COLOR_FORMAT.RGB888: + bytes_per_pixel = 3 + else: + bytes_per_pixel = 4 # ARGB8888 + + size = width * height * bytes_per_pixel + buffer = bytearray(size) + image_dsc = lv.image_dsc_t() + + # Take snapshot of active screen + lv.snapshot_take_to_buf(lv.screen_active(), color_format, image_dsc, buffer, size) + + # Save to file + with open(filepath, "wb") as f: + f.write(buffer) + + return buffer + + +def get_all_widgets_with_text(obj, widgets=None): + """ + Recursively find all widgets that have text in the object hierarchy. + + This traverses the entire widget tree starting from obj and + collects all widgets that have a get_text() method and return + non-empty text. This includes labels, checkboxes, buttons with + text, etc. + + Args: + obj: LVGL object to search (typically lv.screen_active()) + widgets: Internal accumulator list (leave as None) + + Returns: + list: List of all widgets with text found in the hierarchy + + Example: + widgets = get_all_widgets_with_text(lv.screen_active()) + print(f"Found {len(widgets)} widgets with text") + """ + if widgets is None: + widgets = [] + + # Check if this object has text + try: + if hasattr(obj, 'get_text'): + text = obj.get_text() + if text: # Only add if text is non-empty + widgets.append(obj) + except: + pass # Error getting text or no get_text method + + # Recursively check children + try: + child_count = obj.get_child_count() + for i in range(child_count): + child = obj.get_child(i) + get_all_widgets_with_text(child, widgets) + except: + pass # No children or error accessing them + + return widgets + + +def get_all_labels(obj, labels=None): + """ + Recursively find all label widgets in the object hierarchy. + + DEPRECATED: Use get_all_widgets_with_text() instead for better + compatibility with all text-containing widgets (labels, checkboxes, etc.) + + This traverses the entire widget tree starting from obj and + collects all LVGL label objects. Useful for comprehensive + text verification or debugging. + + Args: + obj: LVGL object to search (typically lv.screen_active()) + labels: Internal accumulator list (leave as None) + + Returns: + list: List of all label objects found in the hierarchy + + Example: + labels = get_all_labels(lv.screen_active()) + print(f"Found {len(labels)} labels") + """ + # For backwards compatibility, use the new function + return get_all_widgets_with_text(obj, labels) + + +def find_label_with_text(obj, search_text): + """ + Find a widget containing specific text. + + Searches the entire widget hierarchy for any widget (label, checkbox, + button, etc.) whose text contains the search string (substring match). + Returns the first match found. + + Args: + obj: LVGL object to search (typically lv.screen_active()) + search_text: Text to search for (can be substring) + + Returns: + LVGL widget object if found, None otherwise + + Example: + widget = find_label_with_text(lv.screen_active(), "Settings") + if widget: + print(f"Found Settings widget at {widget.get_coords()}") + """ + widgets = get_all_widgets_with_text(obj) + for widget in widgets: + try: + text = widget.get_text() + if search_text in text: + return widget + except: + pass # Error getting text from this widget + return None + + +def get_screen_text_content(obj): + """ + Extract all text content from all widgets on screen. + + Useful for debugging or comprehensive text verification. + Returns a list of all text strings found in any widgets with text + (labels, checkboxes, buttons, etc.). + + Args: + obj: LVGL object to search (typically lv.screen_active()) + + Returns: + list: List of all text strings found in widgets + + Example: + texts = get_screen_text_content(lv.screen_active()) + assert "Welcome" in texts + assert "Version 1.0" in texts + """ + widgets = get_all_widgets_with_text(obj) + texts = [] + for widget in widgets: + try: + text = widget.get_text() + if text: + texts.append(text) + except: + pass # Error getting text + return texts + + +def verify_text_present(obj, expected_text): + """ + Verify that expected text is present somewhere on screen. + + This is the primary verification method for graphical tests. + It searches all labels for the expected text (substring match). + + Args: + obj: LVGL object to search (typically lv.screen_active()) + expected_text: Text that should be present (can be substring) + + Returns: + bool: True if text found, False otherwise + + Example: + assert verify_text_present(lv.screen_active(), "Settings") + assert verify_text_present(lv.screen_active(), "Version") + """ + return find_label_with_text(obj, expected_text) is not None + + +def print_screen_labels(obj): + """ + Debug helper: Print all text found on screen from any widget. + + Useful for debugging tests to see what text is actually present. + Prints to stdout with numbered list. Includes text from labels, + checkboxes, buttons, and any other widgets with text. + + Args: + obj: LVGL object to search (typically lv.screen_active()) + + Example: + # When a test fails, use this to see what's on screen + print_screen_labels(lv.screen_active()) + # Output: + # Found 5 text widgets on screen: + # 0: MicroPythonOS + # 1: Version 0.3.3 + # 2: Settings + # 3: Force Update (checkbox) + # 4: WiFi + """ + texts = get_screen_text_content(obj) + print(f"Found {len(texts)} text widgets on screen:") + for i, text in enumerate(texts): + print(f" {i}: {text}") + + +def get_widget_coords(widget): + """ + Get the coordinates of a widget. + + Returns the bounding box coordinates of the widget, useful for + clicking on it or verifying its position. + + Args: + widget: LVGL widget object + + Returns: + dict: Dictionary with keys 'x1', 'y1', 'x2', 'y2', 'center_x', 'center_y' + Returns None if widget is invalid or has no coordinates + + Example: + # Find and click on a button + button = find_label_with_text(lv.screen_active(), "Submit") + if button: + coords = get_widget_coords(button.get_parent()) # Get parent button + if coords: + simulate_click(coords['center_x'], coords['center_y']) + """ + try: + area = lv.area_t() + widget.get_coords(area) + return { + 'x1': area.x1, + 'y1': area.y1, + 'x2': area.x2, + 'y2': area.y2, + 'center_x': (area.x1 + area.x2) // 2, + 'center_y': (area.y1 + area.y2) // 2, + 'width': area.x2 - area.x1, + 'height': area.y2 - area.y1, + } + except: + return None + + +def find_button_with_text(obj, search_text): + """ + Find a button widget containing specific text in its label. + + This is specifically for finding buttons (which contain labels as children) + rather than just labels. Very useful for testing UI interactions. + + Args: + obj: LVGL object to search (typically lv.screen_active()) + search_text: Text to search for in button labels (can be substring) + + Returns: + LVGL button object if found, None otherwise + + Example: + submit_btn = find_button_with_text(lv.screen_active(), "Submit") + if submit_btn: + coords = get_widget_coords(submit_btn) + simulate_click(coords['center_x'], coords['center_y']) + """ + # Find the label first + label = find_label_with_text(obj, search_text) + if label: + # Try to get the parent button + try: + parent = label.get_parent() + # Check if parent is a button + if parent.get_class() == lv.button_class: + return parent + # Sometimes there's an extra container layer + grandparent = parent.get_parent() + if grandparent and grandparent.get_class() == lv.button_class: + return grandparent + except: + pass + return None + + +def get_keyboard_button_coords(keyboard, button_text): + """ + Get the coordinates of a specific button on an LVGL keyboard/buttonmatrix. + + This function calculates the exact center position of a keyboard button + by finding its index and computing its position based on the keyboard's + layout, control widths, and actual screen coordinates. + + Args: + keyboard: LVGL keyboard widget (or MposKeyboard wrapper) + button_text: Text of the button to find (e.g., "q", "a", "1") + + Returns: + dict with 'center_x' and 'center_y', or None if button not found + + Example: + from mpos.ui.keyboard import MposKeyboard + keyboard = MposKeyboard(screen) + coords = get_keyboard_button_coords(keyboard, "q") + if coords: + simulate_click(coords['center_x'], coords['center_y']) + """ + # Get the underlying LVGL keyboard if this is a wrapper + if hasattr(keyboard, '_keyboard'): + lvgl_keyboard = keyboard._keyboard + else: + lvgl_keyboard = keyboard + + # Find the button index + button_idx = None + for i in range(100): # Check up to 100 buttons + try: + text = lvgl_keyboard.get_button_text(i) + if text == button_text: + button_idx = i + break + except: + break # No more buttons + + if button_idx is None: + return None + + # Get keyboard widget coordinates + area = lv.area_t() + lvgl_keyboard.get_coords(area) + kb_x = area.x1 + kb_y = area.y1 + kb_width = area.x2 - area.x1 + kb_height = area.y2 - area.y1 + + # Parse the keyboard layout to find button position + # Note: LVGL get_button_text() skips '\n' markers, so they're not in the indices + # Standard keyboard layout (from MposKeyboard): + # Row 0: 10 buttons (q w e r t y u i o p) + # Row 1: 9 buttons (a s d f g h j k l) + # Row 2: 9 buttons (shift z x c v b n m backspace) + # Row 3: 5 buttons (?123, comma, space, dot, enter) + + # Define row lengths for standard keyboard + row_lengths = [10, 9, 9, 5] + + # Find which row our button is in + row = 0 + buttons_before = 0 + for row_len in row_lengths: + if button_idx < buttons_before + row_len: + # Button is in this row + col = button_idx - buttons_before + buttons_this_row = row_len + break + buttons_before += row_len + row += 1 + else: + # Button not found in standard layout, use row 0 + row = 0 + col = button_idx + buttons_this_row = 10 + + # Calculate position + # Approximate: divide keyboard into equal rows and columns + # (This is simplified - actual LVGL uses control widths, but this is good enough) + num_rows = 4 # Typical keyboard has 4 rows + button_height = kb_height / num_rows + button_width = kb_width / max(buttons_this_row, 1) + + # Calculate center position + center_x = int(kb_x + (col * button_width) + (button_width / 2)) + center_y = int(kb_y + (row * button_height) + (button_height / 2)) + + return { + 'center_x': center_x, + 'center_y': center_y, + 'button_idx': button_idx, + 'row': row, + 'col': col + } + + +def _touch_read_cb(indev_drv, data): + """ + Internal callback for simulated touch input device. + + This callback is registered with LVGL and provides touch state + when simulate_click() is used. Not intended for direct use. + + Args: + indev_drv: Input device driver (LVGL internal) + data: Input device data structure to fill + """ + global _touch_x, _touch_y, _touch_pressed + data.point.x = _touch_x + data.point.y = _touch_y + if _touch_pressed: + data.state = lv.INDEV_STATE.PRESSED + else: + data.state = lv.INDEV_STATE.RELEASED + + +def _ensure_touch_indev(): + """ + Ensure that the simulated touch input device is created. + + This is called automatically by simulate_click() on first use. + Creates a pointer-type input device that uses _touch_read_cb. + Not intended for direct use. + """ + global _touch_indev + if _touch_indev is None: + _touch_indev = lv.indev_create() + _touch_indev.set_type(lv.INDEV_TYPE.POINTER) + _touch_indev.set_read_cb(_touch_read_cb) + print("Created simulated touch input device") + + +def simulate_click(x, y, press_duration_ms=100): + """ + Simulate a touch/click at the specified coordinates. + + This creates a simulated touch press at (x, y) and automatically + releases it after press_duration_ms milliseconds. The touch is + processed through LVGL's normal input handling, so it triggers + click events, focus changes, scrolling, etc. just like real input. + + Useful for: + - Automated testing of UI interactions + - Demo modes in apps + - Accessibility automation + - Integration testing + + To find object coordinates for clicking: + obj_area = lv.area_t() + obj.get_coords(obj_area) + center_x = (obj_area.x1 + obj_area.x2) // 2 + center_y = (obj_area.y1 + obj_area.y2) // 2 + simulate_click(center_x, center_y) + + Args: + x: X coordinate to click (in pixels) + y: Y coordinate to click (in pixels) + press_duration_ms: How long to hold the press (default: 100ms) + + Example: + from mpos.ui.testing import simulate_click, wait_for_render + + # Click at screen center (320x240) + simulate_click(160, 120) + wait_for_render() + + # Click on a specific button + button_area = lv.area_t() + my_button.get_coords(button_area) + simulate_click(button_area.x1 + 10, button_area.y1 + 10) + wait_for_render() + """ + global _touch_x, _touch_y, _touch_pressed + + # Ensure the touch input device exists + _ensure_touch_indev() + + # Set touch position and press state + _touch_x = x + _touch_y = y + _touch_pressed = True + + # Process the press event + lv.task_handler() + time.sleep(0.02) + lv.task_handler() + + # Wait for press duration + time.sleep(press_duration_ms / 1000.0) + + # Release the touch + _touch_pressed = False + + # Process the release event - this triggers the CLICKED event + lv.task_handler() + time.sleep(0.02) + lv.task_handler() + time.sleep(0.02) + lv.task_handler() + +def click_button(button_text, timeout=5, use_send_event=True): + """Find and click a button with given text. + + Args: + button_text: Text to search for in button labels + timeout: Maximum time to wait for button to appear (default: 5s) + use_send_event: If True, use send_event() which is more reliable for + triggering button actions. If False, use simulate_click() + which simulates actual touch input. (default: True) + + Returns: + True if button was found and clicked, False otherwise + """ + start = time.time() + while time.time() - start < timeout: + button = find_button_with_text(lv.screen_active(), button_text) + if button: + coords = get_widget_coords(button) + if coords: + print(f"Clicking button '{button_text}' at ({coords['center_x']}, {coords['center_y']})") + if use_send_event: + # Use send_event for more reliable button triggering + button.send_event(lv.EVENT.CLICKED, None) + else: + # Use simulate_click for actual touch simulation + simulate_click(coords['center_x'], coords['center_y']) + wait_for_render(iterations=20) + return True + wait_for_render(iterations=5) + print(f"ERROR: Button '{button_text}' not found after {timeout}s") + return False + +def click_label(label_text, timeout=5, use_send_event=True): + """Find a label with given text and click on it (or its clickable parent). + + This function finds a label, scrolls it into view (with multiple attempts + if needed), verifies it's within the visible viewport, and then clicks it. + If the label itself is not clickable, it will try clicking the parent container. + + Args: + label_text: Text to search for in labels + timeout: Maximum time to wait for label to appear (default: 5s) + use_send_event: If True, use send_event() on clickable parent which is more + reliable. If False, use simulate_click(). (default: True) + + Returns: + True if label was found and clicked, False otherwise + """ + start = time.time() + while time.time() - start < timeout: + label = find_label_with_text(lv.screen_active(), label_text) + if label: + # Get screen dimensions for viewport check + screen = lv.screen_active() + screen_coords = get_widget_coords(screen) + if not screen_coords: + screen_coords = {'x1': 0, 'y1': 0, 'x2': 320, 'y2': 240} + + # Try scrolling multiple times to ensure label is fully visible + max_scroll_attempts = 5 + for scroll_attempt in range(max_scroll_attempts): + print(f"Scrolling label to view (attempt {scroll_attempt + 1}/{max_scroll_attempts})...") + label.scroll_to_view_recursive(True) + wait_for_render(iterations=50) # needs quite a bit of time for scroll animation + + # Get updated coordinates after scroll + coords = get_widget_coords(label) + if not coords: + break + + # Check if label center is within visible viewport + # Account for some margin (e.g., status bar at top, nav bar at bottom) + # Use a larger bottom margin to ensure the element is fully clickable + viewport_top = screen_coords['y1'] + 30 # Account for status bar + viewport_bottom = screen_coords['y2'] - 30 # Larger margin at bottom for clickability + viewport_left = screen_coords['x1'] + viewport_right = screen_coords['x2'] + + center_x = coords['center_x'] + center_y = coords['center_y'] + + is_visible = (viewport_left <= center_x <= viewport_right and + viewport_top <= center_y <= viewport_bottom) + + if is_visible: + print(f"Label '{label_text}' is visible at ({center_x}, {center_y})") + + # Try to find a clickable parent (container) - many UIs have clickable containers + # with non-clickable labels inside. We'll click on the label's position but + # the event should bubble up to the clickable parent. + click_target = label + clickable_parent = None + click_coords = coords + try: + parent = label.get_parent() + if parent and parent.has_flag(lv.obj.FLAG.CLICKABLE): + # The parent is clickable - we can use send_event on it + clickable_parent = parent + parent_coords = get_widget_coords(parent) + if parent_coords: + print(f"Found clickable parent container: ({parent_coords['x1']}, {parent_coords['y1']}) to ({parent_coords['x2']}, {parent_coords['y2']})") + # Use label's x but ensure y is within parent bounds + click_x = center_x + click_y = center_y + # Clamp to parent bounds with some margin + if click_y < parent_coords['y1'] + 5: + click_y = parent_coords['y1'] + 5 + if click_y > parent_coords['y2'] - 5: + click_y = parent_coords['y2'] - 5 + click_coords = {'center_x': click_x, 'center_y': click_y} + except Exception as e: + print(f"Could not check parent clickability: {e}") + + print(f"Clicking label '{label_text}' at ({click_coords['center_x']}, {click_coords['center_y']})") + if use_send_event and clickable_parent: + # Use send_event on the clickable parent for more reliable triggering + print(f"Using send_event on clickable parent") + clickable_parent.send_event(lv.EVENT.CLICKED, None) + else: + # Use simulate_click for actual touch simulation + simulate_click(click_coords['center_x'], click_coords['center_y']) + wait_for_render(iterations=20) + return True + else: + print(f"Label '{label_text}' at ({center_x}, {center_y}) not fully visible " + f"(viewport: y={viewport_top}-{viewport_bottom}), scrolling more...") + # Additional scroll - try scrolling the parent container + try: + parent = label.get_parent() + if parent: + # Try to find a scrollable ancestor + scrollable = parent + for _ in range(5): # Check up to 5 levels up + try: + grandparent = scrollable.get_parent() + if grandparent: + scrollable = grandparent + except: + break + + # Scroll by a fixed amount to bring label more into view + current_scroll = scrollable.get_scroll_y() + if center_y > viewport_bottom: + # Need to scroll down (increase scroll_y) + scrollable.scroll_to_y(current_scroll + 60, True) + elif center_y < viewport_top: + # Need to scroll up (decrease scroll_y) + scrollable.scroll_to_y(max(0, current_scroll - 60), True) + wait_for_render(iterations=30) + except Exception as e: + print(f"Additional scroll failed: {e}") + + # If we exhausted scroll attempts, try clicking anyway + coords = get_widget_coords(label) + if coords: + # Try to find a clickable parent even for fallback click + click_coords = coords + try: + parent = label.get_parent() + if parent and parent.has_flag(lv.obj.FLAG.CLICKABLE): + parent_coords = get_widget_coords(parent) + if parent_coords: + click_coords = parent_coords + print(f"Using clickable parent for fallback click") + except: + pass + + print(f"Clicking at ({click_coords['center_x']}, {click_coords['center_y']}) after max scroll attempts") + # Try to use send_event if we have a clickable parent + try: + parent = label.get_parent() + if use_send_event and parent and parent.has_flag(lv.obj.FLAG.CLICKABLE): + print(f"Using send_event on clickable parent for fallback") + parent.send_event(lv.EVENT.CLICKED, None) + else: + simulate_click(click_coords['center_x'], click_coords['center_y']) + except: + simulate_click(click_coords['center_x'], click_coords['center_y']) + wait_for_render(iterations=20) + return True + + wait_for_render(iterations=5) + print(f"ERROR: Label '{label_text}' not found after {timeout}s") + return False + +def find_text_on_screen(text): + """Check if text is present on screen.""" + return find_label_with_text(lv.screen_active(), text) is not None diff --git a/internal_filesystem/lib/mpos/ui/theme.py b/internal_filesystem/lib/mpos/ui/theme.py new file mode 100644 index 00000000..8de2ed84 --- /dev/null +++ b/internal_filesystem/lib/mpos/ui/theme.py @@ -0,0 +1,81 @@ +import lvgl as lv +import mpos.config + +# Global style for keyboard button fix +_keyboard_button_fix_style = None +_is_light_mode = True + +def get_keyboard_button_fix_style(): + """ + Get the keyboard button fix style for light mode. + + The LVGL default theme applies bg_color_white to keyboard buttons, + which makes them white-on-white (invisible) in light mode. + This function returns a custom style to override that. + + Returns: + lv.style_t: Style to apply to keyboard buttons, or None if not needed + """ + global _keyboard_button_fix_style, _is_light_mode + + # Only return style in light mode + if not _is_light_mode: + return None + + # Create style if it doesn't exist + if _keyboard_button_fix_style is None: + _keyboard_button_fix_style = lv.style_t() + _keyboard_button_fix_style.init() + + # Set button background to light gray (matches LVGL's intended design) + # This provides contrast against white background + # Using palette_lighten gives us the same gray as used in the theme + gray_color = lv.palette_lighten(lv.PALETTE.GREY, 2) + _keyboard_button_fix_style.set_bg_color(gray_color) + _keyboard_button_fix_style.set_bg_opa(lv.OPA.COVER) + + return _keyboard_button_fix_style + +# On ESP32, the keyboard buttons in light mode have no color, just white, +# which makes them hard to see on the white background. Probably a bug in the +# underlying LVGL or MicroPython or lvgl_micropython. +def fix_keyboard_button_style(keyboard): + """ + Apply keyboard button visibility fix to a keyboard instance. + + Call this function after creating a keyboard to ensure buttons + are visible in light mode. + + Args: + keyboard: The lv.keyboard instance to fix + """ + style = get_keyboard_button_fix_style() + if style: + keyboard.add_style(style, lv.PART.ITEMS) + print(f"Applied keyboard button fix for light mode to keyboard instance") + +def set_theme(prefs): + global _is_light_mode + + # Load and set theme: + theme_light_dark = prefs.get_string("theme_light_dark", "light") # default to a light theme + theme_dark_bool = ( theme_light_dark == "dark" ) + _is_light_mode = not theme_dark_bool # Track for keyboard button fix + + primary_color = lv.theme_get_color_primary(None) + color_string = prefs.get_string("theme_primary_color") + if color_string: + try: + color_string = color_string.replace("0x", "").replace("#", "").strip().lower() + color_int = int(color_string, 16) + print(f"Setting primary color: {color_int}") + primary_color = lv.color_hex(color_int) + except Exception as e: + print(f"Converting color setting '{color_string}' to lv_color_hex() got exception: {e}") + + lv.theme_default_init(mpos.ui.main_display._disp_drv, primary_color, lv.color_hex(0xFBDC05), theme_dark_bool, lv.font_montserrat_12) + #mpos.ui.main_display.set_theme(theme) # not needed, default theme is applied immediately + + # Recreate keyboard button fix style if mode changed + global _keyboard_button_fix_style + _keyboard_button_fix_style = None # Force recreation with new theme colors diff --git a/internal_filesystem/lib/mpos/ui/topmenu.py b/internal_filesystem/lib/mpos/ui/topmenu.py index b509f1e8..96486428 100644 --- a/internal_filesystem/lib/mpos/ui/topmenu.py +++ b/internal_filesystem/lib/mpos/ui/topmenu.py @@ -1,6 +1,7 @@ import lvgl as lv import mpos.ui +import mpos.time import mpos.battery_voltage from .display import (get_display_width, get_display_height) from .util import (get_foreground_app) @@ -11,7 +12,7 @@ CLOCK_UPDATE_INTERVAL = 1000 # 10 or even 1 ms doesn't seem to change the framerate but 100ms is enough WIFI_ICON_UPDATE_INTERVAL = 1500 -BATTERY_ICON_UPDATE_INTERVAL = 5000 +BATTERY_ICON_UPDATE_INTERVAL = 15000 # not too often, but not too short, otherwise it takes a while to appear TEMPERATURE_UPDATE_INTERVAL = 2000 MEMFREE_UPDATE_INTERVAL = 5000 # not too frequent because there's a forced gc.collect() to give it a reliable value @@ -92,9 +93,10 @@ def create_notification_bar(): temp_label = lv.label(notification_bar) temp_label.set_text("00°C") temp_label.align_to(time_label, lv.ALIGN.OUT_RIGHT_MID, mpos.ui.pct_of_display_width(7) , 0) - memfree_label = lv.label(notification_bar) - memfree_label.set_text("") - memfree_label.align_to(temp_label, lv.ALIGN.OUT_RIGHT_MID, mpos.ui.pct_of_display_width(7), 0) + if False: + memfree_label = lv.label(notification_bar) + memfree_label.set_text("") + memfree_label.align_to(temp_label, lv.ALIGN.OUT_RIGHT_MID, mpos.ui.pct_of_display_width(7), 0) #style = lv.style_t() #style.init() #style.set_text_font(lv.font_montserrat_8) # tiny font @@ -134,16 +136,20 @@ def update_time(timer): print("Warning: could not check WLAN status:", str(e)) def update_battery_icon(timer=None): - percent = mpos.battery_voltage.get_battery_percentage() - if percent > 80: # 4.1V + try: + percent = mpos.battery_voltage.get_battery_percentage() + except Exception as e: + print(f"battery_voltage.get_battery_percentage got exception, not updating battery_icon: {e}") + return + if percent > 80: battery_icon.set_text(lv.SYMBOL.BATTERY_FULL) - elif percent > 60: # 4.0V + elif percent > 60: battery_icon.set_text(lv.SYMBOL.BATTERY_3) - elif percent > 40: # 3.9V + elif percent > 40: battery_icon.set_text(lv.SYMBOL.BATTERY_2) - elif percent > 20: # 3.8V + elif percent > 20: battery_icon.set_text(lv.SYMBOL.BATTERY_1) - else: # > 3.7V + else: battery_icon.set_text(lv.SYMBOL.BATTERY_EMPTY) battery_icon.remove_flag(lv.obj.FLAG.HIDDEN) # Percentage is not shown for now: @@ -152,21 +158,28 @@ def update_battery_icon(timer=None): update_battery_icon() # run it immediately instead of waiting for the timer def update_wifi_icon(timer): - if mpos.wifi.WifiService.is_connected(): + from mpos.net.wifi_service import WifiService + if WifiService.is_connected(): wifi_icon.remove_flag(lv.obj.FLAG.HIDDEN) else: wifi_icon.add_flag(lv.obj.FLAG.HIDDEN) - can_check_temperature = False - try: - import esp32 - can_check_temperature = True - except Exception as e: - print("Warning: can't check temperature sensor:", str(e)) - + # Get temperature sensor via SensorManager + import mpos.sensor_manager as SensorManager + temp_sensor = None + if SensorManager.is_available(): + # Prefer MCU temperature (more stable) over IMU temperature + temp_sensor = SensorManager.get_default_sensor(SensorManager.TYPE_SOC_TEMPERATURE) + if not temp_sensor: + temp_sensor = SensorManager.get_default_sensor(SensorManager.TYPE_IMU_TEMPERATURE) + def update_temperature(timer): - if can_check_temperature: - temp_label.set_text(f"{esp32.mcu_temperature()}°C") + if temp_sensor: + temp = SensorManager.read_sensor(temp_sensor) + if temp is not None: + temp_label.set_text(f"{round(temp)}°C") + else: + temp_label.set_text("--°C") else: temp_label.set_text("42°C") @@ -181,7 +194,7 @@ def update_memfree(timer): lv.timer_create(update_time, CLOCK_UPDATE_INTERVAL, None) lv.timer_create(update_temperature, TEMPERATURE_UPDATE_INTERVAL, None) - lv.timer_create(update_memfree, MEMFREE_UPDATE_INTERVAL, None) + #lv.timer_create(update_memfree, MEMFREE_UPDATE_INTERVAL, None) lv.timer_create(update_wifi_icon, WIFI_ICON_UPDATE_INTERVAL, None) lv.timer_create(update_battery_icon, BATTERY_ICON_UPDATE_INTERVAL, None) @@ -222,8 +235,8 @@ def create_drawer(display=None): slider_label=lv.label(drawer) prefs = mpos.config.SharedPreferences("com.micropythonos.settings") brightness_int = prefs.get_int("display_brightness", 100) - if display: - display.set_backlight(brightness_int) + if mpos.ui.main_display: + mpos.ui.main_display.set_backlight(brightness_int) slider_label.set_text(f"Brightness: {brightness_int}%") slider_label.align(lv.ALIGN.TOP_MID,0,lv.pct(4)) slider=lv.slider(drawer) @@ -234,8 +247,8 @@ def create_drawer(display=None): def brightness_slider_changed(e): brightness_int = slider.get_value() slider_label.set_text(f"Brightness: {brightness_int}%") - if display: - display.set_backlight(brightness_int) + if mpos.ui.main_display: + mpos.ui.main_display.set_backlight(brightness_int) def brightness_slider_released(e): brightness_int = slider.get_value() prefs = mpos.config.SharedPreferences("com.micropythonos.settings") @@ -271,12 +284,12 @@ def settings_event(e): launcher_btn.set_size(lv.pct(drawer_button_pct),lv.pct(20)) launcher_btn.align(lv.ALIGN.CENTER,0,0) launcher_label=lv.label(launcher_btn) - launcher_label.set_text(lv.SYMBOL.HOME+" Home") + launcher_label.set_text(lv.SYMBOL.HOME+" Launch") launcher_label.center() def launcher_event(e): - print("Home button pressed!") + print("Launch button pressed!") close_drawer(True) - mpos.ui.show_launcher() + mpos.apps.restart_launcher() launcher_btn.add_event_cb(launcher_event,lv.EVENT.CLICKED,None) ''' sleep_btn=lv.button(drawer) @@ -295,7 +308,7 @@ def sleep_event(e): else: # assume unix: # maybe do a system suspend here? or at least show a popup toast "not supported" close_drawer(True) - show_launcher() + mpos.apps.restart_launcher() sleep_btn.add_event_cb(sleep_event,lv.EVENT.CLICKED,None) ''' restart_btn=lv.button(drawer) @@ -334,7 +347,7 @@ def poweroff_cb(e): #wake_pin = machine.Pin(0, machine.Pin.IN, machine.Pin.PULL_UP) # Pull-up enabled, active low #import esp32 #esp32.wake_on_ext0(pin=wake_pin, level=esp32.WAKEUP_ALL_LOW) - print("Entering deep sleep. Press BOOT button to wake up.") + print("Entering deep sleep...") machine.deepsleep() # sleep forever else: # assume unix: lv.deinit() # Deinitialize LVGL (if supported) diff --git a/internal_filesystem/lib/mpos/ui/util.py b/internal_filesystem/lib/mpos/ui/util.py index 060db233..5b125a3c 100644 --- a/internal_filesystem/lib/mpos/ui/util.py +++ b/internal_filesystem/lib/mpos/ui/util.py @@ -14,9 +14,6 @@ def get_foreground_app(): global _foreground_app_name return _foreground_app_name -def show_launcher(): - restart_launcher() - def shutdown(): print("Shutting down...") lv.deinit() diff --git a/internal_filesystem/lib/mpos/ui/view.py b/internal_filesystem/lib/mpos/ui/view.py index a86ab567..8315ca16 100644 --- a/internal_filesystem/lib/mpos/ui/view.py +++ b/internal_filesystem/lib/mpos/ui/view.py @@ -1,4 +1,3 @@ -# lib/mpos/ui/view.py import lvgl as lv from ..apps import restart_launcher from .focus import save_and_clear_current_focusgroup @@ -6,10 +5,6 @@ screen_stack = [] -def empty_screen_stack(): - global screen_stack - screen_stack.clear() - def setContentView(new_activity, new_screen): global screen_stack if screen_stack: @@ -28,6 +23,11 @@ def setContentView(new_activity, new_screen): if new_activity: new_activity.onResume(new_screen) +def remove_and_stop_all_activities(): + global screen_stack + while len(screen_stack): + remove_and_stop_current_activity() + def remove_and_stop_current_activity(): current_activity, current_screen, current_focusgroup, _ = screen_stack.pop() if current_activity: diff --git a/internal_filesystem/lib/mpos/wifi.py b/internal_filesystem/lib/mpos/wifi.py deleted file mode 100644 index 63efbaf0..00000000 --- a/internal_filesystem/lib/mpos/wifi.py +++ /dev/null @@ -1,101 +0,0 @@ -# Automatically connect to the WiFi, based on the saved networks -# Manage concurrent accesses to the wifi (scan while connect, connect while scan etc) -# Manage saved networks -# This gets started in a new thread, does an autoconnect, and exits. - -import ujson -import os -import time - -import mpos.config -import mpos.time - -have_network = False -try: - import network - have_network = True -except Exception as e: - print("Could not import network, have_network=False") - -class WifiService(): - - wifi_busy = False # crude lock on wifi - access_points = {} - - @staticmethod - def connect(): - wlan=network.WLAN(network.STA_IF) - wlan.active(False) # restart WiFi hardware in case it's in a bad state - wlan.active(True) - networks = wlan.scan() - for n in networks: - ssid = n[0].decode() - print(f"auto_connect: checking ssid '{ssid}'") - if ssid in WifiService.access_points: - password = WifiService.access_points.get(ssid).get("password") - print(f"auto_connect: attempting to connect to saved network {ssid} with password {password}") - if WifiService.attempt_connecting(ssid,password): - print(f"auto_connect: Connected to {ssid}") - return True - else: - print(f"auto_connect: failed to connect to {ssid}") - else: - print(f"auto_connect: not trying {ssid} because it hasn't been configured") - print("auto_connect: no known networks connected") - return False - - @staticmethod - def attempt_connecting(ssid,password): - print(f"auto_connect.py attempt_connecting: Attempting to connect to SSID: {ssid}") - try: - wlan=network.WLAN(network.STA_IF) - wlan.connect(ssid,password) - for i in range(10): - if wlan.isconnected(): - print(f"auto_connect.py attempt_connecting: Connected to {ssid} after {i+1} seconds") - mpos.time.sync_time() - return True - elif not wlan.active(): # wificonf app or others might stop the wifi, no point in continuing then - print("auto_connect.py attempt_connecting: Someone disabled wifi, bailing out...") - return False - print(f"auto_connect.py attempt_connecting: Waiting for connection, attempt {i+1}/10") - time.sleep(1) - print(f"auto_connect.py attempt_connecting: Failed to connect to {ssid}") - return False - except Exception as e: - print(f"auto_connect.py attempt_connecting: Connection error: {e}") - return False - - @staticmethod - def auto_connect(): - print("auto_connect thread running") - - # load config: - WifiService.access_points = mpos.config.SharedPreferences("com.micropythonos.system.wifiservice").get_dict("access_points") - if not len(WifiService.access_points): - print("WifiService.py: not access points configured, exiting...") - return - - if not WifiService.wifi_busy: - WifiService.wifi_busy = True - if not have_network: - print("auto_connect: no network module found, waiting to simulate connection...") - time.sleep(10) - print("auto_connect: wifi connect simulation done") - else: - if WifiService.connect(): - print("WifiService.py managed to connect.") - else: - print("WifiService.py did not manage to connect.") - wlan=network.WLAN(network.STA_IF) - wlan.active(False) # disable to conserve power - WifiService.wifi_busy = False - - @staticmethod - def is_connected(): - if WifiService.wifi_busy: - return False - elif not have_network: - return True - else: - return network.WLAN(network.STA_IF).isconnected() diff --git a/internal_filesystem/lib/websocket.py b/internal_filesystem/lib/websocket.py index d6ec5765..c76d1e7e 100644 --- a/internal_filesystem/lib/websocket.py +++ b/internal_filesystem/lib/websocket.py @@ -37,18 +37,23 @@ class WebSocketTimeoutException(WebSocketException): _callback_queue = ucollections.deque((), 100) # Empty tuple, maxlen=100 def _run_callback(callback, *args): + if not callback: + print("_run_callback: skipping None callback") + return """Add callback to queue for execution.""" try: _callback_queue.append((callback, args)) #_log_debug(f"Queued callback {callback}, args={args}, queue size: {len(_callback_queue)}") + # print("Doing callback directly:") + # callback(*args) except IndexError: _log_error("ERROR: websocket.py callback queue full, dropping callback") async def _process_callbacks_async(): """Process queued callbacks asynchronously.""" import _thread - while True: - #print(f"thread {_thread.get_ident()}: _process_callbacks_async") + while True: # this stops when "NWCWallet: manage_wallet_thread stopping, closing connections..." + #print(f"_process_callbacks_async thread {_thread.get_ident()}: _process_callbacks_async") while _callback_queue: _log_debug("Processing callbacks queue...") try: @@ -66,7 +71,7 @@ async def _process_callbacks_async(): except IndexError: _log_debug("Callback queue empty") break - await asyncio.sleep(0.5) # Yield to other tasks + await asyncio.sleep(0.1) # Yield to other tasks class WebSocketApp: def __init__( @@ -130,7 +135,7 @@ def send_bytes(self, data): """Send binary data.""" self.send(data, ABNF.OPCODE_BINARY) - def close(self, **kwargs): + async def close(self, **kwargs): """Close the WebSocket connection.""" _log_debug("Close requested") self.running = False @@ -184,7 +189,7 @@ def ready(self): _log_debug(f"Connection status: ready={status}") return status - def run_forever( + async def run_forever( self, sockopt=None, sslopt=None, @@ -224,13 +229,16 @@ def run_forever( # Run the event loop in the main thread try: - self._loop.run_until_complete(self._async_main()) + print("doing run_until_complete") + #self._loop.run_until_complete(self._async_main()) # this doesn't always finish! + asyncio.create_task(self._async_main()) + print("after run_until_complete") except KeyboardInterrupt: _log_debug("run_forever got KeyboardInterrupt") self.close() return False except Exception as e: - _log_error(f"run_forever's _loop.run_until_complete() got general exception: {e}") + _log_error(f"run_forever's _loop.run_until_complete() for {self.url} got general exception: {e}") self.has_errored = True self.running = False #return True @@ -240,18 +248,20 @@ def run_forever( async def _async_main(self): """Main async loop for WebSocket handling.""" _log_debug("Starting _async_main") - reconnect = 0 # Default, as RECONNECT may not be defined - try: - from websocket import RECONNECT - reconnect = RECONNECT - except ImportError: - pass - if reconnect is not None: - reconnect = reconnect + #reconnect = 0 # Default, as RECONNECT may not be defined + #try: + # from websocket import RECONNECT + # reconnect = RECONNECT + #except ImportError: + # pass + #if reconnect is not None: + # reconnect = reconnect + reconnect = 3 _log_debug(f"Reconnect interval set to {reconnect}s") # Start callback processing task try: + # Make sure the queue is empty callback_task = asyncio.create_task(_process_callbacks_async()) _log_debug("Started callback processing task") except Exception as e: @@ -260,12 +270,12 @@ async def _async_main(self): while self.running: _log_debug("Main loop iteration: self.running=True") try: - await self._connect_and_run() + await self._connect_and_run() # keep waiting for it, until finished except Exception as e: - _log_error(f"_async_main got exception: {e}") + _log_error(f"_async_main's await self._connect_and_run() for {self.url} got exception: {e}") self.has_errored = True _run_callback(self.on_error, self, e) - if not reconnect: + if reconnect is not True: _log_debug("No reconnect configured, breaking loop") break _log_debug(f"Reconnecting after error in {reconnect}s") @@ -275,6 +285,10 @@ async def _async_main(self): # Cleanup _log_debug("Initiating cleanup") + #_run_callback(self.on_close, self, None, None) + # await asyncio.sleep(0.1) # need to wait for _process_callbacks_async to call on_close, but how much is enough? + if self.on_close: + self.on_close(self, None, None) # don't use _run_callback() but do it immediately self.running = False callback_task.cancel() # Stop callback task try: @@ -282,7 +296,6 @@ async def _async_main(self): except asyncio.CancelledError: _log_debug("Callback task cancelled") await self._close_async() - _run_callback(self.on_close, self, None, None) _log_debug("_async_main completed") async def _connect_and_run(self): @@ -297,6 +310,11 @@ async def _connect_and_run(self): self.session = aiohttp.ClientSession(headers=self.header) async with self.session.ws_connect(self.url, ssl=ssl_context) as ws: + if not ws: + print("ERROR: ws_connect got None instead of ws object!") + _run_callback(self.on_error, self, str(e)) + return + self.ws = ws _log_debug("WebSocket connected, running on_open callback") _run_callback(self.on_open, self) @@ -330,7 +348,7 @@ async def _connect_and_run(self): raise WebSocketConnectionClosedException("WebSocket closed") elif msg.type == ABNF.OPCODE_PING: data = msg.data - _run_callback(self.on_ping, self, data, ABNF.OPCODE_PING, True) + _run_callback(self.on_ping, self, data) async def _send_async(self, data, opcode): """Async send implementation.""" diff --git a/internal_filesystem/main.py b/internal_filesystem/main.py index 3e012e55..e768d64d 100644 --- a/internal_filesystem/main.py +++ b/internal_filesystem/main.py @@ -1,81 +1,10 @@ -import task_handler -import _thread -import lvgl as lv - -# Allow LVGL M:/path/to/file or M:relative/path/to/file to work for image set_src etc -import mpos.fs_driver -fs_drv = lv.fs_drv_t() -mpos.fs_driver.fs_register(fs_drv, 'M') - -import mpos.apps -import mpos.config -import mpos.ui -import mpos.ui.topmenu -from mpos.ui.display import init_rootscreen - -prefs = mpos.config.SharedPreferences("com.micropythonos.settings") - -# Load and set theme: -theme_light_dark = prefs.get_string("theme_light_dark", "light") # default to a light theme -theme_dark_bool = ( theme_light_dark == "dark" ) -primary_color = lv.theme_get_color_primary(None) -color_string = prefs.get_string("theme_primary_color") -if color_string: - try: - color_string = color_string.replace("0x", "").replace("#", "").strip().lower() - color_int = int(color_string, 16) - print(f"Setting primary color: {color_int}") - primary_color = lv.color_hex(color_int) - except Exception as e: - print(f"Converting color setting '{color_string}' to lv_color_hex() got exception: {e}") -theme = lv.theme_default_init(display._disp_drv, primary_color, lv.color_hex(0xFBDC05), theme_dark_bool, lv.font_montserrat_12) - -#display.set_theme(theme) - -init_rootscreen() -mpos.ui.topmenu.create_notification_bar() -mpos.ui.topmenu.create_drawer(display) -mpos.ui.handle_back_swipe() -mpos.ui.handle_top_swipe() - -# Clear top menu, notification bar, swipe back and swipe down buttons -# Ideally, these would be stored in a different focusgroup that is used when the user opens the drawer -focusgroup = lv.group_get_default() -if focusgroup: # on esp32 this may not be set - focusgroup.remove_all_objs() # might be better to save and restore the group for "back" actions - -# Can be passed to TaskHandler, currently unused: -def custom_exception_handler(e): - print(f"custom_exception_handler called: {e}") - mpos.ui.th.deinit() - # otherwise it does focus_next and then crashes while doing lv.deinit() - focusgroup.remove_all_objs() - focusgroup.delete() +# This file is the only one that can't be overridden for development (without rebuilding) because it's not in lib/, so keep it minimal. +# Make sure the storage partition's lib/ is first in the path, so whatever is placed there overrides frozen libraries. +# This allows any build to be used for development as well, just by overriding the libraries in lib/ import sys -if sys.platform == "esp32": - mpos.ui.th = task_handler.TaskHandler(duration=1) # 1ms gives highest framerate on esp32-s3's -else: - mpos.ui.th = task_handler.TaskHandler(duration=5) # 5ms is recommended for MicroPython+LVGL on desktop (less results in lower framerate) - -try: - import freezefs_mount_builtin -except Exception as e: - # This will throw an exception if there is already a "/builtin" folder present - print("main.py: WARNING: could not import/run freezefs_mount_builtin: ", e) - -try: - import mpos.wifi - _thread.stack_size(mpos.apps.good_stack_size()) - _thread.start_new_thread(mpos.wifi.WifiService.auto_connect, ()) -except Exception as e: - print(f"Couldn't start mpos.wifi.WifiService.auto_connect thread because: {e}") +sys.path.insert(0, 'lib') -mpos.apps.restart_launcher() +print("Passing execution over to mpos.main") +import mpos.main -# If we got this far without crashing, then no need to rollback the update: -try: - import ota.rollback - ota.rollback.cancel() -except Exception as e: - print("main.py: warning: could not mark this update as valid:", e) diff --git a/lvgl_micropython b/lvgl_micropython index f1ea9816..b886c333 160000 --- a/lvgl_micropython +++ b/lvgl_micropython @@ -1 +1 @@ -Subproject commit f1ea9816b955d91befd1b2c3360e2be97d21a04d +Subproject commit b886c3334890ce3e7eeb9d9588580104eda92c8a diff --git a/manifests/manifest.py b/manifests/manifest.py index 39f577ee..bbda9934 100644 --- a/manifests/manifest.py +++ b/manifests/manifest.py @@ -1,4 +1,3 @@ -freeze('../internal_filesystem/', 'boot.py') # Hardware initialization -freeze('../internal_filesystem/', 'main.py') # User Interface initialization +freeze('../internal_filesystem/', 'main.py') # Hardware initialization freeze('../internal_filesystem/lib', '') # Additional libraries freeze('../freezeFS/', 'freezefs_mount_builtin.py') # Built-in apps diff --git a/manifests/manifest_unix.py b/manifests/manifest_unix.py deleted file mode 100644 index 5012e02a..00000000 --- a/manifests/manifest_unix.py +++ /dev/null @@ -1,4 +0,0 @@ -freeze('../internal_filesystem/', 'boot_unix.py') # Hardware initialization -freeze('../internal_filesystem/', 'main.py') # User Interface initialization -freeze('../internal_filesystem/lib', '') # Additional libraries -freeze('../freezeFS/', 'freezefs_mount_builtin.py') # Built-in apps diff --git a/micropython-camera-API b/micropython-camera-API index 2dd97117..a84c8459 160000 --- a/micropython-camera-API +++ b/micropython-camera-API @@ -1 +1 @@ -Subproject commit 2dd97117359d00729d50448df19404d18f67ac30 +Subproject commit a84c84595b415894b9b4ca3dc05ffd3d7d9d9a22 diff --git a/micropython-nostr b/micropython-nostr index 75ef364d..99be5ce9 160000 --- a/micropython-nostr +++ b/micropython-nostr @@ -1 +1 @@ -Subproject commit 75ef364db2d153cc8cc8156076db9649b0ead1a7 +Subproject commit 99be5ce94d3815e344a8dda9307db2e1a406e3ed diff --git a/patches/fix_mpremote.py b/patches/fix_mpremote.py new file mode 100644 index 00000000..1b5896ac --- /dev/null +++ b/patches/fix_mpremote.py @@ -0,0 +1,27 @@ +diff --git a/tools/mpremote/mpremote/main.py b/tools/mpremote/mpremote/main.py +index b30a1a213..015a31114 100644 +--- a/tools/mpremote/mpremote/main.py ++++ b/tools/mpremote/mpremote/main.py +@@ -508,7 +508,7 @@ class State: + self.ensure_connected() + soft_reset = self._auto_soft_reset if soft_reset is None else soft_reset + if soft_reset or not self.transport.in_raw_repl: +- self.transport.enter_raw_repl(soft_reset=soft_reset) ++ self.transport.enter_raw_repl(soft_reset=False) + self._auto_soft_reset = False + + def ensure_friendly_repl(self): +diff --git a/tools/mpremote/mpremote/transport_serial.py b/tools/mpremote/mpremote/transport_serial.py +index 6aed0bb49..b74bb68a0 100644 +--- a/tools/mpremote/mpremote/transport_serial.py ++++ b/tools/mpremote/mpremote/transport_serial.py +@@ -139,7 +139,7 @@ class SerialTransport(Transport): + time.sleep(0.01) + return data + +- def enter_raw_repl(self, soft_reset=True, timeout_overall=10): ++ def enter_raw_repl(self, soft_reset=False, timeout_overall=10): + self.serial.write(b"\r\x03") # ctrl-C: interrupt any running program + + # flush input (without relying on serial.flushInput()) + diff --git a/patches/i2c_ng.patch b/patches/i2c_ng.patch new file mode 100644 index 00000000..bb5a819a --- /dev/null +++ b/patches/i2c_ng.patch @@ -0,0 +1,13 @@ +--- lvgl_micropython/lib/esp-idf/components/driver/i2c/i2c.c.orig 2025-11-23 11:54:37.321320078 +0100 ++++ lvgl_micropython/lib/esp-idf/components/driver/i2c/i2c.c 2025-11-23 11:54:54.681590547 +0100 +@@ -1715,8 +1715,8 @@ + // So if the new I2C driver is not linked in, then `i2c_acquire_bus_handle()` should be NULL at runtime. + extern __attribute__((weak)) esp_err_t i2c_acquire_bus_handle(int port_num, void *i2c_new_bus, int mode); + if ((void *)i2c_acquire_bus_handle != NULL) { +- ESP_EARLY_LOGE(I2C_TAG, "CONFLICT! driver_ng is not allowed to be used with this old driver"); +- abort(); ++ ESP_EARLY_LOGE(I2C_TAG, "CONFLICT! driver_ng is not allowed to be used with this old driver BUT abort is disabled!"); ++ //abort(); + } + ESP_EARLY_LOGW(I2C_TAG, "This driver is an old driver, please migrate your application code to adapt `driver/i2c_master.h`"); + } diff --git a/patches/micropython-camera-API.patch b/patches/micropython-camera-API.patch new file mode 100644 index 00000000..c56cc025 --- /dev/null +++ b/patches/micropython-camera-API.patch @@ -0,0 +1,167 @@ +diff --git a/src/manifest.py b/src/manifest.py +index ff69f76..929ff84 100644 +--- a/src/manifest.py ++++ b/src/manifest.py +@@ -1,4 +1,5 @@ + # Include the board's default manifest. + include("$(PORT_DIR)/boards/manifest.py") + # Add custom driver +-module("acamera.py") +\ No newline at end of file ++module("acamera.py") ++include("/home/user/projects/MicroPythonOS/claude/MicroPythonOS/lvgl_micropython/build/manifest.py") # workaround to prevent micropython-camera-API from overriding the lvgl_micropython manifest... +diff --git a/src/modcamera.c b/src/modcamera.c +index 5a0bd05..c84f09d 100644 +--- a/src/modcamera.c ++++ b/src/modcamera.c +@@ -252,7 +252,7 @@ const mp_rom_map_elem_t mp_camera_hal_pixel_format_table[] = { + const mp_rom_map_elem_t mp_camera_hal_frame_size_table[] = { + { MP_ROM_QSTR(MP_QSTR_R96X96), MP_ROM_INT((mp_uint_t)FRAMESIZE_96X96) }, + { MP_ROM_QSTR(MP_QSTR_QQVGA), MP_ROM_INT((mp_uint_t)FRAMESIZE_QQVGA) }, +- { MP_ROM_QSTR(MP_QSTR_R128x128), MP_ROM_INT((mp_uint_t)FRAMESIZE_128X128) }, ++ { MP_ROM_QSTR(MP_QSTR_R128X128), MP_ROM_INT((mp_uint_t)FRAMESIZE_128X128) }, + { MP_ROM_QSTR(MP_QSTR_QCIF), MP_ROM_INT((mp_uint_t)FRAMESIZE_QCIF) }, + { MP_ROM_QSTR(MP_QSTR_HQVGA), MP_ROM_INT((mp_uint_t)FRAMESIZE_HQVGA) }, + { MP_ROM_QSTR(MP_QSTR_R240X240), MP_ROM_INT((mp_uint_t)FRAMESIZE_240X240) }, +@@ -260,10 +260,17 @@ const mp_rom_map_elem_t mp_camera_hal_frame_size_table[] = { + { MP_ROM_QSTR(MP_QSTR_R320X320), MP_ROM_INT((mp_uint_t)FRAMESIZE_320X320) }, + { MP_ROM_QSTR(MP_QSTR_CIF), MP_ROM_INT((mp_uint_t)FRAMESIZE_CIF) }, + { MP_ROM_QSTR(MP_QSTR_HVGA), MP_ROM_INT((mp_uint_t)FRAMESIZE_HVGA) }, ++ { MP_ROM_QSTR(MP_QSTR_R480X480), MP_ROM_INT((mp_uint_t)FRAMESIZE_480X480) }, + { MP_ROM_QSTR(MP_QSTR_VGA), MP_ROM_INT((mp_uint_t)FRAMESIZE_VGA) }, ++ { MP_ROM_QSTR(MP_QSTR_R640X640), MP_ROM_INT((mp_uint_t)FRAMESIZE_640X640) }, ++ { MP_ROM_QSTR(MP_QSTR_R720X720), MP_ROM_INT((mp_uint_t)FRAMESIZE_720X720) }, + { MP_ROM_QSTR(MP_QSTR_SVGA), MP_ROM_INT((mp_uint_t)FRAMESIZE_SVGA) }, ++ { MP_ROM_QSTR(MP_QSTR_R800X800), MP_ROM_INT((mp_uint_t)FRAMESIZE_800X800) }, ++ { MP_ROM_QSTR(MP_QSTR_R960X960), MP_ROM_INT((mp_uint_t)FRAMESIZE_960X960) }, + { MP_ROM_QSTR(MP_QSTR_XGA), MP_ROM_INT((mp_uint_t)FRAMESIZE_XGA) }, ++ { MP_ROM_QSTR(MP_QSTR_R1024X1024),MP_ROM_INT((mp_uint_t)FRAMESIZE_1024X1024) }, + { MP_ROM_QSTR(MP_QSTR_HD), MP_ROM_INT((mp_uint_t)FRAMESIZE_HD) }, ++ { MP_ROM_QSTR(MP_QSTR_R1280X1280),MP_ROM_INT((mp_uint_t)FRAMESIZE_1280X1280) }, + { MP_ROM_QSTR(MP_QSTR_SXGA), MP_ROM_INT((mp_uint_t)FRAMESIZE_SXGA) }, + { MP_ROM_QSTR(MP_QSTR_UXGA), MP_ROM_INT((mp_uint_t)FRAMESIZE_UXGA) }, + { MP_ROM_QSTR(MP_QSTR_FHD), MP_ROM_INT((mp_uint_t)FRAMESIZE_FHD) }, +@@ -435,3 +442,22 @@ int mp_camera_hal_get_pixel_height(mp_camera_obj_t *self) { + framesize_t framesize = sensor->status.framesize; + return resolution[framesize].height; + } ++ ++int mp_camera_hal_set_res_raw(mp_camera_obj_t *self, int startX, int startY, int endX, int endY, int offsetX, int offsetY, int totalX, int totalY, int outputX, int outputY, bool scale, bool binning) { ++ check_init(self); ++ sensor_t *sensor = esp_camera_sensor_get(); ++ if (!sensor->set_res_raw) { ++ mp_raise_ValueError(MP_ERROR_TEXT("Sensor does not support set_res_raw")); ++ } ++ ++ if (self->captured_buffer) { ++ esp_camera_return_all(); ++ self->captured_buffer = NULL; ++ } ++ ++ int ret = sensor->set_res_raw(sensor, startX, startY, endX, endY, offsetX, offsetY, totalX, totalY, outputX, outputY, scale, binning); ++ if (ret < 0) { ++ mp_raise_ValueError(MP_ERROR_TEXT("Failed to set raw resolution")); ++ } ++ return ret; ++} +diff --git a/src/modcamera.h b/src/modcamera.h +index a3ce749..a8771bd 100644 +--- a/src/modcamera.h ++++ b/src/modcamera.h +@@ -211,7 +211,7 @@ extern const mp_rom_map_elem_t mp_camera_hal_pixel_format_table[9]; + * @brief Table mapping frame sizes API to their corresponding values at HAL. + * @details Needs to be defined in the port-specific implementation. + */ +-extern const mp_rom_map_elem_t mp_camera_hal_frame_size_table[24]; ++extern const mp_rom_map_elem_t mp_camera_hal_frame_size_table[31]; + + /** + * @brief Table mapping gainceiling API to their corresponding values at HAL. +@@ -278,4 +278,24 @@ DECLARE_CAMERA_HAL_GET(int, pixel_width) + DECLARE_CAMERA_HAL_GET(const char *, sensor_name) + DECLARE_CAMERA_HAL_GET(bool, supports_jpeg) + +-#endif // MICROPY_INCLUDED_MODCAMERA_H +\ No newline at end of file ++/** ++ * @brief Sets the raw resolution parameters including ROI (Region of Interest). ++ * ++ * @param self Pointer to the camera object. ++ * @param startX X start position. ++ * @param startY Y start position. ++ * @param endX X end position. ++ * @param endY Y end position. ++ * @param offsetX X offset. ++ * @param offsetY Y offset. ++ * @param totalX Total X size. ++ * @param totalY Total Y size. ++ * @param outputX Output X size. ++ * @param outputY Output Y size. ++ * @param scale Enable scaling. ++ * @param binning Enable binning. ++ * @return 0 on success, negative value on error. ++ */ ++extern int mp_camera_hal_set_res_raw(mp_camera_obj_t *self, int startX, int startY, int endX, int endY, int offsetX, int offsetY, int totalX, int totalY, int outputX, int outputY, bool scale, bool binning); ++ ++#endif // MICROPY_INCLUDED_MODCAMERA_H +diff --git a/src/modcamera_api.c b/src/modcamera_api.c +index 39afa71..8f888ca 100644 +--- a/src/modcamera_api.c ++++ b/src/modcamera_api.c +@@ -285,6 +285,48 @@ CREATE_GETSET_FUNCTIONS(wpc, mp_obj_new_bool, mp_obj_is_true); + CREATE_GETSET_FUNCTIONS(raw_gma, mp_obj_new_bool, mp_obj_is_true); + CREATE_GETSET_FUNCTIONS(lenc, mp_obj_new_bool, mp_obj_is_true); + ++// set_res_raw function for ROI (Region of Interest) / digital zoom ++static mp_obj_t camera_set_res_raw(size_t n_args, const mp_obj_t *pos_args, mp_map_t *kw_args) { ++ mp_camera_obj_t *self = MP_OBJ_TO_PTR(pos_args[0]); ++ enum { ARG_startX, ARG_startY, ARG_endX, ARG_endY, ARG_offsetX, ARG_offsetY, ARG_totalX, ARG_totalY, ARG_outputX, ARG_outputY, ARG_scale, ARG_binning }; ++ static const mp_arg_t allowed_args[] = { ++ { MP_QSTR_startX, MP_ARG_INT | MP_ARG_REQUIRED }, ++ { MP_QSTR_startY, MP_ARG_INT | MP_ARG_REQUIRED }, ++ { MP_QSTR_endX, MP_ARG_INT | MP_ARG_REQUIRED }, ++ { MP_QSTR_endY, MP_ARG_INT | MP_ARG_REQUIRED }, ++ { MP_QSTR_offsetX, MP_ARG_INT | MP_ARG_REQUIRED }, ++ { MP_QSTR_offsetY, MP_ARG_INT | MP_ARG_REQUIRED }, ++ { MP_QSTR_totalX, MP_ARG_INT | MP_ARG_REQUIRED }, ++ { MP_QSTR_totalY, MP_ARG_INT | MP_ARG_REQUIRED }, ++ { MP_QSTR_outputX, MP_ARG_INT | MP_ARG_REQUIRED }, ++ { MP_QSTR_outputY, MP_ARG_INT | MP_ARG_REQUIRED }, ++ { MP_QSTR_scale, MP_ARG_BOOL, {.u_bool = false} }, ++ { MP_QSTR_binning, MP_ARG_BOOL, {.u_bool = false} }, ++ }; ++ ++ mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; ++ mp_arg_parse_all(n_args - 1, pos_args + 1, kw_args, MP_ARRAY_SIZE(allowed_args), allowed_args, args); ++ ++ int ret = mp_camera_hal_set_res_raw( ++ self, ++ args[ARG_startX].u_int, ++ args[ARG_startY].u_int, ++ args[ARG_endX].u_int, ++ args[ARG_endY].u_int, ++ args[ARG_offsetX].u_int, ++ args[ARG_offsetY].u_int, ++ args[ARG_totalX].u_int, ++ args[ARG_totalY].u_int, ++ args[ARG_outputX].u_int, ++ args[ARG_outputY].u_int, ++ args[ARG_scale].u_bool, ++ args[ARG_binning].u_bool ++ ); ++ ++ return mp_obj_new_int(ret); ++} ++static MP_DEFINE_CONST_FUN_OBJ_KW(camera_set_res_raw_obj, 1, camera_set_res_raw); ++ + //API-Tables + static const mp_rom_map_elem_t camera_camera_locals_table[] = { + { MP_ROM_QSTR(MP_QSTR_reconfigure), MP_ROM_PTR(&camera_reconfigure_obj) }, +@@ -293,6 +335,7 @@ static const mp_rom_map_elem_t camera_camera_locals_table[] = { + { MP_ROM_QSTR(MP_QSTR_free_buffer), MP_ROM_PTR(&camera_free_buf_obj) }, + { MP_ROM_QSTR(MP_QSTR_init), MP_ROM_PTR(&camera_init_obj) }, + { MP_ROM_QSTR(MP_QSTR_deinit), MP_ROM_PTR(&mp_camera_deinit_obj) }, ++ { MP_ROM_QSTR(MP_QSTR_set_res_raw), MP_ROM_PTR(&camera_set_res_raw_obj) }, + { MP_ROM_QSTR(MP_QSTR___del__), MP_ROM_PTR(&mp_camera_deinit_obj) }, + { MP_ROM_QSTR(MP_QSTR___enter__), MP_ROM_PTR(&mp_identity_obj) }, + { MP_ROM_QSTR(MP_QSTR___exit__), MP_ROM_PTR(&mp_camera___exit___obj) }, diff --git a/scripts/addr2line.sh b/scripts/addr2line.sh new file mode 100644 index 00000000..660e39d0 --- /dev/null +++ b/scripts/addr2line.sh @@ -0,0 +1 @@ +~/.espressif/tools/xtensa-esp-elf/esp-14.2.0_20241119/xtensa-esp-elf/bin/xtensa-esp-elf-addr2line -e lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC_S3-SPIRAM_OCT/micropython.elf diff --git a/scripts/build_mpos.sh b/scripts/build_mpos.sh index 953a264b..5f0903e9 100755 --- a/scripts/build_mpos.sh +++ b/scripts/build_mpos.sh @@ -8,17 +8,13 @@ target="$1" buildtype="$2" subtarget="$3" -if [ -z "$target" -o -z "$buildtype" ]; then - echo "Usage: $0 target buildtype [optional subtarget]" - echo "Usage: $0 []" - echo "Example: $0 unix dev" - echo "Example: $0 esp32 dev fri3d-2024" - echo "Example: $0 esp32 prod fri3d-2024" - echo "Example: $0 esp32 dev waveshare-esp32-s3-touch-lcd-2" - echo "Example: $0 esp32 prod waveshare-esp32-s3-touch-lcd-2" +if [ -z "$target" ]; then + echo "Usage: $0 target" + echo "Usage: $0 " + echo "Example: $0 unix" + echo "Example: $0 macOS" + echo "Example: $0 esp32" echo - echo "A 'dev' build is without any preinstalled files or builtin/ filsystem, so it will just start with a black screen and you'll have to do: ./scripts/install.sh to install the User Interface." - echo "A 'prod' build has the files from manifest*.py frozen in. Don't forget to run: ./scripts/freezefs_mount_builtin.sh !" exit 1 fi @@ -74,29 +70,18 @@ echo "Symlinking secp256k1-embedded-ecdh for unix and macOS builds..." ln -sf ../../secp256k1-embedded-ecdh "$codebasedir"/lvgl_micropython/ext_mod/secp256k1-embedded-ecdh echo "Symlinking c_mpos for unix and macOS builds..." ln -sf ../../c_mpos "$codebasedir"/lvgl_micropython/ext_mod/c_mpos +# Only for MicroPython 1.26.1 workaround: +#echo "Applying lvgl_micropython i2c patch..." +#patch -p0 --forward < "$codebasedir"/patches/i2c_ng.patch -if [ "$buildtype" == "prod" ]; then - freezefs="$codebasedir"/scripts/freezefs_mount_builtin.sh - echo "It's a $buildtype build, running $freezefs" - $freezefs -fi - - +echo "Refreshing freezefs..." +"$codebasedir"/scripts/freezefs_mount_builtin.sh manifest="" if [ "$target" == "esp32" ]; then - if [ "$buildtype" == "prod" ]; then - if [ "$subtarget" == "fri3d-2024" ]; then - cp internal_filesystem/boot_fri3d-2024.py /tmp/boot.py # dirty hack to have it included as boot.py by the manifest - manifest="manifest_fri3d-2024.py" - else - manifest="manifest.py" - fi - manifest=$(readlink -f "$codebasedir"/manifests/"$manifest") - frozenmanifest="FROZEN_MANIFEST=$manifest" - else - echo "Note that you can also prevent the builtin filesystem from being mounted by umounting it and creating a builtin/ folder." - fi + manifest=$(readlink -f "$codebasedir"/manifests/manifest.py) + frozenmanifest="FROZEN_MANIFEST=$manifest" + echo "Note that you can also prevent the builtin filesystem from being mounted by umounting it and creating a builtin/ folder." # Build for https://www.waveshare.com/wiki/ESP32-S3-Touch-LCD-2. # See https://github.com/lvgl-micropython/lvgl_micropython # --ota: support Over-The-Air updates @@ -114,18 +99,25 @@ if [ "$target" == "esp32" ]; then python3 make.py --ota --partition-size=4194304 --flash-size=16 esp32 BOARD=ESP32_GENERIC_S3 BOARD_VARIANT=SPIRAM_OCT DISPLAY=st7789 INDEV=cst816s USER_C_MODULE="$codebasedir"/micropython-camera-API/src/micropython.cmake USER_C_MODULE="$codebasedir"/secp256k1-embedded-ecdh/micropython.cmake USER_C_MODULE="$codebasedir"/c_mpos/micropython.cmake CONFIG_FREERTOS_USE_TRACE_FACILITY=y CONFIG_FREERTOS_VTASKLIST_INCLUDE_COREID=y CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS=y "$frozenmanifest" popd elif [ "$target" == "unix" -o "$target" == "macOS" ]; then - if [ "$buildtype" == "prod" ]; then - manifest=$(readlink -f "$codebasedir"/manifests/manifest_unix.py) - frozenmanifest="FROZEN_MANIFEST=$manifest" - fi - # build for desktop - #python3 make.py "$target" DISPLAY=sdl_display INDEV=sdl_pointer INDEV=sdl_keyboard "$manifest" + manifest=$(readlink -f "$codebasedir"/manifests/manifest.py) + frozenmanifest="FROZEN_MANIFEST=$manifest" + + # Comment out @micropython.viper decorator for Unix/macOS builds + # (cross-compiler doesn't support Viper native code emitter) + echo "Temporarily commenting out @micropython.viper decorator for Unix/macOS build..." + stream_wav_file="$codebasedir"/internal_filesystem/lib/mpos/audio/stream_wav.py + sed -i.backup 's/^@micropython\.viper$/#@micropython.viper/' "$stream_wav_file" + # LV_CFLAGS are passed to USER_C_MODULES # STRIP= makes it so that debug symbols are kept pushd "$codebasedir"/lvgl_micropython/ # USER_C_MODULE doesn't seem to work properly so there are symlinks in lvgl_micropython/extmod/ - python3 make.py "$target" LV_CFLAGS="-g -O0 -ggdb -ljpeg" STRIP= DISPLAY=sdl_display INDEV=sdl_pointer INDEV=sdl_keyboard "$manifest" + python3 make.py "$target" LV_CFLAGS="-g -O0 -ggdb -ljpeg" STRIP= DISPLAY=sdl_display INDEV=sdl_pointer INDEV=sdl_keyboard "$frozenmanifest" popd + + # Restore @micropython.viper decorator after build + echo "Restoring @micropython.viper decorator..." + sed -i.backup 's/^#@micropython\.viper$/@micropython.viper/' "$stream_wav_file" else echo "invalid target $target" fi diff --git a/scripts/bundle_apps.sh b/scripts/bundle_apps.sh index af079b64..e939ebc3 100755 --- a/scripts/bundle_apps.sh +++ b/scripts/bundle_apps.sh @@ -14,16 +14,24 @@ mkdir -p "$output" #rm "$output"/*.png rm "$outputjson" -blacklist="com.micropythonos.filemanager com.example.bla" +# These apps are for testing, or aren't ready yet: +# com.quasikili.quasidoodle doesn't work on touch screen devices +# com.micropythonos.filemanager doesn't do anything other than let you browse the filesystem, so it's confusing +# com.micropythonos.confetti crashes when closing +# com.micropythonos.showfonts is slow to open +# com.micropythonos.draw isnt very useful +# com.micropythonos.errortest is an intentional bad app for testing (caught by tests/test_graphical_launch_all_apps.py) +# com.micropythonos.showbattery is just a test +blacklist="com.micropythonos.filemanager com.quasikili.quasidoodle com.micropythonos.confetti com.micropythonos.showfonts com.micropythonos.draw com.micropythonos.errortest com.micropythonos.showbattery" echo "[" | tee -a "$outputjson" # currently, this script doesn't purge unnecessary information from the manifests, such as activities #for apprepo in internal_filesystem/apps internal_filesystem/builtin/apps; do -for apprepo in internal_filesystem/apps +for apprepo in internal_filesystem/apps; do echo "Listing apps in $apprepo" - ls -1 "$apprepo" | while read appdir; do + ls -1 "$apprepo" | sort | while read appdir; do if echo "$blacklist" | grep "$appdir"; then echo "Skipping $appdir because it's in blacklist $blacklist" else @@ -41,7 +49,7 @@ for apprepo in internal_filesystem/apps echo "Setting file modification times to a fixed value..." find . -type f -exec touch -t 202501010000.00 {} \; echo "Creating $mpkname with deterministic file order..." - find . -type f | sort | TZ=CET zip -X -r0 "$mpkname" -@ + find . -type f | grep -v ".git/" | sort | TZ=CET zip -X -r0 "$mpkname" -@ cp res/mipmap-mdpi/icon_64x64.png "$thisappdir"/icons/"$appdir"_"$version"_64x64.png popd fi diff --git a/scripts/changelog_to_json.sh b/scripts/changelog_to_json.sh index ad5cd07b..45175630 100755 --- a/scripts/changelog_to_json.sh +++ b/scripts/changelog_to_json.sh @@ -1 +1,4 @@ -cat CHANGELOG.md | tr -d "\n" | sed 's/- /\\n- /g' +sed -i "s/\"/'/g" CHANGELOG.md # change double to single quotes +#cat CHANGELOG.md | tr -d "\n" | sed 's/- /\\n- /g' +sed ':a;N;$!ba;s/\n/\\n/g' CHANGELOG.md + diff --git a/scripts/cleanup_pyc.sh b/scripts/cleanup_pyc.sh new file mode 100755 index 00000000..55f63f4b --- /dev/null +++ b/scripts/cleanup_pyc.sh @@ -0,0 +1 @@ +find internal_filesystem -iname "*.pyc" -exec rm {} \; diff --git a/scripts/convert_raw_to_png.sh b/scripts/convert_raw_to_png.sh new file mode 100644 index 00000000..ae1c5350 --- /dev/null +++ b/scripts/convert_raw_to_png.sh @@ -0,0 +1,12 @@ +inputfile="$1" +if [ -z "$inputfile" ]; then + echo "Usage: $0 inputfile" + echo "Example: $0 camera_capture_1764503331_960x960_GRAY.raw" + exit 1 +fi + +outputfile="$inputfile".png +echo "Converting $inputfile to $outputfile" + +# For now it's pretty hard coded but the format could be extracted from the filename... +convert -size 960x960 -depth 8 gray:"$inputfile" "$outputfile" diff --git a/scripts/flash_over_usb.sh b/scripts/flash_over_usb.sh index 2dbca395..8033bbf0 100755 --- a/scripts/flash_over_usb.sh +++ b/scripts/flash_over_usb.sh @@ -1,6 +1,10 @@ mydir=$(readlink -f "$0") mydir=$(dirname "$mydir") +fwfile="$0" +# This would break the --erase-all +#if [ -z "$fwfile" ]; then fwfile="$mydir/../lvgl_micropython/build/lvgl_micropy_ESP32_GENERIC_S3-SPIRAM_OCT-16.bin" +#fi ls -al $fwfile echo "Add --erase-all if needed" sleep 5 diff --git a/scripts/install.sh b/scripts/install.sh index e196bd59..9e4aa66b 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -3,24 +3,23 @@ mydir=$(dirname "$mydir") pkill -f "python.*mpremote" -target="$1" -appname="$2" - -if [ -z "$target" ]; then - echo "Usage: $0 [appname]" - echo "Example: $0 fri3d-2024" - echo "Example: $0 waveshare-esp32-s3-touch-lcd-2" - echo "Example: $0 fri3d-2024 appstore" - echo "Example: $0 waveshare-esp32-s3-touch-lcd-2 imu" - exit 1 -fi - +appname="$1" +echo "This script will install the important files from internal_filesystem/ on the device using mpremote.py" +echo +echo "Usage: $0 [appname]" +echo "Example: $0" +echo "Example: $0 com.micropythonos.about" mpremote=$(readlink -f "$mydir/../lvgl_micropython/lib/micropython/tools/mpremote/mpremote.py") pushd internal_filesystem/ +# Maybe also do: import mpos ; mpos.TaskManager.stop() +echo "Disabling wifi because it writes to REPL from time to time when doing disconnect/reconnect for ADC2..." +$mpremote exec "import mpos ; mpos.net.wifi_service.WifiService.disconnect()" +sleep 2 + if [ ! -z "$appname" ]; then echo "Installing one app: $appname" appdir="apps/$appname/" @@ -35,8 +34,8 @@ if [ ! -z "$appname" ]; then fi fi $mpremote mkdir "/apps" - $mpremote mkdir "/builtin" - $mpremote mkdir "/builtin/apps" + #$mpremote mkdir "/builtin" # dont do this because it breaks the mount! + #$mpremote mkdir "/builtin/apps" $mpremote fs cp -r "$appdir" :/"$target" echo "start_app(\"/$appdir\")" $mpremote @@ -44,38 +43,41 @@ if [ ! -z "$appname" ]; then exit fi +# boot.py is not copied because it can't be overridden anyway -if [ -z "$target" -o "$target" == "waveshare-esp32-s3-touch-lcd-2" ]; then - $mpremote fs cp boot.py :/boot.py -else - $mpremote fs cp boot_"$target".py :/boot.py -fi -$mpremote fs cp main.py :/main.py - -#$mpremote fs cp main.py :/system/button.py -#$mpremote fs cp autorun.py :/autorun.py -#$mpremote fs cp -r system :/ +# The issue is that this brings all the .git folders with it: +#$mpremote fs cp -r apps :/ -$mpremote fs cp -r apps :/ +$mpremote fs cp -r lib :/ -if false; then +$mpremote fs mkdir :/apps $mpremote fs cp -r apps/com.micropythonos.* :/apps/ -find apps/ -type l | while read symlink; do +find apps/ -maxdepth 1 -type l | while read symlink; do echo "Handling symlink $symlink" - $mpremote fs mkdir :/{} + $mpremote fs mkdir :/"$symlink" + $mpremote fs cp -r "$symlink"/* :/"$symlink"/ + done -fi +#echo "Unmounting builtin/ so that it can be customized..." # not sure this is necessary +#$mpremote exec "import os ; os.umount('/builtin')" $mpremote fs cp -r builtin :/ -$mpremote fs cp -r lib :/ -$mpremote fs cp -r resources :/ #$mpremote fs cp -r data :/ #$mpremote fs cp -r data/images :/data/ +$mpremote fs mkdir :/data +$mpremote fs mkdir :/data/com.micropythonos.system.wifiservice +$mpremote fs cp ../internal_filesystem_excluded/data/com.micropythonos.system.wifiservice/config.json :/data/com.micropythonos.system.wifiservice/ + popd -if [ -z "$appname" ]; then +# Install test infrastructure (for running ondevice tests) +echo "Installing test infrastructure..." +$mpremote fs mkdir :/tests +$mpremote fs mkdir :/tests/screenshots + +if [ ! -z "$appname" ]; then echo "Not resetting so the installed app can be used immediately." $mpremote reset fi diff --git a/scripts/mklittlefs.sh b/scripts/mklittlefs.sh new file mode 100755 index 00000000..1f7be0c4 --- /dev/null +++ b/scripts/mklittlefs.sh @@ -0,0 +1,8 @@ +#!/bin/sh + +mydir=$(readlink -f "$0") +mydir=$(dirname "$mydir") + +size=0x200000 # 2MB +~/sources/mklittlefs/mklittlefs -c "$mydir"/../internal_filesystem/ -s "$size" internal_filesystem.bin + diff --git a/scripts/run_desktop.sh b/scripts/run_desktop.sh index 1f229ac6..63becd24 100755 --- a/scripts/run_desktop.sh +++ b/scripts/run_desktop.sh @@ -56,15 +56,17 @@ binary=$(readlink -f "$binary") chmod +x "$binary" pushd internal_filesystem/ - if [ -f "$script" ]; then - "$binary" -v -i "$script" - elif [ ! -z "$script" ]; then # it's an app name - scriptdir="$script" - echo "Running app from $scriptdir" - "$binary" -X heapsize=$HEAPSIZE -v -i -c "$(cat boot_unix.py main.py) ; import mpos.apps; mpos.apps.start_app('$scriptdir')" - else - "$binary" -X heapsize=$HEAPSIZE -v -i -c "$(cat boot_unix.py main.py)" - fi - + +if [ -f "$script" ]; then + echo "Running script $script" + "$binary" -v -i "$script" +else + echo "Running app $script" + mv data/com.micropythonos.settings/config.json data/com.micropythonos.settings/config.json.backup + # When $script is empty, it just doesn't find the app and stays at the launcher + echo '{"auto_start_app": "'$script'"}' > data/com.micropythonos.settings/config.json + "$binary" -X heapsize=$HEAPSIZE -v -i -c "$(cat main.py)" + mv data/com.micropythonos.settings/config.json.backup data/com.micropythonos.settings/config.json +fi popd diff --git a/secp256k1-embedded-ecdh b/secp256k1-embedded-ecdh index 3d5149dd..956c014d 160000 --- a/secp256k1-embedded-ecdh +++ b/secp256k1-embedded-ecdh @@ -1 +1 @@ -Subproject commit 3d5149ddc4814cd4c70d5190a52035e4d45ee52f +Subproject commit 956c014d44a3efaa0fcceeb91a7ea1f93df7a012 diff --git a/tests/analyze_screenshot.py b/tests/analyze_screenshot.py new file mode 100755 index 00000000..328c19e0 --- /dev/null +++ b/tests/analyze_screenshot.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +""" +Analyze RGB565 screenshots for color correctness. + +Usage: + python3 analyze_screenshot.py screenshot.raw [width] [height] + +Checks: +- Color channel distribution (detect pale/washed out colors) +- Histogram analysis +- Average brightness +- Color saturation levels +""" + +import sys +import struct +from pathlib import Path + +def rgb565_to_rgb888(pixel): + """Convert RGB565 pixel to RGB888.""" + r5 = (pixel >> 11) & 0x1F + g6 = (pixel >> 5) & 0x3F + b5 = pixel & 0x1F + + r8 = (r5 << 3) | (r5 >> 2) + g8 = (g6 << 2) | (g6 >> 4) + b8 = (b5 << 3) | (b5 >> 2) + + return r8, g8, b8 + +def analyze_screenshot(filepath, width=320, height=240): + """Analyze RGB565 screenshot file.""" + print(f"Analyzing: {filepath}") + print(f"Dimensions: {width}x{height}") + + # Read raw data + try: + with open(filepath, 'rb') as f: + data = f.read() + except FileNotFoundError: + print(f"ERROR: File not found: {filepath}") + return + + expected_size = width * height * 2 + if len(data) != expected_size: + print(f"ERROR: File size mismatch. Expected {expected_size}, got {len(data)}") + print(f" Note: Expected size is for {width}x{height} RGB565 format") + return + + # Parse RGB565 pixels + pixels = [] + for i in range(0, len(data), 2): + # Little-endian RGB565 + pixel = struct.unpack(' 200: + print(" ⚠ WARNING: Very high brightness (overexposed)") + elif avg_brightness < 40: + print(" ⚠ WARNING: Very low brightness (underexposed)") + + # Simple histogram (10 bins) + print(f"\nChannel Histograms:") + for channel_name, channel_values in [('Red', red_values), ('Green', green_values), ('Blue', blue_values)]: + print(f" {channel_name}:") + + # Create 10 bins + bins = [0] * 10 + for val in channel_values: + bin_idx = min(9, val // 26) # 256 / 10 ≈ 26 + bins[bin_idx] += 1 + + for i, count in enumerate(bins): + bar_length = int((count / len(channel_values)) * 50) + bar = '█' * bar_length + bin_start = i * 26 + bin_end = (i + 1) * 26 - 1 + print(f" {bin_start:3d}-{bin_end:3d}: {bar} ({count})") + + # Detect common YUV conversion issues + print(f"\nYUV Conversion Checks:") + + # Check if colors are clamped (many pixels at 0 or 255) + clamped_count = sum(1 for r, g, b in pixels if r == 0 or r == 255 or g == 0 or g == 255 or b == 0 or b == 255) + total_pixels = len(pixels) + clamp_percent = (clamped_count / total_pixels) * 100 + print(f" Clamped pixels: {clamp_percent:.1f}%") + if clamp_percent > 5: + print(" ⚠ WARNING: High clamp rate suggests color conversion overflow") + + # Check for green tint (common YUYV issue) + avg_red = sum(red_values) / len(red_values) + avg_green = sum(green_values) / len(green_values) + avg_blue = sum(blue_values) / len(blue_values) + + green_dominance = avg_green - ((avg_red + avg_blue) / 2) + if green_dominance > 20: + print(f" ⚠ WARNING: Green channel dominance ({green_dominance:.1f}) - possible YUYV U/V swap") + + # Sample pixels for visual inspection + print(f"\nSample Pixels (first 10):") + for i in range(min(10, len(pixels))): + r, g, b = pixels[i] + print(f" Pixel {i}: RGB({r:3d}, {g:3d}, {b:3d})") + +if __name__ == '__main__': + if len(sys.argv) < 2: + print("Usage: python3 analyze_screenshot.py [width] [height]") + print("") + print("Examples:") + print(" python3 analyze_screenshot.py camera_capture.raw") + print(" python3 analyze_screenshot.py camera_640x480.raw 640 480") + sys.exit(1) + + filepath = sys.argv[1] + width = int(sys.argv[2]) if len(sys.argv) > 2 else 320 + height = int(sys.argv[3]) if len(sys.argv) > 3 else 240 + + analyze_screenshot(filepath, width, height) diff --git a/tests/manual_test_camera.py b/tests/manual_test_camera.py new file mode 100644 index 00000000..70a2ec11 --- /dev/null +++ b/tests/manual_test_camera.py @@ -0,0 +1,62 @@ +import unittest + +from mpos import App, PackageManager + +from camera import Camera, GrabMode, PixelFormat, FrameSize, GainCeiling + +class TestCompareVersions(unittest.TestCase): + + def init_cam(self): + try: + cam = Camera(data_pins=[12,13,15,11,14,10,7,2],vsync_pin=6,href_pin=4,sda_pin=21,scl_pin=16,pclk_pin=9,xclk_pin=8,xclk_freq=20000000,pixel_format=PixelFormat.RGB565,powerdown_pin=-1,reset_pin=-1,frame_size=FrameSize.R240X240,grab_mode=GrabMode.LATEST) + return cam + except Exception as e: + #self.assertTrue(False, f"camera init received exception: {e}") + print(f"camera init received exception: {e}") + return None + + def test_init_capture_deinit(self): + cam = self.init_cam() + self.assertTrue(cam is not None, "camera failed to initialize") + self.assertEqual(cam.get_pixel_height(), 240, "wrong pixel height") + self.assertEqual(cam.get_pixel_width(), 240, "wrong pixel width") + memview = cam.capture() + self.assertEqual(len(memview), 2 * 240 * 240, "capture size does not match expectations") + cam.deinit() + + def disabled_test_multiple_runs(self): + for _ in range(10): + self.test_init_capture_deinit() + + def disabled_test_init_capture_deinit_poweroff(self): + self.test_init_capture_deinit() + from machine import Pin, I2C + i2c = I2C(1, scl=Pin(16), sda=Pin(21), freq=100000) # Adjust pins and frequency + devices = i2c.scan() + print([hex(addr) for addr in devices]) # finds it on 60 = 0x3C after init + camera_addr = 0x3C # for OV5640 + reg_addr = 0x3008 + reg_high = (reg_addr >> 8) & 0xFF # 0x30 + reg_low = reg_addr & 0xFF # 0x08 + power_off_command = 0x40 # Power off command Bit[6]: Software power down + #i2c.writeto(camera_addr, bytes([reg_high, reg_low, power_off_command])) + print("\nSecond capture will fail!") + self.test_init_capture_deinit() + + def test_init_twice_capture_deinit_poweroff(self): + self.test_init_capture_deinit() + from machine import Pin, I2C + i2c = I2C(1, scl=Pin(16), sda=Pin(21), freq=100000) # Adjust pins and frequency + devices = i2c.scan() + print([hex(addr) for addr in devices]) # finds it on 60 = 0x3C after init + camera_addr = 0x3C # for OV5640 + reg_addr = 0x3008 + reg_high = (reg_addr >> 8) & 0xFF # 0x30 + reg_low = reg_addr & 0xFF # 0x08 + power_off_command = 0x40 # Power off command Bit[6]: Software power down + #i2c.writeto(camera_addr, bytes([reg_high, reg_low, power_off_command])) + cam = self.init_cam() + self.assertTrue(cam is None, "expected camera to fail after i2c") + print("\nSecond capture should now work!") + self.test_init_capture_deinit() + diff --git a/tests/manual_test_lnbitswallet.py b/tests/manual_test_lnbitswallet.py new file mode 100644 index 00000000..4abbe015 --- /dev/null +++ b/tests/manual_test_lnbitswallet.py @@ -0,0 +1,100 @@ +import asyncio +import json +import ssl +import _thread +import time +import unittest +import requests +import ujson + +import sys +sys.path.append("apps/com.lightningpiggy.displaywallet/assets/") +from wallet import LNBitsWallet + +class TestLNBitsWallet(unittest.TestCase): + + redraw_balance_cb_called = 0 + redraw_payments_cb_called = 0 + redraw_static_receive_code_cb_called = 0 + error_callback_called = 0 + + def redraw_balance_cb(self, balance_added=0): + print(f"redraw_callback called, balance_added: {balance_added}") + self.redraw_balance_cb_called += 1 + + def redraw_payments_cb(self): + print(f"redraw_payments_cb called") + self.redraw_payments_cb_called += 1 + + def redraw_static_receive_code_cb(self): + print(f"redraw_static_receive_code_cb called") + self.redraw_static_receive_code_cb_called += 1 + + def error_callback(self, error): + print(f"error_callback called, error: {error}") + self.error_callback_called += 1 + + def update_balance(self, sats): + """ + Updates the user balance by 'sats' amount using the local API. + Authenticates first, then sends the balance update. + """ + try: + # Step 1: Authenticate and get access token + auth_url = "http://192.168.1.16:5000/api/v1/auth" + auth_payload = {"username": "admin", "password": "adminadmin"} + print("Authenticating...") + auth_response = requests.post( auth_url, json=auth_payload, headers={"Content-Type": "application/json"} ) + if auth_response.status_code != 200: + print("Auth failed:", auth_response.text) + auth_response.close() + return False + auth_data = ujson.loads(auth_response.text) + access_token = auth_data["access_token"] + auth_response.close() + print("Authenticated, got token.") + # Step 2: Update balance + balance_url = "http://192.168.1.16:5000/users/api/v1/balance" + balance_payload = { "amount": str(sats), "id": "24e9334d39b946a3b642f5fd8c292a07" } + cookie_header = f"cookie_access_token={access_token}; is_lnbits_user_authorized=true" + print(f"Updating balance by {sats} sats...") + update_response = requests.put( + balance_url, + json=balance_payload, + headers={ "Content-Type": "application/json", "Cookie": cookie_header }) + result = ujson.loads(update_response.text) + update_response.close() + if result.get("success"): + print("Balance updated successfully!") + return True + else: + print("Update failed:", result) + return False + except Exception as e: + print("Error:", e) + return False + + def test_it(self): + print("starting test") + import sys + print(sys.path) + self.wallet = LNBitsWallet("http://192.168.1.16:5000/", "5a2cf5d536ec45cb9a043071002e4449") + self.wallet.start(self.redraw_balance_cb, self.redraw_payments_cb, self.redraw_static_receive_code_cb, self.error_callback) + time.sleep(3) + self.assertEqual(self.redraw_balance_cb_called, 1) + self.assertGreaterEqual(self.redraw_payments_cb_called, 1) # called once for all of them + before_receive = self.redraw_payments_cb_called + self.assertEqual(self.redraw_static_receive_code_cb_called, 0) # no static receive code so error 404 + self.assertEqual(self.error_callback_called, 1) + print("Everything good so far, now add a transaction...") + self.update_balance(9) + time.sleep(2) # allow some time for the notification + self.wallet.stop() # don't stop the wallet for the fullscreen QR activity + time.sleep(2) + self.assertEqual(self.redraw_balance_cb_called, 2) + self.assertGreaterEqual(self.redraw_payments_cb_called, before_receive+1) + self.assertEqual(self.redraw_static_receive_code_cb_called, 0) # no static receive code so error 404 + self.assertEqual(self.error_callback_called, 1) + print("test finished") + + diff --git a/tests/manual_test_nostr_asyncio.py b/tests/manual_test_nostr_asyncio.py new file mode 100644 index 00000000..7962afa5 --- /dev/null +++ b/tests/manual_test_nostr_asyncio.py @@ -0,0 +1,332 @@ +import asyncio +import json +import ssl +import _thread +import time +import unittest + +from mpos import App, PackageManager +import mpos.apps + +from nostr.relay_manager import RelayManager +from nostr.message_type import ClientMessageType +from nostr.filter import Filter, Filters +from nostr.event import EncryptedDirectMessage +from nostr.key import PrivateKey + + +# keeps a list of items +# The .add() method ensures the list remains unique (via __eq__) +# and sorted (via __lt__) by inserting new items in the correct position. +class UniqueSortedList: + def __init__(self): + self._items = [] + + def add(self, item): + #print(f"before add: {str(self)}") + # Check if item already exists (using __eq__) + if item not in self._items: + # Insert item in sorted position for descending order (using __gt__) + for i, existing_item in enumerate(self._items): + if item > existing_item: + self._items.insert(i, item) + return + # If item is smaller than all existing items, append it + self._items.append(item) + #print(f"after add: {str(self)}") + + def __iter__(self): + # Return iterator for the internal list + return iter(self._items) + + def get(self, index_nr): + # Retrieve item at given index, raise IndexError if invalid + try: + return self._items[index_nr] + except IndexError: + raise IndexError("Index out of range") + + def __len__(self): + # Return the number of items for len() calls + return len(self._items) + + def __str__(self): + #print("UniqueSortedList tostring called") + return "\n".join(str(item) for item in self._items) + + def __eq__(self, other): + if len(self._items) != len(other): + return False + return all(p1 == p2 for p1, p2 in zip(self._items, other)) + +# Payment class remains unchanged +class Payment: + def __init__(self, epoch_time, amount_sats, comment): + self.epoch_time = epoch_time + self.amount_sats = amount_sats + self.comment = comment + + def __str__(self): + sattext = "sats" + if self.amount_sats == 1: + sattext = "sat" + #return f"{self.amount_sats} {sattext} @ {self.epoch_time}: {self.comment}" + return f"{self.amount_sats} {sattext}: {self.comment}" + + def __eq__(self, other): + if not isinstance(other, Payment): + return False + return self.epoch_time == other.epoch_time and self.amount_sats == other.amount_sats and self.comment == other.comment + + def __lt__(self, other): + if not isinstance(other, Payment): + return NotImplemented + return (self.epoch_time, self.amount_sats, self.comment) < (other.epoch_time, other.amount_sats, other.comment) + + def __le__(self, other): + if not isinstance(other, Payment): + return NotImplemented + return (self.epoch_time, self.amount_sats, self.comment) <= (other.epoch_time, other.amount_sats, other.comment) + + def __gt__(self, other): + if not isinstance(other, Payment): + return NotImplemented + return (self.epoch_time, self.amount_sats, self.comment) > (other.epoch_time, other.amount_sats, other.comment) + + def __ge__(self, other): + if not isinstance(other, Payment): + return NotImplemented + return (self.epoch_time, self.amount_sats, self.comment) >= (other.epoch_time, other.amount_sats, other.comment) + + + +class TestNostr(unittest.TestCase): + + PAYMENTS_TO_SHOW = 5 + + keep_running = None + connected = None + balance = -1 + payment_list = [] + transactions_welcome = False + + relays = [ "ws://192.168.1.16:5000/nostrrelay/test", "ws://192.168.1.16:5000/nostrclient/api/v1/relay" ] + #relays = [ "ws://127.0.0.1:5000/nostrrelay/test", "ws://127.0.0.1:5000/nostrclient/api/v1/relay" ] + #relays = [ "wss://relay.damus.io", "wss://nostr-pub.wellorder.net" ] + #relays = [ "ws://127.0.0.1:5000/nostrrelay/test", "ws://127.0.0.1:5000/nostrclient/api/v1/relay", "wss://relay.damus.io", "wss://nostr-pub.wellorder.net" ] + #relays = [ "ws://127.0.0.1:5000/nostrclient/api/v1/relay", "wss://relay.damus.io", "wss://nostr-pub.wellorder.net" ] + secret = "fab0a9a11d4cf4b1d92e901a0b2c56634275e2fa1a7eb396ff1b942f95d59fd3" # not really a secret, just from a local fake wallet + wallet_pubkey = "e46762afab282c324278351165122345f9983ea447b47943b052100321227571" + + async def fetch_balance(self): + if not self.keep_running: + return + # Create get_balance request + balance_request = { + "method": "get_balance", + "params": {} + } + print(f"DEBUG: Created balance request: {balance_request}") + print(f"DEBUG: Creating encrypted DM to wallet pubkey: {self.wallet_pubkey}") + dm = EncryptedDirectMessage( + recipient_pubkey=self.wallet_pubkey, + cleartext_content=json.dumps(balance_request), + kind=23194 + ) + print(f"DEBUG: Signing DM {json.dumps(dm)} with private key") + self.private_key.sign_event(dm) # sign also does encryption if it's a encrypted dm + print(f"DEBUG: Publishing encrypted DM") + self.relay_manager.publish_event(dm) + + def handle_new_balance(self, new_balance, fetchPaymentsIfChanged=True): + if not self.keep_running or new_balance is None: + return + if fetchPaymentsIfChanged: # Fetching *all* payments isn't necessary if balance was changed by a payment notification + print("Refreshing payments...") + self.fetch_payments() # if the balance changed, then re-list transactions + + def fetch_payments(self): + if not self.keep_running: + return + # Create get_balance request + list_transactions = { + "method": "list_transactions", + "params": { + "limit": self.PAYMENTS_TO_SHOW + } + } + dm = EncryptedDirectMessage( + recipient_pubkey=self.wallet_pubkey, + cleartext_content=json.dumps(list_transactions), + kind=23194 + ) + self.private_key.sign_event(dm) # sign also does encryption if it's a encrypted dm + print("\nPublishing DM to fetch payments...") + self.relay_manager.publish_event(dm) + self.transactions_welcome = True + + def handle_new_payments(self, new_payments): + if not self.keep_running or not self.transactions_welcome: + return + print("handle_new_payments") + if self.payment_list != new_payments: + print("new list of payments") + self.payment_list = new_payments + self.payments_updated_cb() + + def payments_updated_cb(self): + print("payments_updated_cb called, now closing everything!") + self.keep_running = False + + def getCommentFromTransaction(self, transaction): + comment = "" + try: + comment = transaction["description"] + json_comment = json.loads(comment) + for field in json_comment: + if field[0] == "text/plain": + comment = field[1] + break + else: + print("text/plain field is missing from JSON description") + except Exception as e: + print(f"Info: could not parse comment as JSON, this is fine, using as-is ({e})") + return comment + + + async def NOmainHERE(self): + self.keep_running = True + self.private_key = PrivateKey(bytes.fromhex(self.secret)) + self.relay_manager = RelayManager() + for relay in self.relays: + self.relay_manager.add_relay(relay) + + print(f"DEBUG: Opening relay connections") + await self.relay_manager.open_connections({"cert_reqs": ssl.CERT_NONE}) + self.allconnected = False + for _ in range(20): + print("Waiting for relay connection...") + await asyncio.sleep(0.5) + nrconnected = 0 + for index, relay in enumerate(self.relays): + try: + relay = self.relay_manager.relays[self.relays[index]] + if relay.connected is True: + print(f"connected: {self.relays[index]}") + nrconnected += 1 + else: + print(f"not connected: {self.relays[index]}") + except Exception as e: + print(f"could not find relay: {e}") + break # not all of them have been initialized, skip... + self.allconnected = ( nrconnected == len(self.relays) ) + if self.allconnected: + print("All relays connected!") + break + if not self.allconnected or not self.keep_running: + print(f"ERROR: could not connect to relay or not self.keep_running, aborting...") + return + + # Set up subscription to receive response + self.subscription_id = "micropython_nwc_" + str(round(time.time())) + print(f"DEBUG: Setting up subscription with ID: {self.subscription_id}") + self.filters = Filters([Filter( + #event_ids=[self.subscription_id], # would be nice to filter, but not like this + kinds=[23195, 23196], # NWC reponses and notifications + authors=[self.wallet_pubkey], + pubkey_refs=[self.private_key.public_key.hex()] + )]) + print(f"DEBUG: Subscription filters: {self.filters.to_json_array()}") + self.relay_manager.add_subscription(self.subscription_id, self.filters) + print(f"DEBUG: Creating subscription request") + request_message = [ClientMessageType.REQUEST, self.subscription_id] + request_message.extend(self.filters.to_json_array()) + print(f"DEBUG: Publishing subscription request") + self.relay_manager.publish_message(json.dumps(request_message)) + print(f"DEBUG: Published subscription request") + for _ in range(4): + if not self.keep_running: + return + print("Waiting a bit before self.fetch_balance()") + await asyncio.sleep(0.5) + + await self.fetch_balance() + + while True: + print(f"checking for incoming events...") + await asyncio.sleep(1) + if not self.keep_running: + print("NWCWallet: not keep_running, closing connections...") + await self.relay_manager.close_connections() + break + + start_time = time.ticks_ms() + if self.relay_manager.message_pool.has_events(): + print(f"DEBUG: Event received from message pool after {time.ticks_ms()-start_time}ms") + event_msg = self.relay_manager.message_pool.get_event() + event_created_at = event_msg.event.created_at + print(f"Received at {time.localtime()} a message with timestamp {event_created_at} after {time.ticks_ms()-start_time}ms") + try: + # This takes a very long time, even for short messages: + decrypted_content = self.private_key.decrypt_message( + event_msg.event.content, + event_msg.event.public_key, + ) + print(f"DEBUG: Decrypted content: {decrypted_content} after {time.ticks_ms()-start_time}ms") + response = json.loads(decrypted_content) + print(f"DEBUG: Parsed response: {response}") + result = response.get("result") + if result: + if result.get("balance") is not None: + new_balance = round(int(result["balance"]) / 1000) + print(f"Got balance: {new_balance}") + self.handle_new_balance(new_balance) + elif result.get("transactions") is not None: + print("Response contains transactions!") + new_payment_list = UniqueSortedList() + for transaction in result["transactions"]: + amount = transaction["amount"] + amount = round(amount / 1000) + comment = self.getCommentFromTransaction(transaction) + epoch_time = transaction["created_at"] + paymentObj = Payment(epoch_time, amount, comment) + new_payment_list.add(paymentObj) + if len(new_payment_list) > 0: + # do them all in one shot instead of one-by-one because the lv_async() isn't always chronological, + # so when a long list of payments is added, it may be overwritten by a short list + self.handle_new_payments(new_payment_list) + else: + notification = response.get("notification") + if notification: + amount = notification["amount"] + amount = round(amount / 1000) + type = notification["type"] + if type == "outgoing": + amount = -amount + elif type == "incoming": + new_balance = self.last_known_balance + amount + self.handle_new_balance(new_balance, False) # don't trigger full fetch because payment info is in notification + epoch_time = notification["created_at"] + comment = self.getCommentFromTransaction(notification) + paymentObj = Payment(epoch_time, amount, comment) + self.handle_new_payment(paymentObj) + else: + print(f"WARNING: invalid notification type {type}, ignoring.") + else: + print("Unsupported response, ignoring.") + except Exception as e: + print(f"DEBUG: Error processing response: {e}") + else: + #print(f"pool has no events after {time.ticks_ms()-start_time}ms") # completes in 0-1ms + pass + + def test_it(self): + print("before do_two") + asyncio.run(self.do_two()) + print("after do_two") + + def do_two(self): + print("before await self.NOmainHERE()") + await self.NOmainHERE() + print("after await self.NOmainHERE()") + diff --git a/tests/manual_test_nwcwallet.py b/tests/manual_test_nwcwallet.py new file mode 100644 index 00000000..9d76cf6c --- /dev/null +++ b/tests/manual_test_nwcwallet.py @@ -0,0 +1,195 @@ +import asyncio +import json +import ssl +import _thread +import time +import unittest +import requests +import ujson + +import sys +sys.path.append("apps/com.lightningpiggy.displaywallet/assets/") +from wallet import NWCWallet + +class TestNWCWallet(unittest.TestCase): + + redraw_balance_cb_called = 0 + redraw_payments_cb_called = 0 + redraw_static_receive_code_cb_called = 0 + error_callback_called = 0 + + def redraw_balance_cb(self, balance=0): + print(f"redraw_callback called, balance: {balance}") + self.redraw_balance_cb_called += 1 + + def redraw_payments_cb(self): + print(f"redraw_payments_cb called") + self.redraw_payments_cb_called += 1 + + def redraw_static_receive_code_cb(self): + print(f"redraw_static_receive_code_cb called") + self.redraw_static_receive_code_cb_called += 1 + + def error_callback(self, error): + print(f"error_callback called, error: {error}") + self.error_callback_called += 1 + + def update_balance(self, sats): + """ + Updates the user balance by 'sats' amount using the local API. + Authenticates first, then sends the balance update. + """ + try: + # Step 1: Authenticate and get access token + auth_url = "http://192.168.1.16:5000/api/v1/auth" + auth_payload = {"username": "admin", "password": "adminadmin"} + print("Authenticating...") + auth_response = requests.post( auth_url, json=auth_payload, headers={"Content-Type": "application/json"} ) + if auth_response.status_code != 200: + print("Auth failed:", auth_response.text) + auth_response.close() + return False + auth_data = ujson.loads(auth_response.text) + access_token = auth_data["access_token"] + auth_response.close() + print("Authenticated, got token.") + # Step 2: Update balance + balance_url = "http://192.168.1.16:5000/users/api/v1/balance" + balance_payload = { "amount": str(sats), "id": "24e9334d39b946a3b642f5fd8c292a07" } + cookie_header = f"cookie_access_token={access_token}; is_lnbits_user_authorized=true" + print(f"Updating balance by {sats} sats...") + update_response = requests.put( + balance_url, + json=balance_payload, + headers={ "Content-Type": "application/json", "Cookie": cookie_header }) + result = ujson.loads(update_response.text) + update_response.close() + if result.get("success"): + print("Balance updated successfully!") + return True + else: + print("Update failed:", result) + return False + except Exception as e: + print("Error:", e) + return False + + def test_it(self): + print("starting test") + self.wallet = NWCWallet("nostr+walletconnect://e46762afab282c324278351165122345f9983ea447b47943b052100321227571?relay=ws://192.168.1.16:5000/nostrclient/api/v1/relay&secret=fab0a9a11d4cf4b1d92e901a0b2c56634275e2fa1a7eb396ff1b942f95d59fd3&lud16=test@example.com") + self.wallet.start(self.redraw_balance_cb, self.redraw_payments_cb, self.redraw_static_receive_code_cb, self.error_callback) + print("\n\nWaiting a bit for the startup to be settled...") + time.sleep(15) + print("\nAsserting state...") + saved = self.redraw_balance_cb_called + print(f"redraw_balance_cb_called is {self.redraw_balance_cb_called}") + self.assertGreaterEqual(self.redraw_balance_cb_called,1) + self.assertGreaterEqual(self.redraw_payments_cb_called, 1) + self.assertGreaterEqual(self.redraw_static_receive_code_cb_called, 1) + self.assertEqual(self.error_callback_called, 0) + self.update_balance(321) + time.sleep(10) + self.assertNotEqual(self.redraw_balance_cb_called,saved+1, "should be equal, but LNBits doesn't seem to send payment notifications (yet)") + self.assertGreaterEqual(self.redraw_payments_cb_called, 1) + self.assertGreaterEqual(self.redraw_static_receive_code_cb_called, 1) + print("Stopping wallet...") + self.wallet.stop() + time.sleep(5) + self.assertNotEqual(self.redraw_balance_cb_called,saved+1, "should be equal, but LNBits doesn't seem to send payment notifications (yet)") + self.assertGreaterEqual(self.redraw_payments_cb_called, 1) + self.assertGreaterEqual(self.redraw_static_receive_code_cb_called, 1) + print("test finished") + + + +class TestNWCWalletMultiRelay(unittest.TestCase): + + redraw_balance_cb_called = 0 + redraw_payments_cb_called = 0 + redraw_static_receive_code_cb_called = 0 + error_callback_called = 0 + + def redraw_balance_cb(self, balance=0): + print(f"redraw_callback called, balance: {balance}") + self.redraw_balance_cb_called += 1 + + def redraw_payments_cb(self): + print(f"redraw_payments_cb called") + self.redraw_payments_cb_called += 1 + + def redraw_static_receive_code_cb(self): + print(f"redraw_static_receive_code_cb called") + self.redraw_static_receive_code_cb_called += 1 + + def error_callback(self, error): + print(f"error_callback called, error: {error}") + self.error_callback_called += 1 + + def update_balance(self, sats): + """ + Updates the user balance by 'sats' amount using the local API. + Authenticates first, then sends the balance update. + """ + try: + # Step 1: Authenticate and get access token + auth_url = "http://192.168.1.16:5000/api/v1/auth" + auth_payload = {"username": "admin", "password": "adminadmin"} + print("Authenticating...") + auth_response = requests.post( auth_url, json=auth_payload, headers={"Content-Type": "application/json"} ) + if auth_response.status_code != 200: + print("Auth failed:", auth_response.text) + auth_response.close() + return False + auth_data = ujson.loads(auth_response.text) + access_token = auth_data["access_token"] + auth_response.close() + print("Authenticated, got token.") + # Step 2: Update balance + balance_url = "http://192.168.1.16:5000/users/api/v1/balance" + balance_payload = { "amount": str(sats), "id": "24e9334d39b946a3b642f5fd8c292a07" } + cookie_header = f"cookie_access_token={access_token}; is_lnbits_user_authorized=true" + print(f"Updating balance by {sats} sats...") + update_response = requests.put( + balance_url, + json=balance_payload, + headers={ "Content-Type": "application/json", "Cookie": cookie_header }) + result = ujson.loads(update_response.text) + update_response.close() + if result.get("success"): + print("Balance updated successfully!") + return True + else: + print("Update failed:", result) + return False + except Exception as e: + print("Error:", e) + return False + + def test_it(self): + print("starting test") + self.wallet = NWCWallet("nostr+walletconnect://e46762afab282c324278351165122345f9983ea447b47943b052100321227571?relay=ws://192.168.1.16:5000/nostrclient/api/v1/relay&relay=ws://127.0.0.1:5000/nostrrelay/test&secret=fab0a9a11d4cf4b1d92e901a0b2c56634275e2fa1a7eb396ff1b942f95d59fd3&lud16=test@example.com") + self.wallet.start(self.redraw_balance_cb, self.redraw_payments_cb, self.redraw_static_receive_code_cb, self.error_callback) + print("\n\nWaiting a bit for the startup to be settled...") + time.sleep(15) + print("\nAsserting state...") + saved = self.redraw_balance_cb_called + print(f"redraw_balance_cb_called is {self.redraw_balance_cb_called}") + self.assertGreaterEqual(self.redraw_balance_cb_called,1,"redraw_balance_cb should be called once") + self.assertGreaterEqual(self.redraw_payments_cb_called, 1, "redraw_payments_cb should be called once") + self.assertGreaterEqual(self.redraw_static_receive_code_cb_called, 1, "redraw_static_receive_code_cb should be called once") + self.assertEqual(self.error_callback_called, 0) + self.update_balance(321) + time.sleep(10) + self.assertNotEqual(self.redraw_balance_cb_called,saved+1, "should be equal, but LNBits doesn't seem to send payment notifications (yet)") + self.assertGreaterEqual(self.redraw_payments_cb_called, 1) + self.assertGreaterEqual(self.redraw_static_receive_code_cb_called, 1) + print("Stopping wallet...") + self.wallet.stop() + time.sleep(5) + self.assertNotEqual(self.redraw_balance_cb_called,saved+1, "should be equal, but LNBits doesn't seem to send payment notifications (yet)") + self.assertGreaterEqual(self.redraw_payments_cb_called, 1) + self.assertGreaterEqual(self.redraw_static_receive_code_cb_called, 1) + print("test finished") + + + diff --git a/tests/manual_test_nwcwallet_start_stop.py b/tests/manual_test_nwcwallet_start_stop.py new file mode 100644 index 00000000..9f3becc2 --- /dev/null +++ b/tests/manual_test_nwcwallet_start_stop.py @@ -0,0 +1,20 @@ +import time +import unittest + +import sys +sys.path.append("apps/com.lightningpiggy.displaywallet/assets/") +from wallet import NWCWallet + +class TestNWCWalletMultiRelayStartStop(unittest.TestCase): + + def unused_callback(self, arg1=None, arg2=None): + pass + + def test_quick_start_stop(self): + self.wallet = NWCWallet("nostr+walletconnect://e46762afab282c324278351165122345f9983ea447b47943b052100321227571?relay=ws://192.168.1.16:5000/nostrclient/api/v1/relay&relay=ws://127.0.0.1:5000/nostrrelay/test&secret=fab0a9a11d4cf4b1d92e901a0b2c56634275e2fa1a7eb396ff1b942f95d59fd3&lud16=test@example.com") + for iteration in range(20): + print(f"\n\nITERATION {iteration}\n\n") + self.wallet.start(self.unused_callback, self.unused_callback, self.unused_callback, self.unused_callback) + time.sleep(max(15-iteration,1)) # not giving any time to connect causes a bad state + self.wallet.stop() + time.sleep(0.2) # 0.1 seems to be okay most of the time, 0.2 is super stable diff --git a/tests/mocks/hardware_mocks.py b/tests/mocks/hardware_mocks.py new file mode 100644 index 00000000..b2d2e97e --- /dev/null +++ b/tests/mocks/hardware_mocks.py @@ -0,0 +1,102 @@ +# Hardware Mocks for Testing AudioFlinger and LightsManager +# Provides mock implementations of PWM, I2S, NeoPixel, and Pin classes + + +class MockPin: + """Mock machine.Pin for testing.""" + + IN = 0 + OUT = 1 + PULL_UP = 2 + + def __init__(self, pin_number, mode=None, pull=None): + self.pin_number = pin_number + self.mode = mode + self.pull = pull + self._value = 0 + + def value(self, val=None): + if val is not None: + self._value = val + return self._value + + +class MockPWM: + """Mock machine.PWM for testing buzzer.""" + + def __init__(self, pin, freq=0, duty=0): + self.pin = pin + self.last_freq = freq + self.last_duty = duty + self.freq_history = [] + self.duty_history = [] + + def freq(self, value=None): + """Set or get frequency.""" + if value is not None: + self.last_freq = value + self.freq_history.append(value) + return self.last_freq + + def duty_u16(self, value=None): + """Set or get duty cycle (0-65535).""" + if value is not None: + self.last_duty = value + self.duty_history.append(value) + return self.last_duty + + +class MockI2S: + """Mock machine.I2S for testing audio playback.""" + + TX = 0 + MONO = 1 + STEREO = 2 + + def __init__(self, id, sck, ws, sd, mode, bits, format, rate, ibuf): + self.id = id + self.sck = sck + self.ws = ws + self.sd = sd + self.mode = mode + self.bits = bits + self.format = format + self.rate = rate + self.ibuf = ibuf + self.written_bytes = [] + self.total_bytes_written = 0 + + def write(self, buf): + """Simulate writing to I2S hardware.""" + self.written_bytes.append(bytes(buf)) + self.total_bytes_written += len(buf) + return len(buf) + + def deinit(self): + """Deinitialize I2S.""" + pass + + +class MockNeoPixel: + """Mock neopixel.NeoPixel for testing LEDs.""" + + def __init__(self, pin, num_leds): + self.pin = pin + self.num_leds = num_leds + self.pixels = [(0, 0, 0)] * num_leds + self.write_count = 0 + + def __setitem__(self, index, value): + """Set LED color (R, G, B) tuple.""" + if 0 <= index < self.num_leds: + self.pixels[index] = value + + def __getitem__(self, index): + """Get LED color.""" + if 0 <= index < self.num_leds: + return self.pixels[index] + return (0, 0, 0) + + def write(self): + """Update hardware (mock - just increment counter).""" + self.write_count += 1 diff --git a/tests/network_test_helper.py b/tests/network_test_helper.py new file mode 100644 index 00000000..1a6d235b --- /dev/null +++ b/tests/network_test_helper.py @@ -0,0 +1,92 @@ +""" +Network testing helper module for MicroPythonOS. + +This module provides mock implementations of network-related modules +for testing without requiring actual network connectivity. + +NOTE: This module re-exports mocks from mpos.testing for backward compatibility. +New code should import directly from mpos.testing. + +Usage: + from network_test_helper import MockNetwork, MockRequests, MockTimer + + # Or use the centralized module directly: + from mpos.testing import MockNetwork, MockRequests, MockTimer +""" + +# Re-export all mocks from centralized module for backward compatibility +from mpos.testing import ( + # Hardware mocks + MockMachine, + MockPin, + MockPWM, + MockI2S, + MockTimer, + MockSocket, + + # MPOS mocks + MockTaskManager, + MockTask, + MockDownloadManager, + + # Network mocks + MockNetwork, + MockRequests, + MockResponse, + MockRaw, + + # Utility mocks + MockTime, + MockJSON, + MockModule, + + # Helper functions + inject_mocks, + create_mock_module, +) + +# For backward compatibility, also provide socket() function +def socket(af=MockSocket.AF_INET, sock_type=MockSocket.SOCK_STREAM): + """ + Create a mock socket. + + Args: + af: Address family (default: AF_INET) + sock_type: Socket type (default: SOCK_STREAM) + + Returns: + MockSocket instance + """ + return MockSocket(af, sock_type) + + +__all__ = [ + # Hardware mocks + 'MockMachine', + 'MockPin', + 'MockPWM', + 'MockI2S', + 'MockTimer', + 'MockSocket', + + # MPOS mocks + 'MockTaskManager', + 'MockTask', + 'MockDownloadManager', + + # Network mocks + 'MockNetwork', + 'MockRequests', + 'MockResponse', + 'MockRaw', + + # Utility mocks + 'MockTime', + 'MockJSON', + 'MockModule', + + # Helper functions + 'inject_mocks', + 'create_mock_module', + 'socket', +] diff --git a/tests/screenshots/.gitignore b/tests/screenshots/.gitignore new file mode 100644 index 00000000..ee96e8df --- /dev/null +++ b/tests/screenshots/.gitignore @@ -0,0 +1,9 @@ +# Ignore all screenshot files +*.raw + +# Ignore converted PNG files (can be regenerated from .raw) +*.png + +# Allow this .gitignore and README.md +!.gitignore +!README.md diff --git a/tests/screenshots/README.md b/tests/screenshots/README.md new file mode 100644 index 00000000..83149024 --- /dev/null +++ b/tests/screenshots/README.md @@ -0,0 +1,61 @@ +# Test Screenshots + +This directory contains screenshots captured during graphical tests. + +## File Format + +Screenshots are saved as raw binary data in RGB565 format: +- 2 bytes per pixel +- For 320x240 screen: 153,600 bytes per file +- Filename format: `{test_name}_{hardware_id}.raw` + +## Converting to PNG + +### Quick Method (Recommended) + +Use the provided convenience script to convert all screenshots: + +```bash +cd tests/screenshots +./convert_to_png.sh +``` + +For custom dimensions: +```bash +./convert_to_png.sh 296 240 +``` + +### Manual Conversion + +To view individual screenshots, convert them to PNG using ffmpeg: + +```bash +# For 320x240 screenshots (default) +ffmpeg -vcodec rawvideo -f rawvideo -pix_fmt rgb565 -s 320x240 -i screenshot.raw screenshot.png + +# For other sizes (e.g., 296x240 for some hardware) +ffmpeg -vcodec rawvideo -f rawvideo -pix_fmt rgb565 -s 296x240 -i screenshot.raw screenshot.png +``` + +## Visual Regression Testing + +Screenshots can be used for visual regression testing by: +1. Capturing a "golden" reference screenshot +2. Comparing new screenshots against the reference +3. Detecting visual changes + +For pixel-by-pixel comparison, you can use ImageMagick: + +```bash +# Convert both to PNG first +ffmpeg -vcodec rawvideo -f rawvideo -pix_fmt rgb565 -s 320x240 -i reference.raw reference.png +ffmpeg -vcodec rawvideo -f rawvideo -pix_fmt rgb565 -s 320x240 -i current.raw current.png + +# Compare +compare -metric AE reference.png current.png diff.png +``` + +## .gitignore + +Screenshot files (.raw and .png) are ignored by git to avoid bloating the repository. +Reference/golden screenshots should be stored separately or documented clearly. diff --git a/tests/screenshots/convert_to_png.sh b/tests/screenshots/convert_to_png.sh new file mode 100755 index 00000000..70288f86 --- /dev/null +++ b/tests/screenshots/convert_to_png.sh @@ -0,0 +1,81 @@ +#!/bin/bash + +# Convert raw RGB565 screenshots to PNG format +# This script converts all .raw files in the current directory to PNG using ffmpeg + +# Default dimensions (can be overridden with arguments) +WIDTH=320 +HEIGHT=240 + +# Parse command line arguments +if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then + echo "Usage: $0 [width] [height]" + echo + echo "Convert all .raw screenshot files to PNG format." + echo + echo "Arguments:" + echo " width Screen width in pixels (default: 320)" + echo " height Screen height in pixels (default: 240)" + echo + echo "Examples:" + echo " $0 # Convert with default 320x240" + echo " $0 296 240 # Convert with custom dimensions" + echo + exit 0 +fi + +if [ -n "$1" ]; then + WIDTH="$1" +fi + +if [ -n "$2" ]; then + HEIGHT="$2" +fi + +# Check if ffmpeg is available +if ! command -v ffmpeg &> /dev/null; then + echo "ERROR: ffmpeg is not installed or not in PATH" + echo "Please install ffmpeg to convert screenshots" + exit 1 +fi + +# Count .raw files +raw_count=$(find . -maxdepth 1 -name "*.raw" | wc -l) + +if [ $raw_count -eq 0 ]; then + echo "No .raw files found in current directory" + exit 0 +fi + +echo "Converting $raw_count screenshot(s) from RGB565 to PNG..." +echo "Dimensions: ${WIDTH}x${HEIGHT}" +echo + +converted=0 +failed=0 + +# Convert each .raw file to .png +for raw_file in *.raw; do + [ -e "$raw_file" ] || continue # Skip if no .raw files exist + + png_file="${raw_file%.raw}.png" + + echo -n "Converting $raw_file -> $png_file ... " + + if ffmpeg -y -v quiet -vcodec rawvideo -f rawvideo -pix_fmt rgb565 -s ${WIDTH}x${HEIGHT} -i "$raw_file" "$png_file" 2>/dev/null; then + echo "✓" + converted=$((converted + 1)) + else + echo "✗ FAILED" + failed=$((failed + 1)) + fi +done + +echo +echo "Conversion complete: $converted succeeded, $failed failed" + +if [ $converted -gt 0 ]; then + echo + echo "PNG files created:" + ls -lh *.png 2>/dev/null | awk '{print " " $9 " (" $5 ")"}' +fi diff --git a/tests/simple.py b/tests/simple.py deleted file mode 100644 index 35437d1e..00000000 --- a/tests/simple.py +++ /dev/null @@ -1,6 +0,0 @@ -import unittest - -class TestMath(unittest.TestCase): - def test_add(self): - self.assertEqual(1 + 1, 2) - diff --git a/tests/test_audioflinger.py b/tests/test_audioflinger.py new file mode 100644 index 00000000..da9414ee --- /dev/null +++ b/tests/test_audioflinger.py @@ -0,0 +1,206 @@ +# Unit tests for AudioFlinger service +import unittest +import sys + +# Import centralized mocks +from mpos.testing import ( + MockMachine, + MockPWM, + MockPin, + MockThread, + MockApps, + inject_mocks, +) + +# Inject mocks before importing AudioFlinger +inject_mocks({ + 'machine': MockMachine(), + '_thread': MockThread, + 'mpos.apps': MockApps, +}) + +# Now import the module to test +import mpos.audio.audioflinger as AudioFlinger + + +class TestAudioFlinger(unittest.TestCase): + """Test cases for AudioFlinger service.""" + + def setUp(self): + """Initialize AudioFlinger before each test.""" + self.buzzer = MockPWM(MockPin(46)) + self.i2s_pins = {'sck': 2, 'ws': 47, 'sd': 16} + + # Reset volume to default before each test + AudioFlinger.set_volume(70) + + AudioFlinger.init( + i2s_pins=self.i2s_pins, + buzzer_instance=self.buzzer + ) + + def tearDown(self): + """Clean up after each test.""" + AudioFlinger.stop() + + def test_initialization(self): + """Test that AudioFlinger initializes correctly.""" + self.assertEqual(AudioFlinger._i2s_pins, self.i2s_pins) + self.assertEqual(AudioFlinger._buzzer_instance, self.buzzer) + + def test_has_i2s(self): + """Test has_i2s() returns correct value.""" + # With I2S configured + AudioFlinger.init(i2s_pins=self.i2s_pins, buzzer_instance=None) + self.assertTrue(AudioFlinger.has_i2s()) + + # Without I2S configured + AudioFlinger.init(i2s_pins=None, buzzer_instance=self.buzzer) + self.assertFalse(AudioFlinger.has_i2s()) + + def test_has_buzzer(self): + """Test has_buzzer() returns correct value.""" + # With buzzer configured + AudioFlinger.init(i2s_pins=None, buzzer_instance=self.buzzer) + self.assertTrue(AudioFlinger.has_buzzer()) + + # Without buzzer configured + AudioFlinger.init(i2s_pins=self.i2s_pins, buzzer_instance=None) + self.assertFalse(AudioFlinger.has_buzzer()) + + def test_stream_types(self): + """Test stream type constants and priority order.""" + self.assertEqual(AudioFlinger.STREAM_MUSIC, 0) + self.assertEqual(AudioFlinger.STREAM_NOTIFICATION, 1) + self.assertEqual(AudioFlinger.STREAM_ALARM, 2) + + # Higher number = higher priority + self.assertTrue(AudioFlinger.STREAM_MUSIC < AudioFlinger.STREAM_NOTIFICATION) + self.assertTrue(AudioFlinger.STREAM_NOTIFICATION < AudioFlinger.STREAM_ALARM) + + def test_volume_control(self): + """Test volume get/set operations.""" + # Set volume + AudioFlinger.set_volume(50) + self.assertEqual(AudioFlinger.get_volume(), 50) + + # Test clamping to 0-100 range + AudioFlinger.set_volume(150) + self.assertEqual(AudioFlinger.get_volume(), 100) + + AudioFlinger.set_volume(-10) + self.assertEqual(AudioFlinger.get_volume(), 0) + + def test_no_hardware_rejects_playback(self): + """Test that no hardware rejects all playback requests.""" + # Re-initialize with no hardware + AudioFlinger.init(i2s_pins=None, buzzer_instance=None) + + # WAV should be rejected (no I2S) + result = AudioFlinger.play_wav("test.wav") + self.assertFalse(result) + + # RTTTL should be rejected (no buzzer) + result = AudioFlinger.play_rtttl("Test:d=4,o=5,b=120:c") + self.assertFalse(result) + + def test_i2s_only_rejects_rtttl(self): + """Test that I2S-only config rejects buzzer playback.""" + # Re-initialize with I2S only + AudioFlinger.init(i2s_pins=self.i2s_pins, buzzer_instance=None) + + # RTTTL should be rejected (no buzzer) + result = AudioFlinger.play_rtttl("Test:d=4,o=5,b=120:c") + self.assertFalse(result) + + def test_buzzer_only_rejects_wav(self): + """Test that buzzer-only config rejects I2S playback.""" + # Re-initialize with buzzer only + AudioFlinger.init(i2s_pins=None, buzzer_instance=self.buzzer) + + # WAV should be rejected (no I2S) + result = AudioFlinger.play_wav("test.wav") + self.assertFalse(result) + + def test_is_playing_initially_false(self): + """Test that is_playing() returns False initially.""" + self.assertFalse(AudioFlinger.is_playing()) + + def test_stop_with_no_playback(self): + """Test that stop() can be called when nothing is playing.""" + # Should not raise exception + AudioFlinger.stop() + self.assertFalse(AudioFlinger.is_playing()) + + def test_audio_focus_check_no_current_stream(self): + """Test audio focus allows playback when no stream is active.""" + result = AudioFlinger._check_audio_focus(AudioFlinger.STREAM_MUSIC) + self.assertTrue(result) + + def test_volume_default_value(self): + """Test that default volume is reasonable.""" + # After init, volume should be at default (70) + AudioFlinger.init(i2s_pins=None, buzzer_instance=None) + self.assertEqual(AudioFlinger.get_volume(), 70) + + +class TestAudioFlingerRecording(unittest.TestCase): + """Test cases for AudioFlinger recording functionality.""" + + def setUp(self): + """Initialize AudioFlinger with microphone before each test.""" + self.buzzer = MockPWM(MockPin(46)) + # I2S pins with microphone input + self.i2s_pins_with_mic = {'sck': 2, 'ws': 47, 'sd': 16, 'sd_in': 15} + # I2S pins without microphone input + self.i2s_pins_no_mic = {'sck': 2, 'ws': 47, 'sd': 16} + + # Reset state + AudioFlinger._current_recording = None + AudioFlinger.set_volume(70) + + AudioFlinger.init( + i2s_pins=self.i2s_pins_with_mic, + buzzer_instance=self.buzzer + ) + + def tearDown(self): + """Clean up after each test.""" + AudioFlinger.stop() + + def test_has_microphone_with_sd_in(self): + """Test has_microphone() returns True when sd_in pin is configured.""" + AudioFlinger.init(i2s_pins=self.i2s_pins_with_mic, buzzer_instance=None) + self.assertTrue(AudioFlinger.has_microphone()) + + def test_has_microphone_without_sd_in(self): + """Test has_microphone() returns False when sd_in pin is not configured.""" + AudioFlinger.init(i2s_pins=self.i2s_pins_no_mic, buzzer_instance=None) + self.assertFalse(AudioFlinger.has_microphone()) + + def test_has_microphone_no_i2s(self): + """Test has_microphone() returns False when no I2S is configured.""" + AudioFlinger.init(i2s_pins=None, buzzer_instance=self.buzzer) + self.assertFalse(AudioFlinger.has_microphone()) + + def test_is_recording_initially_false(self): + """Test that is_recording() returns False initially.""" + self.assertFalse(AudioFlinger.is_recording()) + + def test_record_wav_no_microphone(self): + """Test that record_wav() fails when no microphone is configured.""" + AudioFlinger.init(i2s_pins=self.i2s_pins_no_mic, buzzer_instance=None) + result = AudioFlinger.record_wav("test.wav") + self.assertFalse(result) + + def test_record_wav_no_i2s(self): + """Test that record_wav() fails when no I2S is configured.""" + AudioFlinger.init(i2s_pins=None, buzzer_instance=self.buzzer) + result = AudioFlinger.record_wav("test.wav") + self.assertFalse(result) + + def test_stop_with_no_recording(self): + """Test that stop() can be called when nothing is recording.""" + # Should not raise exception + AudioFlinger.stop() + self.assertFalse(AudioFlinger.is_recording()) diff --git a/tests/test_battery_voltage.py b/tests/test_battery_voltage.py new file mode 100644 index 00000000..3f3336af --- /dev/null +++ b/tests/test_battery_voltage.py @@ -0,0 +1,438 @@ +""" +Unit tests for mpos.battery_voltage module. + +Tests ADC1/ADC2 detection, caching, WiFi coordination, and voltage calculations. +""" + +import unittest +import sys + +# Add parent directory to path for imports +sys.path.insert(0, '../internal_filesystem') + +# Mock modules before importing battery_voltage +class MockADC: + """Mock ADC for testing.""" + ATTN_11DB = 3 + + def __init__(self, pin): + self.pin = pin + self._atten = None + self._read_value = 2048 # Default mid-range value + + def atten(self, value): + self._atten = value + + def read(self): + return self._read_value + + def set_read_value(self, value): + """Test helper to set ADC reading.""" + self._read_value = value + + +class MockPin: + """Mock Pin for testing.""" + def __init__(self, pin_num): + self.pin_num = pin_num + + +class MockMachine: + """Mock machine module.""" + ADC = MockADC + Pin = MockPin + + +class MockWifiService: + """Mock WifiService for testing.""" + wifi_busy = False + _connected = False + _temporarily_disabled = False + + @classmethod + def is_connected(cls): + return cls._connected + + @classmethod + def disconnect(cls): + cls._connected = False + + @classmethod + def temporarily_disable(cls): + """Temporarily disable WiFi and return whether it was connected.""" + if cls.wifi_busy: + raise RuntimeError("Cannot disable WiFi: WifiService is already busy") + was_connected = cls._connected + cls.wifi_busy = True + cls._connected = False + cls._temporarily_disabled = True + return was_connected + + @classmethod + def temporarily_enable(cls, was_connected): + """Re-enable WiFi and reconnect if it was connected before.""" + cls.wifi_busy = False + cls._temporarily_disabled = False + if was_connected: + cls._connected = True # Simulate reconnection + + @classmethod + def reset(cls): + """Test helper to reset state.""" + cls.wifi_busy = False + cls._connected = False + cls._temporarily_disabled = False + + +# Inject mocks +sys.modules['machine'] = MockMachine +sys.modules['mpos.net.wifi_service'] = type('module', (), {'WifiService': MockWifiService})() + +# Now import battery_voltage +import mpos.battery_voltage as bv + + +class TestADC2Detection(unittest.TestCase): + """Test ADC1 vs ADC2 pin detection.""" + + def test_adc1_pins_detected(self): + """Test that ADC1 pins (GPIO1-10) are detected correctly.""" + for pin in range(1, 11): + self.assertFalse(bv._is_adc2_pin(pin), f"GPIO{pin} should be ADC1") + + def test_adc2_pins_detected(self): + """Test that ADC2 pins (GPIO11-20) are detected correctly.""" + for pin in range(11, 21): + self.assertTrue(bv._is_adc2_pin(pin), f"GPIO{pin} should be ADC2") + + def test_out_of_range_pins(self): + """Test pins outside ADC range.""" + self.assertFalse(bv._is_adc2_pin(0)) + self.assertFalse(bv._is_adc2_pin(21)) + self.assertFalse(bv._is_adc2_pin(30)) + self.assertFalse(bv._is_adc2_pin(100)) + + +class TestInitADC(unittest.TestCase): + """Test ADC initialization.""" + + def setUp(self): + """Reset module state.""" + bv.adc = None + bv.conversion_func = None + bv.adc_pin = None + + def test_init_adc1_pin(self): + """Test initializing with ADC1 pin.""" + def adc_to_voltage(adc_value): + return adc_value * 0.00161 + + bv.init_adc(5, adc_to_voltage) + + self.assertIsNotNone(bv.adc) + self.assertEqual(bv.conversion_func, adc_to_voltage) + self.assertEqual(bv.adc_pin, 5) + self.assertEqual(bv.adc._atten, MockADC.ATTN_11DB) + + def test_init_adc2_pin(self): + """Test initializing with ADC2 pin (should warn but work).""" + def adc_to_voltage(adc_value): + return adc_value * 0.00197 + + bv.init_adc(13, adc_to_voltage) + + self.assertIsNotNone(bv.adc) + self.assertIsNotNone(bv.conversion_func) + self.assertEqual(bv.adc_pin, 13) + + def test_conversion_func_stored(self): + """Test that conversion function is stored correctly.""" + def my_conversion(adc_value): + return adc_value * 0.12345 + + bv.init_adc(5, my_conversion) + self.assertEqual(bv.conversion_func, my_conversion) + + +class TestCaching(unittest.TestCase): + """Test caching mechanism.""" + + def setUp(self): + """Reset module state.""" + bv.clear_cache() + def adc_to_voltage(adc_value): + return adc_value * 0.00161 + bv.init_adc(5, adc_to_voltage) # Use ADC1 to avoid WiFi complexity + MockWifiService.reset() + + def tearDown(self): + """Clean up.""" + bv.clear_cache() + + def test_cache_hit_on_first_read(self): + """Test that first read already has a cache (because of read during init) """ + self.assertIsNotNone(bv._cached_raw_adc) + raw = bv.read_raw_adc() + self.assertIsNotNone(bv._cached_raw_adc) + self.assertEqual(raw, bv._cached_raw_adc) + + def test_cache_hit_within_duration(self): + """Test that subsequent reads use cache within duration.""" + raw1 = bv.read_raw_adc() + + # Change ADC value but should still get cached value + bv.adc.set_read_value(3000) + raw2 = bv.read_raw_adc() + + self.assertEqual(raw1, raw2, "Should return cached value") + + def test_force_refresh_bypasses_cache(self): + """Test that force_refresh bypasses cache.""" + bv.adc.set_read_value(2000) + raw1 = bv.read_raw_adc() + + # Change value and force refresh + bv.adc.set_read_value(3000) + raw2 = bv.read_raw_adc(force_refresh=True) + + self.assertNotEqual(raw1, raw2, "force_refresh should bypass cache") + self.assertEqual(raw2, 3000.0) + + def test_clear_cache_works(self): + """Test that clear_cache() clears the cache.""" + bv.read_raw_adc() + self.assertIsNotNone(bv._cached_raw_adc) + + bv.clear_cache() + self.assertIsNone(bv._cached_raw_adc) + self.assertEqual(bv._last_read_time, 0) + + +class TestADC1Reading(unittest.TestCase): + """Test ADC reading with ADC1 (no WiFi interference).""" + + def setUp(self): + """Reset module state.""" + bv.clear_cache() + def adc_to_voltage(adc_value): + return adc_value * 0.00161 + bv.init_adc(5, adc_to_voltage) # GPIO5 is ADC1 + MockWifiService.reset() + MockWifiService._connected = True + + def tearDown(self): + """Clean up.""" + bv.clear_cache() + MockWifiService.reset() + + def test_adc1_doesnt_disable_wifi(self): + """Test that ADC1 reading doesn't disable WiFi.""" + MockWifiService._connected = True + + bv.read_raw_adc(force_refresh=True) + + # WiFi should still be connected + self.assertTrue(MockWifiService.is_connected()) + self.assertFalse(MockWifiService.wifi_busy) + + def test_adc1_ignores_wifi_busy(self): + """Test that ADC1 reading works even if WiFi is busy.""" + MockWifiService.wifi_busy = True + + # Should not raise error + try: + raw = bv.read_raw_adc(force_refresh=True) + self.assertIsNotNone(raw) + except RuntimeError: + self.fail("ADC1 should not raise error when WiFi is busy") + + +class TestADC2Reading(unittest.TestCase): + """Test ADC reading with ADC2 (requires WiFi disable).""" + + def setUp(self): + """Reset module state.""" + bv.clear_cache() + def adc_to_voltage(adc_value): + return adc_value * 0.00197 + bv.init_adc(13, adc_to_voltage) # GPIO13 is ADC2 + MockWifiService.reset() + + def tearDown(self): + """Clean up.""" + bv.clear_cache() + MockWifiService.reset() + + def test_adc2_disables_wifi_when_connected(self): + """Test that ADC2 reading disables WiFi when connected.""" + MockWifiService._connected = True + + bv.read_raw_adc(force_refresh=True) + + # WiFi should be reconnected after reading (if it was connected before) + self.assertTrue(MockWifiService.is_connected()) + + def test_adc2_sets_wifi_busy_flag(self): + """Test that ADC2 reading sets wifi_busy flag.""" + MockWifiService._connected = False + + # wifi_busy should be False before + self.assertFalse(MockWifiService.wifi_busy) + + bv.read_raw_adc(force_refresh=True) + + # wifi_busy should be False after (cleared in finally) + self.assertFalse(MockWifiService.wifi_busy) + + def test_adc2_raises_error_if_wifi_busy(self): + """Test that ADC2 reading raises error if WiFi is busy.""" + MockWifiService.wifi_busy = True + + with self.assertRaises(RuntimeError) as ctx: + bv.read_raw_adc(force_refresh=True) + + self.assertIn("WifiService is already busy", str(ctx.exception)) + + def test_adc2_uses_cache_when_wifi_busy(self): + """Test that ADC2 uses cache even when WiFi is busy.""" + # First read to populate cache + MockWifiService.wifi_busy = False + raw1 = bv.read_raw_adc(force_refresh=True) + + # Now set WiFi busy + MockWifiService.wifi_busy = True + + # Should return cached value without error + raw2 = bv.read_raw_adc() + self.assertEqual(raw1, raw2) + + def test_adc2_only_reconnects_if_was_connected(self): + """Test that ADC2 only reconnects WiFi if it was connected before.""" + # WiFi is NOT connected + MockWifiService._connected = False + + bv.read_raw_adc(force_refresh=True) + + # WiFi should still be disconnected (no unwanted reconnection) + self.assertFalse(MockWifiService.is_connected()) + + +class TestVoltageCalculations(unittest.TestCase): + """Test voltage and percentage calculations.""" + + def setUp(self): + """Reset module state.""" + bv.clear_cache() + def adc_to_voltage(adc_value): + return adc_value * 0.00161 + bv.init_adc(5, adc_to_voltage) # ADC1 pin, scale factor for 2:1 divider + bv.adc.set_read_value(2048) # Mid-range + + def tearDown(self): + """Clean up.""" + bv.clear_cache() + + def test_read_battery_voltage_applies_scale_factor(self): + """Test that voltage is calculated correctly.""" + bv.adc.set_read_value(2048) # Mid-range + bv.clear_cache() + + voltage = bv.read_battery_voltage(force_refresh=True) + expected = 2048 * 0.00161 + self.assertAlmostEqual(voltage, expected, places=4) + + def test_voltage_clamped_to_zero(self): + """Test that negative voltage is clamped to 0.""" + bv.adc.set_read_value(0) + bv.clear_cache() + + voltage = bv.read_battery_voltage(force_refresh=True) + self.assertGreaterEqual(voltage, 0.0) + + def test_get_battery_percentage_calculation(self): + """Test percentage calculation.""" + # Set voltage to mid-range between MIN and MAX + mid_voltage = (bv.MIN_VOLTAGE + bv.MAX_VOLTAGE) / 2 + # Inverse of conversion function: if voltage = adc * 0.00161, then adc = voltage / 0.00161 + raw_adc = mid_voltage / 0.00161 + bv.adc.set_read_value(int(raw_adc)) + bv.clear_cache() + + percentage = bv.get_battery_percentage() + self.assertAlmostEqual(percentage, 50.0, places=0) + + def test_percentage_clamped_to_0_100(self): + """Test that percentage is clamped to 0-100 range.""" + # Test minimum + bv.adc.set_read_value(0) + bv.clear_cache() + percentage = bv.get_battery_percentage() + self.assertGreaterEqual(percentage, 0.0) + self.assertLessEqual(percentage, 100.0) + + # Test maximum + bv.adc.set_read_value(4095) + bv.clear_cache() + percentage = bv.get_battery_percentage() + self.assertGreaterEqual(percentage, 0.0) + self.assertLessEqual(percentage, 100.0) + + +class TestAveragingLogic(unittest.TestCase): + """Test that ADC readings are averaged.""" + + def setUp(self): + """Reset module state.""" + bv.clear_cache() + def adc_to_voltage(adc_value): + return adc_value * 0.00161 + bv.init_adc(5, adc_to_voltage) + + def tearDown(self): + """Clean up.""" + bv.clear_cache() + + def test_adc_read_averages_10_samples(self): + """Test that 10 samples are averaged.""" + bv.adc.set_read_value(2000) + bv.clear_cache() + + raw = bv.read_raw_adc(force_refresh=True) + + # Should be average of 10 reads + self.assertEqual(raw, 2000.0) + + +class TestDesktopMode(unittest.TestCase): + """Test behavior when ADC is not available (desktop mode).""" + + def setUp(self): + """Disable ADC.""" + bv.adc = None + def adc_to_voltage(adc_value): + return adc_value * 0.00161 + bv.conversion_func = adc_to_voltage + + def test_read_raw_adc_returns_random_value(self): + """Test that desktop mode returns random ADC value.""" + raw = bv.read_raw_adc() + self.assertIsNotNone(raw) + self.assertTrue(raw > 0, f"Expected raw > 0, got {raw}") + self.assertTrue(raw < 4096, f"Expected raw < 4096, got {raw}") + + def test_read_battery_voltage_works_without_adc(self): + """Test that voltage reading works in desktop mode.""" + voltage = bv.read_battery_voltage() + self.assertIsNotNone(voltage) + self.assertTrue(voltage > 0, f"Expected voltage > 0, got {voltage}") + + def test_get_battery_percentage_works_without_adc(self): + """Test that percentage reading works in desktop mode.""" + percentage = bv.get_battery_percentage() + self.assertIsNotNone(percentage) + self.assertGreaterEqual(percentage, 0) + self.assertLessEqual(percentage, 100) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_calibration_check_bug.py b/tests/test_calibration_check_bug.py new file mode 100644 index 00000000..14e72d80 --- /dev/null +++ b/tests/test_calibration_check_bug.py @@ -0,0 +1,162 @@ +"""Test for calibration check bug after calibrating. + +Reproduces issue where check_calibration_quality() returns None after calibration. +""" +import unittest +import sys + +# Mock hardware before importing SensorManager +class MockI2C: + def __init__(self, bus_id, sda=None, scl=None): + self.bus_id = bus_id + self.sda = sda + self.scl = scl + self.memory = {} + + def readfrom_mem(self, addr, reg, nbytes): + if addr not in self.memory: + raise OSError("I2C device not found") + if reg not in self.memory[addr]: + return bytes([0] * nbytes) + return bytes(self.memory[addr][reg]) + + def writeto_mem(self, addr, reg, data): + if addr not in self.memory: + self.memory[addr] = {} + self.memory[addr][reg] = list(data) + + +class MockQMI8658: + def __init__(self, i2c_bus, address=0x6B, accel_scale=0b10, gyro_scale=0b100): + self.i2c = i2c_bus + self.address = address + self.accel_scale = accel_scale + self.gyro_scale = gyro_scale + + @property + def temperature(self): + return 25.5 + + @property + def acceleration(self): + return (0.0, 0.0, 1.0) # At rest, Z-axis = 1G + + @property + def gyro(self): + return (0.0, 0.0, 0.0) # Stationary + + +# Mock constants +_QMI8685_PARTID = 0x05 +_REG_PARTID = 0x00 +_ACCELSCALE_RANGE_8G = 0b10 +_GYROSCALE_RANGE_256DPS = 0b100 + +# Create mock modules +mock_machine = type('module', (), { + 'I2C': MockI2C, + 'Pin': type('Pin', (), {}) +})() + +mock_qmi8658 = type('module', (), { + 'QMI8658': MockQMI8658, + '_QMI8685_PARTID': _QMI8685_PARTID, + '_REG_PARTID': _REG_PARTID, + '_ACCELSCALE_RANGE_8G': _ACCELSCALE_RANGE_8G, + '_GYROSCALE_RANGE_256DPS': _GYROSCALE_RANGE_256DPS +})() + +def _mock_mcu_temperature(*args, **kwargs): + return 42.0 + +mock_esp32 = type('module', (), { + 'mcu_temperature': _mock_mcu_temperature +})() + +# Inject mocks +sys.modules['machine'] = mock_machine +sys.modules['mpos.hardware.drivers.qmi8658'] = mock_qmi8658 +sys.modules['esp32'] = mock_esp32 + +try: + import _thread +except ImportError: + mock_thread = type('module', (), { + 'allocate_lock': lambda: type('lock', (), { + 'acquire': lambda self: None, + 'release': lambda self: None + })() + })() + sys.modules['_thread'] = mock_thread + +# Now import the module to test +import mpos.sensor_manager as SensorManager + + +class TestCalibrationCheckBug(unittest.TestCase): + """Test case for calibration check bug.""" + + def setUp(self): + """Set up test fixtures before each test.""" + # Reset SensorManager state + SensorManager._initialized = False + SensorManager._imu_driver = None + SensorManager._sensor_list = [] + SensorManager._has_mcu_temperature = False + + # Create mock I2C bus with QMI8658 + self.i2c_bus = MockI2C(0, sda=48, scl=47) + self.i2c_bus.memory[0x6B] = {_REG_PARTID: [_QMI8685_PARTID]} + + def test_check_quality_after_calibration(self): + """Test that check_calibration_quality() works after calibration. + + This reproduces the bug where check_calibration_quality() returns + None or shows "--" after performing calibration. + """ + # Initialize + SensorManager.init(self.i2c_bus, address=0x6B) + + # Step 1: Check calibration quality BEFORE calibration (should work) + print("\n=== Step 1: Check quality BEFORE calibration ===") + quality_before = SensorManager.check_calibration_quality(samples=10) + self.assertIsNotNone(quality_before, "Quality check BEFORE calibration should return data") + self.assertIn('quality_score', quality_before) + print(f"Quality before: {quality_before['quality_rating']} ({quality_before['quality_score']:.2f})") + + # Step 2: Calibrate sensors + print("\n=== Step 2: Calibrate sensors ===") + accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) + gyro = SensorManager.get_default_sensor(SensorManager.TYPE_GYROSCOPE) + + self.assertIsNotNone(accel, "Accelerometer should be available") + self.assertIsNotNone(gyro, "Gyroscope should be available") + + accel_offsets = SensorManager.calibrate_sensor(accel, samples=10) + print(f"Accel offsets: {accel_offsets}") + self.assertIsNotNone(accel_offsets, "Accelerometer calibration should succeed") + + gyro_offsets = SensorManager.calibrate_sensor(gyro, samples=10) + print(f"Gyro offsets: {gyro_offsets}") + self.assertIsNotNone(gyro_offsets, "Gyroscope calibration should succeed") + + # Step 3: Check calibration quality AFTER calibration (BUG: returns None) + print("\n=== Step 3: Check quality AFTER calibration ===") + quality_after = SensorManager.check_calibration_quality(samples=10) + self.assertIsNotNone(quality_after, "Quality check AFTER calibration should return data (BUG: returns None)") + self.assertIn('quality_score', quality_after) + print(f"Quality after: {quality_after['quality_rating']} ({quality_after['quality_score']:.2f})") + + # Verify sensor reads still work + print("\n=== Step 4: Verify sensor reads still work ===") + accel_data = SensorManager.read_sensor(accel) + self.assertIsNotNone(accel_data, "Accelerometer should still be readable") + print(f"Accel data: {accel_data}") + + gyro_data = SensorManager.read_sensor(gyro) + self.assertIsNotNone(gyro_data, "Gyroscope should still be readable") + print(f"Gyro data: {gyro_data}") + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_connectivity_manager.py b/tests/test_connectivity_manager.py new file mode 100644 index 00000000..99ffd720 --- /dev/null +++ b/tests/test_connectivity_manager.py @@ -0,0 +1,636 @@ +import unittest +import sys + +# Add parent directory to path so we can import network_test_helper +# When running from unittest.sh, we're in internal_filesystem/, so tests/ is ../tests/ +sys.path.insert(0, '../tests') + +# Import our network test helpers +from network_test_helper import MockNetwork, MockTimer, MockTime, MockRequests, MockSocket + +# Mock machine module with Timer +class MockMachine: + """Mock machine module.""" + Timer = MockTimer + +# Mock usocket module +class MockUsocket: + """Mock usocket module.""" + AF_INET = MockSocket.AF_INET + SOCK_STREAM = MockSocket.SOCK_STREAM + + @staticmethod + def socket(af, sock_type): + return MockSocket(af, sock_type) + +# Inject mocks into sys.modules BEFORE importing connectivity_manager +sys.modules['machine'] = MockMachine +sys.modules['usocket'] = MockUsocket + +# Mock requests module +mock_requests = MockRequests() +sys.modules['requests'] = mock_requests + + +class TestConnectivityManagerWithNetwork(unittest.TestCase): + """Test ConnectivityManager with network module available.""" + + def setUp(self): + """Set up test fixtures.""" + # Create a mock network module + self.mock_network = MockNetwork(connected=True) + + # Mock the network module globally BEFORE importing + sys.modules['network'] = self.mock_network + + # Now import after network is mocked + # Need to reload the module to pick up the new network module + if 'mpos.net.connectivity_manager' in sys.modules: + del sys.modules['mpos.net.connectivity_manager'] + + # Import fresh + from mpos.net.connectivity_manager import ConnectivityManager + self.ConnectivityManager = ConnectivityManager + + # Reset the singleton instance + ConnectivityManager._instance = None + + # Reset all mock timers + MockTimer.reset_all() + + def tearDown(self): + """Clean up after test.""" + # Reset singleton + if hasattr(self, 'ConnectivityManager'): + self.ConnectivityManager._instance = None + + # Clean up mocks + if 'network' in sys.modules: + del sys.modules['network'] + if 'mpos.net.connectivity_manager' in sys.modules: + del sys.modules['mpos.net.connectivity_manager'] + MockTimer.reset_all() + + def test_singleton_pattern(self): + """Test that ConnectivityManager is a singleton via get().""" + # Using get() should return the same instance + cm1 = self.ConnectivityManager.get() + cm2 = self.ConnectivityManager.get() + cm3 = self.ConnectivityManager.get() + + # All should be the same instance + self.assertEqual(id(cm1), id(cm2)) + self.assertEqual(id(cm2), id(cm3)) + + def test_initialization_with_network_module(self): + """Test initialization when network module is available.""" + cm = self.ConnectivityManager() + + # Should have network checking capability + self.assertTrue(cm.can_check_network) + + # Should have created WLAN instance + self.assertIsNotNone(cm.wlan) + + # Should have created timer + timer = MockTimer.get_timer(1) + self.assertIsNotNone(timer) + self.assertTrue(timer.active) + self.assertEqual(timer.period, 8000) + self.assertEqual(timer.mode, MockTimer.PERIODIC) + + def test_initial_connection_state_when_connected(self): + """Test initial state when network is connected.""" + self.mock_network.set_connected(True) + cm = self.ConnectivityManager() + + # Should detect connection during initialization + self.assertTrue(cm.is_online()) + + def test_initial_connection_state_when_disconnected(self): + """Test initial state when network is disconnected.""" + self.mock_network.set_connected(False) + cm = self.ConnectivityManager() + + # Should detect disconnection during initialization + self.assertFalse(cm.is_online()) + + def test_callback_registration(self): + """Test registering callbacks.""" + cm = self.ConnectivityManager() + + callback_called = [] + def my_callback(online): + callback_called.append(online) + + cm.register_callback(my_callback) + + # Callback should be in the list + self.assertTrue(my_callback in cm.callbacks) + + # Registering again should not duplicate + cm.register_callback(my_callback) + self.assertEqual(cm.callbacks.count(my_callback), 1) + + def test_callback_unregistration(self): + """Test unregistering callbacks.""" + cm = self.ConnectivityManager() + + def callback1(online): + pass + + def callback2(online): + pass + + cm.register_callback(callback1) + cm.register_callback(callback2) + + # Both should be registered + self.assertTrue(callback1 in cm.callbacks) + self.assertTrue(callback2 in cm.callbacks) + + # Unregister callback1 + cm.unregister_callback(callback1) + + # Only callback2 should remain + self.assertFalse(callback1 in cm.callbacks) + self.assertTrue(callback2 in cm.callbacks) + + def test_callback_notification_on_state_change(self): + """Test that callbacks are notified when state changes.""" + self.mock_network.set_connected(True) + cm = self.ConnectivityManager() + + notifications = [] + def my_callback(online): + notifications.append(online) + + cm.register_callback(my_callback) + + # Simulate going offline + self.mock_network.set_connected(False) + + # Trigger periodic check (timer passes itself as first arg) + timer = MockTimer.get_timer(1) + timer.callback(timer) + + # Should have been notified of offline state + self.assertEqual(len(notifications), 1) + self.assertFalse(notifications[0]) + + # Simulate going back online + self.mock_network.set_connected(True) + timer.callback(timer) + + # Should have been notified of online state + self.assertEqual(len(notifications), 2) + self.assertTrue(notifications[1]) + + def test_callback_notification_not_sent_when_state_unchanged(self): + """Test that callbacks are not notified when state doesn't change.""" + self.mock_network.set_connected(True) + cm = self.ConnectivityManager() + + notifications = [] + def my_callback(online): + notifications.append(online) + + cm.register_callback(my_callback) + + # Trigger periodic check while still connected + timer = MockTimer.get_timer(1) + timer.callback(timer) + + # Should not have been notified (state didn't change) + self.assertEqual(len(notifications), 0) + + def test_periodic_check_detects_connection_change(self): + """Test that periodic check detects connection state changes.""" + self.mock_network.set_connected(True) + cm = self.ConnectivityManager() + + # Should be online initially + self.assertTrue(cm.is_online()) + + # Simulate disconnection + self.mock_network.set_connected(False) + + # Trigger periodic check + timer = MockTimer.get_timer(1) + timer.callback(timer) + + # Should now be offline + self.assertFalse(cm.is_online()) + + # Reconnect + self.mock_network.set_connected(True) + timer.callback(timer) + + # Should be online again + self.assertTrue(cm.is_online()) + + def test_callback_exception_handling(self): + """Test that exceptions in callbacks don't break the manager.""" + cm = self.ConnectivityManager() + + notifications = [] + + def bad_callback(online): + raise Exception("Callback error!") + + def good_callback(online): + notifications.append(online) + + cm.register_callback(bad_callback) + cm.register_callback(good_callback) + + # Trigger state change + self.mock_network.set_connected(False) + timer = MockTimer.get_timer(1) + timer.callback(timer) + + # Good callback should still have been called despite bad callback + self.assertEqual(len(notifications), 1) + self.assertFalse(notifications[0]) + + def test_multiple_callbacks(self): + """Test multiple callbacks are all notified.""" + cm = self.ConnectivityManager() + + notifications1 = [] + notifications2 = [] + notifications3 = [] + + cm.register_callback(lambda online: notifications1.append(online)) + cm.register_callback(lambda online: notifications2.append(online)) + cm.register_callback(lambda online: notifications3.append(online)) + + # Trigger state change + self.mock_network.set_connected(False) + timer = MockTimer.get_timer(1) + timer.callback(timer) + + # All callbacks should have been notified + self.assertEqual(len(notifications1), 1) + self.assertEqual(len(notifications2), 1) + self.assertEqual(len(notifications3), 1) + + self.assertFalse(notifications1[0]) + self.assertFalse(notifications2[0]) + self.assertFalse(notifications3[0]) + + def test_is_wifi_connected(self): + """Test is_wifi_connected() method.""" + cm = self.ConnectivityManager() + + # is_connected is set to False during init for platforms with network module + # It's only set to True for platforms without network module (desktop) + self.assertFalse(cm.is_wifi_connected()) + + +class TestConnectivityManagerWithoutNetwork(unittest.TestCase): + """Test ConnectivityManager without network module (desktop mode).""" + + def setUp(self): + """Set up test fixtures.""" + # Remove network module to simulate desktop environment + if 'network' in sys.modules: + del sys.modules['network'] + + # Reload the module without network + if 'mpos.net.connectivity_manager' in sys.modules: + del sys.modules['mpos.net.connectivity_manager'] + + from mpos.net.connectivity_manager import ConnectivityManager + self.ConnectivityManager = ConnectivityManager + + # Reset the singleton instance + ConnectivityManager._instance = None + + # Reset timers + MockTimer.reset_all() + + def tearDown(self): + """Clean up after test.""" + if hasattr(self, 'ConnectivityManager'): + self.ConnectivityManager._instance = None + if 'mpos.net.connectivity_manager' in sys.modules: + del sys.modules['mpos.net.connectivity_manager'] + MockTimer.reset_all() + + def test_initialization_without_network_module(self): + """Test initialization when network module is not available.""" + cm = self.ConnectivityManager() + + # Should NOT have network checking capability + self.assertFalse(cm.can_check_network) + + # Should not have WLAN instance + self.assertIsNone(cm.wlan) + + # Should still create timer + timer = MockTimer.get_timer(1) + self.assertIsNotNone(timer) + + def test_always_online_without_network_module(self): + """Test that manager assumes always online without network module.""" + cm = self.ConnectivityManager() + + # Should assume connected + self.assertTrue(cm.is_connected) + + # Should assume online + self.assertTrue(cm.is_online()) + + def test_periodic_check_without_network_module(self): + """Test periodic check when there's no network module.""" + cm = self.ConnectivityManager() + + # Trigger periodic check + timer = MockTimer.get_timer(1) + timer.callback(timer) + + # Should still be online + self.assertTrue(cm.is_online()) + + def test_callbacks_not_triggered_without_network(self): + """Test that callbacks aren't triggered when always online.""" + cm = self.ConnectivityManager() + + notifications = [] + cm.register_callback(lambda online: notifications.append(online)) + + # Trigger periodic checks + timer = MockTimer.get_timer(1) + for _ in range(5): + timer.callback(timer) + + # No notifications should have been sent (state never changed) + self.assertEqual(len(notifications), 0) + + +class TestConnectivityManagerWaitUntilOnline(unittest.TestCase): + """Test wait_until_online functionality.""" + + def setUp(self): + """Set up test fixtures.""" + # Create mock network + self.mock_network = MockNetwork(connected=False) + sys.modules['network'] = self.mock_network + + # Reload module + if 'mpos.net.connectivity_manager' in sys.modules: + del sys.modules['mpos.net.connectivity_manager'] + + from mpos.net.connectivity_manager import ConnectivityManager + self.ConnectivityManager = ConnectivityManager + + ConnectivityManager._instance = None + MockTimer.reset_all() + + def tearDown(self): + """Clean up after test.""" + if hasattr(self, 'ConnectivityManager'): + self.ConnectivityManager._instance = None + MockTimer.reset_all() + if 'network' in sys.modules: + del sys.modules['network'] + if 'mpos.net.connectivity_manager' in sys.modules: + del sys.modules['mpos.net.connectivity_manager'] + + def test_wait_until_online_already_online(self): + """Test wait_until_online when already online.""" + self.mock_network.set_connected(True) + cm = self.ConnectivityManager() + + # Should return immediately + result = cm.wait_until_online(timeout=5) + self.assertTrue(result) + + def test_wait_until_online_without_network_module(self): + """Test wait_until_online without network module (desktop).""" + # Remove network module + if 'network' in sys.modules: + del sys.modules['network'] + + # Reload module + if 'mpos.net.connectivity_manager' in sys.modules: + del sys.modules['mpos.net.connectivity_manager'] + + from mpos.net.connectivity_manager import ConnectivityManager + self.ConnectivityManager = ConnectivityManager + ConnectivityManager._instance = None + + cm = self.ConnectivityManager() + + # Should return True immediately (always online) + result = cm.wait_until_online(timeout=5) + self.assertTrue(result) + + +class TestConnectivityManagerEdgeCases(unittest.TestCase): + """Test edge cases and error conditions.""" + + def setUp(self): + """Set up test fixtures.""" + self.mock_network = MockNetwork(connected=True) + sys.modules['network'] = self.mock_network + + if 'mpos.net.connectivity_manager' in sys.modules: + del sys.modules['mpos.net.connectivity_manager'] + + from mpos.net.connectivity_manager import ConnectivityManager + self.ConnectivityManager = ConnectivityManager + + ConnectivityManager._instance = None + MockTimer.reset_all() + + def tearDown(self): + """Clean up after test.""" + if hasattr(self, 'ConnectivityManager'): + self.ConnectivityManager._instance = None + MockTimer.reset_all() + if 'network' in sys.modules: + del sys.modules['network'] + if 'mpos.net.connectivity_manager' in sys.modules: + del sys.modules['mpos.net.connectivity_manager'] + + def test_initialization_creates_timer(self): + """Test that initialization creates periodic timer.""" + cm = self.ConnectivityManager() + + # Timer should exist + timer = MockTimer.get_timer(1) + self.assertIsNotNone(timer) + + # Timer should be configured correctly + self.assertEqual(timer.period, 8000) # 8 seconds + self.assertEqual(timer.mode, MockTimer.PERIODIC) + self.assertTrue(timer.active) + + def test_get_creates_instance_if_not_exists(self): + """Test that get() creates instance if it doesn't exist.""" + # Ensure no instance exists + self.assertIsNone(self.ConnectivityManager._instance) + + # get() should create one + cm = self.ConnectivityManager.get() + self.assertIsNotNone(cm) + + # Subsequent get() should return same instance + cm2 = self.ConnectivityManager.get() + self.assertEqual(id(cm), id(cm2)) + + def test_periodic_check_does_not_notify_on_init(self): + """Test periodic check doesn't notify during initialization.""" + self.mock_network.set_connected(False) + + # Register callback AFTER creating instance to observe later notifications + cm = self.ConnectivityManager() + + notifications = [] + cm.register_callback(lambda online: notifications.append(online)) + + # No notifications yet (initial check had notify=False) + self.assertEqual(len(notifications), 0) + + def test_unregister_nonexistent_callback(self): + """Test unregistering a callback that was never registered.""" + cm = self.ConnectivityManager() + + def my_callback(online): + pass + + # Should not raise an exception + cm.unregister_callback(my_callback) + + # Callbacks should be empty + self.assertEqual(len(cm.callbacks), 0) + + def test_online_offline_online_transitions(self): + """Test multiple state transitions.""" + self.mock_network.set_connected(True) + cm = self.ConnectivityManager() + + notifications = [] + cm.register_callback(lambda online: notifications.append(online)) + + timer = MockTimer.get_timer(1) + + # Go offline + self.mock_network.set_connected(False) + timer.callback(timer) + self.assertFalse(cm.is_online()) + self.assertEqual(notifications[-1], False) + + # Go online + self.mock_network.set_connected(True) + timer.callback(timer) + self.assertTrue(cm.is_online()) + self.assertEqual(notifications[-1], True) + + # Go offline again + self.mock_network.set_connected(False) + timer.callback(timer) + self.assertFalse(cm.is_online()) + self.assertEqual(notifications[-1], False) + + # Should have 3 notifications + self.assertEqual(len(notifications), 3) + + +class TestConnectivityManagerIntegration(unittest.TestCase): + """Integration tests for ConnectivityManager.""" + + def setUp(self): + """Set up test fixtures.""" + self.mock_network = MockNetwork(connected=True) + sys.modules['network'] = self.mock_network + + if 'mpos.net.connectivity_manager' in sys.modules: + del sys.modules['mpos.net.connectivity_manager'] + + from mpos.net.connectivity_manager import ConnectivityManager + self.ConnectivityManager = ConnectivityManager + + ConnectivityManager._instance = None + MockTimer.reset_all() + + def tearDown(self): + """Clean up after test.""" + if hasattr(self, 'ConnectivityManager'): + self.ConnectivityManager._instance = None + MockTimer.reset_all() + if 'network' in sys.modules: + del sys.modules['network'] + if 'mpos.net.connectivity_manager' in sys.modules: + del sys.modules['mpos.net.connectivity_manager'] + + def test_realistic_usage_scenario(self): + """Test a realistic usage scenario.""" + # App starts, creates connectivity manager + cm = self.ConnectivityManager.get() + + # App registers callback to update UI + ui_state = {'online': True} + def update_ui(online): + ui_state['online'] = online + + cm.register_callback(update_ui) + + # Initially online + self.assertTrue(cm.is_online()) + self.assertTrue(ui_state['online']) + + # User moves out of WiFi range + self.mock_network.set_connected(False) + timer = MockTimer.get_timer(1) + timer.callback(timer) + + # UI should reflect offline state + self.assertFalse(cm.is_online()) + self.assertFalse(ui_state['online']) + + # User returns to WiFi range + self.mock_network.set_connected(True) + timer.callback(timer) + + # UI should reflect online state + self.assertTrue(cm.is_online()) + self.assertTrue(ui_state['online']) + + # App closes, unregisters callback + cm.unregister_callback(update_ui) + + # Callback should be removed + self.assertFalse(update_ui in cm.callbacks) + + def test_multiple_apps_using_connectivity_manager(self): + """Test multiple apps/components using the same manager.""" + cm = self.ConnectivityManager.get() + + # Three different apps register callbacks + app1_state = [] + app2_state = [] + app3_state = [] + + cm.register_callback(lambda online: app1_state.append(online)) + cm.register_callback(lambda online: app2_state.append(online)) + cm.register_callback(lambda online: app3_state.append(online)) + + # Network goes offline + self.mock_network.set_connected(False) + timer = MockTimer.get_timer(1) + timer.callback(timer) + + # All apps should be notified + self.assertEqual(len(app1_state), 1) + self.assertEqual(len(app2_state), 1) + self.assertEqual(len(app3_state), 1) + + # All should see offline state + self.assertFalse(app1_state[0]) + self.assertFalse(app2_state[0]) + self.assertFalse(app3_state[0]) + + diff --git a/tests/test_download_manager.py b/tests/test_download_manager.py new file mode 100644 index 00000000..0eee1410 --- /dev/null +++ b/tests/test_download_manager.py @@ -0,0 +1,417 @@ +""" +test_download_manager.py - Tests for DownloadManager module + +Tests the centralized download manager functionality including: +- Session lifecycle management +- Download modes (memory, file, streaming) +- Progress tracking +- Error handling +- Resume support with Range headers +- Concurrent downloads +""" + +import unittest +import os +import sys + +# Import the module under test +sys.path.insert(0, '../internal_filesystem/lib') +import mpos.net.download_manager as DownloadManager + + +class TestDownloadManager(unittest.TestCase): + """Test cases for DownloadManager module.""" + + def setUp(self): + """Reset module state before each test.""" + # Reset module-level state + DownloadManager._session = None + DownloadManager._session_refcount = 0 + DownloadManager._session_lock = None + + # Create temp directory for file downloads + self.temp_dir = "/tmp/test_download_manager" + try: + os.mkdir(self.temp_dir) + except OSError: + pass # Directory already exists + + def tearDown(self): + """Clean up after each test.""" + # Close any open sessions + import asyncio + if DownloadManager._session: + asyncio.run(DownloadManager.close_session()) + + # Clean up temp files + try: + import os + for file in os.listdir(self.temp_dir): + try: + os.remove(f"{self.temp_dir}/{file}") + except OSError: + pass + os.rmdir(self.temp_dir) + except OSError: + pass + + # ==================== Session Lifecycle Tests ==================== + + def test_lazy_session_creation(self): + """Test that session is created lazily on first download.""" + import asyncio + + async def run_test(): + # Verify no session exists initially + self.assertFalse(DownloadManager.is_session_active()) + + # Perform a download + data = await DownloadManager.download_url("https://httpbin.org/bytes/100") + + # Verify session was created + # Note: Session may be closed immediately after download if refcount == 0 + # So we can't reliably check is_session_active() here + self.assertIsNotNone(data) + self.assertEqual(len(data), 100) + + asyncio.run(run_test()) + + def test_session_reuse_across_downloads(self): + """Test that the same session is reused for multiple downloads.""" + import asyncio + + async def run_test(): + # Perform first download + data1 = await DownloadManager.download_url("https://httpbin.org/bytes/50") + self.assertIsNotNone(data1) + + # Perform second download + data2 = await DownloadManager.download_url("https://httpbin.org/bytes/75") + self.assertIsNotNone(data2) + + # Verify different data was downloaded + self.assertEqual(len(data1), 50) + self.assertEqual(len(data2), 75) + + asyncio.run(run_test()) + + def test_explicit_session_close(self): + """Test explicit session closure.""" + import asyncio + + async def run_test(): + # Create session by downloading + data = await DownloadManager.download_url("https://httpbin.org/bytes/10") + self.assertIsNotNone(data) + + # Explicitly close session + await DownloadManager.close_session() + + # Verify session is closed + self.assertFalse(DownloadManager.is_session_active()) + + # Verify new download recreates session + data2 = await DownloadManager.download_url("https://httpbin.org/bytes/20") + self.assertIsNotNone(data2) + self.assertEqual(len(data2), 20) + + asyncio.run(run_test()) + + # ==================== Download Mode Tests ==================== + + def test_download_to_memory(self): + """Test downloading content to memory (returns bytes).""" + import asyncio + + async def run_test(): + data = await DownloadManager.download_url("https://httpbin.org/bytes/1024") + + self.assertIsInstance(data, bytes) + self.assertEqual(len(data), 1024) + + asyncio.run(run_test()) + + def test_download_to_file(self): + """Test downloading content to file (returns True/False).""" + import asyncio + + async def run_test(): + outfile = f"{self.temp_dir}/test_download.bin" + + success = await DownloadManager.download_url( + "https://httpbin.org/bytes/2048", + outfile=outfile + ) + + self.assertTrue(success) + self.assertEqual(os.stat(outfile)[6], 2048) + + # Clean up + os.remove(outfile) + + asyncio.run(run_test()) + + def test_download_with_chunk_callback(self): + """Test streaming download with chunk callback.""" + import asyncio + + async def run_test(): + chunks_received = [] + + async def collect_chunks(chunk): + chunks_received.append(chunk) + + success = await DownloadManager.download_url( + "https://httpbin.org/bytes/512", + chunk_callback=collect_chunks + ) + + self.assertTrue(success) + self.assertTrue(len(chunks_received) > 0) + + # Verify total size matches + total_size = sum(len(chunk) for chunk in chunks_received) + self.assertEqual(total_size, 512) + + asyncio.run(run_test()) + + def test_parameter_validation_conflicting_params(self): + """Test that outfile and chunk_callback cannot both be provided.""" + import asyncio + + async def run_test(): + with self.assertRaises(ValueError) as context: + await DownloadManager.download_url( + "https://httpbin.org/bytes/100", + outfile="/tmp/test.bin", + chunk_callback=lambda chunk: None + ) + + self.assertIn("Cannot use both", str(context.exception)) + + asyncio.run(run_test()) + + # ==================== Progress Tracking Tests ==================== + + def test_progress_callback(self): + """Test that progress callback is called with percentages.""" + import asyncio + + async def run_test(): + progress_calls = [] + + async def track_progress(percent): + progress_calls.append(percent) + + data = await DownloadManager.download_url( + "https://httpbin.org/bytes/5120", # 5KB + progress_callback=track_progress + ) + + self.assertIsNotNone(data) + self.assertTrue(len(progress_calls) > 0) + + # Verify progress values are in valid range + for pct in progress_calls: + self.assertTrue(0 <= pct <= 100) + + # Verify progress generally increases (allowing for some rounding variations) + # Note: Due to chunking and rounding, progress might not be strictly increasing + self.assertTrue(progress_calls[-1] >= 90) # Should end near 100% + + asyncio.run(run_test()) + + def test_progress_with_explicit_total_size(self): + """Test progress tracking with explicitly provided total_size.""" + import asyncio + + async def run_test(): + progress_calls = [] + + async def track_progress(percent): + progress_calls.append(percent) + + data = await DownloadManager.download_url( + "https://httpbin.org/bytes/3072", # 3KB + total_size=3072, + progress_callback=track_progress + ) + + self.assertIsNotNone(data) + self.assertTrue(len(progress_calls) > 0) + + asyncio.run(run_test()) + + # ==================== Error Handling Tests ==================== + + def test_http_error_status(self): + """Test handling of HTTP error status codes.""" + import asyncio + + async def run_test(): + # Request 404 error from httpbin + data = await DownloadManager.download_url("https://httpbin.org/status/404") + + # Should return None for memory download + self.assertIsNone(data) + + asyncio.run(run_test()) + + def test_http_error_with_file_output(self): + """Test that file download returns False on HTTP error.""" + import asyncio + + async def run_test(): + outfile = f"{self.temp_dir}/error_test.bin" + + success = await DownloadManager.download_url( + "https://httpbin.org/status/500", + outfile=outfile + ) + + # Should return False for file download + self.assertFalse(success) + + # File should not be created + try: + os.stat(outfile) + self.fail("File should not exist after failed download") + except OSError: + pass # Expected - file doesn't exist + + asyncio.run(run_test()) + + def test_invalid_url(self): + """Test handling of invalid URL.""" + import asyncio + + async def run_test(): + # Invalid URL should raise exception or return None + try: + data = await DownloadManager.download_url("http://invalid-url-that-does-not-exist.local/") + # If it doesn't raise, it should return None + self.assertIsNone(data) + except Exception: + # Exception is acceptable + pass + + asyncio.run(run_test()) + + # ==================== Headers Support Tests ==================== + + def test_custom_headers(self): + """Test that custom headers are passed to the request.""" + import asyncio + + async def run_test(): + # httpbin.org/headers echoes back the headers sent + data = await DownloadManager.download_url( + "https://httpbin.org/headers", + headers={"X-Custom-Header": "TestValue"} + ) + + self.assertIsNotNone(data) + # Verify the custom header was included (httpbin echoes it back) + response_text = data.decode('utf-8') + self.assertIn("X-Custom-Header", response_text) + self.assertIn("TestValue", response_text) + + asyncio.run(run_test()) + + # ==================== Edge Cases Tests ==================== + + def test_empty_response(self): + """Test handling of empty (0-byte) downloads.""" + import asyncio + + async def run_test(): + # Download 0 bytes + data = await DownloadManager.download_url("https://httpbin.org/bytes/0") + + self.assertIsNotNone(data) + self.assertEqual(len(data), 0) + self.assertEqual(data, b'') + + asyncio.run(run_test()) + + def test_small_download(self): + """Test downloading very small files (smaller than chunk size).""" + import asyncio + + async def run_test(): + # Download 10 bytes (much smaller than 1KB chunk size) + data = await DownloadManager.download_url("https://httpbin.org/bytes/10") + + self.assertIsNotNone(data) + self.assertEqual(len(data), 10) + + asyncio.run(run_test()) + + def test_json_download(self): + """Test downloading JSON data.""" + import asyncio + import json + + async def run_test(): + data = await DownloadManager.download_url("https://httpbin.org/json") + + self.assertIsNotNone(data) + # Verify it's valid JSON + parsed = json.loads(data.decode('utf-8')) + self.assertIsInstance(parsed, dict) + + asyncio.run(run_test()) + + # ==================== File Operations Tests ==================== + + def test_file_download_creates_directory_if_needed(self): + """Test that parent directories are NOT created (caller's responsibility).""" + import asyncio + + async def run_test(): + # Try to download to non-existent directory + outfile = "/tmp/nonexistent_dir_12345/test.bin" + + try: + success = await DownloadManager.download_url( + "https://httpbin.org/bytes/100", + outfile=outfile + ) + # Should fail because directory doesn't exist + self.assertFalse(success) + except Exception: + # Exception is acceptable + pass + + asyncio.run(run_test()) + + def test_file_overwrite(self): + """Test that downloading overwrites existing files.""" + import asyncio + + async def run_test(): + outfile = f"{self.temp_dir}/overwrite_test.bin" + + # Create initial file + with open(outfile, 'wb') as f: + f.write(b'old content') + + # Download and overwrite + success = await DownloadManager.download_url( + "https://httpbin.org/bytes/100", + outfile=outfile + ) + + self.assertTrue(success) + self.assertEqual(os.stat(outfile)[6], 100) + + # Verify old content is gone + with open(outfile, 'rb') as f: + content = f.read() + self.assertNotEqual(content, b'old content') + self.assertEqual(len(content), 100) + + # Clean up + os.remove(outfile) + + asyncio.run(run_test()) diff --git a/tests/test_graphical_abc_button_debug.py b/tests/test_graphical_abc_button_debug.py new file mode 100644 index 00000000..dc8575da --- /dev/null +++ b/tests/test_graphical_abc_button_debug.py @@ -0,0 +1,105 @@ +""" +Automated test that simulates clicking the abc button and shows debug output. + +This will show us exactly what's happening when the abc button is clicked. + +Usage: + Desktop: ./tests/unittest.sh tests/test_graphical_abc_button_debug.py +""" + +import unittest +import lvgl as lv +from mpos.ui.keyboard import MposKeyboard +from mpos.ui.testing import wait_for_render + + +class TestAbcButtonDebug(unittest.TestCase): + """Test that shows debug output when clicking abc button.""" + + def setUp(self): + """Set up test fixtures.""" + self.screen = lv.obj() + self.screen.set_size(320, 240) + + # Create textarea + self.textarea = lv.textarea(self.screen) + self.textarea.set_size(280, 40) + self.textarea.align(lv.ALIGN.TOP_MID, 0, 10) + self.textarea.set_one_line(True) + + # Load screen + lv.screen_load(self.screen) + wait_for_render(5) + + def tearDown(self): + """Clean up.""" + lv.screen_load(lv.obj()) + wait_for_render(5) + + def test_simulate_abc_button_click(self): + """ + Simulate clicking the abc button and show what happens. + """ + print("\n" + "="*70) + print("SIMULATING ABC BUTTON CLICK - WATCH FOR DEBUG OUTPUT") + print("="*70) + + keyboard = MposKeyboard(self.screen) + keyboard.set_textarea(self.textarea) + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + wait_for_render(10) + + # Start in lowercase, switch to numbers + print("\n>>> Switching to NUMBERS mode...") + keyboard.set_mode(MposKeyboard.MODE_NUMBERS) + wait_for_render(10) + + # Wait for debounce period to expire (150ms + margin) + import time + print(">>> Waiting 200ms for debounce period to expire...") + time.sleep(0.2) + + # Clear textarea + self.textarea.set_text("") + print(f">>> Textarea cleared: '{self.textarea.get_text()}'") + + # Find the "abc" button + abc_button_index = None + for i in range(100): + try: + text = keyboard.get_button_text(i) + if text == "abc": + abc_button_index = i + print(f">>> Found 'abc' button at index {abc_button_index}") + break + except: + pass + + # Now simulate what happens when user TOUCHES the button + # When user touches a button, LVGL's button matrix: + # 1. Sets the button as selected + # 2. Triggers VALUE_CHANGED event + print(f"\n>>> Simulating user clicking button {abc_button_index}...") + print(f">>> Before click: textarea = '{self.textarea.get_text()}'") + print("\n--- DEBUG OUTPUT SHOULD APPEAR BELOW ---\n") + + # Trigger the VALUE_CHANGED event which our handler catches + # This simulates a real button press + keyboard._keyboard.send_event(lv.EVENT.VALUE_CHANGED, None) + wait_for_render(5) + + print("\n--- END DEBUG OUTPUT ---\n") + + textarea_after = self.textarea.get_text() + print(f">>> After click: textarea = '{textarea_after}'") + + if textarea_after != "": + print(f"\n❌ BUG CONFIRMED!") + print(f" Expected: '' (empty)") + print(f" Got: '{textarea_after}'") + else: + print(f"\n✓ No text added (but check debug output above)") + + print("="*70) + + diff --git a/tests/test_graphical_about_app.py b/tests/test_graphical_about_app.py new file mode 100644 index 00000000..98c82308 --- /dev/null +++ b/tests/test_graphical_about_app.py @@ -0,0 +1,173 @@ +""" +Graphical test for the About app. + +This test verifies that the About app displays correct information, +specifically that the Hardware ID shown matches the actual hardware ID. + +This is a proof of concept for graphical testing that: +1. Starts an app programmatically +2. Verifies UI content via direct widget inspection +3. Captures screenshots for visual regression testing +4. Works on both desktop and device + +Usage: + Desktop: ./tests/unittest.sh tests/test_graphical_about_app.py + Device: ./tests/unittest.sh tests/test_graphical_about_app.py --ondevice +""" + +import unittest +import lvgl as lv +import mpos.apps +import mpos.info +import mpos.ui +import os +from mpos.ui.testing import ( + wait_for_render, + capture_screenshot, + find_label_with_text, + verify_text_present, + print_screen_labels +) + + +class TestGraphicalAboutApp(unittest.TestCase): + """Test suite for About app graphical verification.""" + + def setUp(self): + """Set up test fixtures before each test method.""" + # Get absolute path to screenshots directory + # When running tests, we're in internal_filesystem/, so go up one level + import sys + if sys.platform == "esp32": + self.screenshot_dir = "tests/screenshots" + else: + # On desktop, tests directory is in parent + self.screenshot_dir = "../tests/screenshots" + + # Ensure screenshots directory exists + try: + os.mkdir(self.screenshot_dir) + except OSError: + pass # Directory already exists + + # Store hardware ID for verification + self.hardware_id = mpos.info.get_hardware_id() + print(f"Testing with hardware ID: {self.hardware_id}") + + def tearDown(self): + """Clean up after each test method.""" + # Navigate back to launcher (closes the About app) + try: + mpos.ui.back_screen() + wait_for_render(5) # Allow navigation to complete + except: + pass # Already on launcher or error + + def test_about_app_shows_correct_hardware_id(self): + """ + Test that About app displays the correct Hardware ID. + + Verification approach: + 1. Start the About app + 2. Wait for UI to render + 3. Find the "Hardware ID:" label + 4. Verify it contains the actual hardware ID + 5. Capture screenshot for visual verification + """ + print("\n=== Starting About app test ===") + + # Start the About app + result = mpos.apps.start_app("com.micropythonos.about") + self.assertTrue(result, "Failed to start About app") + + # Wait for UI to fully render + wait_for_render(iterations=15) + + # Get current screen + screen = lv.screen_active() + + # Debug: Print all labels found (helpful for development) + print("\nLabels found on screen:") + print_screen_labels(screen) + + # Verify that Hardware ID text is present + hardware_id_label = find_label_with_text(screen, "Hardware ID:") + self.assertIsNotNone( + hardware_id_label, + "Could not find 'Hardware ID:' label on screen" + ) + + # Get the full text from the Hardware ID label + hardware_id_text = hardware_id_label.get_text() + print(f"\nHardware ID label text: {hardware_id_text}") + + # Verify the hardware ID matches + expected_text = f"Hardware ID: {self.hardware_id}" + self.assertEqual( + hardware_id_text, + expected_text, + f"Hardware ID mismatch. Expected '{expected_text}', got '{hardware_id_text}'" + ) + + # Also verify using the helper function + self.assertTrue( + verify_text_present(screen, self.hardware_id), + f"Hardware ID '{self.hardware_id}' not found on screen" + ) + + # Capture screenshot for visual regression testing + screenshot_path = f"{self.screenshot_dir}/about_app_{self.hardware_id}.raw" + print(f"\nCapturing screenshot to: {screenshot_path}") + + try: + buffer = capture_screenshot(screenshot_path, width=320, height=240) + print(f"Screenshot captured: {len(buffer)} bytes") + + # Verify screenshot file was created + stat = os.stat(screenshot_path) + self.assertTrue( + stat[6] > 0, # stat[6] is file size + "Screenshot file is empty" + ) + print(f"Screenshot file size: {stat[6]} bytes") + + except Exception as e: + self.fail(f"Failed to capture screenshot: {e}") + + print("\n=== About app test completed successfully ===") + + def test_about_app_shows_os_version(self): + """ + Test that About app displays the OS version. + + This is a simpler test that just verifies version info is present. + """ + print("\n=== Starting About app OS version test ===") + + # Start the About app + result = mpos.apps.start_app("com.micropythonos.about") + self.assertTrue(result, "Failed to start About app") + + # Wait for UI to render + wait_for_render(iterations=15) + + # Get current screen + screen = lv.screen_active() + + # Verify that MicroPythonOS version text is present + self.assertTrue( + verify_text_present(screen, "MicroPythonOS version:"), + "Could not find 'MicroPythonOS version:' on screen" + ) + + # Verify the actual version string is present + os_version = mpos.info.CURRENT_OS_VERSION + self.assertTrue( + verify_text_present(screen, os_version), + f"OS version '{os_version}' not found on screen" + ) + + print(f"Found OS version: {os_version}") + print("=== OS version test completed successfully ===") + + diff --git a/tests/test_graphical_animation_deleted_widget.py b/tests/test_graphical_animation_deleted_widget.py new file mode 100644 index 00000000..4fe367b3 --- /dev/null +++ b/tests/test_graphical_animation_deleted_widget.py @@ -0,0 +1,216 @@ +""" +Test that animations handle deleted widgets gracefully. + +This test reproduces the crash that occurs when: +1. An animation is started on a widget (e.g., keyboard fade-in) +2. The widget is deleted while the animation is running (e.g., user closes app) +3. The animation callback tries to access the deleted widget +4. Result: LvReferenceError crash + +The fix should make animations check if the widget still exists before +trying to access it. + +Usage: + Desktop: ./tests/unittest.sh tests/test_graphical_animation_deleted_widget.py + Device: ./tests/unittest.sh tests/test_graphical_animation_deleted_widget.py --ondevice +""" + +import unittest +import lvgl as lv +import mpos.ui.anim +import time +from mpos.ui.testing import wait_for_render + + +class TestAnimationDeletedWidget(unittest.TestCase): + """Test that animations don't crash when widget is deleted.""" + + def setUp(self): + """Set up test fixtures.""" + self.screen = lv.obj() + self.screen.set_size(320, 240) + lv.screen_load(self.screen) + print("\n=== Animation Deletion Test Setup ===") + + def tearDown(self): + """Clean up.""" + lv.screen_load(lv.obj()) + wait_for_render(5) + print("=== Test Cleanup Complete ===\n") + + def test_smooth_show_with_deleted_widget(self): + """ + Test that smooth_show doesn't crash if widget is deleted during animation. + + This reproduces the exact scenario: + - User opens keyboard (smooth_show animation starts) + - User presses escape (app closes, deleting all widgets) + - Animation tries to complete on deleted widget + """ + print("Testing smooth_show with deleted widget...") + + # Create a widget + widget = lv.obj(self.screen) + widget.set_size(200, 100) + widget.center() + widget.add_flag(lv.obj.FLAG.HIDDEN) + + # Start fade-in animation (500ms duration) + print("Starting smooth_show animation...") + mpos.ui.anim.smooth_show(widget) + + # Give animation time to start + wait_for_render(2) + + # Delete the widget while animation is running (simulates app close) + print("Deleting widget while animation is running...") + widget.delete() + + # Process LVGL tasks - this should trigger animation callbacks + # If not fixed, this will crash with LvReferenceError + print("Processing LVGL tasks (animation callbacks)...") + try: + for _ in range(100): + lv.task_handler() + time.sleep(0.01) # 1 second total to let animation complete + print("SUCCESS: No crash when accessing deleted widget") + except Exception as e: + if "LvReferenceError" in str(type(e).__name__): + self.fail(f"CRASH: Animation tried to access deleted widget: {e}") + else: + raise + + print("=== smooth_show deletion test PASSED ===") + + def test_smooth_hide_with_deleted_widget(self): + """ + Test that smooth_hide doesn't crash if widget is deleted during animation. + """ + print("Testing smooth_hide with deleted widget...") + + # Create a visible widget + widget = lv.obj(self.screen) + widget.set_size(200, 100) + widget.center() + # Start visible + widget.remove_flag(lv.obj.FLAG.HIDDEN) + + # Start fade-out animation + print("Starting smooth_hide animation...") + mpos.ui.anim.smooth_hide(widget) + + # Give animation time to start + wait_for_render(2) + + # Delete the widget while animation is running + print("Deleting widget while animation is running...") + widget.delete() + + # Process LVGL tasks + print("Processing LVGL tasks (animation callbacks)...") + try: + for _ in range(100): + lv.task_handler() + time.sleep(0.01) + print("SUCCESS: No crash when accessing deleted widget") + except Exception as e: + if "LvReferenceError" in str(type(e).__name__): + self.fail(f"CRASH: Animation tried to access deleted widget: {e}") + else: + raise + + print("=== smooth_hide deletion test PASSED ===") + + def test_keyboard_scenario(self): + """ + Test the exact scenario from QuasiNametag: + 1. Create keyboard with smooth_show + 2. Delete screen (simulating app close with ESC) + 3. Should not crash + """ + print("Testing keyboard deletion scenario...") + + from mpos.ui.keyboard import MposKeyboard + + # Create textarea and keyboard (like QuasiNametag does) + textarea = lv.textarea(self.screen) + textarea.set_size(280, 40) + textarea.align(lv.ALIGN.TOP_MID, 0, 10) + + keyboard = MposKeyboard(self.screen) + keyboard.set_textarea(textarea) + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + keyboard.add_flag(lv.obj.FLAG.HIDDEN) + + # User clicks textarea - keyboard shows with animation + print("Showing keyboard with animation...") + mpos.ui.anim.smooth_show(keyboard) + + # Give animation time to start + wait_for_render(2) + + # User presses ESC - app closes, screen is deleted + print("Deleting screen (simulating app close)...") + # Create new screen first, then delete old one + new_screen = lv.obj() + lv.screen_load(new_screen) + self.screen.delete() + self.screen = new_screen + + # Process LVGL tasks - animation callbacks should not crash + print("Processing LVGL tasks after deletion...") + try: + for _ in range(100): + lv.task_handler() + time.sleep(0.01) + print("SUCCESS: No crash after deleting screen with animating keyboard") + except Exception as e: + if "LvReferenceError" in str(type(e).__name__): + self.fail(f"CRASH: Keyboard animation tried to access deleted widget: {e}") + else: + raise + + print("=== Keyboard scenario test PASSED ===") + + def test_multiple_animations_deleted(self): + """ + Test that multiple widgets with animations can be deleted safely. + """ + print("Testing multiple animated widgets deletion...") + + widgets = [] + for i in range(5): + w = lv.obj(self.screen) + w.set_size(50, 50) + w.set_pos(i * 60, 50) + w.add_flag(lv.obj.FLAG.HIDDEN) + widgets.append(w) + + # Start animations on all widgets + print("Starting animations on 5 widgets...") + for w in widgets: + mpos.ui.anim.smooth_show(w) + + wait_for_render(2) + + # Delete all widgets while animations are running + print("Deleting all widgets while animations are running...") + for w in widgets: + w.delete() + + # Process tasks + print("Processing LVGL tasks...") + try: + for _ in range(100): + lv.task_handler() + time.sleep(0.01) + print("SUCCESS: No crash with multiple deleted widgets") + except Exception as e: + if "LvReferenceError" in str(type(e).__name__): + self.fail(f"CRASH: Multiple animations crashed on deleted widgets: {e}") + else: + raise + + print("=== Multiple animations test PASSED ===") + + diff --git a/tests/test_graphical_camera_settings.py b/tests/test_graphical_camera_settings.py new file mode 100644 index 00000000..9ccd7955 --- /dev/null +++ b/tests/test_graphical_camera_settings.py @@ -0,0 +1,258 @@ +""" +Graphical test for Camera app settings functionality. + +This test verifies that: +1. The camera app settings button can be clicked without crashing +2. The settings dialog opens correctly +3. Resolution can be changed without causing segfault +4. The camera continues to work after resolution change + +This specifically tests the fixes for: +- Segfault when clicking settings button +- Pale colors after resolution change +- Buffer size mismatches + +Usage: + Desktop: ./tests/unittest.sh tests/test_graphical_camera_settings.py + Device: ./tests/unittest.sh tests/test_graphical_camera_settings.py --ondevice +""" + +import unittest +import lvgl as lv +import mpos.apps +import mpos.ui +import os +from mpos.ui.testing import ( + wait_for_render, + capture_screenshot, + find_label_with_text, + find_button_with_text, + verify_text_present, + print_screen_labels, + simulate_click, + get_widget_coords +) + + +class TestGraphicalCameraSettings(unittest.TestCase): + """Test suite for Camera app settings verification.""" + + def setUp(self): + """Set up test fixtures before each test method.""" + # Check if webcam module is available + try: + import webcam + self.has_webcam = True + except: + try: + import camera + self.has_webcam = False # Has internal camera instead + except: + self.skipTest("No camera module available (webcam or internal)") + + # Get absolute path to screenshots directory + import sys + if sys.platform == "esp32": + self.screenshot_dir = "tests/screenshots" + else: + self.screenshot_dir = "../tests/screenshots" + + # Ensure screenshots directory exists + try: + os.mkdir(self.screenshot_dir) + except OSError: + pass # Directory already exists + + def tearDown(self): + """Clean up after each test method.""" + # Navigate back to launcher (closes the camera app) + try: + mpos.ui.back_screen() + wait_for_render(10) # Allow navigation and cleanup to complete + except: + pass # Already on launcher or error + + def test_settings_button_click_no_crash(self): + """ + Test that clicking the settings button doesn't cause a segfault. + + This is the critical test that verifies the fix for the segfault + that occurred when clicking settings due to stale image_dsc.data pointer. + + Steps: + 1. Start camera app + 2. Wait for camera to initialize + 3. Capture initial screenshot + 4. Click settings button (top-right corner) + 5. Verify settings dialog opened + 6. If we get here without crash, test passes + """ + print("\n=== Testing settings button click (no crash) ===") + + # Start the Camera app + result = mpos.apps.start_app("com.micropythonos.camera") + self.assertTrue(result, "Failed to start Camera app") + + # Wait for camera to initialize and first frame to render + wait_for_render(iterations=30) + + # Get current screen + screen = lv.screen_active() + + # Debug: Print all text on screen + print("\nInitial screen labels:") + print_screen_labels(screen) + + # Capture screenshot before clicking settings + screenshot_path = f"{self.screenshot_dir}/camera_before_settings.raw" + print(f"\nCapturing initial screenshot: {screenshot_path}") + capture_screenshot(screenshot_path, width=320, height=240) + + # Find and click settings button + # The settings button is positioned at TOP_RIGHT with offset (0, 60) + # On a 320x240 screen, this is approximately x=260, y=90 + # We'll click slightly inside the button to ensure we hit it + settings_x = 300 # Right side of screen, inside the 60px button + settings_y = 100 # 60px down from top, center of 60px button + + print(f"\nClicking settings button at ({settings_x}, {settings_y})") + simulate_click(settings_x, settings_y, press_duration_ms=100) + + # Wait for settings dialog to appear + wait_for_render(iterations=20) + + # Get screen again (might have changed after navigation) + screen = lv.screen_active() + + # Debug: Print labels after clicking + print("\nScreen labels after clicking settings:") + print_screen_labels(screen) + + # Verify settings screen opened + # Look for "Camera Settings" or "resolution" text + has_settings_ui = ( + verify_text_present(screen, "Camera Settings") or + verify_text_present(screen, "Resolution") or + verify_text_present(screen, "resolution") or + verify_text_present(screen, "Save") or + verify_text_present(screen, "Cancel") + ) + + self.assertTrue( + has_settings_ui, + "Settings screen did not open (no expected UI elements found)" + ) + + # Capture screenshot of settings dialog + screenshot_path = f"{self.screenshot_dir}/camera_settings_dialog.raw" + print(f"\nCapturing settings dialog screenshot: {screenshot_path}") + capture_screenshot(screenshot_path, width=320, height=240) + + # If we got here without segfault, the test passes! + print("\n✓ Settings button clicked successfully without crash!") + + def test_resolution_change_no_crash(self): + """ + Test that changing resolution doesn't cause a crash. + + This tests the full resolution change workflow: + 1. Start camera app + 2. Open settings + 3. Change resolution + 4. Save settings + 5. Verify camera continues working + + This verifies fixes for: + - Segfault during reconfiguration + - Buffer size mismatches + - Stale data pointers + """ + print("\n=== Testing resolution change (no crash) ===") + + # Start the Camera app + result = mpos.apps.start_app("com.micropythonos.camera") + self.assertTrue(result, "Failed to start Camera app") + + # Wait for camera to initialize + wait_for_render(iterations=30) + + # Click settings button + print("\nOpening settings...") + simulate_click(290, 90, press_duration_ms=100) + wait_for_render(iterations=20) + + screen = lv.screen_active() + + # Try to find the dropdown/resolution selector + # The CameraSettingsActivity creates a dropdown widget + # Let's look for any dropdown on screen + print("\nLooking for resolution dropdown...") + + # Find all clickable objects (dropdowns are clickable) + # We'll try clicking in the middle area where the dropdown should be + # Dropdown is typically centered, so around x=160, y=120 + dropdown_x = 160 + dropdown_y = 120 + + print(f"Clicking dropdown area at ({dropdown_x}, {dropdown_y})") + simulate_click(dropdown_x, dropdown_y, press_duration_ms=100) + wait_for_render(iterations=15) + + # The dropdown should now be open showing resolution options + # Let's capture what we see + screenshot_path = f"{self.screenshot_dir}/camera_dropdown_open.raw" + print(f"Capturing dropdown screenshot: {screenshot_path}") + capture_screenshot(screenshot_path, width=320, height=240) + + screen = lv.screen_active() + print("\nScreen after opening dropdown:") + print_screen_labels(screen) + + # Try to select a different resolution + # Options are typically stacked vertically + # Let's click a bit lower to select a different option + option_x = 160 + option_y = 150 # Below the current selection + + print(f"\nSelecting different resolution at ({option_x}, {option_y})") + simulate_click(option_x, option_y, press_duration_ms=100) + wait_for_render(iterations=15) + + # Now find and click the Save button + print("\nLooking for Save button...") + save_button = find_button_with_text(lv.screen_active(), "Save") + + if save_button: + coords = get_widget_coords(save_button) + print(f"Found Save button at {coords}") + simulate_click(coords['center_x'], coords['center_y'], press_duration_ms=100) + else: + # Fallback: Save button is typically at bottom-left + # Based on CameraSettingsActivity code: ALIGN.BOTTOM_LEFT + print("Save button not found via text, trying bottom-left corner") + simulate_click(80, 220, press_duration_ms=100) + + # Wait for reconfiguration to complete + print("\nWaiting for reconfiguration...") + wait_for_render(iterations=30) + + # Capture screenshot after reconfiguration + screenshot_path = f"{self.screenshot_dir}/camera_after_resolution_change.raw" + print(f"Capturing post-change screenshot: {screenshot_path}") + capture_screenshot(screenshot_path, width=320, height=240) + + # If we got here without segfault, the test passes! + print("\n✓ Resolution changed successfully without crash!") + + # Verify camera is still showing something + screen = lv.screen_active() + # The camera app should still be active (not crashed back to launcher) + # We can check this by looking for camera-specific UI elements + # or just the fact that we haven't crashed + + print("\n✓ Camera app still running after resolution change!") + + +if __name__ == '__main__': + # Note: Don't include unittest.main() - handled by unittest.sh + pass diff --git a/tests/test_graphical_custom_keyboard.py b/tests/test_graphical_custom_keyboard.py new file mode 100644 index 00000000..55d564db --- /dev/null +++ b/tests/test_graphical_custom_keyboard.py @@ -0,0 +1,316 @@ +""" +Graphical tests for MposKeyboard. + +Tests keyboard visual appearance, text input via simulated button presses, +and mode switching. Captures screenshots for regression testing. + +Usage: + Desktop: ./tests/unittest.sh tests/test_graphical_custom_keyboard.py + Device: ./tests/unittest.sh tests/test_graphical_custom_keyboard.py --ondevice +""" + +import unittest +import lvgl as lv +import sys +import os +from mpos.ui.keyboard import MposKeyboard +from mpos.ui.testing import ( + wait_for_render, + capture_screenshot, +) + + +class TestGraphicalMposKeyboard(unittest.TestCase): + """Test suite for MposKeyboard graphical verification.""" + + def setUp(self): + """Set up test fixtures before each test method.""" + # Determine screenshot directory + if sys.platform == "esp32": + self.screenshot_dir = "tests/screenshots" + else: + self.screenshot_dir = "../tests/screenshots" + + # Ensure screenshots directory exists + try: + os.mkdir(self.screenshot_dir) + except OSError: + pass # Directory already exists + + print(f"\n=== Graphical Keyboard Test Setup ===") + print(f"Platform: {sys.platform}") + + def tearDown(self): + """Clean up after each test method.""" + lv.screen_load(lv.obj()) + wait_for_render(5) + print("=== Test Cleanup Complete ===\n") + + def _create_test_keyboard_scene(self): + """ + Create a test scene with textarea and keyboard. + + Returns: + tuple: (screen, keyboard, textarea) + """ + # Create screen + screen = lv.obj() + screen.set_size(320, 240) + + # Create textarea + textarea = lv.textarea(screen) + textarea.set_size(280, 40) + textarea.align(lv.ALIGN.TOP_MID, 0, 10) + textarea.set_placeholder_text("Type here...") + textarea.set_one_line(True) + + # Create custom keyboard + keyboard = MposKeyboard(screen) + keyboard.set_textarea(textarea) + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + + # Load and render + lv.screen_load(screen) + wait_for_render(iterations=20) + + return screen, keyboard, textarea + + def _simulate_button_press(self, keyboard, button_index): + """ + Simulate pressing a keyboard button. + + Args: + keyboard: CustomKeyboard instance + button_index: Index of button to press + + Returns: + str: Text of the pressed button + """ + + # Get button text before pressing + button_text = keyboard.get_button_text(button_index) + + # Simulate button press by setting it as selected and sending event + # Note: This is a bit of a hack since we can't directly click in tests + # We'll trigger the VALUE_CHANGED event which is what happens on click + + # The keyboard has an internal handler that responds to VALUE_CHANGED + # We need to manually trigger it + keyboard.send_event(lv.EVENT.VALUE_CHANGED, None) + + wait_for_render(5) + + return button_text + + def test_keyboard_lowercase_appearance(self): + """ + Test keyboard appearance in lowercase mode. + + Verifies that the keyboard renders correctly and captures screenshot. + """ + print("\n=== Testing lowercase keyboard appearance ===") + + screen, keyboard, textarea = self._create_test_keyboard_scene() + + # Ensure lowercase mode + keyboard.set_mode(MposKeyboard.MODE_LOWERCASE) + wait_for_render(10) + + # Capture screenshot + screenshot_path = f"{self.screenshot_dir}/custom_keyboard_lowercase.raw" + print(f"Capturing screenshot: {screenshot_path}") + capture_screenshot(screenshot_path, width=320, height=240) + + # Verify screenshot was created + stat = os.stat(screenshot_path) + self.assertTrue(stat[6] > 0, "Screenshot file is empty") + print(f"Screenshot captured: {stat[6]} bytes") + + print("=== Lowercase appearance test PASSED ===") + + def test_keyboard_uppercase_appearance(self): + """Test keyboard appearance in uppercase mode.""" + print("\n=== Testing uppercase keyboard appearance ===") + + screen, keyboard, textarea = self._create_test_keyboard_scene() + + # Switch to uppercase mode + keyboard.set_mode(MposKeyboard.MODE_UPPERCASE) + wait_for_render(10) + + # Capture screenshot + screenshot_path = f"{self.screenshot_dir}/custom_keyboard_uppercase.raw" + print(f"Capturing screenshot: {screenshot_path}") + capture_screenshot(screenshot_path, width=320, height=240) + + # Verify screenshot was created + stat = os.stat(screenshot_path) + self.assertTrue(stat[6] > 0, "Screenshot file is empty") + print(f"Screenshot captured: {stat[6]} bytes") + + print("=== Uppercase appearance test PASSED ===") + + def test_keyboard_numbers_appearance(self): + """Test keyboard appearance in numbers/specials mode.""" + print("\n=== Testing numbers keyboard appearance ===") + + screen, keyboard, textarea = self._create_test_keyboard_scene() + + # Switch to numbers mode + keyboard.set_mode(MposKeyboard.MODE_NUMBERS) + wait_for_render(10) + + # Capture screenshot + screenshot_path = f"{self.screenshot_dir}/custom_keyboard_numbers.raw" + print(f"Capturing screenshot: {screenshot_path}") + capture_screenshot(screenshot_path, width=320, height=240) + + # Verify screenshot was created + stat = os.stat(screenshot_path) + self.assertTrue(stat[6] > 0, "Screenshot file is empty") + print(f"Screenshot captured: {stat[6]} bytes") + + print("=== Numbers appearance test PASSED ===") + + def test_keyboard_specials_appearance(self): + """Test keyboard appearance in additional specials mode.""" + print("\n=== Testing specials keyboard appearance ===") + + screen, keyboard, textarea = self._create_test_keyboard_scene() + + # Switch to specials mode + keyboard.set_mode(MposKeyboard.MODE_SPECIALS) + wait_for_render(10) + + # Capture screenshot + screenshot_path = f"{self.screenshot_dir}/custom_keyboard_specials.raw" + print(f"Capturing screenshot: {screenshot_path}") + capture_screenshot(screenshot_path, width=320, height=240) + + # Verify screenshot was created + stat = os.stat(screenshot_path) + self.assertTrue(stat[6] > 0, "Screenshot file is empty") + print(f"Screenshot captured: {stat[6]} bytes") + + print("=== Specials appearance test PASSED ===") + + def test_keyboard_visibility_light_mode(self): + """ + Test that custom keyboard buttons are visible in light mode. + + This verifies that the theme fix is applied. + """ + print("\n=== Testing keyboard visibility in light mode ===") + + # Set light mode (should already be default) + import mpos.config + import mpos.ui.theme + prefs = mpos.config.SharedPreferences("theme_settings") + editor = prefs.edit() + editor.put_string("theme_light_dark", "light") + editor.commit() + mpos.ui.theme.set_theme(prefs) + wait_for_render(10) + + # Create keyboard + screen, keyboard, textarea = self._create_test_keyboard_scene() + + # Get button background color + bg_color = keyboard.get_style_bg_color(lv.PART.ITEMS) + + # Extract RGB (similar to keyboard styling test) + try: + color_dict = { + 'r': bg_color.red() if hasattr(bg_color, 'red') else 0, + 'g': bg_color.green() if hasattr(bg_color, 'green') else 0, + 'b': bg_color.blue() if hasattr(bg_color, 'blue') else 0, + } + except: + try: + color_int = bg_color.to_int() if hasattr(bg_color, 'to_int') else 0 + color_dict = { + 'r': (color_int >> 16) & 0xFF, + 'g': (color_int >> 8) & 0xFF, + 'b': color_int & 0xFF, + } + except: + color_dict = {'r': 0, 'g': 0, 'b': 0} + + print(f"Button background: RGB({color_dict['r']}, {color_dict['g']}, {color_dict['b']})") + + # Verify buttons are NOT pure white (which would be invisible) + if 'r' in color_dict: + is_white = (color_dict['r'] >= 250 and + color_dict['g'] >= 250 and + color_dict['b'] >= 250) + + self.assertFalse( + is_white, + f"Mpos keyboard buttons are pure white in light mode (invisible)!" + ) + + print("=== Visibility test PASSED ===") + + def test_keyboard_with_standard_comparison(self): + """ + Test custom keyboard alongside standard keyboard. + + Creates both for visual comparison. + """ + print("\n=== Testing custom vs standard keyboard ===") + + # Create screen with two textareas + screen = lv.obj() + screen.set_size(320, 240) + + # Top textarea with standard keyboard + ta_standard = lv.textarea(screen) + ta_standard.set_size(280, 30) + ta_standard.set_pos(20, 5) + ta_standard.set_placeholder_text("Standard") + ta_standard.set_one_line(True) + + # Create standard keyboard (hidden initially) + keyboard_standard = MposKeyboard(screen) + keyboard_standard.set_textarea(ta_standard) + keyboard_standard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + keyboard_standard.set_style_min_height(145, 0) + + # Load and render + lv.screen_load(screen) + wait_for_render(20) + + # Capture standard keyboard + screenshot_path = f"{self.screenshot_dir}/keyboard_standard_comparison.raw" + print(f"Capturing standard keyboard: {screenshot_path}") + capture_screenshot(screenshot_path, width=320, height=240) + + # Clean up + lv.screen_load(lv.obj()) + wait_for_render(5) + + # Now create custom keyboard + screen2 = lv.obj() + screen2.set_size(320, 240) + + ta_custom = lv.textarea(screen2) + ta_custom.set_size(280, 30) + ta_custom.set_pos(20, 5) + ta_custom.set_placeholder_text("Custom") + ta_custom.set_one_line(True) + + keyboard_custom = MposKeyboard(screen2) + keyboard_custom.set_textarea(ta_custom) + keyboard_custom.align(lv.ALIGN.BOTTOM_MID, 0, 0) + + lv.screen_load(screen2) + wait_for_render(20) + + # Capture custom keyboard + screenshot_path = f"{self.screenshot_dir}/keyboard_custom_comparison.raw" + print(f"Capturing custom keyboard: {screenshot_path}") + capture_screenshot(screenshot_path, width=320, height=240) + + print("=== Comparison test PASSED ===") + + diff --git a/tests/test_graphical_custom_keyboard_basic.py b/tests/test_graphical_custom_keyboard_basic.py new file mode 100644 index 00000000..bad39108 --- /dev/null +++ b/tests/test_graphical_custom_keyboard_basic.py @@ -0,0 +1,255 @@ +""" +Functional tests for MposKeyboard. + +Tests keyboard creation, mode switching, text input, and API compatibility. + +Usage: + Desktop: ./tests/unittest.sh tests/test_custom_keyboard.py + Device: ./tests/unittest.sh tests/test_custom_keyboard.py --ondevice +""" + +import unittest +import lvgl as lv +from mpos.ui.keyboard import MposKeyboard +from mpos.ui.testing import simulate_click, wait_for_render + + +class TestMposKeyboard(unittest.TestCase): + """Test suite for MposKeyboard functionality.""" + + def setUp(self): + """Set up test fixtures before each test method.""" + # Create a test screen + self.screen = lv.obj() + self.screen.set_size(320, 240) + + # Create a textarea for testing + self.textarea = lv.textarea(self.screen) + self.textarea.set_size(280, 40) + self.textarea.align(lv.ALIGN.TOP_MID, 0, 10) + self.textarea.set_one_line(True) + + print(f"\n=== Test Setup Complete ===") + + def tearDown(self): + """Clean up after each test method.""" + # Clean up objects + lv.screen_load(lv.obj()) + print("=== Test Cleanup Complete ===\n") + + def test_keyboard_creation(self): + """Test that MposKeyboard can be created.""" + print("Testing keyboard creation...") + + keyboard = MposKeyboard(self.screen) + + # Verify keyboard exists + self.assertIsNotNone(keyboard) + + print("Keyboard created successfully") + + + def test_set_textarea(self): + """Test setting textarea association.""" + print("Testing set_textarea...") + + keyboard = MposKeyboard(self.screen) + keyboard.set_textarea(self.textarea) + + # Verify textarea is associated + associated_ta = keyboard.get_textarea() + self.assertEqual(associated_ta, self.textarea) + + print("Textarea association successful") + + def test_mode_switching(self): + """Test keyboard mode switching.""" + print("Testing mode switching...") + + keyboard = MposKeyboard(self.screen) + + # Test setting different modes + keyboard.set_mode(MposKeyboard.MODE_LOWERCASE) + keyboard.set_mode(MposKeyboard.MODE_UPPERCASE) + keyboard.set_mode(MposKeyboard.MODE_NUMBERS) + keyboard.set_mode(MposKeyboard.MODE_SPECIALS) + + print("Mode switching successful") + + def test_alignment(self): + """Test keyboard alignment.""" + print("Testing alignment...") + + keyboard = MposKeyboard(self.screen) + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + + print("Alignment successful") + + def test_height_settings(self): + """Test height configuration.""" + print("Testing height settings...") + + keyboard = MposKeyboard(self.screen) + keyboard.set_style_min_height(160, 0) + keyboard.set_style_height(160, 0) + + print("Height settings successful") + + def test_flags(self): + """Test object flags (show/hide).""" + print("Testing flags...") + + keyboard = MposKeyboard(self.screen) + + # Test hiding + keyboard.add_flag(lv.obj.FLAG.HIDDEN) + self.assertTrue(keyboard.has_flag(lv.obj.FLAG.HIDDEN)) + + # Test showing + keyboard.remove_flag(lv.obj.FLAG.HIDDEN) + self.assertFalse(keyboard.has_flag(lv.obj.FLAG.HIDDEN)) + + print("Flag operations successful") + + def test_event_callback(self): + """Test adding event callbacks.""" + print("Testing event callbacks...") + + keyboard = MposKeyboard(self.screen) + callback_called = [False] + + def test_callback(event): + callback_called[0] = True + + # Add callback + keyboard.add_event_cb(test_callback, lv.EVENT.READY, None) + + # Send READY event + keyboard.send_event(lv.EVENT.READY, None) + + # Verify callback was called + self.assertTrue(callback_called[0], "Callback was not called") + + print("Event callback successful") + + def test_api_compatibility(self): + """Test that MposKeyboard has same API as lv.keyboard.""" + print("Testing API compatibility...") + + keyboard = MposKeyboard(self.screen) + + # Check that all essential methods exist + essential_methods = [ + 'set_textarea', + 'get_textarea', + 'set_mode', + 'align', + 'add_flag', + 'remove_flag', + 'has_flag', + 'add_event_cb', + 'send_event', + ] + + for method_name in essential_methods: + self.assertTrue( + hasattr(keyboard, method_name), + f"MposKeyboard missing method: {method_name}" + ) + self.assertTrue( + callable(getattr(keyboard, method_name)), + f"MposKeyboard.{method_name} is not callable" + ) + + print("API compatibility verified") + + def test_simulate_click_on_button(self): + """Test clicking keyboard buttons using simulate_click().""" + print("Testing simulate_click() on keyboard buttons...") + + # Create keyboard and load screen + keyboard = MposKeyboard(self.screen) + keyboard.set_textarea(self.textarea) + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + lv.screen_load(self.screen) + wait_for_render(10) + + # Get initial text + initial_text = self.textarea.get_text() + print(f"Initial textarea text: '{initial_text}'") + + # Get keyboard area and click on it + # The keyboard is an lv.keyboard object (accessed via _keyboard or through __getattr__) + obj_area = lv.area_t() + keyboard.get_coords(obj_area) + + # Calculate a point to click - let's click in the lower part of keyboard + # which should be around where letters are + click_x = (obj_area.x1 + obj_area.x2) // 2 # Center horizontally + click_y = obj_area.y1 + (obj_area.y2 - obj_area.y1) // 3 # Upper third + + print(f"Keyboard area: ({obj_area.x1}, {obj_area.y1}) to ({obj_area.x2}, {obj_area.y2})") + print(f"Clicking keyboard at ({click_x}, {click_y})") + + # Click on the keyboard using simulate_click + simulate_click(click_x, click_y, press_duration_ms=100) + wait_for_render(5) + + final_text = self.textarea.get_text() + print(f"Final textarea text: '{final_text}'") + + # The important thing is that simulate_click worked without crashing + # The text might have changed if we hit a letter key + print("simulate_click() completed successfully") + + def test_click_vs_send_event_comparison(self): + """Compare simulate_click() vs send_event() for triggering button actions.""" + print("Testing simulate_click() vs send_event() comparison...") + + # Create keyboard and load screen + keyboard = MposKeyboard(self.screen) + keyboard.set_textarea(self.textarea) + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + lv.screen_load(self.screen) + wait_for_render(10) + + # Test 1: Use send_event() to trigger READY event + callback_from_send_event = [False] + + def callback_send_event(event): + callback_from_send_event[0] = True + print("send_event callback triggered") + + keyboard.add_event_cb(callback_send_event, lv.EVENT.READY, None) + keyboard.send_event(lv.EVENT.READY, None) + wait_for_render(3) + + self.assertTrue( + callback_from_send_event[0], + "send_event() should trigger callback" + ) + + # Test 2: Use simulate_click() to click on keyboard + # This demonstrates that simulate_click works with real UI interaction + initial_text = self.textarea.get_text() + + # Get keyboard area to click within it + obj_area = lv.area_t() + keyboard.get_coords(obj_area) + + # Click somewhere in the middle of the keyboard + click_x = (obj_area.x1 + obj_area.x2) // 2 + click_y = (obj_area.y1 + obj_area.y2) // 2 + + print(f"Clicking keyboard at ({click_x}, {click_y})") + simulate_click(click_x, click_y, press_duration_ms=100) + wait_for_render(5) + + # Verify click completed without crashing + final_text = self.textarea.get_text() + print(f"Text before click: '{initial_text}'") + print(f"Text after click: '{final_text}'") + + print("Both send_event() and simulate_click() work correctly") + + diff --git a/tests/test_graphical_imu_calibration.py b/tests/test_graphical_imu_calibration.py new file mode 100644 index 00000000..601905a9 --- /dev/null +++ b/tests/test_graphical_imu_calibration.py @@ -0,0 +1,187 @@ +""" +Graphical test for IMU calibration activities. + +Tests both CheckIMUCalibrationActivity and CalibrateIMUActivity +with mock data on desktop. + +Usage: + Desktop: ./tests/unittest.sh tests/test_graphical_imu_calibration.py + Device: ./tests/unittest.sh tests/test_graphical_imu_calibration.py --ondevice +""" + +import unittest +import lvgl as lv +import mpos.apps +import mpos.ui +import os +import sys +import time +from mpos.ui.testing import ( + wait_for_render, + capture_screenshot, + find_label_with_text, + verify_text_present, + print_screen_labels, + simulate_click, + get_widget_coords, + find_button_with_text, + click_label, + click_button, + find_text_on_screen +) + + +class TestIMUCalibration(unittest.TestCase): + """Test suite for IMU calibration activities.""" + + def setUp(self): + """Set up test fixtures.""" + # Get screenshot directory + if sys.platform == "esp32": + self.screenshot_dir = "tests/screenshots" + else: + self.screenshot_dir = "../tests/screenshots" # it runs from internal_filesystem/ + + # Ensure directory exists + try: + os.mkdir(self.screenshot_dir) + except OSError: + pass + + def tearDown(self): + """Clean up after test.""" + # Navigate back to launcher + try: + for _ in range(3): # May need multiple backs + mpos.ui.back_screen() + wait_for_render(5) + except: + pass + + def test_check_calibration_activity_loads(self): + """Test that CheckIMUCalibrationActivity loads and displays.""" + print("\n=== Testing CheckIMUCalibrationActivity ===") + + # Navigate: Launcher -> Settings -> Check IMU Calibration + result = mpos.apps.start_app("com.micropythonos.settings") + self.assertTrue(result, "Failed to start Settings app") + wait_for_render(15) + + # Initialize touch device with dummy click + simulate_click(10, 10) + wait_for_render(10) + + print("Clicking 'Check IMU Calibration' menu item...") + self.assertTrue(click_label("Check IMU Calibration"), "Could not find Check IMU Calibration menu item") + wait_for_render(iterations=20) + + # Verify key elements are present + screen = lv.screen_active() + print_screen_labels(screen) + self.assertTrue(verify_text_present(screen, "Quality:"), "Quality label not found") + self.assertTrue(verify_text_present(screen, "Accel."), "Accel. label not found") + self.assertTrue(verify_text_present(screen, "Gyro"), "Gyro label not found") + + # Capture screenshot + screenshot_path = f"{self.screenshot_dir}/check_imu_calibration.raw" + print(f"Capturing screenshot: {screenshot_path}") + capture_screenshot(screenshot_path) + + # Verify screenshot saved + stat = os.stat(screenshot_path) + self.assertTrue(stat[6] > 0, "Screenshot file is empty") + + print("=== CheckIMUCalibrationActivity test complete ===") + + def test_calibrate_activity_flow(self): + """Test CalibrateIMUActivity full calibration flow.""" + print("\n=== Testing CalibrateIMUActivity Flow ===") + + # Navigate: Launcher -> Settings -> Calibrate IMU + result = mpos.apps.start_app("com.micropythonos.settings") + self.assertTrue(result, "Failed to start Settings app") + wait_for_render(15) + + # Initialize touch device with dummy click + simulate_click(10, 10) + wait_for_render(10) + + print("Clicking 'Calibrate IMU' menu item...") + self.assertTrue(click_label("Calibrate IMU"), "Could not find Calibrate IMU item") + wait_for_render(iterations=20) + + # Verify activity loaded and shows instructions + screen = lv.screen_active() + print_screen_labels(screen) + self.assertTrue(verify_text_present(screen, "IMU Calibration"), + "CalibrateIMUActivity title not found") + self.assertTrue(verify_text_present(screen, "Place device on flat"), + "Instructions not shown") + + # Capture initial state + screenshot_path = f"{self.screenshot_dir}/calibrate_imu_01_initial.raw" + capture_screenshot(screenshot_path) + + # Click "Calibrate Now" button to start calibration + calibrate_btn = find_button_with_text(screen, "Calibrate Now") + self.assertIsNotNone(calibrate_btn, "Could not find 'Calibrate Now' button") + coords = get_widget_coords(calibrate_btn) + simulate_click(coords['center_x'], coords['center_y']) + wait_for_render(10) + + # Wait for calibration to complete (mock takes ~3 seconds) + time.sleep(4) + wait_for_render(40) + + # Verify calibration completed + screen = lv.screen_active() + print_screen_labels(screen) + self.assertTrue(verify_text_present(screen, "Calibration successful!"), + "Calibration completion message not found") + + # Verify offsets are shown + self.assertTrue(verify_text_present(screen, "Accel offsets") or + verify_text_present(screen, "offsets"), + "Calibration offsets not shown") + + # Capture completion state + screenshot_path = f"{self.screenshot_dir}/calibrate_imu_02_complete.raw" + capture_screenshot(screenshot_path) + + print("=== CalibrateIMUActivity flow test complete ===") + + def test_navigation_from_check_to_calibrate(self): + """Test navigation from Check to Calibrate activity via button.""" + print("\n=== Testing Check -> Calibrate Navigation ===") + + # Navigate to Check activity + result = mpos.apps.start_app("com.micropythonos.settings") + self.assertTrue(result) + wait_for_render(15) + + # Initialize touch device with dummy click + simulate_click(10, 10) + wait_for_render(10) + + print("Clicking 'Check IMU Calibration' menu item...") + self.assertTrue(click_label("Check IMU Calibration"), "Could not find Check IMU Calibration menu item") + wait_for_render(iterations=20) + + # Click "Calibrate" button to navigate to Calibrate activity + screen = lv.screen_active() + calibrate_btn = find_button_with_text(screen, "Calibrate") + self.assertIsNotNone(calibrate_btn, "Could not find 'Calibrate' button") + + # Use send_event instead of simulate_click (more reliable for navigation) + calibrate_btn.send_event(lv.EVENT.CLICKED, None) + wait_for_render(30) + + # Verify CalibrateIMUActivity loaded + screen = lv.screen_active() + print_screen_labels(screen) + self.assertTrue(verify_text_present(screen, "Calibrate Now"), + "Did not navigate to CalibrateIMUActivity") + self.assertTrue(verify_text_present(screen, "Place device on flat"), + "CalibrateIMUActivity instructions not shown") + + print("=== Navigation test complete ===") diff --git a/tests/test_graphical_imu_calibration_ui_bug.py b/tests/test_graphical_imu_calibration_ui_bug.py new file mode 100755 index 00000000..c44430e0 --- /dev/null +++ b/tests/test_graphical_imu_calibration_ui_bug.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +"""Automated UI test for IMU calibration bug. + +Tests the complete flow: +1. Open Settings → IMU → Check Calibration +2. Verify values are shown +3. Click "Calibrate" → Calibrate IMU +4. Click "Calibrate Now" +5. Go back to Check Calibration +6. BUG: Verify values are shown (not "--") +""" + +import sys +import time +import unittest + +# Import graphical test infrastructure +import lvgl as lv +from mpos.ui.testing import ( + wait_for_render, + simulate_click, + find_button_with_text, + find_label_with_text, + get_widget_coords, + print_screen_labels, + capture_screenshot, + click_label, + click_button, + find_text_on_screen +) + + +class TestIMUCalibrationUI(unittest.TestCase): + + def test_imu_calibration_bug_test(self): + print("=== IMU Calibration UI Bug Test ===\n") + + # Initialize the OS (boot.py and main.py) + print("Step 1: Initializing MicroPythonOS...") + import mpos.main + wait_for_render(iterations=30) + print("OS initialized\n") + + # Step 2: Open Settings app + print("Step 2: Opening Settings app...") + import mpos.apps + + # Start Settings app by name + mpos.apps.start_app("com.micropythonos.settings") + wait_for_render(iterations=30) + print("Settings app opened\n") + + # Initialize touch device with dummy click (required for simulate_click to work) + print("Initializing touch input device...") + simulate_click(10, 10) + wait_for_render(iterations=10) + + print("Current screen content:") + print_screen_labels(lv.screen_active()) + print() + + # Check if we're on the main Settings screen (should see multiple settings options) + # The Settings app shows a list with items like "Calibrate IMU", "Check IMU Calibration", "Theme Color", etc. + on_settings_main = (find_text_on_screen("Calibrate IMU") and + find_text_on_screen("Check IMU Calibration") and + find_text_on_screen("Theme Color")) + + # If we're on a sub-screen (like Calibrate IMU or Check IMU Calibration screens), + # we need to go back to Settings main. We can detect this by looking for screen titles. + if not on_settings_main: + print("Step 3: Not on Settings main screen, clicking Back or Cancel to return...") + self.assertTrue(click_button("Back") or click_button("Cancel"), "Could not click 'Back' or 'Cancel' button") + wait_for_render(iterations=20) + print("Current screen content:") + print_screen_labels(lv.screen_active()) + print() + + # Step 4: Click "Check IMU Calibration" (it's a clickable label/container, not a button) + print("Step 4: Clicking 'Check IMU Calibration' menu item...") + self.assertTrue(click_label("Check IMU Calibration"), "Could not find Check IMU Calibration menu item") + wait_for_render(iterations=40) + + print("Step 5: Checking BEFORE calibration...") + print("Current screen content:") + print_screen_labels(lv.screen_active()) + print() + + # Capture screenshot before + capture_screenshot("../tests/screenshots/check_imu_before_calib.raw") + + # Look for actual values (not "--") + has_values_before = False + widgets = [] + from mpos.ui.testing import get_all_widgets_with_text + for widget in get_all_widgets_with_text(lv.screen_active()): + text = widget.get_text() + # Look for patterns like "X: 0.00" or "Quality: Good" + if ":" in text and "--" not in text: + if any(char.isdigit() for char in text): + print(f"Found value: {text}") + has_values_before = True + + if not has_values_before: + print("WARNING: No values found before calibration (all showing '--')") + else: + print("GOOD: Values are showing before calibration") + print() + + # Step 6: Click "Calibrate" button to go to calibration screen + print("Step 6: Finding 'Calibrate' button...") + calibrate_btn = find_button_with_text(lv.screen_active(), "Calibrate") + self.assertIsNotNone(calibrate_btn, "Could not find 'Calibrate' button") + + print(f"Found Calibrate button: {calibrate_btn}") + print("Manually sending CLICKED event to button...") + # Instead of using simulate_click, manually send the event + calibrate_btn.send_event(lv.EVENT.CLICKED, None) + wait_for_render(iterations=20) + + # Wait for navigation to complete (activity transition can take some time) + time.sleep(0.5) + wait_for_render(iterations=50) + print("Calibrate IMU screen should be open now\n") + + print("Current screen content:") + print_screen_labels(lv.screen_active()) + print() + + # Step 7: Click "Calibrate Now" button + print("Step 7: Clicking 'Calibrate Now' button...") + self.assertTrue(click_button("Calibrate Now"), "Could not click 'Calibrate Now' button") + print("Calibration started...\n") + + # Wait for calibration to complete (~2 seconds + UI updates) + time.sleep(3) + wait_for_render(iterations=50) + + print("Current screen content after calibration:") + print_screen_labels(lv.screen_active()) + print() + + # Step 8: Click "Done" to go back + print("Step 8: Clicking 'Done' button...") + self.assertTrue(click_button("Done"), "Could not click 'Done' button") + print("Going back to Check Calibration\n") + + # Wait for screen to load + time.sleep(0.5) + wait_for_render(iterations=30) + + # Step 9: Check AFTER calibration (BUG: should show values, not "--") + print("Step 9: Checking AFTER calibration (testing for bug)...") + print("Current screen content:") + print_screen_labels(lv.screen_active()) + print() + + # Capture screenshot after + capture_screenshot("../tests/screenshots/check_imu_after_calib.raw") + + # Look for actual values (not "--") + has_values_after = False + for widget in get_all_widgets_with_text(lv.screen_active()): + text = widget.get_text() + # Look for patterns like "X: 0.00" or "Quality: Good" + if ":" in text and "--" not in text: + if any(char.isdigit() for char in text): + print(f"Found value: {text}") + has_values_after = True + + print() + print("="*60) + print("TEST RESULTS:") + print(f" Values shown BEFORE calibration: {has_values_before}") + print(f" Values shown AFTER calibration: {has_values_after}") + + if has_values_before and not has_values_after: + print("\n ❌ BUG REPRODUCED: Values disappeared after calibration!") + print(" Expected: Values should still be shown") + print(" Actual: All showing '--'") + #return False + elif has_values_after: + print("\n ✅ PASS: Values are showing correctly after calibration") + #return True + else: + print("\n ⚠️ WARNING: No values shown before or after (might be desktop mock issue)") + #return True + + diff --git a/tests/test_graphical_keyboard_animation.py b/tests/test_graphical_keyboard_animation.py new file mode 100644 index 00000000..f1e0c54b --- /dev/null +++ b/tests/test_graphical_keyboard_animation.py @@ -0,0 +1,191 @@ +""" +Test MposKeyboard animation support (show/hide with mpos.ui.anim). + +This test reproduces the bug where MposKeyboard is missing methods +required by mpos.ui.anim.smooth_show() and smooth_hide(). + +Usage: + Desktop: ./tests/unittest.sh tests/test_graphical_keyboard_animation.py + Device: ./tests/unittest.sh tests/test_graphical_keyboard_animation.py --ondevice +""" + +import unittest +import lvgl as lv +import time +import mpos.ui.anim +from mpos.ui.keyboard import MposKeyboard +from mpos.ui.testing import wait_for_render + +class TestKeyboardAnimation(unittest.TestCase): + """Test MposKeyboard compatibility with animation system.""" + + def setUp(self): + """Set up test fixtures.""" + # Create a test screen + self.screen = lv.obj() + self.screen.set_size(320, 240) + lv.screen_load(self.screen) + + # Create textarea + self.textarea = lv.textarea(self.screen) + self.textarea.set_size(280, 40) + self.textarea.align(lv.ALIGN.TOP_MID, 0, 10) + self.textarea.set_one_line(True) + + print("\n=== Animation Test Setup Complete ===") + + def tearDown(self): + """Clean up after test.""" + lv.screen_load(lv.obj()) + print("=== Test Cleanup Complete ===\n") + + def test_keyboard_has_set_style_opa(self): + """ + Test that MposKeyboard has set_style_opa method. + + This method is required by mpos.ui.anim for fade animations. + """ + print("Testing that MposKeyboard has set_style_opa...") + + keyboard = MposKeyboard(self.screen) + keyboard.set_textarea(self.textarea) + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + keyboard.add_flag(lv.obj.FLAG.HIDDEN) + + # Verify method exists + self.assertTrue( + hasattr(keyboard, 'set_style_opa'), + "MposKeyboard missing set_style_opa method" + ) + self.assertTrue( + callable(getattr(keyboard, 'set_style_opa')), + "MposKeyboard.set_style_opa is not callable" + ) + + # Try calling it (should not raise AttributeError) + try: + keyboard.set_style_opa(128, 0) + print("set_style_opa called successfully") + except AttributeError as e: + self.fail(f"set_style_opa raised AttributeError: {e}") + + print("=== set_style_opa test PASSED ===") + + def test_keyboard_smooth_show(self): + """ + Test that MposKeyboard can be shown with smooth_show animation. + + This reproduces the actual user interaction in QuasiNametag. + """ + print("Testing smooth_show animation...") + + keyboard = MposKeyboard(self.screen) + keyboard.set_textarea(self.textarea) + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + keyboard.add_flag(lv.obj.FLAG.HIDDEN) + + # This should work without raising AttributeError + try: + mpos.ui.anim.smooth_show(keyboard) + wait_for_render(100) + print("smooth_show called successfully") + except AttributeError as e: + self.fail(f"smooth_show raised AttributeError: {e}\n" + "This is the bug - MposKeyboard missing animation methods") + + # Verify keyboard is no longer hidden + self.assertFalse( + keyboard.has_flag(lv.obj.FLAG.HIDDEN), + "Keyboard should not be hidden after smooth_show" + ) + + print("=== smooth_show test PASSED ===") + + def test_keyboard_smooth_hide(self): + """ + Test that MposKeyboard can be hidden with smooth_hide animation. + + This reproduces the hide behavior in QuasiNametag. + """ + print("Testing smooth_hide animation...") + + keyboard = MposKeyboard(self.screen) + keyboard.set_textarea(self.textarea) + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + # Start visible + keyboard.remove_flag(lv.obj.FLAG.HIDDEN) + + # This should work without raising AttributeError + try: + mpos.ui.anim.smooth_hide(keyboard) + print("smooth_hide called successfully") + except AttributeError as e: + self.fail(f"smooth_hide raised AttributeError: {e}\n" + "This is the bug - MposKeyboard missing animation methods") + + print("=== smooth_hide test PASSED ===") + + def test_keyboard_show_hide_cycle(self): + """ + Test full show/hide animation cycle. + + This mimics the actual user flow: + 1. Click textarea -> show keyboard + 2. Press Enter/Cancel -> hide keyboard + """ + print("Testing full show/hide cycle...") + + keyboard = MposKeyboard(self.screen) + keyboard.set_textarea(self.textarea) + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + keyboard.add_flag(lv.obj.FLAG.HIDDEN) + + # Initial state: hidden + self.assertTrue(keyboard.has_flag(lv.obj.FLAG.HIDDEN)) + + # Show keyboard (simulates textarea click) + try: + mpos.ui.anim.smooth_show(keyboard) + wait_for_render(100) + except AttributeError as e: + self.fail(f"Failed during smooth_show: {e}") + + # Should be visible now + self.assertFalse(keyboard.has_flag(lv.obj.FLAG.HIDDEN)) + + # Hide keyboard (simulates pressing Enter) + try: + mpos.ui.anim.smooth_hide(keyboard) + wait_for_render(100) + except AttributeError as e: + self.fail(f"Failed during smooth_hide: {e}") + + print("=== Full cycle test PASSED ===") + + def test_keyboard_has_get_y_and_set_y(self): + """ + Test that MposKeyboard has get_y and set_y methods. + + These are required for slide animations (though not currently used). + """ + print("Testing get_y and set_y methods...") + + keyboard = MposKeyboard(self.screen) + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + + # Verify methods exist + self.assertTrue(hasattr(keyboard, 'get_y'), "Missing get_y method") + self.assertTrue(hasattr(keyboard, 'set_y'), "Missing set_y method") + + # Try using them + try: + y = keyboard.get_y() + keyboard.set_y(y + 10) + new_y = keyboard.get_y() + print(f"Position test: {y} -> {new_y}") + except AttributeError as e: + self.fail(f"Position methods raised AttributeError: {e}") + + print("=== Position methods test PASSED ===") + + diff --git a/tests/test_graphical_keyboard_crash_reproduction.py b/tests/test_graphical_keyboard_crash_reproduction.py new file mode 100644 index 00000000..0710735f --- /dev/null +++ b/tests/test_graphical_keyboard_crash_reproduction.py @@ -0,0 +1,147 @@ +""" +Test to reproduce the lv_strcmp crash during keyboard mode switching. + +The crash happens in buttonmatrix drawing code when map_p[txt_i] is NULL. + +Usage: + Desktop: ./tests/unittest.sh tests/test_graphical_keyboard_crash_reproduction.py +""" + +import unittest +import lvgl as lv +from mpos.ui.keyboard import MposKeyboard +from mpos.ui.testing import wait_for_render + + +class TestKeyboardCrash(unittest.TestCase): + """Test to reproduce keyboard crashes.""" + + def setUp(self): + """Set up test fixtures.""" + self.screen = lv.obj() + self.screen.set_size(320, 240) + lv.screen_load(self.screen) + wait_for_render(5) + + def tearDown(self): + """Clean up.""" + lv.screen_load(lv.obj()) + wait_for_render(5) + + def test_rapid_mode_switching(self): + """ + Rapidly switch between modes to trigger the crash. + + The crash occurs when btnm->map_p[txt_i] is NULL during drawing. + """ + print("\n=== Testing rapid mode switching ===") + + textarea = lv.textarea(self.screen) + textarea.set_size(280, 40) + textarea.align(lv.ALIGN.TOP_MID, 0, 10) + textarea.set_one_line(True) + wait_for_render(5) + + keyboard = MposKeyboard(self.screen) + keyboard.set_textarea(textarea) + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + wait_for_render(10) + + print("Rapidly switching modes...") + modes = [ + MposKeyboard.MODE_LOWERCASE, + MposKeyboard.MODE_NUMBERS, + MposKeyboard.MODE_LOWERCASE, + MposKeyboard.MODE_UPPERCASE, + MposKeyboard.MODE_LOWERCASE, + MposKeyboard.MODE_NUMBERS, + MposKeyboard.MODE_SPECIALS, + MposKeyboard.MODE_NUMBERS, + MposKeyboard.MODE_LOWERCASE, + ] + + for i, mode in enumerate(modes): + print(f" Switch {i+1}: mode {mode}") + keyboard.set_mode(mode) + # Force rendering - this is where the crash happens + wait_for_render(2) + + print("SUCCESS: No crash during rapid switching") + + def test_mode_switching_with_standard_modes(self): + """ + Test switching using standard LVGL modes (TEXT_LOWER, etc). + + This tests if LVGL internally switching modes causes the crash. + """ + print("\n=== Testing with standard LVGL modes ===") + + textarea = lv.textarea(self.screen) + textarea.set_size(280, 40) + textarea.align(lv.ALIGN.TOP_MID, 0, 10) + textarea.set_one_line(True) + wait_for_render(5) + + keyboard = MposKeyboard(self.screen) + keyboard.set_textarea(textarea) + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + wait_for_render(10) + + print("Switching using standard LVGL modes...") + + # Try standard modes + print(" Switching to TEXT_LOWER") + keyboard._keyboard.set_mode(lv.keyboard.MODE.TEXT_LOWER) + wait_for_render(5) + + print(" Switching to NUMBER") + keyboard._keyboard.set_mode(lv.keyboard.MODE.NUMBER) + wait_for_render(5) + + print(" Switching back to TEXT_LOWER") + keyboard._keyboard.set_mode(lv.keyboard.MODE.TEXT_LOWER) + wait_for_render(5) + + print("SUCCESS: No crash with standard modes") + + def test_multiple_keyboards(self): + """ + Test creating multiple keyboards to see if that causes issues. + """ + print("\n=== Testing multiple keyboard creation ===") + + textarea = lv.textarea(self.screen) + textarea.set_size(280, 40) + textarea.align(lv.ALIGN.TOP_MID, 0, 10) + textarea.set_one_line(True) + wait_for_render(5) + + # Create first keyboard + print("Creating keyboard 1...") + keyboard1 = MposKeyboard(self.screen) + keyboard1.set_textarea(textarea) + keyboard1.align(lv.ALIGN.BOTTOM_MID, 0, 0) + wait_for_render(10) + + print("Switching modes on keyboard 1...") + keyboard1.set_mode(MposKeyboard.MODE_NUMBERS) + wait_for_render(5) + + print("Deleting keyboard 1...") + keyboard1._keyboard.delete() + wait_for_render(5) + + # Create second keyboard + print("Creating keyboard 2...") + keyboard2 = MposKeyboard(self.screen) + keyboard2.set_textarea(textarea) + keyboard2.align(lv.ALIGN.BOTTOM_MID, 0, 0) + wait_for_render(10) + + print("Switching modes on keyboard 2...") + keyboard2.set_mode(MposKeyboard.MODE_UPPERCASE) + wait_for_render(5) + + print("SUCCESS: Multiple keyboards work") + + diff --git a/tests/test_graphical_keyboard_default_vs_custom.py b/tests/test_graphical_keyboard_default_vs_custom.py new file mode 100644 index 00000000..5fba3b9b --- /dev/null +++ b/tests/test_graphical_keyboard_default_vs_custom.py @@ -0,0 +1,187 @@ +""" +Test comparing default LVGL keyboard with custom MposKeyboard. + +This test helps identify the differences between the two keyboard types +so we can properly detect when the bug occurs (switching to default instead of custom). + +Usage: + Desktop: ./tests/unittest.sh tests/test_graphical_keyboard_default_vs_custom.py +""" + +import unittest +import lvgl as lv +from mpos.ui.keyboard import MposKeyboard +from mpos.ui.testing import wait_for_render + + +class TestDefaultVsCustomKeyboard(unittest.TestCase): + """Compare default LVGL keyboard with custom MposKeyboard.""" + + def setUp(self): + """Set up test fixtures.""" + self.screen = lv.obj() + self.screen.set_size(320, 240) + lv.screen_load(self.screen) + wait_for_render(5) + + def tearDown(self): + """Clean up.""" + lv.screen_load(lv.obj()) + wait_for_render(5) + + def test_default_lvgl_keyboard_layout(self): + """ + Examine the default LVGL keyboard to understand its layout. + + This helps us know what we're looking for when detecting the bug. + """ + print("\n=== Examining DEFAULT LVGL keyboard ===") + + # Create textarea + textarea = lv.textarea(self.screen) + textarea.set_size(280, 40) + textarea.align(lv.ALIGN.TOP_MID, 0, 10) + textarea.set_one_line(True) + wait_for_render(5) + + # Create DEFAULT LVGL keyboard + keyboard = lv.keyboard(self.screen) + keyboard.set_textarea(textarea) + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + wait_for_render(10) + + print("\nDefault LVGL keyboard buttons (first 40):") + found_special_labels = {} + for i in range(40): + try: + text = keyboard.get_button_text(i) + if text and text not in ["\n", ""]: + print(f" Index {i}: '{text}'") + # Track special labels + if text in ["Abc", "Abc", "1#", "?123", "#+=", lv.SYMBOL.UP, lv.SYMBOL.DOWN]: + found_special_labels[text] = i + except: + pass + + print("\n--- DEFAULT LVGL keyboard has these special labels ---") + for label, idx in found_special_labels.items(): + print(f" '{label}' at index {idx}") + + print("\n--- Characteristics of DEFAULT LVGL keyboard ---") + if "Abc" in found_special_labels: + print(" ✓ Has 'Abc' (uppercase label)") + if "1#" in found_special_labels: + print(" ✓ Has '1#' (numbers label)") + if "#+" in found_special_labels or "#+=" in found_special_labels: + print(" ✓ Has '#+=/-' type labels") + + def test_custom_mpos_keyboard_layout(self): + """ + Examine our custom MposKeyboard to understand its layout. + + This shows what the CORRECT layout should look like. + """ + print("\n=== Examining CUSTOM MposKeyboard ===") + + # Create textarea + textarea = lv.textarea(self.screen) + textarea.set_size(280, 40) + textarea.align(lv.ALIGN.TOP_MID, 0, 10) + textarea.set_one_line(True) + wait_for_render(5) + + # Create CUSTOM MposKeyboard + keyboard = MposKeyboard(self.screen) + keyboard.set_textarea(textarea) + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + wait_for_render(10) + + print("\nCustom MposKeyboard buttons (first 40):") + found_special_labels = {} + for i in range(40): + try: + text = keyboard.get_button_text(i) + if text and text not in ["\n", ""]: + print(f" Index {i}: '{text}'") + # Track special labels + if text in ["Abc", "Abc", "1#", "?123", "=\\<", lv.SYMBOL.UP, lv.SYMBOL.DOWN]: + found_special_labels[text] = i + except: + pass + + print("\n--- CUSTOM MposKeyboard has these special labels ---") + for label, idx in found_special_labels.items(): + print(f" '{label}' at index {idx}") + + print("\n--- Characteristics of CUSTOM MposKeyboard ---") + if "?123" in found_special_labels: + print(" ✓ Has '?123' (numbers label)") + if "=\\<" in found_special_labels: + print(" ✓ Has '=\\<' (specials label)") + if lv.SYMBOL.UP in found_special_labels: + print(" ✓ Has UP symbol (shift to uppercase)") + + def test_mode_switching_bug_reproduction(self): + """ + Try to reproduce the bug: numbers -> Abc -> wrong layout. + """ + print("\n=== Attempting to reproduce the bug ===") + + textarea = lv.textarea(self.screen) + textarea.set_size(280, 40) + textarea.align(lv.ALIGN.TOP_MID, 0, 10) + textarea.set_one_line(True) + wait_for_render(5) + + keyboard = MposKeyboard(self.screen) + keyboard.set_textarea(textarea) + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + wait_for_render(10) + + # Step 1: Start in lowercase + print("\nStep 1: Initial lowercase mode") + labels_step1 = self._get_special_labels(keyboard) + print(f" Labels: {list(labels_step1.keys())}") + self.assertIn("?123", labels_step1, "Should start with custom lowercase (?123)") + + # Step 2: Switch to numbers + print("\nStep 2: Switch to numbers mode") + keyboard.set_mode(MposKeyboard.MODE_NUMBERS) + wait_for_render(5) + labels_step2 = self._get_special_labels(keyboard) + print(f" Labels: {list(labels_step2.keys())}") + self.assertIn("Abc", labels_step2, "Should have 'Abc' in numbers mode") + + # Step 3: Switch back to lowercase (this is where bug might happen) + print("\nStep 3: Switch back to lowercase via set_mode()") + keyboard.set_mode(MposKeyboard.MODE_LOWERCASE) + wait_for_render(5) + labels_step3 = self._get_special_labels(keyboard) + print(f" Labels: {list(labels_step3.keys())}") + + # Check for bug + if "Abc" in labels_step3 or "1#" in labels_step3: + print(" ❌ BUG DETECTED: Got default LVGL keyboard!") + print(f" Found these labels: {list(labels_step3.keys())}") + self.fail("BUG: Switched to default LVGL keyboard instead of custom") + + if "?123" not in labels_step3: + print(" ❌ BUG DETECTED: Missing '?123' label!") + print(f" Found these labels: {list(labels_step3.keys())}") + self.fail("BUG: Missing '?123' label from custom keyboard") + + print(" ✓ Correct: Has custom layout with '?123'") + + def _get_special_labels(self, keyboard): + """Helper to get special labels from keyboard.""" + labels = {} + for i in range(100): + try: + text = keyboard.get_button_text(i) + if text in ["Abc", "Abc", "1#", "?123", "=\\<", "#+=", lv.SYMBOL.UP, lv.SYMBOL.DOWN]: + labels[text] = i + except: + pass + return labels + + diff --git a/tests/test_graphical_keyboard_layout_switching.py b/tests/test_graphical_keyboard_layout_switching.py new file mode 100644 index 00000000..83c2bcec --- /dev/null +++ b/tests/test_graphical_keyboard_layout_switching.py @@ -0,0 +1,286 @@ +""" +Test for keyboard layout switching bug. + +This test reproduces the issue where clicking the "Abc" button in numbers mode +goes to the wrong (default LVGL) keyboard layout instead of our custom lowercase layout. + +Usage: + Desktop: ./tests/unittest.sh tests/test_graphical_keyboard_layout_switching.py + Device: ./tests/unittest.sh tests/test_graphical_keyboard_layout_switching.py --ondevice +""" + +import unittest +import lvgl as lv +from mpos.ui.keyboard import MposKeyboard +from mpos.ui.testing import wait_for_render + + +class TestKeyboardLayoutSwitching(unittest.TestCase): + """Test keyboard layout switching between different modes.""" + + def setUp(self): + """Set up test fixtures.""" + self.screen = lv.obj() + self.screen.set_size(320, 240) + + # Create textarea + self.textarea = lv.textarea(self.screen) + self.textarea.set_size(280, 40) + self.textarea.align(lv.ALIGN.TOP_MID, 0, 10) + self.textarea.set_one_line(True) + + # Load screen + lv.screen_load(self.screen) + wait_for_render(5) + + def tearDown(self): + """Clean up.""" + lv.screen_load(lv.obj()) + wait_for_render(5) + + def test_abc_button_from_numbers_mode(self): + """ + Test that clicking "Abc" button in numbers mode goes to lowercase mode. + + BUG: Currently goes to the wrong (default LVGL) keyboard layout + instead of our custom lowercase layout. + + Expected behavior: + 1. Start in lowercase mode (has "q", "w", "e", etc.) + 2. Switch to numbers mode (has "1", "2", "3", etc. and "Abc" button) + 3. Click "Abc" button + 4. Should return to lowercase mode (has "q", "w", "e", etc.) + """ + print("\n=== Testing 'Abc' button from numbers mode ===") + + # Create keyboard + keyboard = MposKeyboard(self.screen) + keyboard.set_textarea(self.textarea) + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + wait_for_render(10) + + # Verify we start in lowercase mode + print("Step 1: Verify initial lowercase mode") + # Find 'q' button (should be in lowercase layout) + q_button_index = None + for i in range(100): + try: + text = keyboard.get_button_text(i) + if text == "q": + q_button_index = i + print(f" Found 'q' at index {i} - GOOD (lowercase mode)") + break + except: + pass + + self.assertIsNotNone(q_button_index, "Should find 'q' in lowercase mode") + + # Switch to numbers mode + print("\nStep 2: Switch to numbers mode") + keyboard.set_mode(MposKeyboard.MODE_NUMBERS) + wait_for_render(5) + + # Verify we're in numbers mode by finding '1' button + one_button_index = None + for i in range(100): + try: + text = keyboard.get_button_text(i) + if text == "1": + one_button_index = i + print(f" Found '1' at index {i} - GOOD (numbers mode)") + break + except: + pass + + self.assertIsNotNone(one_button_index, "Should find '1' in numbers mode") + + # Find the 'Abc' button in numbers mode + print("\nStep 3: Find 'Abc' button in numbers mode") + abc_button_index = None + for i in range(100): + try: + text = keyboard.get_button_text(i) + if text == "Abc": + abc_button_index = i + print(f" Found 'Abc' at index {i}") + break + except: + pass + + self.assertIsNotNone(abc_button_index, "Should find 'Abc' button in numbers mode") + + # Switch back to lowercase by calling set_mode (simulating clicking 'Abc') + print("\nStep 4: Click 'Abc' to switch back to lowercase") + keyboard.set_mode(MposKeyboard.MODE_LOWERCASE) + wait_for_render(5) + + # Verify we're back in lowercase mode using DISTINGUISHING LABELS + # When in LOWERCASE mode: + # - Our custom keyboard has "?123" (to switch to numbers) + # - Default LVGL keyboard has "1#" (to switch to numbers) and "ABC" (to switch to uppercase) + # + # Note: "Abc" only appears in NUMBERS/SPECIALS modes to switch back to lowercase + print("\nStep 5: Verify we're in OUR custom lowercase mode (not default LVGL)") + + found_labels = {} + for i in range(100): + try: + text = keyboard.get_button_text(i) + # Check for all possible distinguishing labels + if text in ["Abc", "ABC", "?123", "1#", lv.SYMBOL.UP, lv.SYMBOL.DOWN]: + found_labels[text] = i + print(f" Found label '{text}' at index {i}") + except: + pass + + # Check for WRONG labels (default LVGL keyboard in lowercase mode) + if "ABC" in found_labels: + print(f" ERROR: Found 'ABC' - this is the DEFAULT LVGL keyboard!") + self.fail("BUG DETECTED: Got default LVGL lowercase keyboard with 'ABC' label instead of custom keyboard") + + if "1#" in found_labels: + print(f" ERROR: Found '1#' - this is the DEFAULT LVGL keyboard!") + self.fail("BUG DETECTED: Got default LVGL lowercase keyboard with '1#' label instead of custom keyboard with '?123'") + + # Check for CORRECT labels (our custom lowercase keyboard) + if "?123" not in found_labels: + print(f" ERROR: Did not find '?123' - should be in custom lowercase layout!") + print(f" Found labels: {list(found_labels.keys())}") + self.fail("BUG: Should find '?123' label in custom lowercase mode, but got: " + str(list(found_labels.keys()))) + + # Also verify we have the UP symbol (our custom keyboard) not ABC (default) + if lv.SYMBOL.UP not in found_labels: + print(f" ERROR: Did not find UP symbol - should be in custom lowercase layout!") + print(f" Found labels: {list(found_labels.keys())}") + self.fail("BUG: Should find UP symbol in custom lowercase mode") + + print(f" Found '?123' at index {found_labels['?123']} - GOOD (custom keyboard)") + print(f" Found UP symbol at index {found_labels[lv.SYMBOL.UP]} - GOOD (custom keyboard)") + print("\nSUCCESS: 'Abc' button correctly returns to custom lowercase layout!") + + def test_layout_switching_cycle(self): + """ + Test full cycle of layout switching: lowercase -> numbers -> specials -> lowercase. + + This ensures all mode switches preserve our custom layouts. + """ + print("\n=== Testing full layout switching cycle ===") + + keyboard = MposKeyboard(self.screen) + keyboard.set_textarea(self.textarea) + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + wait_for_render(10) + + # Define what we expect to find in each mode + mode_tests = [ + (MposKeyboard.MODE_LOWERCASE, "q", "lowercase"), + (MposKeyboard.MODE_NUMBERS, "1", "numbers"), + (MposKeyboard.MODE_SPECIALS, "~", "specials"), + (MposKeyboard.MODE_LOWERCASE, "q", "lowercase (again)"), + ] + + for mode, expected_key, mode_name in mode_tests: + print(f"\nSwitching to {mode_name}...") + keyboard.set_mode(mode) + wait_for_render(5) + + # Find the expected key + found = False + for i in range(100): + try: + text = keyboard.get_button_text(i) + if text == expected_key: + print(f" Found '{expected_key}' at index {i} - GOOD") + found = True + break + except: + pass + + self.assertTrue(found, + f"Should find '{expected_key}' in {mode_name} mode") + + print("\nSUCCESS: All layout switches preserve custom layouts!") + + def test_event_handler_switches_layout(self): + """ + Test that the event handler properly switches layouts. + + This simulates what happens when the user actually CLICKS the "Abc" button, + going through the _handle_events method instead of calling set_mode() directly. + """ + print("\n=== Testing event handler layout switching ===") + + keyboard = MposKeyboard(self.screen) + keyboard.set_textarea(self.textarea) + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + wait_for_render(10) + + # Switch to numbers mode first + print("Step 1: Switch to numbers mode") + keyboard.set_mode(MposKeyboard.MODE_NUMBERS) + wait_for_render(5) + + # Verify we're in numbers mode + one_found = False + for i in range(100): + try: + if keyboard.get_button_text(i) == "1": + one_found = True + print(f" Found '1' - in numbers mode") + break + except: + pass + self.assertTrue(one_found, "Should be in numbers mode") + + # Now simulate what the event handler does when "Qbc" is clicked + # The event handler checks: elif text == lv.SYMBOL.DOWN or text == self.LABEL_LETTERS: + # Then it calls: self._keyboard.set_map() and self._keyboard.set_mode() + print("\nStep 2: Simulate clicking 'Abc' (via event handler logic)") + + # This is what the event handler does: + keyboard._keyboard.set_map( + MposKeyboard.MODE_LOWERCASE, + keyboard._lowercase_map, + keyboard._lowercase_ctrl + ) + keyboard._keyboard.set_mode(MposKeyboard.MODE_LOWERCASE) + wait_for_render(5) + + # Verify we're back in lowercase mode with OUR custom layout + # When in LOWERCASE mode: + # - Our custom keyboard has "?123" (to switch to numbers) + # - Default LVGL keyboard has "1#" (to switch to numbers) and "ABC" (to switch to uppercase) + print("\nStep 3: Verify we have custom lowercase layout (not default LVGL)") + + found_labels = {} + for i in range(100): + try: + text = keyboard.get_button_text(i) + if text in ["Abc", "ABC", "?123", "1#", lv.SYMBOL.UP]: + found_labels[text] = i + print(f" Found label '{text}' at index {i}") + except: + pass + + # Check for WRONG labels (default LVGL keyboard) + if "ABC" in found_labels: + print(f" ERROR: Found 'ABC' - this is the DEFAULT LVGL keyboard!") + print(" Found these labels:", list(found_labels.keys())) + self.fail("BUG DETECTED: Event handler caused switch to default LVGL keyboard with 'ABC' label") + + if "1#" in found_labels: + print(f" ERROR: Found '1#' - this is the DEFAULT LVGL keyboard!") + print(" Found these labels:", list(found_labels.keys())) + self.fail("BUG DETECTED: Event handler caused switch to default LVGL keyboard with '1#' label") + + # Check for CORRECT labels (our custom keyboard in lowercase mode) + self.assertIn("?123", found_labels, + "Should find '?123' label in custom lowercase mode (not '1#' from default)") + self.assertIn(lv.SYMBOL.UP, found_labels, + "Should find UP symbol in custom lowercase mode") + + print(f" Found '?123' at index {found_labels['?123']} - GOOD") + print(f" Found UP symbol at index {found_labels[lv.SYMBOL.UP]} - GOOD") + print("\nSUCCESS: Event handler preserves custom layout!") + + diff --git a/tests/test_graphical_keyboard_method_forwarding.py b/tests/test_graphical_keyboard_method_forwarding.py new file mode 100644 index 00000000..e96b3d63 --- /dev/null +++ b/tests/test_graphical_keyboard_method_forwarding.py @@ -0,0 +1,143 @@ +""" +Test that MposKeyboard forwards all methods to underlying lv.keyboard. + +This demonstrates the __getattr__ magic method works correctly and that +MposKeyboard supports any LVGL keyboard method without manual wrapping. + +Usage: + Desktop: ./tests/unittest.sh tests/test_keyboard_method_forwarding.py +""" + +import unittest +import lvgl as lv +from mpos.ui.keyboard import MposKeyboard + + +class TestMethodForwarding(unittest.TestCase): + """Test that arbitrary LVGL methods are forwarded correctly.""" + + def setUp(self): + """Set up test fixtures.""" + self.screen = lv.obj() + self.screen.set_size(320, 240) + lv.screen_load(self.screen) + + def tearDown(self): + """Clean up.""" + lv.screen_load(lv.obj()) + + def test_common_methods_work(self): + """Test commonly used LVGL methods work via __getattr__.""" + print("\nTesting common LVGL methods...") + + keyboard = MposKeyboard(self.screen) + + # These should all work without explicit wrapper methods: + methods_to_test = [ + ('set_style_opa', (128, 0)), + ('get_x', ()), + ('get_y', ()), + ('get_width', ()), + ('get_height', ()), + ('add_flag', (lv.obj.FLAG.HIDDEN,)), + ('has_flag', (lv.obj.FLAG.HIDDEN,)), + ('remove_flag', (lv.obj.FLAG.HIDDEN,)), + ] + + for method_name, args in methods_to_test: + try: + method = getattr(keyboard, method_name) + result = method(*args) + print(f" ✓ {method_name}{args} -> {result}") + except Exception as e: + self.fail(f"{method_name} failed: {e}") + + print("All common methods work!") + + def test_style_methods_work(self): + """Test various style methods work.""" + print("\nTesting style methods...") + + keyboard = MposKeyboard(self.screen) + + # All these style methods should work: + keyboard.set_style_min_height(100, 0) + keyboard.set_style_max_height(200, 0) + keyboard.set_style_height(150, 0) + keyboard.set_style_opa(255, 0) + + print("All style methods work!") + + def test_position_methods_work(self): + """Test position methods work.""" + print("\nTesting position methods...") + + keyboard = MposKeyboard(self.screen) + + # Position methods: + x = keyboard.get_x() + y = keyboard.get_y() + print(f" Initial position: ({x}, {y})") + + keyboard.set_x(50) + keyboard.set_y(100) + keyboard.set_pos(25, 75) + + new_x = keyboard.get_x() + new_y = keyboard.get_y() + print(f" After set_pos(25, 75): ({new_x}, {new_y})") + + print("All position methods work!") + + def test_undocumented_methods_still_work(self): + """ + Test that even undocumented/obscure LVGL methods work. + + The beauty of __getattr__ is that ANY lv.keyboard method works, + even ones we didn't explicitly think about. + """ + print("\nTesting that arbitrary LVGL methods work...") + + keyboard = MposKeyboard(self.screen) + + # Try some less common methods: + try: + # Get the parent object + parent = keyboard.get_parent() + print(f" ✓ get_parent() -> {parent}") + + # Get style properties + border_width = keyboard.get_style_border_width(lv.PART.MAIN) + print(f" ✓ get_style_border_width() -> {border_width}") + + # These methods exist on lv.obj and should work: + keyboard.set_style_border_width(2, 0) + print(f" ✓ set_style_border_width(2, 0)") + + except Exception as e: + self.fail(f"Arbitrary LVGL method failed: {e}") + + print("Even undocumented methods work via __getattr__!") + + def test_method_forwarding_preserves_behavior(self): + """ + Test that forwarded methods behave identically to native calls. + """ + print("\nTesting that forwarding preserves behavior...") + + keyboard = MposKeyboard(self.screen) + textarea = lv.textarea(self.screen) + + # Set textarea through MposKeyboard + keyboard.set_textarea(textarea) + + # Get it back + returned_ta = keyboard.get_textarea() + + # Should be the same object + self.assertEqual(returned_ta, textarea, + "Forwarded methods should preserve object identity") + + print("Method forwarding preserves behavior correctly!") + + diff --git a/tests/test_graphical_keyboard_mode_switch.py b/tests/test_graphical_keyboard_mode_switch.py new file mode 100644 index 00000000..85967d1d --- /dev/null +++ b/tests/test_graphical_keyboard_mode_switch.py @@ -0,0 +1,155 @@ +""" +Test for MposKeyboard mode switching crash. + +This test reproduces the crash that occurs when clicking the UP arrow +to switch to uppercase mode in MposKeyboard. + +Usage: + Desktop: ./tests/unittest.sh tests/test_graphical_keyboard_mode_switch.py + Device: ./tests/unittest.sh tests/test_graphical_keyboard_mode_switch.py --ondevice +""" + +import unittest +import lvgl as lv +from mpos.ui.keyboard import MposKeyboard +from mpos.ui.testing import wait_for_render + + +class TestKeyboardModeSwitch(unittest.TestCase): + """Test keyboard mode switching doesn't crash.""" + + def setUp(self): + """Set up test fixtures.""" + self.screen = lv.obj() + self.screen.set_size(320, 240) + + # Create textarea + self.textarea = lv.textarea(self.screen) + self.textarea.set_size(280, 40) + self.textarea.align(lv.ALIGN.TOP_MID, 0, 10) + self.textarea.set_one_line(True) + + # Load screen + lv.screen_load(self.screen) + wait_for_render(5) + + def tearDown(self): + """Clean up.""" + lv.screen_load(lv.obj()) + wait_for_render(5) + + def test_switch_to_uppercase_with_symbol_up(self): + """ + Test switching to uppercase mode. + + This reproduces the crash that occurred when clicking the UP arrow button. + The bug was that set_mode() was called without set_map() first. + """ + print("\n=== Testing uppercase mode switch ===") + + # Create keyboard + keyboard = MposKeyboard(self.screen) + keyboard.set_textarea(self.textarea) + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + wait_for_render(10) + + # Keyboard starts in lowercase mode + print("Initial mode: MODE_LOWERCASE") + + # Find the UP symbol button by searching all buttons + up_button_index = None + for i in range(100): # Try up to 100 buttons + try: + text = keyboard.get_button_text(i) + if text == lv.SYMBOL.UP: + up_button_index = i + print(f"Found UP symbol at button index {i}") + break + except: + pass + + self.assertIsNotNone(up_button_index, "Should find UP symbol button") + + # Test mode switching (this is what happens when the user clicks UP) + print("Switching to uppercase mode...") + try: + keyboard.set_mode(MposKeyboard.MODE_UPPERCASE) + wait_for_render(5) + print("SUCCESS: No crash when switching to uppercase!") + + # Verify we're now in uppercase mode by checking the button changed + down_button_text = keyboard.get_button_text(up_button_index) + print(f"After switch, button {up_button_index} text: {down_button_text}") + self.assertEqual(down_button_text, lv.SYMBOL.DOWN, + "Should show DOWN symbol in uppercase mode") + + # Switch back to lowercase + keyboard.set_mode(MposKeyboard.MODE_LOWERCASE) + wait_for_render(5) + up_button_text = keyboard.get_button_text(up_button_index) + self.assertEqual(up_button_text, lv.SYMBOL.UP, + "Should show UP symbol in lowercase mode") + + except Exception as e: + self.fail(f"CRASH: Switching to uppercase caused exception: {e}") + + def test_switch_modes_multiple_times(self): + """ + Test switching between all keyboard modes multiple times. + + Tests the full mode switching cycle to ensure all modes work. + """ + print("\n=== Testing multiple mode switches ===") + + keyboard = MposKeyboard(self.screen) + keyboard.set_textarea(self.textarea) + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + wait_for_render(10) + + modes_to_test = [ + (MposKeyboard.MODE_UPPERCASE, "MODE_UPPERCASE"), + (MposKeyboard.MODE_LOWERCASE, "MODE_LOWERCASE"), + (MposKeyboard.MODE_NUMBERS, "MODE_NUMBERS"), + (MposKeyboard.MODE_SPECIALS, "MODE_SPECIALS"), + (MposKeyboard.MODE_LOWERCASE, "MODE_LOWERCASE (again)"), + ] + + for mode, mode_name in modes_to_test: + print(f"Switching to {mode_name}...") + try: + keyboard.set_mode(mode) + wait_for_render(5) + print(f" SUCCESS: Switched to {mode_name}") + except Exception as e: + self.fail(f" CRASH: Switching to {mode_name} caused exception: {e}") + + def test_event_handler_exists(self): + """ + Verify that the event handler exists and is properly connected. + + The _handle_events method should filter events to only process + VALUE_CHANGED events. This prevents duplicate characters from being + typed when other events (like PRESSED, RELEASED, etc.) are fired. + + The fix ensures: + 1. Only VALUE_CHANGED events are processed + 2. None/invalid button text is ignored + 3. Each button press results in exactly ONE character being added + """ + print("\n=== Verifying event handler exists ===") + + keyboard = MposKeyboard(self.screen) + keyboard.set_textarea(self.textarea) + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + wait_for_render(10) + + # Verify the event handler method exists and is callable + self.assertTrue(hasattr(keyboard, '_handle_events'), + "Keyboard should have _handle_events method") + self.assertTrue(callable(keyboard._handle_events), + "_handle_events should be callable") + + print("SUCCESS: Event handler exists and is properly set up") + print("Note: The handler filters for VALUE_CHANGED events only") + + diff --git a/tests/test_graphical_keyboard_q_button_bug.py b/tests/test_graphical_keyboard_q_button_bug.py new file mode 100644 index 00000000..dae8e307 --- /dev/null +++ b/tests/test_graphical_keyboard_q_button_bug.py @@ -0,0 +1,188 @@ +""" +Test for keyboard button functionality (originally created to fix "q" button bug). + +This test verifies that all keyboard buttons work correctly, including the +'q' button which was previously broken due to button index 0 being treated +as False in Python's truthiness check. + +The bug was: `if not button:` would return True when button index was 0, +causing the 'q' key to be ignored. Fixed by changing to `if button is None:`. + +Usage: + Desktop: ./tests/unittest.sh tests/test_graphical_keyboard_q_button_bug.py + Device: ./tests/unittest.sh tests/test_graphical_keyboard_q_button_bug.py --ondevice +""" + +import unittest +import lvgl as lv +from mpos.ui.keyboard import MposKeyboard +from mpos.ui.testing import ( + wait_for_render, + find_button_with_text, + get_widget_coords, + get_keyboard_button_coords, + simulate_click, + print_screen_labels +) + + +class TestKeyboardQButton(unittest.TestCase): + """Test keyboard button functionality (especially 'q' which was at index 0).""" + + def setUp(self): + """Set up test fixtures.""" + self.screen = lv.obj() + self.screen.set_size(320, 240) + lv.screen_load(self.screen) + wait_for_render(5) + + def tearDown(self): + """Clean up.""" + lv.screen_load(lv.obj()) + wait_for_render(5) + + def test_q_button_works(self): + """ + Test that clicking the 'q' button adds 'q' to textarea. + + This test verifies the fix for the bug where: + - Bug: Button index 0 ('q') was treated as False in `if not button:` + - Fix: Changed to `if button is None:` to properly handle index 0 + + Steps: + 1. Create textarea and keyboard + 2. Find 'q' button index in keyboard map + 3. Get button coordinates from keyboard widget + 4. Click it using simulate_click() + 5. Verify 'q' appears in textarea (should PASS after fix) + 6. Repeat with 'a' button + 7. Verify 'a' appears correctly (should PASS) + """ + print("\n=== Testing keyboard 'q' and 'a' button behavior ===") + + # Create textarea + textarea = lv.textarea(self.screen) + textarea.set_size(200, 30) + textarea.set_one_line(True) + textarea.align(lv.ALIGN.TOP_MID, 0, 10) + textarea.set_text("") # Start empty + wait_for_render(5) + + # Create keyboard and connect to textarea + keyboard = MposKeyboard(self.screen) + keyboard.set_textarea(textarea) + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + wait_for_render(20) # increased from 10 to 20 because on macOS this didnt work + + print(f"Initial textarea: '{textarea.get_text()}'") + self.assertEqual(textarea.get_text(), "", "Textarea should start empty") + + # --- Test 'q' button --- + print("\n--- Testing 'q' button ---") + + # Get exact button coordinates using helper function + q_coords = get_keyboard_button_coords(keyboard, "q") + self.assertIsNotNone(q_coords, "Should find 'q' button on keyboard") + + print(f"Found 'q' button at index {q_coords['button_idx']}, row {q_coords['row']}, col {q_coords['col']}") + print(f"Exact 'q' button position: ({q_coords['center_x']}, {q_coords['center_y']})") + + # Click the 'q' button + print(f"Clicking 'q' button at ({q_coords['center_x']}, {q_coords['center_y']})") + simulate_click(q_coords['center_x'], q_coords['center_y']) + wait_for_render(20) # increased from 10 to 20 because on macOS this didnt work + + # Check textarea content + text_after_q = textarea.get_text() + print(f"Textarea after clicking 'q': '{text_after_q}'") + + # Verify 'q' was added (should work after fix) + self.assertEqual(text_after_q, "q", + "Clicking 'q' button should add 'q' to textarea") + + # --- Test 'a' button for comparison --- + print("\n--- Testing 'a' button (for comparison) ---") + + # Clear textarea + textarea.set_text("") + wait_for_render(5) + print("Cleared textarea") + + # Get exact button coordinates using helper function + a_coords = get_keyboard_button_coords(keyboard, "a") + self.assertIsNotNone(a_coords, "Should find 'a' button on keyboard") + + print(f"Found 'a' button at index {a_coords['button_idx']}, row {a_coords['row']}, col {a_coords['col']}") + print(f"Exact 'a' button position: ({a_coords['center_x']}, {a_coords['center_y']})") + + # Click the 'a' button + print(f"Clicking 'a' button at ({a_coords['center_x']}, {a_coords['center_y']})") + simulate_click(a_coords['center_x'], a_coords['center_y']) + wait_for_render(10) + + # Check textarea content + text_after_a = textarea.get_text() + print(f"Textarea after clicking 'a': '{text_after_a}'") + + # The 'a' button should work correctly + self.assertEqual(text_after_a, "a", + "Clicking 'a' button should add 'a' to textarea") + + print("\nSummary:") + print(f" 'q' button result: '{text_after_q}' (expected 'q') ✓") + print(f" 'a' button result: '{text_after_a}' (expected 'a') ✓") + print(" Both buttons work correctly!") + + def test_keyboard_button_discovery(self): + """ + Debug test: Discover all buttons on the keyboard. + + This test helps understand the keyboard layout and button structure. + It prints all found buttons and their text. + """ + print("\n=== Discovering keyboard buttons ===") + + # Create keyboard without textarea to inspect it + keyboard = MposKeyboard(self.screen) + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + wait_for_render(10) + + # Iterate through button indices to find all buttons + print("\nEnumerating keyboard buttons by index:") + found_buttons = [] + + for i in range(100): # Check first 100 indices + try: + text = keyboard.get_button_text(i) + if text: # Skip None/empty + found_buttons.append((i, text)) + # Only print first 20 to avoid clutter + if i < 20: + print(f" Button {i}: '{text}'") + except: + # No more buttons + break + + if len(found_buttons) > 20: + print(f" ... (showing first 20 of {len(found_buttons)} buttons)") + + print(f"\nTotal buttons found: {len(found_buttons)}") + + # Try to find specific letters + letters_to_test = ['q', 'w', 'e', 'r', 'a', 's', 'd', 'f'] + print("\nLooking for specific letters:") + + for letter in letters_to_test: + found = False + for idx, text in found_buttons: + if text == letter: + print(f" '{letter}' at index {idx}") + found = True + break + if not found: + print(f" '{letter}' NOT FOUND") + + # Verify we can find at least some buttons + self.assertTrue(len(found_buttons) > 0, + "Should find at least some buttons on keyboard") + diff --git a/tests/test_graphical_keyboard_rapid_mode_switch.py b/tests/test_graphical_keyboard_rapid_mode_switch.py new file mode 100644 index 00000000..7cded668 --- /dev/null +++ b/tests/test_graphical_keyboard_rapid_mode_switch.py @@ -0,0 +1,152 @@ +""" +Test for rapid mode switching bug (clicking ?123/abc rapidly). + +This test reproduces: +1. Comma being added when clicking "abc" button +2. Intermittent crashes when rapidly clicking mode switch buttons + +Usage: + Desktop: ./tests/unittest.sh tests/test_graphical_keyboard_rapid_mode_switch.py +""" + +import unittest +import lvgl as lv +from mpos.ui.keyboard import MposKeyboard +from mpos.ui.testing import wait_for_render + + +class TestRapidModeSwitching(unittest.TestCase): + """Test rapid mode switching between lowercase and numbers.""" + + def setUp(self): + """Set up test fixtures.""" + self.screen = lv.obj() + self.screen.set_size(320, 240) + + # Create textarea + self.textarea = lv.textarea(self.screen) + self.textarea.set_size(280, 40) + self.textarea.align(lv.ALIGN.TOP_MID, 0, 10) + self.textarea.set_one_line(True) + + # Load screen + lv.screen_load(self.screen) + wait_for_render(5) + + def tearDown(self): + """Clean up.""" + lv.screen_load(lv.obj()) + wait_for_render(5) + + def test_rapid_clicking_abc_button(self): + """ + Rapidly click the "Abc" button to reproduce the comma bug and crash. + + Expected: Clicking "Abc" should NOT add comma to textarea + Bug: Comma is being added, suggesting button index confusion + """ + print("\n=== Testing rapid clicking of Abc button ===") + + keyboard = MposKeyboard(self.screen) + keyboard.set_textarea(self.textarea) + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + wait_for_render(10) + + # Start in lowercase, switch to numbers + print("Step 1: Switch to numbers mode") + keyboard.set_mode(MposKeyboard.MODE_NUMBERS) + wait_for_render(10) # Give time to settle + + # Clear textarea + self.textarea.set_text("") + + # Now find the "abc" button + abc_button_index = None + for i in range(100): + try: + text = keyboard.get_button_text(i) + if text == "Abc": + abc_button_index = i + print(f" Found 'Abc' button at index {i}") + break + except: + pass + + self.assertIsNotNone(abc_button_index, "Should find 'Abc' button in numbers mode") + + # Simulate rapid clicking by alternating modes + print("\nStep 2: Rapidly switch modes by simulating Abc/?123 clicks") + for i in range(10): + # Get current mode + current_mode = keyboard._keyboard.get_mode() + + # Clear text before click + textarea_before = self.textarea.get_text() + print(f" Click {i+1}: mode={current_mode}, textarea='{textarea_before}'") + + if current_mode == MposKeyboard.MODE_NUMBERS or current_mode == lv.keyboard.MODE.NUMBER: + # Click "Abc" to go to lowercase + keyboard.set_mode(MposKeyboard.MODE_LOWERCASE) + else: + # Click "?123" to go to numbers + keyboard.set_mode(MposKeyboard.MODE_NUMBERS) + + wait_for_render(2) + + # Check if text changed (BUG: should not change!) + textarea_after = self.textarea.get_text() + if textarea_after != textarea_before: + print(f" ERROR: Text changed from '{textarea_before}' to '{textarea_after}'") + self.fail(f"BUG: Clicking mode switch button added '{textarea_after}' to textarea") + + # Verify textarea is still empty + final_text = self.textarea.get_text() + print(f"\nFinal textarea text: '{final_text}'") + self.assertEqual(final_text, "", + f"Textarea should be empty after mode switches, but contains: '{final_text}'") + + print("SUCCESS: No spurious characters added during rapid mode switching") + + def test_button_indices_after_mode_switch(self): + """ + Test that button indices remain consistent after mode switches. + + This helps identify if the comma bug is due to button index confusion. + """ + print("\n=== Testing button indices after mode switch ===") + + keyboard = MposKeyboard(self.screen) + keyboard.set_textarea(self.textarea) + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + wait_for_render(10) + + # Map button indices in lowercase mode + print("\nButton indices in LOWERCASE mode:") + keyboard.set_mode(MposKeyboard.MODE_LOWERCASE) + wait_for_render(10) + + lowercase_buttons = {} + for i in range(40): + try: + text = keyboard.get_button_text(i) + if text in ["?123", ",", "Abc", lv.SYMBOL.UP]: + lowercase_buttons[text] = i + print(f" '{text}' at index {i}") + except: + pass + + # Map button indices in numbers mode + print("\nButton indices in NUMBERS mode:") + keyboard.set_mode(MposKeyboard.MODE_NUMBERS) + wait_for_render(10) + + numbers_buttons = {} + for i in range(40): + try: + text = keyboard.get_button_text(i) + if text in ["?123", ",", "Abc", "=\\<"]: + numbers_buttons[text] = i + print(f" '{text}' at index {i}") + except: + pass + diff --git a/tests/test_graphical_keyboard_styling.py b/tests/test_graphical_keyboard_styling.py new file mode 100644 index 00000000..1f925972 --- /dev/null +++ b/tests/test_graphical_keyboard_styling.py @@ -0,0 +1,375 @@ +""" +Graphical test for on-screen keyboard button styling. + +This test verifies that keyboard buttons have proper visible contrast +in both light and dark modes. It checks for the bug where keyboard buttons +appear white-on-white in light mode on ESP32. + +The test uses two approaches: +1. Programmatic: Query LVGL style properties to verify button background colors +2. Visual: Capture screenshots for manual verification and regression testing + +This test should INITIALLY FAIL, demonstrating the bug before the fix is applied. + +Usage: + Desktop: ./tests/unittest.sh tests/test_graphical_keyboard_styling.py + Device: ./tests/unittest.sh tests/test_graphical_keyboard_styling.py --ondevice +""" + +import unittest +import lvgl as lv +import mpos.ui +import mpos.config +import sys +import os +from mpos.ui.testing import ( + wait_for_render, + capture_screenshot, +) + + +class TestKeyboardStyling(unittest.TestCase): + """Test suite for keyboard button visibility and styling.""" + + def setUp(self): + """Set up test fixtures before each test method.""" + # Determine screenshot directory + if sys.platform == "esp32": + self.screenshot_dir = "tests/screenshots" + else: + self.screenshot_dir = "../tests/screenshots" + + # Ensure screenshots directory exists + try: + os.mkdir(self.screenshot_dir) + except OSError: + pass # Directory already exists + + # Save current theme setting + prefs = mpos.config.SharedPreferences("theme_settings") + self.original_theme = prefs.get_string("theme_light_dark", "light") + + print(f"\n=== Keyboard Styling Test Setup ===") + print(f"Platform: {sys.platform}") + print(f"Original theme: {self.original_theme}") + + def tearDown(self): + """Clean up after each test method.""" + # Restore original theme + prefs = mpos.config.SharedPreferences("theme_settings") + editor = prefs.edit() + editor.put_string("theme_light_dark", self.original_theme) + editor.commit() + + # Reapply original theme + mpos.ui.theme.set_theme(prefs) + + print("=== Test cleanup complete ===\n") + + def _create_test_keyboard(self): + """ + Create a test keyboard widget for inspection. + + Returns: + tuple: (screen, keyboard, textarea) widgets + """ + # Create a clean screen + screen = lv.obj() + screen.set_size(320, 240) + + # Create a text area for the keyboard to target + textarea = lv.textarea(screen) + textarea.set_size(280, 40) + textarea.align(lv.ALIGN.TOP_MID, 0, 10) + textarea.set_placeholder_text("Type here...") + + # Create the keyboard + keyboard = lv.keyboard(screen) + keyboard.set_textarea(textarea) + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + keyboard.set_style_min_height(160, 0) + + # Apply the keyboard button fix + mpos.ui.theme.fix_keyboard_button_style(keyboard) + + # Load the screen and wait for rendering + lv.screen_load(screen) + wait_for_render(iterations=20) + + return screen, keyboard, textarea + + def _get_button_background_color(self, keyboard): + """ + Extract the background color of keyboard buttons. + + This queries LVGL's style system to get the actual rendered + background color of the keyboard's button parts (LV_PART_ITEMS). + + Args: + keyboard: LVGL keyboard widget + + Returns: + dict: Color information with 'r', 'g', 'b' values (0-255) + """ + # Get the style property for button background color + # LV_PART_ITEMS is the part that represents individual buttons + bg_color = keyboard.get_style_bg_color(lv.PART.ITEMS) + + # Extract RGB values from LVGL color + # Note: LVGL colors are in RGB565 or RGB888 depending on config + # We convert to RGB888 for comparison + r = lv.color_brightness(bg_color) if hasattr(lv, 'color_brightness') else 0 + + # Try to get RGB components directly + try: + # For LVGL 9.x, colors have direct accessors + color_dict = { + 'r': bg_color.red() if hasattr(bg_color, 'red') else 0, + 'g': bg_color.green() if hasattr(bg_color, 'green') else 0, + 'b': bg_color.blue() if hasattr(bg_color, 'blue') else 0, + } + except: + # Fallback: use color as hex value + try: + color_int = bg_color.to_int() if hasattr(bg_color, 'to_int') else 0 + color_dict = { + 'r': (color_int >> 16) & 0xFF, + 'g': (color_int >> 8) & 0xFF, + 'b': color_int & 0xFF, + 'hex': f"#{color_int:06x}" + } + except: + # Last resort: just store the color object + color_dict = {'color_obj': bg_color} + + return color_dict + + def _get_screen_background_color(self, screen): + """ + Extract the background color of the screen. + + Args: + screen: LVGL screen object + + Returns: + dict: Color information with 'r', 'g', 'b' values (0-255) + """ + bg_color = screen.get_style_bg_color(lv.PART.MAIN) + + try: + color_dict = { + 'r': bg_color.red() if hasattr(bg_color, 'red') else 0, + 'g': bg_color.green() if hasattr(bg_color, 'green') else 0, + 'b': bg_color.blue() if hasattr(bg_color, 'blue') else 0, + } + except: + try: + color_int = bg_color.to_int() if hasattr(bg_color, 'to_int') else 0 + color_dict = { + 'r': (color_int >> 16) & 0xFF, + 'g': (color_int >> 8) & 0xFF, + 'b': color_int & 0xFF, + 'hex': f"#{color_int:06x}" + } + except: + color_dict = {'color_obj': bg_color} + + return color_dict + + def _color_contrast_sufficient(self, color1, color2, min_difference=20): + """ + Check if two colors have sufficient contrast. + + Uses simple RGB distance. For production, you might want to use + proper contrast ratio calculation (WCAG). + + Args: + color1: Dict with 'r', 'g', 'b' keys + color2: Dict with 'r', 'g', 'b' keys + min_difference: Minimum RGB distance for sufficient contrast + + Returns: + bool: True if contrast is sufficient + """ + if 'r' not in color1 or 'r' not in color2: + # Can't determine, assume failure + return False + + # Calculate Euclidean distance in RGB space + r_diff = abs(color1['r'] - color2['r']) + g_diff = abs(color1['g'] - color2['g']) + b_diff = abs(color1['b'] - color2['b']) + + # Simple average difference + avg_diff = (r_diff + g_diff + b_diff) / 3 + + print(f" Color 1: RGB({color1['r']}, {color1['g']}, {color1['b']})") + print(f" Color 2: RGB({color2['r']}, {color2['g']}, {color2['b']})") + print(f" Average difference: {avg_diff:.1f} (min required: {min_difference})") + + return avg_diff >= min_difference + + def test_keyboard_buttons_visible_in_light_mode(self): + """ + Test that keyboard buttons are visible in light mode. + + In light mode, the screen background is white. Keyboard buttons + should NOT be white - they should be a light gray color to provide + contrast. + + This test will FAIL initially, demonstrating the bug. + """ + print("\n=== Testing keyboard buttons in LIGHT mode ===") + + # Set theme to light mode + prefs = mpos.config.SharedPreferences("theme_settings") + editor = prefs.edit() + editor.put_string("theme_light_dark", "light") + editor.commit() + + # Apply theme + mpos.ui.theme.set_theme(prefs) + wait_for_render(iterations=10) + + # Create test keyboard + screen, keyboard, textarea = self._create_test_keyboard() + + # Get colors + button_bg = self._get_button_background_color(keyboard) + screen_bg = self._get_screen_background_color(screen) + + print("\nLight mode colors:") + print(f" Screen background: {screen_bg}") + print(f" Button background: {button_bg}") + + # Capture screenshot + screenshot_path = f"{self.screenshot_dir}/keyboard_light_mode.raw" + print(f"\nCapturing screenshot: {screenshot_path}") + capture_screenshot(screenshot_path, width=320, height=240) + + # Verify contrast + print("\nChecking button/screen contrast...") + has_contrast = self._color_contrast_sufficient(button_bg, screen_bg, min_difference=20) + + # Clean up + lv.screen_load(lv.obj()) + wait_for_render(5) + + # Assert: buttons should have sufficient contrast with background + self.assertTrue( + has_contrast, + f"Keyboard buttons lack sufficient contrast in light mode!\n" + f"Button color: {button_bg}\n" + f"Screen color: {screen_bg}\n" + f"This is the BUG we're trying to fix - buttons are white on white." + ) + + print("=== Light mode test PASSED ===") + + def test_keyboard_buttons_visible_in_dark_mode(self): + """ + Test that keyboard buttons are visible in dark mode. + + In dark mode, buttons should have proper contrast with the + dark background. This typically works correctly. + """ + print("\n=== Testing keyboard buttons in DARK mode ===") + + # Set theme to dark mode + prefs = mpos.config.SharedPreferences("theme_settings") + editor = prefs.edit() + editor.put_string("theme_light_dark", "dark") + editor.commit() + + # Apply theme + mpos.ui.theme.set_theme(prefs) + wait_for_render(iterations=10) + + # Create test keyboard + screen, keyboard, textarea = self._create_test_keyboard() + + # Get colors + button_bg = self._get_button_background_color(keyboard) + screen_bg = self._get_screen_background_color(screen) + + print("\nDark mode colors:") + print(f" Screen background: {screen_bg}") + print(f" Button background: {button_bg}") + + # Capture screenshot + screenshot_path = f"{self.screenshot_dir}/keyboard_dark_mode.raw" + print(f"\nCapturing screenshot: {screenshot_path}") + capture_screenshot(screenshot_path, width=320, height=240) + + # Verify contrast + print("\nChecking button/screen contrast...") + has_contrast = self._color_contrast_sufficient(button_bg, screen_bg, min_difference=20) + + # Clean up + lv.screen_load(lv.obj()) + wait_for_render(5) + + # Assert: buttons should have sufficient contrast + self.assertTrue( + has_contrast, + f"Keyboard buttons lack sufficient contrast in dark mode!\n" + f"Button color: {button_bg}\n" + f"Screen color: {screen_bg}" + ) + + print("=== Dark mode test PASSED ===") + + def test_keyboard_buttons_not_pure_white_in_light_mode(self): + """ + Specific test: In light mode, buttons should NOT be pure white. + + They should be a light gray (approximately RGB(238, 238, 238) or similar). + Pure white (255, 255, 255) means they're invisible on white background. + """ + print("\n=== Testing that buttons are NOT pure white in light mode ===") + + # Set theme to light mode + prefs = mpos.config.SharedPreferences("theme_settings") + editor = prefs.edit() + editor.put_string("theme_light_dark", "light") + editor.commit() + + # Apply theme + mpos.ui.theme.set_theme(prefs) + wait_for_render(iterations=10) + + # Create test keyboard + screen, keyboard, textarea = self._create_test_keyboard() + + # Get button color + button_bg = self._get_button_background_color(keyboard) + + print(f"\nButton background color: {button_bg}") + + # Clean up + lv.screen_load(lv.obj()) + wait_for_render(5) + + # Check if button is pure white (or very close to it) + if 'r' in button_bg: + is_white = (button_bg['r'] >= 250 and + button_bg['g'] >= 250 and + button_bg['b'] >= 250) + + print(f"Is button pure white? {is_white}") + + # Assert: buttons should NOT be pure white + self.assertFalse( + is_white, + f"Keyboard buttons are pure white in light mode!\n" + f"Button color: RGB({button_bg['r']}, {button_bg['g']}, {button_bg['b']})\n" + f"Expected: Light gray around RGB(238, 238, 238) or similar\n" + f"This is the BUG - white buttons on white background are invisible." + ) + else: + # Couldn't extract RGB, fail the test + self.fail(f"Could not extract RGB values from button color: {button_bg}") + + print("=== Pure white test PASSED ===") + + diff --git a/tests/test_graphical_launch_all_apps.py b/tests/test_graphical_launch_all_apps.py new file mode 100644 index 00000000..6dcae3bb --- /dev/null +++ b/tests/test_graphical_launch_all_apps.py @@ -0,0 +1,248 @@ +""" +Test that launches all installed apps to check for startup errors. + +This test discovers all apps in apps/ and builtin/apps/ directories, +launches each one, and checks for exceptions during startup. +""" + +import unittest +import os +import sys +import time + +# This is a graphical test - needs boot and main to run first +# Add tests directory to path for helpers + +from mpos.ui.testing import wait_for_render +import mpos.apps +import mpos.ui +from mpos.content.package_manager import PackageManager + + +class TestLaunchAllApps(unittest.TestCase): + """Test launching all installed apps.""" + + def setUp(self): + """Set up test fixtures.""" + self.apps_to_test = [] + self.app_errors = {} + + # Discover all apps + self._discover_apps() + + def _discover_apps(self): + """Discover all installed apps.""" + # Use PackageManager to get all apps + all_packages = PackageManager.get_app_list() + + for package in all_packages: + # Get the main activity for each app + if package.activities: + # Use first activity as the main one (activities are dicts) + main_activity = package.activities[0] + self.apps_to_test.append({ + 'package_name': package.fullname, + 'activity_name': main_activity.get('classname', 'MainActivity'), + 'label': package.name + }) + + def test_launch_all_apps(self): + """Launch each app and check for errors.""" + print(f"\n{'='*60}") + print(f"Testing {len(self.apps_to_test)} apps for startup errors") + print(f"{'='*60}\n") + + failed_apps = [] + passed_apps = [] + + for i, app_info in enumerate(self.apps_to_test, 1): + package_name = app_info['package_name'] + activity_name = app_info['activity_name'] + label = app_info['label'] + + print(f"\n[{i}/{len(self.apps_to_test)}] Testing: {label} ({package_name})") + + error_found = False + error_message = "" + + try: + # Launch the app by package name + result = mpos.apps.start_app(package_name) + + # Wait for UI to render + wait_for_render(iterations=5) + + # Check if start_app returned False (indicates error during execution) + if result is False: + error_found = True + error_message = "App failed to start (execute_script returned False)" + print(f" ❌ FAILED - App failed to start") + print(f" {error_message}") + failed_apps.append({ + 'info': app_info, + 'error': error_message + }) + else: + # If we got here without error, the app loaded successfully + print(f" ✓ PASSED - App loaded successfully") + passed_apps.append(app_info) + + # Navigate back to exit the app + mpos.ui.back_screen() + wait_for_render(iterations=3) + + except Exception as e: + error_found = True + error_message = f"{type(e).__name__}: {str(e)}" + print(f" ❌ FAILED - Exception during launch") + print(f" {error_message}") + failed_apps.append({ + 'info': app_info, + 'error': error_message + }) + + # Print summary + print(f"\n{'='*60}") + print(f"Test Summary") + print(f"{'='*60}") + print(f"Total apps tested: {len(self.apps_to_test)}") + print(f"Passed: {len(passed_apps)}") + print(f"Failed: {len(failed_apps)}") + print(f"{'='*60}\n") + + if failed_apps: + print("Failed apps:") + for fail in failed_apps: + print(f" - {fail['info']['label']} ({fail['info']['package_name']})") + print(f" Error: {fail['error']}") + print() + + # Separate errortest failures from other failures + errortest_failures = [ + fail for fail in failed_apps + if 'errortest' in fail['info']['package_name'].lower() + ] + + # On macOS, musicplayer is known to fail due to @micropython.viper issue + is_macos = sys.platform == 'darwin' + musicplayer_failures = [ + fail for fail in failed_apps + if fail['info']['package_name'] == 'com.micropythonos.musicplayer' and is_macos + ] + + other_failures = [ + fail for fail in failed_apps + if 'errortest' not in fail['info']['package_name'].lower() and + not (fail['info']['package_name'] == 'com.micropythonos.musicplayer' and is_macos) + ] + + # Check if errortest app exists + all_app_names = [app['package_name'] for app in self.apps_to_test] + has_errortest = any('errortest' in name.lower() for name in all_app_names) + + # Verify errortest app fails if it exists + if has_errortest: + self.assertTrue(len(errortest_failures) > 0, + "Failed to detect error in com.micropythonos.errortest app") + print("✓ Successfully detected the intentional error in errortest app") + + # Report on musicplayer failures on macOS (known issue) + if musicplayer_failures: + print("⚠ Skipped musicplayer failure on macOS (known @micropython.viper issue)") + + # Fail the test if any non-errortest apps have errors + if other_failures: + print(f"\n❌ FAIL: {len(other_failures)} non-errortest app(s) have errors:") + for fail in other_failures: + print(f" - {fail['info']['label']} ({fail['info']['package_name']})") + print(f" Error: {fail['error']}") + self.fail(f"{len(other_failures)} app(s) failed to launch (excluding errortest)") + else: + print("✓ All non-errortest apps launched successfully") + + +class TestLaunchSpecificApps(unittest.TestCase): + """Test specific apps individually for more detailed error reporting.""" + + def _launch_and_check_app(self, package_name, expected_error=False): + """ + Launch an app and check for errors. + + Args: + package_name: Full package name (e.g., 'com.micropythonos.camera') + expected_error: Whether this app is expected to have errors + + Returns: + tuple: (success, error_message) + """ + error_found = False + error_message = "" + + try: + # Launch the app by package name + result = mpos.apps.start_app(package_name) + wait_for_render(iterations=5) + + # Check if start_app returned False (indicates error) + if result is False: + error_found = True + error_message = "App failed to start (execute_script returned False)" + + # Navigate back + mpos.ui.back_screen() + wait_for_render(iterations=3) + + except Exception as e: + error_found = True + error_message = f"{type(e).__name__}: {str(e)}" + + if expected_error: + # For apps expected to have errors + return (error_found, error_message) + else: + # For apps that should work + return (not error_found, error_message) + + def test_errortest_app_has_error(self): + """Test that the errortest app properly reports an error.""" + success, error_msg = self._launch_and_check_app( + 'com.micropythonos.errortest', + expected_error=True + ) + + if success: + print(f"\n✓ Successfully detected error in errortest app:") + print(f" {error_msg}") + else: + print(f"\n❌ Failed to detect error in errortest app") + + self.assertTrue(success, + "The errortest app should have an error but none was detected") + + def test_launcher_app_loads(self): + """Test that the launcher app loads without errors.""" + success, error_msg = self._launch_and_check_app( + 'com.micropythonos.launcher', + expected_error=False + ) + + if not success: + print(f"\n❌ Launcher app has errors: {error_msg}") + + self.assertTrue(success, + f"Launcher app should load without errors: {error_msg}") + + def test_about_app_loads(self): + """Test that the About app loads without errors.""" + success, error_msg = self._launch_and_check_app( + 'com.micropythonos.about', + expected_error=False + ) + + if not success: + print(f"\n❌ About app has errors: {error_msg}") + + self.assertTrue(success, + f"About app should load without errors: {error_msg}") + + diff --git a/tests/test_graphical_osupdate.py b/tests/test_graphical_osupdate.py new file mode 100644 index 00000000..3f718e73 --- /dev/null +++ b/tests/test_graphical_osupdate.py @@ -0,0 +1,286 @@ +import unittest +import lvgl as lv +import mpos +import time +import sys +import os + +# Import graphical test helper +from mpos.ui.testing import ( + wait_for_render, + capture_screenshot, + find_label_with_text, + verify_text_present, + print_screen_labels +) + + +class TestOSUpdateGraphicalUI(unittest.TestCase): + """Graphical tests for OSUpdate app UI state.""" + + def tearDown(self): + """Clean up after each test method.""" + # Navigate back to launcher + mpos.ui.back_screen() + wait_for_render(5) + + def test_app_launches_successfully(self): + """Test that OSUpdate app launches without errors.""" + result = mpos.apps.start_app("com.micropythonos.osupdate") + + self.assertTrue(result, "Failed to start OSUpdate app") + wait_for_render(10) + + # Get active screen + screen = lv.screen_active() + self.assertIsNotNone(screen, "No active screen after launch") + + def test_ui_elements_exist(self): + """Test that all required UI elements are created.""" + result = mpos.apps.start_app("com.micropythonos.osupdate") + self.assertTrue(result) + wait_for_render(15) + + screen = lv.screen_active() + + # Find UI elements by searching for labels/text + current_version_label = find_label_with_text(screen, "Installed OS version") + self.assertIsNotNone(current_version_label, "Current version label not found") + + # Check for force update checkbox text (might be "Force" or "Update") + force_checkbox_found = verify_text_present(screen, "Force") or verify_text_present(screen, "force") + self.assertTrue(force_checkbox_found, "Force checkbox text not found") + + # Check for update button text (case insensitive) + update_button_found = verify_text_present(screen, "Update") or verify_text_present(screen, "update") + self.assertTrue(update_button_found, "Update button text not found") + + def test_force_checkbox_initially_unchecked(self): + """Test that force update checkbox starts unchecked.""" + result = mpos.apps.start_app("com.micropythonos.osupdate") + self.assertTrue(result) + wait_for_render(15) + + screen = lv.screen_active() + + # Find checkbox - it's the first checkbox on the screen + checkbox = None + def find_checkbox(obj): + nonlocal checkbox + if checkbox: + return + # Check if this object is a checkbox + try: + # In LVGL, checkboxes have specific flags/properties + if obj.get_child_count() >= 0: # It's a valid object + # Try to get state - checkboxes respond to STATE.CHECKED + state = obj.get_state() + # If it has checkbox-like text, it's probably our checkbox + for i in range(obj.get_child_count()): + child = obj.get_child(i) + if hasattr(child, 'get_text'): + text = child.get_text() + if text and "Force Update" in text: + checkbox = obj.get_parent() if obj.get_parent() else obj + return + except: + pass + + # Recursively search children + for i in range(obj.get_child_count()): + child = obj.get_child(i) + find_checkbox(child) + + find_checkbox(screen) + + if checkbox: + state = checkbox.get_state() + is_checked = bool(state & lv.STATE.CHECKED) + self.assertFalse(is_checked, "Force Update checkbox should start unchecked") + + def test_install_button_initially_disabled(self): + """Test that install button starts in disabled state.""" + result = mpos.apps.start_app("com.micropythonos.osupdate") + self.assertTrue(result) + wait_for_render(15) + + screen = lv.screen_active() + + # Find the button + button = None + def find_button(obj): + nonlocal button + if button: + return + # Check if this object contains "Update OS" text + for i in range(obj.get_child_count()): + child = obj.get_child(i) + if hasattr(child, 'get_text'): + text = child.get_text() + if text and "Update OS" in text: + # Parent is likely the button + button = obj + return + + # Recursively search children + for i in range(obj.get_child_count()): + child = obj.get_child(i) + find_button(child) + + find_button(screen) + + if button: + state = button.get_state() + is_disabled = bool(state & lv.STATE.DISABLED) + self.assertTrue(is_disabled, "Install button should start disabled") + + def test_current_version_displayed(self): + """Test that current OS version is displayed correctly.""" + result = mpos.apps.start_app("com.micropythonos.osupdate") + self.assertTrue(result) + wait_for_render(15) + + screen = lv.screen_active() + + # Find version label + version_label = find_label_with_text(screen, "Installed OS version:") + self.assertIsNotNone(version_label, "Version label not found") + + # Check that it contains the current version + label_text = version_label.get_text() + current_version = mpos.info.CURRENT_OS_VERSION + self.assertIn(current_version, label_text, + f"Current version {current_version} not in label text: {label_text}") + + def test_initial_status_message_without_wifi(self): + """Test status message when wifi is not connected.""" + # This test assumes desktop mode where wifi check returns True + # On actual hardware without wifi, it would show error + result = mpos.apps.start_app("com.micropythonos.osupdate") + self.assertTrue(result) + wait_for_render(15) + + screen = lv.screen_active() + + # Should show either "Checking for OS updates..." or update info + # or wifi error depending on network state + checking_found = verify_text_present(screen, "Checking") or \ + verify_text_present(screen, "version") or \ + verify_text_present(screen, "WiFi") + self.assertTrue(checking_found, "Should show some status message") + + def test_screenshot_initial_state(self): + """Capture screenshot of initial app state.""" + result = mpos.apps.start_app("com.micropythonos.osupdate") + self.assertTrue(result) + wait_for_render(20) + + screen = lv.screen_active() + + # Print labels for debugging + print("\n=== OSUpdate Initial State Labels ===") + print_screen_labels(screen) + +class TestOSUpdateGraphicalStatusMessages(unittest.TestCase): + """Graphical tests for OSUpdate status messages.""" + + def setUp(self): + """Set up test fixtures.""" + self.hardware_id = mpos.info.get_hardware_id() + self.screenshot_dir = "tests/screenshots" + + try: + os.stat(self.screenshot_dir) + except OSError: + try: + os.mkdir(self.screenshot_dir) + except OSError: + pass + + def tearDown(self): + """Clean up after test.""" + mpos.ui.back_screen() + wait_for_render(5) + + def test_status_label_exists(self): + """Test that status label is created and visible.""" + result = mpos.apps.start_app("com.micropythonos.osupdate") + self.assertTrue(result) + wait_for_render(15) + + screen = lv.screen_active() + + # Status label should exist and show some text + # Look for common status messages + has_status = ( + verify_text_present(screen, "Checking") or + verify_text_present(screen, "version") or + verify_text_present(screen, "WiFi") or + verify_text_present(screen, "Error") or + verify_text_present(screen, "Update") + ) + self.assertTrue(has_status, "Status label should be present with some message") + + def test_all_labels_readable(self): + """Test that all labels are readable (no truncation issues).""" + result = mpos.apps.start_app("com.micropythonos.osupdate") + self.assertTrue(result) + wait_for_render(15) + + screen = lv.screen_active() + + # Print all labels to verify they're readable + print("\n=== All OSUpdate Labels ===") + print_screen_labels(screen) + + # At minimum, should have version label + version_found = verify_text_present(screen, "Installed OS version") + self.assertTrue(version_found, "Version label should be present and readable") + + +class TestOSUpdateGraphicalScreenshots(unittest.TestCase): + """Screenshot tests for visual regression testing.""" + + def setUp(self): + """Set up test fixtures.""" + self.hardware_id = mpos.info.get_hardware_id() + self.screenshot_dir = "tests/screenshots" + + try: + os.stat(self.screenshot_dir) + except OSError: + try: + os.mkdir(self.screenshot_dir) + except OSError: + pass + + def tearDown(self): + """Clean up after test.""" + mpos.ui.back_screen() + wait_for_render(5) + + def test_capture_main_screen(self): + """Capture screenshot of main OSUpdate screen.""" + result = mpos.apps.start_app("com.micropythonos.osupdate") + self.assertTrue(result) + wait_for_render(20) + + + def test_capture_with_labels_visible(self): + """Capture screenshot ensuring all text is visible.""" + result = mpos.apps.start_app("com.micropythonos.osupdate") + self.assertTrue(result) + wait_for_render(20) + + screen = lv.screen_active() + + # Verify key elements are visible before screenshot (case insensitive) + has_version = verify_text_present(screen, "Installed") or verify_text_present(screen, "version") + has_force = verify_text_present(screen, "Force") or verify_text_present(screen, "force") + has_button = verify_text_present(screen, "Update") or verify_text_present(screen, "update") + + self.assertTrue(has_version, "Version label should be visible") + self.assertTrue(has_force, "Force checkbox should be visible") + self.assertTrue(has_button, "Update button should be visible") + + diff --git a/tests/test_graphical_start_app.py b/tests/test_graphical_start_app.py new file mode 100644 index 00000000..e8634d7a --- /dev/null +++ b/tests/test_graphical_start_app.py @@ -0,0 +1,69 @@ +""" +Test for app launching functionality. + +This test verifies that the app starting system works correctly, +including launching existing apps and handling non-existent apps. + +Works on both desktop and ESP32 by using the standard boot/main +initialization pattern. + +Usage: + Desktop: ./tests/unittest.sh tests/test_graphical_start_app.py + Device: ./tests/unittest.sh tests/test_graphical_start_app.py --ondevice +""" + +import unittest +import mpos.apps +import mpos.ui +from mpos.ui.testing import wait_for_render + + +class TestStartApp(unittest.TestCase): + """Test suite for app launching functionality.""" + + def setUp(self): + """Set up test fixtures before each test method.""" + print("\n=== Test Setup ===") + # No custom initialization needed - boot.py/main.py already ran + + def tearDown(self): + """Clean up after each test method.""" + # Navigate back to launcher to close any opened apps + try: + mpos.ui.back_screen() + wait_for_render(5) # Allow navigation to complete + except: + pass # Already on launcher or error + + print("=== Test Cleanup Complete ===\n") + + def test_normal(self): + """Test that launching an existing app succeeds.""" + print("Testing normal app launch...") + + result = mpos.apps.start_app("com.micropythonos.launcher") + wait_for_render(10) # Wait for app to load + + self.assertTrue(result, "com.micropythonos.launcher should start") + print("Normal app launch successful") + + def test_nonexistent(self): + """Test that launching a non-existent app fails gracefully.""" + print("Testing non-existent app launch...") + + result = mpos.apps.start_app("com.micropythonos.nonexistent") + + self.assertFalse(result, "com.micropythonos.nonexistent should not start") + print("Non-existent app handled correctly") + + def test_restart_launcher(self): + """Test that restarting the launcher succeeds.""" + print("Testing launcher restart...") + + result = mpos.apps.restart_launcher() + wait_for_render(10) # Wait for launcher to load + + self.assertTrue(result, "restart_launcher() should succeed") + print("Launcher restart successful") + + diff --git a/tests/test_graphical_wifi_keyboard.py b/tests/test_graphical_wifi_keyboard.py new file mode 100644 index 00000000..59fd910a --- /dev/null +++ b/tests/test_graphical_wifi_keyboard.py @@ -0,0 +1,231 @@ +""" +Test for WiFi app keyboard double-character bug. + +This test reproduces the issue where typing on the keyboard in the WiFi +password page results in double characters being added to the textarea. + +Usage: + Desktop: ./tests/unittest.sh tests/test_graphical_wifi_keyboard.py + Device: ./tests/unittest.sh tests/test_graphical_wifi_keyboard.py --ondevice +""" + +import unittest +import lvgl as lv +from mpos.ui.keyboard import MposKeyboard +from mpos.ui.testing import wait_for_render + + +class TestWiFiKeyboard(unittest.TestCase): + """Test WiFi app keyboard behavior.""" + + def setUp(self): + """Set up test fixtures.""" + self.screen = lv.obj() + self.screen.set_size(320, 240) + lv.screen_load(self.screen) + wait_for_render(5) + + def tearDown(self): + """Clean up.""" + lv.screen_load(lv.obj()) + wait_for_render(5) + + def test_keyboard_with_set_textarea(self): + """ + Test that keyboard with set_textarea doesn't double characters. + + This simulates how the WiFi app uses the keyboard: + 1. Create keyboard + 2. Call set_textarea() + 3. Type a character + 4. Verify only ONE character is added, not two + """ + print("\n=== Testing keyboard with set_textarea ===") + + # Create textarea (like WiFi password field) + textarea = lv.textarea(self.screen) + textarea.set_size(200, 30) + textarea.set_one_line(True) + textarea.align(lv.ALIGN.TOP_MID, 0, 10) + textarea.set_text("") # Start empty + wait_for_render(5) + + # Create keyboard and connect to textarea (like WiFi app does) + keyboard = MposKeyboard(self.screen) + keyboard.set_textarea(textarea) + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + wait_for_render(10) + + print(f"Initial textarea: '{textarea.get_text()}'") + self.assertEqual(textarea.get_text(), "", "Textarea should start empty") + + # Now we need to simulate typing a character + # The problem is that LVGL's keyboard has built-in auto-typing when set_textarea is called + # AND our custom handler also types. This causes doubles. + + # Let's manually trigger what happens when a button is pressed + # Find the 'a' button + a_button_index = None + for i in range(100): + try: + text = keyboard.get_button_text(i) + if text == "a": + a_button_index = i + print(f"Found 'a' button at index {i}") + break + except: + pass + + self.assertIsNotNone(a_button_index, "Should find 'a' button") + + # Get initial text + initial_text = textarea.get_text() + print(f"Text before simulated keypress: '{initial_text}'") + + # Simulate a button press by calling the underlying keyboard's event mechanism + # This is tricky to simulate properly in a test... + # Let's try a different approach: directly call our handler + + # Create a mock event + class MockEvent: + def get_code(self): + return lv.EVENT.VALUE_CHANGED + + # Manually set which button is selected + # (We can't actually set it, but our handler will query it) + # This is hard to test without actual user interaction + + # Alternative: Just verify the handler logic is sound + print("Testing that handler exists and filters correctly") + self.assertTrue(hasattr(keyboard, '_handle_events')) + + # For now, document the expected behavior + print("\nExpected behavior:") + print("- User clicks 'a' button") + print("- LVGL fires VALUE_CHANGED event") + print("- Our handler processes it ONCE") + print("- Exactly ONE 'a' should be added to textarea") + print("\nIf doubles occur, the bug is:") + print("- LVGL's built-in handler types the character") + print("- Our custom handler ALSO types it") + print("- Result: 'aa' instead of 'a'") + + def test_keyboard_manual_text_insertion(self): + """ + Test simulating the double-character bug by manually inserting text twice. + + This demonstrates what happens when both LVGL's default handler + and our custom handler try to insert the same character. + """ + print("\n=== Simulating double-character bug ===") + + textarea = lv.textarea(self.screen) + textarea.set_size(200, 30) + textarea.set_one_line(True) + textarea.align(lv.ALIGN.TOP_MID, 0, 10) + textarea.set_text("") + wait_for_render(5) + + keyboard = MposKeyboard(self.screen) + keyboard.set_textarea(textarea) + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + wait_for_render(10) + + # Simulate what happens if BOTH handlers fire: + # 1. LVGL's default handler inserts "a" + # 2. Our custom handler also inserts "a" + # Result: "aa" + + initial = textarea.get_text() + print(f"Initial: '{initial}'") + + # Simulate first insertion (LVGL default) + textarea.set_text(initial + "a") + wait_for_render(2) + after_first = textarea.get_text() + print(f"After first insertion: '{after_first}'") + + # Simulate second insertion (our handler) + textarea.set_text(after_first + "a") + wait_for_render(2) + after_second = textarea.get_text() + print(f"After second insertion (DOUBLE BUG): '{after_second}'") + + self.assertEqual(after_second, "aa", "Bug creates double characters") + print("\nThis is the BUG: typing 'a' once results in 'aa'") + + def test_keyboard_without_set_textarea(self): + """ + Test keyboard WITHOUT calling set_textarea. + + This tests if we can avoid the double-character bug by NOT + connecting the keyboard to the textarea with set_textarea(), + and instead relying only on our custom handler. + """ + print("\n=== Testing keyboard WITHOUT set_textarea ===") + + textarea = lv.textarea(self.screen) + textarea.set_size(200, 30) + textarea.set_one_line(True) + textarea.align(lv.ALIGN.TOP_MID, 0, 10) + textarea.set_text("") + wait_for_render(5) + + keyboard = MposKeyboard(self.screen) + # DON'T call set_textarea() - handle it manually + # keyboard.set_textarea(textarea) # <-- Commented out + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + wait_for_render(10) + + print("Keyboard created WITHOUT set_textarea()") + print("In this mode, LVGL won't auto-insert characters") + print("Only our custom handler should insert characters") + print("This should prevent double characters") + + # Verify keyboard exists + self.assertIsNotNone(keyboard) + print("SUCCESS: Can create keyboard without set_textarea") + + def test_set_textarea_stores_reference(self): + """ + Test that set_textarea stores the textarea reference internally. + + This is the FIX for the double-character bug. MposKeyboard stores + the textarea reference itself and does NOT pass it to the underlying + LVGL keyboard widget. This prevents LVGL's auto-insertion which + would cause double characters. + """ + print("\n=== Testing set_textarea stores reference correctly ===") + + textarea = lv.textarea(self.screen) + textarea.set_size(200, 30) + textarea.set_one_line(True) + textarea.align(lv.ALIGN.TOP_MID, 0, 10) + wait_for_render(5) + + keyboard = MposKeyboard(self.screen) + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + wait_for_render(5) + + # Initially no textarea + self.assertIsNone(keyboard.get_textarea(), + "Keyboard should have no textarea initially") + + # Set the textarea + keyboard.set_textarea(textarea) + wait_for_render(2) + + # Verify it's stored in our reference + self.assertEqual(keyboard.get_textarea(), textarea, + "get_textarea() should return our textarea") + + # Verify the internal storage + self.assertTrue(hasattr(keyboard, '_textarea'), + "Keyboard should have _textarea attribute") + self.assertEqual(keyboard._textarea, textarea, + "Internal _textarea should be our textarea") + + print("SUCCESS: set_textarea stores reference correctly") + print("This prevents LVGL auto-insertion and fixes double-character bug!") + + diff --git a/tests/test_intent.py b/tests/test_intent.py new file mode 100644 index 00000000..34ef5de5 --- /dev/null +++ b/tests/test_intent.py @@ -0,0 +1,304 @@ +import unittest +from mpos.content.intent import Intent + + +class TestIntent(unittest.TestCase): + """Test suite for Intent class.""" + + # ============================================================ + # Intent Construction + # ============================================================ + + def test_empty_intent(self): + """Test creating an empty intent.""" + intent = Intent() + self.assertIsNone(intent.activity_class) + self.assertIsNone(intent.action) + self.assertIsNone(intent.data) + self.assertEqual(intent.extras, {}) + self.assertEqual(intent.flags, {}) + + def test_intent_with_activity_class(self): + """Test creating an intent with an explicit activity class.""" + class MockActivity: + pass + + intent = Intent(activity_class=MockActivity) + self.assertEqual(intent.activity_class, MockActivity) + self.assertIsNone(intent.action) + + def test_intent_with_action(self): + """Test creating an intent with an action.""" + intent = Intent(action="view") + self.assertEqual(intent.action, "view") + self.assertIsNone(intent.activity_class) + + def test_intent_with_data(self): + """Test creating an intent with data.""" + intent = Intent(data="https://example.com") + self.assertEqual(intent.data, "https://example.com") + + def test_intent_with_extras(self): + """Test creating an intent with extras dictionary.""" + extras = {"user_id": 123, "username": "alice"} + intent = Intent(extras=extras) + self.assertEqual(intent.extras, extras) + + def test_intent_with_all_parameters(self): + """Test creating an intent with all parameters.""" + class MockActivity: + pass + + extras = {"key": "value"} + intent = Intent( + activity_class=MockActivity, + action="share", + data="some_data", + extras=extras + ) + + self.assertEqual(intent.activity_class, MockActivity) + self.assertEqual(intent.action, "share") + self.assertEqual(intent.data, "some_data") + self.assertEqual(intent.extras, extras) + + # ============================================================ + # Extras Operations + # ============================================================ + + def test_put_extra_single(self): + """Test adding a single extra to an intent.""" + intent = Intent() + intent.putExtra("key", "value") + self.assertEqual(intent.extras["key"], "value") + + def test_put_extra_multiple(self): + """Test adding multiple extras to an intent.""" + intent = Intent() + intent.putExtra("key1", "value1") + intent.putExtra("key2", 42) + intent.putExtra("key3", True) + + self.assertEqual(intent.extras["key1"], "value1") + self.assertEqual(intent.extras["key2"], 42) + self.assertTrue(intent.extras["key3"]) + + def test_put_extra_chaining(self): + """Test that putExtra returns self for method chaining.""" + intent = Intent() + result = intent.putExtra("key", "value") + self.assertEqual(result, intent) + + # Test actual chaining + intent.putExtra("a", 1).putExtra("b", 2).putExtra("c", 3) + self.assertEqual(intent.extras["a"], 1) + self.assertEqual(intent.extras["b"], 2) + self.assertEqual(intent.extras["c"], 3) + + def test_put_extra_overwrites(self): + """Test that putting an extra with the same key overwrites the value.""" + intent = Intent() + intent.putExtra("key", "original") + intent.putExtra("key", "updated") + self.assertEqual(intent.extras["key"], "updated") + + def test_put_extra_various_types(self): + """Test putting extras of various data types.""" + intent = Intent() + intent.putExtra("string", "text") + intent.putExtra("int", 123) + intent.putExtra("float", 3.14) + intent.putExtra("bool", True) + intent.putExtra("list", [1, 2, 3]) + intent.putExtra("dict", {"nested": "value"}) + intent.putExtra("none", None) + + self.assertEqual(intent.extras["string"], "text") + self.assertEqual(intent.extras["int"], 123) + self.assertAlmostEqual(intent.extras["float"], 3.14) + self.assertTrue(intent.extras["bool"]) + self.assertEqual(intent.extras["list"], [1, 2, 3]) + self.assertEqual(intent.extras["dict"]["nested"], "value") + self.assertIsNone(intent.extras["none"]) + + # ============================================================ + # Flag Operations + # ============================================================ + + def test_add_flag_single(self): + """Test adding a single flag to an intent.""" + intent = Intent() + intent.addFlag("clear_top") + self.assertTrue(intent.flags["clear_top"]) + + def test_add_flag_with_value(self): + """Test adding a flag with a specific value.""" + intent = Intent() + intent.addFlag("no_history", False) + self.assertFalse(intent.flags["no_history"]) + + intent.addFlag("no_animation", True) + self.assertTrue(intent.flags["no_animation"]) + + def test_add_flag_chaining(self): + """Test that addFlag returns self for method chaining.""" + intent = Intent() + result = intent.addFlag("clear_top") + self.assertEqual(result, intent) + + # Test actual chaining + intent.addFlag("clear_top").addFlag("no_history").addFlag("no_animation") + self.assertTrue(intent.flags["clear_top"]) + self.assertTrue(intent.flags["no_history"]) + self.assertTrue(intent.flags["no_animation"]) + + def test_add_flag_overwrites(self): + """Test that adding a flag with the same name overwrites the value.""" + intent = Intent() + intent.addFlag("flag", True) + intent.addFlag("flag", False) + self.assertFalse(intent.flags["flag"]) + + def test_multiple_flags(self): + """Test adding multiple different flags.""" + intent = Intent() + intent.addFlag("clear_top", True) + intent.addFlag("no_history", False) + intent.addFlag("custom_flag", True) + + self.assertEqual(len(intent.flags), 3) + self.assertTrue(intent.flags["clear_top"]) + self.assertFalse(intent.flags["no_history"]) + self.assertTrue(intent.flags["custom_flag"]) + + # ============================================================ + # Combined Operations + # ============================================================ + + def test_chaining_extras_and_flags(self): + """Test chaining both extras and flags together.""" + intent = Intent(action="view") + intent.putExtra("user_id", 123)\ + .putExtra("username", "alice")\ + .addFlag("clear_top")\ + .addFlag("no_history") + + self.assertEqual(intent.action, "view") + self.assertEqual(intent.extras["user_id"], 123) + self.assertEqual(intent.extras["username"], "alice") + self.assertTrue(intent.flags["clear_top"]) + self.assertTrue(intent.flags["no_history"]) + + def test_intent_builder_pattern(self): + """Test using intent as a builder pattern.""" + class MockActivity: + pass + + intent = Intent()\ + .putExtra("key1", "value1")\ + .putExtra("key2", 42)\ + .addFlag("clear_top")\ + .addFlag("no_animation", False) + + # Modify after initial creation + intent.activity_class = MockActivity + intent.action = "custom_action" + intent.data = "custom_data" + + self.assertEqual(intent.activity_class, MockActivity) + self.assertEqual(intent.action, "custom_action") + self.assertEqual(intent.data, "custom_data") + self.assertEqual(intent.extras["key1"], "value1") + self.assertEqual(intent.extras["key2"], 42) + self.assertTrue(intent.flags["clear_top"]) + self.assertFalse(intent.flags["no_animation"]) + + # ============================================================ + # Common Intent Patterns + # ============================================================ + + def test_view_intent_pattern(self): + """Test creating a typical 'view' intent.""" + intent = Intent(action="view", data="https://micropythonos.com") + intent.putExtra("fullscreen", True) + + self.assertEqual(intent.action, "view") + self.assertEqual(intent.data, "https://micropythonos.com") + self.assertTrue(intent.extras["fullscreen"]) + + def test_share_intent_pattern(self): + """Test creating a typical 'share' intent.""" + intent = Intent(action="share") + intent.putExtra("text", "Check out MicroPythonOS!") + intent.putExtra("subject", "Cool OS") + + self.assertEqual(intent.action, "share") + self.assertEqual(intent.extras["text"], "Check out MicroPythonOS!") + self.assertEqual(intent.extras["subject"], "Cool OS") + + def test_launcher_intent_pattern(self): + """Test creating a typical launcher intent.""" + intent = Intent(action="main") + intent.addFlag("clear_top") + + self.assertEqual(intent.action, "main") + self.assertTrue(intent.flags["clear_top"]) + + def test_scan_qr_intent_pattern(self): + """Test creating a scan QR code intent (from camera app).""" + intent = Intent(action="scan_qr_code") + intent.putExtra("result_key", "qr_data") + + self.assertEqual(intent.action, "scan_qr_code") + self.assertEqual(intent.extras["result_key"], "qr_data") + + # ============================================================ + # Edge Cases + # ============================================================ + + def test_empty_strings(self): + """Test intent with empty strings.""" + intent = Intent(action="", data="") + intent.putExtra("empty", "") + + self.assertEqual(intent.action, "") + self.assertEqual(intent.data, "") + self.assertEqual(intent.extras["empty"], "") + + def test_special_characters_in_extras(self): + """Test extras with special characters in keys.""" + intent = Intent() + intent.putExtra("key.with.dots", "value1") + intent.putExtra("key_with_underscores", "value2") + intent.putExtra("key-with-dashes", "value3") + + self.assertEqual(intent.extras["key.with.dots"], "value1") + self.assertEqual(intent.extras["key_with_underscores"], "value2") + self.assertEqual(intent.extras["key-with-dashes"], "value3") + + def test_unicode_in_extras(self): + """Test extras with Unicode strings.""" + intent = Intent() + intent.putExtra("greeting", "Hello 世界") + intent.putExtra("emoji", "🚀") + + self.assertEqual(intent.extras["greeting"], "Hello 世界") + self.assertEqual(intent.extras["emoji"], "🚀") + + def test_complex_extras_data(self): + """Test extras with complex nested data structures.""" + intent = Intent() + complex_data = { + "users": ["alice", "bob"], + "config": { + "timeout": 30, + "retry": True + } + } + intent.putExtra("data", complex_data) + + self.assertEqual(intent.extras["data"]["users"][0], "alice") + self.assertEqual(intent.extras["data"]["config"]["timeout"], 30) + self.assertTrue(intent.extras["data"]["config"]["retry"]) + + diff --git a/tests/test_lightsmanager.py b/tests/test_lightsmanager.py new file mode 100644 index 00000000..016ccf6b --- /dev/null +++ b/tests/test_lightsmanager.py @@ -0,0 +1,126 @@ +# Unit tests for LightsManager service +import unittest +import sys + + +# Mock hardware before importing LightsManager +class MockPin: + IN = 0 + OUT = 1 + + def __init__(self, pin_number, mode=None): + self.pin_number = pin_number + self.mode = mode + + +class MockNeoPixel: + def __init__(self, pin, num_leds): + self.pin = pin + self.num_leds = num_leds + self.pixels = [(0, 0, 0)] * num_leds + self.write_count = 0 + + def __setitem__(self, index, value): + if 0 <= index < self.num_leds: + self.pixels[index] = value + + def __getitem__(self, index): + if 0 <= index < self.num_leds: + return self.pixels[index] + return (0, 0, 0) + + def write(self): + self.write_count += 1 + + +# Inject mocks +sys.modules['machine'] = type('module', (), {'Pin': MockPin})() +sys.modules['neopixel'] = type('module', (), {'NeoPixel': MockNeoPixel})() + + +# Now import the module to test +import mpos.lights as LightsManager + + +class TestLightsManager(unittest.TestCase): + """Test cases for LightsManager service.""" + + def setUp(self): + """Initialize LightsManager before each test.""" + LightsManager.init(neopixel_pin=12, num_leds=5) + + def test_initialization(self): + """Test that LightsManager initializes correctly.""" + self.assertTrue(LightsManager.is_available()) + self.assertEqual(LightsManager.get_led_count(), 5) + + def test_set_single_led(self): + """Test setting a single LED color.""" + result = LightsManager.set_led(0, 255, 0, 0) + self.assertTrue(result) + + # Verify color was set (via internal _neopixel mock) + neopixel = LightsManager._neopixel + self.assertEqual(neopixel[0], (255, 0, 0)) + + def test_set_led_invalid_index(self): + """Test that invalid LED indices are rejected.""" + # Negative index + result = LightsManager.set_led(-1, 255, 0, 0) + self.assertFalse(result) + + # Index too large + result = LightsManager.set_led(10, 255, 0, 0) + self.assertFalse(result) + + def test_set_all_leds(self): + """Test setting all LEDs to same color.""" + result = LightsManager.set_all(0, 255, 0) + self.assertTrue(result) + + # Verify all LEDs were set + neopixel = LightsManager._neopixel + for i in range(5): + self.assertEqual(neopixel[i], (0, 255, 0)) + + def test_clear(self): + """Test clearing all LEDs.""" + # First set some colors + LightsManager.set_all(255, 255, 255) + + # Then clear + result = LightsManager.clear() + self.assertTrue(result) + + # Verify all LEDs are black + neopixel = LightsManager._neopixel + for i in range(5): + self.assertEqual(neopixel[i], (0, 0, 0)) + + def test_write(self): + """Test that write() updates hardware.""" + neopixel = LightsManager._neopixel + initial_count = neopixel.write_count + + result = LightsManager.write() + self.assertTrue(result) + + # Verify write was called + self.assertEqual(neopixel.write_count, initial_count + 1) + + def test_notification_colors(self): + """Test convenience notification color method.""" + # Valid colors + self.assertTrue(LightsManager.set_notification_color("red")) + self.assertTrue(LightsManager.set_notification_color("green")) + self.assertTrue(LightsManager.set_notification_color("blue")) + + # Invalid color + result = LightsManager.set_notification_color("invalid_color") + self.assertFalse(result) + + def test_case_insensitive_colors(self): + """Test that color names are case-insensitive.""" + self.assertTrue(LightsManager.set_notification_color("RED")) + self.assertTrue(LightsManager.set_notification_color("Green")) + self.assertTrue(LightsManager.set_notification_color("BLUE")) diff --git a/tests/test_multi_connect.py b/tests/test_multi_connect.py new file mode 100644 index 00000000..1559f7c4 --- /dev/null +++ b/tests/test_multi_connect.py @@ -0,0 +1,255 @@ +import unittest +import _thread +import time + +from mpos import App, PackageManager +import mpos.apps + +from websocket import WebSocketApp + + +# demo_multiple_ws.py +import asyncio +import aiohttp +from aiohttp import WSMsgType +import logging +import sys +from typing import List + + + +# ---------------------------------------------------------------------- +# Logging +# ---------------------------------------------------------------------- +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + stream=sys.stdout, +) +log = logging.getLogger(__name__) + + +class TestTwoWebsockets(unittest.TestCase): +#class TestTwoWebsockets(): + + # ---------------------------------------------------------------------- + # Configuration + # ---------------------------------------------------------------------- + # Change these to point to a real echo / chat server you control. + WS_URLS = [ + "wss://echo.websocket.org", # public echo service (may be down) + "wss://echo.websocket.org", # duplicate on purpose – shows concurrency + "wss://echo.websocket.org", + # add more URLs here… + ] + + nr_connected = 0 + + # How many messages each connection should send before closing gracefully + MESSAGES_PER_CONNECTION = 2 + STOP_AFTER = 10 + + # ---------------------------------------------------------------------- + # One connection worker + # ---------------------------------------------------------------------- + async def ws_worker(self, session: aiohttp.ClientSession, url: str, idx: int) -> None: + """ + Handles a single WebSocket connection: + * sends a few messages, + * echoes back everything it receives, + * closes when the remote end says "close" or after MESSAGES_PER_CONNECTION. + """ + try: + async with session.ws_connect(url) as ws: + log.info(f"[{idx}] Connected to {url}") + self.nr_connected += 1 + + # ------------------------------------------------------------------ + # 1. Send a few starter messages + # ------------------------------------------------------------------ + for i in range(self.MESSAGES_PER_CONNECTION): + payload = f"Hello from client #{idx} – msg {i+1}" + await ws.send_str(payload) + log.info(f"[{idx}] → {payload}") + + # give the server a moment to reply + await asyncio.sleep(0.5) + + # ------------------------------------------------------------------ + # 2. Echo-loop – react to incoming messages + # ------------------------------------------------------------------ + msgcounter = 0 + async for msg in ws: + msgcounter += 1 + if msgcounter > self.STOP_AFTER: + print("Max reached, stopping...") + await ws.close() + break + if msg.type == WSMsgType.TEXT: + data: str = msg.data + log.info(f"[{idx}] ← {data}") + + # Echo back (with a suffix) + reply = data + " / answer" + await ws.send_str(reply) + log.info(f"[{idx}] → {reply}") + + # Close if server asks us to + if data.strip().lower() == "close cmd": + log.info(f"[{idx}] Server asked to close → closing") + await ws.close() + break + + elif msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSING, WSMsgType.CLOSED): + log.info(f"[{idx}] Connection closed by remote") + break + + elif msg.type == WSMsgType.ERROR: + log.error(f"[{idx}] WebSocket error: {ws.exception()}") + break + + except asyncio.CancelledError: + log.info(f"[{idx}] Task cancelled") + raise + except Exception as exc: + log.exception(f"[{idx}] Unexpected error on {url}: {exc}") + finally: + log.info(f"[{idx}] Worker finished for {url}") + + # ---------------------------------------------------------------------- + # Main entry point – creates a single ClientSession + many tasks + # ---------------------------------------------------------------------- + async def main(self) -> None: + async with aiohttp.ClientSession() as session: + # Create one task per URL (they all run concurrently) + tasks = [ + asyncio.create_task(self.ws_worker(session, url, idx)) + for idx, url in enumerate(self.WS_URLS) + ] + + log.info(f"Starting {len(tasks)} concurrent WebSocket connections…") + # Wait for *all* of them to finish (or be cancelled) + await asyncio.gather(*tasks, return_exceptions=True) + log.info(f"All tasks stopped successfully!") + self.assertTrue(self.nr_connected, len(self.WS_URLS)) + + def newthread(self): + asyncio.run(self.main()) + + def test_it(self): + _thread.stack_size(mpos.apps.good_stack_size()) + _thread.start_new_thread(self.newthread, ()) + time.sleep(10) + +# This demonstrates a crash when doing asyncio using different threads: +#class TestCrashingSeparateThreads(unittest.TestCase): +class TestCrashingSeparateThreads(): + + # ---------------------------------------------------------------------- + # Configuration + # ---------------------------------------------------------------------- + # Change these to point to a real echo / chat server you control. + WS_URLS = [ + "wss://echo.websocket.org", # public echo service (may be down) + "wss://echo.websocket.org", # duplicate on purpose – shows concurrency + "wss://echo.websocket.org", + # add more URLs here… + ] + + # How many messages each connection should send before closing gracefully + MESSAGES_PER_CONNECTION = 2 + STOP_AFTER = 10 + + # ---------------------------------------------------------------------- + # One connection worker + # ---------------------------------------------------------------------- + async def ws_worker(self, session: aiohttp.ClientSession, url: str, idx: int) -> None: + """ + Handles a single WebSocket connection: + * sends a few messages, + * echoes back everything it receives, + * closes when the remote end says "close" or after MESSAGES_PER_CONNECTION. + """ + try: + async with session.ws_connect(url) as ws: + log.info(f"[{idx}] Connected to {url}") + + # ------------------------------------------------------------------ + # 1. Send a few starter messages + # ------------------------------------------------------------------ + for i in range(self.MESSAGES_PER_CONNECTION): + payload = f"Hello from client #{idx} – msg {i+1}" + await ws.send_str(payload) + log.info(f"[{idx}] → {payload}") + + # give the server a moment to reply + await asyncio.sleep(0.5) + + # ------------------------------------------------------------------ + # 2. Echo-loop – react to incoming messages + # ------------------------------------------------------------------ + msgcounter = 0 + async for msg in ws: + msgcounter += 1 + if msgcounter > self.STOP_AFTER: + print("Max reached, stopping...") + await ws.close() + break + if msg.type == WSMsgType.TEXT: + data: str = msg.data + log.info(f"[{idx}] ← {data}") + + # Echo back (with a suffix) + reply = data + " / answer" + await ws.send_str(reply) + log.info(f"[{idx}] → {reply}") + + # Close if server asks us to + if data.strip().lower() == "close cmd": + log.info(f"[{idx}] Server asked to close → closing") + await ws.close() + break + + elif msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSING, WSMsgType.CLOSED): + log.info(f"[{idx}] Connection closed by remote") + break + + elif msg.type == WSMsgType.ERROR: + log.error(f"[{idx}] WebSocket error: {ws.exception()}") + break + + except asyncio.CancelledError: + log.info(f"[{idx}] Task cancelled") + raise + except Exception as exc: + log.exception(f"[{idx}] Unexpected error on {url}: {exc}") + finally: + log.info(f"[{idx}] Worker finished for {url}") + + # ---------------------------------------------------------------------- + # Main entry point – creates a single ClientSession + many tasks + # ---------------------------------------------------------------------- + async def main(self) -> None: + async with aiohttp.ClientSession() as session: + # Create one task per URL (they all run concurrently) + tasks = [ + asyncio.create_task(self.ws_worker(session, url, idx)) + for idx, url in enumerate(self.WS_URLS) + ] + + log.info(f"Starting {len(tasks)} concurrent WebSocket connections…") + # Wait for *all* of them to finish (or be cancelled) + await asyncio.gather(*tasks, return_exceptions=True) + + async def almostmain(self, url): + async with aiohttp.ClientSession() as session: + asyncio.create_task(self.ws_worker(session, url, idx)) + + def newthread(self, url): + asyncio.run(self.main()) + + def test_it(self): + for url in self.WS_URLS: + _thread.stack_size(mpos.apps.good_stack_size()) + _thread.start_new_thread(self.newthread, (url,)) + time.sleep(15) diff --git a/tests/test_osupdate.py b/tests/test_osupdate.py new file mode 100644 index 00000000..88687edd --- /dev/null +++ b/tests/test_osupdate.py @@ -0,0 +1,493 @@ +import unittest +import sys +import asyncio + +# Add parent directory to path so we can import network_test_helper +# When running from unittest.sh, we're in internal_filesystem/, so tests/ is ../tests/ +sys.path.insert(0, '../tests') + +# Import network test helpers +from network_test_helper import MockNetwork, MockRequests, MockJSON, MockDownloadManager + + +class MockPartition: + """Mock ESP32 Partition for testing UpdateDownloader.""" + + RUNNING = 0 + + def __init__(self, partition_type=None): + self.partition_type = partition_type + self.blocks = {} # Store written blocks + self.boot_set = False + + def get_next_update(self): + """Return a mock OTA partition.""" + return MockPartition() + + def writeblocks(self, block_num, data): + """Mock writing blocks.""" + self.blocks[block_num] = data + + def set_boot(self): + """Mock setting boot partition.""" + self.boot_set = True + + +# Import PackageManager which is needed by UpdateChecker +# The test runs from internal_filesystem/ directory, so we can import from lib/mpos +from mpos import PackageManager + +# Import the actual classes we're testing +# Tests run from internal_filesystem/, so we add the assets directory to path +sys.path.append('builtin/apps/com.micropythonos.osupdate/assets') +from osupdate import UpdateChecker, UpdateDownloader, round_up_to_multiple + + +def run_async(coro): + """Helper to run async coroutines in sync tests.""" + return asyncio.get_event_loop().run_until_complete(coro) + + +class TestUpdateChecker(unittest.TestCase): + """Test UpdateChecker class.""" + + def setUp(self): + self.mock_requests = MockRequests() + self.mock_json = MockJSON() + self.checker = UpdateChecker( + requests_module=self.mock_requests, + json_module=self.mock_json + ) + + def test_get_update_url_waveshare(self): + """Test URL generation for waveshare hardware.""" + url = self.checker.get_update_url("waveshare_esp32_s3_touch_lcd_2") + + self.assertEqual(url, "https://updates.micropythonos.com/osupdate.json") + + def test_get_update_url_other_hardware(self): + """Test URL generation for other hardware.""" + url = self.checker.get_update_url("fri3d_2024") + + self.assertEqual(url, "https://updates.micropythonos.com/osupdate_fri3d_2024.json") + + def test_fetch_update_info_success(self): + """Test successful update info fetch.""" + import json + update_data = { + "version": "0.3.3", + "download_url": "https://example.com/update.bin", + "changelog": "Bug fixes" + } + self.mock_requests.set_next_response( + status_code=200, + text=json.dumps(update_data) + ) + + result = self.checker.fetch_update_info("waveshare_esp32_s3_touch_lcd_2") + + self.assertEqual(result["version"], "0.3.3") + self.assertEqual(result["download_url"], "https://example.com/update.bin") + self.assertEqual(result["changelog"], "Bug fixes") + + def test_fetch_update_info_http_error(self): + """Test fetch with HTTP error response.""" + self.mock_requests.set_next_response(status_code=404) + + # MicroPython doesn't have ConnectionError, so catch generic Exception + try: + self.checker.fetch_update_info("waveshare_esp32_s3_touch_lcd_2") + self.fail("Should have raised an exception for HTTP 404") + except Exception as e: + # Should be a ConnectionError, but we accept any exception with HTTP status + self.assertIn("404", str(e)) + + def test_fetch_update_info_invalid_json(self): + """Test fetch with invalid JSON.""" + self.mock_requests.set_next_response( + status_code=200, + text="not valid json {" + ) + + with self.assertRaises(ValueError) as cm: + self.checker.fetch_update_info("waveshare_esp32_s3_touch_lcd_2") + + self.assertIn("Invalid JSON", str(cm.exception)) + + def test_fetch_update_info_missing_version_field(self): + """Test fetch with missing version field.""" + import json + self.mock_requests.set_next_response( + status_code=200, + text=json.dumps({"download_url": "http://example.com", "changelog": "test"}) + ) + + with self.assertRaises(ValueError) as cm: + self.checker.fetch_update_info("waveshare_esp32_s3_touch_lcd_2") + + self.assertIn("missing required fields", str(cm.exception)) + self.assertIn("version", str(cm.exception)) + + def test_fetch_update_info_missing_download_url_field(self): + """Test fetch with missing download_url field.""" + import json + self.mock_requests.set_next_response( + status_code=200, + text=json.dumps({"version": "1.0.0", "changelog": "test"}) + ) + + with self.assertRaises(ValueError) as cm: + self.checker.fetch_update_info("waveshare_esp32_s3_touch_lcd_2") + + self.assertIn("download_url", str(cm.exception)) + + def test_is_update_available_newer_version(self): + """Test that newer version is detected.""" + result = self.checker.is_update_available("1.2.3", "1.2.2") + + self.assertTrue(result) + + def test_is_update_available_same_version(self): + """Test that same version is not an update.""" + result = self.checker.is_update_available("1.2.3", "1.2.3") + + self.assertFalse(result) + + def test_is_update_available_older_version(self): + """Test that older version is not an update.""" + result = self.checker.is_update_available("1.2.2", "1.2.3") + + self.assertFalse(result) + + def test_fetch_update_info_timeout(self): + """Test fetch with request timeout.""" + self.mock_requests.set_exception(Exception("Timeout")) + + try: + self.checker.fetch_update_info("waveshare_esp32_s3_touch_lcd_2") + self.fail("Should have raised an exception for timeout") + except Exception as e: + self.assertIn("Timeout", str(e)) + + def test_fetch_update_info_connection_refused(self): + """Test fetch with connection refused.""" + self.mock_requests.set_exception(Exception("Connection refused")) + + try: + self.checker.fetch_update_info("waveshare_esp32_s3_touch_lcd_2") + self.fail("Should have raised an exception") + except Exception as e: + self.assertIn("Connection refused", str(e)) + + def test_fetch_update_info_empty_response(self): + """Test fetch with empty response.""" + self.mock_requests.set_next_response(status_code=200, text='') + + try: + self.checker.fetch_update_info("waveshare_esp32_s3_touch_lcd_2") + self.fail("Should have raised an exception for empty response") + except Exception: + pass # Expected to fail + + def test_fetch_update_info_server_error_500(self): + """Test fetch with 500 server error.""" + self.mock_requests.set_next_response(status_code=500) + + try: + self.checker.fetch_update_info("waveshare_esp32_s3_touch_lcd_2") + self.fail("Should have raised an exception for HTTP 500") + except Exception as e: + self.assertIn("500", str(e)) + + def test_fetch_update_info_missing_changelog(self): + """Test fetch with missing changelog field.""" + import json + self.mock_requests.set_next_response( + status_code=200, + text=json.dumps({"version": "1.0.0", "download_url": "http://example.com"}) + ) + + try: + self.checker.fetch_update_info("waveshare_esp32_s3_touch_lcd_2") + self.fail("Should have raised exception for missing changelog") + except ValueError as e: + self.assertIn("changelog", str(e)) + + def test_get_update_url_custom_hardware(self): + """Test URL generation for custom hardware IDs.""" + # Test with different hardware IDs + url1 = self.checker.get_update_url("custom-device-v1") + self.assertEqual(url1, "https://updates.micropythonos.com/osupdate_custom-device-v1.json") + + url2 = self.checker.get_update_url("test-123") + self.assertEqual(url2, "https://updates.micropythonos.com/osupdate_test-123.json") + + +class TestUpdateDownloader(unittest.TestCase): + """Test UpdateDownloader class with async DownloadManager.""" + + def setUp(self): + self.mock_download_manager = MockDownloadManager() + self.mock_partition = MockPartition + self.downloader = UpdateDownloader( + partition_module=self.mock_partition, + download_manager=self.mock_download_manager + ) + + def test_download_and_install_success(self): + """Test successful download and install.""" + # Create 8KB of test data (2 blocks of 4096 bytes) + test_data = b'A' * 8192 + self.mock_download_manager.set_download_data(test_data) + self.mock_download_manager.chunk_size = 4096 + + progress_calls = [] + async def progress_cb(percent): + progress_calls.append(percent) + + async def run_test(): + return await self.downloader.download_and_install( + "http://example.com/update.bin", + progress_callback=progress_cb + ) + + result = run_async(run_test()) + + self.assertTrue(result['success']) + self.assertEqual(result['bytes_written'], 8192) + self.assertIsNone(result['error']) + # MicroPython unittest doesn't have assertGreater + self.assertTrue(len(progress_calls) > 0, "Should have progress callbacks") + + def test_download_and_install_cancelled(self): + """Test cancelled download.""" + test_data = b'A' * 8192 + self.mock_download_manager.set_download_data(test_data) + self.mock_download_manager.chunk_size = 4096 + + call_count = [0] + def should_continue(): + call_count[0] += 1 + return call_count[0] < 2 # Cancel after first chunk + + async def run_test(): + return await self.downloader.download_and_install( + "http://example.com/update.bin", + should_continue_callback=should_continue + ) + + result = run_async(run_test()) + + self.assertFalse(result['success']) + self.assertIn("cancelled", result['error'].lower()) + + def test_download_with_padding(self): + """Test that last chunk is properly padded.""" + # 5000 bytes - not a multiple of 4096 + test_data = b'B' * 5000 + self.mock_download_manager.set_download_data(test_data) + self.mock_download_manager.chunk_size = 4096 + + async def run_test(): + return await self.downloader.download_and_install( + "http://example.com/update.bin" + ) + + result = run_async(run_test()) + + self.assertTrue(result['success']) + # Should be padded to 8192 (2 * 4096) + self.assertEqual(result['bytes_written'], 8192) + + def test_download_with_network_error(self): + """Test download with network error during transfer.""" + self.mock_download_manager.set_should_fail(True) + + async def run_test(): + return await self.downloader.download_and_install( + "http://example.com/update.bin" + ) + + result = run_async(run_test()) + + self.assertFalse(result['success']) + self.assertIsNotNone(result['error']) + + def test_download_with_zero_content_length(self): + """Test download with missing or zero Content-Length.""" + test_data = b'C' * 1000 + self.mock_download_manager.set_download_data(test_data) + self.mock_download_manager.chunk_size = 1000 + + async def run_test(): + return await self.downloader.download_and_install( + "http://example.com/update.bin" + ) + + result = run_async(run_test()) + + # Should still work, just with unknown total size initially + self.assertTrue(result['success']) + + def test_download_progress_callback_called(self): + """Test that progress callback is called during download.""" + test_data = b'D' * 8192 + self.mock_download_manager.set_download_data(test_data) + self.mock_download_manager.chunk_size = 4096 + + progress_values = [] + async def track_progress(percent): + progress_values.append(percent) + + async def run_test(): + return await self.downloader.download_and_install( + "http://example.com/update.bin", + progress_callback=track_progress + ) + + result = run_async(run_test()) + + self.assertTrue(result['success']) + # Should have at least 2 progress updates (for 2 chunks of 4096) + self.assertTrue(len(progress_values) >= 2) + # Last progress should be 100% + self.assertEqual(progress_values[-1], 100) + + def test_download_small_file(self): + """Test downloading a file smaller than one chunk.""" + test_data = b'E' * 100 # Only 100 bytes + self.mock_download_manager.set_download_data(test_data) + self.mock_download_manager.chunk_size = 100 + + async def run_test(): + return await self.downloader.download_and_install( + "http://example.com/update.bin" + ) + + result = run_async(run_test()) + + self.assertTrue(result['success']) + # Should be padded to 4096 + self.assertEqual(result['bytes_written'], 4096) + + def test_download_exact_chunk_multiple(self): + """Test downloading exactly 2 chunks (no padding needed).""" + test_data = b'F' * 8192 # Exactly 2 * 4096 + self.mock_download_manager.set_download_data(test_data) + self.mock_download_manager.chunk_size = 4096 + + async def run_test(): + return await self.downloader.download_and_install( + "http://example.com/update.bin" + ) + + result = run_async(run_test()) + + self.assertTrue(result['success']) + self.assertEqual(result['bytes_written'], 8192) + + def test_network_error_detection_econnaborted(self): + """Test that ECONNABORTED error is detected as network error.""" + error = OSError(-113, "ECONNABORTED") + self.assertTrue(self.downloader._is_network_error(error)) + + def test_network_error_detection_econnreset(self): + """Test that ECONNRESET error is detected as network error.""" + error = OSError(-104, "ECONNRESET") + self.assertTrue(self.downloader._is_network_error(error)) + + def test_network_error_detection_etimedout(self): + """Test that ETIMEDOUT error is detected as network error.""" + error = OSError(-110, "ETIMEDOUT") + self.assertTrue(self.downloader._is_network_error(error)) + + def test_network_error_detection_ehostunreach(self): + """Test that EHOSTUNREACH error is detected as network error.""" + error = OSError(-118, "EHOSTUNREACH") + self.assertTrue(self.downloader._is_network_error(error)) + + def test_network_error_detection_by_message(self): + """Test that network errors are detected by message.""" + self.assertTrue(self.downloader._is_network_error(Exception("Connection reset by peer"))) + self.assertTrue(self.downloader._is_network_error(Exception("Connection aborted"))) + self.assertTrue(self.downloader._is_network_error(Exception("Broken pipe"))) + + def test_non_network_error_not_detected(self): + """Test that non-network errors are not detected as network errors.""" + self.assertFalse(self.downloader._is_network_error(ValueError("Invalid data"))) + self.assertFalse(self.downloader._is_network_error(Exception("File not found"))) + self.assertFalse(self.downloader._is_network_error(KeyError("missing"))) + + def test_download_pauses_on_network_error_during_read(self): + """Test that download pauses when network error occurs during read.""" + # Set up mock to raise network error after first chunk + test_data = b'G' * 16384 # 4 chunks + self.mock_download_manager.set_download_data(test_data) + self.mock_download_manager.chunk_size = 4096 + self.mock_download_manager.set_fail_after_bytes(4096) # Fail after first chunk + + async def run_test(): + return await self.downloader.download_and_install( + "http://example.com/update.bin" + ) + + result = run_async(run_test()) + + self.assertFalse(result['success']) + self.assertTrue(result['paused']) + self.assertEqual(result['bytes_written'], 4096) # Should have written first chunk + self.assertIsNone(result['error']) # Pause, not error + + def test_download_resumes_from_saved_position(self): + """Test that download resumes from the last written position.""" + # Simulate partial download + self.downloader.bytes_written_so_far = 8192 # Already downloaded 2 chunks + self.downloader.total_size_expected = 12288 + + # Server should receive Range header - only remaining data + remaining_data = b'H' * 4096 # Last chunk + self.mock_download_manager.set_download_data(remaining_data) + self.mock_download_manager.chunk_size = 4096 + + async def run_test(): + return await self.downloader.download_and_install( + "http://example.com/update.bin" + ) + + result = run_async(run_test()) + + self.assertTrue(result['success']) + self.assertEqual(result['bytes_written'], 12288) + # Check that Range header was set + self.assertIsNotNone(self.mock_download_manager.headers_received) + self.assertIn('Range', self.mock_download_manager.headers_received) + self.assertEqual(self.mock_download_manager.headers_received['Range'], 'bytes=8192-') + + def test_resume_failure_preserves_state(self): + """Test that resume failures preserve download state for retry.""" + # Simulate partial download state + self.downloader.bytes_written_so_far = 245760 # 60 chunks already downloaded + self.downloader.total_size_expected = 3391488 + + # Resume attempt fails immediately with network error + self.mock_download_manager.set_download_data(b'') + self.mock_download_manager.set_fail_after_bytes(0) # Fail immediately + + async def run_test(): + return await self.downloader.download_and_install( + "http://example.com/update.bin" + ) + + result = run_async(run_test()) + + # Should pause, not fail + self.assertFalse(result['success']) + self.assertTrue(result['paused']) + self.assertIsNone(result['error']) + + # Critical: Must preserve progress for next retry + self.assertEqual(result['bytes_written'], 245760, "Must preserve bytes_written") + self.assertEqual(result['total_size'], 3391488, "Must preserve total_size") + self.assertEqual(self.downloader.bytes_written_so_far, 245760, "Must preserve internal state") + + diff --git a/tests/package_manager.py b/tests/test_package_manager.py similarity index 100% rename from tests/package_manager.py rename to tests/test_package_manager.py diff --git a/tests/test_rtttl.py b/tests/test_rtttl.py new file mode 100644 index 00000000..07dbc801 --- /dev/null +++ b/tests/test_rtttl.py @@ -0,0 +1,173 @@ +# Unit tests for RTTTL parser (RTTTLStream) +import unittest +import sys + + +# Mock hardware before importing +class MockPWM: + def __init__(self, pin, freq=0, duty=0): + self.pin = pin + self.last_freq = freq + self.last_duty = duty + self.freq_history = [] + self.duty_history = [] + + def freq(self, value=None): + if value is not None: + self.last_freq = value + self.freq_history.append(value) + return self.last_freq + + def duty_u16(self, value=None): + if value is not None: + self.last_duty = value + self.duty_history.append(value) + return self.last_duty + + +# Inject mock +sys.modules['machine'] = type('module', (), {'PWM': MockPWM, 'Pin': lambda x: x})() + + +# Now import the module to test +from mpos.audio.stream_rtttl import RTTTLStream + + +class TestRTTTL(unittest.TestCase): + """Test cases for RTTTL parser.""" + + def setUp(self): + """Create a mock buzzer before each test.""" + self.buzzer = MockPWM(46) + + def test_parse_simple_rtttl(self): + """Test parsing a simple RTTTL string.""" + rtttl = "Nokia:d=4,o=5,b=225:8e6,8d6,8f#,8g#" + stream = RTTTLStream(rtttl, 0, 100, self.buzzer, None) + + self.assertEqual(stream.name, "Nokia") + self.assertEqual(stream.default_duration, 4) + self.assertEqual(stream.default_octave, 5) + self.assertEqual(stream.bpm, 225) + + def test_parse_defaults(self): + """Test parsing default values.""" + rtttl = "Test:d=8,o=6,b=180:c" + stream = RTTTLStream(rtttl, 0, 100, self.buzzer, None) + + self.assertEqual(stream.default_duration, 8) + self.assertEqual(stream.default_octave, 6) + self.assertEqual(stream.bpm, 180) + + # Check calculated msec_per_whole_note + # 240000 / 180 = 1333.33... + self.assertAlmostEqual(stream.msec_per_whole_note, 1333.33, places=1) + + def test_invalid_rtttl_format(self): + """Test that invalid RTTTL format raises ValueError.""" + # Missing colons + with self.assertRaises(ValueError): + RTTTLStream("invalid", 0, 100, self.buzzer, None) + + # Too many colons + with self.assertRaises(ValueError): + RTTTLStream("a:b:c:d", 0, 100, self.buzzer, None) + + def test_note_parsing(self): + """Test parsing individual notes.""" + rtttl = "Test:d=4,o=5,b=120:c,d,e" + stream = RTTTLStream(rtttl, 0, 100, self.buzzer, None) + + # Generate notes + notes = list(stream._notes()) + + # Should have 3 notes + self.assertEqual(len(notes), 3) + + # Each note should be a tuple of (frequency, duration) + for freq, duration in notes: + self.assertTrue(freq > 0, "Frequency should be non-zero") + self.assertTrue(duration > 0, "Duration should be non-zero") + + def test_sharp_notes(self): + """Test parsing sharp notes.""" + rtttl = "Test:d=4,o=5,b=120:c#,d#,f#" + stream = RTTTLStream(rtttl, 0, 100, self.buzzer, None) + + notes = list(stream._notes()) + self.assertEqual(len(notes), 3) + + # Sharp notes should have different frequencies than natural notes + # (can't test exact values without knowing frequency table) + + def test_pause_notes(self): + """Test parsing pause notes.""" + rtttl = "Test:d=4,o=5,b=120:c,p,e" + stream = RTTTLStream(rtttl, 0, 100, self.buzzer, None) + + notes = list(stream._notes()) + self.assertEqual(len(notes), 3) + + # Pause (p) should have frequency 0 + freq, duration = notes[1] + self.assertEqual(freq, 0.0) + + def test_duration_modifiers(self): + """Test note duration modifiers (dots).""" + rtttl = "Test:d=4,o=5,b=120:c,c." + stream = RTTTLStream(rtttl, 0, 100, self.buzzer, None) + + notes = list(stream._notes()) + self.assertEqual(len(notes), 2) + + # Dotted note should be 1.5x longer + normal_duration = notes[0][1] + dotted_duration = notes[1][1] + self.assertAlmostEqual(dotted_duration / normal_duration, 1.5, places=1) + + def test_octave_variations(self): + """Test notes with different octaves.""" + rtttl = "Test:d=4,o=5,b=120:c4,c5,c6,c7" + stream = RTTTLStream(rtttl, 0, 100, self.buzzer, None) + + notes = list(stream._notes()) + self.assertEqual(len(notes), 4) + + # Higher octaves should have higher frequencies + freqs = [freq for freq, dur in notes] + self.assertTrue(freqs[0] < freqs[1], "c4 should be lower than c5") + self.assertTrue(freqs[1] < freqs[2], "c5 should be lower than c6") + self.assertTrue(freqs[2] < freqs[3], "c6 should be lower than c7") + + def test_volume_scaling(self): + """Test volume to duty cycle conversion.""" + # Test various volume levels + for volume in [0, 25, 50, 75, 100]: + stream = RTTTLStream("Test:d=4,o=5,b=120:c", 0, volume, self.buzzer, None) + + # Volume 0 should result in duty 0 + if volume == 0: + # Note: play() method calculates duty, not __init__ + pass # Can't easily test without calling play() + else: + # Volume > 0 should result in duty > 0 + # (duty calculation happens in play() method) + pass + + def test_stream_type(self): + """Test that stream type is stored correctly.""" + stream = RTTTLStream("Test:d=4,o=5,b=120:c", 2, 100, self.buzzer, None) + self.assertEqual(stream.stream_type, 2) + + def test_stop_flag(self): + """Test that stop flag can be set.""" + stream = RTTTLStream("Test:d=4,o=5,b=120:c", 0, 100, self.buzzer, None) + self.assertTrue(stream._keep_running) + + stream.stop() + self.assertFalse(stream._keep_running) + + def test_is_playing_flag(self): + """Test playing flag is initially false.""" + stream = RTTTLStream("Test:d=4,o=5,b=120:c", 0, 100, self.buzzer, None) + self.assertFalse(stream.is_playing()) diff --git a/tests/test_sensor_manager.py b/tests/test_sensor_manager.py new file mode 100644 index 00000000..85e77701 --- /dev/null +++ b/tests/test_sensor_manager.py @@ -0,0 +1,376 @@ +# Unit tests for SensorManager service +import unittest +import sys + + +# Mock hardware before importing SensorManager +class MockI2C: + """Mock I2C bus for testing.""" + def __init__(self, bus_id, sda=None, scl=None): + self.bus_id = bus_id + self.sda = sda + self.scl = scl + self.memory = {} # addr -> {reg -> value} + + def readfrom_mem(self, addr, reg, nbytes): + """Read from memory (simulates I2C read).""" + if addr not in self.memory: + raise OSError("I2C device not found") + if reg not in self.memory[addr]: + return bytes([0] * nbytes) + return bytes(self.memory[addr][reg]) + + def writeto_mem(self, addr, reg, data): + """Write to memory (simulates I2C write).""" + if addr not in self.memory: + self.memory[addr] = {} + self.memory[addr][reg] = list(data) + + +class MockQMI8658: + """Mock QMI8658 IMU sensor.""" + def __init__(self, i2c_bus, address=0x6B, accel_scale=0b10, gyro_scale=0b100): + self.i2c = i2c_bus + self.address = address + self.accel_scale = accel_scale + self.gyro_scale = gyro_scale + + @property + def temperature(self): + """Return mock temperature.""" + return 25.5 # Mock temperature in °C + + @property + def acceleration(self): + """Return mock acceleration (in G).""" + return (0.0, 0.0, 1.0) # At rest, Z-axis = 1G + + @property + def gyro(self): + """Return mock gyroscope (in deg/s).""" + return (0.0, 0.0, 0.0) # Stationary + + +class MockWsenIsds: + """Mock WSEN_ISDS IMU sensor.""" + def __init__(self, i2c, address=0x6B, acc_range="8g", acc_data_rate="104Hz", + gyro_range="500dps", gyro_data_rate="104Hz"): + self.i2c = i2c + self.address = address + self.acc_range = acc_range + self.gyro_range = gyro_range + self.acc_sensitivity = 0.244 # mg/digit for 8g + self.gyro_sensitivity = 17.5 # mdps/digit for 500dps + self.acc_offset_x = 0 + self.acc_offset_y = 0 + self.acc_offset_z = 0 + self.gyro_offset_x = 0 + self.gyro_offset_y = 0 + self.gyro_offset_z = 0 + + def get_chip_id(self): + """Return WHO_AM_I value.""" + return 0x6A + + def _read_raw_accelerations(self): + """Return mock acceleration (in mg).""" + return (0.0, 0.0, 1000.0) # At rest, Z-axis = 1000 mg + + def read_angular_velocities(self): + """Return mock gyroscope (in mdps).""" + return (0.0, 0.0, 0.0) + + def acc_calibrate(self, samples=None): + """Mock calibration.""" + pass + + def gyro_calibrate(self, samples=None): + """Mock calibration.""" + pass + + +# Mock constants from drivers +_QMI8685_PARTID = 0x05 +_REG_PARTID = 0x00 +_ACCELSCALE_RANGE_8G = 0b10 +_GYROSCALE_RANGE_256DPS = 0b100 + + +# Create mock modules +mock_machine = type('module', (), { + 'I2C': MockI2C, + 'Pin': type('Pin', (), {}) +})() + +mock_qmi8658 = type('module', (), { + 'QMI8658': MockQMI8658, + '_QMI8685_PARTID': _QMI8685_PARTID, + '_REG_PARTID': _REG_PARTID, + '_ACCELSCALE_RANGE_8G': _ACCELSCALE_RANGE_8G, + '_GYROSCALE_RANGE_256DPS': _GYROSCALE_RANGE_256DPS +})() + +mock_wsen_isds = type('module', (), { + 'Wsen_Isds': MockWsenIsds +})() + +# Mock esp32 module +def _mock_mcu_temperature(*args, **kwargs): + """Mock MCU temperature sensor.""" + return 42.0 + +mock_esp32 = type('module', (), { + 'mcu_temperature': _mock_mcu_temperature +})() + +# Inject mocks into sys.modules +sys.modules['machine'] = mock_machine +sys.modules['mpos.hardware.drivers.qmi8658'] = mock_qmi8658 +sys.modules['mpos.hardware.drivers.wsen_isds'] = mock_wsen_isds +sys.modules['esp32'] = mock_esp32 + +# Mock _thread for thread safety testing +try: + import _thread +except ImportError: + mock_thread = type('module', (), { + 'allocate_lock': lambda: type('lock', (), { + 'acquire': lambda self: None, + 'release': lambda self: None + })() + })() + sys.modules['_thread'] = mock_thread + +# Now import the module to test +import mpos.sensor_manager as SensorManager + + +class TestSensorManagerQMI8658(unittest.TestCase): + """Test cases for SensorManager with QMI8658 IMU.""" + + def setUp(self): + """Set up test fixtures before each test.""" + # Reset SensorManager state + SensorManager._initialized = False + SensorManager._imu_driver = None + SensorManager._sensor_list = [] + SensorManager._has_mcu_temperature = False + + # Create mock I2C bus with QMI8658 + self.i2c_bus = MockI2C(0, sda=48, scl=47) + # Set QMI8658 chip ID + self.i2c_bus.memory[0x6B] = {_REG_PARTID: [_QMI8685_PARTID]} + + def test_initialization_qmi8658(self): + """Test that SensorManager initializes with QMI8658.""" + result = SensorManager.init(self.i2c_bus, address=0x6B) + self.assertTrue(result) + self.assertTrue(SensorManager.is_available()) + + def test_sensor_list_qmi8658(self): + """Test getting sensor list for QMI8658.""" + SensorManager.init(self.i2c_bus, address=0x6B) + sensors = SensorManager.get_sensor_list() + + # QMI8658 provides: Accelerometer, Gyroscope, IMU Temperature, MCU Temperature + self.assertGreaterEqual(len(sensors), 3) + + # Check sensor types present + sensor_types = [s.type for s in sensors] + self.assertIn(SensorManager.TYPE_ACCELEROMETER, sensor_types) + self.assertIn(SensorManager.TYPE_GYROSCOPE, sensor_types) + self.assertIn(SensorManager.TYPE_IMU_TEMPERATURE, sensor_types) + + def test_get_default_sensor(self): + """Test getting default sensor by type.""" + SensorManager.init(self.i2c_bus, address=0x6B) + + accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) + self.assertIsNotNone(accel) + self.assertEqual(accel.type, SensorManager.TYPE_ACCELEROMETER) + + gyro = SensorManager.get_default_sensor(SensorManager.TYPE_GYROSCOPE) + self.assertIsNotNone(gyro) + self.assertEqual(gyro.type, SensorManager.TYPE_GYROSCOPE) + + def test_get_nonexistent_sensor(self): + """Test getting a sensor type that doesn't exist.""" + SensorManager.init(self.i2c_bus, address=0x6B) + + # Type 999 doesn't exist + sensor = SensorManager.get_default_sensor(999) + self.assertIsNone(sensor) + + def test_read_accelerometer(self): + """Test reading accelerometer data.""" + SensorManager.init(self.i2c_bus, address=0x6B) + accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) + + data = SensorManager.read_sensor(accel) + self.assertTrue(data is not None, f"read_sensor returned None, expected tuple") + self.assertEqual(len(data), 3) # (x, y, z) + + ax, ay, az = data + # At rest, Z should be ~9.8 m/s² (1G converted to m/s²) + self.assertAlmostEqual(az, 9.80665, places=2) + + def test_read_gyroscope(self): + """Test reading gyroscope data.""" + SensorManager.init(self.i2c_bus, address=0x6B) + gyro = SensorManager.get_default_sensor(SensorManager.TYPE_GYROSCOPE) + + data = SensorManager.read_sensor(gyro) + self.assertTrue(data is not None, f"read_sensor returned None, expected tuple") + self.assertEqual(len(data), 3) # (x, y, z) + + gx, gy, gz = data + # Stationary, all should be ~0 deg/s + self.assertAlmostEqual(gx, 0.0, places=1) + self.assertAlmostEqual(gy, 0.0, places=1) + self.assertAlmostEqual(gz, 0.0, places=1) + + def test_read_temperature(self): + """Test reading temperature data.""" + SensorManager.init(self.i2c_bus, address=0x6B) + + # Try IMU temperature + imu_temp = SensorManager.get_default_sensor(SensorManager.TYPE_IMU_TEMPERATURE) + if imu_temp: + temp = SensorManager.read_sensor(imu_temp) + self.assertIsNotNone(temp) + self.assertIsInstance(temp, (int, float)) + + # Try MCU temperature + mcu_temp = SensorManager.get_default_sensor(SensorManager.TYPE_SOC_TEMPERATURE) + if mcu_temp: + temp = SensorManager.read_sensor(mcu_temp) + self.assertIsNotNone(temp) + self.assertEqual(temp, 42.0) # Mock value + + def test_read_sensor_without_init(self): + """Test reading sensor without initialization.""" + accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) + self.assertIsNone(accel) + + def test_is_available_before_init(self): + """Test is_available before initialization.""" + self.assertFalse(SensorManager.is_available()) + + +class TestSensorManagerWsenIsds(unittest.TestCase): + """Test cases for SensorManager with WSEN_ISDS IMU.""" + + def setUp(self): + """Set up test fixtures before each test.""" + # Reset SensorManager state + SensorManager._initialized = False + SensorManager._imu_driver = None + SensorManager._sensor_list = [] + SensorManager._has_mcu_temperature = False + + # Create mock I2C bus with WSEN_ISDS + self.i2c_bus = MockI2C(0, sda=9, scl=18) + # Set WSEN_ISDS WHO_AM_I + self.i2c_bus.memory[0x6B] = {0x0F: [0x6A]} + + def test_initialization_wsen_isds(self): + """Test that SensorManager initializes with WSEN_ISDS.""" + result = SensorManager.init(self.i2c_bus, address=0x6B) + self.assertTrue(result) + self.assertTrue(SensorManager.is_available()) + + def test_sensor_list_wsen_isds(self): + """Test getting sensor list for WSEN_ISDS.""" + SensorManager.init(self.i2c_bus, address=0x6B) + sensors = SensorManager.get_sensor_list() + + # WSEN_ISDS provides: Accelerometer, Gyroscope, MCU Temperature + # (no IMU temperature) + self.assertGreaterEqual(len(sensors), 2) + + # Check sensor types + sensor_types = [s.type for s in sensors] + self.assertIn(SensorManager.TYPE_ACCELEROMETER, sensor_types) + self.assertIn(SensorManager.TYPE_GYROSCOPE, sensor_types) + + def test_read_accelerometer_wsen_isds(self): + """Test reading accelerometer from WSEN_ISDS.""" + SensorManager.init(self.i2c_bus, address=0x6B) + accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) + + data = SensorManager.read_sensor(accel) + self.assertTrue(data is not None, f"read_sensor returned None, expected tuple") + self.assertEqual(len(data), 3) + + ax, ay, az = data + # WSEN_ISDS mock returns 1000mg = 1G = 9.80665 m/s² + self.assertAlmostEqual(az, 9.80665, places=2) + + +class TestSensorManagerNoHardware(unittest.TestCase): + """Test cases for SensorManager without hardware (desktop mode).""" + + def setUp(self): + """Set up test fixtures before each test.""" + # Reset SensorManager state + SensorManager._initialized = False + SensorManager._imu_driver = None + SensorManager._sensor_list = [] + SensorManager._has_mcu_temperature = False + + # Create mock I2C bus with no devices + self.i2c_bus = MockI2C(0, sda=48, scl=47) + # No chip ID registered - simulates no hardware + + def test_no_imu_detected(self): + """Test behavior when no IMU is present.""" + result = SensorManager.init(self.i2c_bus, address=0x6B) + # Returns True if MCU temp is available (even without IMU) + self.assertTrue(result) + + def test_graceful_degradation(self): + """Test graceful degradation when no sensors available.""" + SensorManager.init(self.i2c_bus, address=0x6B) + + # Should have at least MCU temperature + sensors = SensorManager.get_sensor_list() + self.assertGreaterEqual(len(sensors), 0) + + # Reading non-existent sensor should return None + accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) + if accel is None: + # Expected when no IMU + pass + else: + # If somehow initialized, reading should handle gracefully + data = SensorManager.read_sensor(accel) + # Should either work or return None, not crash + self.assertTrue(data is None or len(data) == 3) + + +class TestSensorManagerMultipleInit(unittest.TestCase): + """Test cases for multiple initialization calls.""" + + def setUp(self): + """Set up test fixtures before each test.""" + # Reset SensorManager state + SensorManager._initialized = False + SensorManager._imu_driver = None + SensorManager._sensor_list = [] + SensorManager._has_mcu_temperature = False + + # Create mock I2C bus with QMI8658 + self.i2c_bus = MockI2C(0, sda=48, scl=47) + self.i2c_bus.memory[0x6B] = {_REG_PARTID: [_QMI8685_PARTID]} + + def test_multiple_init_calls(self): + """Test that multiple init calls are handled gracefully.""" + result1 = SensorManager.init(self.i2c_bus, address=0x6B) + self.assertTrue(result1) + + # Second init should return True but not re-initialize + result2 = SensorManager.init(self.i2c_bus, address=0x6B) + self.assertTrue(result2) + + # Should still work normally + self.assertTrue(SensorManager.is_available()) diff --git a/tests/test_shared_preferences.py b/tests/test_shared_preferences.py new file mode 100644 index 00000000..f8e28215 --- /dev/null +++ b/tests/test_shared_preferences.py @@ -0,0 +1,687 @@ +import unittest +import os +from mpos.config import SharedPreferences, Editor + + +class TestSharedPreferences(unittest.TestCase): + """Test suite for SharedPreferences configuration storage.""" + + def setUp(self): + """Set up test fixtures before each test method.""" + self.test_app_name = "com.test.unittest" + self.test_dir = f"data/{self.test_app_name}" + self.test_file = f"{self.test_dir}/config.json" + # Clean up any existing test data + self._cleanup() + + def tearDown(self): + """Clean up test fixtures after each test method.""" + self._cleanup() + + def _cleanup(self): + """Remove test data directory if it exists.""" + try: + # Use os.stat() instead of os.path.exists() for MicroPython compatibility + try: + os.stat(self.test_file) + os.remove(self.test_file) + except OSError: + pass # File doesn't exist, that's fine + + try: + os.stat(self.test_dir) + os.rmdir(self.test_dir) + except OSError: + pass # Directory doesn't exist, that's fine + + try: + os.stat("data") + # Try to remove data directory, but it might have other contents + try: + os.rmdir("data") + except OSError: + # Directory not empty, that's okay + pass + except OSError: + pass # Directory doesn't exist, that's fine + except Exception as e: + # Cleanup failure is not critical for tests + print(f"Cleanup warning: {e}") + + # ============================================================ + # Basic String Operations + # ============================================================ + + def test_put_get_string(self): + """Test storing and retrieving a string value.""" + prefs = SharedPreferences(self.test_app_name) + editor = prefs.edit() + editor.put_string("username", "testuser") + editor.commit() + + # Reload to verify persistence + prefs2 = SharedPreferences(self.test_app_name) + self.assertEqual(prefs2.get_string("username"), "testuser") + + def test_get_string_default(self): + """Test getting a string with default value when key doesn't exist.""" + prefs = SharedPreferences(self.test_app_name) + self.assertEqual(prefs.get_string("nonexistent", "default"), "default") + self.assertIsNone(prefs.get_string("nonexistent")) + + def test_put_string_overwrites(self): + """Test that putting a string overwrites existing value.""" + prefs = SharedPreferences(self.test_app_name) + prefs.edit().put_string("key", "value1").commit() + prefs.edit().put_string("key", "value2").commit() + + prefs2 = SharedPreferences(self.test_app_name) + self.assertEqual(prefs2.get_string("key"), "value2") + + # ============================================================ + # Integer Operations + # ============================================================ + + def test_put_get_int(self): + """Test storing and retrieving an integer value.""" + prefs = SharedPreferences(self.test_app_name) + editor = prefs.edit() + editor.put_int("count", 42) + editor.commit() + + prefs2 = SharedPreferences(self.test_app_name) + self.assertEqual(prefs2.get_int("count"), 42) + + def test_get_int_default(self): + """Test getting an integer with default value when key doesn't exist.""" + prefs = SharedPreferences(self.test_app_name) + self.assertEqual(prefs.get_int("nonexistent", 100), 100) + self.assertEqual(prefs.get_int("nonexistent"), 0) + + def test_get_int_invalid_type(self): + """Test getting an integer when stored value is invalid.""" + prefs = SharedPreferences(self.test_app_name) + prefs.edit().put_string("invalid", "not_a_number").commit() + + prefs2 = SharedPreferences(self.test_app_name) + self.assertEqual(prefs2.get_int("invalid", 99), 99) + + # ============================================================ + # Boolean Operations + # ============================================================ + + def test_put_get_bool(self): + """Test storing and retrieving boolean values.""" + prefs = SharedPreferences(self.test_app_name) + prefs.edit().put_bool("enabled", True).commit() + prefs.edit().put_bool("disabled", False).commit() + + prefs2 = SharedPreferences(self.test_app_name) + self.assertTrue(prefs2.get_bool("enabled")) + self.assertFalse(prefs2.get_bool("disabled")) + + def test_get_bool_default(self): + """Test getting a boolean with default value when key doesn't exist.""" + prefs = SharedPreferences(self.test_app_name) + self.assertTrue(prefs.get_bool("nonexistent", True)) + self.assertFalse(prefs.get_bool("nonexistent", False)) + self.assertFalse(prefs.get_bool("nonexistent")) + + # ============================================================ + # List Operations + # ============================================================ + + def test_put_get_list(self): + """Test storing and retrieving a list.""" + prefs = SharedPreferences(self.test_app_name) + test_list = [1, 2, 3, "four"] + prefs.edit().put_list("mylist", test_list).commit() + + prefs2 = SharedPreferences(self.test_app_name) + self.assertEqual(prefs2.get_list("mylist"), test_list) + + def test_get_list_default(self): + """Test getting a list with default value when key doesn't exist.""" + prefs = SharedPreferences(self.test_app_name) + self.assertEqual(prefs.get_list("nonexistent"), []) + self.assertEqual(prefs.get_list("nonexistent", ["a", "b"]), ["a", "b"]) + + def test_append_to_list(self): + """Test appending items to a list.""" + prefs = SharedPreferences(self.test_app_name) + editor = prefs.edit() + editor.append_to_list("items", {"id": 1, "name": "first"}) + editor.append_to_list("items", {"id": 2, "name": "second"}) + editor.commit() + + prefs2 = SharedPreferences(self.test_app_name) + items = prefs2.get_list("items") + self.assertEqual(len(items), 2) + self.assertEqual(items[0]["id"], 1) + self.assertEqual(items[1]["name"], "second") + + def test_update_list_item(self): + """Test updating an item in a list.""" + prefs = SharedPreferences(self.test_app_name) + prefs.edit().put_list("items", [{"a": 1}, {"b": 2}]).commit() + + prefs.edit().update_list_item("items", 1, {"b": 99}).commit() + + prefs2 = SharedPreferences(self.test_app_name) + items = prefs2.get_list("items") + self.assertEqual(items[1]["b"], 99) + + def test_remove_from_list(self): + """Test removing an item from a list.""" + prefs = SharedPreferences(self.test_app_name) + prefs.edit().put_list("items", [{"a": 1}, {"b": 2}, {"c": 3}]).commit() + + prefs.edit().remove_from_list("items", 1).commit() + + prefs2 = SharedPreferences(self.test_app_name) + items = prefs2.get_list("items") + self.assertEqual(len(items), 2) + self.assertEqual(items[0]["a"], 1) + self.assertEqual(items[1]["c"], 3) + + def test_get_list_item(self): + """Test getting a specific field from a list item.""" + prefs = SharedPreferences(self.test_app_name) + prefs.edit().put_list("users", [ + {"name": "Alice", "age": 30}, + {"name": "Bob", "age": 25} + ]).commit() + + self.assertEqual(prefs.get_list_item("users", 0, "name"), "Alice") + self.assertEqual(prefs.get_list_item("users", 1, "age"), 25) + self.assertIsNone(prefs.get_list_item("users", 99, "name")) + self.assertEqual(prefs.get_list_item("users", 99, "name", "default"), "default") + + def test_get_list_item_dict(self): + """Test getting an entire dictionary from a list.""" + prefs = SharedPreferences(self.test_app_name) + prefs.edit().put_list("configs", [{"key": "value"}]).commit() + + item = prefs.get_list_item_dict("configs", 0) + self.assertEqual(item["key"], "value") + self.assertEqual(prefs.get_list_item_dict("configs", 99), {}) + + # ============================================================ + # Dictionary Operations + # ============================================================ + + def test_put_get_dict(self): + """Test storing and retrieving a dictionary.""" + prefs = SharedPreferences(self.test_app_name) + test_dict = {"key1": "value1", "key2": 42} + prefs.edit().put_dict("mydict", test_dict).commit() + + prefs2 = SharedPreferences(self.test_app_name) + self.assertEqual(prefs2.get_dict("mydict"), test_dict) + + def test_get_dict_default(self): + """Test getting a dict with default value when key doesn't exist.""" + prefs = SharedPreferences(self.test_app_name) + self.assertEqual(prefs.get_dict("nonexistent"), {}) + self.assertEqual(prefs.get_dict("nonexistent", {"default": True}), {"default": True}) + + def test_put_dict_item(self): + """Test adding items to a dictionary.""" + prefs = SharedPreferences(self.test_app_name) + editor = prefs.edit() + editor.put_dict_item("wifi_aps", "HomeNetwork", {"password": "secret123", "priority": 1}) + editor.put_dict_item("wifi_aps", "WorkNetwork", {"password": "work456", "priority": 2}) + editor.commit() + + prefs2 = SharedPreferences(self.test_app_name) + self.assertEqual(prefs2.get_dict_item("wifi_aps", "HomeNetwork")["password"], "secret123") + self.assertEqual(prefs2.get_dict_item("wifi_aps", "WorkNetwork")["priority"], 2) + + def test_remove_dict_item(self): + """Test removing an item from a dictionary.""" + prefs = SharedPreferences(self.test_app_name) + prefs.edit().put_dict("items", {"a": 1, "b": 2, "c": 3}).commit() + + prefs.edit().remove_dict_item("items", "b").commit() + + prefs2 = SharedPreferences(self.test_app_name) + items = prefs2.get_dict("items") + self.assertEqual(len(items), 2) + self.assertIn("a", items) + self.assertIn("c", items) + self.assertFalse("b" in items) # Use 'in' operator instead of assertNotIn + + def test_get_dict_item(self): + """Test getting a specific item from a dictionary.""" + prefs = SharedPreferences(self.test_app_name) + prefs.edit().put_dict("settings", { + "theme": {"color": "blue", "size": 14}, + "audio": {"volume": 80} + }).commit() + + theme = prefs.get_dict_item("settings", "theme") + self.assertEqual(theme["color"], "blue") + self.assertEqual(prefs.get_dict_item("settings", "nonexistent"), {}) + + def test_get_dict_item_field(self): + """Test getting a specific field from a dict item.""" + prefs = SharedPreferences(self.test_app_name) + prefs.edit().put_dict("networks", { + "ssid1": {"password": "pass1", "signal": 100}, + "ssid2": {"password": "pass2", "signal": 50} + }).commit() + + self.assertEqual(prefs.get_dict_item_field("networks", "ssid1", "password"), "pass1") + self.assertEqual(prefs.get_dict_item_field("networks", "ssid2", "signal"), 50) + self.assertIsNone(prefs.get_dict_item_field("networks", "ssid99", "password")) + self.assertEqual(prefs.get_dict_item_field("networks", "ssid1", "missing", "def"), "def") + + def test_get_dict_keys(self): + """Test getting all keys from a dictionary.""" + prefs = SharedPreferences(self.test_app_name) + prefs.edit().put_dict("items", {"a": 1, "b": 2, "c": 3}).commit() + + keys = prefs.get_dict_keys("items") + self.assertEqual(len(keys), 3) + self.assertIn("a", keys) + self.assertIn("b", keys) + self.assertIn("c", keys) + + def test_get_dict_keys_nonexistent(self): + """Test getting keys from a nonexistent dictionary.""" + prefs = SharedPreferences(self.test_app_name) + self.assertEqual(prefs.get_dict_keys("nonexistent"), []) + + # ============================================================ + # Editor Operations + # ============================================================ + + def test_editor_chaining(self): + """Test that editor methods can be chained.""" + prefs = SharedPreferences(self.test_app_name) + prefs.edit()\ + .put_string("name", "test")\ + .put_int("count", 5)\ + .put_bool("enabled", True)\ + .commit() + + prefs2 = SharedPreferences(self.test_app_name) + self.assertEqual(prefs2.get_string("name"), "test") + self.assertEqual(prefs2.get_int("count"), 5) + self.assertTrue(prefs2.get_bool("enabled")) + + def test_editor_apply_vs_commit(self): + """Test that both apply and commit save data.""" + prefs = SharedPreferences(self.test_app_name) + + # Test apply + prefs.edit().put_string("key1", "apply_test").apply() + prefs2 = SharedPreferences(self.test_app_name) + self.assertEqual(prefs2.get_string("key1"), "apply_test") + + # Test commit + prefs.edit().put_string("key2", "commit_test").commit() + prefs3 = SharedPreferences(self.test_app_name) + self.assertEqual(prefs3.get_string("key2"), "commit_test") + + def test_editor_without_commit_doesnt_save(self): + """Test that changes without commit/apply are not persisted.""" + prefs = SharedPreferences(self.test_app_name) + editor = prefs.edit() + editor.put_string("unsaved", "value") + # Don't call commit() or apply() + + # Reload and verify data wasn't saved + prefs2 = SharedPreferences(self.test_app_name) + self.assertIsNone(prefs2.get_string("unsaved")) + + def test_multiple_edits(self): + """Test multiple sequential edit operations.""" + prefs = SharedPreferences(self.test_app_name) + + prefs.edit().put_string("step", "1").commit() + prefs.edit().put_string("step", "2").commit() + prefs.edit().put_string("step", "3").commit() + + prefs2 = SharedPreferences(self.test_app_name) + self.assertEqual(prefs2.get_string("step"), "3") + + # ============================================================ + # File Persistence + # ============================================================ + + def test_directory_creation(self): + """Test that directory structure is created automatically.""" + prefs = SharedPreferences(self.test_app_name) + prefs.edit().put_string("test", "value").commit() + + # Use os.stat() instead of os.path.exists() for MicroPython + try: + os.stat("data") + data_exists = True + except OSError: + data_exists = False + self.assertTrue(data_exists) + + try: + os.stat(self.test_dir) + dir_exists = True + except OSError: + dir_exists = False + self.assertTrue(dir_exists) + + try: + os.stat(self.test_file) + file_exists = True + except OSError: + file_exists = False + self.assertTrue(file_exists) + + def test_custom_filename(self): + """Test using a custom filename for preferences.""" + prefs = SharedPreferences(self.test_app_name, "custom.json") + prefs.edit().put_string("custom", "data").commit() + + custom_file = f"{self.test_dir}/custom.json" + # Use os.stat() instead of os.path.exists() for MicroPython + try: + os.stat(custom_file) + file_exists = True + except OSError: + file_exists = False + self.assertTrue(file_exists) + + prefs2 = SharedPreferences(self.test_app_name, "custom.json") + self.assertEqual(prefs2.get_string("custom"), "data") + + def test_load_existing_file(self): + """Test loading from an existing preferences file.""" + # Create initial prefs + prefs1 = SharedPreferences(self.test_app_name) + prefs1.edit().put_string("existing", "data").commit() + + # Load in a new instance + prefs2 = SharedPreferences(self.test_app_name) + self.assertEqual(prefs2.get_string("existing"), "data") + + # ============================================================ + # Edge Cases and Error Handling + # ============================================================ + + def test_empty_string_values(self): + """Test storing and retrieving empty strings.""" + prefs = SharedPreferences(self.test_app_name) + prefs.edit().put_string("empty", "").commit() + + prefs2 = SharedPreferences(self.test_app_name) + self.assertEqual(prefs2.get_string("empty"), "") + + def test_zero_values(self): + """Test storing and retrieving zero values.""" + prefs = SharedPreferences(self.test_app_name) + prefs.edit().put_int("zero", 0).put_bool("false", False).commit() + + prefs2 = SharedPreferences(self.test_app_name) + self.assertEqual(prefs2.get_int("zero"), 0) + self.assertFalse(prefs2.get_bool("false")) + + def test_none_values(self): + """Test handling None values gracefully.""" + prefs = SharedPreferences(self.test_app_name) + # Getting a nonexistent key should return None or default + self.assertIsNone(prefs.get_string("nonexistent")) + + def test_special_characters_in_keys(self): + """Test keys with special characters.""" + prefs = SharedPreferences(self.test_app_name) + prefs.edit()\ + .put_string("key.with.dots", "value1")\ + .put_string("key_with_underscores", "value2")\ + .put_string("key-with-dashes", "value3")\ + .commit() + + prefs2 = SharedPreferences(self.test_app_name) + self.assertEqual(prefs2.get_string("key.with.dots"), "value1") + self.assertEqual(prefs2.get_string("key_with_underscores"), "value2") + self.assertEqual(prefs2.get_string("key-with-dashes"), "value3") + + def test_unicode_values(self): + """Test storing and retrieving Unicode strings.""" + prefs = SharedPreferences(self.test_app_name) + prefs.edit().put_string("unicode", "Hello 世界 🌍").commit() + + prefs2 = SharedPreferences(self.test_app_name) + self.assertEqual(prefs2.get_string("unicode"), "Hello 世界 🌍") + + def test_large_nested_structure(self): + """Test storing a complex nested data structure.""" + prefs = SharedPreferences(self.test_app_name) + complex_data = { + "users": { + "alice": {"age": 30, "roles": ["admin", "user"]}, + "bob": {"age": 25, "roles": ["user"]} + }, + "settings": { + "theme": "dark", + "notifications": True, + "limits": [10, 20, 30] + } + } + prefs.edit().put_dict("app_data", complex_data).commit() + + prefs2 = SharedPreferences(self.test_app_name) + loaded = prefs2.get_dict("app_data") + self.assertEqual(loaded["users"]["alice"]["age"], 30) + self.assertEqual(loaded["settings"]["theme"], "dark") + self.assertEqual(loaded["settings"]["limits"][2], 30) + + # Tests for default values feature + def test_constructor_defaults_basic(self): + """Test that constructor defaults are returned when key is missing.""" + defaults = {"brightness": -1, "enabled": True, "name": "default"} + prefs = SharedPreferences(self.test_app_name, defaults=defaults) + + # No values stored yet, should return constructor defaults + self.assertEqual(prefs.get_int("brightness"), -1) + self.assertEqual(prefs.get_bool("enabled"), True) + self.assertEqual(prefs.get_string("name"), "default") + + def test_method_default_precedence(self): + """Test that method defaults override constructor defaults.""" + defaults = {"brightness": -1, "enabled": False, "name": "default"} + prefs = SharedPreferences(self.test_app_name, defaults=defaults) + + # Method defaults should take precedence when different from hardcoded defaults + self.assertEqual(prefs.get_int("brightness", 50), 50) + # For booleans, we can only test when method default differs from hardcoded False + self.assertEqual(prefs.get_bool("enabled", True), True) + self.assertEqual(prefs.get_string("name", "override"), "override") + + def test_stored_value_precedence(self): + """Test that stored values override all defaults.""" + defaults = {"brightness": -1, "enabled": True, "name": "default"} + prefs = SharedPreferences(self.test_app_name, defaults=defaults) + + # Store some values + prefs.edit().put_int("brightness", 75).put_bool("enabled", False).put_string("name", "stored").commit() + + # Reload and verify stored values override defaults + prefs2 = SharedPreferences(self.test_app_name, defaults=defaults) + self.assertEqual(prefs2.get_int("brightness"), 75) + self.assertEqual(prefs2.get_bool("enabled"), False) + self.assertEqual(prefs2.get_string("name"), "stored") + + # Method defaults should not override stored values + self.assertEqual(prefs2.get_int("brightness", 100), 75) + self.assertEqual(prefs2.get_bool("enabled", True), False) + self.assertEqual(prefs2.get_string("name", "method"), "stored") + + def test_default_values_not_saved(self): + """Test that values matching defaults are not written to disk.""" + defaults = {"brightness": -1, "enabled": True, "name": "default"} + prefs = SharedPreferences(self.test_app_name, defaults=defaults) + + # Set values matching defaults + prefs.edit().put_int("brightness", -1).put_bool("enabled", True).put_string("name", "default").commit() + + # Reload and verify values are returned correctly + prefs2 = SharedPreferences(self.test_app_name, defaults=defaults) + self.assertEqual(prefs2.get_int("brightness"), -1) + self.assertEqual(prefs2.get_bool("enabled"), True) + self.assertEqual(prefs2.get_string("name"), "default") + + # Verify raw data doesn't contain the keys (they weren't saved) + self.assertFalse("brightness" in prefs2.data) + self.assertFalse("enabled" in prefs2.data) + self.assertFalse("name" in prefs2.data) + + def test_cleanup_removes_defaults(self): + """Test that setting a value to its default removes it from storage.""" + defaults = {"brightness": -1} + prefs = SharedPreferences(self.test_app_name, defaults=defaults) + + # Store a non-default value + prefs.edit().put_int("brightness", 75).commit() + prefs2 = SharedPreferences(self.test_app_name, defaults=defaults) + self.assertIn("brightness", prefs2.data) + self.assertEqual(prefs2.get_int("brightness"), 75) + + # Change it back to default + prefs2.edit().put_int("brightness", -1).commit() + + # Reload and verify it's been removed from storage + prefs3 = SharedPreferences(self.test_app_name, defaults=defaults) + self.assertFalse("brightness" in prefs3.data) + self.assertEqual(prefs3.get_int("brightness"), -1) + + def test_none_as_valid_default(self): + """Test that None can be used as a constructor default value.""" + defaults = {"optional_string": None, "optional_list": None} + prefs = SharedPreferences(self.test_app_name, defaults=defaults) + + # Should return None for these keys + self.assertIsNone(prefs.get_string("optional_string")) + self.assertIsNone(prefs.get_list("optional_list")) + + # Store some values + prefs.edit().put_string("optional_string", "value").put_list("optional_list", [1, 2]).commit() + + # Reload + prefs2 = SharedPreferences(self.test_app_name, defaults=defaults) + self.assertEqual(prefs2.get_string("optional_string"), "value") + self.assertEqual(prefs2.get_list("optional_list"), [1, 2]) + + def test_empty_collection_defaults(self): + """Test empty lists and dicts as constructor defaults.""" + defaults = {"items": [], "settings": {}} + prefs = SharedPreferences(self.test_app_name, defaults=defaults) + + # Should return empty collections + self.assertEqual(prefs.get_list("items"), []) + self.assertEqual(prefs.get_dict("settings"), {}) + + # These should not be saved to disk + prefs.edit().put_list("items", []).put_dict("settings", {}).commit() + prefs2 = SharedPreferences(self.test_app_name, defaults=defaults) + self.assertFalse("items" in prefs2.data) + self.assertFalse("settings" in prefs2.data) + + def test_defaults_with_nested_structures(self): + """Test that defaults work with complex nested structures.""" + defaults = { + "config": {"theme": "dark", "size": 12}, + "items": [1, 2, 3] + } + prefs = SharedPreferences(self.test_app_name, defaults=defaults) + + # Constructor defaults should work + self.assertEqual(prefs.get_dict("config"), {"theme": "dark", "size": 12}) + self.assertEqual(prefs.get_list("items"), [1, 2, 3]) + + # Exact match should not be saved + prefs.edit().put_dict("config", {"theme": "dark", "size": 12}).commit() + prefs2 = SharedPreferences(self.test_app_name, defaults=defaults) + self.assertFalse("config" in prefs2.data) + + # Modified value should be saved + prefs2.edit().put_dict("config", {"theme": "light", "size": 12}).commit() + prefs3 = SharedPreferences(self.test_app_name, defaults=defaults) + self.assertIn("config", prefs3.data) + self.assertEqual(prefs3.get_dict("config")["theme"], "light") + + def test_backward_compatibility(self): + """Test that existing code without defaults parameter still works.""" + # Old style initialization (no defaults parameter) + prefs = SharedPreferences(self.test_app_name) + + # Should work exactly as before + prefs.edit().put_string("key", "value").put_int("count", 42).commit() + + prefs2 = SharedPreferences(self.test_app_name) + self.assertEqual(prefs2.get_string("key"), "value") + self.assertEqual(prefs2.get_int("count"), 42) + + def test_type_conversion_with_defaults(self): + """Test type conversion works correctly with constructor defaults.""" + defaults = {"number": -1, "flag": True} + prefs = SharedPreferences(self.test_app_name, defaults=defaults) + + # Store string representations + prefs.edit().put_string("number", "123").put_string("flag", "false").commit() + + # get_int and get_bool should handle conversion + prefs2 = SharedPreferences(self.test_app_name, defaults=defaults) + # Note: the stored values are strings, not ints/bools, so they're different from defaults + self.assertIn("number", prefs2.data) + self.assertIn("flag", prefs2.data) + + def test_multiple_editors_with_defaults(self): + """Test that multiple edit sessions work correctly with defaults.""" + defaults = {"brightness": -1, "volume": 50} + prefs = SharedPreferences(self.test_app_name, defaults=defaults) + + # First editor session + editor1 = prefs.edit() + editor1.put_int("brightness", 75) + editor1.commit() + + # Second editor session + editor2 = prefs.edit() + editor2.put_int("volume", 80) + editor2.commit() + + # Verify both values + prefs2 = SharedPreferences(self.test_app_name, defaults=defaults) + self.assertEqual(prefs2.get_int("brightness"), 75) + self.assertEqual(prefs2.get_int("volume"), 80) + self.assertIn("brightness", prefs2.data) + self.assertIn("volume", prefs2.data) + + # Set one back to default + prefs2.edit().put_int("brightness", -1).commit() + prefs3 = SharedPreferences(self.test_app_name, defaults=defaults) + self.assertFalse("brightness" in prefs3.data) + self.assertEqual(prefs3.get_int("brightness"), -1) + + def test_partial_defaults(self): + """Test that some keys can have defaults while others don't.""" + defaults = {"brightness": -1} # Only brightness has a default + prefs = SharedPreferences(self.test_app_name, defaults=defaults) + + # Save multiple values + prefs.edit().put_int("brightness", -1).put_int("volume", 50).put_string("name", "test").commit() + + # Reload + prefs2 = SharedPreferences(self.test_app_name, defaults=defaults) + + # brightness matches default, should not be in data + self.assertFalse("brightness" in prefs2.data) + self.assertEqual(prefs2.get_int("brightness"), -1) + + # volume and name have no defaults, should be in data + self.assertIn("volume", prefs2.data) + self.assertIn("name", prefs2.data) + self.assertEqual(prefs2.get_int("volume"), 50) + self.assertEqual(prefs2.get_string("name"), "test") + + diff --git a/tests/test_syspath_restore.py b/tests/test_syspath_restore.py new file mode 100644 index 00000000..36d668d8 --- /dev/null +++ b/tests/test_syspath_restore.py @@ -0,0 +1,78 @@ +import unittest +import sys +import os + +class TestSysPathRestore(unittest.TestCase): + """Test that sys.path is properly restored after execute_script""" + + def test_syspath_restored_after_execute_script(self): + """Test that sys.path is restored to original state after script execution""" + # Import here to ensure we're in the right context + import mpos.apps + + # Capture original sys.path + original_path = sys.path[:] + original_length = len(sys.path) + + # Create a test directory path that would be added + test_cwd = "apps/com.test.app/assets/" + + # Verify the test path is not already in sys.path + self.assertFalse(test_cwd in original_path, + f"Test path {test_cwd} should not be in sys.path initially") + + # Create a simple test script + test_script = ''' +import sys +# Just a simple script that does nothing +x = 42 +''' + + # Call execute_script with cwd parameter + # Note: This will fail because there's no Activity to start, + # but that's fine - we're testing the sys.path restoration + result = mpos.apps.execute_script( + test_script, + is_file=False, + cwd=test_cwd, + classname="NonExistentClass" + ) + + # After execution, sys.path should be restored + current_path = sys.path + current_length = len(sys.path) + + # Verify sys.path has been restored to original + self.assertEqual(current_length, original_length, + f"sys.path length should be restored. Original: {original_length}, Current: {current_length}") + + # Verify the test directory is not in sys.path anymore + self.assertFalse(test_cwd in current_path, + f"Test path {test_cwd} should not be in sys.path after execution. sys.path={current_path}") + + # Verify sys.path matches original + self.assertEqual(current_path, original_path, + f"sys.path should match original.\nOriginal: {original_path}\nCurrent: {current_path}") + + def test_syspath_not_affected_when_no_cwd(self): + """Test that sys.path is unchanged when cwd is None""" + import mpos.apps + + # Capture original sys.path + original_path = sys.path[:] + + test_script = ''' +x = 42 +''' + + # Call without cwd parameter + result = mpos.apps.execute_script( + test_script, + is_file=False, + cwd=None, + classname="NonExistentClass" + ) + + # sys.path should be unchanged + self.assertEqual(sys.path, original_path, + "sys.path should be unchanged when cwd is None") diff --git a/tests/test_websocket.py b/tests/test_websocket.py new file mode 100644 index 00000000..ed81e8ea --- /dev/null +++ b/tests/test_websocket.py @@ -0,0 +1,114 @@ +import asyncio +import unittest +import _thread +import time + +from mpos import App, PackageManager +from mpos import TaskManager +import mpos.apps + +from websocket import WebSocketApp + +class TestMutlipleWebsocketsAsyncio(unittest.TestCase): + + max_allowed_connections = 3 # max that echo.websocket.org allows + + #relays = ["wss://echo.websocket.org" ] + #relays = ["wss://echo.websocket.org", "wss://echo.websocket.org"] + #relays = ["wss://echo.websocket.org", "wss://echo.websocket.org", "wss://echo.websocket.org" ] # more gives "too many requests" error + relays = ["wss://echo.websocket.org", "wss://echo.websocket.org", "wss://echo.websocket.org", "wss://echo.websocket.org", "wss://echo.websocket.org" ] # more might give "too many requests" error + wslist = [] + + on_open_called = 0 + on_message_called = 0 + on_ping_called = 0 + on_close_called = 0 + on_error_called = 0 + + def on_message(self, wsapp, message: str): + print(f"on_message received: {message}") + self.on_message_called = True + + def on_open(self, wsapp): + print(f"on_open called: {wsapp}") + self.on_open_called += 1 + #wsapp.send('{"type": "subscribe","product_ids": ["BTC-USD"],"channels": ["ticker_batch"]}') + + def on_ping(wsapp, message): + print("Got a ping!") + self.on_ping_called = True + + def on_close(self, wsapp, close_status_code, close_msg): + print(f"on_close called: {wsapp}") + self.on_close_called += 1 + + def on_error(self, wsapp, arg1): + print(f"on_error called: {wsapp}, {arg1}") + self.on_error_called += 1 + + async def closeall(self): + await asyncio.sleep(1) + + self.on_close_called = 0 + print("disconnecting...") + for ws in self.wslist: + await ws.close() + + async def run_main(self) -> None: + tasks = [] + self.wslist = [] + for idx, wsurl in enumerate(self.relays): + print(f"creating WebSocketApp for {wsurl}") + ws = WebSocketApp( + wsurl, + on_open=self.on_open, + on_close=self.on_close, + on_message=self.on_message, + on_ping=self.on_ping, + on_error=self.on_error + ) + print(f"creating task for {wsurl}") + tasks.append(asyncio.create_task(ws.run_forever(),)) + print(f"created task for {wsurl}") + self.wslist.append(ws) + + print(f"Starting {len(tasks)} concurrent WebSocket connections…") + await asyncio.sleep(2) + await self.closeall() + + for _ in range(10): + print(f"self.on_open_called: {self.on_open_called} so waiting for on_open to be called...") + if self.on_open_called == min(len(self.relays),self.max_allowed_connections): + print("yes, it was called!") + break + await asyncio.sleep(1) + self.assertTrue(self.on_open_called == min(len(self.relays),self.max_allowed_connections)) + + for _ in range(10): + print(f"self.on_close_called: {self.on_close_called} so waiting for on_close to be called...") + if self.on_close_called >= min(len(self.relays),self.max_allowed_connections): + print("yes, it was called!") + break + await asyncio.sleep(1) + self.assertGreaterEqual(self.on_close_called, min(len(self.relays),self.max_allowed_connections), "on_close was called for less than allowed connections") + + self.assertEqual(self.on_error_called, max(0, len(self.relays) - self.max_allowed_connections), "expecting one error per failed connection") + + # Wait for *all* of them to finish (or be cancelled) + # If this hangs, it's also a failure: + print(f"doing gather of tasks: {tasks}") + for index, task in enumerate(tasks): print(f"task {index}: ph_key:{task.ph_key} done:{task.done()} running {task.coro}") + await asyncio.gather(*tasks, return_exceptions=True) + + def wait_for_ping(self): + self.on_ping_called = False + for _ in range(60): + print("Waiting for on_ping to be called...") + if self.on_ping_called: + print("yes, it was called!") + break + time.sleep(1) + self.assertTrue(self.on_ping_called) + + def test_it(self): + asyncio.run(self.run_main()) diff --git a/tests/test_wifi_service.py b/tests/test_wifi_service.py new file mode 100644 index 00000000..705b85d4 --- /dev/null +++ b/tests/test_wifi_service.py @@ -0,0 +1,713 @@ +import unittest +import sys + +# Add tests directory to path for network_test_helper +sys.path.insert(0, '../tests') + +# Import network test helpers +from network_test_helper import MockNetwork, MockTime + +# Mock config classes +class MockSharedPreferences: + """Mock SharedPreferences for testing.""" + _all_data = {} # Class-level storage + + def __init__(self, app_id): + self.app_id = app_id + if app_id not in MockSharedPreferences._all_data: + MockSharedPreferences._all_data[app_id] = {} + + def get_dict(self, key): + return MockSharedPreferences._all_data.get(self.app_id, {}).get(key, {}) + + def edit(self): + return MockEditor(self) + + @classmethod + def reset_all(cls): + cls._all_data = {} + + +class MockEditor: + """Mock editor for SharedPreferences.""" + + def __init__(self, prefs): + self.prefs = prefs + self.pending = {} + + def put_dict(self, key, value): + self.pending[key] = value + + def commit(self): + if self.prefs.app_id not in MockSharedPreferences._all_data: + MockSharedPreferences._all_data[self.prefs.app_id] = {} + MockSharedPreferences._all_data[self.prefs.app_id].update(self.pending) + + +# Create mock mpos module +class MockMpos: + """Mock mpos module with config and time.""" + + class config: + @staticmethod + def SharedPreferences(app_id): + return MockSharedPreferences(app_id) + + class time: + @staticmethod + def sync_time(): + pass # No-op for testing + + +# Inject mocks before importing WifiService +sys.modules['mpos'] = MockMpos +sys.modules['mpos.config'] = MockMpos.config +sys.modules['mpos.time'] = MockMpos.time + +# Add path to wifi_service.py +sys.path.append('lib/mpos/net') + +# Import WifiService +from wifi_service import WifiService + + +class TestWifiServiceConnect(unittest.TestCase): + """Test WifiService.connect() method.""" + + def setUp(self): + """Set up test fixtures.""" + MockSharedPreferences.reset_all() + WifiService.access_points = {} + WifiService.wifi_busy = False + + def tearDown(self): + """Clean up after test.""" + WifiService.access_points = {} + WifiService.wifi_busy = False + + def test_connect_to_saved_network(self): + """Test connecting to a saved network.""" + mock_network = MockNetwork(connected=False) + WifiService.access_points = { + "TestNetwork": {"password": "testpass123"} + } + + # Configure mock scan results + mock_wlan = mock_network.WLAN(mock_network.STA_IF) + mock_wlan._scan_results = [(b"TestNetwork", -50, 1, 3, b"", 0)] + + # Mock connect to succeed immediately + def mock_connect(ssid, password): + mock_wlan._connected = True + + mock_wlan.connect = mock_connect + + result = WifiService.connect(network_module=mock_network) + + self.assertTrue(result) + + def test_connect_with_no_saved_networks(self): + """Test connecting when no networks are saved.""" + mock_network = MockNetwork(connected=False) + WifiService.access_points = {} + + mock_wlan = mock_network.WLAN(mock_network.STA_IF) + mock_wlan._scan_results = [(b"UnsavedNetwork", -50, 1, 3, b"", 0)] + + result = WifiService.connect(network_module=mock_network) + + self.assertFalse(result) + + def test_connect_when_no_saved_networks_available(self): + """Test connecting when saved networks aren't in range.""" + mock_network = MockNetwork(connected=False) + WifiService.access_points = { + "SavedNetwork": {"password": "password123"} + } + + mock_wlan = mock_network.WLAN(mock_network.STA_IF) + mock_wlan._scan_results = [(b"DifferentNetwork", -50, 1, 3, b"", 0)] + + result = WifiService.connect(network_module=mock_network) + + self.assertFalse(result) + + +class TestWifiServiceAttemptConnecting(unittest.TestCase): + """Test WifiService.attempt_connecting() method.""" + + def test_successful_connection(self): + """Test successful WiFi connection.""" + mock_network = MockNetwork(connected=False) + mock_time = MockTime() + + mock_wlan = mock_network.WLAN(mock_network.STA_IF) + + # Mock connect to succeed immediately + call_count = [0] + + def mock_connect(ssid, password): + pass # Don't set connected yet + + def mock_isconnected(): + call_count[0] += 1 + if call_count[0] >= 1: + return True + return False + + mock_wlan.connect = mock_connect + mock_wlan.isconnected = mock_isconnected + + result = WifiService.attempt_connecting( + "TestSSID", + "testpass", + network_module=mock_network, + time_module=mock_time + ) + + self.assertTrue(result) + + def test_connection_timeout(self): + """Test connection timeout after 10 attempts.""" + mock_network = MockNetwork(connected=False) + mock_time = MockTime() + + mock_wlan = mock_network.WLAN(mock_network.STA_IF) + + # Connection never succeeds + def mock_isconnected(): + return False + + mock_wlan.isconnected = mock_isconnected + + result = WifiService.attempt_connecting( + "TestSSID", + "testpass", + network_module=mock_network, + time_module=mock_time + ) + + self.assertFalse(result) + # Should have slept 10 times + self.assertEqual(len(mock_time.get_sleep_calls()), 10) + + def test_connection_aborted_when_wifi_disabled(self): + """Test connection aborts if WiFi is disabled during attempt.""" + mock_network = MockNetwork(connected=False) + mock_time = MockTime() + + mock_wlan = mock_network.WLAN(mock_network.STA_IF) + + # Never connected + def mock_isconnected(): + return False + + # WiFi becomes inactive on 3rd check + check_count = [0] + + def mock_active(state=None): + if state is not None: + mock_wlan._active = state + return None + check_count[0] += 1 + if check_count[0] >= 3: + return False + return True + + mock_wlan.isconnected = mock_isconnected + mock_wlan.active = mock_active + + result = WifiService.attempt_connecting( + "TestSSID", + "testpass", + network_module=mock_network, + time_module=mock_time + ) + + self.assertFalse(result) + # Should have checked less than 10 times (aborted early) + self.assertTrue(check_count[0] < 10) + + def test_connection_error_handling(self): + """Test handling of connection errors.""" + mock_network = MockNetwork(connected=False) + mock_time = MockTime() + + mock_wlan = mock_network.WLAN(mock_network.STA_IF) + + def raise_error(ssid, password): + raise Exception("Connection failed") + + mock_wlan.connect = raise_error + + result = WifiService.attempt_connecting( + "TestSSID", + "testpass", + network_module=mock_network, + time_module=mock_time + ) + + self.assertFalse(result) + + +class TestWifiServiceAutoConnect(unittest.TestCase): + """Test WifiService.auto_connect() method.""" + + def setUp(self): + """Set up test fixtures.""" + MockSharedPreferences.reset_all() + WifiService.access_points = {} + WifiService.wifi_busy = False + + def tearDown(self): + """Clean up after test.""" + WifiService.access_points = {} + WifiService.wifi_busy = False + MockSharedPreferences.reset_all() + + def test_auto_connect_with_no_saved_networks(self): + """Test auto_connect when no networks are saved.""" + WifiService.auto_connect() + + # Should exit early + self.assertEqual(len(WifiService.access_points), 0) + + def test_auto_connect_when_wifi_busy(self): + """Test auto_connect aborts when WiFi is busy.""" + # Save a network + prefs = MockSharedPreferences("com.micropythonos.system.wifiservice") + editor = prefs.edit() + editor.put_dict("access_points", {"TestNet": {"password": "pass"}}) + editor.commit() + + # Set WiFi as busy + WifiService.wifi_busy = True + + WifiService.auto_connect() + + # Should still be busy (not changed) + self.assertTrue(WifiService.wifi_busy) + + def test_auto_connect_desktop_mode(self): + """Test auto_connect in desktop mode (no network module).""" + mock_time = MockTime() + + # Save a network + prefs = MockSharedPreferences("com.micropythonos.system.wifiservice") + editor = prefs.edit() + editor.put_dict("access_points", {"TestNet": {"password": "pass"}}) + editor.commit() + + WifiService.auto_connect(network_module=None, time_module=mock_time) + + # Should have "slept" to simulate connection + self.assertTrue(len(mock_time.get_sleep_calls()) > 0) + # Should clear wifi_busy flag + self.assertFalse(WifiService.wifi_busy) + + +class TestWifiServiceIsConnected(unittest.TestCase): + """Test WifiService.is_connected() method.""" + + def setUp(self): + """Set up test fixtures.""" + WifiService.wifi_busy = False + + def tearDown(self): + """Clean up after test.""" + WifiService.wifi_busy = False + + def test_is_connected_when_connected(self): + """Test is_connected returns True when WiFi is connected.""" + mock_network = MockNetwork(connected=True) + + result = WifiService.is_connected(network_module=mock_network) + + self.assertTrue(result) + + def test_is_connected_when_disconnected(self): + """Test is_connected returns False when WiFi is disconnected.""" + mock_network = MockNetwork(connected=False) + + result = WifiService.is_connected(network_module=mock_network) + + self.assertFalse(result) + + def test_is_connected_when_wifi_busy(self): + """Test is_connected returns False when WiFi is busy.""" + mock_network = MockNetwork(connected=True) + WifiService.wifi_busy = True + + result = WifiService.is_connected(network_module=mock_network) + + # Should return False even though connected + self.assertFalse(result) + + def test_is_connected_desktop_mode(self): + """Test is_connected in desktop mode.""" + result = WifiService.is_connected(network_module=None) + + # Desktop mode always returns True + self.assertTrue(result) + + +class TestWifiServiceNetworkManagement(unittest.TestCase): + """Test network save/forget functionality.""" + + def setUp(self): + """Set up test fixtures.""" + MockSharedPreferences.reset_all() + WifiService.access_points = {} + + def tearDown(self): + """Clean up after test.""" + WifiService.access_points = {} + MockSharedPreferences.reset_all() + + def test_save_network(self): + """Test saving a network.""" + WifiService.save_network("MyNetwork", "mypassword123") + + # Should be in class-level cache + self.assertTrue("MyNetwork" in WifiService.access_points) + self.assertEqual(WifiService.access_points["MyNetwork"]["password"], "mypassword123") + + # Should be persisted + prefs = MockSharedPreferences("com.micropythonos.system.wifiservice") + saved = prefs.get_dict("access_points") + self.assertTrue("MyNetwork" in saved) + + def test_save_network_updates_existing(self): + """Test updating an existing saved network.""" + WifiService.save_network("MyNetwork", "oldpassword") + WifiService.save_network("MyNetwork", "newpassword") + + # Should have new password + self.assertEqual(WifiService.access_points["MyNetwork"]["password"], "newpassword") + + def test_forget_network(self): + """Test forgetting a saved network.""" + WifiService.save_network("MyNetwork", "mypassword") + + result = WifiService.forget_network("MyNetwork") + + self.assertTrue(result) + self.assertFalse("MyNetwork" in WifiService.access_points) + + def test_forget_nonexistent_network(self): + """Test forgetting a network that doesn't exist.""" + result = WifiService.forget_network("NonExistent") + + self.assertFalse(result) + + def test_get_saved_networks(self): + """Test getting list of saved networks.""" + WifiService.save_network("Network1", "pass1") + WifiService.save_network("Network2", "pass2") + WifiService.save_network("Network3", "pass3") + + saved = WifiService.get_saved_networks() + + self.assertEqual(len(saved), 3) + self.assertTrue("Network1" in saved) + self.assertTrue("Network2" in saved) + self.assertTrue("Network3" in saved) + + def test_get_saved_networks_empty(self): + """Test getting saved networks when none exist.""" + saved = WifiService.get_saved_networks() + + self.assertEqual(len(saved), 0) + + +class TestWifiServiceDisconnect(unittest.TestCase): + """Test WifiService.disconnect() method.""" + + def test_disconnect(self): + """Test disconnecting from WiFi.""" + mock_network = MockNetwork(connected=True) + mock_wlan = mock_network.WLAN(mock_network.STA_IF) + + # Track calls + disconnect_called = [False] + active_false_called = [False] + + def mock_disconnect(): + disconnect_called[0] = True + + def mock_active(state=None): + if state is False: + active_false_called[0] = True + return True if state is None else None + + mock_wlan.disconnect = mock_disconnect + mock_wlan.active = mock_active + + WifiService.disconnect(network_module=mock_network) + + # Should have called both + self.assertTrue(disconnect_called[0]) + self.assertTrue(active_false_called[0]) + + def test_disconnect_desktop_mode(self): + """Test disconnect in desktop mode.""" + # Should not raise an error + WifiService.disconnect(network_module=None) + + +class TestWifiServiceRSSISorting(unittest.TestCase): + """Test RSSI-based network prioritization.""" + + def setUp(self): + """Set up test fixtures.""" + MockSharedPreferences.reset_all() + WifiService.access_points = {} + WifiService.wifi_busy = False + + def tearDown(self): + """Clean up after tests.""" + WifiService.access_points = {} + WifiService.wifi_busy = False + MockSharedPreferences.reset_all() + + def test_networks_sorted_by_rssi_strongest_first(self): + """Test that networks are sorted by RSSI with strongest first.""" + # Create mock networks with different RSSI values + # Format: (ssid, bssid, channel, rssi, security, hidden) + mock_network = MockNetwork(connected=False) + mock_wlan = mock_network.WLAN(mock_network.STA_IF) + + # Unsorted networks (weak, strong, medium) + mock_wlan._scan_results = [ + (b'WeakNetwork', b'\xaa\xbb\xcc\xdd\xee\xff', 6, -85, 3, False), + (b'StrongNetwork', b'\x11\x22\x33\x44\x55\x66', 1, -45, 3, False), + (b'MediumNetwork', b'\x77\x88\x99\xaa\xbb\xcc', 11, -65, 3, False), + ] + + # Configure all as saved networks + WifiService.access_points = { + 'WeakNetwork': {'password': 'weak123'}, + 'StrongNetwork': {'password': 'strong123'}, + 'MediumNetwork': {'password': 'medium123'} + } + + # Track connection attempts + connection_attempts = [] + + def mock_connect(ssid, password): + connection_attempts.append(ssid) + # Succeed on first attempt + mock_wlan._connected = True + + mock_wlan.connect = mock_connect + + result = WifiService.connect(network_module=mock_network) + + self.assertTrue(result) + # Should try strongest first (-45 dBm) + self.assertEqual(connection_attempts[0], 'StrongNetwork') + # Should only try one (first succeeds) + self.assertEqual(len(connection_attempts), 1) + + def test_multiple_networks_tried_in_rssi_order(self): + """Test that multiple networks are tried in RSSI order when first fails.""" + mock_network = MockNetwork(connected=False) + mock_wlan = mock_network.WLAN(mock_network.STA_IF) + + # Three networks with different signal strengths + mock_wlan._scan_results = [ + (b'BadNetwork1', b'\xaa\xbb\xcc\xdd\xee\xff', 1, -40, 3, False), + (b'BadNetwork2', b'\x11\x22\x33\x44\x55\x66', 6, -50, 3, False), + (b'GoodNetwork', b'\x77\x88\x99\xaa\xbb\xcc', 11, -60, 3, False), + ] + + WifiService.access_points = { + 'BadNetwork1': {'password': 'pass1'}, + 'BadNetwork2': {'password': 'pass2'}, + 'GoodNetwork': {'password': 'pass3'} + } + + # Track attempts and make first two fail + connection_attempts = [] + + def mock_connect(ssid, password): + connection_attempts.append(ssid) + # Only succeed on third attempt + if len(connection_attempts) >= 3: + mock_wlan._connected = True + + mock_wlan.connect = mock_connect + + result = WifiService.connect(network_module=mock_network) + + self.assertTrue(result) + # Verify order: strongest to weakest + self.assertEqual(connection_attempts[0], 'BadNetwork1') # RSSI -40 + self.assertEqual(connection_attempts[1], 'BadNetwork2') # RSSI -50 + self.assertEqual(connection_attempts[2], 'GoodNetwork') # RSSI -60 + self.assertEqual(len(connection_attempts), 3) + + def test_duplicate_ssid_strongest_tried_first(self): + """Test that with duplicate SSIDs, strongest signal is tried first.""" + mock_network = MockNetwork(connected=False) + mock_wlan = mock_network.WLAN(mock_network.STA_IF) + + # Real-world scenario: Multiple APs with same SSID + mock_wlan._scan_results = [ + (b'MyNetwork', b'\xaa\xbb\xcc\xdd\xee\xff', 1, -70, 3, False), + (b'MyNetwork', b'\x11\x22\x33\x44\x55\x66', 6, -50, 3, False), # Strongest + (b'MyNetwork', b'\x77\x88\x99\xaa\xbb\xcc', 11, -85, 3, False), + ] + + WifiService.access_points = { + 'MyNetwork': {'password': 'mypass123'} + } + + connection_attempts = [] + + def mock_connect(ssid, password): + connection_attempts.append(ssid) + # Succeed on first + mock_wlan._connected = True + + mock_wlan.connect = mock_connect + + result = WifiService.connect(network_module=mock_network) + + self.assertTrue(result) + # Should only try once (first is strongest and succeeds) + self.assertEqual(len(connection_attempts), 1) + self.assertEqual(connection_attempts[0], 'MyNetwork') + + def test_rssi_order_with_real_scan_data(self): + """Test with real scan data from actual ESP32 device.""" + mock_network = MockNetwork(connected=False) + mock_wlan = mock_network.WLAN(mock_network.STA_IF) + + # Real scan output from user's example + mock_wlan._scan_results = [ + (b'Channel 8', b'\xde\xec^\x8f\x00A', 11, -47, 3, False), + (b'Baptistus', b'\xd8\xec^\x8f\x00A', 11, -48, 7, False), + (b'telenet-BD74DC9', b'TgQ>t\xe7', 11, -70, 3, False), + (b'Galaxy S10+64bf', b'b\x19\xdf\xef\xb0\x8f', 11, -83, 3, False), + (b'Najeeb\xe2\x80\x99s iPhone', b"F\x07'\xb8\x0b0", 6, -84, 7, False), + (b'DIRECT-83-HP OfficeJet Pro 7740', b'\x1a`$dk\x83', 1, -87, 3, False), + (b'Channel 8', b'\xde\xec^\xe1#w', 1, -91, 3, False), + (b'Baptistus', b'\xd8\xec^\xe1#w', 1, -91, 7, False), + (b'Proximus-Home-596457', b'\xf4\x05\x95\xf9A\xf1', 1, -93, 3, False), + (b'Proximus-Home-596457', b'\xcc\x00\xf1j}\x94', 1, -93, 3, False), + (b'BASE-9104320', b'4,\xc4\xe7\x01\xb7', 1, -94, 3, False), + ] + + # Save several networks + WifiService.access_points = { + 'Channel 8': {'password': 'pass1'}, + 'Baptistus': {'password': 'pass2'}, + 'telenet-BD74DC9': {'password': 'pass3'}, + 'Galaxy S10+64bf': {'password': 'pass4'}, + } + + # Track attempts and fail first to see ordering + connection_attempts = [] + + def mock_connect(ssid, password): + connection_attempts.append(ssid) + # Succeed on second attempt + if len(connection_attempts) >= 2: + mock_wlan._connected = True + + mock_wlan.connect = mock_connect + + result = WifiService.connect(network_module=mock_network) + + self.assertTrue(result) + # Expected order: Channel 8 (-47), Baptistus (-48), telenet (-70), Galaxy (-83) + self.assertEqual(connection_attempts[0], 'Channel 8') + self.assertEqual(connection_attempts[1], 'Baptistus') + self.assertEqual(len(connection_attempts), 2) + + def test_sorting_preserves_network_data_integrity(self): + """Test that sorting doesn't corrupt or lose network data.""" + mock_network = MockNetwork(connected=False) + mock_wlan = mock_network.WLAN(mock_network.STA_IF) + + # Networks with various attributes + mock_wlan._scan_results = [ + (b'Net3', b'\xaa\xaa\xaa\xaa\xaa\xaa', 11, -90, 3, False), + (b'Net1', b'\xbb\xbb\xbb\xbb\xbb\xbb', 1, -40, 7, True), # Hidden + (b'Net2', b'\xcc\xcc\xcc\xcc\xcc\xcc', 6, -60, 2, False), + ] + + WifiService.access_points = { + 'Net1': {'password': 'p1'}, + 'Net2': {'password': 'p2'}, + 'Net3': {'password': 'p3'} + } + + # Track attempts to verify all are tried + connection_attempts = [] + + def mock_connect(ssid, password): + connection_attempts.append(ssid) + # Never succeed, try all + pass + + mock_wlan.connect = mock_connect + + result = WifiService.connect(network_module=mock_network) + + self.assertFalse(result) # No connection succeeded + # Verify all 3 were attempted in RSSI order + self.assertEqual(len(connection_attempts), 3) + self.assertEqual(connection_attempts[0], 'Net1') # RSSI -40 + self.assertEqual(connection_attempts[1], 'Net2') # RSSI -60 + self.assertEqual(connection_attempts[2], 'Net3') # RSSI -90 + + def test_no_saved_networks_in_scan(self): + """Test behavior when scan finds no saved networks.""" + mock_network = MockNetwork(connected=False) + mock_wlan = mock_network.WLAN(mock_network.STA_IF) + + mock_wlan._scan_results = [ + (b'UnknownNet1', b'\xaa\xbb\xcc\xdd\xee\xff', 1, -50, 3, False), + (b'UnknownNet2', b'\x11\x22\x33\x44\x55\x66', 6, -60, 3, False), + ] + + WifiService.access_points = { + 'SavedNetwork': {'password': 'pass123'} + } + + connection_attempts = [] + + def mock_connect(ssid, password): + connection_attempts.append(ssid) + + mock_wlan.connect = mock_connect + + result = WifiService.connect(network_module=mock_network) + + self.assertFalse(result) + # No attempts should be made + self.assertEqual(len(connection_attempts), 0) + + def test_rssi_logging_shows_signal_strength(self): + """Test that RSSI value is logged during scan (for debugging).""" + # This is more of a documentation test to verify the log format + mock_network = MockNetwork(connected=False) + mock_wlan = mock_network.WLAN(mock_network.STA_IF) + + mock_wlan._scan_results = [ + (b'TestNet', b'\xaa\xbb\xcc\xdd\xee\xff', 1, -55, 3, False), + ] + + WifiService.access_points = { + 'TestNet': {'password': 'pass'} + } + + # The connect method now logs "Found network 'TestNet' (RSSI: -55 dBm)" + # This test just verifies it doesn't crash + result = WifiService.connect(network_module=mock_network) + # Since mock doesn't actually connect, this will likely be False + # but the important part is the code runs without error + + diff --git a/tests/tests/check_syntax.sh b/tests/tests/check_syntax.sh deleted file mode 100755 index 62c9623c..00000000 --- a/tests/tests/check_syntax.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/bin/bash -mydir=$(readlink -f "$0") -mydir=$(dirname "$mydir") -fs="$mydir"/../../internal_filesystem/ -cross="$mydir"/../../lvgl_micropython/lib/micropython/mpy-cross/build/mpy-cross - -failed=0 -while read file; do - "$cross" -march=x64 -o /dev/null "$file" - exitcode="$?" - if [ $exitcode -ne 0 ]; then - echo "$file got exitcode $exitcode" - failed=$(expr $failed \+ 1) - fi -done < <(find "$fs" -iname "*.py") - -if [ $failed -ne 0 ]; then - echo "ERROR: $failed .py files have syntax errors" - exit 1 -fi diff --git a/tests/unittest.sh b/tests/unittest.sh index f27d096d..b7959cba 100755 --- a/tests/unittest.sh +++ b/tests/unittest.sh @@ -3,10 +3,26 @@ mydir=$(readlink -f "$0") mydir=$(dirname "$mydir") testdir="$mydir" +#testdir=/home/user/projects/MicroPythonOS/claude/MicroPythonOS/tests2 scriptdir=$(readlink -f "$mydir"/../scripts/) fs="$mydir"/../internal_filesystem/ -onetest="$1" +mpremote="$mydir"/../lvgl_micropython/lib/micropython/tools/mpremote/mpremote.py +# Parse arguments +ondevice="" +onetest="" + +while [ $# -gt 0 ]; do + case "$1" in + --ondevice) + ondevice="yes" + ;; + *) + onetest="$1" + ;; + esac + shift +done # print os and set binary os_name=$(uname -s) @@ -24,43 +40,115 @@ chmod +x "$binary" one_test() { file="$1" + if [ ! -f "$file" ]; then + echo "ERROR: $file is not a regular, existing file!" + exit 1 + fi pushd "$fs" echo "Testing $file" - "$binary" -X heapsize=8M -c "import sys ; sys.path.append('lib') + + # Detect if this is a graphical test (filename contains "graphical") + if echo "$file" | grep -q "graphical"; then + echo "Detected graphical test - including boot and main files" + is_graphical=1 + # Get absolute path to tests directory for imports + tests_abs_path=$(readlink -f "$testdir") + else + is_graphical=0 + fi + + if [ -z "$ondevice" ]; then + # Desktop execution + if [ $is_graphical -eq 1 ]; then + echo "Graphical test: include main.py" + "$binary" -X heapsize=8M -c "import sys ; sys.path.insert(0, 'lib') ; import mpos ; mpos.TaskManager.disable() ; $(cat main.py) ; import mpos.apps; sys.path.append(\"$tests_abs_path\") $(cat $file) result = unittest.main() ; sys.exit(0 if result.wasSuccessful() else 1) " - result=$? + result=$? + else + # Regular test: no boot files + "$binary" -X heapsize=8M -c "import sys ; sys.path.insert(0, 'lib') ; import mpos ; mpos.TaskManager.disable() ; $(cat main.py) +$(cat $file) +result = unittest.main() ; sys.exit(0 if result.wasSuccessful() else 1) " + result=$? + fi + else + if [ ! -z "$ondevice" ]; then + echo "Hack: reset the device to make sure no previous UnitTest classes have been registered..." + "$mpremote" reset + sleep 15 + fi + + echo "Device execution" + # NOTE: On device, the OS is already running with boot.py and main.py executed, + # so we don't need to (and shouldn't) re-run them. The system is already initialized. + cleanname=$(echo "$file" | sed "s#/#_#g") + testlog=/tmp/"$cleanname".log + echo "$test logging to $testlog" + if [ $is_graphical -eq 1 ]; then + # Graphical test: system already initialized, just add test paths + "$mpremote" exec "import sys ; sys.path.insert(0, 'lib') ; import mpos ; mpos.TaskManager.disable() ; $(cat main.py) ; sys.path.append('tests') +$(cat $file) +result = unittest.main() +if result.wasSuccessful(): + print('TEST WAS A SUCCESS') +else: + print('TEST WAS A FAILURE') +" | tee "$testlog" + else + # Regular test: no boot files + "$mpremote" exec "import sys ; sys.path.insert(0, 'lib') ; import mpos ; mpos.TaskManager.disable() ; $(cat main.py) +$(cat $file) +result = unittest.main() +if result.wasSuccessful(): + print('TEST WAS A SUCCESS') +else: + print('TEST WAS A FAILURE') +" | tee "$testlog" + fi + grep -q "TEST WAS A SUCCESS" "$testlog" + result=$? + fi popd return "$result" } failed=0 +ran=0 if [ -z "$onetest" ]; then - echo "Usage: $0 [one_test_to_run.py]" + echo "Usage: $0 [one_test_to_run.py] [--ondevice]" echo "Example: $0 tests/simple.py" + echo "Example: $0 tests/simple.py --ondevice" + echo "Example: $0 --ondevice" + echo + echo "If no test is specified: run all tests from $testdir on local machine." echo - echo "If no test is specified: run all tests from $testdir" - while read file; do + echo "The '--ondevice' flag will run the test(s) on a connected device using mpremote.py (should be on the PATH) over a serial connection." + files=$(find "$testdir" -iname "test_*.py" ) + for file in $files; do one_test "$file" result=$? if [ $result -ne 0 ]; then - echo "test $file got error $result" + echo -e "\n\n\nWARNING: test $file got error $result !!!\n\n\n" failed=$(expr $failed \+ 1) + exit 1 + else + ran=$(expr $ran \+ 1) fi - - done < <( find "$testdir" -iname "*.py" ) + done else + echo "doing $onetest" one_test $(readlink -f "$onetest") [ $? -ne 0 ] && failed=1 fi if [ $failed -ne 0 ]; then - echo "ERROR: $failed .py files have failing unit tests" + echo "ERROR: $failed of the $ran tests failed" exit 1 else - echo "GOOD: no .py files have failing unit tests" + echo "GOOD: none of the $ran tests failed" exit 0 fi