diff --git a/.github/workflows/linux-arm64.yml b/.github/workflows/linux-arm64.yml new file mode 100644 index 00000000..c5d461c6 --- /dev/null +++ b/.github/workflows/linux-arm64.yml @@ -0,0 +1,93 @@ +name: Build LVGL MicroPython on Linux for arm64 + +on: + push: + branches: + - main + pull_request: + branches: + - main + workflow_dispatch: # allow manual workflow starts + +jobs: + build: + runs-on: ubuntu-24.04-arm + + steps: + - name: Checkout repository with submodules + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install lvgl_micropython dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + build-essential \ + libffi-dev \ + pkg-config \ + cmake \ + ninja-build \ + gnome-desktop-testing \ + libasound2-dev \ + libpulse-dev \ + libaudio-dev \ + libjack-dev \ + libsndio-dev \ + libx11-dev \ + libxext-dev \ + libxrandr-dev \ + libxcursor-dev \ + libxfixes-dev \ + libxi-dev \ + libxss-dev \ + libxkbcommon-dev \ + libdrm-dev \ + libgbm-dev \ + libgl1-mesa-dev \ + libgles2-mesa-dev \ + libegl1-mesa-dev \ + libdbus-1-dev \ + libibus-1.0-dev \ + libudev-dev \ + fcitx-libs-dev \ + libpipewire-0.3-dev \ + libwayland-dev \ + libdecor-0-dev + + - name: Install additional MicroPythonOS dependencies + run: | + sudo apt-get update + sudo apt-get install -y libv4l-dev + + - name: Extract OS version + id: version + run: | + OS_VERSION=$(grep "release = " internal_filesystem/lib/mpos/build_info.py | cut -d "=" -f 2 | cut -d "#" -f 1 | tr -d " " | tr -d '"') + echo "OS_VERSION=$OS_VERSION" >> $GITHUB_OUTPUT + echo "Extracted version: $OS_VERSION" + + - name: Build LVGL MicroPython for unix + run: | + ./scripts/build_mpos.sh unix + cp lvgl_micropython/build/lvgl_micropy_unix lvgl_micropython/build/MicroPythonOS_arm64_linux_${{ steps.version.outputs.OS_VERSION }}.elf + + - name: Upload built binary as artifact + uses: actions/upload-artifact@v4 + with: + name: MicroPythonOS_arm64_linux_${{ steps.version.outputs.OS_VERSION }}.elf + path: lvgl_micropython/build/MicroPythonOS_arm64_linux_${{ steps.version.outputs.OS_VERSION }}.elf + retention-days: 7 + + - name: Run syntax tests on unix + run: | + ./tests/syntax.sh + continue-on-error: true + + - name: Run unit tests on unix + run: | + ./tests/unittest.sh + continue-on-error: true + + + diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index 0d546813..f541afd3 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -1,4 +1,4 @@ -name: Build LVGL MicroPython on Linux +name: Build LVGL MicroPython for Linux x64, esp32 and esp32s3 on: push: @@ -11,7 +11,7 @@ on: jobs: build: - runs-on: ubuntu-latest # Ubuntu is Debian-based and commonly used in GitHub Actions + runs-on: ubuntu-24.04 steps: - name: Checkout repository with submodules @@ -63,13 +63,21 @@ jobs: - name: Extract OS version id: version run: | - OS_VERSION=$(grep CURRENT_OS_VERSION internal_filesystem/lib/mpos/info.py | cut -d "=" -f 2 | tr -d " " | tr -d '"') + OS_VERSION=$(grep "release = " internal_filesystem/lib/mpos/build_info.py | cut -d "=" -f 2 | cut -d "#" -f 1 | tr -d " " | tr -d '"') echo "OS_VERSION=$OS_VERSION" >> $GITHUB_OUTPUT echo "Extracted version: $OS_VERSION" - name: Build LVGL MicroPython for unix run: | ./scripts/build_mpos.sh unix + cp lvgl_micropython/build/lvgl_micropy_unix lvgl_micropython/build/MicroPythonOS_x64_linux_${{ steps.version.outputs.OS_VERSION }}.elf + + - name: Upload built binary as artifact + uses: actions/upload-artifact@v4 + with: + name: MicroPythonOS_x64_linux_${{ steps.version.outputs.OS_VERSION }}.elf + path: lvgl_micropython/build/MicroPythonOS_x64_linux_${{ steps.version.outputs.OS_VERSION }}.elf + retention-days: 7 - name: Run syntax tests on unix run: | @@ -79,21 +87,13 @@ jobs: - name: Run unit tests on unix run: | ./tests/unittest.sh - 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_${{ 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 run: | ./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 + mv lvgl_micropython/build/lvgl_micropy_ESP32_GENERIC-SPIRAM-16.bin lvgl_micropython/build/MicroPythonOS_esp32_${{ steps.version.outputs.OS_VERSION }}.bin + mv lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC-SPIRAM/micropython.bin lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC-SPIRAM/MicroPythonOS_esp32_${{ steps.version.outputs.OS_VERSION }}.ota - name: Upload built binary as artifact uses: actions/upload-artifact@v4 @@ -106,7 +106,27 @@ jobs: uses: actions/upload-artifact@v4 with: 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 + path: lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC-SPIRAM/MicroPythonOS_esp32_${{ steps.version.outputs.OS_VERSION }}.ota + retention-days: 7 + + - name: Build LVGL MicroPython esp32s3 + run: | + ./scripts/build_mpos.sh esp32s3 + mv lvgl_micropython/build/lvgl_micropy_ESP32_GENERIC_S3-SPIRAM_OCT-16.bin lvgl_micropython/build/MicroPythonOS_esp32s3_${{ 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_esp32s3_${{ steps.version.outputs.OS_VERSION }}.ota + + - name: Upload built binary as artifact + uses: actions/upload-artifact@v4 + with: + name: MicroPythonOS_esp32s3_${{ steps.version.outputs.OS_VERSION }}.bin + path: lvgl_micropython/build/MicroPythonOS_esp32s3_${{ steps.version.outputs.OS_VERSION }}.bin + retention-days: 7 + + - name: Upload built binary as artifact + uses: actions/upload-artifact@v4 + with: + name: MicroPythonOS_esp32s3_${{ steps.version.outputs.OS_VERSION }}.ota + path: lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC_S3-SPIRAM_OCT/MicroPythonOS_esp32s3_${{ steps.version.outputs.OS_VERSION }}.ota retention-days: 7 diff --git a/.github/workflows/macos-intel.yml b/.github/workflows/macos-intel.yml new file mode 100644 index 00000000..1d2b3c79 --- /dev/null +++ b/.github/workflows/macos-intel.yml @@ -0,0 +1,101 @@ +name: Build LVGL MicroPython for MacOS Intel, esp32 and esp32s3 + +on: + push: + branches: + - main + pull_request: + branches: + - main + workflow_dispatch: # allow manual workflow starts + +jobs: + build: + runs-on: macos-15-intel + + steps: + - name: Checkout repository with submodules + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install dependencies via Homebrew + run: | + xcode-select --install || true # already installed on github + brew install pkg-config libffi ninja make SDL2 + + - name: Show version numbers + run: | + xcodebuild -version + clang --version + + - name: Extract OS version + id: version + run: | + OS_VERSION=$(grep "release = " internal_filesystem/lib/mpos/build_info.py | cut -d "=" -f 2 | cut -d "#" -f 1 | tr -d " " | tr -d '"') + echo "OS_VERSION=$OS_VERSION" >> $GITHUB_OUTPUT + echo "Extracted version: $OS_VERSION" + + - name: Build LVGL MicroPython for macOS dev + run: | + ./scripts/build_mpos.sh macOS + cp lvgl_micropython/build/lvgl_micropy_macOS lvgl_micropython/build/MicroPythonOS_intel_macOS_${{ steps.version.outputs.OS_VERSION }}.bin + + - name: Run syntax tests on macOS + run: | + ./tests/syntax.sh + continue-on-error: true + + - name: Run unit tests on macOS + run: | + ./tests/unittest.sh + continue-on-error: true + + - name: Upload built binary as artifact + uses: actions/upload-artifact@v4 + with: + name: MicroPythonOS_intel_macOS_${{ steps.version.outputs.OS_VERSION }}.bin + path: lvgl_micropython/build/MicroPythonOS_intel_macOS_${{ steps.version.outputs.OS_VERSION }}.bin + retention-days: 7 + + + - name: Build LVGL MicroPython esp32 + run: | + ./scripts/build_mpos.sh esp32 + mv lvgl_micropython/build/lvgl_micropy_ESP32_GENERIC-SPIRAM-16.bin lvgl_micropython/build/MicroPythonOS_esp32_${{ steps.version.outputs.OS_VERSION }}.bin + mv lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC-SPIRAM/micropython.bin lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC-SPIRAM/MicroPythonOS_esp32_${{ steps.version.outputs.OS_VERSION }}.ota + + - name: Upload built binary as artifact + uses: actions/upload-artifact@v4 + with: + 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_esp32_${{ steps.version.outputs.OS_VERSION }}.ota + path: lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC-SPIRAM/MicroPythonOS_esp32_${{ steps.version.outputs.OS_VERSION }}.ota + retention-days: 7 + + + - name: Build LVGL MicroPython esp32s3 + run: | + ./scripts/build_mpos.sh esp32s3 + mv lvgl_micropython/build/lvgl_micropy_ESP32_GENERIC_S3-SPIRAM_OCT-16.bin lvgl_micropython/build/MicroPythonOS_esp32s3_${{ 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_esp32s3_${{ steps.version.outputs.OS_VERSION }}.ota + + - name: Upload built binary as artifact + uses: actions/upload-artifact@v4 + with: + name: MicroPythonOS_esp32s3_${{ steps.version.outputs.OS_VERSION }}.bin + path: lvgl_micropython/build/MicroPythonOS_esp32s3_${{ steps.version.outputs.OS_VERSION }}.bin + retention-days: 7 + + - name: Upload built binary as artifact + uses: actions/upload-artifact@v4 + with: + name: MicroPythonOS_esp32s3_${{ steps.version.outputs.OS_VERSION }}.ota + path: lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC_S3-SPIRAM_OCT/MicroPythonOS_esp32s3_${{ steps.version.outputs.OS_VERSION }}.ota + retention-days: 7 diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index 7e53cab1..c9ce051a 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -1,4 +1,4 @@ -name: Build LVGL MicroPython for MacOS +name: Build LVGL MicroPython for MacOS arm64 (M1), esp32 and esp32s3 on: push: @@ -11,7 +11,7 @@ on: jobs: build: - runs-on: macos-14 + runs-on: macos-14 # 15 was giving a blank screen for some MacOS users steps: - name: Checkout repository with submodules @@ -29,9 +29,17 @@ jobs: xcodebuild -version clang --version + - name: Extract OS version + id: version + run: | + OS_VERSION=$(grep "release = " internal_filesystem/lib/mpos/build_info.py | cut -d "=" -f 2 | cut -d "#" -f 1 | tr -d " " | tr -d '"') + echo "OS_VERSION=$OS_VERSION" >> $GITHUB_OUTPUT + echo "Extracted version: $OS_VERSION" + - name: Build LVGL MicroPython for macOS dev run: | ./scripts/build_mpos.sh macOS + cp lvgl_micropython/build/lvgl_micropy_macOS lvgl_micropython/build/MicroPythonOS_arm64_macOS_${{ steps.version.outputs.OS_VERSION }}.bin - name: Run syntax tests on macOS run: | @@ -46,34 +54,48 @@ jobs: - name: Upload built binary as artifact uses: actions/upload-artifact@v4 with: - name: lvgl_micropy_macOS.bin - path: lvgl_micropython/build/lvgl_micropy_macOS - compression-level: 0 # don't zip it + name: MicroPythonOS_arm64_macOS_${{ steps.version.outputs.OS_VERSION }}.bin + path: lvgl_micropython/build/MicroPythonOS_arm64_macOS_${{ steps.version.outputs.OS_VERSION }}.bin retention-days: 7 + - name: Build LVGL MicroPython esp32 run: | ./scripts/build_mpos.sh esp32 + mv lvgl_micropython/build/lvgl_micropy_ESP32_GENERIC-SPIRAM-16.bin lvgl_micropython/build/MicroPythonOS_esp32_${{ steps.version.outputs.OS_VERSION }}.bin + mv lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC-SPIRAM/micropython.bin lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC-SPIRAM/MicroPythonOS_esp32_${{ steps.version.outputs.OS_VERSION }}.ota - name: Upload built binary as artifact uses: actions/upload-artifact@v4 with: - name: MicroPythonOS_esp32.bin - path: lvgl_micropython/build/lvgl_micropy_ESP32_GENERIC_S3-SPIRAM_OCT-16.bin - compression-level: 0 # don't zip it + 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_esp32.ota - path: lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC_S3-SPIRAM_OCT/micropython.bin - compression-level: 0 # don't zip it + name: MicroPythonOS_esp32_${{ steps.version.outputs.OS_VERSION }}.ota + path: lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC-SPIRAM/MicroPythonOS_esp32_${{ steps.version.outputs.OS_VERSION }}.ota retention-days: 7 - - name: Cleanup + + - name: Build LVGL MicroPython esp32s3 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 + ./scripts/build_mpos.sh esp32s3 + mv lvgl_micropython/build/lvgl_micropy_ESP32_GENERIC_S3-SPIRAM_OCT-16.bin lvgl_micropython/build/MicroPythonOS_esp32s3_${{ 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_esp32s3_${{ steps.version.outputs.OS_VERSION }}.ota + - name: Upload built binary as artifact + uses: actions/upload-artifact@v4 + with: + name: MicroPythonOS_esp32s3_${{ steps.version.outputs.OS_VERSION }}.bin + path: lvgl_micropython/build/MicroPythonOS_esp32s3_${{ steps.version.outputs.OS_VERSION }}.bin + retention-days: 7 + - name: Upload built binary as artifact + uses: actions/upload-artifact@v4 + with: + name: MicroPythonOS_esp32s3_${{ steps.version.outputs.OS_VERSION }}.ota + path: lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC_S3-SPIRAM_OCT/MicroPythonOS_esp32s3_${{ steps.version.outputs.OS_VERSION }}.ota + retention-days: 7 diff --git a/.gitignore b/.gitignore index 5e87af82..3b505524 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +.* +!.github +!.gitignore + trash/ conf.json* @@ -16,13 +20,23 @@ internal_filesystem_excluded/ # these tests contain actual NWC URLs: tests/manual_test_nwcwallet_alby.py tests/manual_test_nwcwallet_cashu.py +tests/manual_test_nwcwallet_coinos.py # Python cache files (created by CPython when testing imports) __pycache__/ *.py[cod] *$py.class *.so -.Python -# these get created: +# these get created by the build system, don't know why: c_mpos/c_mpos + +# build output +c_mpos/breakout/build + +# build files +*.bin + +# auto created by inline_minify_webrepl.py but still checked in because it's a small file +# and when users download internal_filesystem/ from git (to run a statically compiled Linux build, for example) they need this +# internal_filesystem/builtin/html/webrepl_inlined_minified.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dc26818..5bcea4e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,147 @@ +0.9.0 +===== + +Builtin Apps: +- AppStore: update BadgeHub.eu URL +- About: show netmask separately, make labels focusable +- HowTo: new onboarding app with auto-start handling to explain controls +- Settings: add sub-groups of setings as separate apps, including WiFi app +- Settings: add Hotspot sub-group (SSID, password, security) +- Settings: add WebServer sub-group (autostart, port, password) +- Launcher: ignore launchers and MPOS settings (except WiFi) + +Frameworks: +- Audio streams: WAV playback/recording improvements (duration/progress, hardware volume control) +- AudioManager: registry/session model, multi-speaker/mic routing, ADC-based mic (adc_mic) +- DownloadManager: explicit certificate handling +- InputManager: pointer detection helpers and board registrations +- SensorManager: refactor to IMU drivers with magnetometer support and desktop IIO fallback +- SharedPreferences: fix None handling +- WebServer: new framework with Linux/macOS fixes and no background thread +- WifiService: hotspot support, IP address helpers, simplified connect/auto-connect +- Websocket library: renamed to uaiowebsocket to avoid conflicts + +OS: +- ESP32 boards: bundle WebREPL (not started by default) to offer remote MicroPython shell over the network, accessible through webbrowser +- New board support: LilyGo T-Display-S3 (physical and emulated by QEMU) +- New board support: LilyGo T-Watch S3 Plus +- New board support: M5Stack Fire +- New board support: ODroid Go +- New board support: unPhone 9 +- Fri3d 2024/2026 updates: display reset support using CH32 microcontroller, communicator/expander drivers +- ADC microphone C module and tests +- Build system: switch to static builds for desktop systems to bundle LIBC and fix LIBC version issue +- Build system: add linux-arm64 and macos-intel GitHub workflows to support more precompiled binaries +- Add FreeRTOS module for low-level ESP32 functions + +0.8.0 +===== + +Builtin Apps: +- About: use logger framework +- AppStore: mark BadgeHub backend as 'beta' +- Launcher: improve layout on different screen width sizes +- OSUpdate: remove 'force update' checkbox not in favor of varying button labels + +Frameworks: +- SDCard: add support for SDIO/SD/MMC mode +- CameraManager and CameraActivity: work fully camera-agnostic + +OS: +- Add board support: Makerfabs MaTouch ESP32-S3 SPI IPS 2.8' with Camera OV3660 +- Scale MicroPythonOS boot logo down if necessary +- Don't show battery icon if battery is not supported +- Move logging.py to subdirectory + +0.7.1 +===== + +Builtin Apps: +- Update icons for AppStore, Settings, and Wifi apps + +Frameworks: +- Fix issue with multiple DownloadManager.download_url's on ESP32 due to SSL session sharing/corruption + +0.7.0 +===== + +Builtin Apps: +- Redesign all app icons from scratch for a more consistent style +- About app: show MicroPythonOS logo at the top +- AppStore app: fix BadgeHub backend handling +- OSUpdate app: eliminate requests library +- Settings app: make 'Cancel' button more 'ghost-y' to discourage accidental misclicks + +Frameworks: +- Harmonize frameworks to use same coding patterns +- Rename AudioFlinger to AudioManager framework +- Rename PackageManager to AppManager framework +- Add new AppearanceManager framework +- Add new BatteryManager framework +- Add new DeviceInfo framework +- Add new DisplayMetrics framework +- Add new InputManager framework +- Add new TimeZone framework +- Add new VersionInfo framework +- ActivityNavigator: support pre-instantiated activities so an activity can close a child activity +- SensorManager: add support for LSM6DSO + +OS: +- Show new MicroPythonOS logo at boot +- Replace all compiled binary .mpy files by source copies for transparency (they get compiled during the build, so performance won't suffer) +- Remove dependency on micropython-esp32-ota library +- Remove dependency on traceback library +- Additional board support: Fri3d Camp 2026 (untested) + +0.6.0 +===== +- About app: make more beautiful +- AppStore app: add Settings screen to choose backend +- Camera app and QR scanning: fix aspect ratio for higher resolutions +- WiFi app: check 'hidden' in EditNetwork +- Wifi app: add support for scanning wifi QR codes to 'Add Network' +- Create new SettingsActivity and SettingActivity framework so apps can easily add settings screens with just a few lines of code +- Create CameraManager framework so apps can easily check whether there is a camera available etc. +- Simplify and unify most frameworks to make developing apps easier +- Improve robustness by catching unhandled app exceptions +- Improve robustness with custom exception that does not deinit() the TaskHandler +- Improve robustness by removing TaskHandler callback that throws an uncaught exception +- Don't rate-limit update_ui_threadsafe_if_foreground +- Make 'Power Off' button on desktop exit completely + +0.5.2 +===== +- Fri3d Camp 2024 Board: add I2S microphone as found on the communicator add-on +- API: add TaskManager that wraps asyncio +- API: add DownloadManager that uses TaskManager +- API: use aiorepl to eliminate another thread +- AudioFlinger API: add support for I2S microphone recording to WAV +- AudioFlinger API: optimize WAV volume scaling for speed and immediately set volume +- Rearrange automated testing facilities +- About app: add mpy format info +- AppStore app: eliminate all threads by using TaskManager +- AppStore app: add experimental support for BadgeHub backend (not enabled) +- MusicPlayer app: faster volume slider action +- OSUpdate app: show download speed +- SoundRecorder app: created to test AudioFlinger's new recording feature! +- WiFi app: new 'Add network' functionality for out-of-range networks +- WiFi app: add support for hidden networks +- WiFi app: add 'Forget' button to delete networks + 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 IMU/accelerometers, temperature sensors etc. +- 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! @@ -20,6 +152,8 @@ - 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 ===== diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 05137f09..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,1005 +0,0 @@ -# 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` -- 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.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) - -## 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/MAINTAINERS.md b/MAINTAINERS.md new file mode 100644 index 00000000..8fea65c5 --- /dev/null +++ b/MAINTAINERS.md @@ -0,0 +1,13 @@ +Board Maintained by Github Telegram + +fri3d_2024 Thomas Farstrike @ThomasFarstrike @ThomasF7e +fri3d_2026 +lilygo_t_display_s3 +lilygo_t_watch_s3_plus +linux +matouch_esp32_s3_spi_ips_2_8_with_camera_ov3660 +waveshare_esp32_s3_touch_lcd_2 + +m5stack_fire.py Antonio Cebrián @ancebfer @antonio43269 +odroid_go.py Jens Diemer @jedie @Pylucid +unphone.py diff --git a/c_mpos/breakout/Makefile b/c_mpos/breakout/Makefile new file mode 100644 index 00000000..7149b873 --- /dev/null +++ b/c_mpos/breakout/Makefile @@ -0,0 +1,25 @@ +MPY_DIR = ../../lvgl_micropython/lib/micropython/ + +# Name of module +MOD = breakout + +# Source files (.c or .py) +SRC = breakout.c + +# Link runtime libraries (needed for memset on xtensawin) +LINK_RUNTIME = 1 + +# Architectures to build for (x86, x64, armv6m, armv7m, xtensa, xtensawin, rv32imc, rv64imc) +ARCHES = x64 xtensawin + +ifeq ($(ARCH),) +.PHONY: all $(ARCHES) + +all: $(ARCHES) + +$(ARCHES): + $(MAKE) -f $(lastword $(MAKEFILE_LIST)) ARCH=$@ MOD=$(MOD)_$@ +else +# Include to get the rules for compiling and linking the module +include $(MPY_DIR)/py/dynruntime.mk +endif diff --git a/c_mpos/breakout/breakout.c b/c_mpos/breakout/breakout.c new file mode 100644 index 00000000..59b854c3 --- /dev/null +++ b/c_mpos/breakout/breakout.c @@ -0,0 +1,367 @@ +// Breakout native module renderer. Draws into a framebuffer that may be +// smaller than the full display (partial framebuffer). Rendering is done +// per-slice using a y-offset/row count so MicroPythonOS can refresh displays +// larger than 320x230 without allocating a full-size framebuffer. This keeps +// the simulation state global while allowing sequential chunk flushes. + +// Include the header file to get access to the MicroPython API +#include "py/dynruntime.h" +#include + +// Provide a local memset for xtensawin native modules (libc isn't linked). +void *memset(void *s, int c, size_t n) { + unsigned char *p = (unsigned char *)s; + while (n--) { + *p++ = (unsigned char)c; + } + return s; +} + +// Global BSS (non-static) state is required for native modules. +uint16_t *g_framebuffer; +size_t g_framebuffer_len; +size_t g_framebuffer_width; +size_t g_framebuffer_height; +size_t g_framebuffer_max_pixels; +size_t g_render_y_offset; +size_t g_render_height; + +int g_paddle_x; +int g_paddle_width; +int g_paddle_height; +float g_ball_x; +float g_ball_y; +float g_ball_vx; +float g_ball_vy; +uint32_t g_last_tick_ms; + +uint32_t g_fps_last_ms; +uint32_t g_fps_frames; + +#define BRICK_ROWS 12 +#define BRICK_COLS 8 +uint8_t g_bricks[BRICK_ROWS][BRICK_COLS]; + +static uint32_t ticks_ms(void) { + mp_obj_t time_mod = mp_import_name(MP_QSTR_time, mp_const_none, MP_OBJ_NEW_SMALL_INT(0)); + mp_obj_t ticks_fun = mp_load_attr(time_mod, MP_QSTR_ticks_ms); + mp_obj_t ticks_val = mp_call_function_n_kw(ticks_fun, 0, 0, NULL); + return (uint32_t)mp_obj_get_int(ticks_val); +} + +static inline int clamp_int(int value, int min_value, int max_value) { + if (value < min_value) { + return min_value; + } + if (value > max_value) { + return max_value; + } + return value; +} + +static inline size_t framebuffer_max_pixels(void) { + return g_framebuffer_max_pixels; +} + +static void draw_pixel(int x, int y, uint16_t color) { + if (x < 0 || y < 0) { + return; + } + if ((size_t)x >= g_framebuffer_width || (size_t)y >= g_framebuffer_height) { + return; + } + if ((size_t)y < g_render_y_offset || (size_t)y >= (g_render_y_offset + g_render_height)) { + return; + } + const size_t local_y = (size_t)y - g_render_y_offset; + const size_t idx = local_y * g_framebuffer_width + (size_t)x; + const size_t max_pixels = framebuffer_max_pixels(); + if (idx >= max_pixels) { + return; + } + g_framebuffer[idx] = color; +} + +static void draw_rect(int x, int y, int w, int h, uint16_t color) { + if (w <= 0 || h <= 0 || g_framebuffer == NULL) { + return; + } + + const int x0 = (x < 0) ? 0 : x; + const int y0 = (y < 0) ? 0 : y; + const int x1 = x + w; + const int y1 = y + h; + + const int max_x = (int)g_framebuffer_width; + const int max_y = (int)g_framebuffer_height; + + int clip_x0 = x0; + int clip_y0 = y0; + int clip_x1 = (x1 > max_x) ? max_x : x1; + int clip_y1 = (y1 > max_y) ? max_y : y1; + + const int slice_y0 = (int)g_render_y_offset; + const int slice_y1 = (int)(g_render_y_offset + g_render_height); + if (clip_y0 < slice_y0) { + clip_y0 = slice_y0; + } + if (clip_y1 > slice_y1) { + clip_y1 = slice_y1; + } + + if (clip_x0 >= clip_x1 || clip_y0 >= clip_y1) { + return; + } + + const size_t width = g_framebuffer_width; + const size_t fill_width = (size_t)(clip_x1 - clip_x0); + + for (int yy = clip_y0; yy < clip_y1; yy++) { + const size_t local_y = (size_t)(yy - (int)g_render_y_offset); + uint16_t *row = g_framebuffer + local_y * width + (size_t)clip_x0; + for (size_t xx = 0; xx < fill_width; xx++) { + row[xx] = color; + } + } +} + +static void reset_ball(void) { + g_ball_x = (float)((int)g_framebuffer_width / 2); + g_ball_y = (float)((int)g_framebuffer_height / 2); + g_ball_vx = 120.0f; + g_ball_vy = -120.0f; +} + +static void reset_bricks(void) { + for (int row = 0; row < BRICK_ROWS; row++) { + for (int col = 0; col < BRICK_COLS; col++) { + g_bricks[row][col] = 1; + } + } +} + +// init(framebuffer, width, height): store a reference to the framebuffer and dimensions. +static mp_obj_t init(mp_obj_t framebuffer_obj, mp_obj_t width_obj, mp_obj_t height_obj) { + mp_buffer_info_t bufinfo; + mp_get_buffer_raise(framebuffer_obj, &bufinfo, MP_BUFFER_WRITE); + + g_framebuffer = (uint16_t *)bufinfo.buf; + g_framebuffer_len = bufinfo.len; + g_framebuffer_width = (size_t)mp_obj_get_int(width_obj); + g_framebuffer_height = (size_t)mp_obj_get_int(height_obj); + const size_t max_pixels = g_framebuffer_len / sizeof(uint16_t); + const size_t total_pixels = g_framebuffer_width * g_framebuffer_height; + g_framebuffer_max_pixels = (max_pixels < total_pixels) ? max_pixels : total_pixels; + g_render_y_offset = 0; + g_render_height = g_framebuffer_height; + + g_paddle_width = (int)g_framebuffer_width / 5; + g_paddle_height = 4; + g_paddle_x = ((int)g_framebuffer_width - g_paddle_width) / 2; + + reset_ball(); + reset_bricks(); + + g_fps_last_ms = ticks_ms(); + g_fps_frames = 0; + g_last_tick_ms = g_fps_last_ms; + + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_3(init_obj, init); + +// render([y_offset, rows, advance]): draw a Breakout frame slice and optionally advance simulation. +static mp_obj_t render(size_t n_args, const mp_obj_t *args) { + if (g_framebuffer == NULL || g_framebuffer_width == 0 || g_framebuffer_height == 0) { + return mp_const_none; + } + + const size_t width = g_framebuffer_width; + const size_t height = g_framebuffer_height; + + size_t render_y_offset = 0; + size_t render_rows = height; + bool advance = true; + if (n_args >= 1) { + int y_offset_arg = mp_obj_get_int(args[0]); + if (y_offset_arg > 0) { + render_y_offset = (size_t)y_offset_arg; + } + } + if (n_args >= 2) { + int rows_arg = mp_obj_get_int(args[1]); + if (rows_arg > 0) { + render_rows = (size_t)rows_arg; + } + } + if (n_args >= 3) { + advance = mp_obj_is_true(args[2]); + } else { + advance = (render_y_offset == 0); + } + + if (render_y_offset >= height) { + return mp_const_none; + } + + const size_t max_rows_by_buf = (width > 0) ? (framebuffer_max_pixels() / width) : 0; + const size_t max_rows_by_height = height - render_y_offset; + if (render_rows > max_rows_by_height) { + render_rows = max_rows_by_height; + } + if (max_rows_by_buf > 0 && render_rows > max_rows_by_buf) { + render_rows = max_rows_by_buf; + } + if (render_rows == 0) { + return mp_const_none; + } + + g_render_y_offset = render_y_offset; + g_render_height = render_rows; + + //mp_printf(&mp_plat_print, "breakout.c render y=%lu rows=%lu advance=%d\n", (unsigned long)render_y_offset, (unsigned long)render_rows, (int)advance); + + // Clear to black. + const size_t fill_pixels = width * render_rows; + memset(g_framebuffer, 0, fill_pixels * sizeof(uint16_t)); + + const int paddle_y = (int)height - g_paddle_height - 4; + + if (advance) { + g_fps_frames++; + const uint32_t now_ms = ticks_ms(); + const uint32_t elapsed_ms = now_ms - g_fps_last_ms; + if (elapsed_ms >= 1000) { + const uint32_t fps = (g_fps_frames * 1000) / elapsed_ms; + mp_printf(&mp_plat_print, "breakout.c fps: %lu\n", (unsigned long)fps); + g_fps_last_ms = now_ms; + g_fps_frames = 0; + } + + uint32_t tick_delta_ms = now_ms - g_last_tick_ms; + g_last_tick_ms = now_ms; + if (tick_delta_ms > 50) { + tick_delta_ms = 50; + } + const float dt = (float)tick_delta_ms / 1000.0f; + + // Update ball position. + g_ball_x += g_ball_vx * dt; + g_ball_y += g_ball_vy * dt; + + // Wall collisions. + if (g_ball_x <= 0.0f) { + g_ball_x = 0.0f; + g_ball_vx = 120.0f; + } else if (g_ball_x >= (float)width - 1.0f) { + g_ball_x = (float)width - 1.0f; + g_ball_vx = -120.0f; + } + + if (g_ball_y <= 0.0f) { + g_ball_y = 0.0f; + g_ball_vy = 120.0f; + } + + // Brick collision. + const int brick_gap = 2; + const int brick_rows = BRICK_ROWS; + const int brick_cols = BRICK_COLS; + const int brick_height = 6; + const int brick_area_width = (int)width - (brick_gap * (brick_cols + 1)); + const int brick_width = (brick_area_width > 0) ? (brick_area_width / brick_cols) : 0; + const int brick_offset_y = 8; + + if (brick_width > 0 && g_ball_y <= (float)(brick_offset_y + brick_rows * (brick_height + brick_gap))) { + for (int row = 0; row < brick_rows; row++) { + for (int col = 0; col < brick_cols; col++) { + if (!g_bricks[row][col]) { + continue; + } + const int bx = brick_gap + col * (brick_width + brick_gap); + const int by = brick_offset_y + row * (brick_height + brick_gap); + if (g_ball_x >= (float)bx && g_ball_x < (float)(bx + brick_width) && g_ball_y >= (float)by && g_ball_y < (float)(by + brick_height)) { + g_bricks[row][col] = 0; + g_ball_vy = -g_ball_vy; + row = brick_rows; + break; + } + } + } + } + + // Paddle collision. + if (g_ball_y >= (float)(paddle_y - 1) && g_ball_y <= (float)(paddle_y + g_paddle_height)) { + if (g_ball_x >= (float)g_paddle_x && g_ball_x <= (float)(g_paddle_x + g_paddle_width)) { + g_ball_y = (float)(paddle_y - 1); + g_ball_vy = -120.0f; + const int paddle_center = g_paddle_x + g_paddle_width / 2; + if (g_ball_x < (float)paddle_center) { + g_ball_vx = -120.0f; + } else if (g_ball_x > (float)paddle_center) { + g_ball_vx = 120.0f; + } + } + } + + // Ball fell below paddle: reset. + if (g_ball_y >= (float)((int)height - 1)) { + reset_ball(); + } + } + + // Brick layout. + const int brick_gap = 2; + const int brick_rows = BRICK_ROWS; + const int brick_cols = BRICK_COLS; + const int brick_height = 6; + const int brick_area_width = (int)width - (brick_gap * (brick_cols + 1)); + const int brick_width = (brick_area_width > 0) ? (brick_area_width / brick_cols) : 0; + const int brick_offset_y = 8; + + // Draw bricks. + if (brick_width > 0) { + for (int row = 0; row < brick_rows; row++) { + for (int col = 0; col < brick_cols; col++) { + if (!g_bricks[row][col]) { + continue; + } + const int bx = brick_gap + col * (brick_width + brick_gap); + const int by = brick_offset_y + row * (brick_height + brick_gap); + draw_rect(bx, by, brick_width, brick_height, 0xF800); // RGB565 red + } + } + } + + // Draw paddle and ball. + draw_rect(g_paddle_x, paddle_y, g_paddle_width, g_paddle_height, 0xFFFF); // RGB565 white + draw_pixel((int)g_ball_x, (int)g_ball_y, 0xFFFF); + + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(render_obj, 0, 3, render); + +// move_paddle(delta): move the paddle horizontally by delta. +static mp_obj_t move_paddle(mp_obj_t delta_obj) { + int delta = mp_obj_get_int(delta_obj); + //mp_printf(&mp_plat_print, "delta: %d\n", delta); + if (g_framebuffer_width > 0) { + g_paddle_x = clamp_int(g_paddle_x + delta, 0, (int)g_framebuffer_width - g_paddle_width); + } + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_1(move_paddle_obj, move_paddle); + +// This is the entry point and is called when the module is imported +mp_obj_t mpy_init(mp_obj_fun_bc_t *self, size_t n_args, size_t n_kw, mp_obj_t *args) { + // This must be first, it sets up the globals dict and other things + MP_DYNRUNTIME_INIT_ENTRY + + // Make the function available in the module's namespace + mp_store_global(MP_QSTR_init, MP_OBJ_FROM_PTR(&init_obj)); + mp_store_global(MP_QSTR_render, MP_OBJ_FROM_PTR(&render_obj)); + mp_store_global(MP_QSTR_move_paddle, MP_OBJ_FROM_PTR(&move_paddle_obj)); + + // This must be last, it restores the globals dict + MP_DYNRUNTIME_INIT_EXIT +} diff --git a/c_mpos/breakout/build.sh b/c_mpos/breakout/build.sh new file mode 100755 index 00000000..96121662 --- /dev/null +++ b/c_mpos/breakout/build.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +# NOTE: ~/.espressif doesn't exist on MacOS +export PATH=~/.espressif/tools/xtensa-esp-elf/esp-14.2.0_20241119/xtensa-esp-elf/bin:$PATH +. ~/.espressif/python_env/idf5.4_py3.11_env/bin/activate + +# Even though MacOS installs pyelftools, it still complains about No module named 'elftools' +pip install pyelftools ar + +mydir=$(readlink -f "$0") +mydir=$(dirname "$mydir") + +cd "$mydir" + +rm -rf build +rm *.mpy + +make // For ENOMEM +#include "esp_task_wdt.h" // watchdog +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" // to add a delay + +#define ADC_MIC_DEBUG_PRINT(...) mp_printf(&mp_plat_print, __VA_ARGS__) + +static mp_obj_t adc_mic_read(size_t n_args, const mp_obj_t *args) { + // Extract arguments + // args[0]: chunk_samples + // args[1]: unit_id + // args[2]: adc_channel_list + // args[3]: adc_channel_num + // args[4]: sample_rate_hz + // args[5]: atten + + size_t chunk_samples = mp_obj_get_int(args[0]); + int unit_id = mp_obj_get_int(args[1]); + + mp_obj_t channel_list_obj = args[2]; + size_t channel_list_len; + mp_obj_t *channel_list_items; + mp_obj_get_array(channel_list_obj, &channel_list_len, &channel_list_items); + + int adc_channel_num = mp_obj_get_int(args[3]); + int sample_rate_hz = mp_obj_get_int(args[4]); + int atten = mp_obj_get_int(args[5]); + + ADC_MIC_DEBUG_PRINT("Starting adc_mic_read...\n"); + ADC_MIC_DEBUG_PRINT("CONFIG_ADC_MIC_TASK_CORE: %d\n", CONFIG_ADC_MIC_TASK_CORE); + + if (adc_channel_num > 10) { + mp_raise_ValueError("Too many channels (max 10)"); + } + if (channel_list_len < adc_channel_num) { + mp_raise_ValueError("adc_channel_list shorter than adc_channel_num"); + } + + uint8_t channels[10]; + for (size_t i = 0; i < adc_channel_num; i++) { + channels[i] = (uint8_t)mp_obj_get_int(channel_list_items[i]); + } + + // Configuration + audio_codec_adc_cfg_t cfg = { + .handle = NULL, + .max_store_buf_size = 1024 * 2, + .conv_frame_size = 1024, + .unit_id = (adc_unit_t)unit_id, + .adc_channel_list = channels, + .adc_channel_num = adc_channel_num, // can probably just count adc_channel_list + .sample_rate_hz = sample_rate_hz, + .atten = (adc_atten_t)atten, + }; + ADC_MIC_DEBUG_PRINT("Config created for unit %d, channels %d, sample rate %d, atten %d\n", unit_id, adc_channel_num, sample_rate_hz, atten); + + // ──────────────────────────────────────────────── + // Initialization (same as before) + // ──────────────────────────────────────────────── + const audio_codec_data_if_t *adc_if = audio_codec_new_adc_data(&cfg); + if (adc_if == NULL) { + ADC_MIC_DEBUG_PRINT("Failed to initialize ADC data interface\n"); + mp_raise_msg(&mp_type_RuntimeError, MP_ERROR_TEXT("Failed to init ADC interface")); + } + + esp_codec_dev_cfg_t codec_dev_cfg = { + .dev_type = ESP_CODEC_DEV_TYPE_IN, + .data_if = adc_if, + }; + esp_codec_dev_handle_t dev = esp_codec_dev_new(&codec_dev_cfg); + if (dev == NULL) { + audio_codec_delete_data_if(adc_if); + mp_raise_msg(&mp_type_RuntimeError, MP_ERROR_TEXT("Failed to create codec dev")); + } + + esp_codec_dev_sample_info_t fs = { + .sample_rate = sample_rate_hz, + .channel = adc_channel_num, + .bits_per_sample = 16, + }; + esp_err_t open_ret = esp_codec_dev_open(dev, &fs); + if (open_ret != ESP_OK) { + esp_codec_dev_delete(dev); + audio_codec_delete_data_if(adc_if); + mp_raise_msg_varg(&mp_type_RuntimeError, MP_ERROR_TEXT("esp_codec_dev_open failed: %d"), open_ret); + } + + // ──────────────────────────────────────────────── + // Small reusable buffer + tracking variables + // ──────────────────────────────────────────────── + const size_t buf_size = chunk_samples * sizeof(int16_t) * adc_channel_num; + int16_t *audio_buffer = heap_caps_malloc(buf_size, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); + //int16_t *audio_buffer = heap_caps_malloc_prefer(buf_size, MALLOC_CAP_DEFAULT | MALLOC_CAP_SPIRAM, MALLOC_CAP_DEFAULT); + if (audio_buffer == NULL) { + esp_codec_dev_close(dev); + esp_codec_dev_delete(dev); + audio_codec_delete_data_if(adc_if); + mp_raise_OSError(ENOMEM); + } + + // How many chunks to read (adjust as needed) + const int N = 1; // e.g. 5 × 10240 = ~3.2 seconds @ 16 kHz + const int chunks_to_print = 0; + + int16_t global_min = 32767; + int16_t global_max = -32768; + + ADC_MIC_DEBUG_PRINT("Reading %d chunks of %zu samples each (total %d samples)...\n", + N, chunk_samples, N * chunk_samples); + + mp_obj_t last_buf_obj = mp_const_none; + + for (int chunk = 0; chunk < N; chunk++) { + esp_task_wdt_reset(); // "I'm alive" + int ret = esp_codec_dev_read(dev, audio_buffer, buf_size); + if (ret < 0) { + ADC_MIC_DEBUG_PRINT("Read failed at chunk %d: %d\n", chunk, ret); + break; + } + vTaskDelay(pdMS_TO_TICKS(1)); // 1 ms yield + + // Update global min/max + for (size_t i = 0; i < chunk_samples; i++) { + int16_t s = audio_buffer[i]; + if (s < global_min) global_min = s; + if (s > global_max) global_max = s; + } + + // Optional: print first few chunks for debug (comment out after testing) + if (chunk < chunks_to_print) { + ADC_MIC_DEBUG_PRINT("Chunk %d first 16 samples:\n", chunk); + for (size_t i = 0; i < 16; i++) { + int16_t sample = audio_buffer[i]; + ADC_MIC_DEBUG_PRINT("%6d (0x%04X)", sample, (uint16_t)sample); + if ((i + 1) % 8 == 0) ADC_MIC_DEBUG_PRINT("\n"); + } + ADC_MIC_DEBUG_PRINT("\n"); + } + + // Keep only the last chunk to return + if (chunk == N - 1) { + last_buf_obj = mp_obj_new_bytes((const byte *)audio_buffer, buf_size); + } + } + + // ──────────────────────────────────────────────── + // Report results + // ──────────────────────────────────────────────── + ADC_MIC_DEBUG_PRINT("\nAfter %d chunks:\n", N); + ADC_MIC_DEBUG_PRINT("Global min: %d\n", global_min); + ADC_MIC_DEBUG_PRINT("Global max: %d\n", global_max); + ADC_MIC_DEBUG_PRINT("Range: %d\n", global_max - global_min); + + // Cleanup + heap_caps_free(audio_buffer); + esp_codec_dev_close(dev); + esp_codec_dev_delete(dev); + audio_codec_delete_data_if(adc_if); + + ADC_MIC_DEBUG_PRINT("adc_mic_read completed\n"); + + return last_buf_obj ? last_buf_obj : mp_obj_new_bytes(NULL, 0); +} +MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(adc_mic_read_obj, 6, 6, adc_mic_read); + +static const mp_rom_map_elem_t adc_mic_module_globals_table[] = { + { MP_ROM_QSTR(MP_QSTR___name__), MP_ROM_QSTR(MP_QSTR_adc_mic) }, + { MP_ROM_QSTR(MP_QSTR_read), MP_ROM_PTR(&adc_mic_read_obj) }, +}; +static MP_DEFINE_CONST_DICT(adc_mic_module_globals, adc_mic_module_globals_table); + +const mp_obj_module_t adc_mic_user_cmodule = { + .base = { &mp_type_module }, + .globals = (mp_obj_dict_t *)&adc_mic_module_globals, +}; + +MP_REGISTER_MODULE(MP_QSTR_adc_mic, adc_mic_user_cmodule); \ No newline at end of file diff --git a/internal_filesystem/apps/com.micropythonos.breakout/META-INF/MANIFEST.JSON b/internal_filesystem/apps/com.micropythonos.breakout/META-INF/MANIFEST.JSON new file mode 100644 index 00000000..b9eb2b7b --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.breakout/META-INF/MANIFEST.JSON @@ -0,0 +1,24 @@ +{ +"name": "Breakout", +"publisher": "MicroPythonOS", +"short_description": "Classic Breakout game", +"long_description": "Classic Breakout game to demonstrate native machinecode (from C) in .mpy files on both AMD64 and ESP32", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.breakout/icons/com.micropythonos.breakout_0.1.0_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.breakout/mpks/com.micropythonos.breakout_0.1.0.mpk", +"fullname": "com.micropythonos.breakout", +"version": "0.1.0", +"category": "games", +"activities": [ + { + "entrypoint": "assets/breakout.py", + "classname": "Breakout", + "intent_filters": [ + { + "action": "main", + "category": "launcher" + } + ] + } + ] +} + diff --git a/internal_filesystem/apps/com.micropythonos.breakout/assets/breakout.py b/internal_filesystem/apps/com.micropythonos.breakout/assets/breakout.py new file mode 100644 index 00000000..af907118 --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.breakout/assets/breakout.py @@ -0,0 +1,265 @@ +# Breakout app UI/driver glue. This app renders into a framebuffer that may be +# smaller than the full display (partial framebuffer). The draw loop is more +# complex because it slices the screen into chunks, renders each slice in C, +# and flushes them sequentially using a flush-ready IRQ callback. A scheduled +# (non-IRQ) handler advances chunks so it can work on larger-than-320x230 +# displays without requiring a full-size framebuffer. +import lvgl as lv +import mpos.ui +from mpos import Activity, DisplayMetrics, InputManager + +import sys +if sys.platform == "esp32": + import breakout_xtensawin as breakout +else: + import breakout_x64 as breakout + +class Breakout(Activity): + + hor_res = 0 + ver_res = 0 + paddle_move_step = None + layer = None + buffer = None + touch_active = False + touch_last_x = None + last_fps = 0 + average_fps = 0 + + old_callback = None + + render_next = True + flush_ready = False + chunk_in_progress = False + chunk_waiting = False + chunk_rows_per = 0 + chunk_total = 0 + chunk_index = 0 + + # Widgets: + screen = None + canvas = None + leftbutton = None + playbutton = None + rightbutton = None + + def onCreate(self): + self.screen = lv.obj() + self.screen.add_flag(lv.obj.FLAG.CLICKABLE) + self.screen.add_event_cb(self.touch_cb, lv.EVENT.ALL, None) + + d = lv.display_get_default() + self.hor_res = d.get_horizontal_resolution() + self.paddle_move_step = round(self.hor_res/10) + self.ver_res = d.get_vertical_resolution() + + self.leftbutton = lv.button(self.screen) + self.leftbutton.align(lv.ALIGN.BOTTOM_LEFT, 0, 0) + leftlabel = lv.label(self.leftbutton) + leftlabel.set_text("<") + self.leftbutton.add_event_cb(lambda e: self.move_left_unfocus(),lv.EVENT.FOCUSED,None) + self.leftbutton.add_event_cb(lambda e: self.move_left(),lv.EVENT.CLICKED,None) + + # Invisible button, just for defocusing the left and right buttons: + self.play_button = lv.button(self.screen) + self.play_button.align(lv.ALIGN.BOTTOM_MID,0,0) + self.play_button.set_size(1,1) + self.play_button.set_style_opa(lv.OPA.TRANSP, lv.PART.MAIN) + + self.rightbutton = lv.button(self.screen) + self.rightbutton.align(lv.ALIGN.BOTTOM_RIGHT, 0, 0) + rightlabel = lv.label(self.rightbutton) + rightlabel.set_text(">") + self.rightbutton.add_event_cb(lambda e: self.move_right_unfocus(),lv.EVENT.FOCUSED,None) + self.rightbutton.add_event_cb(lambda e: self.move_right(),lv.EVENT.CLICKED,None) + + self.setContentView(self.screen) + + def onResume(self, screen): + lv.log_register_print_cb(self.log_callback) + breakout.init(mpos.ui.main_display._frame_buffer1, self.hor_res, self.ver_res) + mpos.ui.main_display._data_bus.register_callback(self.flush_ready_cb) + mpos.ui.task_handler.add_event_cb(self.drawframe, mpos.ui.task_handler.TASK_HANDLER_FINISHED) + + def onPause(self, screen): + lv.log_register_print_cb(None) + mpos.ui.main_display._data_bus.register_callback(mpos.ui.main_display._flush_ready_cb) + + def move_left(self): + breakout.move_paddle(-self.paddle_move_step) + + def move_right(self): + breakout.move_paddle(self.paddle_move_step) + + def move_left_unfocus(self): + self.unfocus() + breakout.move_paddle(-self.paddle_move_step) + + def move_right_unfocus(self): + self.unfocus() + breakout.move_paddle(self.paddle_move_step) + + # This only works with the PREV/pageup and NEXT/pagedown buttons, + # because the focus_direction handling of the arrow keys uses a trick to move focus (focus_next) + # which conflicts with the focus_next below... + def unfocus(self): + focusgroup = lv.group_get_default() + if not focusgroup: + print("WARNING: imageview.py could not get default focus group") + return + focused = focusgroup.get_focused() + if focused: + if focused == self.rightbutton: + focusgroup.focus_prev() + elif focused == self.leftbutton: + focusgroup.focus_next() + else: + print("focus isn't on next or previous, leaving it...") + + def flush_ready_cb(self, arg1=None, arg2=None): + # This is called in IRQ (interrupt) context so it can't allocate memory + # So no printf, no calling drawframe() directly, just setting variables or scheduling a function. + mpos.ui.main_display._disp_drv.flush_ready() + self.flush_ready = True + + def drawframe(self, arg1=None, arg2=None): + if self.chunk_waiting: + if self.flush_ready: + self.flush_ready = False + self.chunk_waiting = False + self.chunk_index += 1 + if self.chunk_index >= self.chunk_total: + self.chunk_in_progress = False + self.render_next = True + else: + self._render_and_send_chunk() + return + + if self.chunk_in_progress or not self.render_next: + return + + self.render_next = False + + buffer_len = len(mpos.ui.main_display._frame_buffer1) + bytes_per_row = self.hor_res * 2 + if bytes_per_row <= 0: + self.render_next = True + return + + rows_per_chunk = buffer_len // bytes_per_row + if rows_per_chunk <= 0: + self.render_next = True + return + + if rows_per_chunk >= self.ver_res: + self.chunk_rows_per = self.ver_res + self.chunk_index = 0 + self.chunk_total = 1 + else: + self.chunk_rows_per = rows_per_chunk + self.chunk_index = 0 + self.chunk_total = (self.ver_res + rows_per_chunk - 1) // rows_per_chunk + + self.chunk_in_progress = True + self.chunk_waiting = False + self.flush_ready = False + self._render_and_send_chunk() + + def _render_and_send_chunk(self): + if not self.chunk_in_progress: + return + if self.chunk_waiting: + return + if self.chunk_index >= self.chunk_total: + self.chunk_in_progress = False + self.render_next = True + return + + y_offset = self.chunk_index * self.chunk_rows_per + rows = min(self.chunk_rows_per, self.ver_res - y_offset) + advance = (self.chunk_index == 0) + is_last = (self.chunk_index + 1) == self.chunk_total + + self.chunk_waiting = True + breakout.render(y_offset, rows, advance) + self.send_to_display(y_offset, rows, is_last) + + def send_to_display(self, y_offset=0, rows=None, is_last=True): + x1 = 0 + x2 = mpos.ui.main_display.get_horizontal_resolution() - 1 + x2 = x2 + mpos.ui.main_display._offset_x + x1 = x1 + mpos.ui.main_display._offset_x + + if rows is None: + rows = mpos.ui.main_display.get_vertical_resolution() + y1 = y_offset + y2 = y_offset + rows - 1 + y1 = y1 + mpos.ui.main_display._offset_y + y2 = y2 + mpos.ui.main_display._offset_y + + cmd = mpos.ui.main_display._set_memory_location(x1, y1, x2, y2) + bytes_needed = rows * mpos.ui.main_display.get_horizontal_resolution() * 2 + data_view = memoryview(mpos.ui.main_display._frame_buffer1)[:bytes_needed] + + tx_last = True + mpos.ui.main_display._data_bus.tx_color( + cmd, + data_view, + x1, y1, x2, y2, + mpos.ui.main_display._rotation, + tx_last, + ) + + def touch_cb(self, event): + event_code = event.get_code() + if event_code == lv.EVENT.PRESSED: + x, y = InputManager.pointer_xy() + self.touch_active = True + self.touch_last_x = x + return + + if event_code == lv.EVENT.PRESSING: + if not self.touch_active: + x, y = InputManager.pointer_xy() + self.touch_active = True + self.touch_last_x = x + return + x, y = InputManager.pointer_xy() + if self.touch_last_x is not None: + delta = x - self.touch_last_x + if delta: + breakout.move_paddle(round(delta*1.3)) # amplify movement to avoid sides + self.touch_last_x = x + return + + if event_code == lv.EVENT.RELEASED: + self.touch_active = False + self.touch_last_x = None + return + + average_samples = 20 + fps_buffer = [0.0] * average_samples + fps_index = 0 + fps_sum = 0.0 + fps_count = 0 # Number of valid samples (0 to average_samples) + def moving_average(self, value): + if self.fps_count == self.average_samples: + self.fps_sum -= self.fps_buffer[self.fps_index] + else: + self.fps_count += 1 + self.fps_sum += value + self.fps_buffer[self.fps_index] = value + self.fps_index = (self.fps_index + 1) % self.average_samples + return self.fps_sum / self.fps_count + + # Custom log callback to capture FPS + def log_callback(self, level, log_str): + log_str = log_str.decode() if isinstance(log_str, bytes) else log_str + if "sysmon:" in log_str and "FPS" in log_str: + try: + fps_part = log_str.split("FPS")[0].split("sysmon:")[1].strip() + self.last_fps = int(fps_part) + self.average_fps = self.moving_average(self.last_fps) + print(f"Current FPS: {self.last_fps} - Average FPS: {self.average_fps}") + except (IndexError, ValueError): + pass diff --git a/internal_filesystem/apps/com.micropythonos.breakout/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/apps/com.micropythonos.breakout/res/mipmap-mdpi/icon_64x64.png new file mode 100644 index 00000000..30c6a2d7 Binary files /dev/null and b/internal_filesystem/apps/com.micropythonos.breakout/res/mipmap-mdpi/icon_64x64.png differ 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 1a2cde4f..6216d15f 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/META-INF/MANIFEST.JSON +++ b/internal_filesystem/apps/com.micropythonos.camera/META-INF/MANIFEST.JSON @@ -3,30 +3,21 @@ "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.11_64x64.png", -"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.camera/mpks/com.micropythonos.camera_0.0.11.mpk", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.camera/icons/com.micropythonos.camera_0.2.1_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.camera/mpks/com.micropythonos.camera_0.2.1.mpk", "fullname": "com.micropythonos.camera", -"version": "0.0.11", +"version": "0.2.1", "category": "camera", "activities": [ { "entrypoint": "assets/camera_app.py", - "classname": "CameraApp", + "classname": "CameraActivity", "intent_filters": [ { "action": "main", "category": "launcher" - }, - { - "action": "scan_qr_code", - "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 23675283..b73c77b1 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -1,610 +1,7 @@ -import lvgl as lv -import time - -try: - import webcam -except Exception as e: - print(f"Info: could not import webcam module: {e}") - -import mpos.time -from mpos.apps import Activity -from mpos.content.intent import Intent - -from camera_settings import CameraSettingsActivity - -class CameraApp(Activity): - - PACKAGE = "com.micropythonos.camera" - CONFIGFILE = "config.json" - SCANQR_CONFIG = "config_scanqr_mode.json" - - 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 garba - width = None - height = None - colormode = False - - image_dsc = None - scanqr_mode = False - scanqr_intent = False - use_webcam = 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 - status_label = None - status_label_cont = None - - def onCreate(self): - 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) - # 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() - - 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(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(self.main_screen) - - def onResume(self, screen): - 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: - # 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.update_preview_image() - self.capture_timer = lv.timer_create(self.try_capture, 100, None) - - def stop_cam(self): - if self.capture_timer: - self.capture_timer.delete() - if self.use_webcam: - webcam.deinit(self.cam) - elif self.cam: - self.cam.deinit() - # Power off, otherwise it keeps using a lot of current - try: - from machine import Pin, I2C - i2c = I2C(1, scl=Pin(16), sda=Pin(21)) # 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 = 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}") - 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 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 = 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) - print(f"scale_factors: {scale_factor_w},{scale_factor_h}") - self.image.set_size(target_w, target_h) - #self.image.set_scale(max(scale_factor_w,scale_factor_h)) # fills the entire screen but cuts off borders - self.image.set_scale(min(scale_factor_w,scale_factor_h)) - - def qrdecode_one(self): - try: - result = None - before = time.ticks_ms() - import qrdecode - if self.colormode: - result = qrdecode.qrdecode_rgb565(self.current_cam_buffer, self.width, self.height) - else: - 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_SEARCHING_QR) - except TypeError as e: - print("QR TypeError: ", e) - 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("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(path) - except OSError: - pass - 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.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_SEARCHING_QR) - - def stop_qr_decoding(self): - print("Deactivating live QR decoding...") - self.scanqr_mode = False - self.qr_label.set_text(lv.SYMBOL.EYE_OPEN) - 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.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): - try: - 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() - 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) - - # 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}") +Camera app wrapper that imports from the camera_activity module. """ + +from mpos import CameraActivity + +__all__ = ['CameraActivity'] diff --git a/internal_filesystem/apps/com.micropythonos.camera/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/apps/com.micropythonos.camera/res/mipmap-mdpi/icon_64x64.png index 92a259aa..9c2820ad 100644 Binary files a/internal_filesystem/apps/com.micropythonos.camera/res/mipmap-mdpi/icon_64x64.png and b/internal_filesystem/apps/com.micropythonos.camera/res/mipmap-mdpi/icon_64x64.png differ diff --git a/internal_filesystem/apps/com.micropythonos.confetti/META-INF/MANIFEST.JSON b/internal_filesystem/apps/com.micropythonos.confetti/META-INF/MANIFEST.JSON index a4f2363e..858743d4 100644 --- a/internal_filesystem/apps/com.micropythonos.confetti/META-INF/MANIFEST.JSON +++ b/internal_filesystem/apps/com.micropythonos.confetti/META-INF/MANIFEST.JSON @@ -3,15 +3,15 @@ "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", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.confetti/icons/com.micropythonos.confetti_0.0.4_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.confetti/mpks/com.micropythonos.confetti_0.0.4.mpk", "fullname": "com.micropythonos.confetti", -"version": "0.0.2", +"version": "0.0.4", "category": "games", "activities": [ { - "entrypoint": "assets/confetti.py", - "classname": "Confetti", + "entrypoint": "assets/confetti_app.py", + "classname": "ConfettiApp", "intent_filters": [ { "action": "main", diff --git a/internal_filesystem/apps/com.micropythonos.confetti/assets/confetti.py b/internal_filesystem/apps/com.micropythonos.confetti/assets/confetti.py index 5ec95d77..7d53276c 100644 --- a/internal_filesystem/apps/com.micropythonos.confetti/assets/confetti.py +++ b/internal_filesystem/apps/com.micropythonos.confetti/assets/confetti.py @@ -1,116 +1,186 @@ +# This is a copy of LightningPiggyApp's confetti.py + 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 +from mpos import DisplayMetrics + +class Confetti: + """Manages confetti animation with physics simulation.""" + + def __init__(self, screen, icon_path, asset_path, duration=10000): + """ + Initialize the Confetti system. + + Args: + screen: The LVGL screen/display object + icon_path: Path to icon assets (e.g., "M:apps/com.lightningpiggy.displaywallet/res/mipmap-mdpi/") + asset_path: Path to confetti assets (e.g., "M:apps/com.lightningpiggy.displaywallet/res/drawable-mdpi/") + max_confetti: Maximum number of confetti pieces to display + """ + self.screen = screen + self.icon_path = icon_path + self.asset_path = asset_path + self.duration = duration + self.max_confetti = 21 + + # Physics constants + self.GRAVITY = 100 # pixels/sec² + + # Screen dimensions + self.screen_width = DisplayMetrics.width() + self.screen_height = DisplayMetrics.height() + + # State + self.is_running = False 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 - + self.used_img_indices = set() + self.update_timer = None # Reference to LVGL timer for frame updates + + # Spawn control + self.spawn_timer = 0 + self.spawn_interval = 0.15 # seconds + self.animation_start = 0 + + # 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") + self._init_images() + + def _init_images(self): + """Pre-create LVGL image objects for confetti.""" + iconimages = 2 + for _ in range(iconimages): + img = lv.image(lv.layer_top()) + img.set_src(f"{self.icon_path}icon_64x64.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): + + for i in range(self.max_confetti - iconimages): + img = lv.image(lv.layer_top()) + img.set_src(f"{self.asset_path}confetti{random.randint(0, 4)}.png") + img.add_flag(lv.obj.FLAG.HIDDEN) + self.confetti_images.append(img) + + def start(self): + """Start the confetti animation.""" + if self.is_running: + return + + self.is_running = True + self.last_time = time.ticks_ms() + self._clear_confetti() + + # Staggered spawn control + self.spawn_timer = 0 + self.animation_start = time.ticks_ms() / 1000.0 + + # Initial burst + for _ in range(10): + self._spawn_one() + + self.update_timer = lv.timer_create(self._update_frame, 16, None) # max 60 fps = 16ms/frame + + # Stop spawning after duration + lv.timer_create(self.stop, self.duration, None).set_repeat_count(1) + + def stop(self, timer=None): + """Stop the confetti animation.""" + self.is_running = False + + def _clear_confetti(self): + """Clear all confetti pieces from the screen.""" + for img in self.confetti_images: + img.add_flag(lv.obj.FLAG.HIDDEN) + self.confetti_pieces = [] + self.used_img_indices.clear() + + def _update_frame(self, timer): + """Update frame for confetti animation. Called by LVGL timer.""" current_time = time.ticks_ms() - delta_ms = time.ticks_diff(current_time, self.last_time) - delta_time = delta_ms / 1000.0 + delta_time = time.ticks_diff(current_time, self.last_time) / 1000.0 self.last_time = current_time - + + # === STAGGERED SPAWNING === + if self.is_running: + 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.confetti_pieces) < self.max_confetti: + self._spawn_one() + + # === UPDATE ALL PIECES === new_pieces = [] - for piece in self.confetti_pieces: - # === UPDATE PHYSICS === + # 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 === + + # Render 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 + img.set_rotation(int(piece['rotation'] * 10)) + orig = img.get_width() + if orig >= 64: + img.set_scale(int(256 * piece['scale'] / 1.5)) + elif orig < 32: + img.set_scale(int(256 * piece['scale'] * 1.5)) + else: + img.set_scale(int(256 * piece['scale'])) + + # Death check + dead = ( + piece['x'] < -60 or piece['x'] > self.screen_width + 60 or + piece['y'] > self.screen_height + 60 or + piece['age'] > piece['lifetime'] ) - too_old = piece['age'] > piece['lifetime'] - - if off_screen or too_old: + + if dead: 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 + + # Full stop when empty and paused + if not self.confetti_pieces and not self.is_running: + print("Confetti finished") + if self.update_timer: + self.update_timer.delete() + self.update_timer = None + + def _spawn_one(self): + """Spawn a single confetti piece.""" + if not self.is_running: + return + + # 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(-50, self.screen_width + 50), + 'y': random.uniform(50, 100), # Start above screen + 'vx': random.uniform(-80, 80), + 'vy': random.uniform(-150, 0), + 'spin': random.uniform(-500, 500), + 'age': 0.0, + 'lifetime': random.uniform(5.0, 10.0), # Long enough to fill 10s + 'rotation': random.uniform(0, 360), + 'scale': 1.0 + } + self.confetti_pieces.append(piece) + self.used_img_indices.add(idx) diff --git a/internal_filesystem/apps/com.micropythonos.confetti/assets/confetti_app.py b/internal_filesystem/apps/com.micropythonos.confetti/assets/confetti_app.py new file mode 100644 index 00000000..cc3bbdc8 --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.confetti/assets/confetti_app.py @@ -0,0 +1,28 @@ +import time +import random +import lvgl as lv + +from mpos import Activity + +from confetti import Confetti + +class ConfettiApp(Activity): + + ASSET_PATH = "M:apps/com.micropythonos.confetti/res/drawable-mdpi/" + ICON_PATH = "M:apps/com.micropythonos.confetti/res/mipmap-mdpi/" + confetti_duration = 60 * 1000 + + confetti = None + + def onCreate(self): + main_screen = lv.obj() + self.confetti = Confetti(main_screen, self.ICON_PATH, self.ASSET_PATH, self.confetti_duration) + print("created ", self.confetti) + self.setContentView(main_screen) + + def onResume(self, screen): + print("onResume") + self.confetti.start() + + def onPause(self, screen): + self.confetti.stop() diff --git a/internal_filesystem/apps/com.micropythonos.confetti/res/drawable-mdpi/confetti0.png b/internal_filesystem/apps/com.micropythonos.confetti/res/drawable-mdpi/confetti0.png new file mode 100644 index 00000000..220c65cb Binary files /dev/null and b/internal_filesystem/apps/com.micropythonos.confetti/res/drawable-mdpi/confetti0.png differ diff --git a/internal_filesystem/apps/com.micropythonos.confetti/res/drawable-mdpi/confetti4.png b/internal_filesystem/apps/com.micropythonos.confetti/res/drawable-mdpi/confetti4.png new file mode 100644 index 00000000..bccb6d99 Binary files /dev/null and b/internal_filesystem/apps/com.micropythonos.confetti/res/drawable-mdpi/confetti4.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 index 71203859..aefc23b0 100644 Binary files a/internal_filesystem/apps/com.micropythonos.confetti/res/mipmap-mdpi/icon_64x64.png 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 index 1da4896b..5e9a08e6 100644 --- a/internal_filesystem/apps/com.micropythonos.connect4/META-INF/MANIFEST.JSON +++ b/internal_filesystem/apps/com.micropythonos.connect4/META-INF/MANIFEST.JSON @@ -3,10 +3,10 @@ "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", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.connect4/icons/com.micropythonos.connect4_0.1.0_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.connect4/mpks/com.micropythonos.connect4_0.1.0.mpk", "fullname": "com.micropythonos.connect4", -"version": "0.0.1", +"version": "0.1.0", "category": "games", "activities": [ { diff --git a/internal_filesystem/apps/com.micropythonos.connect4/assets/connect4.py b/internal_filesystem/apps/com.micropythonos.connect4/assets/connect4.py index 70c07559..7519512f 100644 --- a/internal_filesystem/apps/com.micropythonos.connect4/assets/connect4.py +++ b/internal_filesystem/apps/com.micropythonos.connect4/assets/connect4.py @@ -1,8 +1,7 @@ import time import random -from mpos.apps import Activity -import mpos.ui +from mpos import Activity, ui try: import lvgl as lv @@ -106,8 +105,8 @@ def onCreate(self): (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_style_bg_color(lv.color_hex(self.COLOR_BOARD), lv.PART.MAIN) + board_bg.set_style_radius(8, lv.PART.MAIN) board_bg.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) # Create pieces (visual representation) @@ -120,10 +119,10 @@ def onCreate(self): 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_style_radius(lv.RADIUS_CIRCLE, lv.PART.MAIN) + piece.set_style_bg_color(lv.color_hex(self.COLOR_EMPTY), lv.PART.MAIN) + piece.set_style_border_width(1, lv.PART.MAIN) + piece.set_style_border_color(lv.color_hex(0x1C2833), lv.PART.MAIN) piece.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) piece_row.append(piece) self.pieces.append(piece_row) @@ -138,8 +137,8 @@ def onCreate(self): 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.set_style_bg_opa(0, lv.PART.MAIN) # Transparent + btn.set_style_border_width(0, lv.PART.MAIN) 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) @@ -208,7 +207,7 @@ def animate_drop(self, col): # 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) + self.pieces[row][col].set_style_bg_color(lv.color_hex(color), lv.PART.MAIN) # Check for win or tie if self.check_win(row, col): @@ -457,9 +456,9 @@ def check_direction(self, row, col, dr, dc): 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) + self.pieces[row][col].set_style_bg_color(lv.color_hex(self.COLOR_WIN), lv.PART.MAIN) + self.pieces[row][col].set_style_border_width(3, lv.PART.MAIN) + self.pieces[row][col].set_style_border_color(lv.color_hex(0xFFFFFF), lv.PART.MAIN) def is_board_full(self): """Check if the board is full""" @@ -478,6 +477,6 @@ def new_game(self): # 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) + self.pieces[row][col].set_style_bg_color(lv.color_hex(self.COLOR_EMPTY), lv.PART.MAIN) + self.pieces[row][col].set_style_border_width(1, lv.PART.MAIN) + self.pieces[row][col].set_style_border_color(lv.color_hex(0x1C2833), lv.PART.MAIN) 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 index ee77edb2..91e56992 100644 Binary files a/internal_filesystem/apps/com.micropythonos.connect4/res/mipmap-mdpi/icon_64x64.png and b/internal_filesystem/apps/com.micropythonos.connect4/res/mipmap-mdpi/icon_64x64.png differ diff --git a/internal_filesystem/apps/com.micropythonos.doom_launcher/META-INF/MANIFEST.JSON b/internal_filesystem/apps/com.micropythonos.doom_launcher/META-INF/MANIFEST.JSON new file mode 100644 index 00000000..521d23fa --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.doom_launcher/META-INF/MANIFEST.JSON @@ -0,0 +1,12 @@ +{ +"name": "Doom Launcher", +"publisher": "MicroPythonOS", +"short_description": "Legendary 3D shooter", +"long_description": "Plays Doom 1, 2 and modded .wad files from internal storage or SD card and plays them. Place them in the folder /roms/doom/ . Uses ducalex's retro-go port of PrBoom. Supports zipped wad files too.", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.doom_launcher/icons/com.micropythonos.doom_launcher_0.1.0_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.doom_launcher/mpks/com.micropythonos.doom_launcher_0.1.0.mpk", +"fullname": "com.micropythonos.doom_launcher", +"version": "0.1.0", +"category": "games", +} + diff --git a/internal_filesystem/apps/com.micropythonos.doom_launcher/assets/main.py b/internal_filesystem/apps/com.micropythonos.doom_launcher/assets/main.py new file mode 100644 index 00000000..ccf3d5c1 --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.doom_launcher/assets/main.py @@ -0,0 +1,193 @@ +import lvgl as lv +import os +from mpos import Activity, TaskManager, sdcard + +class Main(Activity): + + romdir = "/roms" + doomdir = romdir + "/doom" + retrogodir = "/retro-go" + configdir = retrogodir + "/config" + bootfile = configdir + "/boot.json" + partition_label = "prboom-go" + mountpoint_sdcard = "/sdcard" + esp32_partition_type_ota_0 = 16 + #partition_label = "retro-core" + # Widgets: + status_label = None + wadlist = None + bootfile_prefix = "" + bootfile_to_write = "" + + def onCreate(self): + screen = lv.obj() + screen.set_style_pad_all(15, lv.PART.MAIN) + + # Create title label + title_label = lv.label(screen) + title_label.set_text("Choose your DOOM:") + title_label.align(lv.ALIGN.TOP_LEFT, 0, 0) + + # Create list widget for WAD files + self.wadlist = lv.list(screen) + self.wadlist.set_size(lv.pct(100), lv.pct(70)) + self.wadlist.center() + + # Create status label for messages + self.status_label = lv.label(screen) + self.status_label.set_width(lv.pct(90)) + self.status_label.set_long_mode(lv.label.LONG_MODE.WRAP) + self.status_label.align(lv.ALIGN.BOTTOM_LEFT, 0, 0) + # Set default green color for status label + self.status_label.set_style_text_color(lv.color_hex(0x00FF00), lv.PART.MAIN) + + self.setContentView(screen) + + def onResume(self, screen): + # Try to mount the SD card and if successful, use it, as retro-go can only use one or the other: + self.bootfile_prefix = "" + mounted_sdcard = sdcard.mount_with_optional_format(self.mountpoint_sdcard) + if mounted_sdcard: + print("sdcard is mounted, configuring it...") + self.bootfile_prefix = self.mountpoint_sdcard + self.bootfile_to_write = self.bootfile_prefix + self.bootfile + print(f"writing to {self.bootfile_to_write}") + + # Scan for WAD files and populate the list + self.refresh_wad_list() + + def scan_wad_files(self, directory): + """Scan a directory for .wad and .zip files""" + wad_files = [] + try: + for filename in os.listdir(directory): + if filename.lower().endswith(('.wad', '.zip')): + wad_files.append(filename) + + # Sort the list for consistent ordering + wad_files.sort() + print(f"Found {len(wad_files)} WAD files in {directory}: {wad_files}") + except OSError as e: + print(f"Directory does not exist or cannot be read: {directory}") + except Exception as e: + print(f"Error scanning directory {directory}: {e}") + + return wad_files + + def get_file_size_warning(self, filepath): + """Get file size warning suffix if file is too small or empty""" + try: + size = os.stat(filepath)[6] # Get file size + if size == 0: + return " (EMPTY FILE)" # Red + elif size < 80 * 1024: # 80KB + return " (TOO SMALL)" # Orange + except Exception as e: + print(f"Error checking file size for {filepath}: {e}") + return "" + + def refresh_wad_list(self): + """Scan for WAD files and populate the list""" + self.status_label.set_text(f"Listing files in: {self.bootfile_prefix + self.doomdir}") + print("refresh_wad_list: Clearing current list") + self.wadlist.clean() + + # Scan internal storage or SD card + all_wads = self.scan_wad_files(self.bootfile_prefix + self.doomdir) + all_wads.sort() + + if len(all_wads) == 0: + self.status_label.set_text(f"No .wad or .zip files found in {self.doomdir}") + print("No WAD files found") + return + + # Populate list with WAD files + print(f"refresh_wad_list: Populating list with {len(all_wads)} WAD files") + self.status_label.set_text(f"Listed files in: {self.bootfile_prefix + self.doomdir}") + for wad_file in all_wads: + # Get file size warning if applicable + warning = self.get_file_size_warning(self.bootfile_prefix + self.doomdir + '/' + wad_file) + button_text = wad_file + warning + button = self.wadlist.add_button(None, button_text) + button.add_event_cb(lambda e, p=self.doomdir + '/' + wad_file: TaskManager.create_task(self.start_wad(self.bootfile_prefix, self.bootfile_to_write, p)), lv.EVENT.CLICKED, None) + + # If only one WAD file, auto-start it + if len(all_wads) == 1: + print(f"refresh_wad_list: Only one WAD file found, auto-starting: {all_wads[0]}") + TaskManager.create_task(self.start_wad(self.bootfile_prefix, self.bootfile_to_write, self.doomdir + '/' + all_wads[0])) + + def mkdir(self, dirname): + # Would be better to only create it if it doesn't exist + try: + os.mkdir(dirname) + except Exception as e: + # Not really useful to show this in the UI, as it's usually just an "already exists" error: + print(f"Info: could not create directory {dirname} because: {e}") + + async def start_wad(self, bootfile_prefix, bootfile_to_write, wadfile): + self.status_label.set_text(f"Launching Doom with file: {bootfile_prefix}{wadfile}") + await TaskManager.sleep(1) # Give the user a minimal amount of time to read the filename + + # Create these folders, in case the user wants to add doom later: + self.mkdir(bootfile_prefix + self.romdir) + self.mkdir(bootfile_prefix + self.doomdir) + + # Create structure to place bootfile: + self.mkdir(bootfile_prefix + self.retrogodir) + self.mkdir(bootfile_prefix + self.configdir) + try: + import json + # Would be better to only write this if it differs from what's already there: + fd = open(bootfile_to_write, 'w') + bootconfig = { + "BootName": "doom", + "BootArgs": f"/sd{wadfile}", + "BootSlot": -1, + "BootFlags": 0 + } + json.dump(bootconfig, fd) + fd.close() + except Exception as e: + self.status_label.set_text(f"ERROR: could not write config file: {e}") + return + results = [] + try: + from esp32 import Partition + results = Partition.find(label=self.partition_label) + except Exception as e: + self.status_label.set_text(f"ERROR: could not search for internal partition with label {self.partition_label}, unable to start: {e}") + return + if len(results) < 1: + self.status_label.set_text(f"ERROR: could not find internal partition with label {self.partition_label}, unable to start") + return + partition = results[0] + try: + partition.set_boot() + except Exception as e: + print(f"ERROR: could not set partition {partition} as boot, it probably doesn't contain a valid program: {e}") + try: + import vfs + vfs.umount('/') + except Exception as e: + print(f"Warning: could not unmount internal filesystem from /: {e}") + # Write the currently booted OTA partition number to NVS, so that retro-go's apps know where to go back to: + try: + from esp32 import NVS + nvs = NVS('fri3d.sys') + boot_partition = nvs.get_i32('boot_partition') + print(f"boot_partition in fri3d.sys of NVS: {boot_partition}") + running_partition = Partition(Partition.RUNNING) + running_partition_nr = running_partition.info()[1] - self.esp32_partition_type_ota_0 + print(f"running_partition_nr: {running_partition_nr}") + if running_partition_nr != boot_partition: + print(f"setting boot_partition in fri3d.sys of NVS to {running_partition_nr}") + nvs.set_i32('boot_partition', running_partition_nr) + else: + print("No need to update boot_partition") + except Exception as e: + print(f"Warning: could not write currently booted partition to boot_partition in fri3d.sys of NVS: {e}") + try: + import machine + machine.reset() + except Exception as e: + print(f"Warning: could not restart machine: {e}") diff --git a/internal_filesystem/apps/com.micropythonos.doom_launcher/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/apps/com.micropythonos.doom_launcher/res/mipmap-mdpi/icon_64x64.png new file mode 100644 index 00000000..2c64582b Binary files /dev/null and b/internal_filesystem/apps/com.micropythonos.doom_launcher/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 02c3b41c..57371cde 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.4_64x64.png", -"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.draw/mpks/com.micropythonos.draw_0.0.4.mpk", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.draw/icons/com.micropythonos.draw_0.1.0_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.draw/mpks/com.micropythonos.draw_0.1.0.mpk", "fullname": "com.micropythonos.draw", -"version": "0.0.4", +"version": "0.1.0", "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 d341b94a..eb83f5e3 100644 --- a/internal_filesystem/apps/com.micropythonos.draw/assets/draw.py +++ b/internal_filesystem/apps/com.micropythonos.draw/assets/draw.py @@ -1,5 +1,5 @@ -from mpos.apps import Activity -import mpos.ui +import lvgl as lv +from mpos import Activity, DisplayMetrics, InputManager indev_error_x = 160 indev_error_y = 120 @@ -22,7 +22,7 @@ def onCreate(self): 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) + self.canvas.set_style_bg_color(lv.color_white(), lv.PART.MAIN) buffer = bytearray(self.hor_res * self.ver_res * 4) self.canvas.set_buffer(buffer, self.hor_res, self.ver_res, lv.COLOR_FORMAT.NATIVE) self.canvas.fill_bg(lv.color_white(), lv.OPA.COVER) @@ -35,11 +35,8 @@ def onCreate(self): def touch_cb(self, event): event_code=event.get_code() if event_code not in [19,23,25,26,27,28,29,30,49]: - name = mpos.ui.get_event_name(event_code) - #print(f"lv_event_t: code={event_code}, name={name}") # target={event.get_target()}, user_data={event.get_user_data()}, param={event.get_param()} if event_code == lv.EVENT.PRESSING: # this is probably enough - #if event_code in [lv.EVENT.PRESSED, lv.EVENT.PRESSING, lv.EVENT.LONG_PRESSED, lv.EVENT.LONG_PRESSED_REPEAT]: - x, y = mpos.ui.get_pointer_xy() + x, y = InputManager.pointer_xy() #canvas.set_px(x,y,lv.color_black(),lv.OPA.COVER) # draw a tiny point self.draw_rect(x,y) #self.draw_line(x,y) diff --git a/internal_filesystem/apps/com.micropythonos.draw/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/apps/com.micropythonos.draw/res/mipmap-mdpi/icon_64x64.png index ac0877f0..cd475197 100644 Binary files a/internal_filesystem/apps/com.micropythonos.draw/res/mipmap-mdpi/icon_64x64.png and b/internal_filesystem/apps/com.micropythonos.draw/res/mipmap-mdpi/icon_64x64.png differ diff --git a/internal_filesystem/apps/com.micropythonos.errortest/META-INF/MANIFEST.JSON b/internal_filesystem/apps/com.micropythonos.errortest/META-INF/MANIFEST.JSON index 02aef763..2715179f 100644 --- a/internal_filesystem/apps/com.micropythonos.errortest/META-INF/MANIFEST.JSON +++ b/internal_filesystem/apps/com.micropythonos.errortest/META-INF/MANIFEST.JSON @@ -3,10 +3,10 @@ "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", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.errortest/icons/com.micropythonos.errortest_0.0.3_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.errortest/mpks/com.micropythonos.errortest_0.0.3.mpk", "fullname": "com.micropythonos.errortest", -"version": "0.0.1", +"version": "0.0.3", "category": "development", "activities": [ { diff --git a/internal_filesystem/apps/com.micropythonos.errortest/assets/error.py b/internal_filesystem/apps/com.micropythonos.errortest/assets/error.py index db63482d..979328e7 100644 --- a/internal_filesystem/apps/com.micropythonos.errortest/assets/error.py +++ b/internal_filesystem/apps/com.micropythonos.errortest/assets/error.py @@ -1,4 +1,4 @@ -from mpos.apps import ActivityDoesntExist # should fail here +from mpos import ActivityDoesntExist # should fail here class Error(Activity): diff --git a/internal_filesystem/apps/com.micropythonos.errortest/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/apps/com.micropythonos.errortest/res/mipmap-mdpi/icon_64x64.png new file mode 100644 index 00000000..644ff153 Binary files /dev/null and b/internal_filesystem/apps/com.micropythonos.errortest/res/mipmap-mdpi/icon_64x64.png differ diff --git a/internal_filesystem/apps/com.micropythonos.espnow_chat/META-INF/MANIFEST.JSON b/internal_filesystem/apps/com.micropythonos.espnow_chat/META-INF/MANIFEST.JSON new file mode 100644 index 00000000..2f8e59c2 --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.espnow_chat/META-INF/MANIFEST.JSON @@ -0,0 +1,24 @@ +{ +"name": "ESPNowChat", +"publisher": "MicroPythonOS", +"short_description": "ESPNow Chat", +"long_description": "Simple chat app using EspNow protocol for communication between devices.", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.espnow_chat/icons/com.micropythonos.espnow_chat_0.0.1_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.espnow_chat/mpks/com.micropythonos.espnow_chat_0.0.1.mpk", +"fullname": "com.micropythonos.espnow_chat", +"version": "0.1.0", +"category": "development", +"activities": [ + { + "entrypoint": "assets/espnow_chat.py", + "classname": "EspNowChat", + "intent_filters": [ + { + "action": "main", + "category": "launcher" + } + ] + } + ] +} + diff --git a/internal_filesystem/apps/com.micropythonos.espnow_chat/assets/espnow_chat.py b/internal_filesystem/apps/com.micropythonos.espnow_chat/assets/espnow_chat.py new file mode 100644 index 00000000..87b74c2c --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.espnow_chat/assets/espnow_chat.py @@ -0,0 +1,136 @@ +""" +https://docs.micropython.org/en/latest/library/espnow.html +""" + +from collections import deque + +import lvgl as lv +import machine +from micropython import const +from mpos import Activity, MposKeyboard, TaskManager +from mpos.time import localtime + +try: + import aioespnow +except ImportError: + aioespnow = None + +try: + import network +except ImportError: + network = None + +BROADCAST_MAC = const(b"\xbb\xbb\xbb\xbb\xbb\xbb") + + +def pformat_mac(mac): + if mac: + return ":".join(f"{b:02x}" for b in mac) + else: + return "" + + +class EspNowChat(Activity): + def onCreate(self): + main_content = lv.obj() + main_content.set_flex_flow(lv.FLEX_FLOW.COLUMN) + main_content.set_style_pad_gap(10, 0) + + self.input_textarea = lv.textarea(main_content) + self.input_textarea.set_placeholder_text("Message input...") + self.input_textarea.set_one_line(True) + self.input_textarea.set_style_text_font(lv.font_montserrat_16, lv.PART.MAIN) + self.input_textarea.set_width(lv.pct(100)) + self.input_textarea.add_event_cb(self.show_keyboard, lv.EVENT.CLICKED, None) + + self.keyboard = MposKeyboard(main_content) + self.keyboard.set_textarea(self.input_textarea) + self.keyboard.add_event_cb(self.keyboard_cb, lv.EVENT.READY, None) + self.keyboard.add_flag(lv.obj.FLAG.HIDDEN) + + self.messages = lv.label(main_content) + self.messages.set_style_text_font(lv.font_montserrat_14, 0) + + # Buffer to store and display the latest 20 messages: + self.messages_buffer = deque((), 20) + + self.setContentView(main_content) + + if aioespnow and network: + print("Initialize WLAN interface...") + sta = network.WLAN(network.WLAN.IF_STA) + sta.active(True) + + self.own_id = pformat_mac(machine.unique_id()) + + self.info("Initialize ESPNow...") + self.espnow = aioespnow.AIOESPNow() + self.espnow.active(True) + self.espnow.add_peer(BROADCAST_MAC) + + if sta.isconnected(): + self.info(f"Connected to WiFi: {sta.config('essid')}") + self.info(f"Use WiFi Channel: {sta.config('channel')}") + else: + self.own_id = "" + self.info("ESPNow not available on this platform") + + def info(self, text): + now = localtime() + hour, minute, second = now[3], now[4], now[5] + message = f"{hour:02}:{minute:02}:{second:02} {text}" + print(message) + self.messages_buffer.appendleft(message) + self.messages.set_text("\n".join(self.messages_buffer)) + + def keyboard_cb(self, event): + message = self.input_textarea.get_text() + if not message: + print("Ignore empty input") + else: + self.input_textarea.set_text("") + print(f"Create task to send {message=}...") + TaskManager.create_task(self.send_messages(message)) + + def show_keyboard(self, event): + print("Show keyboard") + self.keyboard.remove_flag(lv.obj.FLAG.HIDDEN) + + async def send_messages(self, message): + self.info(f"Sending: {message} ({self.own_id})") + try: + await self.espnow.asend(BROADCAST_MAC, message.encode()) + except OSError as err: + print(f"Error sending message: {err}") + else: + print(f"{message=} sent") + + async def receive_messages(self): + await self.send_messages(f"{self.own_id} joins ESPNow chat.") + async for mac, msg in self.espnow: + if not msg: + print("Ignore empty message from", pformat_mac(mac)) + continue + try: + msg = msg.decode() + except UnicodeError as err: + msg = f"" + self.info(f"{msg} ({pformat_mac(mac)})") + raise RuntimeError("ESPNow receive loop exited, which shouldn't happen") + + def onResume(self, screen): + super().onResume(screen) + if aioespnow and network: + TaskManager.create_task(self.receive_messages()) + + def onPause(self, screen): + if aioespnow and network: + self.espnow.send( + BROADCAST_MAC, f"{self.own_id} leaves ESPNow chat.".encode() + ) + + print("Stop ESPNow...") + self.espnow.active(False) + print("ESPNow deactivated") + + super().onPause(screen) diff --git a/internal_filesystem/apps/com.micropythonos.espnow_chat/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/apps/com.micropythonos.espnow_chat/res/mipmap-mdpi/icon_64x64.png new file mode 100644 index 00000000..e7412cda Binary files /dev/null and b/internal_filesystem/apps/com.micropythonos.espnow_chat/res/mipmap-mdpi/icon_64x64.png differ 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 0888ef29..02b2e995 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.3_64x64.png", -"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.filemanager/mpks/com.micropythonos.filemanager_0.0.3.mpk", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.filemanager/icons/com.micropythonos.filemanager_0.0.5_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.filemanager/mpks/com.micropythonos.filemanager_0.0.5.mpk", "fullname": "com.micropythonos.filemanager", -"version": "0.0.3", +"version": "0.0.5", "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 39a0d868..12021624 100644 --- a/internal_filesystem/apps/com.micropythonos.filemanager/assets/file_manager.py +++ b/internal_filesystem/apps/com.micropythonos.filemanager/assets/file_manager.py @@ -1,5 +1,5 @@ -from mpos.apps import Activity -import mpos.ui +import lvgl as lv +from mpos import Activity, ui class FileManager(Activity): @@ -40,7 +40,7 @@ def file_explorer_event_cb(self, event): # GET_SELF_SIZE # 47 STYLE CHANGED if event_code not in [2,19,23,24,25,26,27,28,29,30,31,32,33,47,49,52]: - name = mpos.ui.get_event_name(event_code) + name = ui.get_event_name(event_code) print(f"file_explorer_event_cb {event_code} with name {name}") if event_code == lv.EVENT.VALUE_CHANGED: path = self.file_explorer.explorer_get_current_path() diff --git a/internal_filesystem/apps/com.micropythonos.filemanager/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/apps/com.micropythonos.filemanager/res/mipmap-mdpi/icon_64x64.png index e95b8022..9af02217 100644 Binary files a/internal_filesystem/apps/com.micropythonos.filemanager/res/mipmap-mdpi/icon_64x64.png and b/internal_filesystem/apps/com.micropythonos.filemanager/res/mipmap-mdpi/icon_64x64.png differ diff --git a/internal_filesystem/apps/com.micropythonos.helloworld/META-INF/MANIFEST.JSON b/internal_filesystem/apps/com.micropythonos.helloworld/META-INF/MANIFEST.JSON index 96d8eee3..446f9974 100644 --- a/internal_filesystem/apps/com.micropythonos.helloworld/META-INF/MANIFEST.JSON +++ b/internal_filesystem/apps/com.micropythonos.helloworld/META-INF/MANIFEST.JSON @@ -3,10 +3,10 @@ "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", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.helloworld/icons/com.micropythonos.helloworld_0.0.5_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.helloworld/mpks/com.micropythonos.helloworld_0.0.5.mpk", "fullname": "com.micropythonos.helloworld", -"version": "0.0.2", +"version": "0.0.5", "category": "development", "activities": [ { diff --git a/internal_filesystem/apps/com.micropythonos.helloworld/assets/hello.py b/internal_filesystem/apps/com.micropythonos.helloworld/assets/hello.py index 7682beec..87ed4dd3 100644 --- a/internal_filesystem/apps/com.micropythonos.helloworld/assets/hello.py +++ b/internal_filesystem/apps/com.micropythonos.helloworld/assets/hello.py @@ -1,4 +1,4 @@ -from mpos.apps import Activity +from mpos import Activity class Hello(Activity): diff --git a/internal_filesystem/apps/com.micropythonos.helloworld/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/apps/com.micropythonos.helloworld/res/mipmap-mdpi/icon_64x64.png index cac4a6f2..dc1a9506 100644 Binary files a/internal_filesystem/apps/com.micropythonos.helloworld/res/mipmap-mdpi/icon_64x64.png and b/internal_filesystem/apps/com.micropythonos.helloworld/res/mipmap-mdpi/icon_64x64.png differ 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 a0a333f7..36846295 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.4_64x64.png", -"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.imageview/mpks/com.micropythonos.imageview_0.0.4.mpk", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.imageview/icons/com.micropythonos.imageview_0.1.1_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.imageview/mpks/com.micropythonos.imageview_0.1.1.mpk", "fullname": "com.micropythonos.imageview", -"version": "0.0.4", +"version": "0.1.1", "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 4433b503..79e812fd 100644 --- a/internal_filesystem/apps/com.micropythonos.imageview/assets/imageview.py +++ b/internal_filesystem/apps/com.micropythonos.imageview/assets/imageview.py @@ -1,9 +1,7 @@ import gc import os -from mpos.apps import Activity -import mpos.ui -import mpos.ui.anim +from mpos import Activity, WidgetAnimator, DisplayMetrics class ImageView(Activity): @@ -41,13 +39,15 @@ def onCreate(self): 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) + prev_label.set_style_text_font(lv.font_montserrat_16, lv.PART.MAIN) + + # Invisible button, just for defocusing the prev and next buttons: 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) + self.play_button.set_style_opa(lv.OPA.TRANSP, lv.PART.MAIN) #self.play_button.add_flag(lv.obj.FLAG.HIDDEN) #self.play_button.add_event_cb(lambda e: self.unfocus_if_not_fullscreen(),lv.EVENT.FOCUSED,None) - #self.play_button.set_style_shadow_opa(lv.OPA.TRANSP, 0) + #self.play_button.set_style_shadow_opa(lv.OPA.TRANSP, lv.PART.MAIN) #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) @@ -56,7 +56,7 @@ def onCreate(self): 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) + delete_label.set_style_text_font(lv.font_montserrat_16, lv.PART.MAIN) 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) @@ -64,7 +64,7 @@ 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) + next_label.set_style_text_font(lv.font_montserrat_16, lv.PART.MAIN) #screen.add_event_cb(self.print_events, lv.EVENT.ALL, None) self.setContentView(screen) @@ -104,9 +104,9 @@ def onStop(self, screen): 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) + WidgetAnimator.smooth_hide(self.prev_button) + WidgetAnimator.smooth_hide(self.delete_button) + WidgetAnimator.smooth_hide(self.next_button) def show_prev_image(self, event=None): print("showing previous image...") @@ -133,21 +133,21 @@ def toggle_fullscreen(self, event=None): 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) + WidgetAnimator.smooth_show(self.label) + WidgetAnimator.smooth_show(self.prev_button) + WidgetAnimator.smooth_show(self.delete_button) + #WidgetAnimator.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) + WidgetAnimator.smooth_show(self.next_button) 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) + WidgetAnimator.smooth_hide(self.label) + WidgetAnimator.smooth_hide(self.prev_button, hide=False) + WidgetAnimator.smooth_hide(self.delete_button, hide=False) + #WidgetAnimator.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) + WidgetAnimator.smooth_hide(self.next_button, hide=False) self.unfocus() # focus on the invisible center button, not previous or next def show_prev_image_if_fullscreen(self, event=None): @@ -169,12 +169,10 @@ def unfocus(self): if not focusgroup: print("WARNING: imageview.py could not get default focus group") return - print("got focus group") - # group.focus_obj(self.play_button) would be better but appears missing?! focused = focusgroup.get_focused() - print("got focus button") - #focused.remove_state(lv.STATE.FOCUSED) # this doesn't seem to work to remove focus if focused: + print(f"got focus button: {focused}") + #focused.remove_state(lv.STATE.FOCUSED) # this doesn't seem to work to remove focus print("checking which button is focused") if focused == self.next_button: print("next is focused") @@ -273,8 +271,8 @@ def scale_image(self): pct = 100 else: pct = 70 - lvgl_w = mpos.ui.pct_of_display_width(pct) - lvgl_h = mpos.ui.pct_of_display_height(pct) + lvgl_w = DisplayMetrics.pct_of_width(pct) + lvgl_h = DisplayMetrics.pct_of_height(pct) print(f"scaling to size: {lvgl_w}x{lvgl_h}") header = lv.image_header_t() self.image.decoder_get_info(self.image.get_src(), header) diff --git a/internal_filesystem/apps/com.micropythonos.imageview/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/apps/com.micropythonos.imageview/res/mipmap-mdpi/icon_64x64.png index 8ec8f3e6..2f38ecd6 100644 Binary files a/internal_filesystem/apps/com.micropythonos.imageview/res/mipmap-mdpi/icon_64x64.png and b/internal_filesystem/apps/com.micropythonos.imageview/res/mipmap-mdpi/icon_64x64.png differ 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..1c575bec 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.1.0_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.imu/mpks/com.micropythonos.imu_0.1.0.mpk", "fullname": "com.micropythonos.imu", -"version": "0.0.2", +"version": "0.1.0", "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 4cf3cb51..7679758e 100644 --- a/internal_filesystem/apps/com.micropythonos.imu/assets/imu.py +++ b/internal_filesystem/apps/com.micropythonos.imu/assets/imu.py @@ -1,5 +1,4 @@ -from mpos.apps import Activity -import mpos.sensor_manager as SensorManager +from mpos import Activity, SensorManager class IMU(Activity): diff --git a/internal_filesystem/apps/com.micropythonos.imu/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/apps/com.micropythonos.imu/res/mipmap-mdpi/icon_64x64.png index 334a8777..824a3b31 100644 Binary files a/internal_filesystem/apps/com.micropythonos.imu/res/mipmap-mdpi/icon_64x64.png and b/internal_filesystem/apps/com.micropythonos.imu/res/mipmap-mdpi/icon_64x64.png differ 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 e7bf0e1e..6c0ae6de 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.4_64x64.png", -"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.musicplayer/mpks/com.micropythonos.musicplayer_0.0.4.mpk", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.musicplayer/icons/com.micropythonos.musicplayer_0.1.1_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.musicplayer/mpks/com.micropythonos.musicplayer_0.1.1.mpk", "fullname": "com.micropythonos.musicplayer", -"version": "0.0.4", +"version": "0.1.1", "category": "development", "activities": [ { 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 14380937..4d1130f1 100644 --- a/internal_filesystem/apps/com.micropythonos.musicplayer/assets/music_player.py +++ b/internal_filesystem/apps/com.micropythonos.musicplayer/assets/music_player.py @@ -2,10 +2,9 @@ import os import time -from mpos.apps import Activity, Intent -import mpos.sdcard -import mpos.ui -import mpos.audio.audioflinger as AudioFlinger +from mpos import Activity, Intent, sdcard, get_event_name, AudioManager + +slider_max = 16 class MusicPlayer(Activity): @@ -15,7 +14,13 @@ class MusicPlayer(Activity): def onCreate(self): screen = lv.obj() # the user might have recently plugged in the sd card so try to mount it - mpos.sdcard.mount_with_optional_format('/sdcard') + sdcard.mount_with_optional_format('/sdcard') + + active_track = AudioManager.get_active_track(stream_type=AudioManager.STREAM_MUSIC) + if active_track: + self.startActivity(Intent(activity_class=FullscreenPlayer).putExtra("filename", active_track)) + return + self.file_explorer = lv.file_explorer(screen) self.file_explorer.explorer_open_dir('M:/') self.file_explorer.align(lv.ALIGN.CENTER, 0, 0) @@ -30,12 +35,12 @@ def onCreate(self): def onResume(self, screen): # the user might have recently plugged in the sd card so try to mount it - mpos.sdcard.mount_with_optional_format('/sdcard') # would be good to refresh the file_explorer so the /sdcard folder shows up + sdcard.mount_with_optional_format('/sdcard') # would be good to refresh the file_explorer so the /sdcard folder shows up def file_explorer_event_cb(self, event): event_code = event.get_code() if event_code not in [2,19,23,24,25,26,27,28,29,30,31,32,33,47,49,52]: - name = mpos.ui.get_event_name(event_code) + name = get_event_name(event_code) #print(f"file_explorer_event_cb {event_code} with name {name}") if event_code == lv.EVENT.VALUE_CHANGED: path = self.file_explorer.explorer_get_current_path() @@ -65,19 +70,26 @@ class FullscreenPlayer(Activity): 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: {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(AudioFlinger.get_volume(), False) + + audio_volume = AudioManager.get_volume() + slider_volume = int(round(audio_volume * slider_max / 100)) + + self._slider_label = lv.label(qr_screen) + self._slider_label.set_text(f"Volume: {audio_volume}%") + self._slider_label.align(lv.ALIGN.TOP_MID, 0, lv.pct(4)) + self._slider = lv.slider(qr_screen) + self._slider.set_range(0, slider_max) + self._slider.set_value(slider_volume, False) self._slider.set_width(lv.pct(90)) - self._slider.align_to(self._slider_label,lv.ALIGN.OUT_BOTTOM_MID,0,10) + 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() + slider_value = int(self._slider.get_value()) + volume_int = int(round(slider_value * 100 / slider_max)) self._slider_label.set_text(f"Volume: {volume_int}%") - AudioFlinger.set_volume(volume_int) - self._slider.add_event_cb(volume_slider_changed,lv.EVENT.VALUE_CHANGED,None) + AudioManager.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) self._filename_label.set_text(self._filename) @@ -101,24 +113,41 @@ def onResume(self, screen): super().onResume(screen) if not self._filename: print("Not playing any file...") - else: - print(f"Playing file {self._filename}") - AudioFlinger.stop() - time.sleep(0.1) - - success = AudioFlinger.play_wav( - self._filename, - stream_type=AudioFlinger.STREAM_MUSIC, - on_complete=self.player_finished + return + + print(f"Playing file {self._filename}") + active_player = AudioManager.get_active_player(stream_type=AudioManager.STREAM_MUSIC) + if active_player and active_player.file_path == self._filename and active_player.is_playing(): + return + + AudioManager.stop() + time.sleep(0.1) + + output = AudioManager.get_default_output() + if output is None: + error_msg = "Error: No audio output available" + print(error_msg) + self.update_ui_threadsafe_if_foreground( + self._filename_label.set_text, + error_msg + ) + return + + try: + player = AudioManager.player( + file_path=self._filename, + stream_type=AudioManager.STREAM_MUSIC, + on_complete=self.player_finished, + output=output, + ) + player.start() + except Exception as exc: + error_msg = "Error: Audio device unavailable or busy" + print(f"{error_msg}: {exc}") + self.update_ui_threadsafe_if_foreground( + self._filename_label.set_text, + error_msg ) - - 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) @@ -128,7 +157,7 @@ def defocus_obj(self, obj): obj.set_style_border_width(0, lv.PART.MAIN) def stop_button_clicked(self, event): - AudioFlinger.stop() + AudioManager.stop() self.finish() def player_finished(self, result=None): diff --git a/internal_filesystem/apps/com.micropythonos.musicplayer/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/apps/com.micropythonos.musicplayer/res/mipmap-mdpi/icon_64x64.png index 9e6923cb..07e03d1d 100644 Binary files a/internal_filesystem/apps/com.micropythonos.musicplayer/res/mipmap-mdpi/icon_64x64.png and b/internal_filesystem/apps/com.micropythonos.musicplayer/res/mipmap-mdpi/icon_64x64.png differ diff --git a/internal_filesystem/apps/com.micropythonos.nostr/META-INF/MANIFEST.JSON b/internal_filesystem/apps/com.micropythonos.nostr/META-INF/MANIFEST.JSON new file mode 100644 index 00000000..173b3f14 --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.nostr/META-INF/MANIFEST.JSON @@ -0,0 +1,23 @@ +{ +"name": "Nostr", +"publisher": "MicroPythonOS", +"short_description": "Nostr", +"long_description": "Notest and Other Stuff Transmitted by Relays", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.nostr/icons/com.micropythonos.nostr_0.1.1_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.nostr/mpks/com.micropythonos.nostr_0.1.1.mpk", +"fullname": "com.micropythonos.nostr", +"version": "0.1.1", +"category": "communication", +"activities": [ + { + "entrypoint": "assets/nostr_app.py", + "classname": "NostrApp", + "intent_filters": [ + { + "action": "main", + "category": "launcher" + } + ] + } + ] +} diff --git a/internal_filesystem/apps/com.micropythonos.nostr/assets/fullscreen_qr.py b/internal_filesystem/apps/com.micropythonos.nostr/assets/fullscreen_qr.py new file mode 100644 index 00000000..8f4f237a --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.nostr/assets/fullscreen_qr.py @@ -0,0 +1,41 @@ +import lvgl as lv + +from mpos import Activity, DisplayMetrics + +class FullscreenQR(Activity): + # No __init__() so super.__init__() will be called automatically + + def onCreate(self): + print("FullscreenQR.onCreate() called") + intent = self.getIntent() + print(f"Got intent: {intent}") + extras = intent.extras + print(f"Got extras: {extras}") + receive_qr_data = extras.get("receive_qr_data") + print(f"Got receive_qr_data: {receive_qr_data}") + + if not receive_qr_data: + print("ERROR: receive_qr_data is None or empty!") + error_screen = lv.obj() + error_label = lv.label(error_screen) + error_label.set_text("No QR data") + error_label.center() + self.setContentView(error_screen) + return + + qr_screen = lv.obj() + qr_screen.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) + qr_screen.set_scroll_dir(lv.DIR.NONE) + qr_screen.add_event_cb(lambda e: self.finish(),lv.EVENT.CLICKED,None) + big_receive_qr = lv.qrcode(qr_screen) + big_receive_qr.set_size(DisplayMetrics.min_dimension()) + big_receive_qr.set_dark_color(lv.color_black()) + big_receive_qr.set_light_color(lv.color_white()) + big_receive_qr.center() + big_receive_qr.set_style_border_color(lv.color_white(), lv.PART.MAIN) + big_receive_qr.set_style_border_width(0, lv.PART.MAIN); + print(f"Updating QR code with data: {receive_qr_data[:20]}...") + big_receive_qr.update(receive_qr_data, len(receive_qr_data)) + print("QR code updated, setting content view") + self.setContentView(qr_screen) + print("Content view set") diff --git a/internal_filesystem/apps/com.micropythonos.nostr/assets/nostr_app.py b/internal_filesystem/apps/com.micropythonos.nostr/assets/nostr_app.py new file mode 100644 index 00000000..f62d15ba --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.nostr/assets/nostr_app.py @@ -0,0 +1,196 @@ +import lvgl as lv + +from mpos import Activity, Intent, ConnectivityManager, DisplayMetrics, SharedPreferences, SettingsActivity +from fullscreen_qr import FullscreenQR + +class ShowNpubQRActivity(Activity): + """Activity that computes npub from nsec and displays it as a QR code""" + + def onCreate(self): + try: + print("ShowNpubQRActivity.onCreate() called") + prefs = self.getIntent().extras.get("prefs") + print(f"Got prefs: {prefs}") + nsec = prefs.get_string("nostr_nsec") + print(f"Got nsec: {nsec[:20] if nsec else 'None'}...") + + if not nsec: + print("ERROR: No nsec configured") + # Show error screen + error_screen = lv.obj() + error_label = lv.label(error_screen) + error_label.set_text("No nsec configured") + error_label.center() + self.setContentView(error_screen) + return + + # Compute npub from nsec + print("Importing PrivateKey...") + from nostr.key import PrivateKey + print("Computing npub from nsec...") + if nsec.startswith("nsec1"): + print("Using from_nsec()") + private_key = PrivateKey.from_nsec(nsec) + else: + print("Using hex format") + private_key = PrivateKey(bytes.fromhex(nsec)) + + npub = private_key.public_key.bech32() + print(f"Computed npub: {npub[:20]}...") + + # Launch FullscreenQR activity with npub as QR data + print("Creating FullscreenQR intent...") + intent = Intent(activity_class=FullscreenQR) + intent.putExtra("receive_qr_data", npub) + print(f"Starting FullscreenQR activity with npub: {npub[:20]}...") + self.startActivity(intent) + except Exception as e: + print(f"ShowNpubQRActivity exception: {e}") + # Show error screen + error_screen = lv.obj() + error_label = lv.label(error_screen) + error_label.set_text(f"Error: {e}") + error_label.center() + self.setContentView(error_screen) + import sys + sys.print_exception(e) + +class NostrApp(Activity): + + wallet = None + events_label_current_font = 2 + events_label_fonts = [ lv.font_montserrat_10, lv.font_unscii_8, lv.font_montserrat_16, lv.font_montserrat_24, lv.font_unscii_16, lv.font_montserrat_28_compressed, lv.font_montserrat_40] + + # screens: + main_screen = None + + # widgets + balance_label = None + events_label = None + + def onCreate(self): + self.prefs = SharedPreferences("com.micropythonos.nostr") + self.main_screen = lv.obj() + self.main_screen.set_style_pad_all(10, lv.PART.MAIN) + # Header line + header_line = lv.line(self.main_screen) + header_line.set_points([{'x':0,'y':35},{'x':200,'y':35}],2) + header_line.add_flag(lv.obj.FLAG.CLICKABLE) + # Header label showing which npub we're following + self.balance_label = lv.label(self.main_screen) + self.balance_label.set_text("") + self.balance_label.align(lv.ALIGN.TOP_LEFT, 0, 0) + self.balance_label.set_style_text_font(lv.font_montserrat_24, lv.PART.MAIN) + self.balance_label.add_flag(lv.obj.FLAG.CLICKABLE) + self.balance_label.set_width(DisplayMetrics.pct_of_width(100)) + # Events label + self.events_label = lv.label(self.main_screen) + self.events_label.set_text("") + self.events_label.align_to(header_line,lv.ALIGN.OUT_BOTTOM_LEFT,0,10) + self.update_events_label_font() + self.events_label.set_width(DisplayMetrics.pct_of_width(100)) + self.events_label.add_flag(lv.obj.FLAG.CLICKABLE) + self.events_label.add_event_cb(self.events_label_clicked,lv.EVENT.CLICKED,None) + settings_button = lv.button(self.main_screen) + settings_button.set_size(lv.pct(20), lv.pct(25)) + settings_button.align(lv.ALIGN.BOTTOM_RIGHT, 0, 0) + settings_button.add_event_cb(self.settings_button_tap,lv.EVENT.CLICKED,None) + settings_label = lv.label(settings_button) + settings_label.set_text(lv.SYMBOL.SETTINGS) + settings_label.set_style_text_font(lv.font_montserrat_24, lv.PART.MAIN) + settings_label.center() + self.setContentView(self.main_screen) + + def onStart(self, main_screen): + self.main_ui_set_defaults() + + def onResume(self, main_screen): + super().onResume(main_screen) + cm = ConnectivityManager.get() + cm.register_callback(self.network_changed) + self.network_changed(cm.is_online()) + + def onPause(self, main_screen): + if self.wallet: + self.wallet.stop() + cm = ConnectivityManager.get() + cm.unregister_callback(self.network_changed) + + def network_changed(self, online): + print("displaywallet.py network_changed, now:", "ONLINE" if online else "OFFLINE") + if online: + self.went_online() + else: + self.went_offline() + + def went_online(self): + if self.wallet and self.wallet.is_running(): + print("wallet is already running, nothing to do") # might have come from the QR activity + return + try: + from nostr_client import NostrClient + nsec = self.prefs.get_string("nostr_nsec") + # Generate a random nsec if not configured + if not nsec: + from nostr.key import PrivateKey + random_key = PrivateKey() + nsec = random_key.bech32() + self.prefs.edit().put_string("nostr_nsec", nsec).commit() + print(f"Generated random nsec: {nsec}") + follow_npub = self.prefs.get_string("nostr_follow_npub") + relay = self.prefs.get_string("nostr_relay") + self.wallet = NostrClient(nsec, follow_npub, relay) + except Exception as e: + self.error_cb(f"Couldn't initialize Nostr client because: {e}") + import sys + sys.print_exception(e) + return + self.balance_label.set_text("Events from " + self.prefs.get_string("nostr_follow_npub")[:16] + "...") + self.events_label.set_text(f"\nConnecting to relay.\n\nIf this takes too long, the relay might be down or something's wrong with the settings.") + # by now, self.wallet can be assumed + self.wallet.start(self.redraw_events_cb, self.error_cb) + + def went_offline(self): + if self.wallet: + self.wallet.stop() # don't stop the wallet for the fullscreen QR activity + self.events_label.set_text(f"WiFi is not connected, can't talk to relay...") + + def update_events_label_font(self): + self.events_label.set_style_text_font(self.events_label_fonts[self.events_label_current_font], lv.PART.MAIN) + + def events_label_clicked(self, event): + self.events_label_current_font = (self.events_label_current_font + 1) % len(self.events_label_fonts) + self.update_events_label_font() + + def redraw_events_cb(self): + # this gets called from another thread (the wallet) so make sure it happens in the LVGL thread using lv.async_call(): + events_text = "" + if self.wallet.event_list: + for event in self.wallet.event_list: + # Use the enhanced __str__ method that includes kind, timestamp, and tags + events_text += f"{str(event)}\n\n" + else: + events_text = "No events yet..." + self.events_label.set_text(events_text) + + def error_cb(self, error): + if self.wallet and self.wallet.is_running(): + self.events_label.set_text(str(error)) + + def should_show_setting(self, setting): + return True + + def settings_button_tap(self, event): + intent = Intent(activity_class=SettingsActivity) + intent.putExtra("prefs", self.prefs) + intent.putExtra("settings", [ + {"title": "Nostr Private Key (nsec)", "key": "nostr_nsec", "placeholder": "nsec1...", "should_show": self.should_show_setting}, + {"title": "Nostr Follow Public Key (npub)", "key": "nostr_follow_npub", "placeholder": "npub1...", "should_show": self.should_show_setting}, + {"title": "Nostr Relay", "key": "nostr_relay", "placeholder": "wss://relay.example.com", "should_show": self.should_show_setting}, + {"title": "Show My Public Key (npub)", "key": "show_npub_qr", "ui": "activity", "activity_class": ShowNpubQRActivity, "dont_persist": True, "should_show": self.should_show_setting}, + ]) + self.startActivity(intent) + + def main_ui_set_defaults(self): + self.balance_label.set_text("Welcome!") + self.events_label.set_text(lv.SYMBOL.REFRESH) diff --git a/internal_filesystem/apps/com.micropythonos.nostr/assets/nostr_client.py b/internal_filesystem/apps/com.micropythonos.nostr/assets/nostr_client.py new file mode 100644 index 00000000..12d2d156 --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.nostr/assets/nostr_client.py @@ -0,0 +1,291 @@ +import ssl +import json +import time + +from mpos import TaskManager + +from nostr.relay_manager import RelayManager +from nostr.message_type import ClientMessageType +from nostr.filter import Filter, Filters +from nostr.key import PrivateKey + +# Mapping of Nostr event kinds to human-readable names +EVENT_KIND_NAMES = { + 0: "SET_METADATA", + 1: "TEXT_NOTE", + 2: "RECOMMEND_RELAY", + 3: "CONTACTS", + 4: "ENCRYPTED_DM", + 5: "DELETE", +} + +def get_kind_name(kind): + """Get human-readable name for an event kind""" + return EVENT_KIND_NAMES.get(kind, f"UNKNOWN({kind})") + +def format_timestamp(timestamp): + """Format a Unix timestamp to a readable date/time string""" + try: + import time as time_module + # Convert Unix timestamp to time tuple + time_tuple = time_module.localtime(timestamp) + # Format as YYYY-MM-DD HH:MM + return "{:04d}-{:02d}-{:02d} {:02d}:{:02d}".format( + time_tuple[0], time_tuple[1], time_tuple[2], + time_tuple[3], time_tuple[4] + ) + except: + return str(timestamp) + +def format_tags(tags): + """Format event tags into a readable string""" + if not tags: + return "" + + tag_strs = [] + for tag in tags: + if len(tag) >= 2: + tag_type = tag[0] + tag_value = tag[1] + # Truncate long values + if len(tag_value) > 16: + tag_value = tag_value[:16] + "..." + tag_strs.append(f"{tag_type}:{tag_value}") + + if tag_strs: + return "Tags: " + ", ".join(tag_strs) + return "" + +class NostrEvent: + """Simple wrapper for a Nostr event with rich details""" + def __init__(self, event_obj, private_key=None): + self.event = event_obj + self.created_at = event_obj.created_at + self.content = event_obj.content + self.public_key = event_obj.public_key + self.kind = event_obj.kind + self.tags = event_obj.tags if hasattr(event_obj, 'tags') else [] + self.private_key = private_key + self.decrypted_content = None + + # Try to decrypt if this is an encrypted DM + if self.kind == 4 and self.private_key: + self._try_decrypt() + + def _try_decrypt(self): + """Attempt to decrypt encrypted DM content""" + try: + if self.kind == 4 and self.content: + decrypted = self.private_key.decrypt_message( + self.content, + self.public_key + ) + self.decrypted_content = decrypted + print(f"DEBUG: Successfully decrypted DM: {decrypted}") + except Exception as e: + print(f"DEBUG: Failed to decrypt DM: {e}") + # Leave decrypted_content as None if decryption fails + + def get_kind_name(self): + """Get human-readable name for this event's kind""" + return get_kind_name(self.kind) + + def get_formatted_timestamp(self): + """Get formatted timestamp for this event""" + return format_timestamp(self.created_at) + + def get_formatted_tags(self): + """Get formatted tags for this event""" + return format_tags(self.tags) + + def get_display_content(self): + """Get the content to display (decrypted if available, otherwise raw)""" + if self.decrypted_content is not None: + return self.decrypted_content + return self.content + + def __str__(self): + """Return formatted event details""" + kind_name = self.get_kind_name() + timestamp = self.get_formatted_timestamp() + tags_str = self.get_formatted_tags() + display_content = self.get_display_content() + + # Build the formatted event string + result = f"[{kind_name}] {timestamp}\n" + if display_content: + result += f"{display_content}" + if tags_str: + result += f"\n{tags_str}" + + return result + +class NostrClient(): + """Simple Nostr event subscriber that connects to a relay and subscribes to a public key's events""" + + EVENTS_TO_SHOW = 50 + + relay = None + nsec = None + follow_npub = None + private_key = None + relay_manager = None + + def __init__(self, nsec, follow_npub, relay): + super().__init__() + self.nsec = nsec + self.follow_npub = follow_npub + self.relay = relay + self.event_list = [] + + if not nsec: + raise ValueError('Nostr private key (nsec) is not set.') + if not follow_npub: + raise ValueError('Nostr follow public key (npub) is not set.') + if not relay: + raise ValueError('Nostr relay is not set.') + + self.connected = False + + async def async_event_manager_task(self): + """Main event loop: connect to relay and subscribe to events""" + try: + # Initialize private key from nsec + # nsec can be in bech32 format (nsec1...) or hex format + if self.nsec.startswith("nsec1"): + self.private_key = PrivateKey.from_nsec(self.nsec) + else: + self.private_key = PrivateKey(bytes.fromhex(self.nsec)) + + # Initialize relay manager + self.relay_manager = RelayManager() + self.relay_manager.add_relay(self.relay) + + print(f"DEBUG: Opening relay connection to {self.relay}") + await self.relay_manager.open_connections({"cert_reqs": ssl.CERT_NONE}) + + self.connected = False + for _ in range(100): + await TaskManager.sleep(0.1) + nrconnected = self.relay_manager.connected_or_errored_relays() + if nrconnected == 1 or not self.keep_running: + break + + if nrconnected == 0: + self.handle_error("Could not connect to Nostr relay.") + return + + if not self.keep_running: + print(f"async_event_manager_task: not keep_running, returning...") + return + + print(f"Relay connected") + self.connected = True + + # Set up subscription to receive events from follow_npub + self.subscription_id = "micropython_nostr_" + str(round(time.time())) + print(f"DEBUG: Setting up subscription with ID: {self.subscription_id}") + + # Convert npub to hex if needed + follow_npub_hex = self.follow_npub + if self.follow_npub.startswith("npub1"): + from nostr.key import PublicKey + follow_npub_hex = PublicKey.from_npub(self.follow_npub).hex() + print(f"DEBUG: Converted npub to hex: {follow_npub_hex}") + + # Create filter for events from follow_npub + # Note: Some relays don't support filtering by both kinds and authors + # So we just filter by authors + self.filters = Filters([Filter( + authors=[follow_npub_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") + + # Main event loop + while True: + await TaskManager.sleep(0.1) + if not self.keep_running: + print("NostrClient: 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: + # Create NostrEvent wrapper with private key for decryption + nostr_event = NostrEvent(event_msg.event, self.private_key) + print(f"DEBUG: Event content: {nostr_event.content}") + + # Add to event list + self.handle_new_event(nostr_event) + + except Exception as e: + print(f"DEBUG: Error processing event: {e}") + import sys + sys.print_exception(e) + + # Check for relay notices (error messages) + if self.relay_manager.message_pool.has_notices(): + notice_msg = self.relay_manager.message_pool.get_notice() + print(f"DEBUG: Relay notice: {notice_msg}") + if notice_msg: + self.handle_error(f"Relay: {notice_msg.content}") + + except Exception as e: + print(f"async_event_manager_task exception: {e}") + import sys + sys.print_exception(e) + self.handle_error(f"Error in event manager: {e}") + + # Public variables + last_known_balance = 0 + event_list = None + + # Variables + keep_running = True + + # Callbacks: + events_updated_cb = None + error_cb = None + + def handle_new_event(self, new_event): + """Handle a new event from the relay""" + if not self.keep_running: + return + print("handle_new_event") + self.event_list.append(new_event) + # Keep only the most recent EVENTS_TO_SHOW events + if len(self.event_list) > self.EVENTS_TO_SHOW: + self.event_list = self.event_list[-self.EVENTS_TO_SHOW:] + if self.events_updated_cb: + self.events_updated_cb() + + def handle_error(self, e): + if self.error_cb: + self.error_cb(e) + + def start(self, events_updated_cb, error_cb=None): + """Start the event manager task""" + self.keep_running = True + self.events_updated_cb = events_updated_cb + self.error_cb = error_cb + TaskManager.create_task(self.async_event_manager_task()) + + def stop(self): + """Stop the event manager task""" + self.keep_running = False + + def is_running(self): + return self.keep_running diff --git a/internal_filesystem/apps/com.micropythonos.nostr/assets/payment.py b/internal_filesystem/apps/com.micropythonos.nostr/assets/payment.py new file mode 100644 index 00000000..c331f1bb --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.nostr/assets/payment.py @@ -0,0 +1,43 @@ +# 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" + if not self.comment: + verb = "spent" + if self.amount_sats > 0: + verb = "received!" + return f"{self.amount_sats} {sattext} {verb}" + #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) diff --git a/internal_filesystem/apps/com.micropythonos.nostr/assets/unique_sorted_list.py b/internal_filesystem/apps/com.micropythonos.nostr/assets/unique_sorted_list.py new file mode 100644 index 00000000..8f2dc4e5 --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.nostr/assets/unique_sorted_list.py @@ -0,0 +1,43 @@ +# 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)) diff --git a/internal_filesystem/apps/com.micropythonos.nostr/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/apps/com.micropythonos.nostr/res/mipmap-mdpi/icon_64x64.png new file mode 100644 index 00000000..92208d3c Binary files /dev/null and b/internal_filesystem/apps/com.micropythonos.nostr/res/mipmap-mdpi/icon_64x64.png differ diff --git a/internal_filesystem/apps/com.micropythonos.scan_bluetooth/META-INF/MANIFEST.JSON b/internal_filesystem/apps/com.micropythonos.scan_bluetooth/META-INF/MANIFEST.JSON new file mode 100644 index 00000000..53754530 --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.scan_bluetooth/META-INF/MANIFEST.JSON @@ -0,0 +1,24 @@ +{ +"name": "ScanBluetooth", +"publisher": "MicroPythonOS", +"short_description": "Scan Bluetooth", +"long_description": "Lists all nearby Bluetooth devices with some information", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.scan_bluetooth/icons/com.micropythonos.scan_bluetooth_0.0.1_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.scan_bluetooth/mpks/com.micropythonos.scan_bluetooth_0.0.1.mpk", +"fullname": "com.micropythonos.scan_bluetooth", +"version": "0.1.0", +"category": "development", +"activities": [ + { + "entrypoint": "assets/scan_bluetooth.py", + "classname": "ScanBluetooth", + "intent_filters": [ + { + "action": "main", + "category": "launcher" + } + ] + } + ] +} + diff --git a/internal_filesystem/apps/com.micropythonos.scan_bluetooth/assets/scan_bluetooth.py b/internal_filesystem/apps/com.micropythonos.scan_bluetooth/assets/scan_bluetooth.py new file mode 100644 index 00000000..4ee88b80 --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.scan_bluetooth/assets/scan_bluetooth.py @@ -0,0 +1,194 @@ +""" +Initial author: https://github.com/jedie +https://docs.micropython.org/en/latest/library/bluetooth.html +""" + +import time + +try: + import bluetooth +except ImportError: # Linux test runner may not provide bluetooth module + bluetooth = None + +import sys + +import lvgl as lv +from micropython import const +from mpos import Activity, TaskManager + +# Scan for 5 seconds, +SCAN_DURATION_MS = const(5000) # Duration of each BLE scan in milliseconds +# with very low interval/window (to maximize detection rate): +INTERVAL_US = const(30000) +WINDOW_US = const(30000) + +_IRQ_SCAN_RESULT = const(5) +_IRQ_SCAN_DONE = const(6) + +# BLE Advertising Data Types (Standardized by Bluetooth SIG) +_ADV_TYPE_SHORT_NAME = const(8) +_ADV_TYPE_NAME = const(9) + + +def decode_name(payload: bytes) -> str | None: + i = 0 + payload_len = len(payload) + while i < payload_len: + length = payload[i] + if length == 0 or i + length >= payload_len: + break + field_type = payload[i + 1] + if field_type in (_ADV_TYPE_SHORT_NAME, _ADV_TYPE_NAME): + if new_name := payload[i + 2 : i + length + 1]: + return str(new_name, "utf-8") + else: + print(f"Unsupported: {field_type=} with {length=}") + i += length + 1 + + +def set_dynamic_column_widths(table, font=None, padding=8): + font = font or lv.font_montserrat_14 + for col in range(table.get_column_count()): + max_width = 0 + for row in range(table.get_row_count()): + value = table.get_cell_value(row, col) + width = lv.text_get_width(value, len(value), font, lv.TEXT_FLAG.NONE) + if width > max_width: + max_width = width + table.set_column_width(col, max_width + padding) + + +def set_cell_value(table, *, row: int, values: tuple): + for col, value in enumerate(values): + table.set_cell_value(row, col, value) + + +class ScanBluetooth(Activity): + def onCreate(self): + main_content = lv.obj() + main_content.set_flex_flow(lv.FLEX_FLOW.COLUMN) + main_content.set_style_pad_all(0, 0) + main_content.set_size(lv.pct(100), lv.pct(100)) + + info_column = lv.obj(main_content) + info_column.set_flex_flow(lv.FLEX_FLOW.COLUMN) + info_column.set_style_pad_all(1, 1) + info_column.set_size(lv.pct(100), lv.SIZE_CONTENT) + + self.info_label = lv.label(info_column) + self.info_label.set_style_text_font(lv.font_montserrat_14, 0) + + if bluetooth is None: + self.info("Bluetooth not available on this platform") + self.setContentView(main_content) + return + + tabel_column = lv.obj(main_content) + tabel_column.set_flex_flow(lv.FLEX_FLOW.COLUMN) + tabel_column.set_style_pad_all(0, 0) + tabel_column.set_size(lv.pct(100), lv.SIZE_CONTENT) + + self.table = lv.table(tabel_column) + set_cell_value( + self.table, + row=0, + values=("pos", "MAC", "RSSI", "last", "count", "Name"), + ) + set_dynamic_column_widths(self.table) + + self.scan_count = 0 + self.mac2column = {} + self.mac2counts = {} + self.mac2name = {} + self.mac2last_seen = {} + + self.ble = bluetooth.BLE() + + self.setContentView(main_content) + + def info(self, text): + print(text) + self.info_label.set_text(text) + + async def ble_scan(self): + """Check sensor every second""" + while self.scanning: + print(f"async scan for {SCAN_DURATION_MS}ms...") + self.ble.gap_scan(SCAN_DURATION_MS, INTERVAL_US, WINDOW_US, True) + await TaskManager.sleep_ms(SCAN_DURATION_MS + 500) + + def onResume(self, screen): + super().onResume(screen) + if bluetooth is None: + return + + self.info("Activating Bluetooth...") + self.ble.irq(self.ble_irq_handler) + self.ble.active(True) + + self.scanning = True + TaskManager.create_task(self.ble_scan()) + + def onPause(self, screen): + super().onPause(screen) + if bluetooth is None: + return + + self.scanning = False + + self.info("Stop scanning...") + self.ble.gap_scan(None) + self.info("Deactivating BLE...") + self.ble.active(False) + self.info("BLE deactivated") + + def update_last_seen(self): + current_time = int(time.time()) + for addr, last_seen in self.mac2last_seen.items(): + last_seen_sec = int(current_time - last_seen) + column_index = self.mac2column[addr] + self.table.set_cell_value(column_index, 3, f"{last_seen_sec}s") + + def ble_irq_handler(self, event: int, data: tuple) -> None: + try: + if event == _IRQ_SCAN_RESULT: + addr_type, addr, adv_type, rssi, adv_data = data + addr = ":".join(f"{b:02x}" for b in addr) + print(f"{addr=} {rssi=} {len(adv_data)=}") + self.mac2last_seen[addr] = int(time.time()) + if name := decode_name(adv_data): + self.mac2name[addr] = name + else: + name = self.mac2name.get(addr, "Unknown") + + if not (column_index := self.mac2column.get(addr)): + column_index = len(self.mac2column) + 1 + self.mac2column[addr] = column_index + self.mac2counts[addr] = 1 + else: + self.mac2counts[addr] += 1 + + set_cell_value( + self.table, + row=column_index, + values=( + str(column_index), + addr, + f"{rssi} dBm", + '0s', # Last seen since 0 sec ;) + str(self.mac2counts[addr]), + name, + ), + ) + elif event == _IRQ_SCAN_DONE: + self.update_last_seen() + set_dynamic_column_widths(self.table) + self.scan_count += 1 + self.info( + f"{len(self.mac2column)} unique devices (Scan {self.scan_count})" + ) + else: + print(f"Ignored BLE {event=}") + except Exception as e: + sys.print_exception(e) + print(f"Error in BLE IRQ handler {event=}: {e}") diff --git a/internal_filesystem/apps/com.micropythonos.scan_bluetooth/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/apps/com.micropythonos.scan_bluetooth/res/mipmap-mdpi/icon_64x64.png new file mode 100644 index 00000000..f9f8f488 Binary files /dev/null and b/internal_filesystem/apps/com.micropythonos.scan_bluetooth/res/mipmap-mdpi/icon_64x64.png differ diff --git a/internal_filesystem/apps/com.micropythonos.showbattery/META-INF/MANIFEST.JSON b/internal_filesystem/apps/com.micropythonos.showbattery/META-INF/MANIFEST.JSON index 63fbca9e..ae63afbf 100644 --- a/internal_filesystem/apps/com.micropythonos.showbattery/META-INF/MANIFEST.JSON +++ b/internal_filesystem/apps/com.micropythonos.showbattery/META-INF/MANIFEST.JSON @@ -3,15 +3,15 @@ "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", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.showbattery/icons/com.micropythonos.showbattery_0.2.1_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.showbattery/mpks/com.micropythonos.showbattery_0.2.1.mpk", "fullname": "com.micropythonos.showbattery", -"version": "0.0.2", +"version": "0.2.1", "category": "development", "activities": [ { - "entrypoint": "assets/hello.py", - "classname": "Hello", + "entrypoint": "assets/show_battery.py", + "classname": "ShowBattery", "intent_filters": [ { "action": "main", diff --git a/internal_filesystem/apps/com.micropythonos.showbattery/assets/hello.py b/internal_filesystem/apps/com.micropythonos.showbattery/assets/hello.py deleted file mode 100644 index 7e0ac09e..00000000 --- a/internal_filesystem/apps/com.micropythonos.showbattery/assets/hello.py +++ /dev/null @@ -1,87 +0,0 @@ -""" -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.showbattery/assets/show_battery.py b/internal_filesystem/apps/com.micropythonos.showbattery/assets/show_battery.py new file mode 100644 index 00000000..08b525f9 --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.showbattery/assets/show_battery.py @@ -0,0 +1,203 @@ +""" +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 + +I want application that will show big time (hour, minutes), with smaller seconds, date, and current battery parameters on the left side, on the right side, i want big battery icon, green when over 30 percent, red otherwise, and in bottom left I want graph of history values for voltage and percentage. + +""" + +import lvgl as lv +import mpos.time +from mpos import Activity, BatteryManager +from mpos.battery_manager import MAX_VOLTAGE, MIN_VOLTAGE + +HISTORY_LEN = 60 + +DARKPINK = lv.color_hex(0xEC048C) +BLACK = lv.color_hex(0x000000) + +class ShowBattery(Activity): + + refresh_timer = None + + history_v = [] + history_p = [] + + def onCreate(self): + main_content = lv.obj() + main_content.set_flex_flow(lv.FLEX_FLOW.COLUMN) + main_content.set_style_pad_all(0, 0) + main_content.set_size(lv.pct(100), lv.pct(100)) + + # --- TOP FLEX BOX: INFORMATION --- + + info_column = lv.obj(main_content) + info_column.set_flex_flow(lv.FLEX_FLOW.COLUMN) + info_column.set_style_pad_all(1, 1) + info_column.set_size(lv.pct(100), lv.SIZE_CONTENT) + + self.lbl_datetime = lv.label(info_column) + self.lbl_datetime.set_style_text_font(lv.font_montserrat_16, 0) + + self.lbl_battery = lv.label(info_column) + self.lbl_battery.set_style_text_font(lv.font_montserrat_24, 0) + + self.lbl_battery_raw = lv.label(info_column) + self.lbl_battery_raw.set_style_text_font(lv.font_montserrat_14, 0) + + self.clear_cache_checkbox = lv.checkbox(info_column) + self.clear_cache_checkbox.set_text("Real-time values") + + # --- BOTTOM FLEX BOX: GRAPH --- + + self.canvas_width = main_content.get_width() + self.canvas_height = 100 + + canvas_column = lv.obj(main_content) + canvas_column.set_flex_flow(lv.FLEX_FLOW.COLUMN) + canvas_column.set_style_pad_all(0, 0) + canvas_column.set_size(self.canvas_width, self.canvas_height) + + self.canvas = lv.canvas(canvas_column) + self.canvas.set_size(self.canvas_width, self.canvas_height) + buffer = bytearray(self.canvas_width * self.canvas_height * 4) + self.canvas.set_buffer( + buffer, self.canvas_width, self.canvas_height, lv.COLOR_FORMAT.NATIVE + ) + + self.layer = lv.layer_t() + self.canvas.init_layer(self.layer) + self.setContentView(main_content) + + def draw_line(self, color, x1, y1, x2, y2): + dsc = lv.draw_line_dsc_t() + lv.draw_line_dsc_t.init(dsc) + dsc.color = color + dsc.width = 2 + dsc.round_end = 1 + dsc.round_start = 1 + dsc.p1 = lv.point_precise_t() + dsc.p1.x = x1 + dsc.p1.y = y1 + dsc.p2 = lv.point_precise_t() + dsc.p2.x = x2 + dsc.p2.y = y2 + lv.draw_line(self.layer, dsc) + self.canvas.finish_layer(self.layer) + + def draw_graph(self): + self.canvas.fill_bg(lv.color_white(), lv.OPA.COVER) + self.canvas.clean() + + w = self.canvas_width + h = self.canvas_height + + if len(self.history_v) < 2: + return + + v_range = max(MAX_VOLTAGE - MIN_VOLTAGE, 0.01) + + for i in range(1, len(self.history_v)): + x1 = int((i - 1) * w / HISTORY_LEN) + x2 = int(i * w / HISTORY_LEN) + + yv1 = h - int((self.history_v[i - 1] - MIN_VOLTAGE) / v_range * h) + yv2 = h - int((self.history_v[i] - MIN_VOLTAGE) / v_range * h) + + yp1 = h - int(self.history_p[i - 1] / 100 * h) + yp2 = h - int(self.history_p[i] / 100 * h) + + self.draw_line(DARKPINK, x1, yv1, x2, yv2) + self.draw_line(BLACK, x1, yp1, x2, yp2) + + def onResume(self, screen): + super().onResume(screen) + + def update(timer): + # --- DATE+TIME --- + now = mpos.time.localtime() + year, month, day = now[0], now[1], now[2] + hour, minute, second = now[3], now[4], now[5] + self.lbl_datetime.set_text( + f"{year}-{month:02}-{day:02} {hour:02}:{minute:02}:{second:02}" + ) + + # --- BATTERY VALUES --- + + if self.clear_cache_checkbox.get_state() & lv.STATE.CHECKED: + # Get "real-time" values by clearing the cache before reading + BatteryManager.clear_cache() + + voltage = BatteryManager.read_battery_voltage() + percent = BatteryManager.get_battery_percentage() + + if percent > 80: + symbol = lv.SYMBOL.BATTERY_FULL + elif percent > 60: + symbol = lv.SYMBOL.BATTERY_3 + elif percent > 40: + symbol = lv.SYMBOL.BATTERY_2 + elif percent > 20: + symbol = lv.SYMBOL.BATTERY_1 + else: + symbol = lv.SYMBOL.BATTERY_EMPTY + + self.lbl_battery.set_text(f"{symbol} {voltage:.2f}V {percent:.0f}%") + if percent >= 30: + bg_color = lv.PALETTE.GREEN + else: + bg_color = lv.PALETTE.RED + self.lbl_battery.set_style_text_color(lv.palette_main(bg_color), 0) + + self.lbl_battery_raw.set_text(f"Raw ADC: {BatteryManager.read_raw_adc()}") + + # --- HISTORY GRAPH --- + self.history_v.append(voltage) + self.history_p.append(percent) + + if len(self.history_v) > HISTORY_LEN: + self.history_v.pop(0) + self.history_p.pop(0) + + self.draw_graph() + + self.refresh_timer = lv.timer_create(update, 1000, None) + + def onPause(self, screen): + super().onPause(screen) + if self.refresh_timer: + self.refresh_timer.delete() + self.refresh_timer = None diff --git a/internal_filesystem/apps/com.micropythonos.showbattery/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/apps/com.micropythonos.showbattery/res/mipmap-mdpi/icon_64x64.png new file mode 100644 index 00000000..20330351 Binary files /dev/null and b/internal_filesystem/apps/com.micropythonos.showbattery/res/mipmap-mdpi/icon_64x64.png differ diff --git a/internal_filesystem/apps/com.micropythonos.showfonts/META-INF/MANIFEST.JSON b/internal_filesystem/apps/com.micropythonos.showfonts/META-INF/MANIFEST.JSON index 85d27da8..b050c8b6 100644 --- a/internal_filesystem/apps/com.micropythonos.showfonts/META-INF/MANIFEST.JSON +++ b/internal_filesystem/apps/com.micropythonos.showfonts/META-INF/MANIFEST.JSON @@ -3,10 +3,10 @@ "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", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.showfonts/icons/com.micropythonos.showfonts_0.0.4_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.showfonts/mpks/com.micropythonos.showfonts_0.0.4.mpk", "fullname": "com.micropythonos.showfonts", -"version": "0.0.2", +"version": "0.0.4", "category": "development", "activities": [ { diff --git a/internal_filesystem/apps/com.micropythonos.showfonts/assets/showfonts.py b/internal_filesystem/apps/com.micropythonos.showfonts/assets/showfonts.py index d03e8119..42f3bade 100644 --- a/internal_filesystem/apps/com.micropythonos.showfonts/assets/showfonts.py +++ b/internal_filesystem/apps/com.micropythonos.showfonts/assets/showfonts.py @@ -1,4 +1,4 @@ -from mpos.apps import Activity +from mpos import Activity import lvgl as lv class ShowFonts(Activity): @@ -40,7 +40,7 @@ def addAllFontsTitles(self, screen): y = 0 for font, name in fonts: title = lv.label(screen) - title.set_style_text_font(font, 0) + title.set_style_text_font(font, lv.PART.MAIN) title.set_text(f"{name}: 2357 !@#$%^&*( {lv.SYMBOL.OK} {lv.SYMBOL.BACKSPACE} 丰 😀") title.set_pos(0, y) y += font.get_line_height() + 4 @@ -66,7 +66,7 @@ def addAllFonts(self, screen): x = 0 title = lv.label(screen) title.set_text(name + ": 2357 !@#$%^&*(") - title.set_style_text_font(lv.font_montserrat_16, 0) + title.set_style_text_font(lv.font_montserrat_16, lv.PART.MAIN) title.set_pos(x, y) y += title.get_height() + 20 @@ -75,7 +75,7 @@ def addAllFonts(self, screen): 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_style_text_font(font, lv.PART.MAIN) lbl.set_text(chr(cp)) lbl.set_pos(x, y) @@ -106,7 +106,7 @@ def addAllGlyphs(self, screen, 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_style_text_font(lv.font_montserrat_16, lv.PART.MAIN) title.set_pos(4, y) y += title.get_height() + 20 @@ -119,7 +119,7 @@ def addAllGlyphs(self, screen, start_y): #print(f"{cp} : {chr(cp)}", end="") #print(f"{chr(cp)},", end="") lbl = lv.label(screen) - lbl.set_style_text_font(font, 0) + lbl.set_style_text_font(font, lv.PART.MAIN) lbl.set_text(chr(cp)) lbl.set_pos(x, y) 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 index b848f8e6..a7e8a8ab 100644 Binary files a/internal_filesystem/apps/com.micropythonos.showfonts/res/mipmap-mdpi/icon_64x64.png 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..af84efc0 --- /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.1.1_64x64.png", + "download_url": "https://apps.micropythonos.com/apps/com.micropythonos.soundrecorder/mpks/com.micropythonos.soundrecorder_0.1.1.mpk", + "fullname": "com.micropythonos.soundrecorder", + "version": "0.1.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..7981bf84 --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.soundrecorder/assets/sound_recorder.py @@ -0,0 +1,432 @@ +# Sound Recorder App - Record audio from I2S microphone to WAV files +import os +import time + +from mpos import Activity, ui, AudioManager + + +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 + _recorder = None + _player = None + + 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, lv.PART.MAIN) + + # 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, lv.PART.MAIN) + + # 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.""" + default_input = AudioManager.get_default_input() + if default_input is not None: + self._status_label.set_text("Microphone ready") + self._status_label.set_style_text_color(lv.color_hex(0x00AA00), lv.PART.MAIN) + 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), lv.PART.MAIN) + 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") + default_input = AudioManager.get_default_input() + print(f"SoundRecorder: default input = {default_input}") + + if default_input is None: + 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), lv.PART.MAIN) + return + + # Start recording + print(f"SoundRecorder: Calling AudioManager.recorder()") + print(f" file_path: {file_path}") + print(f" duration_ms: {self._current_max_duration_ms}") + print(f" sample_rate: {self.SAMPLE_RATE}") + + try: + self._recorder = AudioManager.recorder( + file_path=file_path, + duration_ms=self._current_max_duration_ms, + on_complete=self._on_recording_complete, + sample_rate=self.SAMPLE_RATE, + input=default_input, + ) + self._recorder.start() + success = True + except Exception as exc: + print(f"SoundRecorder: recorder start failed: {exc}") + success = False + + print(f"SoundRecorder: recorder started: {success}") + + if success: + self._is_recording = True + self._record_start_time = time.ticks_ms() + self._last_recording = file_path + print("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), lv.PART.MAIN) + self._status_label.set_text("Recording...") + self._status_label.set_style_text_color(lv.color_hex(0xAA0000), lv.PART.MAIN) + + # 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: recorder failed!") + self._status_label.set_text("Failed to start recording") + self._status_label.set_style_text_color(lv.color_hex(0xAA0000), lv.PART.MAIN) + + def _stop_recording(self): + """Stop recording audio.""" + if self._recorder: + self._recorder.stop() + self._recorder = None + 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), lv.PART.MAIN) # 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), lv.PART.MAIN) + + # 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 + if self._player: + self._player.stop() + time.sleep_ms(100) + + output = AudioManager.get_default_output() + if output is None: + self._status_label.set_text("Playback failed") + self._status_label.set_style_text_color(lv.color_hex(0xAA0000), lv.PART.MAIN) + return + + # Play the recording + try: + self._player = AudioManager.player( + file_path=self._last_recording, + stream_type=AudioManager.STREAM_MUSIC, + on_complete=self._on_playback_complete, + volume=100, + output=output, + ) + self._player.start() + success = True + except Exception as exc: + print(f"SoundRecorder: playback failed: {exc}") + success = False + + if success: + self._status_label.set_text("Playing...") + self._status_label.set_style_text_color(lv.color_hex(0x0000AA), lv.PART.MAIN) + else: + self._status_label.set_text("Playback failed") + self._status_label.set_style_text_color(lv.color_hex(0xAA0000), lv.PART.MAIN) + + 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), lv.PART.MAIN) \ 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..ef6cbce6 Binary files /dev/null and b/internal_filesystem/apps/com.micropythonos.soundrecorder/res/mipmap-mdpi/icon_64x64.png differ diff --git a/internal_filesystem/apps/cz.ucw.pavel.calendar/META-INF/MANIFEST.JSON b/internal_filesystem/apps/cz.ucw.pavel.calendar/META-INF/MANIFEST.JSON new file mode 100644 index 00000000..7067cde4 --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.calendar/META-INF/MANIFEST.JSON @@ -0,0 +1,24 @@ +{ +"name": "Calendar", +"publisher": "Pavel Machek", +"short_description": "Calendar", +"long_description": "Simple calendar app.", +"icon_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.calendar/icons/cz.ucw.pavel.calendar_0.0.1_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.calendar/mpks/cz.ucw.pavel.calendar_0.0.1.mpk", +"fullname": "cz.ucw.pavel.calendar", +"version": "0.0.1", +"category": "utilities", +"activities": [ + { + "entrypoint": "assets/main.py", + "classname": "Main", + "intent_filters": [ + { + "action": "main", + "category": "launcher" + } + ] + } + ] +} + diff --git a/internal_filesystem/apps/cz.ucw.pavel.calendar/assets/main.py b/internal_filesystem/apps/cz.ucw.pavel.calendar/assets/main.py new file mode 100644 index 00000000..54708487 --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.calendar/assets/main.py @@ -0,0 +1,560 @@ +from mpos import Activity + +""" + +Create simple calendar application. On main screen, it should have +current time, date, and month overview. Current date and dates with +events should be highlighted. There should be list of upcoming events. + +When date is clicked, dialog with adding event for that date should be +displayed. Multi-day events should be supported. + +Data should be read/written to emacs org compatible text file. + + +""" + +import time +import os + +try: + import lvgl as lv +except ImportError: + pass + +from mpos import Activity, MposKeyboard + + +ORG_FILE = f"data/calendar.org" # adjust for your device +MAX_UPCOMING = 8 + + +# ------------------------------------------------------------ +# Small date helpers (no datetime module assumed) +# ------------------------------------------------------------ + +def is_leap_year(y): + return (y % 4 == 0 and y % 100 != 0) or (y % 400 == 0) + + +def days_in_month(y, m): + if m == 2: + return 29 if is_leap_year(y) else 28 + if m in (1, 3, 5, 7, 8, 10, 12): + return 31 + return 30 + + +def ymd_to_int(y, m, d): + return y * 10000 + m * 100 + d + + +def int_to_ymd(v): + y = v // 10000 + m = (v // 100) % 100 + d = v % 100 + return y, m, d + + +def weekday_name(idx): + # MicroPython localtime(): 0=Mon..6=Sun typically + names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + if 0 <= idx < 7: + return names[idx] + return "???" + + +def first_weekday_of_month(y, m): + # brute-force using time.mktime if available + # Some ports support it, some don't. + # If it fails, we fallback to "Monday". + try: + # localtime tuple: (y,m,d,h,mi,s,wd,yd) + t = time.mktime((y, m, 1, 0, 0, 0, 0, 0)) + wd = time.localtime(t)[6] + return wd + except Exception: + return 0 + + +# ------------------------------------------------------------ +# Org event model + parser/writer +# ------------------------------------------------------------ + +class Event: + def __init__(self, title, start_ymd, end_ymd, start_time=None, end_time=None): + self.title = title + self.start = start_ymd # int yyyymmdd + self.end = end_ymd # int yyyymmdd + self.start_time = start_time # "HH:MM" or None + self.end_time = end_time # "HH:MM" or None + + def is_multi_day(self): + return self.end != self.start + + def occurs_on(self, ymd): + return self.start <= ymd <= self.end + + def start_key(self): + return self.start + + +class OrgCalendarStore: + def __init__(self, path): + self.path = path + + def load(self): + if not self._exists(self.path): + return [] + + try: + with open(self.path, "r", encoding="utf-8") as f: + lines = f.read().splitlines() + except Exception: + # fallback without encoding kw if unsupported + with open(self.path, "r") as f: + lines = f.read().splitlines() + + events = [] + current_title = None + # FIXME this likely does not work + + for line in lines: + line = line.strip() + + if line.startswith("** "): + current_title = line[3:].strip() + continue + + if not line.startswith("<"): + continue + + ev = self._parse_timestamp_line(current_title, line) + if ev: + events.append(ev) + + events.sort(key=lambda e: e.start_key()) + return events + + def save_append(self, event): + # Create file if missing + if not self._exists(self.path): + self._write_text("* Events\n") + + # Append event + out = [] + out.append("** " + event.title) + + if event.start == event.end: + y, m, d = int_to_ymd(event.start) + wd = weekday_name(self._weekday_for_ymd(y, m, d)) + if event.start_time and event.end_time: + out.append("<%04d-%02d-%02d %s %s-%s>" % ( + y, m, d, wd, event.start_time, event.end_time + )) + else: + out.append("<%04d-%02d-%02d %s>" % (y, m, d, wd)) + else: + y1, m1, d1 = int_to_ymd(event.start) + y2, m2, d2 = int_to_ymd(event.end) + wd1 = weekday_name(self._weekday_for_ymd(y1, m1, d1)) + wd2 = weekday_name(self._weekday_for_ymd(y2, m2, d2)) + out.append("<%04d-%02d-%02d %s>--<%04d-%02d-%02d %s>" % ( + y1, m1, d1, wd1, + y2, m2, d2, wd2 + )) + + out.append("") # blank line + self._append_text("\n".join(out) + "\n") + + # -------------------- + + def _parse_timestamp_line(self, title, line): + if not title: + return None + + # Single-day: <2026-02-05 Thu> + # With time: <2026-02-05 Thu 10:00-11:00> + # Range: <2026-02-10 Tue>--<2026-02-14 Sat> + + if "--<" in line: + a, b = line.split("--", 1) + s = self._parse_one_timestamp(a) + e = self._parse_one_timestamp(b) + if not s or not e: + return None + return Event(title, s["ymd"], e["ymd"], None, None) + + s = self._parse_one_timestamp(line) + if not s: + return None + + return Event(title, s["ymd"], s["ymd"], s.get("start_time"), s.get("end_time")) + + def _parse_one_timestamp(self, token): + token = token.strip() + if not (token.startswith("<") and token.endswith(">")): + return None + + inner = token[1:-1].strip() + parts = inner.split() + + # Expect YYYY-MM-DD ... + if len(parts) < 2: + return None + + date_s = parts[0] + try: + y = int(date_s[0:4]) + m = int(date_s[5:7]) + d = int(date_s[8:10]) + except Exception: + return None + + ymd = ymd_to_int(y, m, d) + + # Optional time part like 10:00-11:00 + start_time = None + end_time = None + if len(parts) >= 3 and "-" in parts[2]: + t = parts[2] + if len(t) == 11 and t[2] == ":" and t[5] == "-" and t[8] == ":": + start_time = t[0:5] + end_time = t[6:11] + + return { + "ymd": ymd, + "start_time": start_time, + "end_time": end_time + } + + def _exists(self, path): + try: + os.stat(path) + return True + except Exception: + return False + + def _append_text(self, s): + with open(self.path, "a") as f: + f.write(s) + + def _write_text(self, s): + with open(self.path, "w") as f: + f.write(s) + + def _weekday_for_ymd(self, y, m, d): + try: + t = time.mktime((y, m, d, 0, 0, 0, 0, 0)) + return time.localtime(t)[6] + except Exception: + return 0 + + +# ------------------------------------------------------------ +# Calendar Activity +# ------------------------------------------------------------ + +class Main(Activity): + + def __init__(self): + super().__init__() + + self.store = OrgCalendarStore(ORG_FILE) + self.events = [] + + self.timer = None + + # UI + self.screen = None + self.lbl_time = None + self.lbl_date = None + self.lbl_month = None + + self.grid = None + self.day_buttons = [] + + self.upcoming_list = None + + # Current month shown + self.cur_y = 0 + self.cur_m = 0 + self.today_ymd = 0 + + # -------------------- + + def onCreate(self): + self.screen = lv.obj() + #self.screen.remove_flag(lv.obj.FLAG.SCROLLABLE) + + # Top labels + self.lbl_time = lv.label(self.screen) + self.lbl_time.set_style_text_font(lv.font_montserrat_20, 0) + self.lbl_time.align(lv.ALIGN.TOP_LEFT, 6, 4) + + self.lbl_date = lv.label(self.screen) + self.lbl_date.align(lv.ALIGN.TOP_LEFT, 6, 40) + + self.lbl_month = lv.label(self.screen) + self.lbl_month.align(lv.ALIGN.TOP_RIGHT, -6, 10) + + # Upcoming events list + self.upcoming_list = lv.list(self.screen) + self.upcoming_list.set_size(lv.pct(90), 60) + self.upcoming_list.align_to(self.lbl_date, lv.ALIGN.OUT_BOTTOM_LEFT, 0, 10) + + # Month grid container + self.grid = lv.obj(self.screen) + self.grid.set_size(lv.pct(90), 60) + self.grid.set_style_border_width(1, 0) + self.grid.set_style_pad_all(0, 0) + self.grid.set_style_radius(6, 0) + self.grid.align_to(self.upcoming_list, lv.ALIGN.OUT_BOTTOM_LEFT, 0, 10) + + self.setContentView(self.screen) + + self.reload_data() + print("My events == ", self.events) + self.build_month_view() + self.refresh_upcoming() + + def onResume(self, screen): + self.timer = lv.timer_create(self.tick, 30000, None) + self.tick(0) + + def onPause(self, screen): + if self.timer: + self.timer.delete() + self.timer = None + + # -------------------- + + def reload_data(self): + print("Loading...") + self.events = self.store.load() + # FIXME + #self.events = [ Event("Test event", 20260207, 20260208) ] + + def tick(self, t): + now = time.localtime() + y, m, d = now[0], now[1], now[2] + hh, mm, ss = now[3], now[4], now[5] + wd = weekday_name(now[6]) + + self.today_ymd = ymd_to_int(y, m, d) + + self.lbl_time.set_text("%02d:%02d" % (hh, mm)) + self.lbl_date.set_text("%04d-%02d-%02d %s" % (y, m, d, wd)) + + # Month label + self.lbl_month.set_text("%04d-%02d" % (self.cur_y, self.cur_m)) + + # Re-highlight today (cheap) + self.update_day_highlights() + + # -------------------- + + def build_month_view(self): + now = time.localtime() + self.cur_y, self.cur_m = now[0], now[1] + + # Determine size + d = lv.display_get_default() + w = d.get_horizontal_resolution() + + cell = w // 8 + grid_w = cell * 7 + 8 + grid_h = cell * 6 + 8 + + self.grid.set_size(grid_w, grid_h) + + # Clear old buttons + for b in self.day_buttons: + b.delete() + self.day_buttons = [] + self.day_of_btn = {} + + first_wd = first_weekday_of_month(self.cur_y, self.cur_m) # 0=Mon + dim = days_in_month(self.cur_y, self.cur_m) + + # LVGL grid is easiest as absolute positioning here + for day in range(1, dim + 1): + idx = (first_wd + (day - 1)) + row = idx // 7 + col = idx % 7 + + btn = lv.button(self.grid) + btn.set_size(cell - 2, cell - 2) + btn.set_pos(4 + col * cell, 4 + row * cell) + btn.add_event_cb(lambda e, dd=day: self.on_day_clicked(dd), lv.EVENT.CLICKED, None) + + lbl = lv.label(btn) + lbl.set_text(str(day)) + lbl.center() + + self.day_buttons.append(btn) + self.day_of_btn[btn] = day + + self.update_day_highlights() + + def update_day_highlights(self): + for btn in self.day_buttons: + day = self.day_of_btn.get(btn, None) + if day is None: + continue + + ymd = ymd_to_int(self.cur_y, self.cur_m, day) + + has_event = self.day_has_event(ymd) + is_today = (ymd == self.today_ymd) + #print(ymd, has_event, is_today) + + if is_today: + btn.set_style_bg_color(lv.palette_main(lv.PALETTE.BLUE), 0) + elif has_event: + btn.set_style_bg_color(lv.palette_main(lv.PALETTE.GREEN), 0) + else: + btn.set_style_bg_color(lv.palette_main(lv.PALETTE.GREY), 0) + + def day_has_event(self, ymd): + for e in self.events: + if e.occurs_on(ymd): + return True + return False + + # -------------------- + + def refresh_upcoming(self): + self.upcoming_list.clean() + + now = time.localtime() + today = ymd_to_int(now[0], now[1], now[2]) + + upcoming = [] + for e in self.events: + if e.end >= today: + upcoming.append(e) + + upcoming.sort(key=lambda e: e.start) + + for e in upcoming[:MAX_UPCOMING]: + y1, m1, d1 = int_to_ymd(e.start) + y2, m2, d2 = int_to_ymd(e.end) + + if e.start == e.end: + date_s = "%04d-%02d-%02d" % (y1, m1, d1) + else: + date_s = "%04d-%02d-%02d..%04d-%02d-%02d" % (y1, m1, d1, y2, m2, d2) + + txt = date_s + " " + e.title + self.upcoming_list.add_text(txt) + + self.upcoming_list.add_text("that's all folks") + + # -------------------- + + def on_day_clicked(self, day): + print("Day clicked") + ymd = ymd_to_int(self.cur_y, self.cur_m, day) + self.open_add_dialog(ymd) + + def open_add_dialog(self, ymd): + y, m, d = int_to_ymd(ymd) + + dlg = lv.obj(self.screen) + dlg.set_size(lv.pct(100), 480) + dlg.center() + dlg.set_style_bg_color(lv.color_hex(0x8f8f8f), 0) + dlg.set_style_border_width(2, 0) + dlg.set_style_radius(10, 0) + + title = lv.label(dlg) + title.set_text("Add event") + title.align(lv.ALIGN.TOP_MID, 0, 8) + + date_lbl = lv.label(dlg) + date_lbl.set_text("%04d-%02d-%02d" % (y, m, d)) + date_lbl.align_to(title, lv.ALIGN.OUT_BOTTOM_MID, 0, 10) + + # Title input + ti = lv.textarea(dlg) + ti.set_size(220, 32) + ti.align_to(date_lbl, lv.ALIGN.OUT_BOTTOM_MID, 0, 10) + ti.set_placeholder_text("Title") + keyboard = MposKeyboard(dlg) + keyboard.set_textarea(ti) + #keyboard.add_flag(lv.obj.FLAG.HIDDEN) + + # End date offset (days) + end_lbl = lv.label(dlg) + end_lbl.set_text("Duration days:") + end_lbl.align_to(ti, lv.ALIGN.OUT_BOTTOM_MID, 0, 10) + + dd = lv.dropdown(dlg) + dd.set_options("1\n2\n3\n4\n5\n6\n7\n10\n14\n21\n30") + dd.set_selected(0) + dd.set_size(70, 32) + dd.align_to(end_lbl, lv.ALIGN.OUT_BOTTOM_MID, 0, 10) + + # Buttons + btn_cancel = lv.button(dlg) + btn_cancel.set_size(90, 30) + btn_cancel.align(lv.ALIGN.TOP_LEFT, 12, 10) + btn_cancel.add_event_cb(lambda e: dlg.delete(), lv.EVENT.CLICKED, None) + lc = lv.label(btn_cancel) + lc.set_text("Cancel") + lc.center() + + btn_add = lv.button(dlg) + btn_add.set_size(90, 30) + btn_add.align(lv.ALIGN.TOP_RIGHT, -12, 10) + + def do_add(e): + title_s = ti.get_text() + if not title_s or title_s.strip() == "": + return + + dur_s = 1 # dd.get_selected_str() FIXME + try: + dur = int(dur_s) + except Exception: + dur = 1 + + end_ymd = self.add_days(ymd, dur - 1) + + ev = Event(title_s.strip(), ymd, end_ymd, None, None) + self.events.append(ev) + self.store.save_append(ev) # FIXME + + # Reload + refresh UI + # FIXME: common code? + #self.reload_data() + self.update_day_highlights() + self.refresh_upcoming() + + dlg.delete() + + btn_add.add_event_cb(do_add, lv.EVENT.CLICKED, None) + la = lv.label(btn_add) + la.set_text("Add") + la.center() + + # -------------------- + + def add_days(self, ymd, days): + # simple date add (forward only), no datetime dependency + y, m, d = int_to_ymd(ymd) + + while days > 0: + d += 1 + dim = days_in_month(y, m) + if d > dim: + d = 1 + m += 1 + if m > 12: + m = 1 + y += 1 + days -= 1 + + return ymd_to_int(y, m, d) + diff --git a/internal_filesystem/apps/cz.ucw.pavel.calendar/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/apps/cz.ucw.pavel.calendar/res/mipmap-mdpi/icon_64x64.png new file mode 100644 index 00000000..ee72f838 Binary files /dev/null and b/internal_filesystem/apps/cz.ucw.pavel.calendar/res/mipmap-mdpi/icon_64x64.png differ diff --git a/internal_filesystem/apps/cz.ucw.pavel.cellular/META-INF/MANIFEST.JSON b/internal_filesystem/apps/cz.ucw.pavel.cellular/META-INF/MANIFEST.JSON new file mode 100644 index 00000000..5456fb6d --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.cellular/META-INF/MANIFEST.JSON @@ -0,0 +1,24 @@ +{ +"name": "Cellular", +"publisher": "Pavel Machek", +"short_description": "Application for placing phone calls", +"long_description": "Simple application for monitoring network state and placing phone calls.", +"icon_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.cellular/icons/cz.ucw.pavel.cellular_0.0.1_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.cellular/mpks/cz.ucw.pavel.cellular_0.0.1.mpk", +"fullname": "cz.ucw.pavel.cellular", +"version": "0.0.1", +"category": "utilities", +"activities": [ + { + "entrypoint": "assets/main.py", + "classname": "Main", + "intent_filters": [ + { + "action": "main", + "category": "launcher" + } + ] + } + ] +} + diff --git a/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/main.py b/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/main.py new file mode 100644 index 00000000..a6877e50 --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/main.py @@ -0,0 +1,151 @@ +from mpos import Activity + +""" +Simple cellular-network example +""" + +import time +import os +import json + +try: + import lvgl as lv +except ImportError: + pass + +from mpos import Activity, MposKeyboard + +TMP = "/tmp/cmd.json" + + +def run_cmd_json(cmd): + rc = os.system(cmd + " > " + TMP) + if rc != 0: + raise RuntimeError("command failed") + + with open(TMP, "r") as f: + data = f.read().strip() + + return json.loads(data) + +def dbus_json(cmd): + return run_cmd_json("sudo /home/mobian/g/MicroPythonOS/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/phone.py " + cmd) + +class CellularManager: + def init(self): + v = dbus_json("loc_on") + + def poll(self): + v = dbus_json("signal") + print(v) + self.signal = v + + def call(self, num): + v = dbus_json("call '%s'" % num) + + def sms(self, num, text): + v = dbus_json("call '%s' '%s'" % (num, text)) + +cm = CellularManager() + +# ------------------------------------------------------------ +# User interface +# ------------------------------------------------------------ + +class Main(Activity): + + def __init__(self): + super().__init__() + + # -------------------- + + def onCreate(self): + self.screen = lv.obj() + #self.screen.remove_flag(lv.obj.FLAG.SCROLLABLE) + + # Top labels + self.lbl_time = lv.label(self.screen) + self.lbl_time.set_style_text_font(lv.font_montserrat_34, 0) + self.lbl_time.set_text("Startup...") + self.lbl_time.align(lv.ALIGN.TOP_LEFT, 6, 22) + + self.lbl_date = lv.label(self.screen) + self.lbl_date.set_style_text_font(lv.font_montserrat_20, 0) + self.lbl_date.align_to(self.lbl_time, lv.ALIGN.OUT_BOTTOM_LEFT, 0, 5) + self.lbl_date.set_text("(details here?") + + self.lbl_month = lv.label(self.screen) + self.lbl_month.set_style_text_font(lv.font_montserrat_20, 0) + self.lbl_month.align(lv.ALIGN.TOP_RIGHT, -6, 22) + + self.number = lv.textarea(self.screen) + #self.number.set_accepted_chars("0123456789") + self.number.set_one_line(True) + self.number.set_style_text_font(lv.font_montserrat_34, 0) + self.number.align_to(self.lbl_date, lv.ALIGN.OUT_BOTTOM_LEFT, 0, 12) + + self.call = lv.button(self.screen) + self.call.align_to(self.number, lv.ALIGN.OUT_RIGHT_MID, 2, 0) + self.call.add_event_cb(lambda e: self.on_call(), lv.EVENT.CLICKED, None) + + # Two text areas on single screen don't work well. + # Perhaps make it dialog? + #self.sms = lv.textarea(self.screen) + #self.sms.set_style_text_font(lv.font_montserrat_24, 0) + #self.sms.align_to(self.number, lv.ALIGN.OUT_BOTTOM_LEFT, 0, 10) + + l = lv.label(self.call) + l.set_text("Call") + l.center() + + kb = lv.keyboard(self.screen) + kb.set_textarea(self.number) + kb.set_size(lv.pct(100), lv.pct(33)) + + self.setContentView(self.screen) + cm.init() + + def onResume(self, screen): + self.timer = lv.timer_create(self.tick, 60000, None) + self.tick(0) + + def onPause(self, screen): + if self.timer: + self.timer.delete() + self.timer = None + + # -------------------- + + def on_call(self): + num = self.number.get_text() + cm.call(num) + + def on_sms(self): + num = self.number.get_text() + text = self.sms.get_text() + cm.sms(num, text) + + def tick(self, t): + now = time.localtime() + y, m, d = now[0], now[1], now[2] + hh, mm, ss = now[3], now[4], now[5] + + self.lbl_month.set_text("busy") + + cm.poll() + s = "" + s += cm.signal["OperatorName"] + "\n" + s += "RegistrationState %d\n" % cm.signal["RegistrationState"] + s += "State %d " % cm.signal["State"] + sq, re = cm.signal["SignalQuality"] + s += "Signal %d\n" % sq + + self.lbl_month.set_text(s) + self.lbl_time.set_text("%02d:%02d" % (hh, mm)) + s = "" + self.lbl_date.set_text("%04d-%02d-%02d %s" % (y, m, d, s)) + + + # -------------------- + + diff --git a/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/phone.py b/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/phone.py new file mode 100755 index 00000000..948c19ff --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/phone.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +from pydbus import SystemBus, Variant +import pydbus +import time +import sys +import json + +""" +Lets make it class Phone, one method would be reading battery information, one would be reading operator name / signal strength, one would be getting wifi enabled/disabled / AP name. + +sudo apt install python3-pydbus + +sudo mmcli --list-modems +sudo mmcli -m 6 --location-enable-gps-nmea --location-enable-gps-raw +""" + + + +class Phone: + verbose = False + + def __init__(self): + self.bus = pydbus.SystemBus() + + def init_sess(self): + self.sess = pydbus.SessionBus() + + def get_mobile_loc(self): + loc = None + mm = self.bus.get("org.freedesktop.ModemManager1") + for modem_path in mm.GetManagedObjects(): + modem = self.bus.get(".ModemManager1", modem_path) + loc = modem.GetLocation() + return loc + + def get_cell_signal(self): + loc = None + mm = self.bus.get("org.freedesktop.ModemManager1") + for modem_path in mm.GetManagedObjects(): + modem = self.bus.get(".ModemManager1", modem_path) + + loc = {} + + def attr(v): + loc[v] = getattr(modem, v, None) + + attr("OperatorName") + attr("OperatorCode") # 0..11 according to MMState + attr("State") # 0..11 according to MMState + attr("AccessTechnologies") + attr("Model") + attr("Manufacturer") + attr("Revision") + attr("EquipmentIdentifier") + + attr("Gsm") + attr("Umts") + attr("Lte") + + attr("SignalQuality") + attr("RegistrationState") + + return loc + + def start_call(self, num): + mm = self.bus.get("org.freedesktop.ModemManager1") + + for modem_path in mm.GetManagedObjects(): + modem = self.bus.get("org.freedesktop.ModemManager1", modem_path) + voice = modem["org.freedesktop.ModemManager1.Modem.Voice"] + + call_properties = { + "number": Variant('s', num) + } + + call_path = voice.CreateCall(call_properties) + #call = self.bus.get("org.freedesktop.ModemManager1", call_path) + #call_iface = call["org.freedesktop.ModemManager1.Call"] + #call_iface.Start() + + return { "call": call_path } + + def send_sms(self, num, text): + mm = self.bus.get("org.freedesktop.ModemManager1") + + for modem_path in mm.GetManagedObjects(): + modem = self.bus.get("org.freedesktop.ModemManager1", modem_path) + messaging = modem["org.freedesktop.ModemManager1.Modem.Messaging"] + + sms_properties = { + "number": Variant('s', num), + "text": Variant('s', text) + } + + sms_path = messaging.Create(sms_properties) + sms = self.bus.get("org.freedesktop.ModemManager1", sms_path) + sms_iface = sms["org.freedesktop.ModemManager1.Sms"] + sms_iface.Send() + + return { "sms": sms_path } + + # 0x01 = 3GPP LAC/CI + # 0x02 = GPS NMEA + # 0x04 = GPS RAW + # 0x08 = CDMA BS + # 0x10 = GPS Unmanaged + CELL_ID = 0x01 + GPS_NMEA = 0x02 + GPS_RAW = 0x04 + + def enable_mobile_loc(self, gps_on, cell_on): + """ + Enable GPS RAW + NMEA. + """ + mm = self.bus.get("org.freedesktop.ModemManager1") + for modem_path in mm.GetManagedObjects(): + modem = self.bus.get(".ModemManager1", modem_path) + + # Setup(uint32 sources, boolean signal_location) + # signal_location=True makes ModemManager emit LocationUpdated signals + if gps_on: + sources = self.GPS_NMEA | self.GPS_RAW + else: + sources = 0 + if cell_on: + sources |= self.CELL_ID; + modem.Setup(sources, True) + + continue + # Optional: explicitly enable (some modems require it) + try: + modem.SetEnable(True) + except Exception: + print("Cant setenable") + return { 'result' : 'setenable failed' } + return { 'result': 'ok' } + +phone = Phone() + +def handle_cmd(v, a): + if v == "bat": + print(json.dumps(phone.get_battery_info())) + sys.exit(0) + if v == "loc": + print(json.dumps(phone.get_mobile_loc())) + sys.exit(0) + if v == "loc_on": + print(json.dumps(phone.enable_mobile_loc(True, True))) + sys.exit(0) + if v == "loc_off": + print(json.dumps(phone.enable_mobile_loc(False, False))) + sys.exit(0) + if v == "signal": + print(json.dumps(phone.get_cell_signal())) + sys.exit(0) + if v == "call": + print(json.dumps(phone.start_call(a[2]))) + sys.exit(0) + if v == "sms": + print(json.dumps(phone.send_sms(a[2], a[3]))) + sys.exit(0) + print("Unknown command "+v) + sys.exit(1) + +if len(sys.argv) > 1: + handle_cmd(sys.argv[1], sys.argv) + diff --git a/internal_filesystem/apps/cz.ucw.pavel.cellular/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/apps/cz.ucw.pavel.cellular/res/mipmap-mdpi/icon_64x64.png new file mode 100644 index 00000000..662b3c89 Binary files /dev/null and b/internal_filesystem/apps/cz.ucw.pavel.cellular/res/mipmap-mdpi/icon_64x64.png differ diff --git a/internal_filesystem/apps/cz.ucw.pavel.columns/META-INF/MANIFEST.JSON b/internal_filesystem/apps/cz.ucw.pavel.columns/META-INF/MANIFEST.JSON new file mode 100644 index 00000000..2fdd8acc --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.columns/META-INF/MANIFEST.JSON @@ -0,0 +1,24 @@ +{ +"name": "Columns", +"publisher": "Pavel Machek", +"short_description": "Falling columns game", +"long_description": "Blocks of 3 colors are falling. Align the colors to make blocks disappear.", +"icon_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.columns/icons/cz.ucw.pavel.columns_0.0.1_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.columns/mpks/cz.ucw.pavel.columns_0.0.1.mpk", +"fullname": "cz.ucw.pavel.columns", +"version": "0.0.1", +"category": "utilities", +"activities": [ + { + "entrypoint": "assets/main.py", + "classname": "Main", + "intent_filters": [ + { + "action": "main", + "category": "launcher" + } + ] + } + ] +} + diff --git a/internal_filesystem/apps/cz.ucw.pavel.columns/assets/main.py b/internal_filesystem/apps/cz.ucw.pavel.columns/assets/main.py new file mode 100644 index 00000000..4bbc58b5 --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.columns/assets/main.py @@ -0,0 +1,337 @@ +import time +import random + +""" +Columns -- falling columns game + +Possible TODOs: + +should blink explosions +explodes while moving? +/ in bottom left part may not explode + +smooth moving? +music? +some kind of game over? + +more contrast colors? +different shapes? + +""" + +from mpos import Activity + +try: + import lvgl as lv +except ImportError: + pass + +class Main(Activity): + + COLS = 6 + ROWS = 12 + + COLORS = [ + 0xE74C3C, # red + 0xF1C40F, # yellow + 0x2ECC71, # green + 0x3498DB, # blue + 0x9B59B6, # purple + ] + + EMPTY = -1 + + FALL_INTERVAL = 1000 # ms + # I can do 120 in this config :-). + + def __init__(self): + super().__init__() + self.board = [[self.EMPTY for _ in range(self.COLS)] for _ in range(self.ROWS)] + self.cells = [] + + self.active_col = self.COLS // 2 + self.active_row = -3 + self.active_colors = [] + + self.timer = None + self.animating = False + + # --------------------------------------------------------------------- + + def onCreate(self): + self.screen = lv.obj() + self.screen.remove_flag(lv.obj.FLAG.SCROLLABLE) + + vert = 60 + horiz = 60 + font = lv.font_montserrat_20 + + score = lv.label(self.screen) + score.align(lv.ALIGN.TOP_LEFT, 5, 25) + score.set_text("Score") + score.set_style_text_font(font, 0) + self.lb_score = score + + btn_left = lv.button(self.screen) + btn_left.set_size(horiz, vert) + btn_left.align(lv.ALIGN.BOTTOM_LEFT, 5, -10-vert) + btn_left.add_event_cb(lambda e: self.move(-1), lv.EVENT.CLICKED, None) + lc = lv.label(btn_left) + lc.set_style_text_font(font, 0) + lc.set_text("<") + lc.center() + + btn_right = lv.button(self.screen) + btn_right.set_size(horiz, vert) + btn_right.align(lv.ALIGN.BOTTOM_RIGHT, -5, -10-vert) + btn_right.add_event_cb(lambda e: self.move(1), lv.EVENT.CLICKED, None) + lc = lv.label(btn_right) + lc.set_style_text_font(font, 0) + lc.set_text(">") + lc.center() + + btn_rotate = lv.button(self.screen) + btn_rotate.set_size(horiz, vert) + btn_rotate.align(lv.ALIGN.BOTTOM_RIGHT, -5, -15-vert-vert) + btn_rotate.add_event_cb(lambda e: self.rotate(), lv.EVENT.CLICKED, None) + lc = lv.label(btn_rotate) + lc.set_style_text_font(font, 0) + lc.set_text("R") + lc.center() + + btn_down = lv.button(self.screen) + btn_down.set_size(horiz, vert) + btn_down.align(lv.ALIGN.BOTTOM_LEFT, 5, -5) + btn_down.add_event_cb(lambda e: self.tick(0), lv.EVENT.CLICKED, None) + lc = lv.label(btn_down) + lc.set_style_text_font(font, 0) + lc.set_text("v") + lc.center() + + d = lv.display_get_default() + self.SCREEN_WIDTH = d.get_horizontal_resolution() + self.SCREEN_HEIGHT = d.get_vertical_resolution() + + self.CELL = min( + self.SCREEN_WIDTH // (self.COLS + 1), + self.SCREEN_HEIGHT // (self.ROWS + 1) + ) + + board_x = (self.SCREEN_WIDTH - self.CELL * self.COLS) // 2 + board_y = (self.SCREEN_HEIGHT - self.CELL * self.ROWS) // 2 + + for r in range(self.ROWS): + row = [] + for c in range(self.COLS): + o = lv.obj(self.screen) + o.set_size(self.CELL - 2, self.CELL - 2) + o.set_pos( + board_x + c * self.CELL + 1, + board_y + r * self.CELL + 1 + ) + o.set_style_radius(4, 0) + o.set_style_bg_color(lv.color_hex(0x1C2833), 0) + o.set_style_border_width(1, 0) + row.append(o) + self.cells.append(row) + + # Make screen focusable for keyboard input + focusgroup = lv.group_get_default() + if focusgroup: + focusgroup.add_obj(self.screen) + + #self.screen.add_event_cb(self.on_touch, lv.EVENT.CLICKED, None) + self.screen.add_event_cb(self.on_key, lv.EVENT.KEY, None) + + self.setContentView(self.screen) + + self.new_game() + self.spawn_piece() + + + def new_game(self): + self.score = 0 + # --------------------------------------------------------------------- + + def onResume(self, screen): + self.timer = lv.timer_create(self.tick, self.FALL_INTERVAL, None) + + def onPause(self, screen): + if self.timer: + self.timer.delete() + self.timer = None + + # --------------------------------------------------------------------- + + def spawn_piece(self): + self.active_col = self.COLS // 2 + self.active_row = -3 + self.active_colors = [random.randrange(len(self.COLORS)) for _ in range(3)] + + def tick(self, t): + if self.can_fall(): + self.active_row += 1 + else: + self.lock_piece() + self.clear_matches() + self.spawn_piece() + + self.redraw() + + # --------------------------------------------------------------------- + + def can_fall(self): + for i in range(3): + r = self.active_row + i + 1 + c = self.active_col + if r >= self.ROWS: + return False + if r >= 0 and self.board[r][c] != self.EMPTY: + return False + return True + + def lock_piece(self): + for i in range(3): + r = self.active_row + i + if r >= 0: + self.board[r][self.active_col] = self.active_colors[i] + + # --------------------------------------------------------------------- + + def clear_matches(self): + to_clear = set() + score = 0 + + for r in range(self.ROWS): + for c in range(self.COLS): + color = self.board[r][c] + if color == self.EMPTY: + continue + + # horizontal + if c <= self.COLS - 3: + if all(self.board[r][c + i] == color for i in range(3)): + for i in range(3): + to_clear.add((r, c + i)) + score += 1 + + # vertical + if r <= self.ROWS - 3: + if all(self.board[r + i][c] == color for i in range(3)): + for i in range(3): + to_clear.add((r + i, c)) + score += 1 + + # diagonal \ + if r <= self.ROWS - 3 and c <= self.COLS - 3: + if all(self.board[r + i][c + i] == color for i in range(3)): + for i in range(3): + to_clear.add((r + i, c + i)) + score += 1 + + # diagonal / + if r <= self.ROWS - 3 and c > 2: + if all(self.board[r + i][c - i] == color for i in range(3)): + for i in range(3): + to_clear.add((r + i, c - i)) + score += 1 + + if not to_clear: + return + + print("Score: ", score) + self.score += score + self.lb_score.set_text("Score\n%d" % self.score) + for r, c in to_clear: + self.board[r][c] = self.EMPTY + + self.redraw() + time.sleep(.5) + self.apply_gravity() + self.redraw() + time.sleep(.5) + self.clear_matches() + self.redraw() + + def apply_gravity(self): + for c in range(self.COLS): + stack = [self.board[r][c] for r in range(self.ROWS) if self.board[r][c] != self.EMPTY] + for r in range(self.ROWS): + self.board[r][c] = self.EMPTY + for i, v in enumerate(reversed(stack)): + self.board[self.ROWS - 1 - i][c] = v + + # --------------------------------------------------------------------- + + def redraw(self): + # draw board + for r in range(self.ROWS): + for c in range(self.COLS): + v = self.board[r][c] + if v == self.EMPTY: + self.cells[r][c].set_style_bg_color(lv.color_hex(0x1C2833), 0) + else: + self.cells[r][c].set_style_bg_color( + lv.color_hex(self.COLORS[v]), 0 + ) + + # draw active piece + for i in range(3): + r = self.active_row + i + if r >= 0 and r < self.ROWS: + self.cells[r][self.active_col].set_style_bg_color( + lv.color_hex(self.COLORS[self.active_colors[i]]), 0 + ) + + # --------------------------------------------------------------------- + + def on_touch(self, e): + return + print("Touch event") + p = lv.indev_get_act().get_point() + x = p.x + + if x < self.SCREEN_WIDTH // 3: + self.move(-1) + elif x > self.SCREEN_WIDTH * 2 // 3: + self.move(1) + else: + self.rotate() + + def on_key(self, event): + """Handle keyboard input""" + print("Keyboard event") + key = event.get_key() + if key == ord("a"): + self.move(-1) + return + if key == ord("w"): + self.rotate() + return + if key == ord("d"): + self.move(1) + return + if key == ord("s"): + self.tick(0) + return + + #if key == lv.KEY.ENTER or key == lv.KEY.UP or key == ord("A") or key == ord("a"): + print(f"on_key: unhandled key {key}") + + def move(self, dx): + nc = self.active_col + dx + if not(0 <= nc < self.COLS): + return + + for i in range(3): + r = self.active_row + i + if self.board[r][nc] != self.EMPTY: + return + + self.active_col = nc + self.redraw() + + def rotate(self): + self.active_colors = self.active_colors[-1:] + self.active_colors[:-1] + self.redraw() + diff --git a/internal_filesystem/apps/cz.ucw.pavel.columns/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/apps/cz.ucw.pavel.columns/res/mipmap-mdpi/icon_64x64.png new file mode 100644 index 00000000..49812a67 Binary files /dev/null and b/internal_filesystem/apps/cz.ucw.pavel.columns/res/mipmap-mdpi/icon_64x64.png differ diff --git a/internal_filesystem/apps/cz.ucw.pavel.compass/META-INF/MANIFEST.JSON b/internal_filesystem/apps/cz.ucw.pavel.compass/META-INF/MANIFEST.JSON new file mode 100644 index 00000000..dd52c192 --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.compass/META-INF/MANIFEST.JSON @@ -0,0 +1,24 @@ +{ +"name": "Compass", +"publisher": "Pavel Machek", +"short_description": "Application for testing accelerometer and magnetometer", +"long_description": "Simple compass application, allowing tests of accelerometer and magnetometer.", +"icon_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.compass/icons/cz.ucw.pavel.compass_0.0.1_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.compass/mpks/cz.ucw.pavel.compass_0.0.1.mpk", +"fullname": "cz.ucw.pavel.compass", +"version": "0.0.1", +"category": "utilities", +"activities": [ + { + "entrypoint": "assets/main.py", + "classname": "Main", + "intent_filters": [ + { + "action": "main", + "category": "launcher" + } + ] + } + ] +} + diff --git a/internal_filesystem/apps/cz.ucw.pavel.compass/assets/main.py b/internal_filesystem/apps/cz.ucw.pavel.compass/assets/main.py new file mode 100644 index 00000000..383da538 --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.compass/assets/main.py @@ -0,0 +1,687 @@ +""" +Robot translated that from bwatch/magcali.js + +""" + +import time +import os +import math + +try: + import lvgl as lv +except ImportError: + pass + +from mpos import Activity, MposKeyboard, SensorManager + +# ----------------------------- +# Utilities +# ----------------------------- + +def clamp(v, lo, hi): + if v < lo: + return lo + if v > hi: + return hi + return v + +def to_rad(deg): + return deg * math.pi / 180.0 + +def to_deg(rad): + return rad * 180.0 / math.pi + +# ----------------------------- +# Calibration + heading +# ----------------------------- + +class Compass: + def __init__(self): + self.reset() + + def reset(self): + self.vmin = [10000.0, 10000.0, 10000.0] + self.vmax = [-10000.0, -10000.0, -10000.0] + + def step(self, v): + """ + Update min/max. Returns True if calibration box changed ("bad" in JS). + """ + bad = False + for i in range(3): + if v[i] < self.vmin[i]: + self.vmin[i] = v[i] + bad = True + if v[i] > self.vmax[i]: + self.vmax[i] = v[i] + bad = True + return bad + + def compensated(self, v): + """ + Returns: + vh = v - center + sc = scaled to [-1..+1] + """ + vh = [0.0, 0.0, 0.0] + sc = [0.0, 0.0, 0.0] + + for i in range(3): + center = (self.vmin[i] + self.vmax[i]) / 2.0 + vh[i] = v[i] - center + + denom = (self.vmax[i] - self.vmin[i]) + if denom == 0: + sc[i] = 0.0 + else: + sc[i] = (v[i] - self.vmin[i]) / denom * 2.0 - 1.0 + + return vh, sc + + def heading_flat(self): + """ + Equivalent of: + heading = atan2(sc[1], sc[0]) * 180/pi - 90 + + Compute heading based on last update(). This will only work well + on flat surface. + """ + vh, sc = self.compensated(self.val) + + h = to_deg(math.atan2(sc[1], sc[0])) - 90.0 + while h < 0: + h += 360.0 + while h >= 360.0: + h -= 360.0 + return h + + +class TiltCompass(Compass): + def __init__(self): + super().__init__() + + def tilt_calibrate(self): + """ + JS tiltCalibrate(min,max) + vmin/vmax are dicts with x,y,z + """ + vmin = self.vmin + vmax = self.vmax + + offset = ( (vmax[0] + vmin[0]) / 2.0, + (vmax[1] + vmin[1]) / 2.0, + (vmax[2] + vmin[2]) / 2.0 ) + delta = ( (vmax[0] - vmin[0]) / 2.0, + (vmax[1] - vmin[1]) / 2.0, + (vmax[2] - vmin[2]) / 2.0 ) + + avg = (delta[0] + delta[1] + delta[2]) / 3.0 + + # Avoid division by zero + scale = ( + avg / delta[0] if delta[0] else 1.0, + avg / delta[1] if delta[1] else 1.0, + avg / delta[2] if delta[2] else 1.0, + ) + + self.offset = offset + self.scale = scale + + def heading_tilted(self): + """ + Returns heading 0..360 + """ + mag_xyz = self.val + acc_xyz = self.acc + + if mag_xyz is None or acc_xyz is None: + return None + + self.tilt_calibrate() + + mx, my, mz = mag_xyz + ax, ay, az = acc_xyz + + dx = (mx - self.offset[0]) * self.scale[0] + dy = (my - self.offset[1]) * self.scale[1] + dz = (mz - self.offset[2]) * self.scale[2] + + # JS: + # phi = atan(-g.x/-g.z) + # theta = atan(-g.y/(-g.x*sinphi-g.z*cosphi)) + # ... + # psi = atan2(yh,xh) + # + # Keep the same structure. + + # Avoid pathological az=0 + if az == 0: + az = 1e-9 + + phi = math.atan((-ax) / (-az)) + cosphi = math.cos(phi) + sinphi = math.sin(phi) + + denom = (-ax * sinphi - az * cosphi) + if denom == 0: + denom = 1e-9 + + theta = math.atan((-ay) / denom) + costheta = math.cos(theta) + sintheta = math.sin(theta) + + xh = dy * costheta + dx * sinphi * sintheta + dz * cosphi * sintheta + yh = dz * sinphi - dx * cosphi + + psi = to_deg(math.atan2(yh, xh)) + if psi < 0: + psi += 360.0 + return psi + +# ----------------------------- +# Canvas (LVGL) +# ----------------------------- + +class Canvas: + """ + LVGL canvas + layer drawing Canvas. + + This matches ports where: + - lv.canvas has init_layer() / finish_layer() + - primitives are drawn via lv.draw_* into lv.layer_t + """ + + def __init__(self, scr, canvas): + self.scr = scr + + # Screen size + self.W = scr.get_width() + self.H = scr.get_height() + + # Bottom button bar + self.margin = 2 + self.bar_h = 39 + + # Canvas drawing area (everything above button bar) + self.draw_w = self.W + self.draw_h = self.H - (self.bar_h + self.margin * 2) + + self.canvas = canvas + + # Background: white (change if you want dark theme) + self.canvas.set_style_bg_color(lv.color_white(), lv.PART.MAIN) + + # Buffer: your working example uses 4 bytes/pixel + # Reality filter: this depends on LV_COLOR_DEPTH; but your example proves it works. + self.buf = bytearray(self.draw_w * self.draw_h * 4) + self.canvas.set_buffer(self.buf, self.draw_w, self.draw_h, lv.COLOR_FORMAT.NATIVE) + + # Layer used for draw engine + self.layer = lv.layer_t() + self.canvas.init_layer(self.layer) + + # Persistent draw descriptors (avoid allocations) + self._line_dsc = lv.draw_line_dsc_t() + lv.draw_line_dsc_t.init(self._line_dsc) + self._line_dsc.width = 1 + self._line_dsc.color = lv.color_black() + self._line_dsc.round_end = 1 + self._line_dsc.round_start = 1 + + self._label_dsc = lv.draw_label_dsc_t() + lv.draw_label_dsc_t.init(self._label_dsc) + self._label_dsc.color = lv.color_black() + self._label_dsc.font = lv.font_montserrat_24 + + self._rect_dsc = lv.draw_rect_dsc_t() + lv.draw_rect_dsc_t.init(self._rect_dsc) + self._rect_dsc.bg_opa = lv.OPA.TRANSP + self._rect_dsc.border_opa = lv.OPA.COVER + self._rect_dsc.border_width = 1 + self._rect_dsc.border_color = lv.color_black() + + self._fill_dsc = lv.draw_rect_dsc_t() + lv.draw_rect_dsc_t.init(self._fill_dsc) + self._fill_dsc.bg_opa = lv.OPA.COVER + self._fill_dsc.bg_color = lv.color_black() + self._fill_dsc.border_width = 1 + + # Clear once + self.clear() + + # ---------------------------- + # Layer lifecycle + # ---------------------------- + + def _begin(self): + # Start drawing into the layer + self.canvas.init_layer(self.layer) + + def _end(self): + # Commit drawing + self.canvas.finish_layer(self.layer) + + # ---------------------------- + # Public API: drawing + # ---------------------------- + + def clear(self): + # Clear the canvas background + self.canvas.fill_bg(lv.color_white(), lv.OPA.COVER) + + def text(self, x, y, s, fg = lv.color_black()): + self._begin() + + dsc = lv.draw_label_dsc_t() + lv.draw_label_dsc_t.init(dsc) + dsc.text = str(s) + dsc.font = lv.font_montserrat_24 + dsc.color = lv.color_black() + + area = lv.area_t() + area.x1 = x + area.y1 = y + area.x2 = x + self.W + area.y2 = y + self.H + + lv.draw_label(self.layer, dsc, area) + + self._end() + + def line(self, x1, y1, x2, y2, fg = lv.color_black()): + self._begin() + + dsc = self._line_dsc + dsc.p1 = lv.point_precise_t() + dsc.p2 = lv.point_precise_t() + dsc.p1.x = int(x1) + dsc.p1.y = int(y1) + dsc.p2.x = int(x2) + dsc.p2.y = int(y2) + + lv.draw_line(self.layer, dsc) + + self._end() + + def circle(self, x, y, r, fg = lv.color_black()): + # Rounded rectangle trick (works everywhere) + self._begin() + + a = lv.area_t() + a.x1 = int(x - r) + a.y1 = int(y - r) + a.x2 = int(x + r) + a.y2 = int(y + r) + + dsc = self._rect_dsc + dsc.radius = lv.RADIUS_CIRCLE + dsc.border_color = fg + + lv.draw_rect(self.layer, dsc, a) + + self._end() + + def fill_circle(self, x, y, r, fg = lv.color_black(), bg = lv.color_white()): + self._begin() + + a = lv.area_t() + a.x1 = int(x - r) + a.y1 = int(y - r) + a.x2 = int(x + r) + a.y2 = int(y + r) + + dsc = self._rect_dsc + dsc.radius = lv.RADIUS_CIRCLE + dsc.border_color = fg + dsc.bg_color = bg + + lv.draw_rect(self.layer, dsc, a) + + self._end() + + def fill_rect(self, x, y, sx, sy, fg = lv.color_black(), bg = lv.color_white()): + self._begin() + + a = lv.area_t() + a.x1 = x + a.y1 = y + a.x2 = x+sx + a.y2 = y+sy + + dsc = self._fill_dsc + dsc.border_color = fg + dsc.bg_color = bg + + lv.draw_rect(self.layer, dsc, a) + + self._end() + + def update(self): + # Nothing needed; drawing is committed per primitive. + # If you want, you can change the implementation so that: + # - draw ops happen between clear() and update() + # But then you must ensure the app calls update() once per frame. + pass + +# ---------------------------- +# App logic +# ---------------------------- + +class PagedCanvas(Activity): + def __init__(self): + super().__init__() + self.page = 0 + self.pages = 3 + + def onCreate(self): + self.scr = lv.obj() + scr = self.scr + + # Screen size + self.W = scr.get_width() + self.H = scr.get_height() + + # Bottom button bar + self.margin = 2 + self.bar_h = 39 + + # Canvas drawing area (everything above button bar) + self.draw_w = self.W + self.draw_h = self.H - (self.bar_h + self.margin * 2) + + # Canvas + self.canvas = lv.canvas(self.scr) + self.canvas.set_size(self.draw_w, self.draw_h) + self.canvas.align(lv.ALIGN.TOP_LEFT, 0, 0) + self.canvas.set_style_border_width(0, 0) + + self.c = Canvas(self.scr, self.canvas) + + # Build buttons + self.build_buttons() + self.setContentView(self.c.scr) + + # ---------------------------- + # Button bar + # ---------------------------- + + def _make_btn(self, parent, x, y, w, h, label): + b = lv.button(parent) + b.set_pos(x, y) + b.set_size(w, h) + + l = lv.label(b) + l.set_text(label) + l.center() + + return b + + def _btn_cb(self, evt, tag): + self.page = tag + + def template_buttons(self, names): + margin = self.margin + y = self.H - self.bar_h - margin + + num = len(names) + if num == 0: + self.buttons = [] + return + + w = (self.W - margin * (num + 1)) // num + h = self.bar_h + x0 = margin + + self.buttons = [] + + for i, label in enumerate(names): + x = x0 + (w + margin) * i + btn = self._make_btn(self.scr, x, y, w, h, label) + + # capture index correctly + btn.add_event_cb( + lambda evt, idx=i: self._btn_cb(evt, idx), + lv.EVENT.CLICKED, + None + ) + + self.buttons.append(btn) + + def build_buttons(self): + self.template_buttons(["Pg0", "Pg1", "Pg2", "Pg3", "..."]) + + def onResume(self, screen): + self.timer = lv.timer_create(self.tick, 1000, None) + + def onPause(self, screen): + if self.timer: + self.timer.delete() + self.timer = None + + def tick(self, t): + self.update() + self.draw() + + def update(self): + pass + + def draw_page_example(self): + ui = self.c + ui.clear() + + st = 28 + y = 2*st + ui.text(0, y, "Hello world, page is %d" % self.page) + y += st + + def draw(self): + self.draw_page_example() + + def handle_buttons(self): + ui = self.c + +# ---------------------------- +# App logic +# ---------------------------- + +class UCompass(TiltCompass): + # val (+vfirst, vmin, vmax) -- vector from magnetometer + # acc -- vector from accelerometer + + # FIXME: we need to scale acc to similar values we used on watch; + # 90 degrees should correspond to outer circle + + def __init__(self): + super().__init__() + + self.accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) + self.magn = SensorManager.get_default_sensor(SensorManager.TYPE_MAGNETIC_FIELD) + + self.val = None + self.vfirst = None + + def update(self): + v = SensorManager.read_sensor_once(self.magn) + sc = 1000 + v = [float(v[1]) * sc, -float(v[0]) * sc, float(v[2]) * sc] + self.val = v + + if self.vfirst is None: + self.vfirst = self.val[:] + + acc = SensorManager.read_sensor_once(self.accel) + acc = ( -acc[1], -acc[0], acc[2] ) + self.acc = acc + +class Main(PagedCanvas): + def __init__(self): + super().__init__() + + self.cal = UCompass() + + self.bad = False + + self.heading = 0.0 + self.heading2 = None + + self.Ypos = 40 + self.brg = None # bearing target, degrees or None + + def draw(self): + pass + + def onResume(self, screen): + self.timer = lv.timer_create(self.tick, 50, None) + + def update(self): + self.c.clear() + st = 14 + y = 2*st + + self.cal.update() + if self.cal.val is None: + self.c.text(0, y, f"No compass data") + y += st + return + + self.bad = self.cal.step(self.cal.val) + self.heading = self.cal.heading_flat() + + acc = self.cal.acc + + #self.c.text(0, y, f"Compass, raw is {self.cal.val}, bad is {self.bad}, acc is {acc}") + y += st + + self.heading2 = self.cal.heading_tilted() + + if self.page == 0: + self.draw_top(acc) + elif self.page == 1: + self.draw_values() + elif self.page == 2: + self.c.text(0, y, f"Resetting calibration") + self.page = 0 + self.cal.reset() + + def build_buttons(self): + self.template_buttons(["Graph", "Values", "Reset"]) + + def draw_values(self): + self.c.text(0, 28, f""" +Acccelerometer +X {self.cal.acc[0]:.2f} Y {self.cal.acc[1]:.2f} Z {self.cal.acc[2]:.2f} +Magnetometer +X {self.cal.val[0]:.2f} Y {self.cal.val[1]:.2f} Z {self.cal.val[2]:.2f} +""") + + def _px_per_deg(self): + # JS used deg->px: (deg/90)*(width/2.1) + s = min(self.c.W, self.c.H) + return (s / 2.1) / 90.0 + + def _degrees_to_pixels(self, deg): + return deg * self._px_per_deg() + + # ---- TOP VIEW ---- + + def draw_top(self, acc): + heading=self.heading + heading2=self.heading2 + vmin=self.cal.vmin + vmax=self.cal.vmax + vfirst=self.cal.vfirst + v=self.cal.val + bad=self.bad + + cx = self.c.W // 2 + cy = self.c.H // 2 + + # Crosshair + self.c.line(0, cy, self.c.W, cy) + self.c.line(cx, 0, cx, self.c.H) + + # Circles (30/60/90 deg) + for rdeg in (30, 60, 90): + r = int(self._degrees_to_pixels(rdeg)) + self.c.circle(cx, cy, r) + + # Calibration box + current point + self._draw_calib_box(vmin, vmax, vfirst, v, bad) + + # Accel circle + if acc is not None: + self._draw_accel(acc) + + # Heading arrow(s) + self._draw_heading_arrow(heading, color=lv.color_make(255, 0, 0)) + self.c.text(265, 22, "%d°" % int(heading)) + if heading2 is not None: + self._draw_heading_arrow(heading2, color=lv.color_make(255, 255, 255), size = 100) + self.c.text(10, 22, "%d°" % int(heading2)) + + def _draw_heading_arrow(self, heading, color, size = 80): + cx = self.c.W / 2.0 + cy = self.c.H / 2.0 + + rad = -to_rad(heading) + x2 = cx + math.sin(rad - 0.1) * size + y2 = cy - math.cos(rad - 0.1) * size + x3 = cx + math.sin(rad + 0.1) * size + y3 = cy - math.cos(rad + 0.1) * size + + poly = [ + int(cx), int(cy), + int(x2), int(y2), + int(x3), int(y3), + ] + + self.c.line(poly[0], poly[1], poly[2], poly[3]) + self.c.line(poly[2], poly[3], poly[4], poly[5]) + self.c.line(poly[4], poly[5], poly[0], poly[1]) + + def _draw_accel(self, acc): + ax, ay, az = acc + cx = self.c.W / 2.0 + cy = self.c.H / 2.0 + + x2 = cx + ax * self.c.W + y2 = cy + ay * self.c.W + + self.c.circle(int(x2), int(y2), int(self.c.W / 8)) + + def _draw_calib_box(self, vmin, vmax, vfirst, v, bad): + if v is None or vfirst is None: + return + + scale = 0.15 + + boxW = (vmax[0] - vmin[0]) * scale + boxH = -(vmax[1] - vmin[1]) * scale + boxX = (vmin[0] - vfirst[0]) * scale + self.c.W / 2.0 + boxY = -(vmin[1] - vfirst[1]) * scale + self.c.H / 2.0 + + x = (v[0] - vfirst[0]) * scale + self.c.W / 2.0 + y = -(v[1] - vfirst[1]) * scale + self.c.H / 2.0 + + # box rect + if bad: + bg = lv.color_make(255, 0, 0) + else: + bg = lv.color_make(0, 150, 0) + + x1 = int(boxX) + y1 = int(boxY) + x2 = int(boxX + boxW) + y2 = int(boxY + boxH) + + # normalize coords + xa = min(x1, x2) + xb = max(x1, x2) + ya = min(y1, y2) + yb = max(y1, y2) + + self.c.fill_rect(xa, ya, xb - xa, yb - ya, bg = bg) + + # point + self.c.fill_circle(int(x), int(y), 3, bg = lv.color_make(255, 255, 0)) + diff --git a/internal_filesystem/apps/cz.ucw.pavel.compass/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/apps/cz.ucw.pavel.compass/res/mipmap-mdpi/icon_64x64.png new file mode 100644 index 00000000..2b1f919f Binary files /dev/null and b/internal_filesystem/apps/cz.ucw.pavel.compass/res/mipmap-mdpi/icon_64x64.png differ diff --git a/internal_filesystem/apps/cz.ucw.pavel.floodit/META-INF/MANIFEST.JSON b/internal_filesystem/apps/cz.ucw.pavel.floodit/META-INF/MANIFEST.JSON new file mode 100644 index 00000000..55f1cdee --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.floodit/META-INF/MANIFEST.JSON @@ -0,0 +1,24 @@ +{ +"name": "Floodit", +"publisher": "Pavel Machek", +"short_description": "Simple game with colors.", +"long_description": "Game with colors, where objective is to turn whole board into single color in minimum number of steps.", +"icon_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.floodit/icons/cz.ucw.pavel.floodit_0.0.1_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.floodit/mpks/cz.ucw.pavel.floodit_0.0.1.mpk", +"fullname": "cz.ucw.pavel.floodit", +"version": "0.0.1", +"category": "utilities", +"activities": [ + { + "entrypoint": "assets/main.py", + "classname": "Main", + "intent_filters": [ + { + "action": "main", + "category": "launcher" + } + ] + } + ] +} + diff --git a/internal_filesystem/apps/cz.ucw.pavel.floodit/assets/main.py b/internal_filesystem/apps/cz.ucw.pavel.floodit/assets/main.py new file mode 100644 index 00000000..cf0c77ca --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.floodit/assets/main.py @@ -0,0 +1,210 @@ +import time +import random + +""" +Flood-It game + +Fill the entire board with a single color +using the smallest number of moves. + +Touch a color button to flood the region +starting from the top-left corner. +""" + +from mpos import Activity + +try: + import lvgl as lv +except ImportError: + pass + + +class Main(Activity): + + COLS = 10 + ROWS = 10 + + COLORS = [ + 0xE74C3C, # red + 0xF1C40F, # yellow + 0x2ECC71, # green + 0x3498DB, # blue + 0x9B59B6, # purple + 0xE67E22, # orange + ] + + def __init__(self): + super().__init__() + + self.board = [] + self.cells = [] + + self.moves = 0 + + # --------------------------------------------------------------------- + + def onCreate(self): + + self.screen = lv.obj() + self.screen.remove_flag(lv.obj.FLAG.SCROLLABLE) + + font = lv.font_montserrat_20 + + score = lv.label(self.screen) + score.align(lv.ALIGN.TOP_LEFT, 5, 25) + score.set_text("Moves") + score.set_style_text_font(font, 0) + self.lb_score = score + + d = lv.display_get_default() + self.SCREEN_WIDTH = d.get_horizontal_resolution() + self.SCREEN_HEIGHT = d.get_vertical_resolution() + + # color buttons + btn_size = 45 + spacing = 5 + + self.CELL = min( + self.SCREEN_WIDTH // (self.COLS + 2), + (self.SCREEN_HEIGHT - btn_size) // (self.ROWS + 3) + ) + + board_x = (self.SCREEN_WIDTH - self.CELL * self.COLS) // 2 + board_y = (self.SCREEN_HEIGHT - self.CELL * self.ROWS) // 2 + + for r in range(self.ROWS): + row = [] + for c in range(self.COLS): + + o = lv.obj(self.screen) + o.set_size(self.CELL - 2, self.CELL - 2) + + o.set_pos( + board_x + c * self.CELL + 1, + board_y + r * self.CELL + 1 - btn_size // 2 + ) + + o.set_style_radius(4, 0) + o.set_style_border_width(1, 0) + + row.append(o) + + self.cells.append(row) + + + for i, col in enumerate(self.COLORS): + + btn = lv.button(self.screen) + btn.set_size(btn_size, btn_size) + + btn.align( + lv.ALIGN.BOTTOM_LEFT, + 5 + i * (btn_size + spacing), + -5 + ) + + btn.set_style_bg_color(lv.color_hex(col), 0) + + btn.add_event_cb( + lambda e, c=i: self.pick_color(c), + lv.EVENT.CLICKED, + None + ) + + focusgroup = lv.group_get_default() + if focusgroup: + focusgroup.add_obj(self.screen) + + self.setContentView(self.screen) + + self.new_game() + + # --------------------------------------------------------------------- + + def new_game(self): + + self.moves = 0 + self.lb_score.set_text("Moves\n0") + + self.board = [ + [random.randrange(len(self.COLORS)) for _ in range(self.COLS)] + for _ in range(self.ROWS) + ] + + self.redraw() + + # --------------------------------------------------------------------- + + def pick_color(self, color): + + start_color = self.board[0][0] + + if start_color == color: + return + + self.flood_fill(start_color, color) + + self.moves += 1 + self.lb_score.set_text("Moves\n%d" % self.moves) + + self.redraw() + + if self.check_win(): + self.win() + + # --------------------------------------------------------------------- + + def flood_fill(self, old, new): + + stack = [(0, 0)] + + while stack: + + r, c = stack.pop() + + if not (0 <= r < self.ROWS and 0 <= c < self.COLS): + continue + + if self.board[r][c] != old: + continue + + self.board[r][c] = new + + stack.append((r + 1, c)) + stack.append((r - 1, c)) + stack.append((r, c + 1)) + stack.append((r, c - 1)) + + # --------------------------------------------------------------------- + + def check_win(self): + + color = self.board[0][0] + + for r in range(self.ROWS): + for c in range(self.COLS): + if self.board[r][c] != color: + return False + + return True + + # --------------------------------------------------------------------- + + def win(self): + + label = lv.label(self.screen) + label.set_text("Finished in %d moves!" % self.moves) + label.center() + + # --------------------------------------------------------------------- + + def redraw(self): + + for r in range(self.ROWS): + for c in range(self.COLS): + + v = self.board[r][c] + + self.cells[r][c].set_style_bg_color( + lv.color_hex(self.COLORS[v]), 0 + ) diff --git a/internal_filesystem/apps/cz.ucw.pavel.floodit/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/apps/cz.ucw.pavel.floodit/res/mipmap-mdpi/icon_64x64.png new file mode 100644 index 00000000..f0ca6f75 Binary files /dev/null and b/internal_filesystem/apps/cz.ucw.pavel.floodit/res/mipmap-mdpi/icon_64x64.png differ diff --git a/internal_filesystem/apps/cz.ucw.pavel.gyro/META-INF/MANIFEST.JSON b/internal_filesystem/apps/cz.ucw.pavel.gyro/META-INF/MANIFEST.JSON new file mode 100644 index 00000000..bd365e8e --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.gyro/META-INF/MANIFEST.JSON @@ -0,0 +1,24 @@ +{ +"name": "Gyro", +"publisher": "Pavel Machek", +"short_description": "Gyro", +"long_description": "Simple gyro app.", +"icon_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.gyro/icons/cz.ucw.pavel.gyro_0.0.1_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.gyro/mpks/cz.ucw.pavel.gyro_0.0.1.mpk", +"fullname": "cz.ucw.pavel.gyro", +"version": "0.0.1", +"category": "utilities", +"activities": [ + { + "entrypoint": "assets/main.py", + "classname": "Main", + "intent_filters": [ + { + "action": "main", + "category": "launcher" + } + ] + } + ] +} + diff --git a/internal_filesystem/apps/cz.ucw.pavel.gyro/assets/main.py b/internal_filesystem/apps/cz.ucw.pavel.gyro/assets/main.py new file mode 100644 index 00000000..f6127684 --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.gyro/assets/main.py @@ -0,0 +1,625 @@ +""" +Test/visualization of gyroscope / accelerometer + +""" + +import time +import os +import math + +try: + import lvgl as lv +except ImportError: + pass + +from mpos import Activity, MposKeyboard, SensorManager + +# ----------------------------- +# Utilities +# ----------------------------- + +def clamp(v, lo, hi): + if v < lo: + return lo + if v > hi: + return hi + return v + +def to_rad(deg): + return deg * math.pi / 180.0 + +def to_deg(rad): + return rad * 180.0 / math.pi + +class Vec3: + def __init__(self): + pass + + def init3(self, x, y, z): + self.x = float(x) + self.y = float(y) + self.z = float(z) + return self + + def init_v(self, v): + self.x = v[0] + self.y = v[1] + self.z = v[2] + return self + + def __add__(self, other): + return vec3( + self.x + other.x, + self.y + other.y, + self.z + other.z + ) + + def __sub__(self, other): + return vec3( + self.x - other.x, + self.y - other.y, + self.z - other.z + ) + + def __mul__(self, scalar): + return vec3( + self.x * scalar, + self.y * scalar, + self.z * scalar + ) + + def __truediv__(self, scalar): + return vec3( + self.x / scalar, + self.y / scalar, + self.z / scalar + ) + + __rmul__ = __mul__ + + def __repr__(self): + return f"X {self.x:.2f} Y {self.y:.2f} Z {self.z:.2f}" + +def vec3(x, y, z): return Vec3().init3(x, y, z) +def vec0(): return Vec3().init3(0, 0, 0) + +# ----------------------------- +# Calibration + heading +# ----------------------------- + +class Gyro: + def __init__(self): + super().__init__() + self.rot = vec0() + self.last = time.time() + self.last_reset = self.last + self.smooth = vec0() + self.calibration = vec0() + + def reset(self): + now = time.time() + self.calibration = self.rot / (now - self.last_reset) + print("Reset... ", self.calibration) + self.last_reset = now + self.rot = vec0() + + def update(self): + """ + Returns heading 0..360 + + iio is in rads/second + """ + t = time.time() + # pp: gyr[1] seems to be rotation "away" and "towards" the user, like pitch in plane ... or maybe roll? + # gyr[2] sseems to be rotation -- as useful for compass on table + v = self.gyr + coef = 1 + self.smooth = self.smooth * (1-coef) + v * coef + self.rot -= self.smooth * (t - self.last) + self.last = t + + def angle(self): + now = time.time() + return self.rot - (now - self.last_reset) * self.calibration + + def angvel(self): + return vec0()-self.smooth + +class UGyro(Gyro): + def __init__(self): + super().__init__() + + self.accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) + self.magn = SensorManager.get_default_sensor(SensorManager.TYPE_MAGNETIC_FIELD) + self.gyro = SensorManager.get_default_sensor(SensorManager.TYPE_GYROSCOPE) + + self.gyr = None + + def update(self): + acc = SensorManager.read_sensor_once(self.accel) + sc = 1/9.81 + acc = vec3( -acc[0] * sc, acc[1] * sc, acc[2] * sc ) + self.acc = acc + + self.gyr = Vec3().init_v(SensorManager.read_sensor_once(self.gyro)) + super().update() + +# ----------------------------- +# Canvas (LVGL) +# ----------------------------- + +class Canvas: + """ + LVGL canvas + layer drawing Canvas. + + This matches ports where: + - lv.canvas has init_layer() / finish_layer() + - primitives are drawn via lv.draw_* into lv.layer_t + """ + + def __init__(self, scr, canvas): + self.scr = scr + + # Screen size + self.W = scr.get_width() + self.H = scr.get_height() + + # Bottom button bar + self.margin = 2 + self.bar_h = 39 + + # Canvas drawing area (everything above button bar) + self.draw_w = self.W + self.draw_h = self.H - (self.bar_h + self.margin * 2) + + self.canvas = canvas + + # Background: white (change if you want dark theme) + self.canvas.set_style_bg_color(lv.color_white(), lv.PART.MAIN) + + # Buffer: your working example uses 4 bytes/pixel + # Reality filter: this depends on LV_COLOR_DEPTH; but your example proves it works. + self.buf = bytearray(self.draw_w * self.draw_h * 4) + self.canvas.set_buffer(self.buf, self.draw_w, self.draw_h, lv.COLOR_FORMAT.NATIVE) + + # Layer used for draw engine + self.layer = lv.layer_t() + self.canvas.init_layer(self.layer) + + # Persistent draw descriptors (avoid allocations) + self._line_dsc = lv.draw_line_dsc_t() + lv.draw_line_dsc_t.init(self._line_dsc) + self._line_dsc.width = 1 + self._line_dsc.color = lv.color_black() + self._line_dsc.round_end = 1 + self._line_dsc.round_start = 1 + + self._label_dsc = lv.draw_label_dsc_t() + lv.draw_label_dsc_t.init(self._label_dsc) + self._label_dsc.color = lv.color_black() + self._label_dsc.font = lv.font_montserrat_24 + + self._rect_dsc = lv.draw_rect_dsc_t() + lv.draw_rect_dsc_t.init(self._rect_dsc) + self._rect_dsc.bg_opa = lv.OPA.TRANSP + self._rect_dsc.border_opa = lv.OPA.COVER + self._rect_dsc.border_width = 1 + self._rect_dsc.border_color = lv.color_black() + + self._fill_dsc = lv.draw_rect_dsc_t() + lv.draw_rect_dsc_t.init(self._fill_dsc) + self._fill_dsc.bg_opa = lv.OPA.COVER + self._fill_dsc.bg_color = lv.color_black() + self._fill_dsc.border_width = 1 + + # Clear once + self.clear() + + # ---------------------------- + # Layer lifecycle + # ---------------------------- + + def _begin(self): + # Start drawing into the layer + self.canvas.init_layer(self.layer) + + def _end(self): + # Commit drawing + self.canvas.finish_layer(self.layer) + + # ---------------------------- + # Public API: drawing + # ---------------------------- + + def clear(self): + # Clear the canvas background + self.canvas.fill_bg(lv.color_white(), lv.OPA.COVER) + + def text(self, x, y, s, fg = lv.color_black()): + self._begin() + + dsc = lv.draw_label_dsc_t() + lv.draw_label_dsc_t.init(dsc) + dsc.text = str(s) + dsc.font = lv.font_montserrat_24 + dsc.color = lv.color_black() + + area = lv.area_t() + area.x1 = x + area.y1 = y + area.x2 = x + self.W + area.y2 = y + self.H + + lv.draw_label(self.layer, dsc, area) + + self._end() + + def line(self, x1, y1, x2, y2, fg = lv.color_black()): + self._begin() + + dsc = self._line_dsc + dsc.p1 = lv.point_precise_t() + dsc.p2 = lv.point_precise_t() + dsc.p1.x = int(x1) + dsc.p1.y = int(y1) + dsc.p2.x = int(x2) + dsc.p2.y = int(y2) + + lv.draw_line(self.layer, dsc) + + self._end() + + def circle(self, x, y, r, fg = lv.color_black()): + # Rounded rectangle trick (works everywhere) + self._begin() + + a = lv.area_t() + a.x1 = int(x - r) + a.y1 = int(y - r) + a.x2 = int(x + r) + a.y2 = int(y + r) + + dsc = self._rect_dsc + dsc.radius = lv.RADIUS_CIRCLE + dsc.border_color = fg + + lv.draw_rect(self.layer, dsc, a) + + self._end() + + def fill_circle(self, x, y, r, fg = lv.color_black(), bg = lv.color_white()): + self._begin() + + a = lv.area_t() + a.x1 = int(x - r) + a.y1 = int(y - r) + a.x2 = int(x + r) + a.y2 = int(y + r) + + dsc = self._rect_dsc + dsc.radius = lv.RADIUS_CIRCLE + dsc.border_color = fg + dsc.bg_color = bg + + lv.draw_rect(self.layer, dsc, a) + + self._end() + + def fill_rect(self, x, y, sx, sy, fg = lv.color_black(), bg = lv.color_white()): + self._begin() + + a = lv.area_t() + a.x1 = x + a.y1 = y + a.x2 = x+sx + a.y2 = y+sy + + dsc = self._fill_dsc + dsc.border_color = fg + dsc.bg_color = bg + + lv.draw_rect(self.layer, dsc, a) + + self._end() + + def update(self): + # Nothing needed; drawing is committed per primitive. + # If you want, you can change the implementation so that: + # - draw ops happen between clear() and update() + # But then you must ensure the app calls update() once per frame. + pass + +# ---------------------------- +# App logic +# ---------------------------- + +class PagedCanvas(Activity): + def __init__(self): + super().__init__() + self.page = 0 + self.pages = 3 + + def onCreate(self): + self.scr = lv.obj() + scr = self.scr + + # Screen size + self.W = scr.get_width() + self.H = scr.get_height() + + # Bottom button bar + self.margin = 2 + self.bar_h = 39 + + # Canvas drawing area (everything above button bar) + self.draw_w = self.W + self.draw_h = self.H - (self.bar_h + self.margin * 2) + + # Canvas + self.canvas = lv.canvas(self.scr) + self.canvas.set_size(self.draw_w, self.draw_h) + self.canvas.align(lv.ALIGN.TOP_LEFT, 0, 0) + self.canvas.set_style_border_width(0, 0) + + self.c = Canvas(self.scr, self.canvas) + + # Build buttons + self.build_buttons() + self.setContentView(self.c.scr) + + # ---------------------------- + # Button bar + # ---------------------------- + + def _make_btn(self, parent, x, y, w, h, label): + b = lv.button(parent) + b.set_pos(x, y) + b.set_size(w, h) + + l = lv.label(b) + l.set_text(label) + l.center() + + return b + + def _btn_cb(self, evt, tag): + self.page = tag + + def template_buttons(self, names): + margin = self.margin + y = self.H - self.bar_h - margin + + num = len(names) + if num == 0: + self.buttons = [] + return + + w = (self.W - margin * (num + 1)) // num + h = self.bar_h + x0 = margin + + self.buttons = [] + + for i, label in enumerate(names): + x = x0 + (w + margin) * i + btn = self._make_btn(self.scr, x, y, w, h, label) + + # capture index correctly + btn.add_event_cb( + lambda evt, idx=i: self._btn_cb(evt, idx), + lv.EVENT.CLICKED, + None + ) + + self.buttons.append(btn) + + def build_buttons(self): + self.template_buttons(["Pg0", "Pg1", "Pg2", "Pg3", "..."]) + + def onResume(self, screen): + self.timer = lv.timer_create(self.tick, 1000, None) + + def onPause(self, screen): + if self.timer: + self.timer.delete() + self.timer = None + + def tick(self, t): + self.update() + self.draw() + + def update(self): + pass + + def draw_page_example(self): + ui = self.c + ui.clear() + + st = 28 + y = 2*st + ui.text(0, y, "Hello world, page is %d" % self.page) + y += st + + def draw(self): + self.draw_page_example() + + def handle_buttons(self): + ui = self.c + +# ---------------------------- +# App logic +# ---------------------------- + +class Main(PagedCanvas): + ASSET_PATH = "M:apps/cz.ucw.pavel.gyro/res/gyro-help.png" + + def __init__(self): + super().__init__() + + self.cal = UGyro() + self.Ypos = 40 + + img = lv.image(lv.layer_top()) + img.set_src(f"{self.ASSET_PATH}") + self.help_img = img + self.hide_img() + + def hide_img(self): + self.help_img.add_flag(lv.obj.FLAG.HIDDEN) + + def draw_img(self): + img = self.help_img + img.remove_flag(lv.obj.FLAG.HIDDEN) + img.set_pos(60, 18) + #img.set_size(640, 640) + img.set_rotation(0) + + def draw(self): + pass + + def onResume(self, screen): + self.timer = lv.timer_create(self.tick, 50, None) + + def update(self): + self.c.clear() + + y = 20 + st = 20 + + self.cal.update() + if self.cal.gyr is None: + self.c.text(0, y, f"No compass data") + y += st + return + + if self.page == 2: + self.draw_img() + return + self.hide_img() + + if self.page == 0: + self.draw_top(self.cal.acc) + elif self.page == 1: + self.draw_values() + elif self.page == 3: + self.c.text(0, y, f"Resetting calibration") + self.page = 0 + self.cal.reset() + + def build_buttons(self): + self.template_buttons(["Graph", "Values", "Help", "Reset"]) + + def draw_values(self): + x, y, z = self.cal.acc.x, self.cal.acc.y, self.cal.acc.z + total = math.sqrt(x*x+y*y+z*z) + s = "" + if x > .6: + s += " left" + if x < -.6: + s += " right" + if y > .6: + s += " up" + if y < -.6: + s += " down" + if z > .6: + s += " below" + if z < -.6: + s += " above" + + t = "" + lim = 25 + angvel = self.cal.angvel() + if angvel.z > lim: + # top part moves to the right + t += " yaw+" + if angvel.z < -lim: + t += " yaw-" + if angvel.x > lim: + # top part goes up + t += " pitch+" + if angvel.x < -lim: + t += " pitch-" + if angvel.y > lim: + # right part goes down + t += " roll+" + if angvel.y < -lim: + t += " roll-" + + self.c.text(0, 7, f""" +^ Up -> Right +|| Acc +{self.cal.acc} +Earth is{s}, {total*100:.0f}% +{self.cal.gyr} +Rotation is{t} +""") + + def _px_per_deg(self): + # JS used deg->px: (deg/90)*(width/2.1) + s = min(self.c.W, self.c.H) + return (s / 2.1) / 90.0 + + def _degrees_to_pixels(self, deg): + return deg * self._px_per_deg() + + # ---- TOP VIEW ---- + + def draw_top(self, acc): + heading=self.cal.angle().z + heading2=self.cal.angvel().z + vmin=0 + vmax=20 + v=self.cal.gyr + + cx = self.c.W // 2 + cy = self.c.H // 2 + + # Crosshair + self.c.line(0, cy, self.c.W, cy) + self.c.line(cx, 0, cx, self.c.H) + + # Circles (30/60/90 deg) + for rdeg in (30, 60, 90): + r = int(self._degrees_to_pixels(rdeg)) + self.c.circle(cx, cy, r) + + # Accel circle + if acc is not None: + self._draw_accel(acc) + + # Heading arrow(s) + self._draw_heading_arrow(heading, color=lv.color_make(255, 0, 0)) + self.c.text(265, 22, "%d°" % int(heading)) + if heading2 is not None: + self._draw_heading_arrow(heading2, color=lv.color_make(255, 255, 255), size = 100) + self.c.text(10, 22, "%d°" % int(heading2)) + + def _draw_heading_arrow(self, heading, color, size = 80): + cx = self.c.W / 2.0 + cy = self.c.H / 2.0 + + rad = -to_rad(heading) + x2 = cx + math.sin(rad - 0.1) * size + y2 = cy - math.cos(rad - 0.1) * size + x3 = cx + math.sin(rad + 0.1) * size + y3 = cy - math.cos(rad + 0.1) * size + + poly = [ + int(cx), int(cy), + int(x2), int(y2), + int(x3), int(y3), + ] + + self.c.line(poly[0], poly[1], poly[2], poly[3]) + self.c.line(poly[2], poly[3], poly[4], poly[5]) + self.c.line(poly[4], poly[5], poly[0], poly[1]) + + def _draw_accel(self, acc): + ax, ay, az = acc.x, acc.y, acc.z + cx = self.c.W / 2.0 + cy = self.c.H / 2.0 + + x2 = cx + ax * self.c.W + y2 = cy + ay * self.c.W + + self.c.circle(int(x2), int(y2), int(self.c.W / 8)) diff --git a/internal_filesystem/apps/cz.ucw.pavel.gyro/res/gyro-help.png b/internal_filesystem/apps/cz.ucw.pavel.gyro/res/gyro-help.png new file mode 100644 index 00000000..4b54b993 Binary files /dev/null and b/internal_filesystem/apps/cz.ucw.pavel.gyro/res/gyro-help.png differ diff --git a/internal_filesystem/apps/cz.ucw.pavel.gyro/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/apps/cz.ucw.pavel.gyro/res/mipmap-mdpi/icon_64x64.png new file mode 100644 index 00000000..fcb60f0c Binary files /dev/null and b/internal_filesystem/apps/cz.ucw.pavel.gyro/res/mipmap-mdpi/icon_64x64.png differ diff --git a/internal_filesystem/apps/cz.ucw.pavel.navstar/META-INF/MANIFEST.JSON b/internal_filesystem/apps/cz.ucw.pavel.navstar/META-INF/MANIFEST.JSON new file mode 100644 index 00000000..f8d69b0d --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.navstar/META-INF/MANIFEST.JSON @@ -0,0 +1,24 @@ +{ +"name": "Navstar", +"publisher": "Pavel Machek", +"short_description": "Simple navigation app.", +"long_description": "Simple navigation app using data from NAVSTAR GPS and other GNSS systems.", +"icon_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.navstar/icons/cz.ucw.pavel.navstar_0.0.1_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.navstar/mpks/cz.ucw.pavel.navstar_0.0.1.mpk", +"fullname": "cz.ucw.pavel.navstar", +"version": "0.0.1", +"category": "utilities", +"activities": [ + { + "entrypoint": "assets/main.py", + "classname": "Main", + "intent_filters": [ + { + "action": "main", + "category": "launcher" + } + ] + } + ] +} + diff --git a/internal_filesystem/apps/cz.ucw.pavel.navstar/assets/main.py b/internal_filesystem/apps/cz.ucw.pavel.navstar/assets/main.py new file mode 100644 index 00000000..62270fc1 --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.navstar/assets/main.py @@ -0,0 +1,1621 @@ +from mpos import Activity + +""" +micropythonos, give me code to parse nmea data from gps, display lat/lon/speed/... display sky view, allow recording of track to egt, display current track length in kilometers, and allow navigation to a point. + +""" + +import time +import os +import uselect +import json +import time +import math +import re + +from pcanvas import * + +try: + import lvgl as lv +except ImportError: + pass + +import mpos +from mpos import Activity, MposKeyboard + +# +# Features: +# - NMEA parsing: RMC, GGA, GSV +# - Live data: lat/lon/speed/alt/course/time/fix/sats/hdop +# - Sky view from GSV +# - Track recording to EGT +# - Track length (km) +# - Navigation to a point: bearing + distance +# +# Reality filter: +# - Sky view uses only azimuth/elevation from GSV, which many GPS modules output, +# but some modules omit/limit GSV. In that case the sky view will be empty. +# - EGT is a simple plaintext format defined here (not a standard). + + +# ---------------------------- +# Small utilities +# ---------------------------- + +def clamp(x, lo, hi): + if x < lo: + return lo + if x > hi: + return hi + return x + + +def nmea_checksum_ok(line): + # line includes leading '$' and optional \r\n + line = line.strip() + if not line.startswith("$"): + return False + star = line.find("*") + if star < 0: + return False + body = line[1:star] + given = line[star + 1:] + if len(given) < 2: + return False + try: + want = int(given[:2], 16) + except ValueError: + return False + + c = 0 + for ch in body: + c ^= ord(ch) + return c == want + + +def safe_float(s): + try: + return float(s) + except Exception: + return None + + +def safe_int(s): + try: + return int(s) + except Exception: + return None + + +def knots_to_kmh(knots): + return knots * 1.852 + + +def deg_to_rad(d): + return d * math.pi / 180.0 + + +def rad_to_deg(r): + return r * 180.0 / math.pi + + +def haversine_km(lat1, lon1, lat2, lon2): + # Great-circle distance + R = 6371.0088 + phi1 = deg_to_rad(lat1) + phi2 = deg_to_rad(lat2) + dphi = deg_to_rad(lat2 - lat1) + dl = deg_to_rad(lon2 - lon1) + + a = math.sin(dphi / 2.0) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dl / 2.0) ** 2 + c = 2.0 * math.atan2(math.sqrt(a), math.sqrt(1.0 - a)) + return R * c + + +def bearing_deg(lat1, lon1, lat2, lon2): + # Initial bearing from point1 -> point2 + phi1 = deg_to_rad(lat1) + phi2 = deg_to_rad(lat2) + dl = deg_to_rad(lon2 - lon1) + + y = math.sin(dl) * math.cos(phi2) + x = math.cos(phi1) * math.sin(phi2) - math.sin(phi1) * math.cos(phi2) * math.cos(dl) + br = math.atan2(y, x) + brd = (rad_to_deg(br) + 360.0) % 360.0 + return brd + + +def parse_latlon(ddmm, hemi): + # NMEA format: latitude ddmm.mmmm, longitude dddmm.mmmm + if not ddmm or not hemi: + return None + + v = safe_float(ddmm) + if v is None: + return None + + # Split degrees and minutes + # For lat: 2 deg digits; for lon: 3 deg digits + # We infer by length before decimal. + s = ddmm + dot = s.find(".") + if dot < 0: + dot = len(s) + + deg_digits = 2 + if dot > 4: + deg_digits = 3 + + try: + deg = int(s[:deg_digits]) + minutes = float(s[deg_digits:]) + except Exception: + return None + + dec = deg + (minutes / 60.0) + if hemi in ("S", "W"): + dec = -dec + return dec + + +def parse_hhmmss(hhmmss): + # Returns (h,m,s) or None + if not hhmmss or len(hhmmss) < 6: + return None + try: + h = int(hhmmss[0:2]) + m = int(hhmmss[2:4]) + s = int(hhmmss[4:6]) + return (h, m, s) + except Exception: + return None + + +def parse_ddmmyy(ddmmyy): + if not ddmmyy or len(ddmmyy) != 6: + return None + try: + d = int(ddmmyy[0:2]) + mo = int(ddmmyy[2:4]) + y = int(ddmmyy[4:6]) + 2000 + return (y, mo, d) + except Exception: + return None + +class Config: + pass + +config = Config() +config.lat = None +config.lon = None +config.name = "" +config.recording = False + +# ---------------------------- +# NMEA state model +# ---------------------------- + +class GPSState: + def __init__(self): + self.start = time.time() + self.start_good = self.start + + # Position / motion + self.lat = None + self.lon = None + self.alt_m = None + self.speed_kmh = None + self.course_deg = None + + # Fix / quality + self.fix_quality = 0 # from GGA + self.fix_valid = False # from RMC + self.sats_used = 0 + self.hdop = None + + # Time + self.time_hms = None + self.date_ymd = None + + # Satellites in view from GSV: + # dict prn -> {el, az, snr} + self.sats_in_view = {} + + # For display freshness + self.last_update_ms = 0 + + def has_fix(self): + # Require RMC valid + lat/lon present + return self.fix_valid and (self.lat is not None) and (self.lon is not None) + + def summary(self): + num = 0 + good = 0 + best_snr = 0 + snrlim = 25 + print(self.sats_in_view) + for prn in self.sats_in_view: + d = self.sats_in_view[prn] + snr = d.get("snr") + num += 1 + if snr: + if snr > snrlim: + good += 1 + if best_snr < snr: + best_snr = snr + + now = time.time() + if good < 4: + self.start_good = now + + if self.has_fix(): + if good >=4: + return f"Have FIX, good sky, hdop {self.hdop}" + + return f"FIX, bad sky {good}/{num}" + + if best_snr < snrlim: + if best_snr > 0: + return f"Need some sky {best_snr} dB" + return f"Need some sky {num} sats" + + if good < 4: + return f"Need clear sky {good}/{num}" + + delta = now - self.start_good + return f"Need a minute {delta:.0f}s" + + delta = now - self.start + return f"No fix for {delta:.0f}" + + +class NMEAParser: + def __init__(self, gps_state): + self.gps = gps_state + + # GSV is multi-part, but we do not need to store parts, + # we just update sats_in_view as they arrive. + # Some modules send multiple talker IDs: GP, GN, GL, GA... + # We'll accept any. + + def feed_line(self, line): + line = line.strip() + if not line.startswith("$"): + return + + if not nmea_checksum_ok(line): + return + + # Strip $ and checksum + star = line.find("*") + body = line[1:star] + fields = body.split(",") + if len(fields) < 1: + return + + msg = fields[0] + # msg like GPRMC, GNRMC, etc. + if len(msg) < 5: + return + + msg_type = msg[-3:] + + if msg_type == "RMC": + self._parse_rmc(fields) + elif msg_type == "GGA": + self._parse_gga(fields) + elif msg_type == "GSV": + self._parse_gsv(fields) + + self.gps.last_update_ms = time.ticks_ms() + + def _parse_rmc(self, f): + # $GPRMC,hhmmss.sss,A,llll.ll,a,yyyyy.yy,a,x.x,x.x,ddmmyy,x.x,a*hh + # 0 1 2 3 4 5 6 7 8 9 ... + if len(f) < 10: + return + + self.gps.time_hms = parse_hhmmss(f[1]) + status = f[2] + self.gps.fix_valid = (status == "A") + + lat = parse_latlon(f[3], f[4]) + lon = parse_latlon(f[5], f[6]) + + if lat is not None and lon is not None: + self.gps.lat = lat + self.gps.lon = lon + + sp_kn = safe_float(f[7]) + if sp_kn is not None: + self.gps.speed_kmh = knots_to_kmh(sp_kn) + + course = safe_float(f[8]) + if course is not None: + self.gps.course_deg = course + + self.gps.date_ymd = parse_ddmmyy(f[9]) + + def _parse_gga(self, f): + # $GPGGA,hhmmss.sss,lat,NS,lon,EW,quality,numSV,HDOP,alt,M,... + if len(f) < 10: + return + + self.gps.time_hms = parse_hhmmss(f[1]) + + lat = parse_latlon(f[2], f[3]) + lon = parse_latlon(f[4], f[5]) + if lat is not None and lon is not None: + self.gps.lat = lat + self.gps.lon = lon + + q = safe_int(f[6]) + if q is not None: + self.gps.fix_quality = q + + sats = safe_int(f[7]) + if sats is not None: + self.gps.sats_used = sats + + hdop = safe_float(f[8]) + if hdop is not None: + self.gps.hdop = hdop + + alt = safe_float(f[9]) + if alt is not None: + self.gps.alt_m = alt + + def _parse_gsv(self, f): + # $GPGSV,total_msgs,msg_num,total_sats, [sat blocks...] + # Each sat block: prn, elev, az, snr + if len(f) < 4: + return + + # total_msgs = safe_int(f[1]) + # msg_num = safe_int(f[2]) + total_sats = safe_int(f[3]) + if total_sats is not None: + # not exactly "used", but we store it in view count indirectly + pass + + # sat blocks start at index 4 + i = 4 + while i + 3 < len(f): + prn = safe_int(f[i + 0]) + el = safe_int(f[i + 1]) + az = safe_int(f[i + 2]) + snr = safe_int(f[i + 3]) + i += 4 + + if prn is None: + continue + + d = self.gps.sats_in_view.get(prn) + if d is None: + d = {} + self.gps.sats_in_view[prn] = d + + if el is not None: + d["el"] = el + if az is not None: + d["az"] = az + if snr is not None: + d["snr"] = snr + + +# ---------------------------- +# Track recording (EGT) +# ---------------------------- + +class EGTWriter: + """ + EGT (Editable GPS Track) - a minimal plaintext format. + + So... recording to gpx is not really suitable, as that format is hard to modify by tools such as head/tail/tac. It also has trailer, so if you reboot while recording, you would end up with invalid file. + + Format description and some tools to work with these are available at tui/gtracks. + """ + + def __init__(self): + self.fp = None + self.started = False + + def start(self, filename): + self.filename = filename + if self.fp: + return + self.fp = open(self.filename, "a") + if not self.started: + self.fp.write("# EGT 1\n") + self.fp.write("# fields: lat lon alt_m speed_kmh course_deg sats_used hdop time date\n") + self.started = True + self.fp.flush() + + def stop(self): + if self.fp: + self.fp.flush() + self.fp.close() + self.fp = None + + def write_point(self, gps): + if not self.fp: + return + if not gps.has_fix(): + return + + lat = gps.lat + lon = gps.lon + alt = gps.alt_m if gps.alt_m is not None else -9999.0 + spd = gps.speed_kmh if gps.speed_kmh is not None else 0.0 + crs = gps.course_deg if gps.course_deg is not None else 0.0 + sats = gps.sats_used + hdop = gps.hdop if gps.hdop is not None else -1.0 + + if gps.time_hms: + t = "%02d:%02d:%02d" % gps.time_hms + else: + t = "--:--:--" + + if gps.date_ymd: + y, mo, d = gps.date_ymd + da = "%04d-%02d-%02d" % (y, mo, d) + else: + da = "---- -- --" + + #self.fp.write("P %.7f %.7f %.1f %.2f %.1f %d %.2f %s %s\n" % (lat, lon, alt, spd, crs, sats, hdop, t, da)) + self.fp.write("%.7f %.7f\n" % (lat, lon)) + #self.fp.flush() + + +class Track: + def __init__(self): + self.points = [] # list of (lat, lon) + self.length_km = 0.0 + + def reset(self): + self.points = [] + self.length_km = 0.0 + + def add_point(self, lat, lon): + if lat is None or lon is None: + return + + if len(self.points) > 0: + lat0, lon0 = self.points[-1] + d = haversine_km(lat0, lon0, lat, lon) + # Basic noise suppression: ignore jumps < 2m + if d < 0.002: + return + self.length_km += d + + self.points.append((lat, lon)) + +# ---------------------------- +# Navigation target +# ---------------------------- + +class NavTarget: + def __init__(self): + self.enabled = False + self.lat = None + self.lon = None + self.name = "TARGET" + + def set(self, lat, lon, name=None): + self.lat = lat + self.lon = lon + self.enabled = True + if name: + self.name = name + + def clear(self): + self.enabled = False + self.lat = None + self.lon = None + + + +# ---------------------------- +# App logic +# ---------------------------- + +class Main(PagedCanvas): + def __init__(self): + super().__init__() + self.gps = GPSState() + self.parser = NMEAParser(self.gps) + + self.track = Track() + self.egt = EGTWriter() + self.recording = False + + self.nav = NavTarget() + + self.last_track_write_ms = 0 + self.last_track_add_ms = 0 + + self.uart = None + + # Default nav point (Prague center) - change as desired + # (Reality filter: this is just a reasonable example coordinate.) + self.nav.set(50.087465, 14.421254, "Prague") + + def onResume(self, screen): + if not config.lon is None: + self.nav.name = config.name + self.nav.lon = config.lon + self.nav.lat = config.lat + self.recording = config.recording + self.toggle_recording() + self.timer = lv.timer_create(self.tick, 1000, None) + + def tick(self, t): + lm.poll() + nmea = lm.get_nmea() + if nmea: + lines = nmea.split('\n') + for line in lines: + #print("line", line) + self.parser.feed_line(line) + self.update() + self.draw() + + def build_buttons(self): + self.template_buttons(["Basic", "Sky", "Goto", "Rec", "..."]) + + def _btn_cb(self, evt, tag): + self.page = tag + if tag == 4: + intent = mpos.Intent(activity_class=EnterTarget) + self.startActivity(intent) + + def toggle_recording(self): + if self.recording: + track_file=f"track-{time.time()}.egt" + self.egt.start(track_file) + else: + self.egt.stop() + + def set_nav_target_here(self): + if self.gps.has_fix(): + self.nav.set(self.gps.lat, self.gps.lon, "HERE") + + def clear_track(self): + self.track.reset() + + def read_uart(self): + if not self.uart: + return + + # We read line-by-line. Many GPS modules end lines with \r\n. + while True: + line = self.uart.readline() + if not line: + break + try: + s = line.decode("ascii", "ignore") + except Exception: + continue + self.parser.feed_line(s) + + def maybe_update_track(self): + if not self.gps.has_fix(): + return + + now = time.ticks_ms() + + # Add a track point at ~1 Hz + if time.ticks_diff(now, self.last_track_add_ms) > 1000: + self.last_track_add_ms = now + self.track.add_point(self.gps.lat, self.gps.lon) + + # Write to file at ~1 Hz if recording + if self.recording and time.ticks_diff(now, self.last_track_write_ms) > 1000: + self.last_track_write_ms = now + self.egt.write_point(self.gps) + + def draw_page_status(self): + gps = self.gps + ui = self.c + + ui.clear() + + st = 28 + y = 1*st + fix = "FIX" if gps.has_fix() else "NOFIX" + rec = "REC" if self.recording else "----" + ui.text(0, y, "%s %s sats:%d" % (fix, rec, gps.sats_used)) + y += st + ui.text(0, y, "%s" % gps.summary()) + y += 2*st + + if gps.lat is not None and gps.lon is not None: + ui.text(0, y, "Lat: %.6f" % gps.lat) + ui.text(0, y+st, "Lon: %.6f" % gps.lon) + else: + ui.text(0, y, "Lat: ---") + ui.text(0, y+st, "Lon: ---") + y += 2*st + + if gps.speed_kmh is not None: + ui.text(0, y, "Speed: %.1f km/h" % gps.speed_kmh) + else: + ui.text(0, y, "Speed: ---") + y += st + + if gps.alt_m is not None: + ui.text(0, y, "Alt: %.1f m" % gps.alt_m) + else: + ui.text(0, y, "Alt: ---") + y += st + + if gps.course_deg is not None: + ui.text(0, y, "Head: %.0f deg" % gps.course_deg) + else: + ui.text(0, y, "Head: ---") + y += st + + ui.text(0, y, "Track: %.3f km" % self.track.length_km) + y += st + + if gps.hdop is not None: + ui.text(0, y, "HDOP: %.1f" % gps.hdop) + y += st + + if gps.time_hms: + ui.text(0, y, "Time: %02d:%02d:%02d" % gps.time_hms) + y += st + #print("Final size: ", y) + + ui.update() + + def draw_page_sky(self): + gps = self.gps + ui = self.c + + ui.clear() + ui.text(0, 22, "Sky view") + + # Sky view circle + cx = 200 + cy = 110 + R = 90 + + ui.circle(cx, cy, R) + ui.circle(cx, cy, int(R * 0.66)) + ui.circle(cx, cy, int(R * 0.33)) + ui.line(cx - R, cy, cx + R, cy) + ui.line(cx, cy - R, cx, cy + R) + + # Plot satellites + # NMEA: elevation 0..90, azimuth 0..359 + # Map elevation: 90 at center, 0 at edge + count = 0 + for prn in gps.sats_in_view: + d = gps.sats_in_view[prn] + el = d.get("el") + az = d.get("az") + snr = d.get("snr") + + if el is None or az is None: + continue + + # radial distance + r = (90 - el) / 90.0 + r = clamp(r, 0.0, 1.0) * R + + a = deg_to_rad(az - 90) # rotate so 0 deg is up + x = int(cx + r * math.cos(a)) + y = int(cy + r * math.sin(a)) + + # Dot size from SNR + if snr is None: + rr = 1 + else: + rr = 1 + int(clamp(snr-10, 0, 15)) / 3 + + ui.fill_circle(x, y, rr) + count += 1 + + ui.text(0, cy + R - 35, "SV: %d" % count) + ui.update() + + def draw_page_nav(self): + gps = self.gps + ui = self.c + st = 28 + + ui.clear() + + draw_nav_screen(ui, self.gps, self.track.points, self.nav.lat, self.nav.lon) + + y = st + + if not self.nav.enabled: + ui.text(0, st, "No target.") + ui.update() + return + + if gps.has_fix(): + dist = haversine_km(gps.lat, gps.lon, self.nav.lat, self.nav.lon) + brg = bearing_deg(gps.lat, gps.lon, self.nav.lat, self.nav.lon) + + ui.text(0, y, "Dist: %.3f km" % dist) + ui.text(0, y+st, "Bear: %.0f deg" % brg) + + if gps.course_deg is not None: + rel = (brg - gps.course_deg + 360.0) % 360.0 + if rel > 180.0: + rel -= 360.0 + ui.text(0, y+2*st, "Turn: %+d deg" % int(rel)) + + else: + ui.text(0, y, "Waiting for fix...") + y += st*3 + + ui.text(0, y, "%s" % self.nav.name) + ui.text(0, y+st, "Lat: %.4f" % self.nav.lat) + ui.text(0, y+st*2, "Lon: %.4f" % self.nav.lon) + y += st*3 + + ui.update() + + def draw_page_record(self): + gps = self.gps + ui = self.c + + ui.clear() + + st = 28 + y = st + fix = "FIX" if gps.has_fix() else "NOFIX" + rec = "REC" if self.recording else "----" + if False: + ui.text(0, y, "%s %s sats:%d" % (fix, rec, gps.sats_used)) + y += st + ui.text(0, y, "%s" % gps.summary()) + y += 2*st + + if gps.speed_kmh is not None: + ui.text(0, y, "Speed: %.1f km/h" % gps.speed_kmh) + else: + ui.text(0, y, "Speed: ---") + y += st + + if gps.alt_m is not None: + ui.text(0, y, "Alt: %.1f m" % gps.alt_m) + else: + ui.text(0, y, "Alt: ---") + y += st + + ui.text(0, y, "Track: %.3f km" % self.track.length_km) + y += st + + #print("Final size: ", y) + + ui.update() + + def update(self): + self.maybe_update_track() + + def draw(self): + if self.page == 0: + self.draw_page_status() + elif self.page == 1: + self.draw_page_sky() + elif self.page == 2: + self.draw_page_nav() + elif self.page == 3: + self.draw_page_record() + else: + self.draw_page_example() + + def handle_buttons(self): + ui = self.c + + if ui.button_next_page(): + ui.page = (ui.page + 1) % ui.pages + + if ui.button_toggle_record(): + self.toggle_recording() + + if ui.button_set_nav_target(): + # Here we implement: "set target to current position" + # If you want manual entry, see note below. + self.set_nav_target_here() + + if ui.button_clear_track(): + self.clear_track() + +# ---------------------------- +# GPS hardware handling +# ---------------------------- + +TMP = "/tmp/cmd.json" + +def run_cmd_json(cmd): + rc = os.system(cmd + " > " + TMP) + if rc != 0: + raise RuntimeError("command failed") + + with open(TMP, "r") as f: + data = f.read().strip() + + return json.loads(data) + +def dbus_json(cmd): + return run_cmd_json("sudo /home/mobian/g/MicroPythonOS/phone.py " + cmd) + +class LocationManagerDBUS: + def poll(self): + v = dbus_json("loc") + print(v) + self.loc = v + + def get_cellid(self): + if "1" in self.loc: + return self.loc["1"] + return None + + def get_nmea(self): + if "4" in self.loc: + return self.loc["4"] + return None + + +class LocationManager: + def __init__(self): + path = "/dev/gnss0" + self.f = open(path, "rb") + self.sel = uselect.poll() + self.sel.register(self.f, uselect.POLLIN) + self.data = b"" + + def poll(self): + while True: + events = self.sel.poll(0) # non-blocking + if not events: + break + self.data += self.f.readline() + + def get_cellid(self): + return None + + def get_nmea(self): + d = self.data + print(d) + self.data = b"" + return d.decode("ascii", "ignore") + +# ---------------------------- +# Fake NMEA source +# ---------------------------- + +def nmea_checksum(sentence_body): + # sentence_body without leading '$' and without '*xx' + c = 0 + for ch in sentence_body: + c ^= ord(ch) + return "%02X" % c + + +def nmea_wrap(sentence_body): + return "$%s*%s" % (sentence_body, nmea_checksum(sentence_body)) + + +def deg_to_nmea_lat(lat_deg): + # ddmm.mmmm, N/S + sign = "N" + if lat_deg < 0: + sign = "S" + lat_deg = -lat_deg + + dd = int(lat_deg) + mm = (lat_deg - dd) * 60.0 + return "%02d%07.4f" % (dd, mm), sign + + +def deg_to_nmea_lon(lon_deg): + # dddmm.mmmm, E/W + sign = "E" + if lon_deg < 0: + sign = "W" + lon_deg = -lon_deg + + ddd = int(lon_deg) + mm = (lon_deg - ddd) * 60.0 + return "%03d%07.4f" % (ddd, mm), sign + + +class FakeNMEASpiral: + """ + Fake NMEA generator for testing. + + Simulates a spiral around a center coordinate: + center_lat=50.0, center_lon=14.0 + + Generates: + - GGA + - RMC + - GSV (fake sats) + + Usage: + sim = FakeNMEASpiral() + lines = sim.next_sentences() # list of NMEA lines (strings) + """ + + def __init__(self, + center_lat=50.0, + center_lon=14.0, + alt_m=260.0, + start_radius_m=0.0, + radius_growth_m_per_s=0.25, + angular_speed_deg_per_s=18.0, + speed_noise=0.05, + sat_count=10, + seed_time=None): + self.center_lat = float(center_lat) + self.center_lon = float(center_lon) + self.alt_m = float(alt_m) + + self.r0 = float(start_radius_m) + self.r_growth = float(radius_growth_m_per_s) + self.w_deg = float(angular_speed_deg_per_s) + + self.speed_noise = float(speed_noise) + + self.sat_count = int(sat_count) + self.sats = self._make_fake_sats(self.sat_count) + + if seed_time is None: + seed_time = time.time() + + self.t0 = float(seed_time) + self.last_t = self.t0 + + self.last_lat = self.center_lat + self.last_lon = self.center_lon + self.last_course = 0.0 + self.last_speed_mps = 0.0 + + # NMEA-ish fields + self.hdop = 0.9 + self.fix_quality = 1 # 1=GPS fix + self.num_sats = clamp(self.sat_count, 4, 12) + + def _make_fake_sats(self, n): + # PRN, elevation, azimuth, snr + sats = [] + for i in range(n): + prn = 1 + i + el = 15 + (i * 7) % 70 + az = (i * 360.0 / n) % 360.0 + snr = 20 + (i * 3) % 30 + sats.append((prn, el, az, snr)) + return sats + + def _spiral_position(self, t): + # t in seconds since t0 + dt = t - self.t0 + + r = self.r0 + self.r_growth * dt # meters + ang_deg = (self.w_deg * dt) % 360.0 + ang = math.radians(ang_deg) + + # local ENU offsets (east, north) in meters + east = r * math.cos(ang) + north = r * math.sin(ang) + + # convert meters -> degrees + lat = self.center_lat + (north / 111132.0) + lon = self.center_lon + (east / (111320.0 * math.cos(math.radians(self.center_lat)))) + + return lat, lon, r, ang_deg + + def _course_and_speed(self, lat, lon, dt): + # compute speed and course from last point (very simple) + if dt <= 0.0: + return self.last_course, self.last_speed_mps + + # local approx meters + phi = math.radians(self.center_lat) + m_per_deg_lat = 111132.0 + m_per_deg_lon = 111320.0 * math.cos(phi) + + dlat = (lat - self.last_lat) * m_per_deg_lat + dlon = (lon - self.last_lon) * m_per_deg_lon + + # north/east + north = dlat + east = dlon + + dist = math.sqrt(north * north + east * east) + speed = dist / dt + + # course: 0=north, 90=east + course = math.degrees(math.atan2(east, north)) % 360.0 + + # add tiny deterministic noise + speed *= (1.0 + self.speed_noise * math.sin((time.time() - self.t0) * 0.7)) + + return course, speed + + def _utc_hhmmss(self, t): + #dt = datetime.datetime.utcfromtimestamp(t) + #return dt.strftime("%H%M%S") + ".00" + return "123456.00" + + def _utc_ddmmyy(self, t): + #dt = datetime.datetime.utcfromtimestamp(t) + #return dt.strftime("%d%m%y") + return "311122" + + def next_sentences(self, t=None, include_gsv=True): + """ + Return list of NMEA sentences (strings). + """ + if t is None: + t = time.time() + + dt = t - self.last_t + lat, lon, r_m, ang_deg = self._spiral_position(t) + course, speed_mps = self._course_and_speed(lat, lon, dt) + + # update state + self.last_t = t + self.last_lat = lat + self.last_lon = lon + self.last_course = course + self.last_speed_mps = speed_mps + + # NMEA formatting + hhmmss = self._utc_hhmmss(t) + ddmmyy = self._utc_ddmmyy(t) + + lat_s, lat_hemi = deg_to_nmea_lat(lat) + lon_s, lon_hemi = deg_to_nmea_lon(lon) + + speed_knots = speed_mps * 1.94384449 + + # --- GGA + # $GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47 + gga_body = "GPGGA,%s,%s,%s,%s,%s,%d,%02d,%.1f,%.1f,M,0.0,M,," % ( + hhmmss, + lat_s, lat_hemi, + lon_s, lon_hemi, + self.fix_quality, + self.num_sats, + self.hdop, + self.alt_m, + ) + + # --- RMC + # $GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A + # We omit magnetic variation field -> empty. + rmc_body = "GPRMC,%s,A,%s,%s,%s,%s,%.2f,%.1f,%s,," % ( + hhmmss, + lat_s, lat_hemi, + lon_s, lon_hemi, + speed_knots, + course, + ddmmyy, + ) + + out = [ + nmea_wrap(gga_body), + nmea_wrap(rmc_body), + ] + + if include_gsv: + out.extend(self._gsv_sentences()) + + return out + + def poll(self): + self.data = '\n'.join(self.next_sentences()) + + def get_cellid(self): + return None + + def get_nmea(self): + return self.data + + def _gsv_sentences(self): + # GSV: 4 sats per message + sats = self.sats + total = len(sats) + per = 4 + msgs = (total + per - 1) // per + out = [] + + for mi in range(msgs): + chunk = sats[mi * per:(mi + 1) * per] + fields = ["GPGSV", str(msgs), str(mi + 1), str(total)] + for (prn, el, az, snr) in chunk: + fields.extend([ + "%02d" % prn, + "%02d" % int(el), + "%03d" % int(az), + "%02d" % int(snr), + ]) + body = ",".join(fields) + out.append(nmea_wrap(body)) + + return out + +# ----------------------------- +# Helpers +# ----------------------------- + +def norm_deg(d): + # normalize to 0..360 + d = d % 360.0 + if d < 0: + d += 360.0 + return d + + +def bearing_deg(lat1, lon1, lat2, lon2): + # initial bearing (true) in degrees, 0..360 + # Inputs in degrees. + phi1 = deg_to_rad(lat1) + phi2 = deg_to_rad(lat2) + dlon = deg_to_rad(lon2 - lon1) + + y = math.sin(dlon) * math.cos(phi2) + x = math.cos(phi1) * math.sin(phi2) - math.sin(phi1) * math.cos(phi2) * math.cos(dlon) + brng = rad_to_deg(math.atan2(y, x)) + return norm_deg(brng) + + +def haversine_m(lat1, lon1, lat2, lon2): + return haversine_km(lat1, lon1, lat2, lon2) * 1000 + + +def meters_to_human(m): + if m is None: + return "?" + if m < 1000.0: + return "%dm" % int(m + 0.5) + return "%.2fkm" % (m / 1000.0) + + +def ms_to_kmh(v): + if v is None: + return None + return v * 3.6 + + +def kmh_to_human(kmh): + if kmh is None: + return "?" + if kmh < 10: + return "%.1f km/h" % kmh + return "%.0f km/h" % kmh + + +def draw_arrow(ui, x0, y0, x1, y1, head_len=14, head_ang_deg=28): + # main shaft + ui.line(x0, y0, x1, y1) + + # arrow head + ang = math.atan2(y1 - y0, x1 - x0) + ha = deg_to_rad(head_ang_deg) + + xh1 = int(x1 - head_len * math.cos(ang - ha)) + yh1 = int(y1 - head_len * math.sin(ang - ha)) + + xh2 = int(x1 - head_len * math.cos(ang + ha)) + yh2 = int(y1 - head_len * math.sin(ang + ha)) + + ui.line(x1, y1, xh1, yh1) + ui.line(x1, y1, xh2, yh2) + + +def polar_to_xy(cx, cy, r, angle_deg): + # angle_deg: 0 is up, 90 is right (screen coords) + a = deg_to_rad(angle_deg - 90.0) + x = int(cx + r * math.cos(a)) + y = int(cy + r * math.sin(a)) + return x, y + + +# ----------------------------- +# Main draw routine +# ----------------------------- + +def draw_nav_screen(ui, gps, trail, + dest_lat, dest_lon, + mag_declination_deg=None): + """ + Expected gps fields (typical gpsd-ish): + gps.lat, gps.lon + gps.speed_ms (or gps.speed) + gps.track_deg (COG, or gps.track) + gps.fix_ok (bool) + + trail: list of dicts: {"lat":..., "lon":...} newest last + mag_declination_deg: + If known for your region (e.g. Prague ~ 4-5 deg E in 2025-ish), + pass it here. If unknown, pass None and M will not be drawn. + """ + + # --- Geometry + cx = 200 + cy = 110 + R = 90 + + # --- Draw compass rose + ui.circle(cx, cy, R) + ui.line(cx - R, cy, cx + R, cy) + ui.line(cx, cy - R, cx, cy + R) + + # --- Require a fix + if not getattr(gps, "fix_ok", True): + ui.text(0, 440, "No GPS fix") + return + + lat = getattr(gps, "lat", None) + lon = getattr(gps, "lon", None) + + if lat is None or lon is None: + ui.text(0, 44, "No position") + return + + # --- Course over ground: defines "UP" + cog = getattr(gps, "course_deg", None) + #print("Lat, lon", lat, lon, "Cog", cog, "trail", trail) + + # If no course, assume north-up + if cog is None: + cog = 0.0 + + cog = norm_deg(cog) + + # --- Destination bearing and distance + brng_true = bearing_deg(lat, lon, dest_lat, dest_lon) + dist_m = haversine_m(lat, lon, dest_lat, dest_lon) + + # Arrow angle relative to UP=COG: + # If destination is straight ahead, arrow points up. + rel = norm_deg(brng_true - cog) + # Convert to signed -180..180 for nicer behavior (optional) + if rel > 180.0: + rel -= 360.0 + + # --- Draw destination arrow + # Use a fixed length so it is always visible + arrow_len = int(R * 0.85) + x_tip, y_tip = polar_to_xy(cx, cy, arrow_len, rel) + draw_arrow(ui, cx, cy, x_tip, y_tip, head_len=16, head_ang_deg=30) + + # --- Mark TRUE NORTH on the ring + # True north is bearing 0°, relative to UP=COG => angle = 0 - COG + ang_true_n = norm_deg(0.0 - cog) + xn, yn = polar_to_xy(cx, cy, R, ang_true_n) + ui.text(xn - 6, yn - 8, "N") + + # --- Mark MAGNETIC NORTH on the ring (if declination known) + # Magnetic bearing = true - declination(E positive) + # Magnetic north direction in true coords = -declination + if mag_declination_deg is not None: + ang_mag_n = norm_deg((-mag_declination_deg) - cog) + xm, ym = polar_to_xy(cx, cy, R, ang_mag_n) + ui.text(xm - 6, ym - 8, "M") + + # --- Draw trail of last fixes + # Project lat/lon into local meters (simple equirectangular) + # and rotate so UP is COG. + if trail and len(trail) >= 2: + lat0 = lat + lon0 = lon + phi = deg_to_rad(lat0) + + # meters per degree + m_per_deg_lat = 111132.0 + m_per_deg_lon = 111320.0 * math.cos(phi) + + # max range shown in trail radius + # (you can tune this) + trail_range_m = 80.0 + + # rotate by -COG so direction of travel is up + rot = deg_to_rad(cog) + + prev_xy = None + for p in trail[-12:]: + plat, plon = p + if plat is None or plon is None: + continue + + dx = (plon - lon0) * m_per_deg_lon + dy = (plat - lat0) * m_per_deg_lat + + # rotate into screen coords + rx = dx * math.cos(rot) - dy * math.sin(rot) + ry = dx * math.sin(rot) + dy * math.cos(rot) + + # Map meters -> pixels + sx = int(cx + (rx / trail_range_m) * (R * 0.95)) + sy = int(cy - (ry / trail_range_m) * (R * 0.95)) + + # clamp to circle-ish bounds + sx = clamp(sx, cx - R + 2, cx + R - 2) + sy = clamp(sy, cy - R + 2, cy + R - 2) + + # draw point (small cross) + ui.line(sx - 1, sy, sx + 1, sy) + ui.line(sx, sy - 1, sx, sy + 1) + + if prev_xy is not None: + ui.line(prev_xy[0], prev_xy[1], sx, sy) + + prev_xy = (sx, sy) + + # --- Text info + speed_ms = getattr(gps, "speed_ms", None) + if speed_ms is None: + speed_ms = getattr(gps, "speed", None) + + speed_kmh = ms_to_kmh(speed_ms) + + ui.text(0, 290, "Dist: " + meters_to_human(dist_m)) + ui.text(0, 312, "Speed: " + kmh_to_human(speed_kmh)) + + # Optional: show bearing numbers + ui.text(0, 334, "COG: %d deg" % int(cog + 0.5)) + ui.text(0, 356, "BRG: %d deg" % int(brng_true + 0.5)) + + +# ------------------------------------------------- +# Position parsing +# ------------------------------------------------- + +def parse_position(text): + """ + Flexible coordinate parser. + + Supports: + N 50 30.123 E 14 13.231 + 50.1234N 14.2345E + -14.2345 50.1234 + 50°30'12"N 14°13'20"E + 14 13 20 E 50 30 12 N + """ + + def split_compass(s): + result = [] + token = "" + + for c in s: + if c in "NSEWnsew": + if token.strip(): + result.append(token.strip()) + result.append(c) + token = "" + else: + token += c + + if token.strip(): + result.append(token.strip()) + + return result + + def normalize(s): + s = s.strip() + s = s.replace("°", " ") + s = s.replace("'", " ") + s = s.replace('"', " ") + s = re.sub(r"\s+", " ", s) + return s + + def extract_numbers(s): + nums = [] + buf = "" + + for c in s: + if c in "+-.0123456789": + buf += c + else: + if buf: + nums.append(buf) + buf = "" + if buf: + nums.append(buf) + + return nums + + def parse_one(part): + # Extract direction if present + dir_match = re.search(r"[NSEWnsew]", part) + direction = None + if dir_match: + direction = dir_match.group(0).upper() + part = re.sub(r"[NSEWnsew]", "", part) + + nums = extract_numbers(part) + if not nums: + return 0, "-", "No numeric data" + + nums = [float(x) for x in nums] + + # dd.dddd + if len(nums) == 1: + value = nums[0] + + # dd mm.mmm + elif len(nums) == 2: + deg, minutes = nums + value = abs(deg) + minutes / 60.0 + if deg < 0: + value = -value + + # dd mm ss + else: + deg, minutes, seconds = nums[:3] + value = abs(deg) + minutes / 60.0 + seconds / 3600.0 + if deg < 0: + value = -value + + if direction: + if direction in ("S", "W"): + value = -abs(value) + else: + value = abs(value) + + return value, direction, None + + text = normalize(text) + + # Try splitting into two coordinate parts + # Strategy: split around direction letters if possible + parts = split_compass(text) + + coords = [] + + for part in parts: + part = part.strip() + if not part: + continue + + value, direction, comment = parse_one(part) + if not comment: + coords.append((value, direction, comment)) + + # If we didn’t get two parts, fallback: split in half + if len(coords) != 2: + tokens = text.split(" ") + mid = len(tokens) // 2 + left = " ".join(tokens[:mid]) + right = " ".join(tokens[mid:]) + coords = [ + parse_one(left), + parse_one(right), + ] + + if len(coords) != 2: + return 0, 0, "Could not parse two coordinates" + + print("coords = ", coords) + + lat = None + lon = None + + for value, direction, comment in coords: + if direction in ("N", "S"): + lat = value + elif direction in ("E", "W"): + lon = value + + # If directions missing, assume first = lat, second = lon + if lat is None or lon is None: + lat = coords[0][0] + lon = coords[1][0] + + if abs(lat) > 90 or abs(lon) > 180: + return 0, 0, "Coordinate out of range" + + return lat, lon, "User input" + + +# ------------------------------------------------- +# Enter Target dialog +# ------------------------------------------------- + +class EnterTarget(Activity): + def __init__(self): + super().__init__() + + def onCreate(self): + self.scr = lv.obj() + + # Position input + self.pos_ta = lv.textarea(self.scr) + self.pos_ta.set_size(300, 40) + self.pos_ta.align(lv.ALIGN.TOP_MID, 0, 18) + self.pos_ta.set_placeholder_text("N 50 30.123 E 14 13.231") + + title = lv.label(self.scr) + title.set_text("Goto position") + title.align_to(self.pos_ta, lv.ALIGN.OUT_BOTTOM_MID, 0, 10) + + if False: + # Filename input + self.file_ta = lv.textarea(self.scr) + self.file_ta.set_size(300, 40) + self.file_ta.align(lv.ALIGN.TOP_MID, 0, 10) + self.file_ta.set_placeholder_text("track.txt") + + # Record checkbox + self.record_cb = lv.checkbox(self.scr) + self.record_cb.set_text("Record track") + self.record_cb.align_to(title, lv.ALIGN.OUT_BOTTOM_MID, 0, 10) + + if False: + # Status label + self.status = lv.label(self.scr) + self.status.set_text("") + self.status.align(lv.ALIGN.TOP_MID, 0, 10) + + # Apply button + apply_btn = lv.button(self.scr) + apply_btn.set_size(120, 50) + apply_btn.align(lv.ALIGN.BOTTOM_RIGHT, -20, -5) + apply_btn.add_event_cb(self.on_apply, lv.EVENT.CLICKED, None) + + lbl_apply = lv.label(apply_btn) + lbl_apply.set_text("Apply") + lbl_apply.center() + + # Back button + back_btn = lv.button(self.scr) + back_btn.set_size(120, 50) + back_btn.align(lv.ALIGN.BOTTOM_LEFT, 20, -5) + back_btn.add_event_cb(self.on_back, lv.EVENT.CLICKED, None) + + lbl_back = lv.label(back_btn) + lbl_back.set_text("Back") + lbl_back.center() + + keyboard = MposKeyboard(self.scr) + keyboard.set_textarea(self.pos_ta) + + self.setContentView(self.scr) + + def onResume(self, screen): + pass + + def on_apply(self, e): + pos_text = self.pos_ta.get_text() + if False: + file_text = self.file_ta.get_text() + config.recording = self.record_cb.get_state() & lv.STATE.CHECKED + + config.lat, config.lon, config.name = parse_position(pos_text) + + self.finish() + + def on_back(self, e): + self.finish() + + def load(self): + lv.scr_load(self.scr) + +if False: + print(parse_position("50 N 10 E")) + print(parse_position("N 50 30.000 E 10 15.000")) + print(parse_position("50.123 N 12.345 E")) + # FIXME: S/W does not really work. + print(parse_position("50 S 10 W")) + print(parse_position("52.345 12.345")) + print() + print() + print() + os.exit(1) + +if False: + lm = LocationManager() +elif False: + lm = LocationManagerDBUS() +else: + lm = FakeNMEASpiral(center_lat=50.0, center_lon=14.0) diff --git a/internal_filesystem/apps/cz.ucw.pavel.navstar/assets/pcanvas.py b/internal_filesystem/apps/cz.ucw.pavel.navstar/assets/pcanvas.py new file mode 100644 index 00000000..41f758ad --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.navstar/assets/pcanvas.py @@ -0,0 +1,306 @@ +import lvgl as lv +import mpos +from mpos import Activity, MposKeyboard + + +# ----------------------------- +# Canvas (LVGL) +# ----------------------------- + +class Canvas: + """ + LVGL canvas + layer drawing Canvas. + + This matches ports where: + - lv.canvas has init_layer() / finish_layer() + - primitives are drawn via lv.draw_* into lv.layer_t + """ + + def __init__(self, scr, canvas): + self.scr = scr + + # Screen size + self.W = scr.get_width() + self.H = scr.get_height() + + # Bottom button bar + self.margin = 2 + self.bar_h = 39 + + # Canvas drawing area (everything above button bar) + self.draw_w = self.W + self.draw_h = self.H - (self.bar_h + self.margin * 2) + + self.canvas = canvas + + # Background: white (change if you want dark theme) + self.canvas.set_style_bg_color(lv.color_white(), lv.PART.MAIN) + + # Buffer: your working example uses 4 bytes/pixel + # Reality filter: this depends on LV_COLOR_DEPTH; but your example proves it works. + self.buf = bytearray(self.draw_w * self.draw_h * 4) + self.canvas.set_buffer(self.buf, self.draw_w, self.draw_h, lv.COLOR_FORMAT.NATIVE) + + # Layer used for draw engine + self.layer = lv.layer_t() + self.canvas.init_layer(self.layer) + + # Persistent draw descriptors (avoid allocations) + self._line_dsc = lv.draw_line_dsc_t() + lv.draw_line_dsc_t.init(self._line_dsc) + self._line_dsc.width = 1 + self._line_dsc.color = lv.color_black() + self._line_dsc.round_end = 1 + self._line_dsc.round_start = 1 + + self._label_dsc = lv.draw_label_dsc_t() + lv.draw_label_dsc_t.init(self._label_dsc) + self._label_dsc.color = lv.color_black() + self._label_dsc.font = lv.font_montserrat_24 + + self._rect_dsc = lv.draw_rect_dsc_t() + lv.draw_rect_dsc_t.init(self._rect_dsc) + self._rect_dsc.bg_opa = lv.OPA.TRANSP + self._rect_dsc.border_opa = lv.OPA.COVER + self._rect_dsc.border_width = 1 + self._rect_dsc.border_color = lv.color_black() + + self._fill_dsc = lv.draw_rect_dsc_t() + lv.draw_rect_dsc_t.init(self._fill_dsc) + self._fill_dsc.bg_opa = lv.OPA.COVER + self._fill_dsc.bg_color = lv.color_black() + self._fill_dsc.border_width = 1 + + # Clear once + self.clear() + + # ---------------------------- + # Layer lifecycle + # ---------------------------- + + def _begin(self): + # Start drawing into the layer + self.canvas.init_layer(self.layer) + + def _end(self): + # Commit drawing + self.canvas.finish_layer(self.layer) + + # ---------------------------- + # Public API: drawing + # ---------------------------- + + def clear(self): + # Clear the canvas background + self.canvas.fill_bg(lv.color_white(), lv.OPA.COVER) + + def text(self, x, y, s, fg = lv.color_black()): + self._begin() + + dsc = lv.draw_label_dsc_t() + lv.draw_label_dsc_t.init(dsc) + dsc.text = str(s) + dsc.font = lv.font_montserrat_24 + dsc.color = lv.color_black() + + area = lv.area_t() + area.x1 = x + area.y1 = y + area.x2 = x + self.W + area.y2 = y + self.H + + lv.draw_label(self.layer, dsc, area) + + self._end() + + def line(self, x1, y1, x2, y2, fg = lv.color_black()): + self._begin() + + dsc = self._line_dsc + dsc.p1 = lv.point_precise_t() + dsc.p2 = lv.point_precise_t() + dsc.p1.x = int(x1) + dsc.p1.y = int(y1) + dsc.p2.x = int(x2) + dsc.p2.y = int(y2) + + lv.draw_line(self.layer, dsc) + + self._end() + + def circle(self, x, y, r, fg = lv.color_black()): + # Rounded rectangle trick (works everywhere) + self._begin() + + a = lv.area_t() + a.x1 = int(x - r) + a.y1 = int(y - r) + a.x2 = int(x + r) + a.y2 = int(y + r) + + dsc = self._rect_dsc + dsc.radius = lv.RADIUS_CIRCLE + dsc.border_color = fg + + lv.draw_rect(self.layer, dsc, a) + + self._end() + + def fill_circle(self, x, y, r, fg = lv.color_black(), bg = lv.color_white()): + self._begin() + + a = lv.area_t() + a.x1 = int(x - r) + a.y1 = int(y - r) + a.x2 = int(x + r) + a.y2 = int(y + r) + + dsc = self._rect_dsc + dsc.radius = lv.RADIUS_CIRCLE + dsc.border_color = fg + dsc.bg_color = bg + + lv.draw_rect(self.layer, dsc, a) + + self._end() + + def fill_rect(self, x, y, sx, sy, fg = lv.color_black(), bg = lv.color_white()): + self._begin() + + a = lv.area_t() + a.x1 = x + a.y1 = y + a.x2 = x+sx + a.y2 = y+sy + + dsc = self._fill_dsc + dsc.border_color = fg + dsc.bg_color = bg + + lv.draw_rect(self.layer, dsc, a) + + self._end() + + def update(self): + # Nothing needed; drawing is committed per primitive. + # If you want, you can change the implementation so that: + # - draw ops happen between clear() and update() + # But then you must ensure the app calls update() once per frame. + pass + +# ---------------------------- +# App logic +# ---------------------------- + +class PagedCanvas(Activity): + def __init__(self): + super().__init__() + self.page = 0 + self.pages = 3 + + def onCreate(self): + self.scr = lv.obj() + scr = self.scr + + # Screen size + self.W = scr.get_width() + self.H = scr.get_height() + + # Bottom button bar + self.margin = 2 + self.bar_h = 39 + + # Canvas drawing area (everything above button bar) + self.draw_w = self.W + self.draw_h = self.H - (self.bar_h + self.margin * 2) + + # Canvas + self.canvas = lv.canvas(self.scr) + self.canvas.set_size(self.draw_w, self.draw_h) + self.canvas.align(lv.ALIGN.TOP_LEFT, 0, 0) + self.canvas.set_style_border_width(0, 0) + + self.c = Canvas(self.scr, self.canvas) + + # Build buttons + self.build_buttons() + self.setContentView(self.c.scr) + + # ---------------------------- + # Button bar + # ---------------------------- + + def _make_btn(self, parent, x, y, w, h, label): + b = lv.button(parent) + b.set_pos(x, y) + b.set_size(w, h) + + l = lv.label(b) + l.set_text(label) + l.center() + + return b + + def _btn_cb(self, evt, tag): + self.page = tag + + def template_buttons(self, names): + margin = self.margin + y = self.H - self.bar_h - margin + + num = len(names) + if num == 0: + self.buttons = [] + return + + w = (self.W - margin * (num + 1)) // num + h = self.bar_h + x0 = margin + + self.buttons = [] + + for i, label in enumerate(names): + x = x0 + (w + margin) * i + btn = self._make_btn(self.scr, x, y, w, h, label) + + # capture index correctly + btn.add_event_cb( + lambda evt, idx=i: self._btn_cb(evt, idx), + lv.EVENT.CLICKED, + None + ) + + self.buttons.append(btn) + + def build_buttons(self): + self.template_buttons(["Pg0", "Pg1", "Pg2", "Pg3", "..."]) + + def onResume(self, screen): + self.timer = lv.timer_create(self.tick, 1000, None) + + def onPause(self, screen): + if self.timer: + self.timer.delete() + self.timer = None + + def tick(self, t): + self.update() + self.draw() + + def update(self): + pass + + def draw_page_example(self): + ui = self.c + ui.clear() + + st = 28 + y = 2*st + ui.text(0, y, "Hello world, page is %d" % self.page) + y += st + + def draw(self): + self.draw_page_example() + + def handle_buttons(self): + ui = self.c diff --git a/internal_filesystem/apps/cz.ucw.pavel.navstar/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/apps/cz.ucw.pavel.navstar/res/mipmap-mdpi/icon_64x64.png new file mode 100644 index 00000000..88d6d074 Binary files /dev/null and b/internal_filesystem/apps/cz.ucw.pavel.navstar/res/mipmap-mdpi/icon_64x64.png differ diff --git a/internal_filesystem/apps/cz.ucw.pavel.weather/META-INF/MANIFEST.JSON b/internal_filesystem/apps/cz.ucw.pavel.weather/META-INF/MANIFEST.JSON new file mode 100644 index 00000000..53b80338 --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.weather/META-INF/MANIFEST.JSON @@ -0,0 +1,24 @@ +{ +"name": "Weather", +"publisher": "Pavel Machek", +"short_description": "Display weather information.", +"long_description": "This displays weather information from open-meteo.com.", +"icon_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.weather/icons/cz.ucw.pavel.weather_0.0.1_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.weather/mpks/cz.ucw.pavel.weather_0.0.1.mpk", +"fullname": "cz.ucw.pavel.weather", +"version": "0.0.1", +"category": "utilities", +"activities": [ + { + "entrypoint": "assets/main.py", + "classname": "Main", + "intent_filters": [ + { + "action": "main", + "category": "launcher" + } + ] + } + ] +} + diff --git a/internal_filesystem/apps/cz.ucw.pavel.weather/assets/main.py b/internal_filesystem/apps/cz.ucw.pavel.weather/assets/main.py new file mode 100644 index 00000000..b8189327 --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.weather/assets/main.py @@ -0,0 +1,414 @@ +from mpos import Activity + +""" +Look at https://open-meteo.com/en/docs , then design an application that would display current time and weather, and summary of forecast ("no change expected for 2 days" or maybe "rain in 5 hours"), with a way to access detailed forecast. +""" + +import time +import os + +try: + import lvgl as lv +except ImportError: + pass + +from mpos import Activity, MposKeyboard, DownloadManager + +import ujson +import utime +import usocket as socket +import ujson + +# ----------------------------- +# WEATHER DATA MODEL +# ----------------------------- + +class WData: + WMO_CODES = { + 0: "Clear sky", + 1: "Mainly clear", + 2: "Partly cloudy", + 3: "Overcast", + 45: "Fog", + 48: "Rime fog", + 51: "Light drizzle", + 53: "Drizzle", + 55: "Heavy drizzle", + 56: "Freezing drizzle", + 57: "Freezing drizzle", + 61: "Light rain", + 63: "Rain", + 65: "Heavy rain", + 66: "Freezing rain", + 67: "Freezing rain", + 71: "Light snow", + 73: "Snow", + 75: "Heavy snow", + 77: "Snow grains", + 80: "Rain showers", + 81: "Rain showers", + 82: "Heavy rain showers", + 85: "Snow showers", + 86: "Heavy snow showers", + 95: "Thunderstorm", + 96: "Thunderstorm + hail", + 99: "Thunderstorm + hail", + } + + def init(self): + pass + + def code_to_text(self, code): + return self.WMO_CODES.get(int(code), "Unknown") + + def get(self, v, cw, ind): + if ind == None: + return cw[v] + else: + return cw[v][ind] + + def full(self): + return f"{self.code}\nTemp {self.temp:.1f} dew {self.dew:.1f} pres {self.pres:1f}\n" \ + f"Precip {self.precip}\nWind {self.wind} gust {self.gust}" + + def short(self): + r = f"{self.code} {self.temp:.1f}°C" + if self.dew + 3 > self.temp: + r += f" dew {self.dew:.1f}°C" + if self.gust > self.wind + 5: + r += f" {self.gust:.0f} g" + elif self.wind > 10: + r += f" {self.wind:.0f} w" + # FIXME: add precip + return r + + def similar(self, prev): + if self.code != prev.code: + return False + if abs(self.temp - prev.temp) > 3: + return False + if abs(self.wind - prev.wind) > 10: + return False + if abs(self.gust - prev.gust) > 10: + return False + return True + + def summarize(self): + return self.ftime() + self.short() + +class Hourly(WData): + def init(self, cw, ind): + super().init() + self.time = None + self.temp = self.get("temperature_2m", cw, ind) + self.dew = self.get("dewpoint_2m", cw, ind) + self.pres = self.get("pressure_msl", cw, ind) + self.precip = self.get("precipitation", cw, ind) + self.wind = self.get("wind_speed_10m", cw, ind) + self.gust = self.get("wind_gusts_10m", cw, ind) + self.raw_code = self.get("weather_code", cw, ind) + self.code = self.code_to_text(self.raw_code) + + def ftime(self): + if self.time: + return self.time[11:13] + "h " + return "" + +class Daily(WData): + def init(self, cw, ind): + super().init() + self.temp = self.get("temperature_2m_max", cw, ind) + self.temp_min = self.get("temperature_2m_min", cw, ind) + self.dew = self.get("dewpoint_2m_max", cw, ind) + self.dew_min = self.get("dewpoint_2m_min", cw, ind) + self.pres = None + self.precip = self.get("precipitation_sum", cw, ind) + self.wind = self.get("wind_speed_10m_max", cw, ind) + self.gust = self.get("wind_gusts_10m_max", cw, ind) + self.raw_code = self.get("weather_code", cw, ind) + self.code = self.code_to_text(self.raw_code) + + def ftime(self): + return self.time[8:10] + ". " + +class Weather: + name = "Prague" + # LKPR airport + lat = 50 + 6/60. + lon = 14 + 15/60. + + def __init__(self): + self.now = None + self.hourly = [] + self.daily = [] + self.summary = "(no weather)" + + def fetch(self): + self.summary = "...fetching..." + + # See https://open-meteo.com/en/docs?forecast_days=1¤t=relative_humidity_2m + + host = "api.open-meteo.com" + path = ( + "/v1/forecast?" + "latitude={}&longitude={}" + "¤t=temperature_2m,dewpoint_2m,pressure_msl,precipitation,weather_code,wind_speed_10m,wind_gusts_10m" + "&forecast_hours=8" + "&hourly=temperature_2m,dewpoint_2m,pressure_msl,precipitation,weather_code,wind_speed_10m,wind_gusts_10m" + "&forecast_days=10" + "&daily=temperature_2m_max,temperature_2m_min,dewpoint_2m_min,dewpoint_2m_max,pressure_msl_min,pressure_msl_max,precipitation_sum,weather_code,wind_speed_10m_max,wind_gusts_10m_max" + "&timezone=auto" + ).format(self.lat, self.lon) + + print("Weather fetch: ", path) + data = DownloadManager.download_url("https://"+host+path) + if not data: + self.summary = "Download error" + return + + #print("Have result:", body.decode()) + + # Parse JSON + data = ujson.loads(data) + + # ---- Extract data ---- + print("\n\n") + + s = "" + + print("---- ") + cw = data["current"] + self.now = Hourly() + self.now.init(cw, None) + prev = self.now + t = self.now.summarize() + s += t + "\n" + print(t) + + self.hourly = [] + d = data["hourly"] + times = d["time"] + #print(d) + + print("---- ") + for i in range(len(times)): + h = Hourly() + h.init(d, i) + h.time = times[i] + self.hourly.append(h) + if not h.similar(prev): + t = h.summarize() + s += t + "\n" + print(t) + prev = h + + self.daily = [] + d = data["daily"] + times = d["time"] + #print(d) + + print("---- ") + for i in range(len(times)): + h = Daily() + h.init(d, i) + h.time = times[i] + self.daily.append(h) + if i == 0: + prev = h + elif not h.similar(prev): + t = h.summarize() + s += t + "\n" + print(t) + prev = h + + + self.summary = s + + def summarize_future(): + now = utime.time() + + # Rain detection in next 24h + for h in weather.hourly[:24]: + if h["precip"] >= 1.0: + return "Rain soon" + + # Temperature trend + if len(weather.hourly) > 24: + t0 = weather.hourly[0]["temp"] + t24 = weather.hourly[24]["temp"] + if abs(t24 - t0) < 2: + return "No change expected" + if t24 > t0: + return "Getting warmer" + else: + return "Getting cooler" + + return "Stable weather" + + +weather = Weather() + +# ------------------------------------------------------------ +# Main activity +# ------------------------------------------------------------ + +class Main(Activity): + def __init__(self): + self.last_hour = 0 + super().__init__() + + # -------------------- + + def onCreate(self): + self.screen = lv.obj() + #self.screen.remove_flag(lv.obj.FLAG.SCROLLABLE) + scr_main = self.screen + + # ---- MAIN SCREEN ---- + + label_weather = lv.label(scr_main) + label_weather.set_text(f"{weather.name} ({weather.lat}, {weather.lon})") + label_weather.align(lv.ALIGN.TOP_LEFT, 10, 24) + label_weather.set_style_text_font(lv.font_montserrat_14, 0) + self.label_weather = label_weather + + btn_hourly = lv.button(scr_main) + btn_hourly.align(lv.ALIGN.TOP_RIGHT, -5, 24) + lv.label(btn_hourly).set_text("Reload") + btn_hourly.add_event_cb(lambda x: self.do_load(), lv.EVENT.CLICKED, None) + + label_time = lv.label(scr_main) + label_time.set_text("(time)") + label_time.align_to(btn_hourly, lv.ALIGN.TOP_LEFT, -85, -10) + label_time.set_style_text_font(lv.font_montserrat_24, 0) + self.label_time = label_time + + label_summary = lv.label(scr_main) + label_summary.set_text("(weather)") + #label_summary.set_long_mode(lv.label.LONG.WRAP) + #label_summary.set_width(300) + label_summary.align_to(label_weather, lv.ALIGN.OUT_BOTTOM_LEFT, 0, 5) + label_summary.set_style_text_font(lv.font_montserrat_24, 0) + self.label_summary = label_summary + + + if False: + btn_daily = lv.button(scr_main) + btn_daily.set_size(100, 40) + btn_daily.align(lv.ALIGN.BOTTOM_RIGHT, -10, -10) + lv.label(btn_daily).set_text("Daily") + + + self.setContentView(self.screen) + + def onResume(self, screen): + self.timer = lv.timer_create(self.tick, 15000, None) + self.tick(0) + + def onPause(self, screen): + if self.timer: + self.timer.delete() + self.timer = None + + # -------------------- + + def tick(self, t): + now = time.localtime() + y, m, d = now[0], now[1], now[2] + hh, mm, ss = now[3], now[4], now[5] + + if hh != self.last_hour: + self.last_hour = hh + self.do_load() + + self.label_time.set_text("%02d:%02d" % (hh, mm)) + self.label_summary.set_text(weather.summary) + + def do_load(self): + self.label_summary.set_text("Requesting...") + weather.fetch() + + # -------------------- + + def code(): + # ----------------------------- + # LVGL UI + # ----------------------------- + + scr_main = lv.obj() + scr_hourly = lv.obj() + scr_daily = lv.obj() + + + # ---- HOURLY SCREEN ---- + + hourly_list = lv.list(scr_hourly) + hourly_list.set_size(320, 200) + hourly_list.align(lv.ALIGN.TOP_MID, 0, 10) + + btn_back1 = lv.button(scr_hourly) + btn_back1.set_size(80, 30) + btn_back1.align(lv.ALIGN.BOTTOM_MID, 0, -5) + lv.label(btn_back1).set_text("Back") + + # ---- DAILY SCREEN ---- + + daily_list = lv.list(scr_daily) + daily_list.set_size(320, 200) + daily_list.align(lv.ALIGN.TOP_MID, 0, 10) + + btn_back2 = lv.button(scr_daily) + btn_back2.set_size(80, 30) + btn_back2.align(lv.ALIGN.BOTTOM_MID, 0, -5) + lv.label(btn_back2).set_text("Back") + + def foo(): + btn_hourly.add_event_cb(go_hourly, lv.EVENT.CLICKED, None) + btn_daily.add_event_cb(go_daily, lv.EVENT.CLICKED, None) + btn_back1.add_event_cb(go_back, lv.EVENT.CLICKED, None) + btn_back2.add_event_cb(go_back, lv.EVENT.CLICKED, None) + + # ----------------------------- + # STARTUP + # ----------------------------- + + def go_hourly(e): + populate_hourly() + lv.scr_load(scr_hourly) + + def go_daily(e): + populate_daily() + lv.scr_load(scr_daily) + + def go_back(e): + lv.scr_load(scr_main) + + def update_ui(): + if weather.current_temp is not None: + text = "%s %.1f C" % ( + weather_code_to_text(weather.current_code), + weather.current_temp + ) + label_weather.set_text(text) + + label_summary.set_text(weather.summary) + + def populate_hourly(): + hourly_list.clean() + for h in weather.hourly[:24]: + line = "%s %.1fC %.1fmm" % ( + h["time"][11:16], + h["temp"], + h["precip"] + ) + hourly_list.add_text(line) + + def populate_daily(): + daily_list.clean() + for d in weather.daily: + line = "%s %.1f/%.1f" % ( + d["date"], + d["high"], + d["low"] + ) + daily_list.add_text(line) diff --git a/internal_filesystem/apps/cz.ucw.pavel.weather/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/apps/cz.ucw.pavel.weather/res/mipmap-mdpi/icon_64x64.png new file mode 100644 index 00000000..87df6b62 Binary files /dev/null and b/internal_filesystem/apps/cz.ucw.pavel.weather/res/mipmap-mdpi/icon_64x64.png differ 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 457f3494..cd8a111f 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.6_64x64.png", -"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.about/mpks/com.micropythonos.about_0.0.6.mpk", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.about/icons/com.micropythonos.about_0.1.2_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.about/mpks/com.micropythonos.about_0.1.2.mpk", "fullname": "com.micropythonos.about", -"version": "0.0.6", +"version": "0.1.2", "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 00c9767e..e83ea73a 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.about/assets/about.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.about/assets/about.py @@ -1,80 +1,135 @@ -from mpos.apps import Activity - -import mpos.info import sys +import logging + +from mpos import Activity, DisplayMetrics, BuildInfo, DeviceInfo class About(Activity): + logger = logging.getLogger(__file__) + logger.setLevel(logging.INFO) + def onCreate(self): screen = lv.obj() - screen.set_style_border_width(0, 0) + screen.set_style_border_width(0, lv.PART.MAIN) screen.set_flex_flow(lv.FLEX_FLOW.COLUMN) - screen.set_style_pad_all(mpos.ui.pct_of_display_width(2), 0) + screen.set_style_pad_all(DisplayMetrics.pct_of_width(2), lv.PART.MAIN) # Make the screen focusable so it can be scrolled with the arrow keys focusgroup = lv.group_get_default() if focusgroup: focusgroup.add_obj(screen) - label0 = lv.label(screen) - label0.set_text(f"Hardware ID: {mpos.info.get_hardware_id()}") - label1 = lv.label(screen) - label1.set_text(f"MicroPythonOS version: {mpos.info.CURRENT_OS_VERSION}") - label2 = lv.label(screen) - label2.set_text(f"sys.version: {sys.version}") - label3 = lv.label(screen) - 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}") + # Logo + img = lv.image(screen) + img.set_src("M:builtin/res/mipmap-mdpi/MicroPythonOS-logo-white-long-w296.png") # from the MPOS-logo repo + img.set_blend_mode(lv.BLEND_MODE.DIFFERENCE) + + # Basic OS info + self._add_label(screen, f"{lv.SYMBOL.HOME} Build Information", is_header=True, margin_top=0) # close to logo + self._add_label(screen, f"Release version: {BuildInfo.version.release}") + self._add_label(screen, f"API Level: {BuildInfo.version.api_level}") + self._add_label(screen, f"Hardware ID: {DeviceInfo.hardware_id}") + self._add_label(screen, f"sys.version: {sys.version}") + self._add_label(screen, f"sys.implementation: {sys.implementation}") + self._add_label(screen, f"sys.byteorder: {sys.byteorder}") + self._add_label(screen, f"sys.maxsize of integer: {sys.maxsize}") + + # Platform info + self._add_label(screen, f"{lv.SYMBOL.FILE} Platform", is_header=True) + self._add_label(screen, f"sys.platform: {sys.platform}") + self._add_label(screen, f"sys.path: {sys.path}") + + # MPY version info + self._add_label(screen, f"{lv.SYMBOL.SETTINGS} Binary MPY Format", is_header=True) + sys_mpy = sys.implementation._mpy + self._add_label(screen, f'mpy version: {sys_mpy & 0xff}') + self._add_label(screen, f'mpy sub-version: {sys_mpy >> 8 & 3}') + arch = [None, 'x86', 'x64', + 'armv6', 'armv6m', 'armv7m', 'armv7em', 'armv7emsp', 'armv7emdp', + 'xtensa', 'xtensawin', 'rv32imc', 'rv64imc'][(sys_mpy >> 10) & 0x0F] + flags = "" + if arch: + flags += ' -march=' + arch + if (sys_mpy >> 16) != 0: + flags += ' -march-flags=' + (sys_mpy >> 16) + if len(flags) > 0: + self._add_label(screen, 'mpy flags: ' + flags) + + # MicroPython and memory info + self._add_label(screen, f"{lv.SYMBOL.DRIVE} Memory & Performance", is_header=True) import micropython - label16 = lv.label(screen) - label16.set_text(f"micropython.opt_level(): {micropython.opt_level()}") + self._add_label(screen, 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") + self._add_label(screen, 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 - current = Partition(Partition.RUNNING) - label5.set_text(f"Partition.RUNNING: {current}") - next_partition = current.get_next_update() - label6 = lv.label(screen) - label6.set_text(f"Next update partition: {next_partition}") - except Exception as e: - print(f"Partition info got exception: {e}") + #self._add_label(screen, f"micropython.mem_info(): {micropython.mem_info()}") + #self._add_label(screen, f"micropython.qstr_info(): {micropython.qstr_info()}") + import mpos + self._add_label(screen, f"mpos.__path__: {mpos.__path__}") # this will show .frozen if the /lib folder is frozen (prod build) + + # ESP32 hardware info + if sys.platform == "esp32": + try: + self._add_label(screen, f"{lv.SYMBOL.SETTINGS} ESP32 Hardware", is_header=True) + import esp32 + self._add_label(screen, f"Temperature: {esp32.mcu_temperature()} °C") + except Exception as e: + self.logger.warning(f"Could not get ESP32 hardware info: {e}") + + # Machine info + try: + self.logger.info("Trying to find out additional board info, not available on every platform...") + self._add_label(screen, f"{lv.SYMBOL.POWER} Machine Info", is_header=True) + import machine + self._add_label(screen, f"machine.freq: {machine.freq()}") + # Format unique_id as MAC address (AA:BB:CC:DD:EE:FF) + unique_id = machine.unique_id() + mac_address = ':'.join(f'{b:02X}' for b in unique_id) + self._add_label(screen, f"machine.unique_id(): {mac_address}") + self._add_label(screen, f"machine.wake_reason(): {machine.wake_reason()}") + self._add_label(screen, f"machine.reset_cause(): {machine.reset_cause()}") + except Exception as e: + error = f"Could not find machine info because: {e}\nIt's normal to get this error on desktop." + self.logger.warning(error) + self._add_label(screen, error) + + # Partition info (ESP32 only) + try: + self._add_label(screen, f"{lv.SYMBOL.SD_CARD} Partition Info", is_header=True) + from esp32 import Partition + current = Partition(Partition.RUNNING) + self._add_label(screen, f"Partition.RUNNING: {current}") + next_partition = current.get_next_update() + self._add_label(screen, f"Next update partition: {next_partition}") + except Exception as e: + error = f"Could not find partition info because: {e}\nIt's normal to get this error on desktop." + self.logger.warning(error) + self._add_label(screen, error) + + # Network info 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 - label7.set_text(f"machine.freq: {machine.freq()}") - label8 = lv.label(screen) - label8.set_text(f"machine.unique_id(): {machine.unique_id()}") - label9 = lv.label(screen) - label9.set_text(f"machine.wake_reason(): {machine.wake_reason()}") - label10 = lv.label(screen) - label10.set_text(f"machine.reset_cause(): {machine.reset_cause()}") + self._add_label(screen, f"{lv.SYMBOL.WIFI} Network Info", is_header=True) + from mpos import WifiService + ipv4_address = WifiService.get_ipv4_address() or "127.0.0.1" + ipv4_netmask = WifiService.get_ipv4_netmask() or "255.255.255.0" + ipv4_gateway = WifiService.get_ipv4_gateway() or "" + self._add_label(screen, f"IPv4 Address: {ipv4_address}") + self._add_label(screen, f"IPv4 Netmask: {ipv4_netmask}") + self._add_label(screen, f"IPv4 Gateway: {ipv4_gateway}") except Exception as e: - print(f"Additional board info got exception: {e}") + error = f"Could not find network info because: {e}" + self.logger.warning(error) + self._add_label(screen, error) + + + # Freezefs info (production builds only) try: - print("Trying to find out freezefs info, this only works on production builds...") # dev builds already have the /builtin folder + self.logger.info("Trying to find out freezefs info") + self._add_label(screen, f"{lv.SYMBOL.DOWNLOAD} Frozen Filesystem", is_header=True) import freezefs_mount_builtin - label11 = lv.label(screen) - label11.set_text(f"freezefs_mount_builtin.date_frozen: {freezefs_mount_builtin.date_frozen}") - label12 = lv.label(screen) - label12.set_text(f"freezefs_mount_builtin.files_folders: {freezefs_mount_builtin.files_folders}") - label13 = lv.label(screen) - label13.set_text(f"freezefs_mount_builtin.sum_size: {freezefs_mount_builtin.sum_size}") - label14 = lv.label(screen) - label14.set_text(f"freezefs_mount_builtin.version: {freezefs_mount_builtin.version}") + self._add_label(screen, f"freezefs_mount_builtin.date_frozen: {freezefs_mount_builtin.date_frozen}") + self._add_label(screen, f"freezefs_mount_builtin.files_folders: {freezefs_mount_builtin.files_folders}") + self._add_label(screen, f"freezefs_mount_builtin.sum_size: {freezefs_mount_builtin.sum_size}") + self._add_label(screen, f"freezefs_mount_builtin.version: {freezefs_mount_builtin.version}") except Exception as e: # 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 @@ -82,35 +137,71 @@ def onCreate(self): # 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}") - # Disk usage: - import os + error = f"Could not get freezefs_mount_builtin info because: {e}\nIt's normal to get an exception if the internal storage partition contains an overriding /builtin folder." + self.logger.warning(error) + self._add_label(screen, error) + + # Display info 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") + self._add_label(screen, f"{lv.SYMBOL.IMAGE} Display", is_header=True) + hor_res = DisplayMetrics.width() + ver_res = DisplayMetrics.height() + self._add_label(screen, f"Resolution: {hor_res}x{ver_res}") + dpi = DisplayMetrics.dpi() + self._add_label(screen, f"Dots Per Inch (dpi): {dpi}") except Exception as e: - print(f"About app could not get info on / filesystem: {e}") + self.logger.warning(f"Could not get display info: {e}") + + # Disk usage info + self._add_label(screen, f"{lv.SYMBOL.DRIVE} Storage", is_header=True) + self._add_disk_info(screen, '/') + self._add_disk_info(screen, '/sdcard') + + self.setContentView(screen) + + @staticmethod + def _focus_obj(event): + target = event.get_target_obj() + target.set_style_border_color(lv.theme_get_color_primary(None),lv.PART.MAIN) + target.set_style_border_width(1, lv.PART.MAIN) + target.scroll_to_view(True) + + @staticmethod + def _defocus_obj(event): + target = event.get_target_obj() + target.set_style_border_width(0, lv.PART.MAIN) + + def _add_label(self, parent, text, is_header=False, margin_top=DisplayMetrics.pct_of_height(5)): + """Helper to create and add a label with text.""" + label = lv.label(parent) + label.set_text(text) + # Make labels focusable to allow scroll on devices without touch screen + label.add_event_cb(self._focus_obj, lv.EVENT.FOCUSED, None) + label.add_event_cb(self._defocus_obj, lv.EVENT.DEFOCUSED, None) + focusgroup = lv.group_get_default() + if focusgroup: + focusgroup.add_obj(label) + if is_header: + primary_color = lv.theme_get_color_primary(None) + label.set_style_text_color(primary_color, lv.PART.MAIN) + label.set_style_text_font(lv.font_montserrat_14, lv.PART.MAIN) + label.set_style_margin_top(margin_top, lv.PART.MAIN) + label.set_style_margin_bottom(DisplayMetrics.pct_of_height(2), lv.PART.MAIN) + else: + label.set_style_text_font(lv.font_montserrat_12, lv.PART.MAIN) + label.set_style_margin_bottom(2, lv.PART.MAIN) + return label + + def _add_disk_info(self, screen, path): + """Helper to add disk usage info for a given path.""" + import os try: - stat = os.statvfs('/sdcard') + stat = os.statvfs(path) 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") + self._add_label(screen, f"Total space {path}: {total_space} bytes") + self._add_label(screen, f"Free space {path}: {free_space} bytes") + self._add_label(screen, f"Used space {path}: {used_space} bytes") except Exception as e: - print(f"About app could not get info on /sdcard filesystem: {e}") - self.setContentView(screen) + self.logger.warning(f"About app could not get info on {path} filesystem: {e}") diff --git a/internal_filesystem/builtin/apps/com.micropythonos.about/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/builtin/apps/com.micropythonos.about/res/mipmap-mdpi/icon_64x64.png index fbb465dc..a866a567 100644 Binary files a/internal_filesystem/builtin/apps/com.micropythonos.about/res/mipmap-mdpi/icon_64x64.png and b/internal_filesystem/builtin/apps/com.micropythonos.about/res/mipmap-mdpi/icon_64x64.png differ 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..768e4782 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.1.3_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.appstore/mpks/com.micropythonos.appstore_0.1.3.mpk", "fullname": "com.micropythonos.appstore", -"version": "0.0.8", +"version": "0.1.3", "category": "appstore", "activities": [ { diff --git a/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/app_detail.py b/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/app_detail.py new file mode 100644 index 00000000..c74f5b1e --- /dev/null +++ b/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/app_detail.py @@ -0,0 +1,336 @@ +import os +import json +import lvgl as lv + +from mpos import Activity, DownloadManager, AppManager, TaskManager + +class AppDetail(Activity): + + action_label_install = "Install" + action_label_uninstall = "Uninstall" + action_label_restore = "Restore Built-in" + action_label_nothing = "Disable" # This could mark builtin apps as "Disabled" somehow and also allow for "Enable" then + + # Widgets: + install_button = None + 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 + + @staticmethod + def _apply_default_styles(widget, border=0, radius=0, pad=0): + """Apply common default styles to reduce repetition""" + widget.set_style_border_width(border, lv.PART.MAIN) + widget.set_style_radius(radius, lv.PART.MAIN) + widget.set_style_pad_all(pad, lv.PART.MAIN) + + def _cleanup_temp_file(self, path="tmp/temp.mpk"): + """Safely remove temporary file""" + try: + os.remove(path) + except Exception: + pass + + async def _update_progress(self, value, wait=True): + """Update progress bar with optional wait""" + self.progress_bar.set_value(value, wait) + if wait: + await TaskManager.sleep(1) + + def _show_progress_bar(self): + """Show progress bar and reset to 0""" + self.progress_bar.remove_flag(lv.obj.FLAG.HIDDEN) + self.progress_bar.set_value(0, False) + + def _hide_progress_bar(self): + """Hide progress bar and reset to 0""" + self.progress_bar.set_value(0, False) + self.progress_bar.add_flag(lv.obj.FLAG.HIDDEN) + + def onCreate(self): + print("Creating app detail screen...") + 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, lv.PART.MAIN) + app_detail_screen.set_size(lv.pct(100), lv.pct(100)) + app_detail_screen.set_pos(0, 40) + app_detail_screen.set_flex_flow(lv.FLEX_FLOW.COLUMN) + + headercont = lv.obj(app_detail_screen) + self._apply_default_styles(headercont) + headercont.set_flex_flow(lv.FLEX_FLOW.ROW) + headercont.set_size(lv.pct(100), lv.SIZE_CONTENT) + headercont.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) + icon_spacer = lv.image(headercont) + icon_spacer.set_size(64, 64) + if self.app.icon_data: + image_dsc = lv.image_dsc_t({ + 'data_size': len(self.app.icon_data), + 'data': self.app.icon_data + }) + icon_spacer.set_src(image_dsc) + else: + icon_spacer.set_src(lv.SYMBOL.IMAGE) + detail_cont = lv.obj(headercont) + self._apply_default_styles(detail_cont) + 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(self.app.name) + name_label.set_style_text_font(lv.font_montserrat_24, lv.PART.MAIN) + 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, lv.PART.MAIN) + + 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: + self.buttoncont = lv.obj(app_detail_screen) + self._apply_default_styles(self.buttoncont) + 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, lv.PART.MAIN) + 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, lv.PART.MAIN) + self.long_desc_label.set_width(lv.pct(100)) + print("Loading app detail screen...") + self.setContentView(app_detail_screen) + + def onResume(self, screen): + backend_type = self.appstore.get_backend_type_from_settings() + if backend_type == 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, 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(self.app.fullname) + if app.version and AppManager.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, 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() + + async def fetch_and_set_app_details(self): + await self.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: + # - "install" option if not installed + # - "update" option if already installed and new version + # - "uninstall" option if already installed and not builtin + # - "restore builtin" option if it's an overridden builtin app + # So: + # - install, uninstall and restore builtin can be same button, always shown + # - update is separate button, only shown if already installed and new version + is_installed = True + update_available = False + builtin_app = AppManager.is_builtin_app(app_fullname) + overridden_builtin_app = AppManager.is_overridden_builtin_app(app_fullname) + if not overridden_builtin_app: + is_installed = AppManager.is_installed_by_name(app_fullname) + if is_installed: + if builtin_app: + if overridden_builtin_app: + action_label = self.action_label_restore + else: + action_label = self.action_label_nothing + else: + action_label = self.action_label_uninstall + else: + action_label = self.action_label_install + self.install_label.set_text(action_label) + + 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: + 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("Starting uninstall task...") + TaskManager.create_task(self.uninstall_app(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) + TaskManager.create_task(self.download_and_install(app_obj, f"apps/{fullname}")) + + async def uninstall_app(self, app_fullname): + self.install_button.add_state(lv.STATE.DISABLED) + self.install_label.set_text("Please wait...") + self._show_progress_bar() + await self._update_progress(21) + await self._update_progress(42) + AppManager.uninstall_app(app_fullname) + await self._update_progress(100, wait=False) + self._hide_progress_bar() + self.set_install_label(app_fullname) + self.install_button.remove_state(lv.STATE.DISABLED) + if AppManager.is_builtin_app(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 + + 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 = getattr(app_obj, "download_url_size", None) + temp_zip_path = "tmp/temp.mpk" + self.install_button.add_state(lv.STATE.DISABLED) + self.install_label.set_text("Please wait...") + self._show_progress_bar() + await self._update_progress(5) + # Download the .mpk file to temporary location + self._cleanup_temp_file(temp_zip_path) + try: + os.mkdir("tmp") + except Exception: + pass + print(f"Downloading .mpk file from: {zip_url} to {temp_zip_path}") + try: + 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") + # Install it: + AppManager.install_mpk(temp_zip_path, dest_folder) # 60 until 80 percent is the unzip but no progress there... + await self._update_progress(80, wait=False) + except Exception as e: + print(f"Download failed with exception: {e}") + if DownloadManager.is_network_error(e): + self.install_label.set_text(f"Network error - check WiFi") + else: + self.install_label.set_text(f"Download failed: {str(e)[:30]}") + self.install_button.remove_state(lv.STATE.DISABLED) + self._hide_progress_bar() + self._cleanup_temp_file(temp_zip_path) + return + # Make sure there's no leftover file filling the storage: + self._cleanup_temp_file(temp_zip_path) + await self._update_progress(85, wait=False) + # TODO: report the install if badgehub /report/install is fixed + # Success: + await self._update_progress(100, wait=False) + self._hide_progress_bar() + self.set_install_label(app_fullname) + self.install_button.remove_state(lv.STATE.DISABLED) + + async def fetch_badgehub_app_details(self, app_obj): + details_url = self.appstore.get_backend_details_url_from_settings() + "/" + app_obj.fullname + try: + response = await DownloadManager.download_url(details_url) + except Exception as e: + print(f"Could not download app details from {details_url}: {e}") + if DownloadManager.is_network_error(e): + print("Network error while fetching app details") + 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: + app_obj.publisher = app_metadata.get("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: + err = f"ERROR: could not parse app details JSON: {e}" + print(err) + self.appstore.please_wait_label.set_text(err) + return 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 ff1674dd..48e57324 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/appstore.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/appstore.py @@ -1,132 +1,203 @@ -import lvgl as lv -import json -import requests -import gc import os -import time -import _thread +import json +import lvgl as lv -from mpos.apps import Activity, Intent -from mpos.app import App -import mpos.ui -from mpos.content.package_manager import PackageManager +from mpos import Activity, App, Intent, DownloadManager, SettingActivity, SharedPreferences, TaskManager +from app_detail import AppDetail class AppStore(Activity): + + PACKAGE = "com.micropythonos.appstore" + + _GITHUB_PROD_BASE_URL = "https://apps.micropythonos.com" + _GITHUB_LIST = "/app_index.json" + + _BADGEHUB_TEST_BASE_URL = "https://badgehub.p1m.nl/api/v3" + _BADGEHUB_PROD_BASE_URL = "https://badgehub.eu/api/v3" + _BADGEHUB_LIST = "project-summaries?badge=fri3d_2024" + _BADGEHUB_DETAILS = "projects" + + _BACKEND_API_GITHUB = "github" + _BACKEND_API_BADGEHUB = "badgehub" + + _ICON_SIZE = 64 + + # Hardcoded list for now: + backends = [ + ("Apps.MicroPythonOS.com on GitHub", _BACKEND_API_GITHUB, _GITHUB_PROD_BASE_URL, _GITHUB_LIST, None), + ("BadgeHub.eu (beta)", _BACKEND_API_BADGEHUB, _BADGEHUB_PROD_BASE_URL, _BADGEHUB_LIST, _BADGEHUB_DETAILS), + ("BadgeHub.p1m.nl Testing (unstable)", _BACKEND_API_BADGEHUB, _BADGEHUB_TEST_BASE_URL, _BADGEHUB_LIST, _BADGEHUB_DETAILS), + ] + apps = [] - app_index_url = "https://apps.micropythonos.com/app_index.json" can_check_network = True # Widgets: main_screen = None + app_list = None update_button = None install_button = None install_label = None please_wait_label = None progress_bar = None + settings_button = None def onCreate(self): + self.prefs = SharedPreferences(self.PACKAGE) + self._DEFAULT_BACKEND = AppStore.get_backend_pref_string(0) self.main_screen = lv.obj() self.please_wait_label = lv.label(self.main_screen) self.please_wait_label.set_text("Downloading app index...") self.please_wait_label.center() + self.settings_button = lv.button(self.main_screen) + settings_margin = 15 + settings_size = self._ICON_SIZE - settings_margin + self.settings_button.set_size(settings_size, settings_size) + self.settings_button.align(lv.ALIGN.TOP_RIGHT, -settings_margin, 10) + self.settings_button.add_event_cb(self.settings_button_tap,lv.EVENT.CLICKED,None) + settings_label = lv.label(self.settings_button) + settings_label.set_text(lv.SYMBOL.SETTINGS) + settings_label.set_style_text_font(lv.font_montserrat_24, lv.PART.MAIN) + settings_label.center() self.setContentView(self.main_screen) def onResume(self, screen): - super().onResume(screen) - if len(self.apps): - return # already downloaded them + super().onResume(screen) # super handles self._has_foreground + if not len(self.apps): + self.refresh_list() + + def refresh_list(self): try: import network + if not network.WLAN(network.STA_IF).isconnected(): + self.please_wait_label.remove_flag(lv.obj.FLAG.HIDDEN) # make sure it's visible + self.please_wait_label.set_text("Error: WiFi is not connected.") except Exception as e: - self.can_check_network = False - 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,)) - - def download_app_index(self, json_url): + print("Warning: can't check network state, assuming we're online...") + TaskManager.create_task(self.download_app_index(self.get_backend_list_url_from_settings())) + + def settings_button_tap(self, event): + intent = Intent(activity_class=SettingActivity) + intent.putExtra("prefs", self.prefs) + intent.putExtra("setting", {"title": "AppStore Backend", + "key": "backend", + "ui": "radiobuttons", + "default_value": self._DEFAULT_BACKEND, + "ui_options": [(backend[0], AppStore.get_backend_pref_string(index)) for index, backend in enumerate(AppStore.backends)], + "changed_callback": self.backend_changed}) + self.startActivity(intent) + + def backend_changed(self, new_value): + print(f"backend changed to {new_value}, refreshing...") + self.refresh_list() + + async def download_app_index(self, json_url): try: - response = requests.get(json_url, timeout=10) + response = await DownloadManager.download_url(json_url) 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}") + print(f"Failed to download app index: {e}") + if DownloadManager.is_network_error(e): + self.please_wait_label.set_text(f"Network error - check your WiFi connection\nand try again.") + else: + self.please_wait_label.set_text(f"Could not download app index from\n{json_url}\nError: {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: + print(f"Got response text: {response[0:20]}") + try: + parsed = json.loads(response) + #print(f"parsed json: {parsed}") + self.apps.clear() + for app in parsed: + try: + backend_type = self.get_backend_type_from_settings() + if backend_type == 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(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() + except Exception as e: + print(f"Warning: could not add app from {json_url} to apps list: {e}") + except Exception as e: + self.please_wait_label.set_text(f"ERROR: could not parse reponse.text JSON: {e}") + return + 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") - apps_list = lv.list(self.main_screen) - apps_list.set_style_border_width(0, 0) - apps_list.set_style_radius(0, 0) - apps_list.set_style_pad_all(0, 0) - apps_list.set_size(lv.pct(100), lv.pct(100)) + + print("Hiding please wait label...") + self.please_wait_label.add_flag(lv.obj.FLAG.HIDDEN) + + print("Emptying focus group") + # removing objects or even cleaning the screen doesn't seem to empty the focus group + focusgroup = lv.group_get_default() + if focusgroup: + focusgroup.remove_all_objs() + focusgroup.add_obj(self.settings_button) + + self.apps_list = lv.list(self.main_screen) + self._apply_default_styles(self.apps_list) + self.apps_list.set_size(lv.pct(100), lv.pct(100)) self._icon_widgets = {} # Clear old icons print("create_apps_list iterating") for app in self.apps: print(app) - item = apps_list.add_button(None, "Test") - item.set_style_pad_all(0, 0) + item = self.apps_list.add_button(None, "") + item.set_style_pad_all(0, lv.PART.MAIN) item.set_size(lv.pct(100), lv.SIZE_CONTENT) - item.add_event_cb(lambda e, a=app: self.show_app_detail(a), lv.EVENT.CLICKED, None) + self._add_click_handler(item, self.show_app_detail, app) cont = lv.obj(item) - cont.set_style_pad_all(0, 0) + cont.set_style_pad_all(0, lv.PART.MAIN) cont.set_flex_flow(lv.FLEX_FLOW.ROW) cont.set_size(lv.pct(100), lv.SIZE_CONTENT) cont.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) - cont.set_style_border_width(0, 0) - cont.set_style_radius(0, 0) - cont.add_event_cb(lambda e, a=app: self.show_app_detail(a), lv.EVENT.CLICKED, None) + self._apply_default_styles(cont) + self._add_click_handler(cont, self.show_app_detail, app) icon_spacer = lv.image(cont) - icon_spacer.set_size(64, 64) + icon_spacer.set_size(self._ICON_SIZE, self._ICON_SIZE) icon_spacer.set_src(lv.SYMBOL.REFRESH) - icon_spacer.add_event_cb(lambda e, a=app: self.show_app_detail(a), lv.EVENT.CLICKED, None) + self._add_click_handler(icon_spacer, self.show_app_detail, app) app.image_icon_widget = icon_spacer # save it so it can be later set to the actual image label_cont = lv.obj(cont) - label_cont.set_style_border_width(0, 0) - label_cont.set_style_radius(0, 0) + self._apply_default_styles(label_cont) label_cont.set_flex_flow(lv.FLEX_FLOW.COLUMN) + label_cont.set_style_pad_ver(10, lv.PART.MAIN) # Add vertical padding for spacing label_cont.set_size(lv.pct(75), lv.SIZE_CONTENT) - label_cont.add_event_cb(lambda e, a=app: self.show_app_detail(a), lv.EVENT.CLICKED, None) + self._add_click_handler(label_cont, self.show_app_detail, app) name_label = lv.label(label_cont) name_label.set_text(app.name) - name_label.set_style_text_font(lv.font_montserrat_16, 0) - name_label.add_event_cb(lambda e, a=app: self.show_app_detail(a), lv.EVENT.CLICKED, None) + name_label.set_style_text_font(lv.font_montserrat_16, lv.PART.MAIN) + self._add_click_handler(name_label, self.show_app_detail, app) desc_label = lv.label(label_cont) desc_label.set_text(app.short_description) - 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): + desc_label.set_style_text_font(lv.font_montserrat_12, lv.PART.MAIN) + self._add_click_handler(desc_label, self.show_app_detail, app) + print("create_apps_list done") + # Settings button needs to float in foreground: + self.settings_button.move_to_index(-1) + + 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,241 +210,74 @@ 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}") - 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) - except Exception as e: - print(f"Exception during download of icon: {e}") - return None + def _get_backend_config(self): + """Get backend configuration tuple (type, list_url, details_url)""" + pref_string = self.prefs.get_string("backend", self._DEFAULT_BACKEND) + return AppStore.backend_pref_string_to_backend(pref_string) -class AppDetail(Activity): + def get_backend_type_from_settings(self): + return self._get_backend_config()[0] - action_label_install = "Install" - action_label_uninstall = "Uninstall" - action_label_restore = "Restore Built-in" - action_label_nothing = "Disable" # This could mark builtin apps as "Disabled" somehow and also allow for "Enable" then + def get_backend_list_url_from_settings(self): + return self._get_backend_config()[1] - # Widgets: - install_button = None - update_button = None - progress_bar = None - install_label = None + def get_backend_details_url_from_settings(self): + return self._get_backend_config()[2] - def onCreate(self): - print("Creating app detail screen...") - app = self.getIntent().extras.get("app") - 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)) - app_detail_screen.set_pos(0, 40) - app_detail_screen.set_flex_flow(lv.FLEX_FLOW.COLUMN) - - headercont = lv.obj(app_detail_screen) - headercont.set_style_border_width(0, 0) - headercont.set_style_pad_all(0, 0) - headercont.set_flex_flow(lv.FLEX_FLOW.ROW) - headercont.set_size(lv.pct(100), lv.SIZE_CONTENT) - headercont.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) - icon_spacer = lv.image(headercont) - icon_spacer.set_size(64, 64) - if app.icon_data: - image_dsc = lv.image_dsc_t({ - 'data_size': len(app.icon_data), - 'data': app.icon_data - }) - icon_spacer.set_src(image_dsc) - else: - icon_spacer.set_src(lv.SYMBOL.IMAGE) - detail_cont = lv.obj(headercont) - detail_cont.set_style_border_width(0, 0) - detail_cont.set_style_radius(0, 0) - 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_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.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.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.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.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) - 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) - - - def set_install_label(self, app_fullname): - # Figure out whether to show: - # - "install" option if not installed - # - "update" option if already installed and new version - # - "uninstall" option if already installed and not builtin - # - "restore builtin" option if it's an overridden builtin app - # So: - # - install, uninstall and restore builtin can be same button, always shown - # - update is separate button, only shown if already installed and new version - is_installed = True - update_available = False - builtin_app = PackageManager.is_builtin_app(app_fullname) - overridden_builtin_app = PackageManager.is_overridden_builtin_app(app_fullname) - if not overridden_builtin_app: - is_installed = PackageManager.is_installed_by_name(app_fullname) - if is_installed: - if builtin_app: - if overridden_builtin_app: - action_label = self.action_label_restore - else: - action_label = self.action_label_nothing - else: - action_label = self.action_label_uninstall - else: - 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}") - 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) - 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) - - def update_button_click(self, download_url, 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) + @staticmethod + def badgehub_app_to_mpos_app(bhapp): + name = bhapp.get("name") + print(f"Got app name: {name}") + short_description = bhapp.get("description") + fullname = bhapp.get("slug") + # Safely extract nested icon URL + icon_url = None 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) - - 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 - 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 - 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 - self.progress_bar.set_value(100, False) - self.progress_bar.add_flag(lv.obj.FLAG.HIDDEN) - self.progress_bar.set_value(0, False) - self.set_install_label(app_fullname) - self.install_button.remove_state(lv.STATE.DISABLED) - if PackageManager.is_builtin_app(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): - 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 + icon_url = bhapp.get("icon_map", {}).get("64x64", {}).get("url") + except Exception: + print("Could not find icon_map 64x64 url") + # Safely extract first category + category = None 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() - 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) # ERROR: temp_zip_path might not be set if download failed! - # Success: - 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(100, False) - self.progress_bar.add_flag(lv.obj.FLAG.HIDDEN) - self.progress_bar.set_value(0, False) - self.set_install_label(app_fullname) - self.install_button.remove_state(lv.STATE.DISABLED) + category = bhapp.get("categories", [None])[0] + except Exception: + print("Could not parse category") + return App(name, None, short_description, None, icon_url, None, fullname, None, category, None) + + @staticmethod + def get_backend_pref_string(index): + backend_info = AppStore.backends[index] + if backend_info: + api = backend_info[1] + base_url = backend_info[2] + list_suffix = backend_info[3] + details_suffix = backend_info[4] + toreturn = api + "," + base_url + "/" + list_suffix + if api == AppStore._BACKEND_API_BADGEHUB: + toreturn += "," + base_url + "/" + details_suffix + return toreturn + + @staticmethod + def backend_pref_string_to_backend(string): + return string.split(",") + + @staticmethod + def _apply_default_styles(widget, border=0, radius=0, pad=0): + """Apply common default styles to reduce repetition""" + widget.set_style_border_width(border, lv.PART.MAIN) + widget.set_style_radius(radius, lv.PART.MAIN) + widget.set_style_pad_all(pad, lv.PART.MAIN) + + @staticmethod + def _add_click_handler(widget, callback, app): + """Register click handler to avoid repetition""" + widget.add_event_cb(lambda e, a=app: callback(a), lv.EVENT.CLICKED, None) diff --git a/internal_filesystem/builtin/apps/com.micropythonos.appstore/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/builtin/apps/com.micropythonos.appstore/res/mipmap-mdpi/icon_64x64.png index 195ca215..967d5ab4 100644 Binary files a/internal_filesystem/builtin/apps/com.micropythonos.appstore/res/mipmap-mdpi/icon_64x64.png and b/internal_filesystem/builtin/apps/com.micropythonos.appstore/res/mipmap-mdpi/icon_64x64.png differ diff --git a/internal_filesystem/builtin/apps/com.micropythonos.howto/META-INF/MANIFEST.JSON b/internal_filesystem/builtin/apps/com.micropythonos.howto/META-INF/MANIFEST.JSON new file mode 100644 index 00000000..f57aef91 --- /dev/null +++ b/internal_filesystem/builtin/apps/com.micropythonos.howto/META-INF/MANIFEST.JSON @@ -0,0 +1,23 @@ +{ +"name": "HowTo", +"publisher": "MicroPythonOS", +"short_description": "Welcomes the user and gives a few pointers on how to control and use MicroPythonOS.", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.howto/icons/com.micropythonos.howto_0.0.1_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.howto/mpks/com.micropythonos.howto_0.0.1.mpk", +"fullname": "com.micropythonos.howto", +"version": "0.0.1", +"category": "help", +"activities": [ + { + "entrypoint": "assets/howto.py", + "classname": "HowTo", + "intent_filters": [ + { + "action": "main", + "category": "launcher" + } + ] + } + ] +} + diff --git a/internal_filesystem/builtin/apps/com.micropythonos.howto/assets/howto.py b/internal_filesystem/builtin/apps/com.micropythonos.howto/assets/howto.py new file mode 100644 index 00000000..75481b89 --- /dev/null +++ b/internal_filesystem/builtin/apps/com.micropythonos.howto/assets/howto.py @@ -0,0 +1,94 @@ +from mpos import Activity, SharedPreferences + +class HowTo(Activity): + + appname = "com.micropythonos.howto" + + dontshow_checkbox = None + prefs = None + autostart_enabled = None + + def onCreate(self): + screen = lv.obj() + screen.set_flex_flow(lv.FLEX_FLOW.COLUMN) + # Make the screen focusable so it can be scrolled with the arrow keys + focusgroup = lv.group_get_default() + if focusgroup: + focusgroup.add_obj(screen) + preamble = "How to Navigate" + self._add_label(screen, preamble, is_header=True) + + buttonhelp_intro = "As you don't have a touch screen, you need to use the buttons to navigate:" + buttonhelp_items = [ + "If you have a joystick and at least 2 buttons, then use the joystick to move around. Use one of the buttons to ENTER and another to go BACK.", + "If you have 3 buttons, then one is PREVIOUS, one is ENTER and one is NEXT. To go back, press PREVIOUS and NEXT together.", + "If you have just 2 buttons, then one is PREVIOUS, the other is NEXT. To ENTER, press both at the same time. To go back, long-press the PREVIOUS button.", + ] + touchhelp = "Swipe from the left edge to go back and from the top edge to open the menu." + from mpos import InputManager + if InputManager.has_pointer(): + self._add_label(screen, touchhelp) + else: + self._add_label(screen, buttonhelp_intro) + for item in buttonhelp_items: + self._add_label(screen, f"• {item}") + + self.dontshow_checkbox = lv.checkbox(screen) + self.dontshow_checkbox.set_text("Don't show again") + + closebutton = lv.button(screen) + closebutton.add_event_cb(lambda *args: self.finish(), lv.EVENT.CLICKED, None) + closelabel = lv.label(closebutton) + closelabel.set_text("Close") + + self.setContentView(screen) + + @staticmethod + def _focus_obj(event): + target = event.get_target_obj() + target.set_style_border_color(lv.theme_get_color_primary(None), lv.PART.MAIN) + target.set_style_border_width(1, lv.PART.MAIN) + target.scroll_to_view(True) + + @staticmethod + def _defocus_obj(event): + target = event.get_target_obj() + target.set_style_border_width(0, lv.PART.MAIN) + + def _add_label(self, parent, text, is_header=False): + label = lv.label(parent) + label.set_width(lv.pct(100)) + label.set_text(text) + label.set_long_mode(lv.label.LONG_MODE.WRAP) + label.add_event_cb(self._focus_obj, lv.EVENT.FOCUSED, None) + label.add_event_cb(self._defocus_obj, lv.EVENT.DEFOCUSED, None) + focusgroup = lv.group_get_default() + if focusgroup: + focusgroup.add_obj(label) + if is_header: + label.set_style_text_font(lv.font_montserrat_24, lv.PART.MAIN) + label.set_style_margin_bottom(4, lv.PART.MAIN) + else: + label.set_style_text_font(lv.font_montserrat_14, lv.PART.MAIN) + label.set_style_margin_bottom(2, lv.PART.MAIN) + return label + + def onResume(self, screen): + # Autostart can only be disabled if nothing was enabled or if this app was enabled + self.prefs = SharedPreferences("com.micropythonos.settings") + auto_start_app_early = self.prefs.get_string("auto_start_app_early") + print(f"auto_start_app_early: {auto_start_app_early}") + if auto_start_app_early is None or auto_start_app_early == self.appname: # empty also means autostart because then it's the default + self.dontshow_checkbox.remove_state(lv.STATE.CHECKED) + else: + self.dontshow_checkbox.add_state(lv.STATE.CHECKED) + + def onPause(self, screen): + checked = self.dontshow_checkbox.get_state() & lv.STATE.CHECKED + print("Removing this app from autostart") + editor = self.prefs.edit() + if checked: + editor.put_string("auto_start_app_early", "") # None might result in the OS starting it, empty string means explictly don't start it + else: + editor.put_string("auto_start_app_early", self.appname) + editor.commit() diff --git a/internal_filesystem/builtin/apps/com.micropythonos.howto/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/builtin/apps/com.micropythonos.howto/res/mipmap-mdpi/icon_64x64.png new file mode 100644 index 00000000..e5d573ce Binary files /dev/null and b/internal_filesystem/builtin/apps/com.micropythonos.howto/res/mipmap-mdpi/icon_64x64.png differ diff --git a/internal_filesystem/builtin/apps/com.micropythonos.launcher/META-INF/MANIFEST.JSON b/internal_filesystem/builtin/apps/com.micropythonos.launcher/META-INF/MANIFEST.JSON index 00774d2e..c22a3ab1 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.launcher/META-INF/MANIFEST.JSON +++ b/internal_filesystem/builtin/apps/com.micropythonos.launcher/META-INF/MANIFEST.JSON @@ -3,10 +3,10 @@ "publisher": "MicroPythonOS", "short_description": "Simple launcher to start apps.", "long_description": "", -"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.launcher/icons/com.micropythonos.launcher_0.0.8_64x64.png", -"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.launcher/mpks/com.micropythonos.launcher_0.0.8.mpk", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.launcher/icons/com.micropythonos.launcher_0.1.3_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.launcher/mpks/com.micropythonos.launcher_0.1.3.mpk", "fullname": "com.micropythonos.launcher", -"version": "0.0.8", +"version": "0.1.3", "category": "launcher", "activities": [ { diff --git a/internal_filesystem/builtin/apps/com.micropythonos.launcher/assets/launcher.py b/internal_filesystem/builtin/apps/com.micropythonos.launcher/assets/launcher.py index 02e41ae1..48b4eb10 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.launcher/assets/launcher.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.launcher/assets/launcher.py @@ -1,22 +1,10 @@ -# bin files: -# All icons took: 1085ms -# All icons took: 1051ms -# All icons took: 1032ms -# All icons took: 1118ms -# png files: -# All icons took: 1258ms -# All icons took: 1457ms -# All icons took: 1250ms -# Most of this time is actually spent reading and parsing manifests. import lvgl as lv -import mpos.apps -import mpos.ui -from mpos.content.package_manager import PackageManager -from mpos import Activity +import math import time import uhashlib import ubinascii +from mpos import AppearanceManager, AppManager, Activity, DisplayMetrics class Launcher(Activity): def __init__(self): @@ -29,10 +17,10 @@ def onCreate(self): print("launcher.py onCreate()") main_screen = lv.obj() main_screen.set_style_border_width(0, lv.PART.MAIN) - main_screen.set_style_radius(0, 0) - main_screen.set_pos(0, mpos.ui.topmenu.NOTIFICATION_BAR_HEIGHT) - main_screen.set_style_pad_hor(mpos.ui.pct_of_display_width(2), 0) - main_screen.set_style_pad_ver(mpos.ui.topmenu.NOTIFICATION_BAR_HEIGHT, 0) + main_screen.set_style_radius(0, lv.PART.MAIN) + main_screen.set_pos(0, AppearanceManager.NOTIFICATION_BAR_HEIGHT) + main_screen.set_style_pad_hor(0, lv.PART.MAIN) + main_screen.set_style_pad_ver(AppearanceManager.NOTIFICATION_BAR_HEIGHT, lv.PART.MAIN) main_screen.set_flex_flow(lv.FLEX_FLOW.ROW_WRAP) self.setContentView(main_screen) @@ -57,7 +45,7 @@ def onResume(self, screen): # ------------------------------------------------------------------ # 1. Build a *compact* representation of the current app list current_apps = [] - for app in PackageManager.get_app_list(): + for app in AppManager.get_app_list(): if app.category == "launcher": continue icon_hash = Launcher._hash_file(app.icon_path) # cheap SHA-1 of the icon file @@ -90,23 +78,28 @@ def onResume(self, screen): # Grid parameters icon_size = 64 label_height = 24 - iconcont_width = icon_size + label_height + width_margin = 25 + icons_fit_width = math.floor((DisplayMetrics.width()-width_margin) / icon_size) + #print(f"{icons_fit_width} icons fit") + iconcont_width = int((DisplayMetrics.width()-width_margin) / icons_fit_width) + #print(f"{iconcont_width} iconcont_width") iconcont_height = icon_size + label_height - for app in PackageManager.get_app_list(): - if app.category == "launcher": + for app in AppManager.get_app_list(): + if app.category == "launcher" or (app.fullname != "com.micropythonos.settings.wifi" and app.fullname.startswith("com.micropythonos.settings.")): + # Ignore launchers and MPOS settings (except wifi) continue app_name = app.name app_dir_fullpath = app.installed_path - print(f"Adding app {app_name} from {app_dir_fullpath}") + #print(f"Adding app {app_name} from {app_dir_fullpath}") # ----- container ------------------------------------------------ app_cont = lv.obj(screen) app_cont.set_size(iconcont_width, iconcont_height) app_cont.set_style_border_width(0, lv.PART.MAIN) - app_cont.set_style_pad_all(0, 0) - app_cont.set_style_bg_opa(lv.OPA.TRANSP, 0) + app_cont.set_style_pad_all(0, lv.PART.MAIN) + app_cont.set_style_bg_opa(lv.OPA.TRANSP, lv.PART.MAIN) app_cont.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) # ----- icon ---------------------------------------------------- @@ -127,18 +120,12 @@ def onResume(self, screen): label.set_long_mode(lv.label.LONG_MODE.WRAP) label.set_width(iconcont_width) label.align(lv.ALIGN.BOTTOM_MID, 0, 0) - label.set_style_text_align(lv.TEXT_ALIGN.CENTER, 0) + label.set_style_text_align(lv.TEXT_ALIGN.CENTER, lv.PART.MAIN) # ----- events -------------------------------------------------- - app_cont.add_event_cb( - lambda e, fullname=app.fullname: mpos.apps.start_app(fullname), - lv.EVENT.CLICKED, None) - app_cont.add_event_cb( - lambda e, cont=app_cont: self.focus_app_cont(cont), - lv.EVENT.FOCUSED, None) - app_cont.add_event_cb( - lambda e, cont=app_cont: self.defocus_app_cont(cont), - lv.EVENT.DEFOCUSED, None) + app_cont.add_event_cb(lambda e, fullname=app.fullname: AppManager.start_app(fullname),lv.EVENT.CLICKED, None) + app_cont.add_event_cb(lambda e, cont=app_cont: self.focus_app_cont(cont),lv.EVENT.FOCUSED, None) + app_cont.add_event_cb(lambda e, cont=app_cont: self.defocus_app_cont(cont),lv.EVENT.DEFOCUSED, None) if focusgroup: focusgroup.add_obj(app_cont) diff --git a/internal_filesystem/builtin/apps/com.micropythonos.launcher/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/builtin/apps/com.micropythonos.launcher/res/mipmap-mdpi/icon_64x64.png deleted file mode 100644 index 79654b38..00000000 Binary files a/internal_filesystem/builtin/apps/com.micropythonos.launcher/res/mipmap-mdpi/icon_64x64.png and /dev/null differ 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 87781fec..ca4eecdc 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.10_64x64.png", -"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.osupdate/mpks/com.micropythonos.osupdate_0.0.10.mpk", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.osupdate/icons/com.micropythonos.osupdate_0.1.3_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.osupdate/mpks/com.micropythonos.osupdate_0.1.3.mpk", "fullname": "com.micropythonos.osupdate", -"version": "0.0.10", +"version": "0.1.3", "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 deceb590..2da08e13 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.osupdate/assets/osupdate.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.osupdate/assets/osupdate.py @@ -1,13 +1,8 @@ import lvgl as lv -import requests import ujson import time -import _thread -from mpos.apps import Activity -from mpos import PackageManager, ConnectivityManager -import mpos.info -import mpos.ui +from mpos import Activity, AppManager, ConnectivityManager, TaskManager, DownloadManager, DisplayMetrics, DeviceInfo, BuildInfo class OSUpdate(Activity): @@ -16,11 +11,11 @@ class OSUpdate(Activity): # Widgets: 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 @@ -38,11 +33,11 @@ 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 + self._update_ui_for_state() def onCreate(self): self.main_screen = lv.obj() - self.main_screen.set_style_pad_all(mpos.ui.pct_of_display_width(2), 0) + self.main_screen.set_style_pad_all(DisplayMetrics.pct_of_width(2), lv.PART.MAIN) # Make the screen focusable so it can be scrolled with the arrow keys if focusgroup := lv.group_get_default(): @@ -50,19 +45,16 @@ def onCreate(self): 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}") - self.force_update = lv.checkbox(self.main_screen) - self.force_update.set_text("Force Update") - self.force_update.add_event_cb(lambda *args: self.force_update_clicked(), lv.EVENT.VALUE_CHANGED, None) - #self.force_update.add_event_cb(lambda e: mpos.ui.print_event(e), lv.EVENT.ALL, None) - self.force_update.align_to(self.current_version_label, lv.ALIGN.OUT_BOTTOM_LEFT, 0, mpos.ui.pct_of_display_height(5)) + self.current_version_label.set_text(f"Installed OS version: {BuildInfo.version.release}") + self.current_version_label.set_width(lv.pct(75)) + self.current_version_label.set_long_mode(lv.label.LONG_MODE.WRAP) self.install_button = lv.button(self.main_screen) self.install_button.align(lv.ALIGN.TOP_RIGHT, 0, 0) self.install_button.add_state(lv.STATE.DISABLED) # button will be enabled if there is an update available self.install_button.set_size(lv.SIZE_CONTENT, lv.pct(25)) self.install_button.add_event_cb(lambda e: self.install_button_click(), lv.EVENT.CLICKED, None) install_label = lv.label(self.install_button) - install_label.set_text("Update OS") + install_label.set_text("No\nUpdate") install_label.center() # Check Again button (hidden initially, shown on errors) @@ -76,7 +68,7 @@ def onCreate(self): 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.status_label.align_to(self.current_version_label, lv.ALIGN.OUT_BOTTOM_LEFT, 0, DisplayMetrics.pct_of_height(5)) self.setContentView(self.main_screen) def _update_ui_for_state(self): @@ -137,12 +129,12 @@ def network_changed(self, 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() + TaskManager.create_task(self.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() + TaskManager.create_task(self.show_update_info()) elif self.current_state == UpdateState.DOWNLOAD_PAUSED: # Download was paused, will auto-resume in download thread pass @@ -185,22 +177,18 @@ def _get_user_friendly_error(self, error): else: 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() + async def show_update_info(self): + hwid = DeviceInfo.hardware_id try: # 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"] - ) + update_info = await self.update_checker.fetch_update_info(hwid) + if self.has_foreground(): + 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) @@ -212,7 +200,7 @@ def show_update_info(self, timer=None): except Exception as 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): + if DownloadManager.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) @@ -224,18 +212,35 @@ def show_update_info(self, timer=None): def handle_update_info(self, version, download_url, changelog): self.download_update_url = download_url - # 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) + # Compare versions to determine button text and state + current_version = BuildInfo.version.release + + # AppManager.compare_versions() returns 1 if ver1 > ver2, -1 if ver1 < ver2, 0 if equal + # We need to check three cases: newer, same, or older + is_newer = AppManager.compare_versions(version, current_version) + is_older = AppManager.compare_versions(current_version, version) + + # Determine button text based on version comparison + if is_newer > 0: + # Update version > installed OS version + button_text = "Install\nnew\nversion" + label = "newer" + elif is_older > 0: + # Update version < installed OS version + button_text = "Install\nold\nversion" + label = "older" else: - 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}" - self.status_label.set_text(label) + # Update version == installed OS version (neither is newer than the other) + button_text = "Reinstall\nsame\nversion" + label = "the same version" + + # Update button text and enable it + install_label = self.install_button.get_child(0) + install_label.set_text(button_text) + install_label.center() + self.install_button.remove_state(lv.STATE.DISABLED) + + self.status_label.set_text(f"Update version: {version}\nUpdate version is {label}.\n\nDetails:\n\n{changelog}") def install_button_click(self): @@ -250,58 +255,81 @@ def install_button_click(self): 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.set_size(lv.pct(80), lv.pct(10)) 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) - - def force_update_clicked(self): - if self.download_update_url and (self.force_update.get_state() & lv.STATE.CHECKED): - self.install_button.remove_state(lv.STATE.DISABLED) - else: - self.install_button.add_state(lv.STATE.DISABLED) + + # Use TaskManager instead of _thread for async download + TaskManager.create_task(self.perform_update()) 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() + self.show_update_info() - 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) + 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}") - # Custom OTA update with LVGL progress - def update_with_lvgl(self, url): - """Download and install update in background thread. + 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: # Loop to handle pause/resume cycles while self.has_foreground(): - # Use UpdateDownloader to handle the download - result = self.update_downloader.download_and_install( + # Use UpdateDownloader to handle the download (now async) + result = await self.update_downloader.download_and_install( url, - progress_callback=self.progress_callback, + 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.update_ui_threadsafe_if_foreground(self.status_label.set_text,"Update finished! Restarting...") - # Small delay to show the message - time.sleep(5) + self.status_label.set_text("Update finished! Restarting...") + await TaskManager.sleep(5) self.update_downloader.set_boot_partition_and_restart() return @@ -314,8 +342,7 @@ def update_with_lvgl(self, url): print(f"OSUpdate: Download paused at {percent:.1f}% ({bytes_written}/{total_size} bytes)") self.set_state(UpdateState.DOWNLOAD_PAUSED) - # Wait for wifi to return - # ConnectivityManager will notify us via callback when network returns + # 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 @@ -324,19 +351,19 @@ def update_with_lvgl(self, url): while elapsed < max_wait and self.has_foreground(): if self.connectivity_manager.is_online(): print("OSUpdate: Network reconnected, waiting for stabilization...") - time.sleep(2) # Let routing table and DNS fully stabilize + 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 - time.sleep(check_interval) + 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.update_ui_threadsafe_if_foreground(self.status_label.set_text, msg) - self.update_ui_threadsafe_if_foreground(self.install_button.remove_state, lv.STATE.DISABLED) + self.status_label.set_text(msg) + self.install_button.remove_state(lv.STATE.DISABLED) self.set_state(UpdateState.ERROR) return @@ -344,32 +371,40 @@ def update_with_lvgl(self, url): else: # Update failed with error (not pause) - 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.update_ui_threadsafe_if_foreground(self.status_label.set_text, msg) - self.update_ui_threadsafe_if_foreground(self.install_button.remove_state, lv.STATE.DISABLED) # allow retry + self._handle_update_error(result) return except Exception as e: - msg = self._get_user_friendly_error(e) + "\n\nPress 'Update OS' to retry." - self.set_state(UpdateState.ERROR) - self.update_ui_threadsafe_if_foreground(self.status_label.set_text, msg) - self.update_ui_threadsafe_if_foreground(self.install_button.remove_state, lv.STATE.DISABLED) # allow retry + self._handle_update_exception(e) + + def _handle_update_error(self, result): + print(f"Handle update error: {result}") + 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): + print(f"Handle update exception: {e}") + 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: @@ -386,26 +421,36 @@ class UpdateState: ERROR = "error" class UpdateDownloader: - """Handles downloading and installing OS updates.""" + """Handles downloading and installing OS updates using async DownloadManager.""" - def __init__(self, requests_module=None, partition_module=None, connectivity_manager=None): + # 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: - requests_module: HTTP requests module (defaults to requests) partition_module: ESP32 Partition module (defaults to esp32.Partition if available) connectivity_manager: ConnectivityManager instance for checking network during download + download_manager: DownloadManager instance for async downloads (defaults to DownloadManager class) """ - self.requests = requests_module if requests_module else requests self.partition_module = partition_module self.connectivity_manager = connectivity_manager + self.download_manager = download_manager if download_manager else DownloadManager self.simulate = False # Download state for pause/resume self.is_paused = False - self.bytes_written_so_far = 0 + self.bytes_written_so_far = 0 # Bytes written to partition (in complete 4096-byte blocks) 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: @@ -415,41 +460,89 @@ def __init__(self, requests_module=None, partition_module=None, connectivity_man 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. - + 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: - exception: Exception to check - - Returns: - bool: True if this is a recoverable network error + chunk: bytes data received from download """ - 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 download_and_install(self, url, progress_callback=None, should_continue_callback=None): - """Download firmware and install to OTA partition. + # 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 callback function(percent: float) + 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 @@ -460,9 +553,6 @@ def download_and_install(self, url, progress_callback=None, should_continue_call - 'total_size': int - 'error': str (if success=False) - 'paused': bool (if paused due to wifi loss) - - Raises: - Exception: If download or installation fails """ result = { 'success': False, @@ -472,135 +562,112 @@ def download_and_install(self, url, progress_callback=None, should_continue_call 'paused': False } + # Store callbacks for use in _process_chunk + self._progress_callback = progress_callback + self._should_continue = True + self._total_bytes_received = 0 + try: - # Get OTA partition - next_partition = None - if not self.simulate: - current = self.partition_module(self.partition_module.RUNNING) - next_partition = current.get_next_update() - print(f"UpdateDownloader: Writing to partition: {next_partition}") + # Setup partition + self._setup_partition() + + # Initialize block index from resume position + self._block_index = self.bytes_written_so_far // self.CHUNK_SIZE - # Start download (or resume if we have bytes_written_so_far) - headers = {} + # Build headers for resume - use bytes_written_so_far (last complete block) + # This ensures we re-download any partial/buffered data and overwrite any + # potentially corrupted block from when the error occurred + 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}") + headers = {'Range': f'bytes={self.bytes_written_so_far}-'} + print(f"UpdateDownloader: Resuming from byte {self.bytes_written_so_far} (last complete block)") - response = self.requests.get(url, stream=True, headers=headers) + # Get the download manager (use injected one for testing, or global) + dm = self.download_manager - # For initial download, get total size + # 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: - total_size = int(response.headers.get('Content-Length', 0)) - result['total_size'] = round_up_to_multiple(total_size, 4096) - self.total_size_expected = result['total_size'] - else: - # For resume, use the stored total size - # (Content-Length will be the remaining bytes, not total) - result['total_size'] = self.total_size_expected + # We'll update total_size_expected as we download + # For now, set a placeholder that will be updated + self.total_size_expected = 0 - print(f"UpdateDownloader: Download target {result['total_size']} bytes") + # 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 + ) - chunk_size = 4096 - bytes_written = self.bytes_written_so_far - block_index = bytes_written // chunk_size + if success: + # Flush any remaining buffered data + await self._flush_buffer() - while True: - # Check if we should continue (user cancelled) - if should_continue_callback and not should_continue_callback(): - result['error'] = "Download cancelled by user" - response.close() - return result - - # Check network connection before reading - 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 (pre-check), pausing download") - self.is_paused = True - self.bytes_written_so_far = bytes_written - result['paused'] = True - result['bytes_written'] = bytes_written - response.close() - return result - - # Read next chunk (may raise exception if network drops) - try: - chunk = response.raw.read(chunk_size) - except Exception as read_error: - # Check if this is a network error that should trigger pause - if self._is_network_error(read_error): - print(f"UpdateDownloader: Network error during read ({read_error}), pausing") - self.is_paused = True - self.bytes_written_so_far = bytes_written - result['paused'] = True - result['bytes_written'] = bytes_written - try: - response.close() - except: - pass - return result - else: - # Non-network error, re-raise - raise - - if not chunk: - break - - # Pad last chunk if needed - if len(chunk) < chunk_size: - print(f"UpdateDownloader: Padding chunk {block_index} from {len(chunk)} to {chunk_size} bytes") - chunk = chunk + b'\xFF' * (chunk_size - len(chunk)) - - # Write to partition - if not self.simulate: - next_partition.writeblocks(block_index, chunk) - - bytes_written += len(chunk) - self.bytes_written_so_far = bytes_written - block_index += 1 - - # Update progress - if progress_callback and result['total_size'] > 0: - percent = (bytes_written / result['total_size']) * 100 - progress_callback(percent) - - # Small delay to avoid hogging CPU - time.sleep_ms(100) - - response.close() - result['bytes_written'] = bytes_written - - # Check if complete - if bytes_written >= result['total_size']: 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 # Reset for next download + self.bytes_written_so_far = 0 self.total_size_expected = 0 - print(f"UpdateDownloader: Download complete ({bytes_written} bytes)") + 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: - result['error'] = f"Incomplete download: {bytes_written} < {result['total_size']}" - print(f"UpdateDownloader: {result['error']}") + # 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) + print(f"error_msg: {error_msg}") + + # 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 - if self._is_network_error(e): + elif DownloadManager.is_network_error(e): print(f"UpdateDownloader: Network error ({e}), pausing download") + + # Clear buffer - we'll re-download this data on resume + # This ensures we overwrite any potentially corrupted block + if self._chunk_buffer: + buffer_len = len(self._chunk_buffer) + print(f"UpdateDownloader: Discarding {buffer_len} bytes from buffer (will re-download on resume)") + self._chunk_buffer = b'' + self.is_paused = True - # Only update bytes_written_so_far if we actually wrote bytes in this attempt - # Otherwise preserve the existing state (important for resume failures) - if result.get('bytes_written', 0) > 0: - self.bytes_written_so_far = result['bytes_written'] result['paused'] = True - result['bytes_written'] = self.bytes_written_so_far - result['total_size'] = self.total_size_expected # Preserve total size for UI + result['bytes_written'] = self.bytes_written_so_far # Resume from last complete block + result['total_size'] = self.total_size_expected + print(f"UpdateDownloader: Will resume from byte {self.bytes_written_so_far} (last complete block)") else: # Non-network error - result['error'] = str(e) + 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 @@ -630,33 +697,20 @@ def set_boot_partition_and_restart(self): class UpdateChecker: """Handles checking for OS updates from remote server.""" - def __init__(self, requests_module=None, json_module=None): + def __init__(self, download_manager=None, json_module=None): """Initialize with optional dependency injection for testing. Args: - requests_module: HTTP requests module (defaults to requests) + download_manager: DownloadManager instance (defaults to DownloadManager class) json_module: JSON parsing module (defaults to ujson) """ - self.requests = requests_module if requests_module else requests + self.download_manager = download_manager if download_manager else DownloadManager 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. + return f"https://updates.micropythonos.com/osupdate_{hardware_id}.json" - 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): + async def fetch_update_info(self, hardware_id): """Fetch and parse update information from server. Args: @@ -668,27 +722,20 @@ def fetch_update_info(self, hardware_id): Raises: ValueError: If JSON is malformed or missing required fields - ConnectionError: If network request fails + RuntimeError: 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}" - ) + # Use DownloadManager to fetch the JSON data + response_data = await self.download_manager.download_url(url) # Parse JSON try: - update_data = self.json.loads(response.text) + update_data = self.json.loads(response_data) 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'] @@ -718,7 +765,7 @@ def is_update_available(self, remote_version, current_version): Returns: bool: True if remote version is newer """ - return PackageManager.compare_versions(remote_version, current_version) + return AppManager.compare_versions(remote_version, current_version) # Non-class functions: diff --git a/internal_filesystem/builtin/apps/com.micropythonos.osupdate/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/builtin/apps/com.micropythonos.osupdate/res/mipmap-mdpi/icon_64x64.png index 3cff67c5..6ee8056f 100644 Binary files a/internal_filesystem/builtin/apps/com.micropythonos.osupdate/res/mipmap-mdpi/icon_64x64.png and b/internal_filesystem/builtin/apps/com.micropythonos.osupdate/res/mipmap-mdpi/icon_64x64.png differ diff --git a/internal_filesystem/builtin/apps/com.micropythonos.settings.hotspot/META-INF/MANIFEST.JSON b/internal_filesystem/builtin/apps/com.micropythonos.settings.hotspot/META-INF/MANIFEST.JSON new file mode 100644 index 00000000..c3b07729 --- /dev/null +++ b/internal_filesystem/builtin/apps/com.micropythonos.settings.hotspot/META-INF/MANIFEST.JSON @@ -0,0 +1,23 @@ +{ +"name": "Hotspot", +"publisher": "MicroPythonOS", +"short_description": "Configure Wi-Fi hotspot settings.", +"long_description": "Configure and toggle the device Wi-Fi hotspot, including SSID, security, and network options.", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.settings.hotspot/icons/com.micropythonos.settings.hotspot_0.1.0_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.settings.hotspot/mpks/com.micropythonos.settings.hotspot_0.1.0.mpk", +"fullname": "com.micropythonos.settings.hotspot", +"version": "0.1.0", +"category": "networking", +"activities": [ + { + "entrypoint": "assets/hotspot_settings.py", + "classname": "HotspotSettings", + "intent_filters": [ + { + "action": "main", + "category": "launcher" + } + ] + } + ] +} diff --git a/internal_filesystem/builtin/apps/com.micropythonos.settings.hotspot/assets/hotspot_settings.py b/internal_filesystem/builtin/apps/com.micropythonos.settings.hotspot/assets/hotspot_settings.py new file mode 100644 index 00000000..84b6f9d4 --- /dev/null +++ b/internal_filesystem/builtin/apps/com.micropythonos.settings.hotspot/assets/hotspot_settings.py @@ -0,0 +1,143 @@ +import lvgl as lv + +from mpos import Activity, DisplayMetrics, Intent, SettingsActivity, SharedPreferences, WifiService + + +class HotspotSettings(Activity): + """ + Hotspot configuration app. + + Uses SettingsActivity to render and edit hotspot preferences stored under + com.micropythonos.settings.hotspot. + """ + + DEFAULTS = { + "ssid": "MicroPythonOS", + "password": "", + "authmode": "none", + } + + status_label = None + action_button = None + action_label = None + settings_button = None + prefs = None + + def onCreate(self): + self.prefs = SharedPreferences("com.micropythonos.settings.hotspot", defaults=self.DEFAULTS) + self.ui_prefs = SharedPreferences("com.micropythonos.settings.hotspot") + screen = lv.obj() + screen.set_style_border_width(0, lv.PART.MAIN) + screen.set_style_pad_all(DisplayMetrics.pct_of_width(3), lv.PART.MAIN) + screen.set_flex_flow(lv.FLEX_FLOW.COLUMN) + + header = lv.label(screen) + header.set_text("Hotspot") + header.set_style_text_font(lv.font_montserrat_20, lv.PART.MAIN) + + self.status_label = lv.label(screen) + self.status_label.set_style_text_font(lv.font_montserrat_14, lv.PART.MAIN) + self.status_label.set_long_mode(lv.label.LONG_MODE.WRAP) + self.status_label.set_width(lv.pct(100)) + + button_row = lv.obj(screen) + button_row.set_width(lv.pct(100)) + button_row.set_height(lv.SIZE_CONTENT) + button_row.set_style_border_width(0, lv.PART.MAIN) + button_row.set_style_pad_all(10, lv.PART.MAIN) + button_row.set_flex_flow(lv.FLEX_FLOW.ROW) + button_row.set_style_flex_main_place(lv.FLEX_ALIGN.SPACE_BETWEEN, lv.PART.MAIN) + + self.action_button = lv.button(button_row) + self.action_button.set_size(lv.pct(45), lv.SIZE_CONTENT) + self.action_button.add_event_cb(self.toggle_hotspot_button, lv.EVENT.CLICKED, None) + self.action_label = lv.label(self.action_button) + self.action_label.center() + + self.settings_button = lv.button(button_row) + self.settings_button.set_size(lv.pct(45), lv.SIZE_CONTENT) + self.settings_button.add_event_cb(self.open_settings, lv.EVENT.CLICKED, None) + settings_label = lv.label(self.settings_button) + settings_label.set_text("Settings") + settings_label.center() + + self.setContentView(screen) + + def onResume(self, screen): + super().onResume(screen) + self.refresh_status() + + def refresh_status(self): + is_running = WifiService.is_hotspot_enabled() + state_text = "Running" if is_running else "Stopped" + self.prefs.load() + self.ui_prefs.load() + ssid = self.ui_prefs.get_string("ssid", self.DEFAULTS["ssid"]) + authmode = self.ui_prefs.get_string("authmode", self.DEFAULTS["authmode"]) + security_text = self._format_security_label(authmode) + self.status_label.set_text( + f"Status: {state_text}\nHotspot name: {ssid}\nSecurity: {security_text}" + ) + button_text = "Stop" if is_running else "Start" + self.action_label.set_text(button_text) + self.action_label.center() + + def toggle_hotspot_button(self, event): + if WifiService.is_hotspot_enabled(): + WifiService.disable_hotspot() + else: + WifiService.enable_hotspot() + self.refresh_status() + + def open_settings(self, event): + intent = Intent(activity_class=SettingsActivity) + intent.putExtra("prefs", self.ui_prefs) + intent.putExtra("settings", self._settings_entries()) + self.startActivity(intent) + + def _settings_entries(self): + return [ + { + "title": "Network Name (SSID)", + "key": "ssid", + "placeholder": "Hotspot SSID", + "default_value": self.DEFAULTS["ssid"], + }, + { + "title": "Password", + "key": "password", + "placeholder": "Leave empty for open network", + "default_value": self.DEFAULTS["password"], + "should_show": self.should_show_password, + }, + { + "title": "Auth Mode", + "key": "authmode", + "ui": "dropdown", + "ui_options": [ + ("None", "none"), + ("WPA2", "wpa2"), + ], + "default_value": self.DEFAULTS["authmode"], + "changed_callback": self.toggle_hotspot, + }, + ] + + def toggle_hotspot(self, new_value): + if WifiService.is_hotspot_enabled(): + WifiService.enable_hotspot() + self.refresh_status() + + def should_show_password(self, setting): + authmode = self.ui_prefs.get_string("authmode", None) + if authmode is None: + authmode = self.DEFAULTS["authmode"] + return authmode != "none" + + def _format_security_label(self, authmode): + labels = { + "none": "None", + "wpa2": "WPA2", + } + return labels.get(authmode, "WPA2") + diff --git a/internal_filesystem/builtin/apps/com.micropythonos.settings.hotspot/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/builtin/apps/com.micropythonos.settings.hotspot/res/mipmap-mdpi/icon_64x64.png new file mode 100644 index 00000000..497e0f0b Binary files /dev/null and b/internal_filesystem/builtin/apps/com.micropythonos.settings.hotspot/res/mipmap-mdpi/icon_64x64.png differ diff --git a/internal_filesystem/builtin/apps/com.micropythonos.settings.webserver/META-INF/MANIFEST.JSON b/internal_filesystem/builtin/apps/com.micropythonos.settings.webserver/META-INF/MANIFEST.JSON new file mode 100644 index 00000000..ba3fe285 --- /dev/null +++ b/internal_filesystem/builtin/apps/com.micropythonos.settings.webserver/META-INF/MANIFEST.JSON @@ -0,0 +1,23 @@ +{ +"name": "WebServer", +"publisher": "MicroPythonOS", +"short_description": "Configure and control the WebServer.", +"long_description": "Configure WebServer settings, start or stop the WebREPL web server.", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.settings.webserver/icons/com.micropythonos.settings.webserver_0.1.0_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.settings.webserver/mpks/com.micropythonos.settings.webserver_0.1.0.mpk", +"fullname": "com.micropythonos.settings.webserver", +"version": "0.1.0", +"category": "networking", +"activities": [ + { + "entrypoint": "assets/webserver_settings.py", + "classname": "WebServerSettings", + "intent_filters": [ + { + "action": "main", + "category": "launcher" + } + ] + } + ] +} diff --git a/internal_filesystem/builtin/apps/com.micropythonos.settings.webserver/assets/webserver_settings.py b/internal_filesystem/builtin/apps/com.micropythonos.settings.webserver/assets/webserver_settings.py new file mode 100644 index 00000000..86d6d721 --- /dev/null +++ b/internal_filesystem/builtin/apps/com.micropythonos.settings.webserver/assets/webserver_settings.py @@ -0,0 +1,119 @@ +import lvgl as lv + +from mpos import Activity, DisplayMetrics, Intent, SettingsActivity, SharedPreferences, WebServer, WifiService + + +class WebServerSettings(Activity): + status_label = None + detail_label = None + action_button = None + action_label = None + settings_button = None + + def onCreate(self): + self.ui_prefs = SharedPreferences(WebServer.PREFS_NAMESPACE) + screen = lv.obj() + screen.set_style_border_width(0, lv.PART.MAIN) + screen.set_style_pad_all(DisplayMetrics.pct_of_width(3), lv.PART.MAIN) + screen.set_flex_flow(lv.FLEX_FLOW.COLUMN) + + header = lv.label(screen) + header.set_text("WebServer") + header.set_style_text_font(lv.font_montserrat_20, lv.PART.MAIN) + + self.status_label = lv.label(screen) + self.status_label.set_style_text_font(lv.font_montserrat_14, lv.PART.MAIN) + self.status_label.set_long_mode(lv.label.LONG_MODE.WRAP) + self.status_label.set_width(lv.pct(100)) + + self.detail_label = lv.label(screen) + self.detail_label.set_style_text_font(lv.font_montserrat_12, lv.PART.MAIN) + self.detail_label.set_long_mode(lv.label.LONG_MODE.WRAP) + self.detail_label.set_width(lv.pct(100)) + + button_row = lv.obj(screen) + button_row.set_width(lv.pct(100)) + button_row.set_height(lv.SIZE_CONTENT) + button_row.set_style_border_width(0, lv.PART.MAIN) + button_row.set_style_pad_all(10, lv.PART.MAIN) + button_row.set_flex_flow(lv.FLEX_FLOW.ROW) + button_row.set_style_flex_main_place(lv.FLEX_ALIGN.SPACE_BETWEEN, lv.PART.MAIN) + + self.action_button = lv.button(button_row) + self.action_button.set_size(lv.pct(45), lv.SIZE_CONTENT) + self.action_button.add_event_cb(self.toggle_webserver, lv.EVENT.CLICKED, None) + self.action_label = lv.label(self.action_button) + self.action_label.center() + + self.settings_button = lv.button(button_row) + self.settings_button.set_size(lv.pct(45), lv.SIZE_CONTENT) + self.settings_button.add_event_cb(self.open_settings, lv.EVENT.CLICKED, None) + settings_label = lv.label(self.settings_button) + settings_label.set_text("Settings") + settings_label.center() + + self.setContentView(screen) + + def onResume(self, screen): + super().onResume(screen) + self.refresh_status() + + def refresh_status(self): + status = WebServer.status() + state_text = "Running" if status.get("started") else "Stopped" + self.status_label.set_text(f"Status: {state_text}") + autostart_text = "On" if status.get("autostart") else "Off" + port = status.get("port") + ip_address = WifiService.get_ipv4_address() + if ip_address: + url_text = f"http://{ip_address}:{port}/" + else: + url_text = f"http://:{port}/" + self.detail_label.set_text(f"URL: {url_text}\nAutostart: {autostart_text}") + + button_text = "Stop" if status.get("started") else "Start" + self.action_label.set_text(button_text) + self.action_label.center() + + def toggle_webserver(self, event): + if WebServer.is_started(): + WebServer.stop() + else: + WebServer.start() + self.refresh_status() + + def open_settings(self, event): + intent = Intent(activity_class=SettingsActivity) + intent.putExtra("prefs", self.ui_prefs) + intent.putExtra( + "settings", + [ + { + "title": "Autostart", + "key": "autostart", + "ui": "radiobuttons", + "ui_options": [("On", "True"), ("Off", "False")], + "default_value": WebServer.DEFAULTS["autostart"], + "changed_callback": self.settings_changed, + }, + { + "title": "Port", + "key": "port", + "placeholder": "WebServer port, e.g. 7890", + "default_value": WebServer.DEFAULTS["port"], + "changed_callback": self.settings_changed, + }, + { + "title": "Password", + "key": "password", + "placeholder": "Max 9 characters", + "default_value": WebServer.DEFAULTS["password"], + "changed_callback": self.settings_changed, + }, + ], + ) + self.startActivity(intent) + + def settings_changed(self, new_value): + WebServer.apply_settings(restart_if_running=True) + self.refresh_status() diff --git a/internal_filesystem/builtin/apps/com.micropythonos.wifi/META-INF/MANIFEST.JSON b/internal_filesystem/builtin/apps/com.micropythonos.settings.wifi/META-INF/MANIFEST.JSON similarity index 64% rename from internal_filesystem/builtin/apps/com.micropythonos.wifi/META-INF/MANIFEST.JSON rename to internal_filesystem/builtin/apps/com.micropythonos.settings.wifi/META-INF/MANIFEST.JSON index 0c09327e..ede10083 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.wifi/META-INF/MANIFEST.JSON +++ b/internal_filesystem/builtin/apps/com.micropythonos.settings.wifi/META-INF/MANIFEST.JSON @@ -3,15 +3,15 @@ "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.10_64x64.png", -"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.wifi/mpks/com.micropythonos.wifi_0.0.10.mpk", -"fullname": "com.micropythonos.wifi", -"version": "0.0.10", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.settings.wifi/icons/com.micropythonos.settings.wifi_0.1.2_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.settings.wifi/mpks/com.micropythonos.settings.wifi_0.1.2.mpk", +"fullname": "com.micropythonos.settings.wifi", +"version": "0.1.2", "category": "networking", "activities": [ { - "entrypoint": "assets/wifi.py", - "classname": "WiFi", + "entrypoint": "assets/wifi_settings.py", + "classname": "WiFiSettings", "intent_filters": [ { "action": "main", diff --git a/internal_filesystem/builtin/apps/com.micropythonos.settings.wifi/assets/wifi_settings.py b/internal_filesystem/builtin/apps/com.micropythonos.settings.wifi/assets/wifi_settings.py new file mode 100644 index 00000000..d4b4401d --- /dev/null +++ b/internal_filesystem/builtin/apps/com.micropythonos.settings.wifi/assets/wifi_settings.py @@ -0,0 +1,449 @@ +import time +import lvgl as lv +import _thread + +from mpos import Activity, Intent, MposKeyboard, WifiService, CameraActivity, DisplayMetrics, CameraManager, TaskManager + +class WiFiSettings(Activity): + """ + WiFi settings app for MicroPythonOS. + + This is a pure UI layer - all WiFi operations are delegated to WifiService. + """ + + last_tried_ssid = "" + last_tried_result = "" + + scan_button_scan_text = "Rescan" + scan_button_scanning_text = "Scanning..." + + scanned_ssids = [] + busy_scanning = False + busy_connecting = False + error_timer = None + + # Widgets: + aplist = None + error_label = None + scan_button = None + scan_button_label = None + + def onCreate(self): + print("wifi.py onCreate") + main_screen = lv.obj() + main_screen.set_style_pad_all(5, lv.PART.MAIN) + self.aplist = lv.list(main_screen) + self.aplist.set_size(lv.pct(100), lv.pct(75)) + self.aplist.align(lv.ALIGN.TOP_MID, 0, 0) + self.error_label = lv.label(main_screen) + self.error_label.set_text("THIS IS ERROR TEXT THAT WILL BE SET LATER") + self.error_label.align_to(self.aplist, lv.ALIGN.OUT_BOTTOM_LEFT, 0, 0) + #self.error_label.align(lv.ALIGN.BOTTOM_LEFT, 0, 0) + self.error_label.add_flag(lv.obj.FLAG.HIDDEN) + self.add_network_button = lv.button(main_screen) + self.add_network_button.set_size(lv.SIZE_CONTENT, lv.pct(15)) + self.add_network_button.align(lv.ALIGN.BOTTOM_LEFT, 0, 0) + self.add_network_button.add_event_cb(self.add_network_callback, lv.EVENT.CLICKED, None) + self.add_network_button_label = lv.label(self.add_network_button) + self.add_network_button_label.set_text("Add network") + self.add_network_button_label.center() + self.scan_button = lv.button(main_screen) + self.scan_button.set_size(lv.SIZE_CONTENT, lv.pct(15)) + self.scan_button.align(lv.ALIGN.BOTTOM_RIGHT, 0, 0) + self.scan_button.add_event_cb(self.scan_cb, lv.EVENT.CLICKED, None) + self.scan_button_label = lv.label(self.scan_button) + self.scan_button_label.set_text(self.scan_button_scan_text) + self.scan_button_label.center() + self.setContentView(main_screen) + + def onResume(self, screen): + print("wifi.py onResume") + super().onResume(screen) + + # Ensure WifiService has loaded saved networks + WifiService.get_saved_networks() + + if len(self.scanned_ssids) == 0: + if not WifiService.is_busy(): + self.start_scan_networks() + else: + self.show_error("Wifi is busy, please try again later.") + + def show_error(self, message): + # Schedule UI updates because different thread + print(f"show_error: Displaying error: {message}") + self.update_ui_threadsafe_if_foreground(self.error_label.set_text, message) + self.update_ui_threadsafe_if_foreground(self.error_label.remove_flag, lv.obj.FLAG.HIDDEN) + self.error_timer = lv.timer_create(self.hide_error, 5000, None) + self.error_timer.set_repeat_count(1) + + def hide_error(self, timer): + self.update_ui_threadsafe_if_foreground(self.error_label.add_flag, lv.obj.FLAG.HIDDEN) + + def scan_networks_thread(self): + print("scan_networks: Scanning for Wi-Fi networks") + try: + self.scanned_ssids = WifiService.scan_networks() + print(f"scan_networks: Found networks: {self.scanned_ssids}") + except Exception as e: + print(f"scan_networks: Scan failed: {e}") + self.show_error("Wi-Fi scan failed") + # scan done - WifiService.scan_networks() manages wifi_busy flag internally + self.busy_scanning = 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) + + def start_scan_networks(self): + if self.busy_scanning: + print("Not scanning for networks because already busy_scanning.") + return + self.busy_scanning = True + self.scan_button.add_state(lv.STATE.DISABLED) + self.scan_button_label.set_text(self.scan_button_scanning_text) + _thread.stack_size(TaskManager.good_stack_size()) + _thread.start_new_thread(self.scan_networks_thread, ()) + + def refresh_list(self): + print("refresh_list: Clearing current list") + self.aplist.clean() # this causes an issue with lost taps if an ssid is clicked that has been removed + print("refresh_list: Populating list with scanned networks") + + # Combine scanned SSIDs with saved networks + saved_networks = WifiService.get_saved_networks() + all_ssids = set(self.scanned_ssids + saved_networks) + + for ssid in all_ssids: + if len(ssid) < 1 or len(ssid) > 32: + print(f"Skipping too short or long SSID: {ssid}") + continue + print(f"refresh_list: Adding SSID: {ssid}") + button = self.aplist.add_button(None, ssid) + button.add_event_cb(lambda e, s=ssid: self.select_ssid_cb(s), lv.EVENT.CLICKED, None) + + # Determine status + status = "" + current_ssid = WifiService.get_current_ssid() + if current_ssid == ssid: + status = "connected" + elif self.last_tried_ssid == ssid: + # Show last connection attempt result + status = self.last_tried_result + elif ssid in saved_networks: + status = "saved" + + label = lv.label(button) + label.set_text(status) + label.align(lv.ALIGN.RIGHT_MID, 0, 0) + + def add_network_callback(self, event): + print(f"add_network_callback clicked") + intent = Intent(activity_class=EditNetwork) + intent.putExtra("selected_ssid", None) + self.startActivityForResult(intent, self.edit_network_result_callback) + + def scan_cb(self, event): + print("scan_cb: Scan button clicked, refreshing list") + self.start_scan_networks() + + def select_ssid_cb(self, ssid): + print(f"select_ssid_cb: SSID selected: {ssid}") + intent = Intent(activity_class=EditNetwork) + intent.putExtra("selected_ssid", ssid) + intent.putExtra("known_password", WifiService.get_network_password(ssid)) + intent.putExtra("hidden", WifiService.get_network_hidden(ssid)) + self.startActivityForResult(intent, self.edit_network_result_callback) + + def edit_network_result_callback(self, result): + print(f"EditNetwork finished, result: {result}") + if result.get("result_code") is True: + data = result.get("data") + if data: + ssid = data.get("ssid") + forget = data.get("forget") + if forget: + WifiService.forget_network(ssid) + self.refresh_list() + else: + # Save or update the network + password = data.get("password") + hidden = data.get("hidden") + WifiService.save_network(ssid, password, hidden) + self.start_attempt_connecting(ssid, password) + + def start_attempt_connecting(self, ssid, password): + print(f"start_attempt_connecting: Attempting to connect to SSID '{ssid}' with password '{password}'") + self.scan_button.add_state(lv.STATE.DISABLED) + self.scan_button_label.set_text("Connecting...") + if self.busy_connecting: + print("Not attempting connect because busy_connecting.") + else: + self.busy_connecting = True + _thread.stack_size(TaskManager.good_stack_size()) + _thread.start_new_thread(self.attempt_connecting_thread, (ssid, password)) + + def attempt_connecting_thread(self, ssid, password): + print(f"attempt_connecting_thread: Attempting to connect to SSID '{ssid}'") + result = "connected" + try: + if WifiService.attempt_connecting(ssid, password): + result = "connected" + else: + result = "timeout" + except Exception as e: + print(f"attempt_connecting: Connection error: {e}") + result = f"{e}" + self.show_error(f"Connecting to {ssid} failed!") + + print(f"Connecting to {ssid} got result: {result}") + self.last_tried_ssid = ssid + self.last_tried_result = result + + # Note: Time sync is handled by WifiService.attempt_connecting() + + self.busy_connecting = 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) + + +class EditNetwork(Activity): + + selected_ssid = None + + # Widgets: + ssid_ta = None + password_ta = None + hidden_cb = None + keyboard = None + connect_button = None + cancel_button = None + forget_button = None + + action_button_label_forget = "Forget" + action_button_label_scanqr = "Scan QR" + + def onCreate(self): + password_page = lv.obj() + password_page.set_style_pad_all(0, lv.PART.MAIN) + password_page.set_flex_flow(lv.FLEX_FLOW.COLUMN) + self.selected_ssid = self.getIntent().extras.get("selected_ssid") + known_password = self.getIntent().extras.get("known_password") + known_hidden = self.getIntent().extras.get("hidden", False) + + # SSID: + if self.selected_ssid is None: + print("No ssid selected, the user should fill it out.") + label = lv.label(password_page) + label.set_text(f"Network name:") + self.ssid_ta = lv.textarea(password_page) + self.ssid_ta.set_width(lv.pct(100)) + self.ssid_ta.set_style_margin_left(DisplayMetrics.pct_of_width(2), lv.PART.MAIN) + self.ssid_ta.set_style_margin_right(DisplayMetrics.pct_of_width(2), lv.PART.MAIN) + self.ssid_ta.set_one_line(True) + self.ssid_ta.set_placeholder_text("Enter the SSID") + self.keyboard = MposKeyboard(password_page) + self.keyboard.set_textarea(self.ssid_ta) + self.keyboard.add_flag(lv.obj.FLAG.HIDDEN) + + # Password: + label = lv.label(password_page) + if self.selected_ssid is None: + label.set_text("Password:") + else: + label.set_text(f"Password for '{self.selected_ssid}':") + self.password_ta = lv.textarea(password_page) + self.password_ta.set_width(lv.pct(100)) + self.password_ta.set_style_margin_left(DisplayMetrics.pct_of_width(2), lv.PART.MAIN) + self.password_ta.set_style_margin_right(DisplayMetrics.pct_of_width(2), lv.PART.MAIN) + self.password_ta.set_one_line(True) + if known_password: + self.password_ta.set_text(known_password) + self.password_ta.set_placeholder_text("Password") + self.keyboard = MposKeyboard(password_page) + self.keyboard.set_textarea(self.password_ta) + self.keyboard.add_flag(lv.obj.FLAG.HIDDEN) + + # Hidden network: + hidden_cont = lv.obj(password_page) + hidden_cont.set_width(lv.pct(100)) + hidden_cont.set_height(lv.SIZE_CONTENT) + hidden_cont.set_style_bg_opa(lv.OPA.TRANSP, lv.PART.MAIN) + hidden_cont.set_style_border_width(0, lv.PART.MAIN) + hidden_cont.set_style_pad_all(0, lv.PART.MAIN) + hidden_cont.remove_flag(lv.obj.FLAG.SCROLLABLE) + self.hidden_cb = lv.checkbox(hidden_cont) + self.hidden_cb.set_text("") + self.hidden_cb.align(lv.ALIGN.LEFT_MID, 0, 0) + label = lv.label(hidden_cont) + label.set_text("Hidden network (always try connecting)") + label.set_long_mode(lv.label.LONG_MODE.WRAP) + label.set_width(lv.pct(85)) + label.align_to(self.hidden_cb, lv.ALIGN.OUT_RIGHT_MID, 0, 0) + label.add_event_cb(self.hidden_clicked,lv.EVENT.CLICKED,None) + label.add_flag(lv.obj.FLAG.CLICKABLE) + label.add_event_cb(lambda e, cont=label: self.focus_app_cont(cont),lv.EVENT.FOCUSED, None) + #label.add_event_cb(lambda e, cont=self.hidden_cb: self.focus_app_cont(cont),lv.EVENT.FOCUSED, None) + label.add_event_cb(lambda e, cont=label: self.defocus_app_cont(cont),lv.EVENT.DEFOCUSED, None) + #label.add_event_cb(lambda e, cont=self.hidden_cb: self.defocus_app_cont(cont),lv.EVENT.DEFOCUSED, None) + focusgroup = lv.group_get_default() + if focusgroup: + focusgroup.add_obj(label) + if known_hidden: + self.hidden_cb.set_state(lv.STATE.CHECKED, True) + + # Action buttons: + buttons = lv.obj(password_page) + buttons.set_width(lv.pct(100)) + buttons.set_height(lv.SIZE_CONTENT) + buttons.set_style_bg_opa(lv.OPA.TRANSP, lv.PART.MAIN) + buttons.set_style_border_width(0, lv.PART.MAIN) + buttons.set_style_pad_all(0, lv.PART.MAIN) + # Forget / Scan QR button + self.forget_button = lv.button(buttons) + self.forget_button.align(lv.ALIGN.LEFT_MID, 0, 0) + self.forget_button.add_event_cb(self.forget_cb, lv.EVENT.CLICKED, None) + label = lv.label(self.forget_button) + label.center() + if self.selected_ssid: + label.set_text(self.action_button_label_forget) + else: + if CameraManager.has_camera(): + label.set_text(self.action_button_label_scanqr) + else: + self.forget_button.add_flag(lv.obj.FLAG.HIDDEN) + # Close button + self.cancel_button = lv.button(buttons) + self.cancel_button.center() + self.cancel_button.set_style_margin_top(5, lv.PART.MAIN) + self.cancel_button.set_style_margin_bottom(5, lv.PART.MAIN) + self.cancel_button.add_event_cb(lambda *args: self.finish(), lv.EVENT.CLICKED, None) + label = lv.label(self.cancel_button) + label.set_text("Close") + label.center() + # Connect button + self.connect_button = lv.button(buttons) + self.connect_button.align(lv.ALIGN.RIGHT_MID, 0, 0) + self.connect_button.add_event_cb(self.connect_cb, lv.EVENT.CLICKED, None) + label = lv.label(self.connect_button) + label.set_text("Connect") + label.center() + + self.setContentView(password_page) + + def focus_app_cont(self, app_cont): + app_cont.set_style_border_color(lv.theme_get_color_primary(None), lv.PART.MAIN) + app_cont.set_style_border_width(2, lv.PART.MAIN) + app_cont.set_style_border_opa(lv.OPA._50, lv.PART.MAIN) + app_cont.set_style_radius(5, lv.PART.MAIN) + app_cont.scroll_to_view(True) + app_cont.set_style_pad_all(2, lv.PART.MAIN) + + def defocus_app_cont(self, app_cont): + app_cont.set_style_border_width(0, lv.PART.MAIN) + + def hidden_clicked(self, event): + print("hidden clicked") + checked = self.hidden_cb.get_state() & lv.STATE.CHECKED + self.hidden_cb.set_state(lv.STATE.CHECKED, not checked) + + def connect_cb(self, event): + # Validate the form + if self.selected_ssid is None: + new_ssid = self.ssid_ta.get_text() + if not new_ssid: + self.ssid_ta.set_style_bg_color(lv.color_hex(0xff8080), lv.PART.MAIN) + return + else: + self.selected_ssid = new_ssid + # If a password is filled, then it should be at least 8 characters: + pwd = self.password_ta.get_text() + if len(pwd) > 0 and len(pwd) < 8: + self.password_ta.set_style_bg_color(lv.color_hex(0xff8080), lv.PART.MAIN) + return + + # Return the result + hidden_checked = True if self.hidden_cb.get_state() & lv.STATE.CHECKED else False + self.setResult(True, {"ssid": self.selected_ssid, "password": pwd, "hidden": hidden_checked}) + self.finish() + + def forget_cb(self, event): + label = self.forget_button.get_child(0) + if not label: + return + action = label.get_text() + print(f"{action} button clicked") + if action == self.action_button_label_forget: + print("Closing Activity") + self.setResult(True, {"ssid": self.selected_ssid, "forget": True}) + self.finish() + else: + print("Opening CameraApp") + self.startActivityForResult(Intent(activity_class=CameraActivity).putExtra("scanqr_intent", True), self.gotqr_result_callback) + + def gotqr_result_callback(self, result): + print(f"QR capture finished, result: {result}") + if result.get("result_code"): + data = result.get("data") + print(f"Setting textarea data: {data}") + authentication_type, ssid, password, hidden = self.decode_wifi_qr_code(data) + if ssid and self.ssid_ta: # not always present + self.ssid_ta.set_text(ssid) + if password: + self.password_ta.set_text(password) + if hidden is True: + self.hidden_cb.set_state(lv.STATE.CHECKED, True) + elif hidden is False: + self.hidden_cb.remove_state(lv.STATE.CHECKED) + + @staticmethod + def decode_wifi_qr_code(to_decode): + """ + Decode a WiFi QR code string in the format: + WIFI:T:WPA;S:SSID;P:PASSWORD;H:hidden; + + Returns: (authentication_type, ssid, password, hidden) + """ + print(f"decoding {to_decode}") + + # Initialize return values + authentication_type = "WPA" + ssid = None + password = None + hidden = False + + try: + # Remove the "WIFI:" prefix if present + if to_decode.startswith("WIFI:"): + to_decode = to_decode[5:] + + # Split by semicolon to get key-value pairs + pairs = to_decode.split(";") + + for pair in pairs: + if not pair: # Skip empty strings + continue + + # Split by colon to get key and value + if ":" not in pair: + continue + + key, value = pair.split(":", 1) + + if key == "T": + # Authentication type (WPA, WEP, nopass, etc.) + authentication_type = value + elif key == "S": + # SSID (network name) + ssid = value + elif key == "P": + # Password + password = value + elif key == "H": + # Hidden network (true/false) + hidden = value.lower() in ("true", "1", "yes") + + except Exception as e: + print(f"Error decoding WiFi QR code: {e}") + + return authentication_type, ssid, password, hidden \ No newline at end of file diff --git a/internal_filesystem/builtin/apps/com.micropythonos.settings.wifi/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/builtin/apps/com.micropythonos.settings.wifi/res/mipmap-mdpi/icon_64x64.png new file mode 100644 index 00000000..155856f8 Binary files /dev/null and b/internal_filesystem/builtin/apps/com.micropythonos.settings.wifi/res/mipmap-mdpi/icon_64x64.png differ 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 8bdf1233..719e8a9d 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,15 +3,15 @@ "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.8_64x64.png", -"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.settings/mpks/com.micropythonos.settings_0.0.8.mpk", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.settings/icons/com.micropythonos.settings_0.1.3_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.settings/mpks/com.micropythonos.settings_0.1.3.mpk", "fullname": "com.micropythonos.settings", -"version": "0.0.8", +"version": "0.1.3", "category": "development", "activities": [ { "entrypoint": "assets/settings.py", - "classname": "SettingsActivity", + "classname": "Settings", "intent_filters": [ { "action": "main", diff --git a/internal_filesystem/lib/mpos/bootloader.py b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/bootloader.py similarity index 77% rename from internal_filesystem/lib/mpos/bootloader.py rename to internal_filesystem/builtin/apps/com.micropythonos.settings/assets/bootloader.py index d8bfab47..7910c866 100644 --- a/internal_filesystem/lib/mpos/bootloader.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/bootloader.py @@ -1,7 +1,9 @@ -from mpos.apps import Activity import lvgl as lv +from mpos import Activity + class ResetIntoBootloader(Activity): + message = "Bootloader mode activated.\nYou can now install firmware over USB.\n\nReset the device to cancel." def onCreate(self): @@ -13,9 +15,8 @@ def onCreate(self): self.setContentView(screen) def onResume(self, screen): - # Use a timer, otherwise the UI won't have time to update: - timer = lv.timer_create(self.start_bootloader, 1000, None) # give it some time (at least 500ms) for the new screen animation - timer.set_repeat_count(1) + print("Starting start_bootloader time so the UI has time to update") + timer = lv.timer_create(self.start_bootloader, 1000, None).set_repeat_count(1) def start_bootloader(self, timer): try: 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 index 009a2e75..6cf81305 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/calibrate_imu.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/calibrate_imu.py @@ -10,10 +10,7 @@ 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 +from mpos import Activity, SensorManager, DisplayMetrics class CalibrationState: @@ -27,9 +24,7 @@ class CalibrationState: class CalibrateIMUActivity(Activity): """Guide user through IMU calibration process.""" - # State current_state = CalibrationState.READY - calibration_thread = None # Widgets title_label = None @@ -45,7 +40,7 @@ def __init__(self): def onCreate(self): screen = lv.obj() - screen.set_style_pad_all(mpos.ui.pct_of_display_width(3), 0) + screen.set_style_pad_all(DisplayMetrics.pct_of_width(3), lv.PART.MAIN) 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() @@ -55,20 +50,20 @@ def onCreate(self): # 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) + self.title_label.set_style_text_font(lv.font_montserrat_16, lv.PART.MAIN) # 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_style_text_font(lv.font_montserrat_12, lv.PART.MAIN) self.status_label.set_long_mode(lv.label.LONG_MODE.WRAP) - self.status_label.set_width(lv.pct(90)) + 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_style_text_font(lv.font_montserrat_10, lv.PART.MAIN) + self.detail_label.set_style_text_color(lv.color_hex(0x888888), lv.PART.MAIN) self.detail_label.set_long_mode(lv.label.LONG_MODE.WRAP) self.detail_label.set_width(lv.pct(90)) @@ -76,9 +71,9 @@ def onCreate(self): 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_style_border_width(0, lv.PART.MAIN) btn_cont.set_flex_flow(lv.FLEX_FLOW.ROW) - btn_cont.set_style_flex_main_place(lv.FLEX_ALIGN.SPACE_BETWEEN, 0) + btn_cont.set_style_flex_main_place(lv.FLEX_ALIGN.SPACE_BETWEEN, lv.PART.MAIN) # Action button self.action_button = lv.button(btn_cont) @@ -169,7 +164,6 @@ def start_calibration_process(self): 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'} @@ -205,9 +199,9 @@ def start_calibration_process(self): # Step 3: Show results result_msg = "Calibration successful!" if accel_offsets: - result_msg += f"\n\nAccel offsets:\nX:{accel_offsets[0]:.3f} Y:{accel_offsets[1]:.3f} Z:{accel_offsets[2]:.3f}" + 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:\nX:{gyro_offsets[0]:.3f} Y:{gyro_offsets[1]:.3f} Z:{gyro_offsets[2]:.3f}" + 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) 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 index 097aa75e..df401261 100644 --- 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 @@ -7,9 +7,7 @@ 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 import Activity, SensorManager, DisplayMetrics class CheckIMUCalibrationActivity(Activity): @@ -36,8 +34,7 @@ def __init__(self): 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_style_pad_all(DisplayMetrics.pct_of_width(1), lv.PART.MAIN) screen.set_flex_flow(lv.FLEX_FLOW.COLUMN) focusgroup = lv.group_get_default() if focusgroup: @@ -57,84 +54,79 @@ def onResume(self, screen): # 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) + self.status_label.set_style_text_font(lv.font_montserrat_14, lv.PART.MAIN) # Separator sep1 = lv.obj(screen) sep1.set_size(lv.pct(100), 2) - sep1.set_style_bg_color(lv.color_hex(0x666666), 0) + sep1.set_style_bg_color(lv.color_hex(0x666666), lv.PART.MAIN) # 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) + self.quality_score_label.set_style_text_font(lv.font_montserrat_16, lv.PART.MAIN) 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_style_pad_all(0, lv.PART.MAIN) + data_cont.set_style_bg_opa(lv.OPA.TRANSP, lv.PART.MAIN) + data_cont.set_style_border_width(0, lv.PART.MAIN) data_cont.set_flex_flow(lv.FLEX_FLOW.ROW) - data_cont.set_style_flex_main_place(lv.FLEX_ALIGN.SPACE_BETWEEN, 0) + data_cont.set_style_flex_main_place(lv.FLEX_ALIGN.SPACE_BETWEEN, lv.PART.MAIN) # 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_style_border_width(0, lv.PART.MAIN) + acc_cont.set_style_pad_all(0, lv.PART.MAIN) 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) + accel_title.set_style_text_font(lv.font_montserrat_12, lv.PART.MAIN) 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) + label.set_style_text_font(lv.font_montserrat_10, lv.PART.MAIN) 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_width(DisplayMetrics.pct_of_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_style_border_width(0, lv.PART.MAIN) + gyro_cont.set_style_pad_all(0, lv.PART.MAIN) 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) + gyro_title.set_style_text_font(lv.font_montserrat_12, lv.PART.MAIN) 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) + label.set_style_text_font(lv.font_montserrat_10, lv.PART.MAIN) 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_style_text_font(lv.font_montserrat_12, lv.PART.MAIN) + self.issues_label.set_style_text_color(lv.color_hex(0xFF6666), lv.PART.MAIN) 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_style_pad_all(5, lv.PART.MAIN) 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_style_border_width(0, lv.PART.MAIN) btn_cont.set_flex_flow(lv.FLEX_FLOW.ROW) - btn_cont.set_style_flex_main_place(lv.FLEX_ALIGN.SPACE_BETWEEN, 0) + btn_cont.set_style_flex_main_place(lv.FLEX_ALIGN.SPACE_BETWEEN, lv.PART.MAIN) # Back button back_btn = lv.button(btn_cont) @@ -199,7 +191,7 @@ def update_display(self, timer=None): color = 0xFFFF66 # Yellow else: color = 0xFF6666 # Red - self.quality_score_label.set_style_text_color(lv.color_hex(color), 0) + self.quality_score_label.set_style_text_color(lv.color_hex(color), lv.PART.MAIN) # Update accelerometer values accel_mean = quality['accel_mean'] @@ -261,7 +253,7 @@ def get_mock_quality(self): def start_calibration(self, event): """Navigate to calibration activity.""" - from mpos.content.intent import Intent + from mpos import Intent from calibrate_imu import CalibrateIMUActivity intent = Intent(activity_class=CalibrateIMUActivity) 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 8dac9420..84837dbe 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py @@ -1,418 +1,138 @@ 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 +from mpos import Activity, Intent, AppearanceManager, AppManager, NumberFormat, SettingActivity, SettingsActivity, TimeZone -# Import IMU calibration activities -from check_imu_calibration import CheckIMUCalibrationActivity +from bootloader import ResetIntoBootloader from calibrate_imu import CalibrateIMUActivity +from check_imu_calibration import CheckIMUCalibrationActivity + +class LaunchWiFi(Activity): + + def onCreate(self): + AppManager.start_app("com.micropythonos.settings.wifi") + + +class LaunchHotspot(Activity): + + def onCreate(self): + AppManager.start_app("com.micropythonos.settings.hotspot") -# Used to list and edit all settings: -class SettingsActivity(Activity): - def __init__(self): - super().__init__() - self.prefs = None + +class LaunchWebServer(Activity): + + def onCreate(self): + AppManager.start_app("com.micropythonos.settings.webserver") + + +class Settings(SettingsActivity): + + """Override getIntent to provide prefs and settings via Intent extras""" + def getIntent(self): theme_colors = [ + ("Amethyst", "9966cc"), ("Aqua Blue", "00ffff"), ("Bitcoin Orange", "f0a010"), + ("Burnt Orange", "cc5500"), + ("Charcoal Gray", "36454f"), ("Coral Red", "ff7f50"), + ("Crimson", "dc143c"), ("Dark Slate", "2f4f4f"), + ("Emerald", "50c878"), ("Forest Green", "228b22"), - ("Piggy Pink", "ff69b4"), + ("Goldenrod", "daa520"), + ("Indigo", "4b0082"), + ("Lime", "00ff00"), ("Matrix Green", "03a062"), ("Midnight Blue", "191970"), ("Nostr Purple", "ff00ff"), + ("Piggy Pink", "ff69b4"), ("Saddle Brown", "8b4513"), ("Sky Blue", "87ceeb"), ("Solarized Yellow", "b58900"), - ("Vivid Violet", "9f00ff"), - ("Amethyst", "9966cc"), - ("Burnt Orange", "cc5500"), - ("Charcoal Gray", "36454f"), - ("Crimson", "dc143c"), - ("Emerald", "50c878"), - ("Goldenrod", "daa520"), - ("Indigo", "4b0082"), - ("Lime", "00ff00"), ("Teal", "008080"), - ("Turquoise", "40e0d0") + ("Turquoise", "40e0d0"), + ("Vivid Violet", "9f00ff") ] - self.settings = [ - # Novice settings, alphabetically: - {"title": "Calibrate IMU", "key": "calibrate_imu", "value_label": None, "cont": None, "ui": "activity", "activity_class": "CalibrateIMUActivity"}, - {"title": "Check IMU Calibration", "key": "check_imu_calibration", "value_label": None, "cont": None, "ui": "activity", "activity_class": "CheckIMUCalibrationActivity"}, - {"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": "Timezone", "key": "timezone", "value_label": None, "cont": None, "ui": "dropdown", "ui_options": self.get_timezone_tuples(), "changed_callback": lambda : mpos.time.refresh_timezone_preference()}, + intent = Intent() + from mpos import SharedPreferences + intent.putExtra("prefs", SharedPreferences("com.micropythonos.settings")) + intent.putExtra("settings", [ + { + "title": "Wi-Fi", + "key": "wifi_settings", + "ui": "activity", + "activity_class": LaunchWiFi, + "placeholder": "Scan and connect to Wi-Fi", + }, + { + "title": "Hotspot", + "key": "hotspot_settings", + "ui": "activity", + "activity_class": LaunchHotspot, + "placeholder": "Standalone Wi-Fi access point", + }, + { + "title": "WebServer", + "key": "webserver_settings", + "ui": "activity", + "activity_class": LaunchWebServer, + "placeholder": "WebREPL, password, port etc", + }, + # Basic settings, alphabetically: + {"title": "Light/Dark Theme", "key": "theme_light_dark", "ui": "radiobuttons", "ui_options": [("Light", "light"), ("Dark", "dark")], "changed_callback": self.theme_changed}, + {"title": "Theme Color", "key": "theme_primary_color", "placeholder": "HTML hex color, like: EC048C", "ui": "dropdown", "ui_options": theme_colors, "changed_callback": self.theme_changed, "default_value": AppearanceManager.DEFAULT_PRIMARY_COLOR}, + {"title": "Timezone", "key": "timezone", "ui": "dropdown", "ui_options": [(tz, tz) for tz in TimeZone.get_timezones()], "changed_callback": lambda *args: TimeZone.refresh_timezone_preference()}, + {"title": "Number Format", "key": "number_format", "ui": "dropdown", "ui_options": NumberFormat.get_format_options(), "changed_callback": lambda *args: NumberFormat.refresh_preference(), "default_value": "comma_dot"}, # 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": "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 + {"title": "Auto Start App", "key": "auto_start_app", "ui": "radiobuttons", "ui_options": [(app.name, app.fullname) for app in AppManager.get_app_list()]}, + {"title": "Check IMU Calibration", "key": "check_imu_calibration", "ui": "activity", "activity_class": CheckIMUCalibrationActivity}, + {"title": "Calibrate IMU", "key": "calibrate_imu", "ui": "activity", "activity_class": CalibrateIMUActivity}, + # Expert settings, alphabetically + {"title": "Restart to Bootloader", "key": "boot_mode", "dont_persist": True, "ui": "radiobuttons", "ui_options": [("Normal", "normal"), ("Bootloader", "bootloader")], "changed_callback": self.reset_into_bootloader}, + {"title": "Format internal data partition", "key": "format_internal_data_partition", "dont_persist": True, "ui": "radiobuttons", "ui_options": [("No, do not format", "no"), ("Yes, erase all settings, files and non-builtin apps", "yes")], "changed_callback": self.format_internal_data_partition}, # 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."}, + #{"title": "Display Brightness", "key": "display_brightness", "placeholder": "A value from 0 to 100."}, # Maybe also add font size (but ideally then all fonts should scale up/down) - ] - - def onCreate(self): - screen = lv.obj() - print("creating SettingsActivity ui...") - screen.set_style_pad_all(mpos.ui.pct_of_display_width(2), 0) - screen.set_flex_flow(lv.FLEX_FLOW.COLUMN) - screen.set_style_border_width(0, 0) - self.setContentView(screen) - - def onResume(self, screen): - # reload settings because the SettingsActivity might have changed them - could be optimized to only load if it did: - self.prefs = mpos.config.SharedPreferences("com.micropythonos.settings") - #wallet_type = self.prefs.get_string("wallet_type") # unused - - # Create settings entries - screen.clean() - # Get the group for focusable objects - focusgroup = lv.group_get_default() - if not focusgroup: - print("WARNING: could not get default focusgroup") - - for setting in self.settings: - #print(f"setting {setting.get('title')} has changed_callback {setting.get('changed_callback')}") - # Container for each setting - setting_cont = lv.obj(screen) - setting_cont.set_width(lv.pct(100)) - setting_cont.set_height(lv.SIZE_CONTENT) - setting_cont.set_style_border_width(1, 0) - #setting_cont.set_style_border_side(lv.BORDER_SIDE.BOTTOM, 0) - setting_cont.set_style_pad_all(mpos.ui.pct_of_display_width(2), 0) - setting_cont.add_flag(lv.obj.FLAG.CLICKABLE) - setting["cont"] = setting_cont # Store container reference for visibility control - - # Title label (bold, larger) - title = lv.label(setting_cont) - title.set_text(setting["title"]) - title.set_style_text_font(lv.font_montserrat_16, 0) - title.set_pos(0, 0) + ]) + return intent - # Value label (smaller, below title) - value = lv.label(setting_cont) - value.set_text(self.prefs.get_string(setting["key"], "(not set)")) - value.set_style_text_font(lv.font_montserrat_12, 0) - value.set_style_text_color(lv.color_hex(0x666666), 0) - value.set_pos(0, 20) - setting["value_label"] = value # Store reference for updating - setting_cont.add_event_cb(lambda e, s=setting: self.startSettingActivity(s), lv.EVENT.CLICKED, None) - setting_cont.add_event_cb(lambda e, container=setting_cont: self.focus_container(container),lv.EVENT.FOCUSED,None) - setting_cont.add_event_cb(lambda e, container=setting_cont: self.defocus_container(container),lv.EVENT.DEFOCUSED,None) - if focusgroup: - 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) + # Change handlers: + def reset_into_bootloader(self, new_value): + if new_value is not "bootloader": return - - # Handle traditional settings (existing code) - intent = Intent(activity_class=SettingActivity) - intent.putExtra("setting", setting) + intent = Intent(activity_class=ResetIntoBootloader) self.startActivity(intent) - @staticmethod - 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) - container.set_style_border_width(1, lv.PART.MAIN) - container.scroll_to_view(True) # scroll to bring it into view - - def defocus_container(self, container): - print(f"container {container} defocused, unsetting border...") - container.set_style_border_width(0, lv.PART.MAIN) - - -# Used to edit one setting: -class SettingActivity(Activity): - - active_radio_index = -1 # Track active radio button index - - # Widgets: - keyboard = None - textarea = None - dropdown = None - radio_container = None - - def __init__(self): - super().__init__() - self.prefs = mpos.config.SharedPreferences("com.micropythonos.settings") - self.setting = None - - def onCreate(self): - setting = self.getIntent().extras.get("setting") - #print(f"onCreate changed_callback: {setting.get('changed_callback')}") - settings_screen_detail = lv.obj() - settings_screen_detail.set_style_pad_all(mpos.ui.pct_of_display_width(2), 0) - settings_screen_detail.set_flex_flow(lv.FLEX_FLOW.COLUMN) - - top_cont = lv.obj(settings_screen_detail) - top_cont.set_width(lv.pct(100)) - top_cont.set_style_border_width(0, 0) - top_cont.set_height(lv.SIZE_CONTENT) - 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_20, 0) - - ui = setting.get("ui") - ui_options = setting.get("ui_options") - current_setting = self.prefs.get_string(setting["key"]) - if ui and ui == "radiobuttons" and ui_options: - # Create container for radio buttons - self.radio_container = lv.obj(settings_screen_detail) - 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.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): - cb = self.create_radio_button(self.radio_container, option_text, i) - if current_setting == option_value: - self.active_radio_index = i - cb.add_state(lv.STATE.CHECKED) - elif ui and ui == "dropdown" and ui_options: - self.dropdown = lv.dropdown(settings_screen_detail) - self.dropdown.set_width(lv.pct(100)) - options_with_newlines = "" - for option in ui_options: - if option[0] != option[1]: - options_with_newlines += (f"{option[0]} ({option[1]})\n") - else: # don't show identical options - options_with_newlines += (f"{option[0]}\n") - self.dropdown.set_options(options_with_newlines) - # select the right one: - for i, (option_text, option_value) in enumerate(ui_options): - if current_setting == option_value: - self.dropdown.set_selected(i) - break # no need to check the rest because only one can be selected - else: - # Textarea for other settings - self.textarea = lv.textarea(settings_screen_detail) - self.textarea.set_width(lv.pct(100)) - self.textarea.set_height(lv.SIZE_CONTENT) - self.textarea.align_to(top_cont, lv.ALIGN.OUT_BOTTOM_MID, 0, 0) - if current_setting: - self.textarea.set_text(current_setting) - placeholder = setting.get("placeholder") - if placeholder: - self.textarea.set_placeholder_text(placeholder) - 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 = MposKeyboard(settings_screen_detail) - self.keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 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) - self.keyboard.set_textarea(self.textarea) - - # Button container - btn_cont = lv.obj(settings_screen_detail) - btn_cont.set_width(lv.pct(100)) - btn_cont.set_style_border_width(0, 0) - btn_cont.set_height(lv.SIZE_CONTENT) - btn_cont.set_flex_flow(lv.FLEX_FLOW.ROW) - btn_cont.set_style_flex_main_place(lv.FLEX_ALIGN.SPACE_BETWEEN, 0) - # Save button - save_btn = lv.button(btn_cont) - save_btn.set_size(lv.pct(45), lv.SIZE_CONTENT) - save_label = lv.label(save_btn) - save_label.set_text("Save") - save_label.center() - save_btn.add_event_cb(lambda e, s=setting: self.save_setting(s), lv.EVENT.CLICKED, None) - # Cancel button - cancel_btn = lv.button(btn_cont) - cancel_btn.set_size(lv.pct(45), lv.SIZE_CONTENT) - cancel_label = lv.label(cancel_btn) - cancel_label.set_text("Cancel") - cancel_label.center() - cancel_btn.add_event_cb(lambda e: self.finish(), lv.EVENT.CLICKED, None) - - if False: # No scan QR button for text settings because they're all short right now - cambutton = lv.button(settings_screen_detail) - cambutton.align(lv.ALIGN.BOTTOM_MID,0,0) - cambutton.set_size(lv.pct(100), lv.pct(30)) - cambuttonlabel = lv.label(cambutton) - cambuttonlabel.set_text("Scan data from QR code") - cambuttonlabel.set_style_text_font(lv.font_montserrat_18, 0) - cambuttonlabel.align(lv.ALIGN.TOP_MID, 0, 0) - cambuttonlabel2 = lv.label(cambutton) - cambuttonlabel2.set_text("Tip: Create your own QR code,\nusing https://genqrcode.com or another tool.") - cambuttonlabel2.set_style_text_font(lv.font_montserrat_10, 0) - cambuttonlabel2.align(lv.ALIGN.BOTTOM_MID, 0, 0) - cambutton.add_event_cb(self.cambutton_cb, lv.EVENT.CLICKED, None) - - self.setContentView(settings_screen_detail) - - def onStop(self, screen): - if self.keyboard: - mpos.ui.anim.smooth_hide(self.keyboard) - - def radio_event_handler(self, event): - print("radio_event_handler called") - 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 + def format_internal_data_partition(self, new_value): + if new_value is not "yes": + return # user picked "no" - abort + # 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}") 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) - cb.set_text(text) - cb.add_flag(lv.obj.FLAG.EVENT_BUBBLE) - # Add circular style to indicator for radio button appearance - style_radio = lv.style_t() - style_radio.init() - style_radio.set_radius(lv.RADIUS_CIRCLE) - cb.add_style(style_radio, lv.PART.INDICATOR) - style_radio_chk = lv.style_t() - style_radio_chk.init() - style_radio_chk.set_bg_image_src(None) - cb.add_style(style_radio_chk, lv.PART.INDICATOR | lv.STATE.CHECKED) - return cb - - def gotqr_result_callback_unused(self, result): - print(f"QR capture finished, result: {result}") - if result.get("result_code"): - data = result.get("data") - print(f"Setting textarea data: {data}") - self.textarea.set_text(data) - - def cambutton_cb_unused(self, event): - print("cambutton clicked!") - self.startActivityForResult(Intent(activity_class=CameraApp).putExtra("scanqr_mode", True), self.gotqr_result_callback) - - def save_setting(self, setting): - # 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") - if ui and ui == "radiobuttons" and ui_options: - selected_idx = self.active_radio_index - new_value = "" - if selected_idx >= 0: - new_value = ui_options[selected_idx][1] - elif ui and ui == "dropdown" and ui_options: - selected_index = self.dropdown.get_selected() - print(f"selected item: {selected_index}") - new_value = ui_options[selected_index][1] - elif self.textarea: - new_value = self.textarea.get_text() - else: - new_value = "" - old_value = self.prefs.get_string(setting["key"]) - editor = self.prefs.edit() - editor.put_string(setting["key"], new_value) - editor.commit() - setting["value_label"].set_text(new_value if new_value else "(not set)") - changed_callback = setting.get("changed_callback") - #print(f"changed_callback: {changed_callback}") - 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() + 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") + AppManager.refresh_apps() + + def theme_changed(self, new_value): + from mpos import AppearanceManager + AppearanceManager.init(self.prefs) diff --git a/internal_filesystem/builtin/apps/com.micropythonos.settings/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/builtin/apps/com.micropythonos.settings/res/mipmap-mdpi/icon_64x64.png index c661275b..497e0f0b 100644 Binary files a/internal_filesystem/builtin/apps/com.micropythonos.settings/res/mipmap-mdpi/icon_64x64.png and b/internal_filesystem/builtin/apps/com.micropythonos.settings/res/mipmap-mdpi/icon_64x64.png differ diff --git a/internal_filesystem/builtin/apps/com.micropythonos.wifi/assets/wifi.py b/internal_filesystem/builtin/apps/com.micropythonos.wifi/assets/wifi.py deleted file mode 100644 index 82aeab89..00000000 --- a/internal_filesystem/builtin/apps/com.micropythonos.wifi/assets/wifi.py +++ /dev/null @@ -1,303 +0,0 @@ -import ujson -import os -import time -import lvgl as lv -import _thread - -from mpos.apps import Activity, Intent -from mpos.ui.keyboard import MposKeyboard - -import mpos.config -import mpos.ui.anim -import mpos.ui.theme -from mpos.net.wifi_service import WifiService - -have_network = True -try: - import network -except Exception as e: - have_network = False - -# Global variables because they're used by multiple Activities: -access_points={} -last_tried_ssid = "" -last_tried_result = "" - -# This is basically the wifi settings app -class WiFi(Activity): - - scan_button_scan_text = "Rescan" - scan_button_scanning_text = "Scanning..." - - ssids=[] - busy_scanning = False - busy_connecting = False - error_timer = None - - # Widgets: - aplist = None - error_label = None - scan_button = None - scan_button_label = None - - def onCreate(self): - print("wifi.py onCreate") - main_screen = lv.obj() - main_screen.set_style_pad_all(15, 0) - print("create_ui: Creating list widget") - self.aplist=lv.list(main_screen) - self.aplist.set_size(lv.pct(100),lv.pct(75)) - self.aplist.align(lv.ALIGN.TOP_MID,0,0) - print("create_ui: Creating error label") - self.error_label=lv.label(main_screen) - self.error_label.set_text("THIS IS ERROR TEXT THAT WILL BE SET LATER") - self.error_label.align_to(self.aplist, lv.ALIGN.OUT_BOTTOM_MID,0,0) - self.error_label.add_flag(lv.obj.FLAG.HIDDEN) - print("create_ui: Creating Scan button") - self.scan_button=lv.button(main_screen) - self.scan_button.set_size(lv.SIZE_CONTENT,lv.pct(15)) - self.scan_button.align(lv.ALIGN.BOTTOM_MID,0,0) - self.scan_button_label=lv.label(self.scan_button) - self.scan_button_label.set_text(self.scan_button_scan_text) - self.scan_button_label.center() - self.scan_button.add_event_cb(self.scan_cb,lv.EVENT.CLICKED,None) - self.setContentView(main_screen) - - def onResume(self, screen): - print("wifi.py onResume") - super().onResume(screen) - global access_points - access_points = mpos.config.SharedPreferences("com.micropythonos.system.wifiservice").get_dict("access_points") - if len(self.ssids) == 0: - if WifiService.wifi_busy == False: - WifiService.wifi_busy = True - self.start_scan_networks() - else: - self.show_error("Wifi is busy, please try again later.") - - def show_error(self, message): - # Schedule UI updates because different thread - print(f"show_error: Displaying error: {message}") - self.update_ui_threadsafe_if_foreground(self.error_label.set_text, message) - self.update_ui_threadsafe_if_foreground(self.error_label.remove_flag, lv.obj.FLAG.HIDDEN) - self.error_timer = lv.timer_create(self.hide_error,5000,None) - self.error_timer.set_repeat_count(1) - - def hide_error(self, timer): - self.update_ui_threadsafe_if_foreground(self.error_label.add_flag,lv.obj.FLAG.HIDDEN) - - def scan_networks_thread(self): - global have_network - print("scan_networks: Scanning for Wi-Fi networks") - if have_network: - wlan=network.WLAN(network.STA_IF) - if not wlan.isconnected(): # restart WiFi hardware in case it's in a bad state - wlan.active(False) - wlan.active(True) - try: - if have_network: - networks = wlan.scan() - self.ssids = list(set(n[0].decode() for n in networks)) - else: - time.sleep(1) - self.ssids = ["Home WiFi", "Pretty Fly for a Wi Fi", "Winternet is coming", "The Promised LAN"] - print(f"scan_networks: Found networks: {self.ssids}") - except Exception as e: - print(f"scan_networks: Scan failed: {e}") - self.show_error("Wi-Fi scan failed") - # scan done: - self.busy_scanning = 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) - - def start_scan_networks(self): - if self.busy_scanning: - print("Not scanning for networks because already busy_scanning.") - return - self.busy_scanning = True - self.scan_button.add_state(lv.STATE.DISABLED) - self.scan_button_label.set_text(self.scan_button_scanning_text) - _thread.stack_size(mpos.apps.good_stack_size()) - _thread.start_new_thread(self.scan_networks_thread, ()) - - def refresh_list(self): - global have_network - print("refresh_list: Clearing current list") - self.aplist.clean() # this causes an issue with lost taps if an ssid is clicked that has been removed - print("refresh_list: Populating list with scanned networks") - for ssid in self.ssids: - if len(ssid) < 1 or len(ssid) > 32: - print(f"Skipping too short or long SSID: {ssid}") - continue - print(f"refresh_list: Adding SSID: {ssid}") - button=self.aplist.add_button(None,ssid) - button.add_event_cb(lambda e, s=ssid: self.select_ssid_cb(s),lv.EVENT.CLICKED,None) - status = "" - if have_network: - wlan=network.WLAN(network.STA_IF) - if wlan.isconnected() and wlan.config('essid')==ssid: - status="connected" - if status != "connected": - if last_tried_ssid == ssid: # implies not connected because not wlan.isconnected() - status=last_tried_result - elif ssid in access_points: - status="saved" - label=lv.label(button) - label.set_text(status) - label.align(lv.ALIGN.RIGHT_MID,0,0) - - def scan_cb(self, event): - print("scan_cb: Scan button clicked, refreshing list") - self.start_scan_networks() - - def select_ssid_cb(self,ssid): - print(f"select_ssid_cb: SSID selected: {ssid}") - intent = Intent(activity_class=PasswordPage) - intent.putExtra("selected_ssid", ssid) - self.startActivityForResult(intent, self.password_page_result_cb) - - def password_page_result_cb(self, result): - print(f"PasswordPage finished, result: {result}") - if result.get("result_code") is True: - data = result.get("data") - if data: - self.start_attempt_connecting(data.get("ssid"), data.get("password")) - - def start_attempt_connecting(self, ssid, password): - print(f"start_attempt_connecting: Attempting to connect to SSID '{ssid}' with password '{password}'") - self.scan_button.add_state(lv.STATE.DISABLED) - self.scan_button_label.set_text(f"Connecting to '{ssid}'") - if self.busy_connecting: - print("Not attempting connect because busy_connecting.") - else: - self.busy_connecting = True - _thread.stack_size(mpos.apps.good_stack_size()) - _thread.start_new_thread(self.attempt_connecting_thread, (ssid,password)) - - def attempt_connecting_thread(self, ssid, password): - global last_tried_ssid, last_tried_result, have_network - print(f"attempt_connecting_thread: Attempting to connect to SSID '{ssid}' with password '{password}'") - result="connected" - try: - if have_network: - wlan=network.WLAN(network.STA_IF) - wlan.disconnect() - wlan.connect(ssid,password) - for i in range(10): - if wlan.isconnected(): - print(f"attempt_connecting: Connected to {ssid} after {i+1} seconds") - break - print(f"attempt_connecting: Waiting for connection, attempt {i+1}/10") - time.sleep(1) - if not wlan.isconnected(): - result="timeout" - else: - print("Warning: not trying to connect because not have_network, just waiting a bit...") - time.sleep(5) - except Exception as e: - print(f"attempt_connecting: Connection error: {e}") - result=f"{e}" - self.show_error("Connecting to {ssid} failed!") - print(f"Connecting to {ssid} got result: {result}") - last_tried_ssid = ssid - last_tried_result = result - # also do a time sync, otherwise some apps (Nostr Wallet Connect) won't work: - if have_network and wlan.isconnected(): - mpos.time.sync_time() - self.busy_connecting=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) - - - -class PasswordPage(Activity): - # Would be good to add some validation here so the password is not too short etc... - - selected_ssid = None - - # Widgets: - password_ta=None - keyboard=None - connect_button=None - cancel_button=None - - def onCreate(self): - self.selected_ssid = self.getIntent().extras.get("selected_ssid") - print("PasswordPage: Creating new password page") - 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.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, 5) - print("PasswordPage: Creating Connect button") - self.connect_button=lv.button(password_page) - self.connect_button.set_size(100,40) - self.connect_button.align(lv.ALIGN.BOTTOM_LEFT,10,-40) - self.connect_button.add_event_cb(self.connect_cb,lv.EVENT.CLICKED,None) - label=lv.label(self.connect_button) - label.set_text("Connect") - label.center() - print("PasswordPage: Creating Cancel button") - self.cancel_button=lv.button(password_page) - self.cancel_button.set_size(100,40) - self.cancel_button.align(lv.ALIGN.BOTTOM_RIGHT,-10,-40) - self.cancel_button.add_event_cb(self.cancel_cb,lv.EVENT.CLICKED,None) - label=lv.label(self.cancel_button) - label.set_text("Close") - label.center() - pwd = self.findSavedPassword(self.selected_ssid) - if pwd: - self.password_ta.set_text(pwd) - self.password_ta.set_placeholder_text("Password") - print("PasswordPage: Creating keyboard (hidden by default)") - self.keyboard=MposKeyboard(password_page) - self.keyboard.align(lv.ALIGN.BOTTOM_MID,0,0) - self.keyboard.set_textarea(self.password_ta) - self.keyboard.add_flag(lv.obj.FLAG.HIDDEN) - print("PasswordPage: Loading password page") - self.setContentView(password_page) - - def connect_cb(self, event): - global access_points - print("connect_cb: Connect button clicked") - password=self.password_ta.get_text() - print(f"connect_cb: Got password: {password}") - self.setPassword(self.selected_ssid, password) - print(f"connect_cb: Updated access_points: {access_points}") - editor = mpos.config.SharedPreferences("com.micropythonos.system.wifiservice").edit() - editor.put_dict("access_points", access_points) - editor.commit() - self.setResult(True, {"ssid": self.selected_ssid, "password": password}) - print("connect_cb: Restoring main_screen") - self.finish() - - def cancel_cb(self, event): - print("cancel_cb: Cancel button clicked") - self.finish() - - @staticmethod - def setPassword(ssid, password): - global access_points - ap = access_points.get(ssid) - if ap: - ap["password"] = password - return - # if not found, then add it: - access_points[ssid] = { "password": password } - - @staticmethod - def findSavedPassword(ssid): - if not access_points: - return None - ap = access_points.get(ssid) - if ap: - return ap.get("password") - return None diff --git a/internal_filesystem/builtin/apps/com.micropythonos.wifi/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/builtin/apps/com.micropythonos.wifi/res/mipmap-mdpi/icon_64x64.png deleted file mode 100644 index f2564775..00000000 Binary files a/internal_filesystem/builtin/apps/com.micropythonos.wifi/res/mipmap-mdpi/icon_64x64.png and /dev/null differ diff --git a/internal_filesystem/builtin/html/README.md b/internal_filesystem/builtin/html/README.md new file mode 100644 index 00000000..d95ffd72 --- /dev/null +++ b/internal_filesystem/builtin/html/README.md @@ -0,0 +1 @@ +This folder gets filled by the webrepl/inline_minify_webrepl.py script. diff --git a/internal_filesystem/builtin/html/webrepl_inlined_minified.html b/internal_filesystem/builtin/html/webrepl_inlined_minified.html new file mode 100644 index 00000000..fe7e27dd --- /dev/null +++ b/internal_filesystem/builtin/html/webrepl_inlined_minified.html @@ -0,0 +1,48 @@ + + + +MicroPythonOS WebREPL + + + + + +

MicroPythonOS WebREPL

+
+
+ + +
+
+
+
+
+ +
+ +
+ Send a file + +
+ +
+ +
+ Get a file + + +
+ +
(file operation status)
+ +
+ +
+Terminal widget should be focused (text cursor visible) to accept input. Click on it if not.
+To paste, press Ctrl+A, then Ctrl+V + + + + + + diff --git a/internal_filesystem/builtin/res/mipmap-mdpi/MicroPythonOS-logo-white-long-w296.png b/internal_filesystem/builtin/res/mipmap-mdpi/MicroPythonOS-logo-white-long-w296.png new file mode 100644 index 00000000..dd147b24 Binary files /dev/null and b/internal_filesystem/builtin/res/mipmap-mdpi/MicroPythonOS-logo-white-long-w296.png differ diff --git a/internal_filesystem/builtin/res/mipmap-mdpi/default_icon_64x64.png b/internal_filesystem/builtin/res/mipmap-mdpi/default_icon_64x64.png deleted file mode 100644 index 79654b38..00000000 Binary files a/internal_filesystem/builtin/res/mipmap-mdpi/default_icon_64x64.png and /dev/null differ diff --git a/internal_filesystem/lib/README.md b/internal_filesystem/lib/README.md index 078e0c71..a5492472 100644 --- a/internal_filesystem/lib/README.md +++ b/internal_filesystem/lib/README.md @@ -1,12 +1,13 @@ This /lib folder contains: -- https://github.com/echo-lalia/qmi8658-micropython/blob/main/qmi8685.py but given the correct name "qmi8658.py" -- traceback.mpy from https://github.com/micropython/micropython-lib -- https://github.com/glenn20/micropython-esp32-ota/ installed with import mip; mip.install('github:glenn20/micropython-esp32-ota/mip/ota') + - mip.install('github:jonnor/micropython-zipfile') -- mip.install("shutil") for shutil.rmtree('/apps/com.example.files') # for rmtree() - mip.install("aiohttp") # easy websockets -- mip.install("base64") # for nostr etc -- mip.install("collections") # used by aiohttp -- mip.install("unittest") -- mip.install("logging") +- https://github.com/micropython/micropython-lib/blob/master/micropython/aiorepl/aiorepl.py version 0.2.2 # for asyncio REPL, allowing await expressions + +- https://github.com/micropython/micropython-lib/blob/master/python-stdlib/base64/base64.py version 3.3.6 # for nostr +- https://github.com/micropython/micropython-lib/blob/master/python-stdlib/binascii/binascii.py version 2.4.1 # for base64.py +- https://github.com/micropython/micropython-lib/blob/master/python-stdlib/logging/logging.py version 0.6.2 # for About app +- https://github.com/micropython/micropython-lib/blob/master/python-stdlib/shutil/shutil.py version 0.0.5 # for rmtree() +- https://github.com/micropython/micropython-lib/blob/master/python-stdlib/unittest/unittest/__init__.py version 0.10.4 # for testing (also on-device) +- https://github.com/micropython/micropython-lib/blob/master/python-stdlib/pathlib/pathlib.py version 0.0.1 # for Path() diff --git a/internal_filesystem/lib/aiorepl.py b/internal_filesystem/lib/aiorepl.py new file mode 100644 index 00000000..9d431ef8 --- /dev/null +++ b/internal_filesystem/lib/aiorepl.py @@ -0,0 +1,333 @@ +# MIT license; Copyright (c) 2022 Jim Mussared + +import micropython +from micropython import const +import re +import sys +import time +import asyncio + +# Import statement (needs to be global, and does not return). +_RE_IMPORT = re.compile("^import ([^ ]+)( as ([^ ]+))?") +_RE_FROM_IMPORT = re.compile("^from [^ ]+ import ([^ ]+)( as ([^ ]+))?") +# Global variable assignment. +_RE_GLOBAL = re.compile("^([a-zA-Z0-9_]+) ?=[^=]") +# General assignment expression or import statement (does not return a value). +_RE_ASSIGN = re.compile("[^=]=[^=]") + +# Command hist (One reserved slot for the current command). +_HISTORY_LIMIT = const(5 + 1) + + +CHAR_CTRL_A = const(1) +CHAR_CTRL_B = const(2) +CHAR_CTRL_C = const(3) +CHAR_CTRL_D = const(4) +CHAR_CTRL_E = const(5) + + +async def execute(code, g, s): + if not code.strip(): + return + + try: + if "await " in code: + # Execute the code snippet in an async context. + if m := _RE_IMPORT.match(code) or _RE_FROM_IMPORT.match(code): + code = "global {}\n {}".format(m.group(3) or m.group(1), code) + elif m := _RE_GLOBAL.match(code): + code = "global {}\n {}".format(m.group(1), code) + elif not _RE_ASSIGN.search(code): + code = "return {}".format(code) + + code = """ +import asyncio +async def __code(): + {} + +__exec_task = asyncio.create_task(__code()) +""".format(code) + + async def kbd_intr_task(exec_task, s): + while True: + if ord(await s.read(1)) == CHAR_CTRL_C: + exec_task.cancel() + return + + l = {"__exec_task": None} + exec(code, g, l) + exec_task = l["__exec_task"] + + # Concurrently wait for either Ctrl-C from the stream or task + # completion. + intr_task = asyncio.create_task(kbd_intr_task(exec_task, s)) + + try: + try: + return await exec_task + except asyncio.CancelledError: + pass + finally: + intr_task.cancel() + try: + await intr_task + except asyncio.CancelledError: + pass + else: + # Execute code snippet directly. + try: + try: + micropython.kbd_intr(3) + try: + return eval(code, g) + except SyntaxError: + # Maybe an assignment, try with exec. + return exec(code, g) + except KeyboardInterrupt: + pass + finally: + micropython.kbd_intr(-1) + + except Exception as err: + print("{}: {}".format(type(err).__name__, err)) + + +# REPL task. Invoke this with an optional mutable globals dict. +async def task(g=None, prompt="--> "): + print("Starting asyncio REPL...") + if g is None: + g = __import__("__main__").__dict__ + try: + micropython.kbd_intr(-1) + s = asyncio.StreamReader(sys.stdin) + # clear = True + hist = [None] * _HISTORY_LIMIT + hist_i = 0 # Index of most recent entry. + hist_n = 0 # Number of history entries. + c = 0 # ord of most recent character. + t = 0 # timestamp of most recent character. + while True: + hist_b = 0 # How far back in the history are we currently. + sys.stdout.write(prompt) + cmd: str = "" + paste = False + curs = 0 # cursor offset from end of cmd buffer + while True: + b = await s.read(1) + # MPOS: return on EOF to avoid infinite prompt spam with /dev/null (differs from upstream). + if not b: # Handle EOF/empty read + return + pc = c # save previous character + c = ord(b) + pt = t # save previous time + t = time.ticks_ms() + if c < 0x20 or c > 0x7E: + if c == 0x0A: + # LF + if paste: + sys.stdout.write(b) + cmd += b + continue + # If the previous character was also LF, and was less + # than 20 ms ago, this was likely due to CRLF->LFLF + # conversion, so ignore this linefeed. + if pc == 0x0A and time.ticks_diff(t, pt) < 20: + continue + if curs: + # move cursor to end of the line + sys.stdout.write("\x1b[{}C".format(curs)) + curs = 0 + sys.stdout.write("\n") + if cmd: + # Push current command. + hist[hist_i] = cmd + # Increase history length if possible, and rotate ring forward. + hist_n = min(_HISTORY_LIMIT - 1, hist_n + 1) + hist_i = (hist_i + 1) % _HISTORY_LIMIT + + result = await execute(cmd, g, s) + if result is not None: + sys.stdout.write(repr(result)) + sys.stdout.write("\n") + break + elif c == 0x08 or c == 0x7F: + # Backspace. + if cmd: + if curs: + cmd = "".join((cmd[: -curs - 1], cmd[-curs:])) + sys.stdout.write( + "\x08\x1b[K" + ) # move cursor back, erase to end of line + sys.stdout.write(cmd[-curs:]) # redraw line + sys.stdout.write("\x1b[{}D".format(curs)) # reset cursor location + else: + cmd = cmd[:-1] + sys.stdout.write("\x08 \x08") + elif c == CHAR_CTRL_A: + raw_repl(sys.stdin, g) + break + elif c == CHAR_CTRL_B: + continue + elif c == CHAR_CTRL_C: + if paste: + break + sys.stdout.write("\n") + break + elif c == CHAR_CTRL_D: + if paste: + result = await execute(cmd, g, s) + if result is not None: + sys.stdout.write(repr(result)) + sys.stdout.write("\n") + break + + sys.stdout.write("\n") + # Shutdown asyncio. + asyncio.new_event_loop() + return + elif c == CHAR_CTRL_E: + sys.stdout.write("paste mode; Ctrl-C to cancel, Ctrl-D to finish\n===\n") + paste = True + elif c == 0x1B: + # Start of escape sequence. + key = await s.read(2) + if key in ("[A", "[B"): # up, down + # Stash the current command. + hist[(hist_i - hist_b) % _HISTORY_LIMIT] = cmd + # Clear current command. + b = "\x08" * len(cmd) + sys.stdout.write(b) + sys.stdout.write(" " * len(cmd)) + sys.stdout.write(b) + # Go backwards or forwards in the history. + if key == "[A": + hist_b = min(hist_n, hist_b + 1) + else: + hist_b = max(0, hist_b - 1) + # Update current command. + cmd = hist[(hist_i - hist_b) % _HISTORY_LIMIT] + sys.stdout.write(cmd) + elif key == "[D": # left + if curs < len(cmd) - 1: + curs += 1 + sys.stdout.write("\x1b") + sys.stdout.write(key) + elif key == "[C": # right + if curs: + curs -= 1 + sys.stdout.write("\x1b") + sys.stdout.write(key) + elif key == "[H": # home + pcurs = curs + curs = len(cmd) + sys.stdout.write("\x1b[{}D".format(curs - pcurs)) # move cursor left + elif key == "[F": # end + pcurs = curs + curs = 0 + sys.stdout.write("\x1b[{}C".format(pcurs)) # move cursor right + else: + # sys.stdout.write("\\x") + # sys.stdout.write(hex(c)) + pass + else: + if curs: + # inserting into middle of line + cmd = "".join((cmd[:-curs], b, cmd[-curs:])) + sys.stdout.write(cmd[-curs - 1 :]) # redraw line to end + sys.stdout.write("\x1b[{}D".format(curs)) # reset cursor location + else: + sys.stdout.write(b) + cmd += b + finally: + micropython.kbd_intr(3) + + +def raw_paste(s, window=512): + sys.stdout.write("R\x01") # supported + sys.stdout.write(bytearray([window & 0xFF, window >> 8, 0x01]).decode()) + eof = False + idx = 0 + buff = bytearray(window) + file = b"" + while not eof: + for idx in range(window): + b = s.read(1) + c = ord(b) + if c == CHAR_CTRL_C or c == CHAR_CTRL_D: + # end of file + sys.stdout.write(chr(CHAR_CTRL_D)) + if c == CHAR_CTRL_C: + raise KeyboardInterrupt + file += buff[:idx] + eof = True + break + buff[idx] = c + + if not eof: + file += buff + sys.stdout.write("\x01") # indicate window available to host + + return file + + +def raw_repl(s, g: dict): + """ + This function is blocking to prevent other + async tasks from writing to the stdio stream and + breaking the raw repl session. + """ + heading = "raw REPL; CTRL-B to exit\n" + line = "" + sys.stdout.write(heading) + + while True: + line = "" + sys.stdout.write(">") + while True: + b = s.read(1) + c = ord(b) + if c == CHAR_CTRL_A: + rline = line + line = "" + + if len(rline) == 2 and ord(rline[0]) == CHAR_CTRL_E: + if rline[1] == "A": + line = raw_paste(s) + break + else: + # reset raw REPL + sys.stdout.write(heading) + sys.stdout.write(">") + continue + elif c == CHAR_CTRL_B: + # exit raw REPL + sys.stdout.write("\n") + return 0 + elif c == CHAR_CTRL_C: + # clear line + line = "" + elif c == CHAR_CTRL_D: + # entry finished + # indicate reception of command + sys.stdout.write("OK") + break + else: + # let through any other raw 8-bit value + line += b + + if len(line) == 0: + # Normally used to trigger soft-reset but stay in raw mode. + # Fake it for aiorepl / mpremote. + sys.stdout.write("Ignored: soft reboot\n") + sys.stdout.write(heading) + + try: + result = exec(line, g) + if result is not None: + sys.stdout.write(repr(result)) + sys.stdout.write(chr(CHAR_CTRL_D)) + except Exception as ex: + print(line) + sys.stdout.write(chr(CHAR_CTRL_D)) + sys.print_exception(ex, sys.stdout) + sys.stdout.write(chr(CHAR_CTRL_D)) diff --git a/internal_filesystem/lib/base64.mpy b/internal_filesystem/lib/base64.mpy deleted file mode 100644 index fc7fa053..00000000 Binary files a/internal_filesystem/lib/base64.mpy and /dev/null differ diff --git a/internal_filesystem/lib/base64.py b/internal_filesystem/lib/base64.py new file mode 100644 index 00000000..d6baca05 --- /dev/null +++ b/internal_filesystem/lib/base64.py @@ -0,0 +1,480 @@ +#! /usr/bin/env python3 + +"""RFC 3548: Base16, Base32, Base64 Data Encodings""" + +# Modified 04-Oct-1995 by Jack Jansen to use binascii module +# Modified 30-Dec-2003 by Barry Warsaw to add full RFC 3548 support +# Modified 22-May-2007 by Guido van Rossum to use bytes everywhere + +import re +import struct +import binascii + + +__all__ = [ + # Legacy interface exports traditional RFC 1521 Base64 encodings + "encode", + "decode", + "encodebytes", + "decodebytes", + # Generalized interface for other encodings + "b64encode", + "b64decode", + "b32encode", + "b32decode", + "b16encode", + "b16decode", + # Standard Base64 encoding + "standard_b64encode", + "standard_b64decode", + # Some common Base64 alternatives. As referenced by RFC 3458, see thread + # starting at: + # + # http://zgp.org/pipermail/p2p-hackers/2001-September/000316.html + "urlsafe_b64encode", + "urlsafe_b64decode", +] + + +bytes_types = (bytes, bytearray) # Types acceptable as binary data + + +def _bytes_from_decode_data(s): + if isinstance(s, str): + try: + return s.encode("ascii") + # except UnicodeEncodeError: + except: + raise ValueError("string argument should contain only ASCII characters") + elif isinstance(s, bytes_types): + return s + else: + raise TypeError("argument should be bytes or ASCII string, not %s" % s.__class__.__name__) + + +def _maketrans(f, t): + """Re-implement bytes.maketrans() as there is no such function in micropython""" + if len(f) != len(t): + raise ValueError("maketrans arguments must have same length") + translation_table = dict(zip(f, t)) + return translation_table + + +def _translate(input_bytes, trans_table): + """Re-implement bytes.translate() as there is no such function in micropython""" + result = bytearray() + + for byte in input_bytes: + translated_byte = trans_table.get(byte, byte) + result.append(translated_byte) + + return bytes(result) + + +# Base64 encoding/decoding uses binascii + + +def b64encode(s, altchars=None): + """Encode a byte string using Base64. + + s is the byte string to encode. Optional altchars must be a byte + string of length 2 which specifies an alternative alphabet for the + '+' and '/' characters. This allows an application to + e.g. generate url or filesystem safe Base64 strings. + + The encoded byte string is returned. + """ + if not isinstance(s, bytes_types): + raise TypeError("expected bytes, not %s" % s.__class__.__name__) + # Strip off the trailing newline + encoded = binascii.b2a_base64(s)[:-1] + if altchars is not None: + if not isinstance(altchars, bytes_types): + raise TypeError("expected bytes, not %s" % altchars.__class__.__name__) + assert len(altchars) == 2, repr(altchars) + encoded = _translate(encoded, _maketrans(b"+/", altchars)) + return encoded + + +def b64decode(s, altchars=None, validate=False): + """Decode a Base64 encoded byte string. + + s is the byte string to decode. Optional altchars must be a + string of length 2 which specifies the alternative alphabet used + instead of the '+' and '/' characters. + + The decoded string is returned. A binascii.Error is raised if s is + incorrectly padded. + + If validate is False (the default), non-base64-alphabet characters are + discarded prior to the padding check. If validate is True, + non-base64-alphabet characters in the input result in a binascii.Error. + """ + s = _bytes_from_decode_data(s) + if altchars is not None: + altchars = _bytes_from_decode_data(altchars) + assert len(altchars) == 2, repr(altchars) + s = _translate(s, _maketrans(altchars, b"+/")) + if validate and not re.match(b"^[A-Za-z0-9+/]*=*$", s): + raise binascii.Error("Non-base64 digit found") + return binascii.a2b_base64(s) + + +def standard_b64encode(s): + """Encode a byte string using the standard Base64 alphabet. + + s is the byte string to encode. The encoded byte string is returned. + """ + return b64encode(s) + + +def standard_b64decode(s): + """Decode a byte string encoded with the standard Base64 alphabet. + + s is the byte string to decode. The decoded byte string is + returned. binascii.Error is raised if the input is incorrectly + padded or if there are non-alphabet characters present in the + input. + """ + return b64decode(s) + + +# _urlsafe_encode_translation = _maketrans(b'+/', b'-_') +# _urlsafe_decode_translation = _maketrans(b'-_', b'+/') + + +def urlsafe_b64encode(s): + """Encode a byte string using a url-safe Base64 alphabet. + + s is the byte string to encode. The encoded byte string is + returned. The alphabet uses '-' instead of '+' and '_' instead of + '/'. + """ + # return b64encode(s).translate(_urlsafe_encode_translation) + return b64encode(s, b"-_").rstrip(b"\n") + + +def urlsafe_b64decode(s): + """Decode a byte string encoded with the standard Base64 alphabet. + + s is the byte string to decode. The decoded byte string is + returned. binascii.Error is raised if the input is incorrectly + padded or if there are non-alphabet characters present in the + input. + + The alphabet uses '-' instead of '+' and '_' instead of '/'. + """ + # s = _bytes_from_decode_data(s) + # s = s.translate(_urlsafe_decode_translation) + # return b64decode(s) + raise NotImplementedError() + + +# Base32 encoding/decoding must be done in Python +_b32alphabet = { + 0: b"A", + 9: b"J", + 18: b"S", + 27: b"3", + 1: b"B", + 10: b"K", + 19: b"T", + 28: b"4", + 2: b"C", + 11: b"L", + 20: b"U", + 29: b"5", + 3: b"D", + 12: b"M", + 21: b"V", + 30: b"6", + 4: b"E", + 13: b"N", + 22: b"W", + 31: b"7", + 5: b"F", + 14: b"O", + 23: b"X", + 6: b"G", + 15: b"P", + 24: b"Y", + 7: b"H", + 16: b"Q", + 25: b"Z", + 8: b"I", + 17: b"R", + 26: b"2", +} + +_b32tab = [v[0] for k, v in sorted(_b32alphabet.items())] +_b32rev = dict([(v[0], k) for k, v in _b32alphabet.items()]) + + +def b32encode(s): + """Encode a byte string using Base32. + + s is the byte string to encode. The encoded byte string is returned. + """ + if not isinstance(s, bytes_types): + raise TypeError("expected bytes, not %s" % s.__class__.__name__) + quanta, leftover = divmod(len(s), 5) + # Pad the last quantum with zero bits if necessary + if leftover: + s = s + bytes(5 - leftover) # Don't use += ! + quanta += 1 + encoded = bytearray() + for i in range(quanta): + # c1 and c2 are 16 bits wide, c3 is 8 bits wide. The intent of this + # code is to process the 40 bits in units of 5 bits. So we take the 1 + # leftover bit of c1 and tack it onto c2. Then we take the 2 leftover + # bits of c2 and tack them onto c3. The shifts and masks are intended + # to give us values of exactly 5 bits in width. + c1, c2, c3 = struct.unpack("!HHB", s[i * 5 : (i + 1) * 5]) + c2 += (c1 & 1) << 16 # 17 bits wide + c3 += (c2 & 3) << 8 # 10 bits wide + encoded += bytes( + [ + _b32tab[c1 >> 11], # bits 1 - 5 + _b32tab[(c1 >> 6) & 0x1F], # bits 6 - 10 + _b32tab[(c1 >> 1) & 0x1F], # bits 11 - 15 + _b32tab[c2 >> 12], # bits 16 - 20 (1 - 5) + _b32tab[(c2 >> 7) & 0x1F], # bits 21 - 25 (6 - 10) + _b32tab[(c2 >> 2) & 0x1F], # bits 26 - 30 (11 - 15) + _b32tab[c3 >> 5], # bits 31 - 35 (1 - 5) + _b32tab[c3 & 0x1F], # bits 36 - 40 (1 - 5) + ] + ) + # Adjust for any leftover partial quanta + if leftover == 1: + encoded = encoded[:-6] + b"======" + elif leftover == 2: + encoded = encoded[:-4] + b"====" + elif leftover == 3: + encoded = encoded[:-3] + b"===" + elif leftover == 4: + encoded = encoded[:-1] + b"=" + return bytes(encoded) + + +def b32decode(s, casefold=False, map01=None): + """Decode a Base32 encoded byte string. + + s is the byte string to decode. Optional casefold is a flag + specifying whether a lowercase alphabet is acceptable as input. + For security purposes, the default is False. + + RFC 3548 allows for optional mapping of the digit 0 (zero) to the + letter O (oh), and for optional mapping of the digit 1 (one) to + either the letter I (eye) or letter L (el). The optional argument + map01 when not None, specifies which letter the digit 1 should be + mapped to (when map01 is not None, the digit 0 is always mapped to + the letter O). For security purposes the default is None, so that + 0 and 1 are not allowed in the input. + + The decoded byte string is returned. binascii.Error is raised if + the input is incorrectly padded or if there are non-alphabet + characters present in the input. + """ + s = _bytes_from_decode_data(s) + quanta, leftover = divmod(len(s), 8) + if leftover: + raise binascii.Error("Incorrect padding") + # Handle section 2.4 zero and one mapping. The flag map01 will be either + # False, or the character to map the digit 1 (one) to. It should be + # either L (el) or I (eye). + if map01 is not None: + map01 = _bytes_from_decode_data(map01) + assert len(map01) == 1, repr(map01) + s = _translate(s, _maketrans(b"01", b"O" + map01)) + if casefold: + s = s.upper() + # Strip off pad characters from the right. We need to count the pad + # characters because this will tell us how many null bytes to remove from + # the end of the decoded string. + padchars = s.find(b"=") + if padchars > 0: + padchars = len(s) - padchars + s = s[:-padchars] + else: + padchars = 0 + + # Now decode the full quanta + parts = [] + acc = 0 + shift = 35 + for c in s: + val = _b32rev.get(c) + if val is None: + raise binascii.Error("Non-base32 digit found") + acc += _b32rev[c] << shift + shift -= 5 + if shift < 0: + parts.append(binascii.unhexlify(bytes("%010x" % acc, "ascii"))) + acc = 0 + shift = 35 + # Process the last, partial quanta + last = binascii.unhexlify(bytes("%010x" % acc, "ascii")) + if padchars == 0: + last = b"" # No characters + elif padchars == 1: + last = last[:-1] + elif padchars == 3: + last = last[:-2] + elif padchars == 4: + last = last[:-3] + elif padchars == 6: + last = last[:-4] + else: + raise binascii.Error("Incorrect padding") + parts.append(last) + return b"".join(parts) + + +# RFC 3548, Base 16 Alphabet specifies uppercase, but hexlify() returns +# lowercase. The RFC also recommends against accepting input case +# insensitively. +def b16encode(s): + """Encode a byte string using Base16. + + s is the byte string to encode. The encoded byte string is returned. + """ + if not isinstance(s, bytes_types): + raise TypeError("expected bytes, not %s" % s.__class__.__name__) + return binascii.hexlify(s).upper() + + +def b16decode(s, casefold=False): + """Decode a Base16 encoded byte string. + + s is the byte string to decode. Optional casefold is a flag + specifying whether a lowercase alphabet is acceptable as input. + For security purposes, the default is False. + + The decoded byte string is returned. binascii.Error is raised if + s were incorrectly padded or if there are non-alphabet characters + present in the string. + """ + s = _bytes_from_decode_data(s) + if casefold: + s = s.upper() + if re.search(b"[^0-9A-F]", s): + raise binascii.Error("Non-base16 digit found") + return binascii.unhexlify(s) + + +# Legacy interface. This code could be cleaned up since I don't believe +# binascii has any line length limitations. It just doesn't seem worth it +# though. The files should be opened in binary mode. + +MAXLINESIZE = 76 # Excluding the CRLF +MAXBINSIZE = (MAXLINESIZE // 4) * 3 + + +def encode(input, output): + """Encode a file; input and output are binary files.""" + while True: + s = input.read(MAXBINSIZE) + if not s: + break + while len(s) < MAXBINSIZE: + ns = input.read(MAXBINSIZE - len(s)) + if not ns: + break + s += ns + line = binascii.b2a_base64(s) + output.write(line) + + +def decode(input, output): + """Decode a file; input and output are binary files.""" + while True: + line = input.readline() + if not line: + break + s = binascii.a2b_base64(line) + output.write(s) + + +def encodebytes(s): + """Encode a bytestring into a bytestring containing multiple lines + of base-64 data.""" + if not isinstance(s, bytes_types): + raise TypeError("expected bytes, not %s" % s.__class__.__name__) + pieces = [] + for i in range(0, len(s), MAXBINSIZE): + chunk = s[i : i + MAXBINSIZE] + pieces.append(binascii.b2a_base64(chunk)) + return b"".join(pieces) + + +def encodestring(s): + """Legacy alias of encodebytes().""" + import warnings + + warnings.warn("encodestring() is a deprecated alias, use encodebytes()", DeprecationWarning, 2) + return encodebytes(s) + + +def decodebytes(s): + """Decode a bytestring of base-64 data into a bytestring.""" + if not isinstance(s, bytes_types): + raise TypeError("expected bytes, not %s" % s.__class__.__name__) + return binascii.a2b_base64(s) + + +def decodestring(s): + """Legacy alias of decodebytes().""" + import warnings + + warnings.warn("decodestring() is a deprecated alias, use decodebytes()", DeprecationWarning, 2) + return decodebytes(s) + + +# Usable as a script... +def main(): + """Small main program""" + import sys, getopt + + try: + opts, args = getopt.getopt(sys.argv[1:], "deut") + except getopt.error as msg: + sys.stdout = sys.stderr + print(msg) + print( + """usage: %s [-d|-e|-u|-t] [file|-] + -d, -u: decode + -e: encode (default) + -t: encode and decode string 'Aladdin:open sesame'""" + % sys.argv[0] + ) + sys.exit(2) + func = encode + for o, a in opts: + if o == "-e": + func = encode + if o == "-d": + func = decode + if o == "-u": + func = decode + if o == "-t": + test() + return + if args and args[0] != "-": + with open(args[0], "rb") as f: + func(f, sys.stdout.buffer) + else: + func(sys.stdin.buffer, sys.stdout.buffer) + + +def test(): + s0 = b"Aladdin:open sesame" + print(repr(s0)) + s1 = encodebytes(s0) + print(repr(s1)) + s2 = decodebytes(s1) + print(repr(s2)) + assert s0 == s2 + + +if __name__ == "__main__": + main() diff --git a/internal_filesystem/lib/binascii.mpy b/internal_filesystem/lib/binascii.mpy deleted file mode 100644 index e7f55600..00000000 Binary files a/internal_filesystem/lib/binascii.mpy and /dev/null differ diff --git a/internal_filesystem/lib/binascii.py b/internal_filesystem/lib/binascii.py new file mode 100644 index 00000000..f2ec39e8 --- /dev/null +++ b/internal_filesystem/lib/binascii.py @@ -0,0 +1,362 @@ +from ubinascii import * + +if not "unhexlify" in globals(): + + def unhexlify(data): + if len(data) % 2 != 0: + raise ValueError("Odd-length string") + + return bytes([int(data[i : i + 2], 16) for i in range(0, len(data), 2)]) + + +b2a_hex = hexlify +a2b_hex = unhexlify + +# ____________________________________________________________ + +PAD = "=" + +table_a2b_base64 = [ + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + 62, + -1, + -1, + -1, + 63, + 52, + 53, + 54, + 55, + 56, + 57, + 58, + 59, + 60, + 61, + -1, + -1, + -1, + -1, + -1, + -1, # Note PAD->-1 here + -1, + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + -1, + -1, + -1, + -1, + -1, + -1, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 49, + 50, + 51, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, +] + + +def _transform(n): + if n == -1: + return "\xff" + else: + return chr(n) + + +table_a2b_base64 = "".join(map(_transform, table_a2b_base64)) +assert len(table_a2b_base64) == 256 + + +def a2b_base64(ascii): + "Decode a line of base64 data." + + res = [] + quad_pos = 0 + leftchar = 0 + leftbits = 0 + last_char_was_a_pad = False + + for c in ascii: + c = chr(c) + if c == PAD: + if quad_pos > 2 or (quad_pos == 2 and last_char_was_a_pad): + break # stop on 'xxx=' or on 'xx==' + last_char_was_a_pad = True + else: + n = ord(table_a2b_base64[ord(c)]) + if n == 0xFF: + continue # ignore strange characters + # + # Shift it in on the low end, and see if there's + # a byte ready for output. + quad_pos = (quad_pos + 1) & 3 + leftchar = (leftchar << 6) | n + leftbits += 6 + # + if leftbits >= 8: + leftbits -= 8 + res.append((leftchar >> leftbits).to_bytes(1, "big")) + leftchar &= (1 << leftbits) - 1 + # + last_char_was_a_pad = False + else: + if leftbits != 0: + raise Exception("Incorrect padding") + + return b"".join(res) + + +# ____________________________________________________________ + +table_b2a_base64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + + +def b2a_base64(bin, newline=True): + "Base64-code line of data." + + newlength = (len(bin) + 2) // 3 + newlength = newlength * 4 + 1 + res = [] + + leftchar = 0 + leftbits = 0 + for c in bin: + # Shift into our buffer, and output any 6bits ready + leftchar = (leftchar << 8) | c + leftbits += 8 + res.append(table_b2a_base64[(leftchar >> (leftbits - 6)) & 0x3F]) + leftbits -= 6 + if leftbits >= 6: + res.append(table_b2a_base64[(leftchar >> (leftbits - 6)) & 0x3F]) + leftbits -= 6 + # + if leftbits == 2: + res.append(table_b2a_base64[(leftchar & 3) << 4]) + res.append(PAD) + res.append(PAD) + elif leftbits == 4: + res.append(table_b2a_base64[(leftchar & 0xF) << 2]) + res.append(PAD) + if newline: + res.append("\n") + return "".join(res).encode("ascii") diff --git a/internal_filesystem/lib/collections/__init__.mpy b/internal_filesystem/lib/collections/__init__.mpy deleted file mode 100644 index 138664c2..00000000 Binary files a/internal_filesystem/lib/collections/__init__.mpy and /dev/null differ diff --git a/internal_filesystem/lib/drivers/display/hx8357d/__init__.py b/internal_filesystem/lib/drivers/display/hx8357d/__init__.py new file mode 100644 index 00000000..481c92ce --- /dev/null +++ b/internal_filesystem/lib/drivers/display/hx8357d/__init__.py @@ -0,0 +1,26 @@ +import sys +from . import hx8357d +from . import _hx8357d_init + +# Register _hx8357d_init in sys.modules so __import__('_hx8357d_init') can find it +# This is needed because display_driver_framework.py uses __import__('_hx8357d_init') +# expecting a top-level module, but _hx8357d_init is in the hx8357d package subdirectory +sys.modules['_hx8357d_init'] = _hx8357d_init + +# Explicitly define __all__ and re-export public symbols from hx8357d module +__all__ = [ + 'HX8357D', + 'STATE_HIGH', + 'STATE_LOW', + 'STATE_PWM', + 'BYTE_ORDER_RGB', + 'BYTE_ORDER_BGR', +] + +# Re-export the public symbols +HX8357D = hx8357d.HX8357D +STATE_HIGH = hx8357d.STATE_HIGH +STATE_LOW = hx8357d.STATE_LOW +STATE_PWM = hx8357d.STATE_PWM +BYTE_ORDER_RGB = hx8357d.BYTE_ORDER_RGB +BYTE_ORDER_BGR = hx8357d.BYTE_ORDER_BGR diff --git a/internal_filesystem/lib/drivers/display/hx8357d/_hx8357d_init.py b/internal_filesystem/lib/drivers/display/hx8357d/_hx8357d_init.py new file mode 100644 index 00000000..8ade855c --- /dev/null +++ b/internal_filesystem/lib/drivers/display/hx8357d/_hx8357d_init.py @@ -0,0 +1,93 @@ +# Copyright (c) 2024 - 2025 Kevin G. Schlosser + +import time +from micropython import const # NOQA + +import lvgl as lv # NOQA +import lcd_bus # NOQA + + +_SWRESET = const(0x01) +_SLPOUT = const(0x11) +_DISPON = const(0x29) +_COLMOD = const(0x3A) +_MADCTL = const(0x36) +_TEON = const(0x35) +_TEARLINE = const(0x44) +_SETOSC = const(0xB0) +_SETPWR1 = const(0xB1) +_SETRGB = const(0xB3) +_SETCOM = const(0xB6) +_SETCYC = const(0xB4) +_SETC = const(0xB9) +_SETSTBA = const(0xC0) +_SETPANEL = const(0xCC) +_SETGAMMA = const(0xE0) + + +def init(self): + param_buf = bytearray(34) + param_mv = memoryview(param_buf) + + time.sleep_ms(300) # NOQA + param_buf[:3] = bytearray([0xFF, 0x83, 0x57]) + self.set_params(_SETC, param_mv[:3]) + + param_buf[0] = 0x80 + self.set_params(_SETRGB, param_mv[:1]) + + param_buf[:4] = bytearray([0x00, 0x06, 0x06, 0x25]) + self.set_params(_SETCOM, param_mv[:4]) + + param_buf[0] = 0x68 + self.set_params(_SETOSC, param_mv[:1]) + + param_buf[0] = 0x05 + self.set_params(_SETPANEL, param_mv[:1]) + + param_buf[:6] = bytearray([0x00, 0x15, 0x1C, 0x1C, 0x83, 0xAA]) + self.set_params(_SETPWR1, param_mv[:6]) + + param_buf[:6] = bytearray([0x50, 0x50, 0x01, 0x3C, 0x1E, 0x08]) + self.set_params(_SETSTBA, param_mv[:6]) + + param_buf[:7] = bytearray([0x02, 0x40, 0x00, 0x2A, 0x2A, 0x0D, 0x78]) + self.set_params(_SETCYC, param_mv[:7]) + + param_buf[:34] = bytearray([ + 0x02, 0x0A, 0x11, 0x1d, 0x23, 0x35, 0x41, 0x4b, 0x4b, 0x42, 0x3A, + 0x27, 0x1B, 0x08, 0x09, 0x03, 0x02, 0x0A, 0x11, 0x1d, 0x23, 0x35, + 0x41, 0x4b, 0x4b, 0x42, 0x3A, 0x27, 0x1B, 0x08, 0x09, 0x03, 0x00, 0x01]) + self.set_params(_SETGAMMA, param_mv[:34]) + + param_buf[0] = ( + self._madctl( + self._color_byte_order, + self._ORIENTATION_TABLE # NOQA + ) + ) + self.set_params(_MADCTL, param_mv[:1]) + + color_size = lv.color_format_get_size(self._color_space) + if color_size == 2: # NOQA + pixel_format = 0x55 + else: + raise RuntimeError( + f'{self.__class__.__name__} IC only supports ' + 'lv.COLOR_FORMAT.RGB565' + ) + + param_buf[0] = pixel_format + self.set_params(_COLMOD, param_mv[:1]) + + param_buf[0] = 0x00 + self.set_params(_TEON, param_mv[:1]) + + param_buf[:2] = bytearray([0x00, 0x02]) + self.set_params(_TEARLINE, param_mv[:2]) + + time.sleep_ms(150) # NOQA + self.set_params(_SLPOUT) + + time.sleep_ms(50) # NOQA + self.set_params(_DISPON) diff --git a/internal_filesystem/lib/drivers/display/hx8357d/hx8357d.py b/internal_filesystem/lib/drivers/display/hx8357d/hx8357d.py new file mode 100644 index 00000000..90b91306 --- /dev/null +++ b/internal_filesystem/lib/drivers/display/hx8357d/hx8357d.py @@ -0,0 +1,15 @@ +# Copyright (c) 2024 - 2025 Kevin G. Schlosser + +import display_driver_framework + + +STATE_HIGH = display_driver_framework.STATE_HIGH +STATE_LOW = display_driver_framework.STATE_LOW +STATE_PWM = display_driver_framework.STATE_PWM + +BYTE_ORDER_RGB = display_driver_framework.BYTE_ORDER_RGB +BYTE_ORDER_BGR = display_driver_framework.BYTE_ORDER_BGR + + +class HX8357D(display_driver_framework.DisplayDriver): + pass diff --git a/internal_filesystem/lib/drivers/display/ili9341/__init__.py b/internal_filesystem/lib/drivers/display/ili9341/__init__.py new file mode 100644 index 00000000..5712c270 --- /dev/null +++ b/internal_filesystem/lib/drivers/display/ili9341/__init__.py @@ -0,0 +1,28 @@ +import sys +from . import ili9341 +from . import _ili9341_init_type1 +from . import _ili9341_init_type2 + +# Register _ili9341_init_type1 and _ili9341_init_type2 in sys.modules so __import__() can find them +# This is needed because display_driver_framework.py uses __import__('_ili9341_init_type1') and __import__('_ili9341_init_type2') +# expecting top-level modules, but they are in the ili9341 package subdirectory +sys.modules['_ili9341_init_type1'] = _ili9341_init_type1 +sys.modules['_ili9341_init_type2'] = _ili9341_init_type2 + +# Explicitly define __all__ and re-export public symbols from ili9341 module +__all__ = [ + 'ILI9341', + 'STATE_HIGH', + 'STATE_LOW', + 'STATE_PWM', + 'BYTE_ORDER_RGB', + 'BYTE_ORDER_BGR', +] + +# Re-export the public symbols +ILI9341 = ili9341.ILI9341 +STATE_HIGH = ili9341.STATE_HIGH +STATE_LOW = ili9341.STATE_LOW +STATE_PWM = ili9341.STATE_PWM +BYTE_ORDER_RGB = ili9341.BYTE_ORDER_RGB +BYTE_ORDER_BGR = ili9341.BYTE_ORDER_BGR diff --git a/internal_filesystem/lib/drivers/display/ili9341/_ili9341_init_type1.py b/internal_filesystem/lib/drivers/display/ili9341/_ili9341_init_type1.py new file mode 100644 index 00000000..7b973dbe --- /dev/null +++ b/internal_filesystem/lib/drivers/display/ili9341/_ili9341_init_type1.py @@ -0,0 +1,114 @@ +# Copyright (c) 2024 - 2025 Kevin G. Schlosser + +import time +from micropython import const # NOQA +import lvgl as lv # NOQA + + +_PWR1 = const(0xC0) +_PWR2 = const(0xC1) +_VCOMCTL1 = const(0xC5) +_VCOMCTL2 = const(0xC7) +_MADCTL = const(0x36) +_COLMOD = const(0x3A) +_FRMCTR1 = const(0xB1) +_DFUNCTRL = const(0xB6) +_GAMSET = const(0x26) +_PGC = const(0xE0) +_NGC = const(0xE1) +_SLPOUT = const(0x11) +_DISPON = const(0x29) +_RASET = const(0x2B) +_CASET = const(0x2A) +_PWRCTRLB = const(0xCF) +_PWRONSQCTRL = const(0xED) +_DRVTIMCTRLA1 = const(0xE8) +_PWRCTRLA = const(0xCB) +_PUMPRATIOCTRL = const(0xF7) +_DRVTIMCTRLB = const(0xEA) +_ENA3GAMMA = const(0xF2) + + +def init(self): + param_buf = bytearray(15) + param_mv = memoryview(param_buf) + + param_buf[:3] = bytearray([0x03, 0x80, 0x02]) + self.set_params(0xEF, param_mv[:3]) + + param_buf[:3] = bytearray([0x00, 0XC1, 0X30]) + self.set_params(_PWRCTRLB, param_mv[:3]) + + param_buf[:4] = bytearray([0x64, 0x03, 0X12, 0X81]) + self.set_params(_PWRONSQCTRL, param_mv[:4]) + + param_buf[:3] = bytearray([0x85, 0x00, 0x78]) + self.set_params(_DRVTIMCTRLA1, param_mv[:3]) + + param_buf[:5] = bytearray([0x39, 0x2C, 0x00, 0x34, 0x02]) + self.set_params(_PWRCTRLA, param_mv[:5]) + + param_buf[0] = 0x20 + self.set_params(_PUMPRATIOCTRL, param_mv[:1]) + + param_buf[:2] = bytearray([0x00, 0x00]) + self.set_params(_DRVTIMCTRLB, param_mv[:2]) + + param_buf[0] = 0x23 + self.set_params(_PWR1, param_mv[:1]) + + param_buf[0] = 0x10 + self.set_params(_PWR2, param_mv[:1]) + + param_buf[:2] = bytearray([0x3e, 0x28]) + self.set_params(_VCOMCTL1, param_mv[:2]) + + param_buf[0] = 0x86 + self.set_params(_VCOMCTL2, param_mv[:1]) + + param_buf[0] = ( + self._madctl( + self._color_byte_order, + self._ORIENTATION_TABLE # NOQA + ) + ) + self.set_params(_MADCTL, param_mv[:1]) + + color_size = lv.color_format_get_size(self._color_space) + if color_size == 2: # NOQA + pixel_format = 0x55 + else: + raise RuntimeError( + f'{self.__class__.__name__} IC only supports ' + 'lv.COLOR_FORMAT.RGB565' + ) + + param_buf[0] = pixel_format + self.set_params(_COLMOD, param_mv[:1]) + + param_buf[:2] = bytearray([0x00, 0x13]) # 0x18 ?? + self.set_params(_FRMCTR1, param_mv[:2]) + + param_buf[:3] = bytearray([0x08, 0x82, 0x27]) + self.set_params(_DFUNCTRL, param_mv[:3]) + + param_buf[0] = 0x00 + self.set_params(_ENA3GAMMA, param_mv[:1]) + + param_buf[0] = 0x01 + self.set_params(_GAMSET, param_mv[:1]) + + param_buf[:15] = bytearray([ + 0x0F, 0x31, 0x2B, 0x0C, 0x0E, 0x08, 0x4E, 0xF1, + 0x37, 0x07, 0x10, 0x03, 0x0E, 0x09, 0x00]) + self.set_params(_PGC, param_mv[:15]) + + param_buf[:15] = bytearray([ + 0x00, 0x0E, 0x14, 0x03, 0x11, 0x07, 0x31, 0xC1, + 0x48, 0x08, 0x0F, 0x0C, 0x31, 0x36, 0x0F]) + self.set_params(_NGC, param_mv[:15]) + + self.set_params(_SLPOUT) + time.sleep_ms(120) # NOQA + self.set_params(_DISPON) + time.sleep_ms(20) # NOQA diff --git a/internal_filesystem/lib/drivers/display/ili9341/_ili9341_init_type2.py b/internal_filesystem/lib/drivers/display/ili9341/_ili9341_init_type2.py new file mode 100644 index 00000000..769aea31 --- /dev/null +++ b/internal_filesystem/lib/drivers/display/ili9341/_ili9341_init_type2.py @@ -0,0 +1,117 @@ +# Copyright (c) 2024 - 2025 Kevin G. Schlosser + +import time +from micropython import const # NOQA +import lvgl as lv # NOQA + + +_PWR1 = const(0xC0) +_PWR2 = const(0xC1) +_VCOMCTL1 = const(0xC5) +_VCOMCTL2 = const(0xC7) +_MADCTL = const(0x36) +_COLMOD = const(0x3A) +_FRMCTR1 = const(0xB1) +_DFUNCTRL = const(0xB6) +_GAMSET = const(0x26) +_PGC = const(0xE0) +_NGC = const(0xE1) +_SLPOUT = const(0x11) +_DISPON = const(0x29) +_RASET = const(0x2B) +_CASET = const(0x2A) +_PWRCTRLB = const(0xCF) +_PWRONSQCTRL = const(0xED) +_DRVTIMCTRLA1 = const(0xE8) +_PWRCTRLA = const(0xCB) +_PUMPRATIOCTRL = const(0xF7) +_DRVTIMCTRLB = const(0xEA) +_ENA3GAMMA = const(0xF2) + + +def init(self): + param_buf = bytearray(15) + param_mv = memoryview(param_buf) + + param_buf[:3] = bytearray([0x00, 0XC1, 0X30]) + self.set_params(_PWRCTRLB, param_mv[:3]) + + param_buf[:4] = bytearray([0x64, 0x03, 0X12, 0X81]) + self.set_params(_PWRONSQCTRL, param_mv[:4]) + + param_buf[:3] = bytearray([0x85, 0x00, 0x78]) + self.set_params(_DRVTIMCTRLA1, param_mv[:3]) + + param_buf[:5] = bytearray([0x39, 0x2C, 0x00, 0x34, 0x02]) + self.set_params(_PWRCTRLA, param_mv[:5]) + + param_buf[0] = 0x20 + self.set_params(_PUMPRATIOCTRL, param_mv[:1]) + + param_buf[:2] = bytearray([0x00, 0x00]) + self.set_params(_DRVTIMCTRLB, param_mv[:2]) + + param_buf[0] = 0x10 + self.set_params(_PWR1, param_mv[:1]) + + param_buf[0] = 0x00 + self.set_params(_PWR2, param_mv[:1]) + + param_buf[:2] = bytearray([0x30, 0x30]) + self.set_params(_VCOMCTL1, param_mv[:2]) + + param_buf[0] = 0xB7 + self.set_params(_VCOMCTL2, param_mv[:1]) + + param_buf[0] = ( + self._madctl( + self._color_byte_order, + self._ORIENTATION_TABLE # NOQA + ) + ) + self.set_params(_MADCTL, param_mv[:1]) + + color_size = lv.color_format_get_size(self._color_space) + if color_size == 2: # NOQA + pixel_format = 0x55 + else: + raise RuntimeError( + f'{self.__class__.__name__} IC only supports ' + 'lv.COLOR_FORMAT.RGB565' + ) + + param_buf[0] = pixel_format + self.set_params(_COLMOD, param_mv[:1]) + + param_buf[:2] = bytearray([0x00, 0x1A]) + self.set_params(_FRMCTR1, param_mv[:2]) + + param_buf[:3] = bytearray([0x08, 0x82, 0x27]) + self.set_params(_DFUNCTRL, param_mv[:3]) + + param_buf[0] = 0x00 + self.set_params(_ENA3GAMMA, param_mv[:1]) + + param_buf[0] = 0x01 + self.set_params(_GAMSET, param_mv[:1]) + + param_buf[:15] = bytearray([ + 0x0F, 0x2A, 0x28, 0x08, 0x0E, 0x08, 0x54, 0xA9, + 0x43, 0x0A, 0x0F, 0x00, 0x00, 0x00, 0x00]) + self.set_params(_PGC, param_mv[:15]) + + param_buf[:15] = bytearray([ + 0x00, 0x15, 0x17, 0x07, 0x11, 0x06, 0x2B, 0x56, + 0x3C, 0x05, 0x10, 0x0F, 0x3F, 0x3F, 0x0F]) + self.set_params(_NGC, param_mv[:15]) + + param_buf[:4] = bytearray([0x00, 0x00, 0x01, 0x3f]) + self.set_params(_RASET, param_mv[:4]) + + param_buf[:4] = bytearray([0x00, 0x00, 0x00, 0xef]) + self.set_params(_CASET, param_mv[:4]) + + self.set_params(_SLPOUT) + time.sleep_ms(120) # NOQA + self.set_params(_DISPON) + time.sleep_ms(20) # NOQA diff --git a/internal_filesystem/lib/drivers/display/ili9341/ili9341.py b/internal_filesystem/lib/drivers/display/ili9341/ili9341.py new file mode 100644 index 00000000..e4ac2c32 --- /dev/null +++ b/internal_filesystem/lib/drivers/display/ili9341/ili9341.py @@ -0,0 +1,15 @@ +# Copyright (c) 2024 - 2025 Kevin G. Schlosser + +import display_driver_framework + + +STATE_HIGH = display_driver_framework.STATE_HIGH +STATE_LOW = display_driver_framework.STATE_LOW +STATE_PWM = display_driver_framework.STATE_PWM + +BYTE_ORDER_RGB = display_driver_framework.BYTE_ORDER_RGB +BYTE_ORDER_BGR = display_driver_framework.BYTE_ORDER_BGR + + +class ILI9341(display_driver_framework.DisplayDriver): + pass diff --git a/internal_filesystem/lib/drivers/display/st7789/__init__.py b/internal_filesystem/lib/drivers/display/st7789/__init__.py new file mode 100644 index 00000000..0b38c11b --- /dev/null +++ b/internal_filesystem/lib/drivers/display/st7789/__init__.py @@ -0,0 +1,26 @@ +import sys +from . import st7789 +from . import _st7789_init + +# Register _st7789_init in sys.modules so __import__('_st7789_init') can find it +# This is needed because display_driver_framework.py uses __import__('_st7789_init') +# expecting a top-level module, but _st7789_init is in the st7789 package subdirectory +sys.modules['_st7789_init'] = _st7789_init + +# Explicitly define __all__ and re-export public symbols from st7789 module +__all__ = [ + 'ST7789', + 'STATE_HIGH', + 'STATE_LOW', + 'STATE_PWM', + 'BYTE_ORDER_RGB', + 'BYTE_ORDER_BGR', +] + +# Re-export the public symbols +ST7789 = st7789.ST7789 +STATE_HIGH = st7789.STATE_HIGH +STATE_LOW = st7789.STATE_LOW +STATE_PWM = st7789.STATE_PWM +BYTE_ORDER_RGB = st7789.BYTE_ORDER_RGB +BYTE_ORDER_BGR = st7789.BYTE_ORDER_BGR diff --git a/internal_filesystem/lib/drivers/display/st7789/_st7789_init.py b/internal_filesystem/lib/drivers/display/st7789/_st7789_init.py new file mode 100644 index 00000000..aebf24df --- /dev/null +++ b/internal_filesystem/lib/drivers/display/st7789/_st7789_init.py @@ -0,0 +1,171 @@ +# Copyright (c) 2024 - 2025 Kevin G. Schlosser + +import time +from micropython import const # NOQA + +import lvgl as lv # NOQA +import lcd_bus + + +_SWRESET = const(0x01) +_SLPOUT = const(0x11) +_MADCTL = const(0x36) +_COLMOD = const(0x3A) +_PORCTRL = const(0xB2) +_GCTRL = const(0xB7) +_VCOMS = const(0xBB) +_LCMCTRL = const(0xC0) +_VDVVRHEN = const(0xC2) +_VRHS = const(0xC3) +_VDVSET = const(0xC4) +_FRCTR2 = const(0xC6) +_PWCTRL1 = const(0xD0) +_INVON = const(0x21) +_CASET = const(0x2A) +_RASET = const(0x2B) +_PGC = const(0xE0) +_NGC = const(0xE1) +_DISPON = const(0x29) +_NORON = const(0x13) + +_RAMCTRL = const(0xB0) +_RGB565SWAP = const(0x08) + + +def init(self): + param_buf = bytearray(14) + param_mv = memoryview(param_buf) + + self.set_params(_SWRESET) + + time.sleep_ms(120) # NOQA + + self.set_params(_SLPOUT) + + time.sleep_ms(120) # NOQA + + self.set_params(_NORON) + + param_buf[0] = ( + self._madctl( + self._color_byte_order, + self._ORIENTATION_TABLE # NOQA + ) + ) + self.set_params(_MADCTL, param_mv[:1]) + + param_buf[0] = 0x0A + param_buf[1] = 0x82 + self.set_params(0xB6, param_mv[:2]) + + # sets swapping the bytes at the hardware level. + + color_size = lv.color_format_get_size(self._color_space) + if isinstance(self._data_bus, lcd_bus.I80Bus) and color_size == 2: + param_buf[0] = 0x00 + param_buf[1] = 0xF0 + if self._data_bus.get_lane_count() == 8: + param_buf[1] |= _RGB565SWAP + self.set_params(_RAMCTRL, param_mv[:2]) + + if color_size == 2: # NOQA + pixel_format = 0x55 + elif color_size == 3: + pixel_format = 0x77 + else: + raise RuntimeError( + f'{self.__class__.__name__} IC only supports ' + 'lv.COLOR_FORMAT.RGB565 or lv.COLOR_FORMAT.RGB888' + ) + + param_buf[0] = pixel_format + self.set_params(_COLMOD, param_mv[:1]) + + time.sleep_ms(10) # NOQA + + param_buf[0] = 0x0C + param_buf[1] = 0x0C + param_buf[2] = 0x00 + param_buf[3] = 0x33 + param_buf[4] = 0x33 + self.set_params(_PORCTRL, param_mv[:5]) + + param_buf[0] = 0x35 + self.set_params(_GCTRL, param_mv[:1]) + + param_buf[0] = 0x28 + self.set_params(_VCOMS, param_mv[:1]) + + param_buf[0] = 0x0C + self.set_params(_LCMCTRL, param_mv[:1]) + + param_buf[0] = 0x01 + self.set_params(_VDVVRHEN, param_mv[:1]) + + param_buf[0] = 0x13 + self.set_params(_VRHS, param_mv[:1]) + + param_buf[0] = 0x20 + self.set_params(_VDVSET, param_mv[:1]) + + param_buf[0] = 0x0F + self.set_params(_FRCTR2, param_mv[:1]) + + param_buf[0] = 0xA4 + param_buf[1] = 0xA1 + self.set_params(_PWCTRL1, param_mv[:2]) + + param_buf[0] = 0xD0 + param_buf[1] = 0x00 + param_buf[2] = 0x02 + param_buf[3] = 0x07 + param_buf[4] = 0x0A + param_buf[5] = 0x28 + param_buf[6] = 0x32 + param_buf[7] = 0x44 + param_buf[8] = 0x42 + param_buf[9] = 0x06 + param_buf[10] = 0x0E + param_buf[11] = 0x12 + param_buf[12] = 0x14 + param_buf[13] = 0x17 + self.set_params(_PGC, param_mv[:14]) + + param_buf[0] = 0xD0 + param_buf[1] = 0x00 + param_buf[2] = 0x02 + param_buf[3] = 0x07 + param_buf[4] = 0x0A + param_buf[5] = 0x28 + param_buf[6] = 0x31 + param_buf[7] = 0x54 + param_buf[8] = 0x47 + param_buf[9] = 0x0E + param_buf[10] = 0x1C + param_buf[11] = 0x17 + param_buf[12] = 0x1B + param_buf[13] = 0x1E + self.set_params(_NGC, param_mv[:14]) + + self.set_params(_INVON) + + param_buf[0] = 0x00 + param_buf[1] = 0x00 + param_buf[2] = (self.display_width >> 8) & 0xFF + param_buf[3] = self.display_width & 0xFF + + self.set_params(_CASET, param_mv[:4]) + + # Page addresses + param_buf[0] = 0x00 + param_buf[1] = 0x00 + param_buf[2] = (self.display_height >> 8) & 0xFF + param_buf[3] = self.display_height & 0xFF + + self.set_params(_RASET, param_mv[:4]) + + self.set_params(_DISPON) + time.sleep_ms(120) # NOQA + + self.set_params(_SLPOUT) + time.sleep_ms(120) # NOQA diff --git a/internal_filesystem/lib/drivers/display/st7789/st7789.py b/internal_filesystem/lib/drivers/display/st7789/st7789.py new file mode 100644 index 00000000..26b9b3a5 --- /dev/null +++ b/internal_filesystem/lib/drivers/display/st7789/st7789.py @@ -0,0 +1,80 @@ +# Copyright (c) 2024 - 2025 Kevin G. Schlosser + +from micropython import const # NOQA +import display_driver_framework +import lcd_bus +import lvgl as lv + + +STATE_HIGH = display_driver_framework.STATE_HIGH +STATE_LOW = display_driver_framework.STATE_LOW +STATE_PWM = display_driver_framework.STATE_PWM + +BYTE_ORDER_RGB = display_driver_framework.BYTE_ORDER_RGB +BYTE_ORDER_BGR = display_driver_framework.BYTE_ORDER_BGR + +_MADCTL_MV = const(0x20) # 0=Normal, 1=Row/column exchange +_MADCTL_MX = const(0x40) # 0=Left to Right, 1=Right to Left +_MADCTL_MY = const(0x80) # 0=Top to Bottom, 1=Bottom to Top + + +class ST7789(display_driver_framework.DisplayDriver): + _ORIENTATION_TABLE = ( + 0x0, + _MADCTL_MV | _MADCTL_MX, + _MADCTL_MY | _MADCTL_MX, + _MADCTL_MV | _MADCTL_MY + ) + + def __init__( + self, + data_bus, + display_width, + display_height, + frame_buffer1=None, + frame_buffer2=None, + reset_pin=None, + reset_state=STATE_HIGH, + power_pin=None, + power_on_state=STATE_HIGH, + backlight_pin=None, + backlight_on_state=STATE_HIGH, + offset_x=0, + offset_y=0, + color_byte_order=BYTE_ORDER_RGB, + color_space=lv.COLOR_FORMAT.RGB888, # NOQA + rgb565_byte_swap=False, + ): + + if color_space != lv.COLOR_FORMAT.RGB565: # NOQA + rgb565_byte_swap = False + + self._rgb565_byte_swap = rgb565_byte_swap + + if ( + isinstance(data_bus, lcd_bus.I80Bus) and + data_bus.get_lane_count() == 8 + ): + rgb565_byte_swap = False + + super().__init__( + data_bus=data_bus, + display_width=display_width, + display_height=display_height, + frame_buffer1=frame_buffer1, + frame_buffer2=frame_buffer2, + reset_pin=reset_pin, + reset_state=reset_state, + power_pin=power_pin, + power_on_state=power_on_state, + backlight_pin=backlight_pin, + backlight_on_state=backlight_on_state, + offset_x=offset_x, + offset_y=offset_y, + color_byte_order=color_byte_order, + color_space=color_space, # NOQA + rgb565_byte_swap=rgb565_byte_swap, + _cmd_bits=8, + _param_bits=8, + _init_bus=True + ) diff --git a/internal_filesystem/lib/drivers/fri3d/communicator.py b/internal_filesystem/lib/drivers/fri3d/communicator.py new file mode 100644 index 00000000..e384edca --- /dev/null +++ b/internal_filesystem/lib/drivers/fri3d/communicator.py @@ -0,0 +1,122 @@ +import struct + +from micropython import const +from machine import I2C, UART + +from .device import Device + +# registers +_COMM_REG_KEY_REPORT = const(0x03) +_COMM_REG_CONFIG = const(0x0B) +_COMM_REG_BACKLIGHT = const(0x0C) +_COMM2024_REG_RGB_LED = const(0x0E) +_COMM2024_REG_RED_LED = const(0x11) + +_COMM2024_I2CADDR_DEFAULT = const(0x38) +_COMM2026_I2CADDR_DEFAULT = const(0x39) + + +class Communicator2026(Device): + """Fri3d Badge 2026 expander MCU.""" + + def __init__( + self, + i2c_bus: I2C, + uart_bus: UART = None, + address: int = _COMM2026_I2CADDR_DEFAULT, + ): + """Read from a 2026 communicator""" + Device.__init__(self, i2c_bus, address) + self.use_uart = False + self.write_idx = 0 + self.data_ready = False + if uart_bus: + self.use_uart = True + self.uart = uart_bus.init(115200, bits=8, parity=None, stop=1) + self._rx_buf = bytearray(8) + self._rx_mv = memoryview(self._rx_buf) + self.uart.irq(handler=self.uart_handler, trigger=UART.IRQ_RX) + + def uart_handler(self, uart): + """Interrupt handler for incoming UART data""" + while uart.any() and not self.data_ready: + # Calculate how much space is left + space_left = 8 - self.write_idx + + # Read directly into the slice of the memoryview + # readinto returns the number of bytes actually read + num_read = uart.readinto(self._rx_mv[self.write_idx :], space_left) + + if num_read: + self.write_idx += num_read + + if self.write_idx >= 8: + self.data_ready = True + + @property + def key_report(self) -> tuple[int, int, int, int, int, int, int, int, int]: + """return the key report read using I2C or UART""" + ret = None + if self.use_uart and self.data_ready: + # Process the data (raw_buffer now contains the 8 bytes) + ret = tuple(self._rx_buf) + self.write_idx = 0 + self.data_ready = False + else: + ret = self._read("BBBBBBBB", _COMM_REG_KEY_REPORT, 8) + return ret + + @property + def configuration(self) -> int: + """get the configuration byte""" + return self._read("B", _COMM_REG_CONFIG, 1)[0] + + @configuration.setter + def configuration(self, value: int): + """Set the configuration byte""" + self._write(_COMM_REG_CONFIG, struct.pack("B", value)) + + @property + def backlight(self) -> int: + """Get the backlight value (0-100)""" + return self._read("= 0 and value <= 100: + self.i2c.writeto_mem( + self.address, _COMM_REG_BACKLIGHT, struct.pack(" tuple[int, int, int]: + """Get the LANA module RGB LED""" + return self._read("BBB", _COMM2024_REG_RGB_LED, 3) + + @rgb_led.setter + def rgb_led(self, value: tuple[int, int, int]): + """Set the LANA module RGB LED""" + self._write(_COMM2024_REG_RGB_LED, struct.pack("BBB", *value)) + + @property + def red_led(self) -> int: + """Get the CAPS LED""" + return self._read("B", _COMM2024_REG_RED_LED, 1)[0] + + @red_led.setter + def red_led(self, value: int): + """Set the CAPS LED""" + if value >= 0 and value <= 0xFF: + self._write(_COMM2024_REG_RED_LED, struct.pack("B", value)) diff --git a/internal_filesystem/lib/drivers/fri3d/device.py b/internal_filesystem/lib/drivers/fri3d/device.py new file mode 100644 index 00000000..84f5c76f --- /dev/null +++ b/internal_filesystem/lib/drivers/fri3d/device.py @@ -0,0 +1,26 @@ +import struct + +from micropython import const +from machine import I2C + +# common registers +_REG_VERSION = const(0x00) + + +class Device: + """Fri3d I2C device.""" + + def __init__(self, i2c_bus: I2C, address: int): + """Read from a sensor on the given I2C bus, at the given address.""" + self.i2c = i2c_bus + self.address = address + + def _read(self, format, reg, amount): + return struct.unpack(format, self.i2c.readfrom_mem(self.address, reg, amount)) + + def _write(self, reg, value): + self.i2c.writeto_mem(self.address, reg, value, addrsize=8) + + @property + def version(self) -> tuple[int, int, int]: + return self._read("BBB", _REG_VERSION, 3) diff --git a/internal_filesystem/lib/drivers/fri3d/expander.py b/internal_filesystem/lib/drivers/fri3d/expander.py new file mode 100644 index 00000000..c24cbe6f --- /dev/null +++ b/internal_filesystem/lib/drivers/fri3d/expander.py @@ -0,0 +1,89 @@ +import struct + +from micropython import const +from machine import I2C, Pin + +from .device import Device + +# registers +_EXPANDER_REG_INPUTS = const(0x04) +_EXPANDER_REG_ANALOG = const(0x06) +_EXPANDER_REG_LCD_BRIGHTNESS = const(0x12) +_EXPANDER_REG_DEBUG_LED = const(0x14) +_EXPANDER_REG_CONFIG = const(0x16) + +_EXPANDER_I2CADDR_DEFAULT = const(0x50) + + +class Expander(Device): + """Fri3d Badge 2026 expander MCU.""" + + def __init__( + self, + i2c_bus: I2C, + address: int = _EXPANDER_I2CADDR_DEFAULT, + int_pin: Pin = None, + ): + """Read from a sensor on the given I2C bus, at the given address.""" + Device.__init__(self, i2c_bus, address) + self.use_interrupt = False + if int_pin: + self.use_interrupt = True + self._rx_buf = bytearray(2) + self._rx_mv = memoryview(self._rx_buf) + self.int_pin = int_pin + self.i2c.readfrom_mem_into(self.address, _EXPANDER_REG_INPUTS, self._rx_mv) + self.int_pin.irq(trigger=Pin.IRQ_RISING, handler=self.int_callback) + + def int_callback(self, p): + self.i2c.readfrom_mem_into(self.address, _EXPANDER_REG_INPUTS, self._rx_mv) + + @property + def analog(self) -> tuple[int, int, int, int, int, int]: + """Read the analog inputs: ain1, ain0, battery_monitor, usb_monitor, joystick_y, joystick_x""" + return self._read(" tuple[bool, bool, bool, bool, bool, bool, bool, bool, bool, bool, bool, bool]: + """Read the digital inputs: usb_plugged, joy_right, joy_left, joy_down, joy_up, button_menu, button_b, button_a, button_y, button_x, charger_standby, charger_charging""" + if self.use_interrupt: + inputs = struct.unpack(" int: + """Read the LCD brightness state (0-100)""" + return self._read("= 0 and value <= 100: + self._write(_EXPANDER_REG_LCD_BRIGHTNESS, struct.pack(" int: + """Read the Debug LED state (0-100)""" + return self._read("= 0 and value <= 100: + self._write(_EXPANDER_REG_DEBUG_LED, struct.pack(" tuple[bool, bool, bool]: + """Read the configuration bits: reboot, lcd_reset, aux_power""" + config = self._read("B", _EXPANDER_REG_CONFIG, 1)[0] + return tuple([bool(int(digit)) for digit in "{:08b}".format(config)[5:]]) + + @config.setter + def config(self, value: int): + """set the configuration byte""" + if value >= 0 and value <= 0xFF: + self._write(_EXPANDER_REG_CONFIG, struct.pack("B", value)) diff --git a/internal_filesystem/lib/drivers/imu_sensor/bma423/bma423.py b/internal_filesystem/lib/drivers/imu_sensor/bma423/bma423.py new file mode 100644 index 00000000..020ae266 --- /dev/null +++ b/internal_filesystem/lib/drivers/imu_sensor/bma423/bma423.py @@ -0,0 +1,383 @@ +# BMA423 driver that aims to support features detection. +# +# Copyright (C) 2024 Salvatore Sanfilippo -- All Rights Reserved +# This code is released under the MIT license +# https://opensource.org/license/mit/ +# +# Written reading the specification at: +# https://www.mouser.com/datasheet/2/783/BST-BMA423-DS000-1509600.pdf + +from machine import Pin +import time + +# Registers +REG_CHIP_ID = const(0x00) # Chip identification number. +REG_INT_STATUS_0 = const(0x1C) # Interrupt status for features detection. +REG_INT_STATUS_1 = const(0x1D) # Interrupt status for data ready. +REG_STEP_COUNTER_0 = const(0x1E) # For bytes starting here. Number of steps. +REG_TEMPERATURE = const(0x22) # Temperature sensor reading: kelvin units. +REG_INTERNAL_STATUS = const(0x2A) # Error / status bits. +REG_ACC_CONF = const(0x40) # Out data rate, bandwidth, read mode. +REG_ACC_RANGE = const(0x41) # Acceleration range selection. +REG_PWR_CONF = const(0x7c) # Power mode configuration. +REG_PWR_CTL = const(0x7d) # Used to power-on the device parts +REG_CMD = const(0x7e) # Write there to send commands +REG_INT1_IO_CTRL = const(0x53) # Electrical config of interrupt 1 pin. +REG_INT2_IO_CTRL = const(0x54) # Electrical config of interrupt 2 pin. +REG_INT_LATCH = const(0x55) # Interrupt latch mode. +REG_INT1_MAP = const(0x56) # Interrput map for detected features and pin1. +REG_INT2_MAP = const(0x57) # Interrput map for detected features and pin2. +REG_INT_MAP_DATA = const(0x58) # Interrupt map for pin1/2 data events. +REG_INIT_CTRL = const(0x59) # Initialization register. +FEATURES_IN_SIZE = const(70) # Size of the features configuration area + +# Commands for the REG_CMD register +REG_CMD_SOFTRESET = const(0xB6) + +class BMA423: + # Acceleration range can be selected among the available settings of + # 2G, 4G, 8G and 16G. If we want to be able to measure higher + # max accelerations, the relative precision decreases as we have + # a fixed 12 bit reading. + def __init__(self,i2c,*,acc_range=2,address=None): + default_addr = [0x18,0x19] # Changes depending on SDO pin + # pulled to ground or V+ + self.i2c = i2c + self.myaddr = None + self.features_in = bytearray(FEATURES_IN_SIZE) + + if address is not None: + self.myaddr = address + else: + found_devices = i2c.scan() + print("BMA423: scan i2c bus:", [hex(x) for x in found_devices]) + for addr in default_addr: + if addr in found_devices: + self.myaddr = addr + break + if self.myaddr == None: + raise Exception("BMA423 not found at i2c bus") + print("BMA423: device with matching address found at",hex(self.myaddr)) + + # Device initialization. + self.reset() + chip_id = self.get_reg(REG_CHIP_ID) + if chip_id != 0x13: + raise Exception("BMA423 chip ID is not 0x13 as expected. Different sensor connected?") + print("BMA423: chip correctly identified.") + + # Set default parameters. By default we enable the accelerometer + # so that the user can read the acceleration vector from the + # device without much setup work. + self.enable_accelerometer(acc=True,aux=False) + self.set_accelerometer_perf(True) + self.set_accelerometer_avg(2) + self.set_accelerometer_freq(100) + self.set_advanced_power_save(False,False) + self.set_range(acc_range) + + # Soft reset using the commands register. + def reset(self): + self.set_reg(REG_CMD,REG_CMD_SOFTRESET) # Reset the chip. + time.sleep(1) # Datasheet claims we need to wait that much. I know. + + # Enable or disable advanced power saving (ADP). + # + # When data is not being sampled, power saving mode slows down the + # clock and makes latency higher. + # Fifo self wakeup controls if the FIFO works when ADP is enabled. + # Step counting less reliable if APS is enabled (note of the implementator). + def set_advanced_power_save(self,adp=False,fifo_self_wakeup=False): + adp = int(adp) & 1 + fifo_self_wakeup = (int(fifo_self_wakeup) & 1) << 1 + self.set_reg(REG_PWR_CONF,adp|fifo_self_wakeup) + + # Enable/Disable accelerometer and aux sensor. + def enable_accelerometer(self,*,acc=True,aux=False): + val = 0 + if acc: val |= 0x4 # acc_en bit, enable accelerometer acquisition. + if aux: val |= 0x1 # aux_en bit, enable aux sensor. + self.set_reg(REG_PWR_CTL,val) + + # Enable/Disable performance mode. When performance mode is enabled + # the accelerometer performs continuous sampling at the specified + # sampling rate. + def set_accelerometer_perf(self,perf_mode): + val = self.get_reg(REG_ACC_CONF) + val = (val & 0b01111111) | (int(perf_mode) << 7) + self.set_reg(REG_ACC_CONF,val) + + # Set average mode. The mode selected depends on the fact performance + # mode is enabled/disabled. + # Valid values: + # perf mode on: 0 = osr4, 1 = osr2, 2 = normal, 3 = cic. + # perf mode off: 0 = avg1, 1 = avg2, 2 = avg4, 3 = avg8 + # 4 = avg16, 5 = avg32, 6 = avg64, 7 = avg128. + def set_accelerometer_avg(self,avg_mode): + val = self.get_reg(REG_ACC_CONF) + val = (val & 0b10001111) | avg_mode << 4 + self.set_reg(REG_ACC_CONF,val) + + # Set accelerometer sampling frequency, either as a frequency in + # hz that we convert using a table, or as immediate value if the + # user wants to select one of the low frequency modes (see datasheet). + def set_accelerometer_freq(self,freq): + table = {25:6, 50:7, 100:8, 200:9, 400:10, 800:11, 1600:12} + if freq in table: + freq = table[freq] + elif freq == 0 or freq >= 0x0d: + raise Exception("Invalid frequency or raw value") + val = self.get_reg(REG_ACC_CONF) + val = (val & 0b11110000) | freq + self.set_reg(REG_ACC_CONF,val) + + # Write in the FEATURES-IN configuration to enable specific + # features. + def enable_features_detection(self,*features): + self.read_features_in() + for f in features: + if f == "step-count": + self.features_in[0x3B] |= 0x10 # Enable step counter. + else: + raise Exception("Unrecognized feature name",f) + self.write_features_in() + + # Prepare the device to load the binary configuration in the + # bma423conf.bin file (data from Bosch). This step is required for + # features detection. + def load_features_config(self): + saved_pwr_conf = self.get_reg(REG_PWR_CONF) # To restore it later. + self.set_reg(REG_PWR_CONF,0x00) # Disable adv_power_save. + time.sleep_us(500) # Wait time synchronization. + self.set_reg(REG_INIT_CTRL,0x00) # Prepare for loading configuration. + self.transfer_config() # Load binary features config. + self.set_reg(REG_INIT_CTRL,0x01) # Enable features. + time.sleep_ms(140) # Wait ASIC initialization. + + # The chip is ready for further configuration when the + # status "message" turns 1. + wait_epoch = 0 + while True: + status = self.get_reg(REG_INTERNAL_STATUS) & 0b11111 + if status == 1: break # Initialization successful + time.sleep_ms(50) + wait_epoch += 1 + if wait_epoch == 20: + raise Exception("Timeout during init, internal_status: ", + status) + print("BMA423: features engine initialized successfully.") + self.set_reg(REG_PWR_CONF,saved_pwr_conf) + + # Write to the ASIC memory. This is useful to set the device + # features configuration. + # + # Writing / reading from ASIC works setting two registers that + # point to the memory area(0x5B/5C), and then reading/writing from/to + # the register 0x5E. Note that while normally writing / reading + # to a given register will write bytes to successive registers, in + # the case of 0x5E it works like a "port", so we keep reading + # or writing from successive parts of the ASIC memory. + def write_config_mem(self,idx,buf): + # The index of the half-word (so index/2) must + # be placed into this two undocumented registers + # 0x5B and 0x5C. Data goes in 0xE. + # Thanks for the mess, Bosch! + self.set_reg(0x5b,(idx//2)&0xf) # Set LSB (bits 3:0) + self.set_reg(0x5c,(idx//2)>>4) # Set MSB (bits 11:5) + self.set_reg(0x5e,buf) + + # see write_config_mem(). + def read_config_mem(self,idx,count): + self.set_reg(0x5b,(idx//2)&0xf) # Set LSB (bits 3:0) + self.set_reg(0x5c,(idx//2)>>4) # Set MSB (bits 11:5) + return self.get_reg(0x5e,count) + + # Read the steps counter. + def get_steps(self): + data = self.get_reg(REG_STEP_COUNTER_0,4) + return data[0] | data[1]<<8 | data[2]<<16 | data[3]<<24 + + # The BMA423 features detection requires that we transfer a binary + # blob via the features configuration register (and other two undocumented + # registers that set the internal target address at which the register + # points). If this are the weights of a small neural network, or just + # parameters, I'm not sure. More info (LOL, not really) here: + # + # https://github.com/boschsensortec/BMA423_SensorDriver + def transfer_config(self): + print("Uploading features configuration...") + f = open("bma423conf.bin","rb") + buf = bytearray(8) # Binary config is multiple of 8 in len. + idx = 0 + while f.readinto(buf,8) == 8: + self.write_config_mem(idx,buf) + idx += 8 + print("Done: total transfer: ", idx) + + # Verify the content. + print("BMA423: Verifying stored configuration...") + idx = 0 + f.seek(0) + while f.readinto(buf,8) == 8: + content = self.read_config_mem(idx,8) + idx += 8 + if content != buf: + raise Exception("Feature config data mismatch at",idx) + f.close() + + # Enable interrupt for the specified list of events. + # + # 'chip_pin' is 1 or 2 (the chip has two interrupt pins), you should + # select the one you want to use or the one you have an actual + # connection to with your host. + # 'pin' is your machine.Pin instance in your host. + # 'callback' is the function to call when the specified events will fire. + # 'events' is a list of strings specifying what events you want to + # listen for. Valid events are: + # "data": new acceleration reading available. + # "fifo-wm: fifo watermark reached. + # "fifo-full": fifo is full. + # "step": step feature. + # "activity": detect walking, running, ... + # "tilt": tilt on wrist. + # "double-tap": double tap. + # "single-tap": single tap. + # "any-none": any motion / no motion detected. + # Note: you can't subscribe to both double and single tap. + def enable_interrupt(self,chip_pin,pin,callback,events): + self.callback = callback + + # Features detection only work in latch mode. + self.set_reg(REG_INT_LATCH,0x01) + # feature name -> bit to set in INT1/2_MAP. + feature_bits = {"any-none":6,"tilt":3,"activity":2,"step":1} + # data source name -> bit to set for [pin1,pin2] in + # INT_MAP_DATA. + data_bits = {"data":[2,6],"fifo-wm":[1,5],"fifo-full":[0,4]} + + # Set features/data interrupt maps register values. + feature_map,data_map = 0,0 + for e in events: + if e in feature_bits: + feature_map |= (1 << feature_bits[e]) + elif e in data_bits: + data_map |= data_bits[e][chip_pin-1] + else: + raise Exception(f"Unknown event {e} when enabling interrupt.") + if feature_map != 0: + map_int_reg = REG_INT1_MAP if chip_pin == 1 else REG_INT2_MAP + self.set_reg(map_int_reg,feature_map) + if data_map != 0: self.set_reg(REG_INT_MAP_DATA,data_map) + + # XXX: set config registers according to single/double tap. + # XXX: set FEATURES_IN registers. + + # Configure the electrical interrput pin behavior. + ctrl_reg = REG_INT1_IO_CTRL if chip_pin == 1 else REG_INT2_IO_CTRL + # Output enabled 0x8, active high 0x2, all other bits zero, that + # is: input_enabled=no, edge_ctrl=level-trigger, od=push-pull. + self.set_reg(ctrl_reg, 0x8 | 0x2) + + # Finally enable the interrupt in the host pin. + pin.irq(handler=self.irq, trigger=Pin.IRQ_RISING) + + # Set range of 2, 4 or 8 or 16g + def set_range(self,acc_range): + range_to_regval = {2:0,4:1,8:2,16:3} + if not acc_range in range_to_regval: + raise Exception(f"Invalid range {acc_range}: use 2, 4, 8, 16", + acc_range) + self.range = acc_range + self.set_reg(REG_ACC_RANGE,range_to_regval[acc_range]) + + # Convert the raw 12 bit number in two's complement as a signed + # number. + def convert_to_int12(self,raw_value): + if not raw_value & 0x800: return raw_value + raw_value = ((~raw_value) & 0x7ff) + 1 + return -raw_value + + # Normalize the signed 12 bit acceleration value to + # acceleration value in "g" according to the currently + # selected range. + def normalize_reading(self,reading): + return self.range / 2047 * reading + + # Return x,y,z acceleration. + def get_xyz(self): + rawdata = self.get_reg(0x12,6) + acc_x = (rawdata[0] >> 4) | (rawdata[1] << 4) + acc_y = (rawdata[2] >> 4) | (rawdata[3] << 4) + acc_z = (rawdata[4] >> 4) | (rawdata[5] << 4) + acc_x = self.convert_to_int12(acc_x) + acc_y = self.convert_to_int12(acc_y) + acc_z = self.convert_to_int12(acc_z) + acc_x = self.normalize_reading(acc_x) + acc_y = self.normalize_reading(acc_y) + acc_z = self.normalize_reading(acc_z) + return (acc_x,acc_y,acc_z) + + # Return the chip tempereature in celsius. + # If the temperature is invalid, None is returned. + def get_temperature(self): + raw = self.get_reg(REG_TEMPERATURE) + if raw == 0x80: return None + if raw & 0x80: + raw = -((~raw)+1) # Conver 2 complement to signed integer. + return 23+raw + + def irq(self, pin): + if self.callback is None: + print("BMA423: not handled IRQ. Please, set a callback.") + return + data = {} + + print("IRQ CALLED") + + if len(data) is None: + return + self.callback(data) + + # Return the single byte at the specified register + def get_reg(self, register, count=1): + if count == 1: + return self.i2c.readfrom_mem(self.myaddr,register,1)[0] + else: + return self.i2c.readfrom_mem(self.myaddr,register,count) + + # Write 'value' to the specified register + def set_reg(self, register, value): + if isinstance(value,bytearray) or isinstance(value,bytes): + self.i2c.writeto_mem(self.myaddr,register,value) + else: + self.i2c.writeto_mem(self.myaddr,register,bytes([value])) + + def read_features_in(self): + self.i2c.readfrom_mem_into(self.myaddr,0x5E,self.features_in) + + def write_features_in(self): + self.i2c.writeto_mem(self.myaddr,0x5E,self.features_in) + +# Example usage and quick test to see if your device is working. +if __name__ == "__main__": + from machine import SoftI2C, Pin + import time + + # Called when a feature/data interrupt triggers. + def mycallback(data): + print(data) + + i2c = SoftI2C(scl=11,sda=10) + sensor = BMA423(i2c) + sensor.enable_interrupt(1,Pin(14,Pin.IN),mycallback,["data"]) + + # Enable steps counting + sensor.load_features_config() + sensor.enable_features_detection("step-count") + + while True: + print("(x,y,z),temp,steps", + sensor.get_xyz(), + sensor.get_temperature(), + sensor.get_steps()) + time.sleep(.1) diff --git a/internal_filesystem/lib/drivers/imu_sensor/bma423/git.version b/internal_filesystem/lib/drivers/imu_sensor/bma423/git.version new file mode 100644 index 00000000..bc0e9abd --- /dev/null +++ b/internal_filesystem/lib/drivers/imu_sensor/bma423/git.version @@ -0,0 +1 @@ +9ce483a0e067629a10486a305d9fb91ce5d2bad2 diff --git a/internal_filesystem/lib/drivers/imu_sensor/mpu6886.py b/internal_filesystem/lib/drivers/imu_sensor/mpu6886.py new file mode 100644 index 00000000..a7a570c5 --- /dev/null +++ b/internal_filesystem/lib/drivers/imu_sensor/mpu6886.py @@ -0,0 +1,83 @@ +""" +MicroPython driver for MPU6886 3-Axis Accelerometer + 3-Axis Gyroscope. +Tested with M5Stack FIRE +https://docs.m5stack.com/en/unit/imu +https://github.com/m5stack/M5Stack/blob/master/src/utility/MPU6886.h +""" + +import time + +from machine import I2C +from micropython import const + +_I2CADDR_DEFAULT = const(0x68) + + +# register addresses +_REG_PWR_MGMT_1 = const(0x6B) +_REG_ACCEL_XOUT_H = const(0x3B) +_REG_GYRO_XOUT_H = const(0x43) +_REG_ACCEL_CONFIG = const(0x1C) +_REG_GYRO_CONFIG = const(0x1B) +_REG_TEMPERATURE_OUT_H = const(0x41) + +# Scale factors for converting raw sensor data to physical units: +_ACCEL_SCALE_8G = 8.0 / 32768.0 # LSB/g for +-8g range +_GYRO_SCALE_2000DPS = 2000.0 / 32768.0 # LSB/°/s for +-2000dps range +_TEMPERATURE_SCALE = 326.8 # LSB/°C +_TEMPERATURE_OFFSET = const(25) # Offset (25°C at 0 LSB) + + +def twos_complement(val, bits): + if val & (1 << (bits - 1)): + val -= 1 << bits + return val + + +class MPU6886: + def __init__( + self, + i2c_bus: I2C, + address: int = _I2CADDR_DEFAULT, + ): + self.i2c = i2c_bus + self.address = address + + for data in (b"\x00", b"\x80", b"\x01"): # Reset, then wake up + self._write(_REG_PWR_MGMT_1, data) + time.sleep(0.01) + + self._write(_REG_ACCEL_CONFIG, b"\x10") # +-8g + time.sleep(0.001) + + self._write(_REG_GYRO_CONFIG, b"\x18") # +-2000dps + time.sleep(0.001) + + # Helper functions for register operations + def _write(self, reg: int, data: bytes): + self.i2c.writeto_mem(self.address, reg, data) + + def _read_xyz(self, reg: int, scale: float) -> tuple[int, int, int]: + data = self.i2c.readfrom_mem(self.address, reg, 6) + x = twos_complement(data[0] << 8 | data[1], 16) * -1 + y = twos_complement(data[2] << 8 | data[3], 16) + z = twos_complement(data[4] << 8 | data[5], 16) + return (x * scale, y * scale, z * scale) + + @property + def temperature(self) -> float: + buf = self.i2c.readfrom_mem(self.address, _REG_TEMPERATURE_OUT_H, 14) + temp_raw = (buf[6] << 8) | buf[7] + if temp_raw & 0x8000: # If MSB is 1, it's negative + temp_raw -= 0x10000 # Subtract 2^16 to get negative value + return temp_raw / _TEMPERATURE_SCALE + _TEMPERATURE_OFFSET + + @property + def acceleration(self) -> tuple[int, int, int]: + """Get current acceleration reading.""" + return self._read_xyz(_REG_ACCEL_XOUT_H, scale=_ACCEL_SCALE_8G) + + @property + def gyro(self) -> tuple[int, int, int]: + """Get current gyroscope reading.""" + return self._read_xyz(_REG_GYRO_XOUT_H, scale=_GYRO_SCALE_2000DPS) diff --git a/internal_filesystem/lib/mpos/hardware/drivers/qmi8658.py b/internal_filesystem/lib/drivers/imu_sensor/qmi8658.py similarity index 100% rename from internal_filesystem/lib/mpos/hardware/drivers/qmi8658.py rename to internal_filesystem/lib/drivers/imu_sensor/qmi8658.py diff --git a/internal_filesystem/lib/mpos/hardware/drivers/wsen_isds.py b/internal_filesystem/lib/drivers/imu_sensor/wsen_isds.py similarity index 72% rename from internal_filesystem/lib/mpos/hardware/drivers/wsen_isds.py rename to internal_filesystem/lib/drivers/imu_sensor/wsen_isds.py index 97cf7d00..3cdc2d5c 100644 --- a/internal_filesystem/lib/mpos/hardware/drivers/wsen_isds.py +++ b/internal_filesystem/lib/drivers/imu_sensor/wsen_isds.py @@ -35,6 +35,8 @@ class Wsen_Isds: _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 @@ -102,7 +104,7 @@ class Wsen_Isds: 'reg': 0x5E, 'mask': 0b11110111, 'shift_left': 3, 'val_to_bits': {0: 0b00, 1: 0b01} }, - 'int1_on_int0': { + 'int1_on_int0': { # on the LSM6DSO, this is called "INT2_on_INT1" 'reg': 0x13, 'mask': 0b11011111, 'shift_left': 5, 'val_to_bits': {0: 0b00, 1: 0b01} }, @@ -131,24 +133,12 @@ def __init__(self, i2c, address=0x6B, acc_range="2g", acc_data_rate="1.6Hz", self.i2c = i2c self.address = address - self.acc_offset_x = 0 - self.acc_offset_y = 0 - self.acc_offset_z = 0 self.acc_range = 0 self.acc_sensitivity = 0 - self.gyro_offset_x = 0 - self.gyro_offset_y = 0 - self.gyro_offset_z = 0 self.gyro_range = 0 self.gyro_sensitivity = 0 - self.ACC_NUM_SAMPLES_CALIBRATION = 5 - self.ACC_CALIBRATION_DELAY_MS = 10 - - self.GYRO_NUM_SAMPLES_CALIBRATION = 5 - self.GYRO_CALIBRATION_DELAY_MS = 10 - self.set_acc_range(acc_range) self.set_acc_data_rate(acc_data_rate) @@ -252,30 +242,6 @@ def set_interrupt(self, interrupts_enable=False, inact_en=False, slope_fds=False self._write_option('tap_double_to_int0', 1) self._write_option('int1_on_int0', 1) - def acc_calibrate(self, samples=None): - """Calibrate accelerometer by averaging samples while device is stationary. - - Args: - samples: Number of samples to average (default: ACC_NUM_SAMPLES_CALIBRATION) - """ - if samples is None: - samples = self.ACC_NUM_SAMPLES_CALIBRATION - - self.acc_offset_x = 0 - self.acc_offset_y = 0 - self.acc_offset_z = 0 - - for _ in range(samples): - x, y, z = self._read_raw_accelerations() - self.acc_offset_x += x - self.acc_offset_y += y - self.acc_offset_z += z - time.sleep_ms(self.ACC_CALIBRATION_DELAY_MS) - - self.acc_offset_x //= samples - self.acc_offset_y //= samples - self.acc_offset_z //= samples - def _acc_calc_sensitivity(self): """Calculate accelerometer sensitivity based on range (in mg/digit).""" sensitivity_mapping = { @@ -289,20 +255,6 @@ def _acc_calc_sensitivity(self): else: print("Invalid range value:", self.acc_range) - def read_accelerations(self): - """Read calibrated accelerometer data. - - Returns: - Tuple (x, y, z) in mg (milligrams) - """ - raw_a_x, raw_a_y, raw_a_z = self._read_raw_accelerations() - - a_x = (raw_a_x - self.acc_offset_x) * self.acc_sensitivity - a_y = (raw_a_y - self.acc_offset_y) * self.acc_sensitivity - a_z = (raw_a_z - self.acc_offset_z) * self.acc_sensitivity - - return a_x, a_y, a_z - def _read_raw_accelerations(self): """Read raw accelerometer data.""" if not self._acc_data_ready(): @@ -314,45 +266,22 @@ def _read_raw_accelerations(self): 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, raw_a_y, raw_a_z - - def gyro_calibrate(self, samples=None): - """Calibrate gyroscope by averaging samples while device is stationary. - - Args: - samples: Number of samples to average (default: GYRO_NUM_SAMPLES_CALIBRATION) - """ - if samples is None: - samples = self.GYRO_NUM_SAMPLES_CALIBRATION - - self.gyro_offset_x = 0 - self.gyro_offset_y = 0 - self.gyro_offset_z = 0 - - for _ in range(samples): - x, y, z = self._read_raw_angular_velocities() - self.gyro_offset_x += x - self.gyro_offset_y += y - self.gyro_offset_z += z - time.sleep_ms(self.GYRO_CALIBRATION_DELAY_MS) + return raw_a_x * self.acc_sensitivity, raw_a_y * self.acc_sensitivity, raw_a_z * self.acc_sensitivity - self.gyro_offset_x //= samples - self.gyro_offset_y //= samples - self.gyro_offset_z //= samples - def read_angular_velocities(self): - """Read calibrated gyroscope data. - - Returns: - Tuple (x, y, z) in mdps (milli-degrees per second) - """ - raw_g_x, raw_g_y, raw_g_z = self._read_raw_angular_velocities() + @property + def temperature(self) -> float: + temp_raw = self._read_raw_temperature() + return ((temp_raw / 256.0) + 25.0) - g_x = (raw_g_x - self.gyro_offset_x) * self.gyro_sensitivity - g_y = (raw_g_y - self.gyro_offset_y) * self.gyro_sensitivity - g_z = (raw_g_z - self.gyro_offset_z) * self.gyro_sensitivity + def _read_raw_temperature(self): + """Read raw temperature data.""" + if not self._temp_data_ready(): + raise Exception("temp sensor data not ready") - return g_x, g_y, g_z + 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.""" @@ -365,44 +294,15 @@ def _read_raw_angular_velocities(self): 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, raw_g_y, raw_g_z - - def read_angular_velocities_accelerations(self): - """Read both gyroscope and accelerometer in one call. - - Returns: - Tuple (gx, gy, gz, ax, ay, az) where gyro is in mdps, accel is in mg - """ - raw_g_x, raw_g_y, raw_g_z, raw_a_x, raw_a_y, raw_a_z = \ - self._read_raw_gyro_acc() - - g_x = (raw_g_x - self.gyro_offset_x) * self.gyro_sensitivity - g_y = (raw_g_y - self.gyro_offset_y) * self.gyro_sensitivity - g_z = (raw_g_z - self.gyro_offset_z) * self.gyro_sensitivity - - a_x = (raw_a_x - self.acc_offset_x) * self.acc_sensitivity - a_y = (raw_a_y - self.acc_offset_y) * self.acc_sensitivity - a_z = (raw_a_z - self.acc_offset_z) * self.acc_sensitivity - - return g_x, g_y, g_z, a_x, a_y, a_z - - def _read_raw_gyro_acc(self): - """Read raw gyroscope and accelerometer data in one call.""" - acc_data_ready, gyro_data_ready = self._acc_gyro_data_ready() - if not acc_data_ready or not gyro_data_ready: - raise Exception("sensor data not ready") - - raw = self.i2c.readfrom_mem(self.address, Wsen_Isds._REG_G_X_OUT_L, 12) - - 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]) - - raw_a_x = self._convert_from_raw(raw[6], raw[7]) - raw_a_y = self._convert_from_raw(raw[8], raw[9]) - raw_a_z = self._convert_from_raw(raw[10], raw[11]) + return ( + raw_g_x * self.gyro_sensitivity, + raw_g_y * self.gyro_sensitivity, + raw_g_z * self.gyro_sensitivity, + ) - return raw_g_x, raw_g_y, raw_g_z, raw_a_x, raw_a_y, raw_a_z + def read_angular_velocities(self): + """Read gyroscope data in mdps.""" + return self._read_raw_angular_velocities() @staticmethod def _convert_from_raw(b_l, b_h): @@ -420,6 +320,10 @@ 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() diff --git a/internal_filesystem/lib/drivers/indev/__init__.py b/internal_filesystem/lib/drivers/indev/__init__.py new file mode 100644 index 00000000..b51d2f04 --- /dev/null +++ b/internal_filesystem/lib/drivers/indev/__init__.py @@ -0,0 +1,10 @@ +"""Input device drivers package helpers.""" + +try: + import sys + from . import focaltech_touch as _focaltech_touch + + if "focaltech_touch" not in sys.modules: + sys.modules["focaltech_touch"] = _focaltech_touch +except Exception: + pass diff --git a/internal_filesystem/lib/drivers/indev/cst816s.py b/internal_filesystem/lib/drivers/indev/cst816s.py new file mode 100644 index 00000000..93306002 --- /dev/null +++ b/internal_filesystem/lib/drivers/indev/cst816s.py @@ -0,0 +1,307 @@ +# Copyright (c) 2024 - 2025 Kevin G. Schlosser + +from micropython import const # NOQA +import pointer_framework +import time +import machine # NOQA + + +I2C_ADDR = 0x15 +BITS = 8 + +# 0x00: No gesture +# 0x01: Swipe up +# 0x02: Swipe down +# 0x03: Swipe left +# 0x04: Swipe right +# 0x05: Single click +# 0x0B: Double click +# 0x0C: Long press +_GestureID = const(0x01) + +# 0: No finger +# 1: 1 finger +_FingerNum = const(0x02) + +# & 0xF << 8 +_XposH = const(0x03) +_XposL = const(0x04) + +# & 0xF << 8 +_YposH = const(0x05) +_YposL = const(0x06) + +_RegisterVersion = const(0x15) + +_BPC0H = const(0xB0) +_BPC0L = const(0xB1) + +_BPC1H = const(0xB2) +_BPC1L = const(0xB3) + +_ChipID = const(0xA7) +_ChipIDValue = const(0xB5) +_ChipIDValue2 = const(0xB6) + +_ProjID = const(0xA8) +_FwVersion = const(0xA9) + + +# =============================== +_MotionMask = const(0xEC) + +# Enables continuous left and right sliding +_EnConLR = const(0x04) +# Enables continuous up and down sliding +_EnConUD = const(0x02) +# Enable double-click action +_EnDClick = const(0x01) +# =============================== + +# Interrupt low pulse output width. +# Unit 0.1ms, optional value: 1~200. The default value is 10. +_IrqPluseWidth = const(0xED) + +# Normal fast detection cycle. +# This value affects LpAutoWakeTime and AutoSleepTime. +# Unit 10ms, optional value: 1~30. The default value is 1. +_NorScanPer = const(0xEE) + +# Gesture detection sliding partition angle control. Angle=tan(c)*10 +# c is the angle based on the positive direction of the x-axis. +_MotionSlAngle = const(0xEF) + +_LpScanRaw1H = const(0xF0) +_LpScanRaw1L = const(0xF1) +_LpScanRaw2H = const(0xF2) +_LpScanRaw2L = const(0xF3) + +# Automatic recalibration period in low power consumption. +# Unit: 1 minute, optional value: 1 to 5. The default value is 5. +_LpAutoWakeTime = const(0xF4) + + +# Low power scan wake-up threshold. The smaller the value, +# the more sensitive it is. +# Optional values: 1 to 255. The default value is 48. +_LpScanTH = const(0xF5) + +# Low power scan range. The larger the value, the more sensitive it is, +# and the higher the power consumption is. +# Optional values: 0, 1, 2, 3. The default value is 3. +_LpScanWin = const(0xF6) + +# Low power scan frequency. The smaller the value, the more sensitive it is. +# Optional values: 1 to 255. The default value is 7. +_LpScanFreq = const(0xF7) + +# Low power scan current. The smaller the value, the more sensitive it is. +# Optional values: 1 to 255. +_LpScanIdac = const(0xF8) + + +# Automatically enters low power mode when there is no touch within x seconds. +# Unit: 1S, default value: 2S. +_AutoSleepTime = const(0xF9) + +# =============================== +_IrqCtl = const(0xFA) +# Interrupt pin test, automatically sends low pulses periodically after enabling +_EnTest = const(0x80) +# Sends low pulses periodically when touch is detected. +_EnTouch = const(0x40) +# Sends low pulses when touch state changes are detected. +_EnChange = const(0x20) +# Sends low pulses when gestures are detected. +_EnMotion = const(0x10) +# Long press gesture only sends one low pulse signal. +_OnceWLP = const(0x01) +# =============================== + + +# Automatically reset when there is touch but no valid gesture within x seconds. +# Unit: 1S. This function is not enabled when it is 0. The default value is 5. +_AutoReset = const(0xFB) + +# Automatically reset after long pressing for x seconds. +# Unit: 1S. This function is not enabled when it is 0. The default value is 10. +_LongPressTime = const(0xFC) + +# =============================== +_IOCtl = const(0xFD) + +# The master controller realizes the soft reset function +# of the touch screen by pulling down the IRQ pin. +# 0: Disable soft reset. +# 1: Enable soft reset. +_SOFT_RST = const(0x04) + +# IIC pin drive mode, the default is resistor pull-up. +# 0: Resistor pull-up +# 1: OD +_IIC_OD = const(0x02) + +# IIC and IRQ pin level selection, the default is VDD level. +# 0: VDD +# 1: 1.8V +_En1v8 = const(0x01) +# =============================== + +# The default value is 0, enabling automatic entry into low power mode. +# When the value is non-zero, automatic entry into low power mode is disabled. +# 0: enabled +# 1: disabled +_DisAutoSleep = const(0xFE) + + +class CST816S(pointer_framework.PointerDriver): + + def _read_reg(self, reg): + self._tx_buf[0] = reg + self._rx_buf[0] = 0x00 + + self._device.write_readinto(self._tx_mv[:1], self._rx_mv[:1]) + + def _write_reg(self, reg, value): + self._tx_buf[0] = reg + self._tx_buf[1] = value + self._device.write(self._tx_mv[:2]) + + def __init__( + self, + device, + reset_pin=None, + touch_cal=None, + startup_rotation=pointer_framework.lv.DISPLAY_ROTATION._0, # NOQA + debug=False + ): + self._tx_buf = bytearray(2) + self._tx_mv = memoryview(self._tx_buf) + self._rx_buf = bytearray(1) + self._rx_mv = memoryview(self._rx_buf) + + self._device = device + + if not isinstance(reset_pin, int): + self._reset_pin = reset_pin + else: + self._reset_pin = machine.Pin(reset_pin, machine.Pin.OUT) + + if self._reset_pin: + self._reset_pin.value(1) + + self.hw_reset() + self.auto_sleep = False + + self._read_reg(_ChipID) + print('Chip ID:', hex(self._rx_buf[0])) + chip_id = self._rx_buf[0] + + self._read_reg(_RegisterVersion) + print('Touch version:', self._rx_buf[0]) + + self._read_reg(_ProjID) + print('Proj ID:', hex(self._rx_buf[0])) + + self._read_reg(_FwVersion) + print('FW Version:', hex(self._rx_buf[0])) + + if chip_id not in (_ChipIDValue, _ChipIDValue2): + raise RuntimeError(f'Incorrect chip id ({hex(chip_id)})') + + self._write_reg(_IrqCtl, _EnTouch | _EnChange) + + super().__init__( + touch_cal=touch_cal, startup_rotation=startup_rotation, debug=debug + ) + + @property + def wake_up_threshold(self): + self._read_reg(_LpScanTH) + return 256 - self._rx_buf[0] + + @wake_up_threshold.setter + def wake_up_threshold(self, value): + if value < 1: + value = 1 + elif value > 255: + value = 255 + + self._write_reg(_LpScanTH, 256 - value) + + @property + def wake_up_scan_frequency(self): + self._read_reg(_LpScanFreq) + return 256 - self._rx_buf[0] + + @wake_up_scan_frequency.setter + def wake_up_scan_frequency(self, value): + if value < 1: + value = 1 + elif value > 255: + value = 255 + + self._write_reg(_LpScanFreq, 256 - value) + + @property + def auto_sleep_timeout(self): + self._read_reg(_AutoSleepTime) + return self._rx_buf[0] + + @auto_sleep_timeout.setter + def auto_sleep_timeout(self, value): + if value < 1: + value = 1 + elif value > 255: + value = 255 + + self._write_reg(_AutoSleepTime, value) + + def wake_up(self): + auto_sleep = self.auto_sleep + + self._write_reg(_DisAutoSleep, 0x00) + time.sleep_ms(10) # NOQA + self._write_reg(_DisAutoSleep, 0xFE) + time.sleep_ms(50) # NOQA + self._write_reg(_DisAutoSleep, 0xFE) + time.sleep_ms(50) # NOQA + self._write_reg(_DisAutoSleep, int(not auto_sleep)) + + @property + def auto_sleep(self): + self._read_reg(_DisAutoSleep) + return self._rx_buf[0] == 0x00 + + @auto_sleep.setter + def auto_sleep(self, en): + if en: + self._write_reg(_DisAutoSleep, 0x00) + else: + self._write_reg(_DisAutoSleep, 0xFE) + + def hw_reset(self): + if self._reset_pin is None: + return + + self._reset_pin(0) + time.sleep_ms(1) # NOQA + self._reset_pin(1) + time.sleep_ms(50) # NOQA + + def _get_coords(self): + self._read_reg(_FingerNum) + if self._rx_buf[0] == 0: + return None + + self._read_reg(_XposH) + x = (self._rx_buf[0] & 0x0F) << 8 + self._read_reg(_XposL) + x |= self._rx_buf[0] + + self._read_reg(_YposH) + y = (self._rx_buf[0] & 0x0F) << 8 + self._read_reg(_YposL) + y |= self._rx_buf[0] + + return self.PRESSED, x, y diff --git a/internal_filesystem/lib/drivers/indev/focaltech_touch.py b/internal_filesystem/lib/drivers/indev/focaltech_touch.py new file mode 100644 index 00000000..5e0f6d1a --- /dev/null +++ b/internal_filesystem/lib/drivers/indev/focaltech_touch.py @@ -0,0 +1,124 @@ +# Copyright (c) 2024 - 2025 Kevin G. Schlosser + +from micropython import const # NOQA +import pointer_framework +import machine # NOQA + + +# Register of the current mode +_DEV_MODE_REG = const(0x00) + +# ** Possible modes as of FT6X36_DEV_MODE_REG ** +_DEV_MODE_WORKING = const(0x00) +_CTRL = const(0x86) + + +# Status register: stores number of active touch points (0, 1, 2) +_TD_STAT_REG = const(0x02) +_P1_XH = const(0x03) +_P1_XL = const(0x04) + +_P1_YH = const(0x05) +_P1_YL = const(0x06) + +_MSB_MASK = const(0x0F) +_LSB_MASK = const(0xFF) + +# Report rate in Active mode +_PERIOD_ACTIVE_REG = const(0x88) + + +_VENDID = const(0x11) +_CHIPID_REG = const(0xA3) + +_FIRMWARE_ID_REG = const(0xA6) +_RELEASECODE_REG = const(0xAF) +_PANEL_ID_REG = const(0xA8) + +_G_MODE = const(0xA4) + + +class FocalTechTouch(pointer_framework.PointerDriver): + + def __init__( + self, + device, + touch_cal, + startup_rotation, # NOQA + debug, + factors, + *chip_ids + ): # NOQA + self._tx_buf = bytearray(5) + self._tx_mv = memoryview(self._tx_buf) + self._rx_buf = bytearray(5) + self._rx_mv = memoryview(self._rx_buf) + + self._device = device + self._factors = factors + + self._read_reg(_PANEL_ID_REG) + print("Touch Device ID: 0x%02x" % self._rx_buf[0]) + ven_id = self._rx_buf[0] # NOQA + + self._read_reg(_CHIPID_REG) + print("Touch Chip ID: 0x%02x" % self._rx_buf[0]) + chip_id = self._rx_buf[0] + + self._read_reg(_DEV_MODE_REG) + print("Touch Device mode: 0x%02x" % self._rx_buf[0]) + + self._read_reg(_FIRMWARE_ID_REG) + print("Touch Firmware ID: 0x%02x" % self._rx_buf[0]) + + self._read_reg(_RELEASECODE_REG) + print("Touch Release code: 0x%02x" % self._rx_buf[0]) + + if chip_id not in chip_ids: + raise RuntimeError( + f'IC is not compatable with the {self.__class__.__name__} driver' # NOQA + ) + + self._write_reg(_DEV_MODE_REG, _DEV_MODE_WORKING) + self._write_reg(_PERIOD_ACTIVE_REG, 0x0E) + self._write_reg(_G_MODE, 0x00) + + # This is needed so the TS doesn't go to sleep + self._write_reg(_CTRL, 0x00) + + super().__init__( + touch_cal=touch_cal, startup_rotation=startup_rotation, debug=debug + ) + + def _get_coords(self): + self._tx_buf[0] = _TD_STAT_REG + try: + self._device.write_readinto(self._tx_mv, self._rx_mv) + except OSError: + return None + + buf = self._rx_buf + + touch_pnt_cnt = buf[0] + if touch_pnt_cnt != 1: + return None + + x = ((buf[1] & _MSB_MASK) << 8) | buf[2] + y = ((buf[3] & _MSB_MASK) << 8) | buf[4] + + if self._factors is not None: + x = round(x / self._factors[0]) + y = round(y / self._factors[1]) + + return self.PRESSED, x, y + + def _read_reg(self, reg): + self._tx_buf[0] = reg + self._rx_buf[0] = 0x00 + + self._device.write_readinto(self._tx_mv[:1], self._rx_mv[:1]) + + def _write_reg(self, reg, value): + self._tx_buf[0] = reg + self._tx_buf[1] = value + self._device.write(self._tx_mv[:2]) diff --git a/internal_filesystem/lib/drivers/indev/ft6x36.py b/internal_filesystem/lib/drivers/indev/ft6x36.py new file mode 100644 index 00000000..b85c8ae2 --- /dev/null +++ b/internal_filesystem/lib/drivers/indev/ft6x36.py @@ -0,0 +1,37 @@ +# Copyright (c) 2024 - 2025 Kevin G. Schlosser + +# FT6236/FT6336/FT6436/FT6436L + +from micropython import const # NOQA +import focaltech_touch +import pointer_framework + + +I2C_ADDR = const(0x38) +BITS = 8 + +_FT6x36_CHIPID_1 = const(0x36) +_FT6x36_CHIPID_2 = const(0x64) +_FT6x36_CHIPID_3 = const(0xCD) + + +class FT6x36(focaltech_touch.FocalTechTouch): + + def __init__( + self, + device, + touch_cal=None, + startup_rotation=pointer_framework.lv.DISPLAY_ROTATION._0, # NOQA + debug=False + ): # NOQA + + super().__init__( + device, + touch_cal, + startup_rotation, + debug, + None, + _FT6x36_CHIPID_1, + _FT6x36_CHIPID_2, + _FT6x36_CHIPID_3 + ) diff --git a/internal_filesystem/lib/drivers/indev/gt911.py b/internal_filesystem/lib/drivers/indev/gt911.py new file mode 100644 index 00000000..ada0f428 --- /dev/null +++ b/internal_filesystem/lib/drivers/indev/gt911.py @@ -0,0 +1,218 @@ +# Copyright (c) 2024 - 2025 Kevin G. Schlosser + +# this driver uses a special i2c bus implimentation I have written. +# This implimentation takes into consideration the ESP32 and it having +# threading available. It also has some convience methods built into it +# that figure out what is wanting to be done automatically. +# read more about it's use in the stub files. + +from micropython import const # NOQA +import pointer_framework +import machine # NOQA +import time + + +_CMD_REG = const(0x8040) +_CMD_CHECK_REG = const(0x8046) +_CMD_READ_DATA = const(0x01) + +_ESD_CHECK_REG = const(0x8041) + +_MODULE_SWITCH_1 = const(0x804D) +_CMD_INT_RISING_EDGE = const(0x00) +_CMD_INT_FALLING_EDGE = const(0x01) +_CMD_INT_LOW_LEVEL = const(0x02) +_CMD_INT_HIGH_LEVEL = const(0x03) + +_STATUS_REG = const(0x814E) +_POINT_1_REG = const(0x8150) + +_PRODUCT_ID_REG = const(0x8140) +_FIRMWARE_VERSION_REG = const(0x8144) +_VENDOR_ID_REG = const(0x814A) + +_X_CORD_RES_REG = const(0x8146) +_Y_CORD_RES_REG = const(0x8148) + +#I2C_ADDR = 0x5D +I2C_ADDR = 0x14 +BITS = 16 + +_ADDR2 = const(0x14) + +_USE_INTERRUPTS = False # Interrupt handler based? Or just polling? + +class GT911(pointer_framework.PointerDriver): + + def _read_reg(self, reg, num_bytes=None, buf=None): + self._tx_buf[0] = reg >> 8 + self._tx_buf[1] = reg & 0xFF + try: + if num_bytes is not None: + self._device.write_readinto(self._tx_mv[:2], self._rx_mv[:num_bytes]) + else: + self._device.write_readinto(self._tx_mv[:2], buf) + except Exception as e: + print(f"GT911 _read_reg got exception: {e}") + + def _write_reg(self, reg, value=None, buf=None): + try: + if value is not None: + self._tx_buf[0] = value + self._device.write_mem(reg, self._tx_mv[:1]) + elif buf is not None: + self._device.write_mem(reg, buf) + except Exception as e: + print(f"GT911 _write_reg got exception: {e}") + + def __init__( + self, + device, + reset_pin=None, + interrupt_pin=None, + touch_cal=None, + startup_rotation=pointer_framework.lv.DISPLAY_ROTATION._0, # NOQA + debug=False + ): + self._tx_buf = bytearray(3) + self._tx_mv = memoryview(self._tx_buf) + self._rx_buf = bytearray(6) + self._rx_mv = memoryview(self._rx_buf) + + self._device = device + + self.__x = 0 + self.__y = 0 + self.__last_state = self.RELEASED + self._interrupt_flag = False + + if isinstance(reset_pin, int): + reset_pin = machine.Pin(reset_pin, machine.Pin.OUT) + + if isinstance(interrupt_pin, int) and _USE_INTERRUPTS: + interrupt_pin = machine.Pin(interrupt_pin, machine.Pin.IN) + else: + interrupt_pin = machine.Pin(interrupt_pin, machine.Pin.OUT) + + self._reset_pin = reset_pin + self._interrupt_pin = interrupt_pin + + self.hw_reset() + super().__init__( + touch_cal=touch_cal, startup_rotation=startup_rotation, debug=debug + ) + + def _interrupt_handler(self, pin): + """Interrupt handler called when touch event occurs""" + self._interrupt_flag = True + + def hw_reset(self): + if self._reset_pin: + if self._interrupt_pin: + self._interrupt_pin.init(self._interrupt_pin.OUT) + self._interrupt_pin(0) + self._reset_pin(0) + time.sleep_ms(10) # NOQA + if self._interrupt_pin: + self._interrupt_pin(1) # causes it to stay on 0x14 address + #self._interrupt_pin(0) # causes it to go to 0x5D address + time.sleep_ms(1) # NOQA + self._reset_pin(1) + time.sleep_ms(5) # NOQA + if self._interrupt_pin: + self._interrupt_pin(0) + time.sleep_ms(50) # NOQA + if self._interrupt_pin and _USE_INTERRUPTS: + self._interrupt_pin.init(mode=self._interrupt_pin.IN) + time.sleep_ms(50) # NOQA + + self._write_reg(_ESD_CHECK_REG, 0x00) + self._write_reg(_CMD_CHECK_REG, _CMD_READ_DATA) + self._write_reg(_CMD_REG, _CMD_READ_DATA) + + self._read_reg(_PRODUCT_ID_REG, 4) + + product_id = '' + for item in self._rx_buf[:4]: + try: + product_id += chr(item) + except: # NOQA + break + + print('Touch Product id:', product_id) + + self._read_reg(_FIRMWARE_VERSION_REG, 2) + print( + 'Touch Firmware version:', + hex(self._rx_buf[0] + (self._rx_buf[1] << 8)) + ) + + self._read_reg(_VENDOR_ID_REG, 1) + print(f'Touch Vendor id: 0x{hex(self._rx_buf[0])[2:].upper()}') + x, y = self.hw_size + print(f'Touch resolution: width={x}, height={y}') + + # Set up interrupt handler if interrupt pin is available + if self._interrupt_pin and _USE_INTERRUPTS: + self._interrupt_pin.irq(trigger=machine.Pin.IRQ_FALLING, handler=self._interrupt_handler) + # Setting _MODULE_SWITCH_1 will "hang" the touch input after a second or 2 of initial swipe + #self._write_reg(_MODULE_SWITCH_1, _CMD_INT_FALLING_EDGE) # stops working + #self._write_reg(_MODULE_SWITCH_1, _CMD_INT_RISING_EDGE) # stops working + # Unknown IRQ_LOW_LEVEL: + #self._interrupt_pin.irq(trigger=machine.Pin.IRQ_LOW_LEVEL, handler=self._interrupt_handler) + #self._write_reg(_MODULE_SWITCH_1, _CMD_INT_LOW_LEVEL) + + @property + def hw_size(self): + self._read_reg(_X_CORD_RES_REG, 2) + x = self._rx_buf[0] + (self._rx_buf[1] << 8) + + self._read_reg(_Y_CORD_RES_REG, 2) + y = self._rx_buf[0] + (self._rx_buf[1] << 8) + + return x, y + + @property + def firmware_config(self): + try: + import gt911_extension + except ImportError: + raise ImportError( + 'you need to upload the gt911_extension.py file to the MCU' + ) + return gt911_extension.GT911Extension(self, self._device) + + def _get_coords(self): + # If interrupt pin is available, only fetch data when interrupt flag is set + if self._interrupt_pin and not self._interrupt_flag and _USE_INTERRUPTS: + return self.__last_state, self.__x, self.__y + + # Clear interrupt flag before reading + if self._interrupt_pin and _USE_INTERRUPTS: + self._interrupt_flag = False + #self._write_reg(_MODULE_SWITCH_1, _CMD_INT_FALLING_EDGE) + #print("[GT911] Interrupt-triggered read") + + self._read_reg(_STATUS_REG, 1) + touch_cnt = self._rx_buf[0] & 0x0F + status = self._rx_buf[0] & 0x80 + + if status: + if touch_cnt == 1: + self._read_reg(_POINT_1_REG, 6) + + x = self._rx_buf[0] + (self._rx_buf[1] << 8) + y = self._rx_buf[2] + (self._rx_buf[3] << 8) + + self._write_reg(_STATUS_REG, 0x00) + + self.__x = x + self.__y = y + self.__last_state = self.PRESSED + + elif touch_cnt == 0: + self.__last_state = self.RELEASED + + self._write_reg(_STATUS_REG, 0x00) + + return self.__last_state, self.__x, self.__y diff --git a/internal_filesystem/lib/mpos/indev/mpos_sdl_keyboard.py b/internal_filesystem/lib/drivers/indev/sdl_keyboard.py similarity index 98% rename from internal_filesystem/lib/mpos/indev/mpos_sdl_keyboard.py rename to internal_filesystem/lib/drivers/indev/sdl_keyboard.py index e1cc39db..1445e549 100644 --- a/internal_filesystem/lib/mpos/indev/mpos_sdl_keyboard.py +++ b/internal_filesystem/lib/drivers/indev/sdl_keyboard.py @@ -370,7 +370,10 @@ def _keypad_cb(self, *args): else: self.__current_state = self.RELEASED - micropython.schedule(MposSDLKeyboard.read, self) + try: + micropython.schedule(MposSDLKeyboard.read, self) + except Exception as e: + print(f"mpos_sdl_keyboard.py failed to call micropython.schedule: {e}") def _get_key(self): return self.__current_state, self.__last_key diff --git a/internal_filesystem/lib/drivers/indev/xpt2046.py b/internal_filesystem/lib/drivers/indev/xpt2046.py new file mode 100644 index 00000000..dde01c70 --- /dev/null +++ b/internal_filesystem/lib/drivers/indev/xpt2046.py @@ -0,0 +1,127 @@ +# Copyright (c) 2024 - 2025 Kevin G. Schlosser + +import lvgl as lv # NOQA +from micropython import const # NOQA +import micropython # NOQA +import machine # NOQA +import pointer_framework +import time + + +_CMD_X_READ = const(0xD0) # 12 bit resolution +_CMD_Y_READ = const(0x90) # 12 bit resolution +_CMD_Z1_READ = const(0xB0) +_CMD_Z2_READ = const(0xC0) +_MIN_RAW_COORD = const(10) +_MAX_RAW_COORD = const(4090) + + +class XPT2046(pointer_framework.PointerDriver): + touch_threshold = 400 + confidence = 5 + margin = 50 + + def __init__( + self, + device: machine.SPI.Bus, + display_width: int, + display_height: int, + lcd_cs: int, + touch_cs: int, + touch_cal=None, + startup_rotation=lv.DISPLAY_ROTATION._0, + debug=False, + ): + self._device = device # machine.SPI.Bus() instance, shared with display + self._debug = debug + + self.lcd_cs = machine.Pin(lcd_cs, machine.Pin.OUT, value=0) + self.touch_cs = machine.Pin(touch_cs, machine.Pin.OUT, value=1) + + self._width = display_width + self._height = display_height + + self._tx_buf = bytearray(3) + self._tx_mv = memoryview(self._tx_buf) + + self._rx_buf = bytearray(3) + self._rx_mv = memoryview(self._rx_buf) + + self.__confidence = max(min(self.confidence, 25), 3) + self.__points = [[0, 0] for _ in range(self.__confidence)] + + margin = max(min(self.margin, 100), 1) + self.__margin = margin * margin + + super().__init__( + touch_cal=touch_cal, startup_rotation=startup_rotation, debug=debug + ) + + def _read_reg(self, reg, num_bytes): + self._tx_buf[0] = reg + self._device.write_readinto(self._tx_mv[:num_bytes], self._rx_mv[:num_bytes]) + return ((self._rx_buf[1] << 8) | self._rx_buf[2]) >> 3 + + def _get_coords(self): + try: + self.lcd_cs.value(1) # deselect LCD to avoid conflicts + self.touch_cs.value(0) # select touch chip + + z1 = self._read_reg(_CMD_Z1_READ, 3) + z2 = self._read_reg(_CMD_Z2_READ, 3) + z = z1 + ((_MAX_RAW_COORD + 6) - z2) + if z < self.touch_threshold: + return None # Not touched + + points = self.__points + count = 0 + end_time = time.ticks_us() + 5000 + while time.ticks_us() < end_time: + if count == self.__confidence: + break + + raw_x = self._read_reg(_CMD_X_READ, 3) + if raw_x < _MIN_RAW_COORD: + continue + + raw_y = self._read_reg(_CMD_Y_READ, 3) + if raw_y > _MAX_RAW_COORD: + continue + + # put in buff + points[count][0] = raw_x + points[count][1] = raw_y + count += 1 + + finally: + self.touch_cs.value(1) # deselect touch chip + self.lcd_cs.value(0) # select LCD + + if not count: + return None # Not touched + + meanx = sum([points[i][0] for i in range(count)]) // count + meany = sum([points[i][1] for i in range(count)]) // count + dev = ( + sum( + [ + (points[i][0] - meanx) ** 2 + (points[i][1] - meany) ** 2 + for i in range(count) + ] + ) + / count + ) + if dev >= self.__margin: + return None # Not touched + + x = pointer_framework.remap( + meanx, _MIN_RAW_COORD, _MAX_RAW_COORD, 0, self._orig_width + ) + y = pointer_framework.remap( + meany, _MIN_RAW_COORD, _MAX_RAW_COORD, 0, self._orig_height + ) + if self._debug: + print( + f"{self.__class__.__name__}_TP_DATA({count=} {meanx=} {meany=} {z1=} {z2=} {z=})" + ) # NOQA + return self.PRESSED, x, y diff --git a/internal_filesystem/lib/drivers/power/axp192.py b/internal_filesystem/lib/drivers/power/axp192.py new file mode 100644 index 00000000..4a1a9c65 --- /dev/null +++ b/internal_filesystem/lib/drivers/power/axp192.py @@ -0,0 +1,276 @@ +# AXP192 Power Management IC Driver for M5Stack Core2 +# I2C address: 0x34 +# Datasheet reference + M5Stack Core2 specific initialization + +from micropython import const + +# Registers +_REG_POWER_STATUS = const(0x00) +_REG_CHARGE_STATUS = const(0x01) +_REG_POWER_OUTPUT_CTRL = const(0x12) +_REG_DCDC1_VOLTAGE = const(0x26) +_REG_DCDC3_VOLTAGE = const(0x27) +_REG_LDO23_VOLTAGE = const(0x28) +_REG_VBUS_IPSOUT = const(0x30) +_REG_POWER_OFF = const(0x32) +_REG_CHARGE_CTRL1 = const(0x33) +_REG_BACKUP_CHG = const(0x35) +_REG_PEK_PARAMS = const(0x36) +_REG_ADC_ENABLE1 = const(0x82) +_REG_GPIO0_FUNCTION = const(0x90) +_REG_GPIO0_LDO_VOLTAGE = const(0x91) +_REG_GPIO1_FUNCTION = const(0x92) +_REG_GPIO2_FUNCTION = const(0x93) +_REG_GPIO_SIGNAL = const(0x94) +_REG_GPIO4_FUNCTION = const(0x95) +_REG_GPIO34_SIGNAL = const(0x96) +_REG_COULOMB_CTRL = const(0xB8) + +# ADC data registers +_REG_BAT_VOLTAGE_H = const(0x78) +_REG_BAT_CURRENT_IN_H = const(0x7A) +_REG_BAT_CURRENT_OUT_H = const(0x7C) +_REG_APS_VOLTAGE_H = const(0x7E) + +# Power output control bits (register 0x12) +_BIT_EXTEN = const(6) # EXTEN (5V boost) +_BIT_DCDC2 = const(4) +_BIT_LDO3 = const(3) +_BIT_LDO2 = const(2) +_BIT_DCDC3 = const(1) +_BIT_DCDC1 = const(0) + +# Bus power mode +_MBUS_MODE_INPUT = const(0) +_MBUS_MODE_OUTPUT = const(1) + +I2C_ADDR = const(0x34) + + +class AXP192: + """AXP192 power management driver for M5Stack Core2.""" + + def __init__(self, i2c, addr=I2C_ADDR): + self._i2c = i2c + self._addr = addr + self._buf1 = bytearray(1) + self._buf2 = bytearray(2) + + def _read_reg(self, reg): + self._buf1[0] = reg + self._i2c.readfrom_mem_into(self._addr, reg, self._buf1) + return self._buf1[0] + + def _write_reg(self, reg, val): + self._buf1[0] = val + self._i2c.writeto_mem(self._addr, reg, self._buf1) + + def _read_12bit(self, reg): + self._i2c.readfrom_mem_into(self._addr, reg, self._buf2) + return (self._buf2[0] << 4) | self._buf2[1] + + def _read_13bit(self, reg): + self._i2c.readfrom_mem_into(self._addr, reg, self._buf2) + return (self._buf2[0] << 5) | self._buf2[1] + + def init_core2(self): + """Initialize AXP192 for M5Stack Core2 hardware.""" + # VBUS-IPSOUT path: set N_VBUSEN pin control, auto VBUS current limit + self._write_reg(_REG_VBUS_IPSOUT, (self._read_reg(_REG_VBUS_IPSOUT) & 0x04) | 0x02) + + # GPIO1: Open-drain output (Touch RST control) + self._write_reg(_REG_GPIO1_FUNCTION, self._read_reg(_REG_GPIO1_FUNCTION) & 0xF8) + + # GPIO2: Open-drain output (Speaker enable control) + self._write_reg(_REG_GPIO2_FUNCTION, self._read_reg(_REG_GPIO2_FUNCTION) & 0xF8) + + # RTC battery charge: 3.0V, 200uA + self._write_reg(_REG_BACKUP_CHG, (self._read_reg(_REG_BACKUP_CHG) & 0x1C) | 0xA2) + + # Set ESP32 core voltage (DCDC1) to 3350mV + self.set_dcdc1_voltage(3350) + + # Set LCD backlight voltage (DCDC3) to 2800mV + self.set_dcdc3_voltage(2800) + + # Set LDO2 (LCD logic + SD card) to 3300mV + self.set_ldo2_voltage(3300) + + # Set LDO3 (vibration motor) to 2000mV (low to keep motor off initially) + self.set_ldo3_voltage(2000) + + # Enable LDO2 (LCD logic power) + self.set_ldo2_enable(True) + + # Enable DCDC3 (LCD backlight) + self.set_dcdc3_enable(True) + + # Disable LDO3 at startup (vibration motor off) + self.set_ldo3_enable(False) + + # Set charging current to 100mA + self.set_charge_current(0) # 0 = 100mA + + # GPIO4: NMOS open-drain output (LCD RST) + self._write_reg(_REG_GPIO4_FUNCTION, (self._read_reg(_REG_GPIO4_FUNCTION) & 0x72) | 0x84) + + # PEK parameters: power key settings + self._write_reg(_REG_PEK_PARAMS, 0x4C) + + # Enable all ADCs + self._write_reg(_REG_ADC_ENABLE1, 0xFF) + + # Check power input and configure bus power mode + if self._read_reg(_REG_POWER_STATUS) & 0x08: + self._write_reg(_REG_VBUS_IPSOUT, self._read_reg(_REG_VBUS_IPSOUT) | 0x80) + self._set_bus_power_mode(_MBUS_MODE_INPUT) + else: + self._set_bus_power_mode(_MBUS_MODE_OUTPUT) + + # Perform LCD + Touch reset sequence (both share AXP192 GPIO4) + self.set_lcd_reset(False) + import time + time.sleep_ms(100) + self.set_lcd_reset(True) + time.sleep_ms(300) # FT6336U needs ~300ms after reset to be ready + + # Enable speaker amp after init + self.set_speaker_enable(True) + + # -- Voltage setters -- + + def set_dcdc1_voltage(self, mv): + """Set DCDC1 voltage (ESP32 core). Range: 700-3500mV, step 25mV.""" + val = max(0, min(127, (mv - 700) // 25)) + self._write_reg(_REG_DCDC1_VOLTAGE, (self._read_reg(_REG_DCDC1_VOLTAGE) & 0x80) | val) + + def set_dcdc3_voltage(self, mv): + """Set DCDC3 voltage (LCD backlight). Range: 700-3500mV, step 25mV.""" + val = max(0, min(127, (mv - 700) // 25)) + self._write_reg(_REG_DCDC3_VOLTAGE, (self._read_reg(_REG_DCDC3_VOLTAGE) & 0x80) | val) + + def set_ldo2_voltage(self, mv): + """Set LDO2 voltage. Range: 1800-3300mV, step 100mV.""" + val = max(0, min(15, (mv - 1800) // 100)) + self._write_reg(_REG_LDO23_VOLTAGE, (self._read_reg(_REG_LDO23_VOLTAGE) & 0x0F) | (val << 4)) + + def set_ldo3_voltage(self, mv): + """Set LDO3 voltage. Range: 1800-3300mV, step 100mV.""" + val = max(0, min(15, (mv - 1800) // 100)) + self._write_reg(_REG_LDO23_VOLTAGE, (self._read_reg(_REG_LDO23_VOLTAGE) & 0xF0) | val) + + # -- Power output enable/disable -- + + def _set_power_output(self, bit, enable): + reg = self._read_reg(_REG_POWER_OUTPUT_CTRL) + if enable: + reg |= (1 << bit) + else: + reg &= ~(1 << bit) + self._write_reg(_REG_POWER_OUTPUT_CTRL, reg) + + def set_dcdc1_enable(self, enable): + self._set_power_output(_BIT_DCDC1, enable) + + def set_dcdc3_enable(self, enable): + self._set_power_output(_BIT_DCDC3, enable) + + def set_ldo2_enable(self, enable): + self._set_power_output(_BIT_LDO2, enable) + + def set_ldo3_enable(self, enable): + self._set_power_output(_BIT_LDO3, enable) + + # -- GPIO control (used for peripherals) -- + + def set_lcd_reset(self, state): + """Control LCD reset via AXP192 GPIO4.""" + data = self._read_reg(_REG_GPIO34_SIGNAL) + if state: + data |= 0x02 + else: + data &= ~0x02 + self._write_reg(_REG_GPIO34_SIGNAL, data) + + def set_touch_reset(self, state): + """Control touch controller reset via AXP192 GPIO4 (shared with LCD).""" + self.set_lcd_reset(state) + + def set_speaker_enable(self, state): + """Control speaker amplifier enable via AXP192 GPIO2.""" + data = self._read_reg(_REG_GPIO_SIGNAL) + if state: + data |= 0x04 + else: + data &= ~0x04 + self._write_reg(_REG_GPIO_SIGNAL, data) + + def _set_bus_power_mode(self, mode): + if mode == _MBUS_MODE_INPUT: + # GPIO0 LDO output, pull up N_VBUSEN to disable 5V from BUS + data = self._read_reg(0x91) + self._write_reg(0x91, (data & 0x0F) | 0xF0) + data = self._read_reg(0x90) + self._write_reg(0x90, (data & 0xF8) | 0x02) + # Enable EXTEN for 5V boost + data = self._read_reg(_REG_POWER_OUTPUT_CTRL) + self._write_reg(_REG_POWER_OUTPUT_CTRL, data | 0x40) + else: + # Disable 5V boost + data = self._read_reg(_REG_POWER_OUTPUT_CTRL) + self._write_reg(_REG_POWER_OUTPUT_CTRL, data & 0xBF) + # GPIO0 floating, external pulldown enables BUS_5V supply + data = self._read_reg(0x90) + self._write_reg(0x90, (data & 0xF8) | 0x01) + + # -- Charging -- + + def set_charge_current(self, level): + """Set charge current. 0=100mA, 1=190mA, ..., 8=780mA, etc.""" + data = self._read_reg(_REG_CHARGE_CTRL1) + data = (data & 0xF0) | (level & 0x0F) + self._write_reg(_REG_CHARGE_CTRL1, data) + + # -- Battery / power readings -- + + def get_battery_voltage(self): + """Get battery voltage in volts.""" + return self._read_12bit(_REG_BAT_VOLTAGE_H) * 1.1 / 1000.0 + + def get_battery_current(self): + """Get net battery current in mA (positive=charging, negative=discharging).""" + current_in = self._read_13bit(_REG_BAT_CURRENT_IN_H) * 0.5 + current_out = self._read_13bit(_REG_BAT_CURRENT_OUT_H) * 0.5 + return current_in - current_out + + def is_charging(self): + return bool(self._read_reg(_REG_POWER_STATUS) & 0x04) + + def is_vbus_present(self): + return bool(self._read_reg(_REG_POWER_STATUS) & 0x20) + + def get_battery_level(self): + """Estimate battery percentage (simple linear approximation).""" + v = self.get_battery_voltage() + if v < 3.2: + return 0 + pct = (v - 3.12) * 100.0 + return min(100, max(0, int(pct))) + + # -- Screen brightness via DCDC3 -- + + def set_screen_brightness(self, percent): + """Set screen brightness 0-100% by adjusting DCDC3 voltage (2500-3300mV).""" + percent = max(0, min(100, percent)) + mv = 2500 + int(percent * 8) # 2500mV-3300mV + self.set_dcdc3_voltage(mv) + + # -- Power control -- + + def power_off(self): + """Cut all power except RTC (LDO1).""" + self._write_reg(_REG_POWER_OFF, self._read_reg(_REG_POWER_OFF) | 0x80) + + def set_vibration(self, enable): + """Enable/disable vibration motor via LDO3.""" + self.set_ldo3_enable(enable) diff --git a/internal_filesystem/lib/logging.mpy b/internal_filesystem/lib/logging.mpy deleted file mode 100644 index 2a765951..00000000 Binary files a/internal_filesystem/lib/logging.mpy and /dev/null differ diff --git a/internal_filesystem/lib/logging/__init__.py b/internal_filesystem/lib/logging/__init__.py new file mode 100644 index 00000000..e060b377 --- /dev/null +++ b/internal_filesystem/lib/logging/__init__.py @@ -0,0 +1 @@ +from .logging import * diff --git a/internal_filesystem/lib/logging/logging.py b/internal_filesystem/lib/logging/logging.py new file mode 100644 index 00000000..360bd16f --- /dev/null +++ b/internal_filesystem/lib/logging/logging.py @@ -0,0 +1,254 @@ +from micropython import const +import io +import sys +import time + +CRITICAL = const(50) +ERROR = const(40) +WARNING = const(30) +INFO = const(20) +DEBUG = const(10) +NOTSET = const(0) + +_DEFAULT_LEVEL = const(WARNING) + +_level_dict = { + CRITICAL: "CRITICAL", + ERROR: "ERROR", + WARNING: "WARNING", + INFO: "INFO", + DEBUG: "DEBUG", + NOTSET: "NOTSET", +} + +_loggers = {} +_stream = sys.stderr +_default_fmt = "%(levelname)s:%(name)s:%(message)s" +_default_datefmt = "%Y-%m-%d %H:%M:%S" + + +class LogRecord: + def set(self, name, level, message): + self.name = name + self.levelno = level + self.levelname = _level_dict[level] + self.message = message + self.ct = time.time() + self.msecs = int((self.ct - int(self.ct)) * 1000) + self.asctime = None + + +class Handler: + def __init__(self, level=NOTSET): + self.level = level + self.formatter = None + + def close(self): + pass + + def setLevel(self, level): + self.level = level + + def setFormatter(self, formatter): + self.formatter = formatter + + def format(self, record): + return self.formatter.format(record) + + +class StreamHandler(Handler): + def __init__(self, stream=None): + super().__init__() + self.stream = _stream if stream is None else stream + self.terminator = "\n" + + def close(self): + if hasattr(self.stream, "flush"): + self.stream.flush() + + def emit(self, record): + if record.levelno >= self.level: + self.stream.write(self.format(record) + self.terminator) + + +class FileHandler(StreamHandler): + def __init__(self, filename, mode="a", encoding="UTF-8"): + super().__init__(stream=open(filename, mode=mode, encoding=encoding)) + + def close(self): + super().close() + self.stream.close() + + +class Formatter: + def __init__(self, fmt=None, datefmt=None): + self.fmt = _default_fmt if fmt is None else fmt + self.datefmt = _default_datefmt if datefmt is None else datefmt + + def usesTime(self): + return "asctime" in self.fmt + + def formatTime(self, datefmt, record): + if hasattr(time, "strftime"): + return time.strftime(datefmt, time.localtime(record.ct)) + return None + + def format(self, record): + if self.usesTime(): + record.asctime = self.formatTime(self.datefmt, record) + return self.fmt % { + "name": record.name, + "message": record.message, + "msecs": record.msecs, + "asctime": record.asctime, + "levelname": record.levelname, + } + + +class Logger: + def __init__(self, name, level=NOTSET): + self.name = name + self.level = level + self.handlers = [] + self.record = LogRecord() + + def setLevel(self, level): + self.level = level + + def isEnabledFor(self, level): + return level >= self.getEffectiveLevel() + + def getEffectiveLevel(self): + return self.level or getLogger().level or _DEFAULT_LEVEL + + def log(self, level, msg, *args): + if self.isEnabledFor(level): + if args: + if isinstance(args[0], dict): + args = args[0] + msg = msg % args + self.record.set(self.name, level, msg) + handlers = self.handlers + if not handlers: + handlers = getLogger().handlers + for h in handlers: + h.emit(self.record) + + def debug(self, msg, *args): + self.log(DEBUG, msg, *args) + + def info(self, msg, *args): + self.log(INFO, msg, *args) + + def warning(self, msg, *args): + self.log(WARNING, msg, *args) + + def error(self, msg, *args): + self.log(ERROR, msg, *args) + + def critical(self, msg, *args): + self.log(CRITICAL, msg, *args) + + def exception(self, msg, *args, exc_info=True): + self.log(ERROR, msg, *args) + tb = None + if isinstance(exc_info, BaseException): + tb = exc_info + elif hasattr(sys, "exc_info"): + tb = sys.exc_info()[1] + if tb: + buf = io.StringIO() + sys.print_exception(tb, buf) + self.log(ERROR, buf.getvalue()) + + def addHandler(self, handler): + self.handlers.append(handler) + + def hasHandlers(self): + return len(self.handlers) > 0 + + +def getLogger(name=None): + if name is None: + name = "root" + if name not in _loggers: + _loggers[name] = Logger(name) + if name == "root": + basicConfig() + return _loggers[name] + + +def log(level, msg, *args): + getLogger().log(level, msg, *args) + + +def debug(msg, *args): + getLogger().debug(msg, *args) + + +def info(msg, *args): + getLogger().info(msg, *args) + + +def warning(msg, *args): + getLogger().warning(msg, *args) + + +def error(msg, *args): + getLogger().error(msg, *args) + + +def critical(msg, *args): + getLogger().critical(msg, *args) + + +def exception(msg, *args, exc_info=True): + getLogger().exception(msg, *args, exc_info=exc_info) + + +def shutdown(): + for k, logger in _loggers.items(): + for h in logger.handlers: + h.close() + _loggers.pop(logger, None) + + +def addLevelName(level, name): + _level_dict[level] = name + + +def basicConfig( + filename=None, + filemode="a", + format=None, + datefmt=None, + level=WARNING, + stream=None, + encoding="UTF-8", + force=False, +): + if "root" not in _loggers: + _loggers["root"] = Logger("root") + + logger = _loggers["root"] + + if force or not logger.handlers: + for h in logger.handlers: + h.close() + logger.handlers = [] + + if filename is None: + handler = StreamHandler(stream) + else: + handler = FileHandler(filename, filemode, encoding) + + # Fix from https://github.com/micropython/micropython-lib/pull/1077 is on the line below: + handler.setLevel(NOTSET) + handler.setFormatter(Formatter(format, datefmt)) + + logger.setLevel(level) + logger.addHandler(handler) + + +if hasattr(sys, "atexit"): + sys.atexit(shutdown) diff --git a/internal_filesystem/lib/mpos/__init__.py b/internal_filesystem/lib/mpos/__init__.py index 6111795a..71a2193f 100644 --- a/internal_filesystem/lib/mpos/__init__.py +++ b/internal_filesystem/lib/mpos/__init__.py @@ -1,18 +1,109 @@ # Core framework from .app.app import App from .app.activity import Activity -from .net.connectivity_manager import ConnectivityManager from .content.intent import Intent from .activity_navigator import ActivityNavigator -from .content.package_manager import PackageManager -# Common activities (optional) +from .content.app_manager import AppManager +from .config import SharedPreferences +from .net.connectivity_manager import ConnectivityManager +from .net.wifi_service import WifiService +from .audio.audiomanager import AudioManager +from .net.download_manager import DownloadManager +from .task_manager import TaskManager +from .camera_manager import CameraManager +from .sensor_manager import SensorManager +from .time_zone import TimeZone +from .number_format import NumberFormat +from .device_info import DeviceInfo +from .build_info import BuildInfo + +# Battery manager (imported early for UI dependencies) +from .battery_manager import BatteryManager +try: + from .webserver.webserver import WebServer +except ImportError: + WebServer = None # _webrepl not available on desktop/Unix builds + +# Common activities from .app.activities.chooser import ChooserActivity from .app.activities.view import ViewActivity from .app.activities.share import ShareActivity +from .ui.setting_activity import SettingActivity +from .ui.settings_activity import SettingsActivity +from .ui.camera_activity import CameraActivity +from .ui.keyboard import MposKeyboard +from .ui.testing import ( + wait_for_render, capture_screenshot, simulate_click, get_widget_coords, + find_label_with_text, verify_text_present, print_screen_labels, find_text_on_screen, + click_button, click_label, click_keyboard_button, find_button_with_text, + get_all_widgets_with_text, find_setting_value_label, get_setting_value_text, + verify_setting_value_text, find_dropdown_widget, get_dropdown_options, + find_dropdown_option_index, select_dropdown_option_by_text +) + +# UI utility functions +from .ui.display_metrics import DisplayMetrics +from .ui.input_manager import InputManager +from .ui.appearance_manager import AppearanceManager +from .ui.event import get_event_name, print_event +from .ui.view import setContentView, back_screen +from .ui.topmenu import open_bar, close_bar, open_drawer, drawer_open +from .ui.focus import save_and_clear_current_focusgroup +from .ui.gesture_navigation import handle_back_swipe, handle_top_swipe +from .ui.util import shutdown, set_foreground_app, get_foreground_app +from .ui.widget_animator import WidgetAnimator +from .ui import focus_direction + +# Utility modules +from . import ui +from . import config +from . import net +from . import content +from . import time +from . import sensor_manager +from . import camera_manager +from . import sdcard +from . import audio +from . import hardware + __all__ = [ - "App", "Activity", "ConnectivityManager", "Intent", - "ActivityNavigator", "PackageManager", - "ChooserActivity", "ViewActivity", "ShareActivity" + # Core framework + "App", + "Activity", + "SharedPreferences", + "ConnectivityManager", "DownloadManager", "WifiService", "AudioManager", "Intent", + "ActivityNavigator", "AppManager", "TaskManager", "CameraManager", "BatteryManager", "WebServer", + # Device and build info + "DeviceInfo", "BuildInfo", + # Common activities + "ChooserActivity", "ViewActivity", "ShareActivity", + "SettingActivity", "SettingsActivity", "CameraActivity", + # UI components + "MposKeyboard", + # UI utility - DisplayMetrics, InputManager and AppearanceManager + "DisplayMetrics", + "InputManager", + "AppearanceManager", + "get_event_name", "print_event", + "setContentView", "back_screen", + "open_bar", "close_bar", "open_drawer", "drawer_open", + "save_and_clear_current_focusgroup", + "handle_back_swipe", "handle_top_swipe", + "shutdown", "set_foreground_app", "get_foreground_app", + "WidgetAnimator", + "focus_direction", + # Testing utilities + "wait_for_render", "capture_screenshot", "simulate_click", "get_widget_coords", + "find_label_with_text", "verify_text_present", "print_screen_labels", "find_text_on_screen", + "click_button", "click_label", "click_keyboard_button", "find_button_with_text", + "get_all_widgets_with_text", "find_setting_value_label", "get_setting_value_text", + "verify_setting_value_text", "find_dropdown_widget", "get_dropdown_options", + "find_dropdown_option_index", "select_dropdown_option_by_text", + # Submodules + "ui", "config", "net", "content", "time", "sensor_manager", + "camera_manager", "sdcard", "audio", "hardware", + # Timezone utilities + "TimeZone" ] diff --git a/internal_filesystem/lib/mpos/activity_navigator.py b/internal_filesystem/lib/mpos/activity_navigator.py index 58603759..e6579235 100644 --- a/internal_filesystem/lib/mpos/activity_navigator.py +++ b/internal_filesystem/lib/mpos/activity_navigator.py @@ -1,6 +1,8 @@ +import sys import utime + from .content.intent import Intent -from .content.package_manager import PackageManager +from .content.app_manager import AppManager import mpos.ui @@ -11,7 +13,7 @@ def startActivity(intent): if not isinstance(intent, Intent): raise ValueError("Must provide an Intent") if intent.action: # Implicit intent: resolve handlers - handlers = PackageManager.resolve_activity(intent) + handlers = AppManager.resolve_activity(intent) if not handlers: print("No handler for action:", intent.action) return @@ -29,7 +31,7 @@ def startActivityForResult(intent, result_callback): if not isinstance(intent, Intent): raise ValueError("Must provide an Intent") if intent.action: # Implicit intent: resolve handlers - handlers = PackageManager.resolve_activity(intent) + handlers = AppManager.resolve_activity(intent) if not handlers: print("No handler for action:", intent.action) return @@ -45,12 +47,19 @@ def startActivityForResult(intent, result_callback): @staticmethod def _launch_activity(intent, result_callback=None): """Launch an activity and set up result callback.""" - activity = intent.activity_class() + activity = intent.activity_class + if callable(activity): + # Instantiate the class if necessary + activity = activity() activity.intent = intent activity._result_callback = result_callback # Pass callback to activity start_time = utime.ticks_ms() mpos.ui.save_and_clear_current_focusgroup() - activity.onCreate() + try: + activity.onCreate() + except Exception as e: + print(f"activity.onCreate caught exception:") + sys.print_exception(e) end_time = utime.ticks_diff(utime.ticks_ms(), start_time) print(f"apps.py _launch_activity: activity.onCreate took {end_time}ms") return activity diff --git a/internal_filesystem/lib/mpos/app/activities/chooser.py b/internal_filesystem/lib/mpos/app/activities/chooser.py index 694d36cc..a93c731f 100644 --- a/internal_filesystem/lib/mpos/app/activities/chooser.py +++ b/internal_filesystem/lib/mpos/app/activities/chooser.py @@ -2,7 +2,7 @@ # Chooser doesn't handle an action — it shows handlers # → No registration needed -from ...content.package_manager import PackageManager +from ...content.app_manager import AppManager class ChooserActivity(Activity): def __init__(self): @@ -27,7 +27,7 @@ def onCreate(self): self.setContentView(screen) def _select_handler(self, handler_name, original_intent): - for handler in PackageManager.APP_REGISTRY.get(original_intent.action, []): + for handler in AppManager.APP_REGISTRY.get(original_intent.action, []): if handler.__name__ == handler_name: original_intent.activity_class = handler navigator.startActivity(original_intent) diff --git a/internal_filesystem/lib/mpos/app/activities/share.py b/internal_filesystem/lib/mpos/app/activities/share.py index bc2879ca..d4280a87 100644 --- a/internal_filesystem/lib/mpos/app/activities/share.py +++ b/internal_filesystem/lib/mpos/app/activities/share.py @@ -1,5 +1,5 @@ from ..activity import Activity -from ...content.package_manager import PackageManager +from ...content.app_manager import AppManager class ShareActivity(Activity): def __init__(self): @@ -35,4 +35,4 @@ def onStop(self, screen): else: print("Stopped for other screen") -PackageManager.register_activity("share", ShareActivity) +AppManager.register_activity("share", ShareActivity) diff --git a/internal_filesystem/lib/mpos/app/activities/view.py b/internal_filesystem/lib/mpos/app/activities/view.py index 38bb1c23..6123a0cf 100644 --- a/internal_filesystem/lib/mpos/app/activities/view.py +++ b/internal_filesystem/lib/mpos/app/activities/view.py @@ -1,5 +1,5 @@ from ..activity import Activity -from ...content.package_manager import PackageManager +from ...content.app_manager import AppManager class ViewActivity(Activity): def __init__(self): @@ -28,4 +28,4 @@ def onStop(self, screen): print("Stopped for other screen") # Register this activity for "view" intents -PackageManager.register_activity("view", ViewActivity) +AppManager.register_activity("view", ViewActivity) diff --git a/internal_filesystem/lib/mpos/app/activity.py b/internal_filesystem/lib/mpos/app/activity.py index c8373710..1a65c985 100644 --- a/internal_filesystem/lib/mpos/app/activity.py +++ b/internal_filesystem/lib/mpos/app/activity.py @@ -4,8 +4,6 @@ class Activity: - throttle_async_call_counter = 0 - def __init__(self): self.intent = None # Store the intent that launched this activity self.result = None @@ -19,7 +17,6 @@ def onStart(self, screen): def onResume(self, screen): # app goes to foreground self._has_foreground = True - mpos.ui.task_handler.add_event_cb(self.task_handler_callback, 1) def onPause(self, screen): # app goes to background self._has_foreground = False @@ -69,28 +66,23 @@ def finish(self): def has_foreground(self): return self._has_foreground - 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}") result = func(*args, **kwargs) + if event: + event.set() return result else: #print(f"[if_foreground] Skipped {func} because _has_foreground=False") return None # 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. # 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, **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 + # Or avoid using threads altogether, by using TaskManager (asyncio). + def update_ui_threadsafe_if_foreground(self, func, *args, important=False, event=None, **kwargs): # 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/apps.py b/internal_filesystem/lib/mpos/apps.py deleted file mode 100644 index a66102ec..00000000 --- a/internal_filesystem/lib/mpos/apps.py +++ /dev/null @@ -1,150 +0,0 @@ -import lvgl as lv - -import _thread -import traceback - -import mpos.info -import mpos.ui -from mpos.app.activity import Activity -from mpos.content.intent import Intent -from mpos.content.package_manager import PackageManager - -def good_stack_size(): - stacksize = 24*1024 - 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() - compile_name = 'script' if not is_file else script_source - print(f"Thread {thread_id}: executing script with cwd: {cwd}") - try: - if is_file: - print(f"Thread {thread_id}: reading script from file {script_source}") - with open(script_source, 'r') as f: # No need to check if it exists as exceptions are caught - start_time = utime.ticks_ms() - script_source = f.read() - read_time = utime.ticks_diff(utime.ticks_ms(), start_time) - print(f"execute_script: reading script_source took {read_time}ms") - script_globals = { - 'lv': lv, - '__name__': "__main__" - } - print(f"Thread {thread_id}: starting script") - import sys - path_before = sys.path[:] # Make a copy, not a reference - if cwd: - sys.path.append(cwd) - try: - start_time = utime.ticks_ms() - compiled_script = compile(script_source, compile_name, 'exec') - compile_time = utime.ticks_diff(utime.ticks_ms(), start_time) - print(f"execute_script: compiling script_source took {compile_time}ms") - start_time = utime.ticks_ms() - exec(compiled_script, script_globals) - end_time = utime.ticks_diff(utime.ticks_ms(), start_time) - print(f"apps.py execute_script: exec took {end_time}ms") - # Introspect globals - #classes = {k: v for k, v in script_globals.items() if isinstance(v, type)} - #functions = {k: v for k, v in script_globals.items() if callable(v) and not isinstance(v, type)} - #variables = {k: v for k, v in script_globals.items() if not callable(v)} - #print("Classes:", classes.keys()) - #print("Functions:", functions.keys()) - #print("Variables:", variables.keys()) - 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) - 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: -# NOTE: check if the script exists here instead of launching a new thread? -def execute_script_new_thread(scriptname, is_file): - print(f"main.py: execute_script_new_thread({scriptname},{is_file})") - try: - # 168KB maximum at startup but 136KB after loading display, drivers, LVGL gui etc so let's go for 128KB for now, still a lot... - # But then no additional threads can be created. A stacksize of 32KB allows for 4 threads, so 3 in the app itself, which might be tight. - # 16KB allows for 10 threads in the apps, but seems too tight for urequests on unix (desktop) targets - # 32KB seems better for the camera, but it forced me to lower other app threads from 16 to 12KB - #_thread.stack_size(24576) # causes camera issue... - # NOTE: This doesn't do anything if apps are started in the same thread! - if "camtest" in scriptname: - print("Starting camtest with extra stack size!") - stack=32*1024 - elif "appstore" in scriptname: - print("Starting appstore with extra stack size!") - stack=24*1024 # this doesn't do anything because it's all started in the same thread - else: - stack=16*1024 # 16KB doesn't seem to be enough for the AppStore app on desktop - stack = mpos.apps.good_stack_size() - print(f"app.py: setting stack size for script to {stack}") - _thread.stack_size(stack) - _thread.start_new_thread(execute_script, (scriptname, is_file)) - except Exception as e: - 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 - start_time = utime.ticks_ms() - app = PackageManager.get(fullname) - if not app: - print(f"Warning: start_app can't find app {fullname}") - return - if not app.installed_path: - print(f"Warning: start_app can't start {fullname} because no it doesn't have an installed_path") - return - if not app.main_launcher_activity: - 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')}" - 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() - else: - 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") - # 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 - 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 index 86526aa9..f3b85cc8 100644 --- a/internal_filesystem/lib/mpos/audio/__init__.py +++ b/internal_filesystem/lib/mpos/audio/__init__.py @@ -1,55 +1,4 @@ -# AudioFlinger - Centralized Audio Management Service for MicroPythonOS -# Android-inspired audio routing with priority-based audio focus +# AudioManager - Centralized Audio Management Service for MicroPythonOS +# Registry-based audio routing with device descriptors and session control -from . import audioflinger - -# Re-export main API -from .audioflinger import ( - # Device types - DEVICE_NULL, - DEVICE_I2S, - DEVICE_BUZZER, - DEVICE_BOTH, - - # Stream types - STREAM_MUSIC, - STREAM_NOTIFICATION, - STREAM_ALARM, - - # Core functions - init, - play_wav, - play_rtttl, - stop, - pause, - resume, - set_volume, - get_volume, - get_device_type, - is_playing, -) - -__all__ = [ - # Device types - 'DEVICE_NULL', - 'DEVICE_I2S', - 'DEVICE_BUZZER', - 'DEVICE_BOTH', - - # Stream types - 'STREAM_MUSIC', - 'STREAM_NOTIFICATION', - 'STREAM_ALARM', - - # Functions - 'init', - 'play_wav', - 'play_rtttl', - 'stop', - 'pause', - 'resume', - 'set_volume', - 'get_volume', - 'get_device_type', - 'is_playing', -] +from .audiomanager import AudioManager, Player, Recorder, StereoNotSupported diff --git a/internal_filesystem/lib/mpos/audio/audioflinger.py b/internal_filesystem/lib/mpos/audio/audioflinger.py deleted file mode 100644 index 47dfcd98..00000000 --- a/internal_filesystem/lib/mpos/audio/audioflinger.py +++ /dev/null @@ -1,330 +0,0 @@ -# AudioFlinger - Core Audio Management Service -# Centralized audio routing with priority-based audio focus (Android-inspired) -# Supports I2S (digital audio) and PWM buzzer (tones/ringtones) - -# Device type constants -DEVICE_NULL = 0 # No audio hardware (desktop fallback) -DEVICE_I2S = 1 # Digital audio output (WAV playback) -DEVICE_BUZZER = 2 # PWM buzzer (tones/RTTTL) -DEVICE_BOTH = 3 # Both I2S and buzzer available - -# 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) -_device_type = DEVICE_NULL -_i2s_pins = None # I2S pin configuration dict (created per-stream) -_buzzer_instance = None # PWM buzzer instance -_current_stream = None # Currently playing stream -_volume = 70 # System volume (0-100) -_stream_lock = None # Thread lock for stream management - - -def init(device_type, i2s_pins=None, buzzer_instance=None): - """ - Initialize AudioFlinger with hardware configuration. - - Args: - device_type: One of DEVICE_NULL, DEVICE_I2S, DEVICE_BUZZER, DEVICE_BOTH - i2s_pins: Dict with 'sck', 'ws', 'sd' pin numbers (for I2S devices) - buzzer_instance: PWM instance for buzzer (for buzzer devices) - """ - global _device_type, _i2s_pins, _buzzer_instance, _stream_lock - - _device_type = device_type - _i2s_pins = i2s_pins - _buzzer_instance = buzzer_instance - - # Initialize thread lock for stream management - try: - import _thread - _stream_lock = _thread.allocate_lock() - except ImportError: - # Desktop mode - no threading support - _stream_lock = None - - device_names = { - DEVICE_NULL: "NULL (no audio)", - DEVICE_I2S: "I2S (digital audio)", - DEVICE_BUZZER: "Buzzer (PWM tones)", - DEVICE_BOTH: "Both (I2S + Buzzer)" - } - - print(f"AudioFlinger initialized: {device_names.get(device_type, 'Unknown')}") - - -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): - """ - Background thread function for audio playback. - - Args: - stream: Stream instance (WAVStream or RTTTLStream) - """ - global _current_stream - - # Acquire lock and set as current stream - if _stream_lock: - _stream_lock.acquire() - _current_stream = stream - if _stream_lock: - _stream_lock.release() - - try: - # Run playback (blocks until complete or stopped) - stream.play() - except Exception as e: - print(f"AudioFlinger: Playback error: {e}") - finally: - # Clear current stream - if _stream_lock: - _stream_lock.acquire() - if _current_stream == stream: - _current_stream = None - if _stream_lock: - _stream_lock.release() - - -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 _device_type not in (DEVICE_I2S, DEVICE_BOTH): - print("AudioFlinger: play_wav() failed - no I2S device available") - return False - - if not _i2s_pins: - print("AudioFlinger: play_wav() failed - I2S pins not configured") - return False - - # Check audio focus - if _stream_lock: - _stream_lock.acquire() - can_start = _check_audio_focus(stream_type) - if _stream_lock: - _stream_lock.release() - - if not can_start: - return False - - # Create stream and start playback in background thread - try: - from mpos.audio.stream_wav import WAVStream - import _thread - import mpos.apps - - 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 _device_type not in (DEVICE_BUZZER, DEVICE_BOTH): - print("AudioFlinger: play_rtttl() failed - no buzzer device available") - return False - - if not _buzzer_instance: - print("AudioFlinger: play_rtttl() failed - buzzer not initialized") - return False - - # Check audio focus - if _stream_lock: - _stream_lock.acquire() - can_start = _check_audio_focus(stream_type) - if _stream_lock: - _stream_lock.release() - - if not can_start: - return False - - # Create stream and start playback in background thread - try: - from mpos.audio.stream_rtttl import RTTTLStream - import _thread - import mpos.apps - - 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 stop(): - """Stop current audio playback.""" - global _current_stream - - if _stream_lock: - _stream_lock.acquire() - - if _current_stream: - _current_stream.stop() - print("AudioFlinger: Playback stopped") - else: - print("AudioFlinger: No playback to stop") - - if _stream_lock: - _stream_lock.release() - - -def pause(): - """ - Pause current audio playback (if supported by stream). - Note: Most streams don't support pause, only stop. - """ - global _current_stream - - if _stream_lock: - _stream_lock.acquire() - - 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") - - if _stream_lock: - _stream_lock.release() - - -def resume(): - """ - Resume paused audio playback (if supported by stream). - Note: Most streams don't support resume, only play. - """ - global _current_stream - - if _stream_lock: - _stream_lock.acquire() - - 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") - - if _stream_lock: - _stream_lock.release() - - -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)) - - -def get_volume(): - """ - Get system volume. - - Returns: - int: Current system volume (0-100) - """ - return _volume - - -def get_device_type(): - """ - Get configured audio device type. - - Returns: - int: Device type (DEVICE_NULL, DEVICE_I2S, DEVICE_BUZZER, DEVICE_BOTH) - """ - return _device_type - - -def is_playing(): - """ - Check if audio is currently playing. - - Returns: - bool: True if playback active, False otherwise - """ - if _stream_lock: - _stream_lock.acquire() - - result = _current_stream is not None and _current_stream.is_playing() - - if _stream_lock: - _stream_lock.release() - - return result diff --git a/internal_filesystem/lib/mpos/audio/audiomanager.py b/internal_filesystem/lib/mpos/audio/audiomanager.py new file mode 100644 index 00000000..e0e3bf08 --- /dev/null +++ b/internal_filesystem/lib/mpos/audio/audiomanager.py @@ -0,0 +1,694 @@ +# AudioManager - Core Audio Management Service +# Registry-based audio routing with device descriptors and session control + +import _thread + +from ..task_manager import TaskManager + + +class StereoNotSupported(Exception): + pass + + +class AudioManager: + """ + Centralized audio management service with device registry and session control. + + Usage: + from mpos import AudioManager + + AudioManager.add(AudioManager.Output(...)) + AudioManager.add(AudioManager.Input(...)) + + player = AudioManager.player(file_path="music.wav") + player.start() + """ + + STREAM_MUSIC = 0 + STREAM_NOTIFICATION = 1 + STREAM_ALARM = 2 + + _instance = None + + class Output: + def __init__( + self, + name, + kind, + channels=1, + i2s_pins=None, + buzzer_pin=None, + preferred_sample_rate=None, + ): + if kind not in ("i2s", "buzzer"): + raise ValueError("Output.kind must be 'i2s' or 'buzzer'") + if channels not in (1, 2): + raise ValueError("Output.channels must be 1 or 2") + + self.name = name + self.kind = kind + self.channels = channels + self.preferred_sample_rate = preferred_sample_rate + + if kind == "i2s": + if not i2s_pins: + raise ValueError("Output.i2s_pins required for i2s output") + self._validate_i2s_pins(i2s_pins) + self.i2s_pins = dict(i2s_pins) + self.buzzer_pin = None + else: + if buzzer_pin is None: + raise ValueError("Output.buzzer_pin required for buzzer output") + self.buzzer_pin = buzzer_pin + self.i2s_pins = None + + @staticmethod + def _validate_i2s_pins(i2s_pins): + allowed = {"sck", "ws", "sd", "mck"} + for key in i2s_pins: + if key not in allowed: + raise ValueError("Invalid i2s_pins key for output: %s" % key) + for key in ("ws", "sd"): + if key not in i2s_pins: + raise ValueError("i2s_pins must include '%s'" % key) + + def __repr__(self): + return "" % (self.name, self.kind) + + class Input: + def __init__( + self, + name, + kind, + channels=1, + i2s_pins=None, + adc_mic_pin=None, + preferred_sample_rate=None, + ): + if kind not in ("i2s", "adc"): + raise ValueError("Input.kind must be 'i2s' or 'adc'") + if channels != 1: + raise StereoNotSupported("Input channels=2 not supported yet") + + self.name = name + self.kind = kind + self.channels = channels + self.preferred_sample_rate = preferred_sample_rate + + if kind == "i2s": + if not i2s_pins: + raise ValueError("Input.i2s_pins required for i2s input") + self._validate_i2s_pins(i2s_pins) + self.i2s_pins = dict(i2s_pins) + self.adc_mic_pin = None + else: + if adc_mic_pin is None: + raise ValueError("Input.adc_mic_pin required for adc input") + self.adc_mic_pin = adc_mic_pin + self.i2s_pins = None + + @staticmethod + def _validate_i2s_pins(i2s_pins): + allowed = {"sck_in", "sck", "ws", "sd_in"} + for key in i2s_pins: + if key not in allowed: + raise ValueError("Invalid i2s_pins key for input: %s" % key) + for key in ("ws", "sd_in"): + if key not in i2s_pins: + raise ValueError("i2s_pins must include '%s'" % key) + + def __repr__(self): + return "" % (self.name, self.kind) + + def __init__(self): + if getattr(self, "_initialized", False): + return + + AudioManager._instance = self + self._outputs = [] + self._inputs = [] + self._default_output = None + self._default_input = None + self._active_sessions = [] + self._volume = 50 + self._initialized = True + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + @classmethod + def get(cls): + if cls._instance is None: + cls._instance = cls() + return cls._instance + + @classmethod + def add(cls, device): + return cls.get()._add_device(device) + + def _add_device(self, device): + if isinstance(device, AudioManager.Output): + self._outputs.append(device) + if self._default_output is None: + self._default_output = device + return device + if isinstance(device, AudioManager.Input): + self._inputs.append(device) + if self._default_input is None: + self._default_input = device + return device + raise ValueError("Unsupported device type") + + @classmethod + def get_outputs(cls): + return list(cls.get()._outputs) + + @classmethod + def get_inputs(cls): + return list(cls.get()._inputs) + + @classmethod + def get_default_output(cls): + return cls.get()._default_output + + @classmethod + def get_default_input(cls): + return cls.get()._default_input + + @classmethod + def set_default_output(cls, output): + cls.get()._default_output = output + + @classmethod + def set_default_input(cls, input_device): + cls.get()._default_input = input_device + + @classmethod + def set_volume(cls, volume): + manager = cls.get() + try: + volume_int = int(round(volume)) + except (TypeError, ValueError): + return manager._volume + volume_int = max(0, min(100, volume_int)) + manager._volume = volume_int + + for session in list(manager._active_sessions): + stream = getattr(session, "_stream", None) + if stream and hasattr(stream, "set_volume"): + try: + stream.set_volume(volume_int) + except Exception: + pass + + return volume_int + + @classmethod + def get_volume(cls): + return cls.get()._volume + + @classmethod + def get_active_player(cls, stream_type=None, file_path=None): + manager = cls.get() + manager._cleanup_inactive() + for session in list(manager._active_sessions): + if isinstance(session, Player): + if stream_type is not None and session.stream_type != stream_type: + continue + if file_path is not None and session.file_path != file_path: + continue + if session.is_playing(): + return session + return None + + @classmethod + def get_active_track(cls, stream_type=None): + player = cls.get_active_player(stream_type=stream_type) + if player and player.file_path: + return player.file_path + return None + + @classmethod + def player( + cls, + file_path=None, + rtttl=None, + stream_type=None, + on_complete=None, + output=None, + sample_rate=None, + volume=None, + ): + return Player( + manager=cls.get(), + file_path=file_path, + rtttl=rtttl, + stream_type=stream_type, + on_complete=on_complete, + output=output, + sample_rate=sample_rate, + volume=volume, + ) + + @classmethod + def rtttl_player(cls, rtttl, **kwargs): + return cls.player(rtttl=rtttl, **kwargs) + + @classmethod + def recorder( + cls, + file_path, + input=None, + sample_rate=None, + on_complete=None, + duration_ms=None, + **adc_config + ): + return Recorder( + manager=cls.get(), + file_path=file_path, + input_device=input, + sample_rate=sample_rate, + on_complete=on_complete, + duration_ms=duration_ms, + adc_config=adc_config, + ) + + @classmethod + def record_wav_adc( + cls, + file_path, + duration_ms=None, + sample_rate=None, + adc_pin=None, + on_complete=None, + **adc_config + ): + manager = cls.get() + from mpos.audio.stream_record_adc import ADCRecordStream + + stream = ADCRecordStream( + file_path=file_path, + duration_ms=duration_ms, + sample_rate=sample_rate, + adc_pin=adc_pin, + on_complete=on_complete, + **adc_config, + ) + session = _ADCRecorderSession(manager, stream) + manager._resolve_conflicts(session) + manager._register_session(session) + + _thread.stack_size(TaskManager.good_stack_size()) + _thread.start_new_thread(session._record_thread, ()) + return True + + @classmethod + def stop(cls): + return cls.get()._stop_all() + + def _stop_all(self): + for session in list(self._active_sessions): + session.stop() + self._active_sessions = [] + + def _register_session(self, session): + self._active_sessions.append(session) + + def _session_finished(self, session): + if session in self._active_sessions: + self._active_sessions.remove(session) + + def _cleanup_inactive(self): + active = [] + for session in self._active_sessions: + if session.is_active(): + active.append(session) + self._active_sessions = active + + def _resolve_conflicts(self, new_session): + self._cleanup_inactive() + to_stop = [] + for session in self._active_sessions: + if self._sessions_conflict(session, new_session): + to_stop.append(session) + for session in to_stop: + session.stop() + if session in self._active_sessions: + self._active_sessions.remove(session) + + @staticmethod + def _pins_compatible(existing_signal, new_signal): + if existing_signal == new_signal and existing_signal in ("ws", "sck"): + return True + return False + + def _sessions_conflict(self, existing, new_session): + existing_pins = existing.pin_usage() + new_pins = new_session.pin_usage() + shared_clock = False + + for pin, new_signal in new_pins.items(): + if pin in existing_pins: + existing_signal = existing_pins[pin] + if self._pins_compatible(existing_signal, new_signal): + shared_clock = True + continue + return True + + if shared_clock: + if existing.sample_rate is None or new_session.sample_rate is None: + return True + if existing.sample_rate != new_session.sample_rate: + return True + + return False + + def _start_player(self, player): + if player.output is None: + player.output = self._default_output + if player.output is None: + raise ValueError("No output device registered") + + if player.stream_type is None: + player.stream_type = ( + self.STREAM_NOTIFICATION if player.rtttl else self.STREAM_MUSIC + ) + + if player.output.kind == "buzzer" and not player.rtttl: + raise ValueError("RTTTL string required for buzzer output") + if player.output.kind == "i2s" and not player.file_path: + raise ValueError("file_path required for i2s output") + + player.sample_rate = self._determine_player_rate(player) + + self._resolve_conflicts(player) + self._register_session(player) + + _thread.stack_size(TaskManager.good_stack_size()) + _thread.start_new_thread(player._play_thread, ()) + + def _start_recorder(self, recorder): + if recorder.input_device is None: + recorder.input_device = self._default_input + if recorder.input_device is None: + raise ValueError("No input device registered") + + recorder.sample_rate = self._determine_recorder_rate(recorder) + + self._resolve_conflicts(recorder) + self._register_session(recorder) + + _thread.stack_size(TaskManager.good_stack_size()) + _thread.start_new_thread(recorder._record_thread, ()) + + def _determine_player_rate(self, player): + if player.output.kind != "i2s": + return None + + preferred = player.sample_rate or player.output.preferred_sample_rate + + from mpos.audio.stream_wav import WAVStream + + info = WAVStream.get_wav_info(player.file_path) + original_rate = info["sample_rate"] + playback_rate, _ = WAVStream.compute_playback_rate(original_rate, preferred) + return playback_rate + + def _determine_recorder_rate(self, recorder): + if recorder.sample_rate: + return recorder.sample_rate + if recorder.input_device and recorder.input_device.preferred_sample_rate: + return recorder.input_device.preferred_sample_rate + return 16000 + + +class _ADCRecorderSession: + def __init__(self, manager, stream): + self._manager = manager + self._stream = stream + self.sample_rate = stream.sample_rate + + def start(self): + self._manager._resolve_conflicts(self) + self._manager._register_session(self) + + _thread.stack_size(TaskManager.good_stack_size()) + _thread.start_new_thread(self._record_thread, ()) + + def stop(self): + if self._stream: + self._stream.stop() + self._manager._session_finished(self) + + def is_active(self): + return self.is_recording() + + def is_recording(self): + return self._stream is not None and self._stream.is_recording() + + def pin_usage(self): + adc_pin = getattr(self._stream, "adc_pin", None) + if adc_pin is None: + return {} + return {adc_pin: "adc"} + + def _record_thread(self): + try: + self._stream.record() + finally: + self._manager._session_finished(self) + + +class Player: + def __init__( + self, + manager, + file_path=None, + rtttl=None, + stream_type=None, + on_complete=None, + output=None, + sample_rate=None, + volume=None, + ): + self._manager = manager + self.file_path = file_path + self.rtttl = rtttl + self.stream_type = stream_type + self.on_complete = on_complete + self.output = output + self.sample_rate = sample_rate + self.volume = volume + self._stream = None + self._buzzer = None + + def start(self): + self._manager._start_player(self) + + def stop(self): + if self._stream: + self._stream.stop() + if self._buzzer: + try: + self._buzzer.deinit() + except Exception: + pass + self._manager._session_finished(self) + + def pause(self): + if self._stream and hasattr(self._stream, "pause"): + self._stream.pause() + + def resume(self): + if self._stream and hasattr(self._stream, "resume"): + self._stream.resume() + + def is_active(self): + return self.is_playing() + + def is_playing(self): + return self._stream is not None and self._stream.is_playing() + + def get_progress_percent(self): + if self._stream and hasattr(self._stream, "get_progress_percent"): + return self._stream.get_progress_percent() + return None + + def get_progress_ms(self): + if self._stream and hasattr(self._stream, "get_progress_ms"): + return self._stream.get_progress_ms() + return None + + def get_duration_ms(self): + if self._stream and hasattr(self._stream, "get_duration_ms"): + return self._stream.get_duration_ms() + return None + + def pin_usage(self): + if not self.output: + return {} + if self.output.kind == "buzzer": + return {self.output.buzzer_pin: "buzzer"} + if self.output.kind == "i2s": + return _pin_map_i2s_output(self.output.i2s_pins) + return {} + + def _play_thread(self): + try: + if self.output.kind == "buzzer": + self._play_rtttl() + else: + self._play_wav() + finally: + if self._buzzer: + try: + self._buzzer.deinit() + except Exception: + pass + self._manager._session_finished(self) + + def _play_rtttl(self): + from mpos.audio.stream_rtttl import RTTTLStream + from machine import Pin, PWM + + self._buzzer = PWM(Pin(self.output.buzzer_pin, Pin.OUT)) + self._buzzer.duty_u16(0) + + self._stream = RTTTLStream( + rtttl_string=self.rtttl, + stream_type=self.stream_type, + volume=self.volume if self.volume is not None else self._manager._volume, + buzzer_instance=self._buzzer, + on_complete=self.on_complete, + ) + self._stream.play() + + def _play_wav(self): + from mpos.audio.stream_wav import WAVStream + + self._stream = WAVStream( + file_path=self.file_path, + stream_type=self.stream_type, + volume=self.volume if self.volume is not None else self._manager._volume, + i2s_pins=self.output.i2s_pins, + on_complete=self.on_complete, + requested_sample_rate=self.sample_rate, + ) + self._stream.play() + + +class Recorder: + def __init__( + self, + manager, + file_path, + input_device=None, + sample_rate=None, + on_complete=None, + duration_ms=None, + adc_config=None, + ): + self._manager = manager + self.file_path = file_path + self.input_device = input_device + self.sample_rate = sample_rate + self.on_complete = on_complete + self.duration_ms = duration_ms + self.adc_config = adc_config or {} + self._stream = None + + def start(self): + self._manager._start_recorder(self) + + def stop(self): + if self._stream: + self._stream.stop() + self._manager._session_finished(self) + + def pause(self): + if self._stream and hasattr(self._stream, "pause"): + self._stream.pause() + + def resume(self): + if self._stream and hasattr(self._stream, "resume"): + self._stream.resume() + + def is_active(self): + return self.is_recording() + + def is_recording(self): + return self._stream is not None and self._stream.is_recording() + + def get_duration_ms(self): + if self._stream and hasattr(self._stream, "get_duration_ms"): + return self._stream.get_duration_ms() + if self._stream and hasattr(self._stream, "get_elapsed_ms"): + return self._stream.get_elapsed_ms() + return None + + def pin_usage(self): + if not self.input_device: + return {} + if self.input_device.kind == "adc": + return {self.input_device.adc_mic_pin: "adc"} + if self.input_device.kind == "i2s": + return _pin_map_i2s_input(self.input_device.i2s_pins) + return {} + + def _record_thread(self): + try: + if self.input_device.kind == "adc": + self._record_adc() + else: + self._record_i2s() + finally: + self._manager._session_finished(self) + + def _record_i2s(self): + from mpos.audio.stream_record import RecordStream + + self._stream = RecordStream( + file_path=self.file_path, + duration_ms=self.duration_ms, + sample_rate=self.sample_rate, + i2s_pins=self.input_device.i2s_pins, + on_complete=self.on_complete, + ) + self._stream.record() + + def _record_adc(self): + from mpos.audio.stream_record_adc import ADCRecordStream + + self._stream = ADCRecordStream( + file_path=self.file_path, + duration_ms=self.duration_ms, + sample_rate=self.sample_rate, + adc_pin=self.input_device.adc_mic_pin, + on_complete=self.on_complete, + **self.adc_config, + ) + self._stream.record() + + +def _pin_map_i2s_output(i2s_pins): + pins = {} + if i2s_pins.get("sck") is not None: + pins[i2s_pins["sck"]] = "sck" + pins[i2s_pins["ws"]] = "ws" + pins[i2s_pins["sd"]] = "sd" + if i2s_pins.get("mck") is not None: + pins[i2s_pins["mck"]] = "mck" + return pins + + +def _pin_map_i2s_input(i2s_pins): + pins = {} + sck_pin = i2s_pins.get("sck_in", i2s_pins.get("sck")) + if sck_pin is not None: + pins[sck_pin] = "sck" + pins[i2s_pins["ws"]] = "ws" + pins[i2s_pins["sd_in"]] = "sd_in" + return pins 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..ff9f9034 --- /dev/null +++ b/internal_filesystem/lib/mpos/audio/stream_record.py @@ -0,0 +1,356 @@ +# RecordStream - WAV File Recording Stream for AudioManager +# 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 + self._start_time_ms = 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 + self._start_time_ms = time.ticks_ms() + + 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") + + def get_duration_ms(self): + if self._start_time_ms <= 0: + return 0 + return time.ticks_diff(time.ticks_ms(), self._start_time_ms) \ No newline at end of file diff --git a/internal_filesystem/lib/mpos/audio/stream_record_adc.py b/internal_filesystem/lib/mpos/audio/stream_record_adc.py new file mode 100644 index 00000000..8bf1a114 --- /dev/null +++ b/internal_filesystem/lib/mpos/audio/stream_record_adc.py @@ -0,0 +1,362 @@ +# ADCRecordStream - WAV File Recording Stream with C-based ADC Sampling +# Records 16-bit mono PCM audio from ADC using the optimized adc_mic C module +# Uses timer-based sampling with double buffering in C for high performance +# Maintains compatibility with AudioManager and existing recording framework + +import os +import sys +import time +import gc +import array + +# Try to import machine module (not available on desktop) +try: + import machine + import adc_mic + _HAS_HARDWARE = True +except ImportError: + _HAS_HARDWARE = 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 ADCRecordStream: + """ + WAV file recording stream with C-optimized ADC sampling. + Records 16-bit mono PCM audio from ADC using the adc_mic module. + """ + + # Default recording parameters + DEFAULT_SAMPLE_RATE = 16000 # 16kHz - good for voice/ADC + DEFAULT_MAX_DURATION_MS = 60000 # 60 seconds max + DEFAULT_FILESIZE = 1024 * 1024 * 1024 # 1GB data size + + # ADC configuration defaults + DEFAULT_ADC_PIN = 1 # GPIO1 on Fri3d 2026 + DEFAULT_ADC_UNIT = 0 # ADC_UNIT_1 = 0 + DEFAULT_ADC_CHANNEL = 0 # ADC_CHANNEL_0 = 0 (GPIO1) + #DEFAULT_ATTEN = 2 # ADC_ATTEN_DB_6 + DEFAULT_ATTEN = 3 # ADC_ATTEN_DB_12 == ADC_ATTEN_DB_11 + + def __init__(self, file_path, duration_ms, sample_rate, adc_pin=None, + on_complete=None, **adc_config): + """ + Initialize ADC recording stream. + + Args: + file_path: Path to save WAV file + duration_ms: Recording duration in milliseconds (None = until stop()) + sample_rate: Target sample rate in Hz + adc_pin: GPIO pin for ADC input (default: GPIO1) + on_complete: Callback function(message) when recording finishes + **adc_config: Additional ADC configuration + """ + 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.adc_pin = adc_pin if adc_pin is not None else self.DEFAULT_ADC_PIN + self.on_complete = on_complete + + # Determine ADC unit and channel from pin + # This is a simple mapping for ESP32-S3 + # TODO: Make this more robust or pass in unit/channel directly + self.adc_unit = self.DEFAULT_ADC_UNIT + self.adc_channel = self.DEFAULT_ADC_CHANNEL + + # Simple mapping for Fri3d 2026 (GPIO1 -> ADC1_CH0) + if self.adc_pin == 1: + self.adc_unit = 0 # ADC_UNIT_1 + self.adc_channel = 0 # ADC_CHANNEL_0 + elif self.adc_pin == 2: + self.adc_unit = 0 + self.adc_channel = 1 + # Add more mappings as needed + + self._keep_running = True + self._is_recording = False + self._bytes_recorded = 0 + self._start_time_ms = 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.""" + 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: + file_path: Path to WAV file + 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) + """ + import math + 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"ADCRecordStream.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" adc_pin: {self.adc_pin} (Unit {self.adc_unit}, Channel {self.adc_channel})") + print(f" _HAS_HARDWARE: {_HAS_HARDWARE}") + + self._is_recording = True + self._bytes_recorded = 0 + self._start_time_ms = time.ticks_ms() + + try: + # Ensure directory exists + dir_path = '/'.join(self.file_path.split('/')[:-1]) + if dir_path: + _makedirs(dir_path) + + # Create file with placeholder header + print(f"ADCRecordStream: 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"ADCRecordStream: Recording to {self.file_path}") + + # Check if we have real hardware or need to simulate + use_simulation = not _HAS_HARDWARE + + if not use_simulation: + print(f"ADCRecordStream: Using hardware ADC") + # No explicit init needed for adc_mic.read() as it handles it internally per call + # But we might want to do some setup if the C module required it. + # The current C module implementation does setup/teardown inside read() + # which is inefficient for streaming. + # However, the C module read() reads a LARGE chunk (e.g. 10000 samples). + pass + + if use_simulation: + print(f"ADCRecordStream: Using desktop simulation (sine wave)") + + # Calculate recording parameters + max_bytes = int((self.duration_ms / 1000) * self.sample_rate * 2) + + # Open file for appending audio data + f = open(self.file_path, 'ab') + + # Chunk size for reading + # For ADC, we want a reasonable chunk size to minimize overhead + # 4096 samples = 8192 bytes = ~0.25s at 16kHz + chunk_samples = 4096 + + sample_offset = 0 + + try: + while self._keep_running: + # Check elapsed time + elapsed = time.ticks_diff(time.ticks_ms(), self._start_time_ms) + if elapsed >= self.duration_ms: + print(f"ADCRecordStream: Duration limit reached") + break + + # Check byte limit + if self._bytes_recorded >= max_bytes: + print(f"ADCRecordStream: Byte limit reached") + break + + if use_simulation: + # Generate sine wave samples for desktop testing + buf, num_samples = self._generate_sine_wave_chunk(chunk_samples * 2, sample_offset) + sample_offset += num_samples + + f.write(buf) + self._bytes_recorded += len(buf) + + # Simulate real-time recording speed + time.sleep_ms(int((chunk_samples) / self.sample_rate * 1000)) + + else: + # Read from C module + # adc_mic.read(chunk_samples, unit_id, adc_channel_list, adc_channel_num, sample_rate_hz, atten) + # Returns bytes object + + # unit_id: 0 (ADC_UNIT_1) + # adc_channel_list: [self.adc_channel] + # adc_channel_num: 1 + # sample_rate_hz: self.sample_rate + # atten: 2 (ADC_ATTEN_DB_6) + + data = adc_mic.read( + chunk_samples, + self.adc_unit, + [self.adc_channel], + 1, + self.sample_rate, + self.DEFAULT_ATTEN + ) + + if data: + f.write(data) + self._bytes_recorded += len(data) + else: + # No data available yet, short sleep + time.sleep_ms(10) + + finally: + f.close() + + # Update WAV header with actual size + try: + # Only update if we actually recorded something + if self._bytes_recorded > 0: + self._update_wav_header(self.file_path, self._bytes_recorded) + except Exception as e: + print(f"ADCRecordStream: Error updating header: {e}") + + elapsed_ms = time.ticks_diff(time.ticks_ms(), self._start_time_ms) + print(f"ADCRecordStream: 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: + sys.print_exception(e) + if self.on_complete: + self.on_complete(f"Error: {e}") + + finally: + self._is_recording = False + print(f"ADCRecordStream: Recording thread finished") + + def get_duration_ms(self): + if self._start_time_ms <= 0: + return 0 + return time.ticks_diff(time.ticks_ms(), self._start_time_ms) diff --git a/internal_filesystem/lib/mpos/audio/stream_rtttl.py b/internal_filesystem/lib/mpos/audio/stream_rtttl.py index 00bae756..83469d10 100644 --- a/internal_filesystem/lib/mpos/audio/stream_rtttl.py +++ b/internal_filesystem/lib/mpos/audio/stream_rtttl.py @@ -1,6 +1,6 @@ -# RTTTLStream - RTTTL Ringtone Playback Stream for AudioFlinger +# RTTTLStream - RTTTL Ringtone Playback Stream for AudioManager # Ring Tone Text Transfer Language parser and player -# Ported from Fri3d Camp 2024 Badge firmware +# Uses synchronous playback in a separate thread for non-blocking operation import math import time @@ -180,7 +180,7 @@ def _notes(self): yield freq, msec def play(self): - """Play RTTTL tune via buzzer (runs in background thread).""" + """Play RTTTL tune via buzzer (runs in separate thread).""" self._is_playing = True # Calculate exponential duty cycle for perceptually linear volume @@ -212,6 +212,7 @@ def play(self): 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)) @@ -229,3 +230,6 @@ def play(self): # 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 index 884d936f..255f81b8 100644 --- a/internal_filesystem/lib/mpos/audio/stream_wav.py +++ b/internal_filesystem/lib/mpos/audio/stream_wav.py @@ -1,16 +1,20 @@ -# WAVStream - WAV File Playback Stream for AudioFlinger +# WAVStream - WAV File Playback Stream for AudioManager # Supports 8/16/24/32-bit PCM, mono+stereo, auto-upsampling, volume control -# Ported from MusicPlayer's AudioPlayer class +# Uses synchronous playback in a separate thread for non-blocking operation import machine +import micropython import os -import time import sys +import time + +# Toggle to enable I2S.shift-based volume scaling when available. +# Set to False to use legacy software scaling only. +USE_I2S_SHIFT_VOLUME = False # 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. -import micropython @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).""" @@ -28,14 +32,149 @@ def _scale_audio(buf: ptr8, num_bytes: int, scale_fixed: int): 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 + + +# Would be faster to use a lookup table here +def _volume_to_shift(scale_fixed): + """Convert fixed-point volume (0..32768) to a right-shift amount (0..16).""" + if scale_fixed >= 32768: + return 0 + if scale_fixed <= 0: + return 16 + shift = 0 + threshold = 32768 + while shift < 16 and scale_fixed < threshold: + shift += 1 + threshold >>= 1 + return shift class WAVStream: """ WAV file playback stream with I2S output. - Supports 8/16/24/32-bit PCM, mono and stereo, auto-upsampling to >=22050 Hz. + Supports 8/16/24/32-bit PCM, mono and stereo, auto-upsampling to >=8000 Hz. """ - def __init__(self, file_path, stream_type, volume, i2s_pins, on_complete): + def __init__( + self, + file_path, + stream_type, + volume, + i2s_pins, + on_complete, + requested_sample_rate=None, + ): """ Initialize WAV stream. @@ -45,15 +184,26 @@ def __init__(self, file_path, stream_type, volume, i2s_pins, on_complete): volume: Volume level (0-100) i2s_pins: Dict with 'sck', 'ws', 'sd' pin numbers on_complete: Callback function(message) when playback finishes + requested_sample_rate: Optional negotiated sample rate for shared clocks """ self.file_path = file_path self.stream_type = stream_type self.volume = volume self.i2s_pins = i2s_pins self.on_complete = on_complete + self.requested_sample_rate = requested_sample_rate self._keep_running = True self._is_playing = False self._i2s = None + self._mck_pwm = None + self._progress_samples = 0 + self._total_samples = 0 + self._duration_ms = None + self._playback_rate = None + self._original_rate = None + self._channels = None + self._bits_per_sample = None + self._data_size = None def is_playing(self): """Check if stream is currently playing.""" @@ -63,6 +213,19 @@ def stop(self): """Stop playback.""" self._keep_running = False + def get_progress_percent(self): + if self._total_samples <= 0: + return None + return int((self._progress_samples / self._total_samples) * 100) + + def get_progress_ms(self): + if self._playback_rate: + return int((self._progress_samples / self._playback_rate) * 1000) + return None + + def get_duration_ms(self): + return self._duration_ms + # ---------------------------------------------------------------------- # WAV header parser - returns bit-depth and format info # ---------------------------------------------------------------------- @@ -123,6 +286,37 @@ def _find_data_chunk(f): raise ValueError("No 'data' chunk found") + # ---------------------------------------------------------------------- + # WAV info helpers + # ---------------------------------------------------------------------- + @staticmethod + def get_wav_info(file_path): + with open(file_path, 'rb') as f: + data_start, data_size, sample_rate, channels, bits_per_sample = ( + WAVStream._find_data_chunk(f) + ) + return { + "data_start": data_start, + "data_size": data_size, + "sample_rate": sample_rate, + "channels": channels, + "bits_per_sample": bits_per_sample, + } + + @staticmethod + def compute_playback_rate(original_rate, requested_rate=None): + if requested_rate: + if requested_rate <= original_rate: + return original_rate, 1 + upsample_factor = (requested_rate + original_rate - 1) // original_rate + return original_rate * upsample_factor, upsample_factor + + minimal_rate = 8000 + if original_rate >= minimal_rate: + return original_rate, 1 + upsample_factor = (minimal_rate + original_rate - 1) // original_rate + return original_rate * upsample_factor, upsample_factor + # ---------------------------------------------------------------------- # Bit depth conversion functions # ---------------------------------------------------------------------- @@ -202,7 +396,7 @@ def _upsample_buffer(raw, factor): # Main playback routine # ---------------------------------------------------------------------- def play(self): - """Main playback routine (runs in background thread).""" + """Main synchronous playback routine (runs in separate thread).""" self._is_playing = True try: @@ -215,14 +409,19 @@ def play(self): data_start, data_size, original_rate, channels, bits_per_sample = \ self._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 + self._original_rate = original_rate + self._channels = channels + self._bits_per_sample = bits_per_sample + self._data_size = data_size + + playback_rate, upsample_factor = self.compute_playback_rate( + original_rate, + self.requested_sample_rate, + ) + + self._playback_rate = playback_rate + # ibuf = playback_rate # doesnt account for stereo vs mono... + ibuf = 32000 print(f"WAVStream: {original_rate} Hz, {bits_per_sample}-bit, {channels}-ch") print(f"WAVStream: Playback at {playback_rate} Hz (factor {upsample_factor})") @@ -230,20 +429,69 @@ def play(self): if data_size > file_size - data_start: data_size = file_size - data_start + bytes_per_sample = (bits_per_sample // 8) * channels + if bytes_per_sample > 0: + self._total_samples = data_size // bytes_per_sample + self._duration_ms = int((self._total_samples / original_rate) * 1000) + + print( + "WAVStream: I2S init params: " + f"requested_rate={self.requested_sample_rate}, " + f"playback_rate={playback_rate}, original_rate={original_rate}, " + f"channels={channels}, bits=16, i2s_pins={self.i2s_pins}" + ) + # 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 + print( + "WAVStream: I2S config: " + f"format={'MONO' if channels == 1 else 'STEREO'}, " + f"ibuf={ibuf}, has_sck={bool(self.i2s_pins.get('sck'))}, " + f"mck_pin={self.i2s_pins.get('mck')}" ) + + # Configure MCLK pin if provided (must be done before I2S init) + # On some MicroPython versions, machine.I2S() supports a mck argument + # but not on ESP32S3 1.25.0 version, apparently. + if 'mck' in self.i2s_pins: + mck_pin = machine.Pin(self.i2s_pins['mck'], machine.Pin.OUT) + from machine import Pin, PWM + # Add MCLK generation on GPIO2 + try: + self._mck_pwm = PWM(mck_pin) + # Set frequency to sample_rate * 256 (common ratio for CJC4334H auto-detect) + # Use duty_u16 for finer control (0–65535 range, 32768 = 50%) + self._mck_pwm.freq(playback_rate * 256) + self._mck_pwm.duty_u16(32768) # 50% duty cycle + print(f"MCLK PWM started on GPIO2 at {playback_rate * 256} Hz") + except Exception as e: + print(f"MCLK PWM init failed: {e}") + # fallback or error handling + + if self.i2s_pins.get("sck"): + 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=ibuf + ) + else: + self._i2s = machine.I2S( + 0, + 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=ibuf + ) except Exception as e: print(f"WAVStream: I2S init failed: {e}") return @@ -251,10 +499,18 @@ def play(self): print(f"WAVStream: Playing {data_size} bytes (volume {self.volume}%)") f.seek(data_start) - chunk_size = 4096 - bytes_per_original_sample = (bits_per_sample // 8) * channels - total_original = 0 + # Chunk size tuning notes: + # - Smaller chunks = more responsive to stop() + # - Larger chunks = less overhead, smoother audio + # - The 0.5-second (stereo) or 1 second (mono) I2S buffer handles timing smoothness + bytes_per_second = original_rate * bytes_per_sample + chunk_size = int(bytes_per_second / 10.7) # chunk_size of 8192 worked great with 22050hz stereo 16 bit so 88200 bytes per sample so fator 10.7 + #chunk_size = bytes_per_second >> 3 # 12-14 fps + #chunk_size = bytes_per_second >> 4 # 16-18 fps but stutters + #chunk_size = int(bytes_per_second / 12) # 18 fps for 8khz mono, 16 fps for 22khz mono, higher stutters + #chunk_size = int(bytes_per_second / 11) # still jitters at 22050hz stereo in quasibird + total_original = 0 while total_original < data_size: if not self._keep_running: print("WAVStream: Playback stopped by user") @@ -262,7 +518,7 @@ def play(self): # Read chunk of original data to_read = min(chunk_size, data_size - total_original) - to_read -= (to_read % bytes_per_original_sample) + to_read -= (to_read % bytes_per_sample) if to_read <= 0: break @@ -287,9 +543,26 @@ def play(self): scale = self.volume / 100.0 if scale < 1.0: scale_fixed = int(scale * 32768) - _scale_audio(raw, len(raw), scale_fixed) - - # 4. Output to I2S + if ( + USE_I2S_SHIFT_VOLUME + and self._i2s + and hasattr(self._i2s, "shift") + ): + shift = _volume_to_shift(scale_fixed) + if shift >= 16: + for i in range(len(raw)): + raw[i] = 0 + elif shift > 0: + try: + self._i2s.shift(raw, 16, shift) # triggers exception + except Exception as e: + print(f"_i2s.shift got exception, falling back to software scaling: {e}") + _scale_audio_optimized(raw, len(raw), scale_fixed) + else: + print("_i2s has no shift attribute, falling back to software scaling") + _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: @@ -298,6 +571,7 @@ def play(self): time.sleep(num_samples / playback_rate) total_original += to_read + self._progress_samples = total_original // bytes_per_sample print(f"WAVStream: Finished playing {self.file_path}") if self.on_complete: @@ -311,5 +585,15 @@ def play(self): finally: self._is_playing = False if self._i2s: - self._i2s.deinit() + print("Done playing, doing i2s deinit") + self._i2s.deinit() # disabling this does not fix the "play just once" issue self._i2s = None + if self._mck_pwm: + try: + print("Done playing, stopping MCLK PWM") + self._mck_pwm.deinit() + finally: + self._mck_pwm = None + + def set_volume(self, vol): + self.volume = vol diff --git a/internal_filesystem/lib/mpos/battery_manager.py b/internal_filesystem/lib/mpos/battery_manager.py new file mode 100644 index 00000000..a430f53c --- /dev/null +++ b/internal_filesystem/lib/mpos/battery_manager.py @@ -0,0 +1,185 @@ +""" +BatteryManager - Android-inspired battery and power information API. + +Provides direct query access to battery voltage, charge percentage, and raw ADC values. +Handles ADC1/ADC2 pin differences on ESP32-S3 with adaptive caching to minimize WiFi interference. +""" + +import time + +MIN_VOLTAGE = 3.15 +MAX_VOLTAGE = 4.15 + +# Internal state +_adc = None +_conversion_func = None +_adc_pin = None + +# Cache to reduce WiFi interruptions (ADC2 requires WiFi to be disabled) +_cached_raw_adc = None +_last_read_time = 0 +CACHE_DURATION_ADC1_MS = 30000 # 30 seconds (cheaper: no WiFi interference) +CACHE_DURATION_ADC2_MS = 600000 # 600 seconds (expensive: requires WiFi disable) + + +def _is_adc2_pin(pin): + """Check if pin is on ADC2 (ESP32-S3: GPIO11-20).""" + return 11 <= pin <= 20 + + +class BatteryManager: + """ + Android-inspired BatteryManager for querying battery and power information. + + Provides static methods for battery voltage, percentage, and raw ADC readings. + Automatically handles ADC1/ADC2 differences and WiFi coordination on ESP32-S3. + """ + + @staticmethod + 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 + + try: + 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)) + _adc.atten(ADC.ATTN_11DB) # 0-3.3V range + except Exception as e: + print(f"Info: this platform has no ADC for measuring battery voltage: {e}") + + initial_adc_value = BatteryManager.read_raw_adc() + print(f"Reading ADC at init to fill cache: {initial_adc_value} => {BatteryManager.read_battery_voltage(raw_adc_value=initial_adc_value)}V => {BatteryManager.get_battery_percentage(raw_adc_value=initial_adc_value)}%") + + @staticmethod + def has_battery(): + """ + Check if battery monitoring is initialized. + + Returns: + bool: True if init_adc() was called, False otherwise + """ + return _adc_pin is not None + + @staticmethod + 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 + 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: + # Needs actual path, not "from mpos" shorthand because it's mocked by test_battery_voltage.py + 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) + + @staticmethod + 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 + raw_adc_value: Optional pre-computed raw ADC value (for testing) + + Returns: + float: Battery voltage in volts (clamped to 0-MAX_VOLTAGE) + """ + raw = raw_adc_value if raw_adc_value else BatteryManager.read_raw_adc(force_refresh) + voltage = _conversion_func(raw) if _conversion_func else 0.0 + return voltage + + @staticmethod + def get_battery_percentage(raw_adc_value=None): + """ + Get battery charge percentage. + + Args: + raw_adc_value: Optional pre-computed raw ADC value (for testing) + + Returns: + float: Battery percentage (0-100) + """ + voltage = BatteryManager.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 + + @staticmethod + 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/lib/mpos/battery_voltage.py b/internal_filesystem/lib/mpos/battery_voltage.py deleted file mode 100644 index ca284272..00000000 --- a/internal_filesystem/lib/mpos/battery_voltage.py +++ /dev/null @@ -1,153 +0,0 @@ -import time - -MIN_VOLTAGE = 3.15 -MAX_VOLTAGE = 4.15 - -adc = None -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 - - try: - 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)) - adc.atten(ADC.ATTN_11DB) # 0-3.3V range - except Exception as e: - print(f"Info: this platform has no ADC for measuring battery voltage: {e}") - - 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 - 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 - - -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/lib/mpos/board/fri3d_2024.py b/internal_filesystem/lib/mpos/board/fri3d_2024.py index 88f7e131..365edc72 100644 --- a/internal_filesystem/lib/mpos/board/fri3d_2024.py +++ b/internal_filesystem/lib/mpos/board/fri3d_2024.py @@ -1,9 +1,7 @@ # Hardware initialization for Fri3d Camp 2024 Badge from machine import Pin, SPI, SDCard -import st7789 import lcd_bus import machine -import cst816s import i2c import math @@ -13,36 +11,23 @@ import lvgl as lv import task_handler +import drivers.display.st7789 as st7789 + import mpos.ui import mpos.ui.focus_direction - - -# Pin configuration -SPI_BUS = 2 -SPI_FREQ = 40000000 -#SPI_FREQ = 20000000 # also works but I guess higher is better -LCD_SCLK = 7 -LCD_MOSI = 6 -LCD_MISO = 8 -LCD_DC = 4 -LCD_CS = 5 -#LCD_BL = 1 # backlight can't be controlled on this hardware -LCD_RST = 48 - -TFT_HOR_RES=296 -TFT_VER_RES=240 +from mpos import InputManager spi_bus = machine.SPI.Bus( - host=SPI_BUS, - mosi=LCD_MOSI, - miso=LCD_MISO, - sck=LCD_SCLK + host=2, + mosi=6, + miso=8, # not connected to the display, only to the SD card, so can't read from it + sck=7 ) display_bus = lcd_bus.SPIBus( spi_bus=spi_bus, - freq=SPI_FREQ, - dc=LCD_DC, - cs=LCD_CS + freq=40000000, + dc=4, + cs=5 ) # lv.color_format_get_size(lv.COLOR_FORMAT.RGB565) = 2 bytes per pixel * 320 * 240 px = 153600 bytes @@ -57,32 +42,28 @@ fb1 = display_bus.allocate_framebuffer(buffersize, lcd_bus.MEMORY_INTERNAL | lcd_bus.MEMORY_DMA) fb2 = display_bus.allocate_framebuffer(buffersize, lcd_bus.MEMORY_INTERNAL | lcd_bus.MEMORY_DMA) -STATE_HIGH = 1 -STATE_LOW = 0 - # see ./lvgl_micropython/api_drivers/py_api_drivers/frozen/display/display_driver_framework.py 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, + display_width=240, + display_height=296, 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=48, + reset_state=0 ) mpos.ui.main_display.init() mpos.ui.main_display.set_power(True) mpos.ui.main_display.set_backlight(100) - mpos.ui.main_display.set_color_inversion(False) lv.init() 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])) +mpos.ui.main_display.set_params(0x36, bytearray([0x28])) # mirror # Button and joystick handling code: from machine import ADC, Pin @@ -93,7 +74,7 @@ btn_a = Pin(39, Pin.IN, Pin.PULL_UP) # A btn_b = Pin(40, Pin.IN, Pin.PULL_UP) # B btn_start = Pin(0, Pin.IN, Pin.PULL_UP) # START -btn_menu = Pin(45, Pin.IN, Pin.PULL_UP) # START +btn_menu = Pin(45, Pin.IN, Pin.PULL_UP) # MENU ADC_KEY_MAP = [ {'key': 'UP', 'unit': 1, 'channel': 2, 'min': 3072, 'max': 4096}, @@ -253,14 +234,18 @@ def keypad_read_cb(indev, data): indev.set_type(lv.INDEV_TYPE.KEYPAD) indev.set_read_cb(keypad_read_cb) indev.set_group(group) # is this needed? maybe better to move the default group creation to main.py so it's available everywhere... -disp = lv.display_get_default() # NOQA +disp = lv.display_get_default() indev.set_display(disp) # different from display -indev.enable(True) # NOQA +indev.enable(True) +InputManager.register_indev(indev) + +import mpos.sdcard +mpos.sdcard.init(spi_bus=spi_bus, cs_pin=14) # 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 +# BatteryManager handles this automatically: disables WiFi, reads ADC, reconnects WiFi. +from mpos import BatteryManager """ best fit on battery power: 2482 is 4.180 @@ -284,47 +269,63 @@ def adc_to_voltage(adc_value): """ 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) +BatteryManager.init_adc(13, adc_to_voltage) # === 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) +from mpos import AudioManager -# I2S pin configuration (GPIO 2, 47, 16) +# I2S pin configuration for audio output (DAC) and input (microphone) # Note: I2S is created per-stream, not at boot (only one instance can exist) -i2s_pins = { - 'sck': 2, - 'ws': 47, - 'sd': 16, +# 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_output_pins = { + 'ws': 47, # Word Select / LRCLK shared between DAC and mic (mandatory) + 'sck': 2, # SCLK or BCLK - Bit Clock for DAC output (mandatory) + 'sd': 16, # Serial Data OUT (speaker/DAC) } -# Initialize AudioFlinger (both I2S and buzzer available) -AudioFlinger.init( - device_type=AudioFlinger.DEVICE_BOTH, - i2s_pins=i2s_pins, - buzzer_instance=buzzer +i2s_input_pins = { + 'ws': 47, # Word Select / LRCLK shared between DAC and mic (mandatory) + 'sck_in': 17, # SCLK - Serial Clock for microphone input + 'sd_in': 15, # DIN - Serial Data IN (microphone) +} + +speaker_output = AudioManager.add( + AudioManager.Output( + name="speaker", + kind="i2s", + i2s_pins=i2s_output_pins, + ) ) -# === LED HARDWARE === -import mpos.lights as LightsManager +buzzer_output = AudioManager.add( + AudioManager.Output( + name="buzzer", + kind="buzzer", + buzzer_pin=46, + ) +) -# Initialize 5 NeoPixel LEDs (GPIO 12) -LightsManager.init(neopixel_pin=12, num_leds=5) +mic_input = AudioManager.add( + AudioManager.Input( + name="mic", + kind="i2s", + i2s_pins=i2s_input_pins, + ) +) # === SENSOR HARDWARE === -import mpos.sensor_manager as SensorManager - +from mpos import 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) +# === LED HARDWARE === +import mpos.lights as LightsManager +# Initialize 5 NeoPixel LEDs (GPIO 12) +LightsManager.init(neopixel_pin=12, num_leds=5) + print("Fri3d hardware: Audio, LEDs, and sensors initialized") # === STARTUP "WOW" EFFECT === @@ -332,20 +333,19 @@ def adc_to_voltage(adc_value): 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" + #startup_jingle = "ShortBeeps:d=32,o=5,b=320:c6,c7" # Start the jingle - AudioFlinger.play_rtttl( - startup_jingle, - stream_type=AudioFlinger.STREAM_NOTIFICATION, - volume=60 + player = AudioManager.player( + rtttl=startup_jingle, + stream_type=AudioManager.STREAM_NOTIFICATION, + volume=60, + output=buzzer_output, ) + player.start() # Rainbow colors for the 5 LEDs rainbow = [ @@ -383,7 +383,8 @@ def startup_wow_effect(): 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! +from mpos import TaskManager +_thread.stack_size(TaskManager.good_stack_size()) # default stack size won't work, crashes! _thread.start_new_thread(startup_wow_effect, ()) -print("boot.py finished") +print("fri3d_2024.py finished") diff --git a/internal_filesystem/lib/mpos/board/fri3d_2026.py b/internal_filesystem/lib/mpos/board/fri3d_2026.py new file mode 100644 index 00000000..897ef8c8 --- /dev/null +++ b/internal_filesystem/lib/mpos/board/fri3d_2026.py @@ -0,0 +1,314 @@ +# Hardware initialization for Fri3d Camp 2026 Badge + +# Overview: +# - Touch screen controller is cst816s +# - IMU (LSM6DSO) is different from fri3d_2024 (and address 0x6A instead of 0x6B) but the API seems the same, except different chip ID (0x6C iso 0x6A) +# - I2S audio (communicator) is the same +# - headphone jack microphone is on ESP.IO1 +# - CH32X035GxUx over I2C: +# - battery voltage measurement +# - analog joystick +# - digital buttons (X,Y,A,B, MENU) +# - buzzer +# - audio DAC emulation using buzzer might be slow or need specific buffered protocol +# - test it on the Waveshare to make sure no syntax / variable errors + +from machine import ADC, I2C, Pin, SPI, SDCard +import lcd_bus +import i2c +import math + +import micropython +import gc + +import lvgl as lv +import task_handler + +import drivers.display.st7789 as st7789 + +import mpos.ui +import mpos.ui.focus_direction +from mpos import InputManager + +spi_bus = SPI.Bus( + host=2, + mosi=6, + miso=8, + sck=7 +) +display_bus = lcd_bus.SPIBus( + spi_bus=spi_bus, + freq=40000000, # 40 Mhz + dc=4, + cs=5 +) + +# 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) +buffersize = const(28800) +fb1 = display_bus.allocate_framebuffer(buffersize, lcd_bus.MEMORY_INTERNAL | lcd_bus.MEMORY_DMA) +fb2 = display_bus.allocate_framebuffer(buffersize, lcd_bus.MEMORY_INTERNAL | lcd_bus.MEMORY_DMA) + +# Quick and dirty LCD reset using the CH32 microcontroller +ADDRESS = 0x50 +expander_i2c = I2C(sda=Pin(39), scl=Pin(42), freq=400000) +expander_i2c.writeto_mem(ADDRESS, 22, b'\x01') # 3v3 aux on + LCD off +import time +time.sleep_ms(200) +expander_i2c.writeto_mem(ADDRESS, 22, b'\x03') # 3v3 aux + LCD on + +# see ./lvgl_micropython/api_drivers/py_api_drivers/frozen/display/display_driver_framework.py +mpos.ui.main_display = st7789.ST7789( + data_bus=display_bus, + frame_buffer1=fb1, + frame_buffer2=fb2, + display_width=240, + display_height=320, + color_space=lv.COLOR_FORMAT.RGB565, + color_byte_order=st7789.BYTE_ORDER_BGR, + rgb565_byte_swap=True, + # reset_pin = driven by the CH32 microcontroller +) + +mpos.ui.main_display.init() +mpos.ui.main_display.set_power(True) +mpos.ui.main_display.set_backlight(100) +mpos.ui.main_display.set_color_inversion(True) + +# Touch handling: +# touch pad interrupt TP Int is on ESP.IO13 +import drivers.indev.cst816s as cst816s +i2c_bus = i2c.I2C.Bus(host=0, scl=18, sda=9, freq=400000, use_locks=False) +touch_dev = i2c.I2C.Device(bus=i2c_bus, dev_id=0x15, reg_bits=8) +try: + tindev=cst816s.CST816S(touch_dev,startup_rotation=lv.DISPLAY_ROTATION._180) # button in top left, good + InputManager.register_indev(tindev) +except Exception as e: + print(f"Touch screen init got exception: {e}") + +lv.init() +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 + +# Button handling code: +import time + +btn_start = Pin(0, Pin.IN, Pin.PULL_UP) # START + +# Key repeat configuration +# This whole debounce logic is only necessary because LVGL 9.2.2 seems to have an issue where +# the lv_keyboard widget doesn't handle PRESSING (long presses) properly, it loses focus. +REPEAT_INITIAL_DELAY_MS = 300 # Delay before first repeat +REPEAT_RATE_MS = 100 # Interval between repeats +last_key = None +last_state = lv.INDEV_STATE.RELEASED +key_press_start = 0 # Time when key was first pressed +last_repeat_time = 0 # Time of last repeat event + +# Read callback +# Warning: This gets called several times per second, and if it outputs continuous debugging on the serial line, +# that will break tools like mpremote from working properly to upload new files over the serial line, thus needing a reflash. +def keypad_read_cb(indev, data): + global last_key, last_state, key_press_start, last_repeat_time + data.continue_reading = False + since_last_repeat = 0 + + # Check buttons + current_key = None + current_time = time.ticks_ms() + + # Check buttons + if btn_start.value() == 0: + current_key = lv.KEY.END + + # Key repeat logic + if current_key: + if current_key != last_key: + # New key press + data.key = current_key + data.state = lv.INDEV_STATE.PRESSED + last_key = current_key + last_state = lv.INDEV_STATE.PRESSED + key_press_start = current_time + last_repeat_time = current_time + else: # same key + # Key held: Check for repeat + elapsed = time.ticks_diff(current_time, key_press_start) + since_last_repeat = time.ticks_diff(current_time, last_repeat_time) + if elapsed >= REPEAT_INITIAL_DELAY_MS and since_last_repeat >= REPEAT_RATE_MS: + # Send a new PRESSED/RELEASED pair for repeat + data.key = current_key + data.state = lv.INDEV_STATE.PRESSED if last_state == lv.INDEV_STATE.RELEASED else lv.INDEV_STATE.RELEASED + last_state = data.state + last_repeat_time = current_time + else: + # No repeat yet, send RELEASED to avoid PRESSING + data.state = lv.INDEV_STATE.RELEASED + last_state = lv.INDEV_STATE.RELEASED + else: + # No key pressed + data.key = last_key if last_key else lv.KEY.ENTER + data.state = lv.INDEV_STATE.RELEASED + last_key = None + last_state = lv.INDEV_STATE.RELEASED + key_press_start = 0 + last_repeat_time = 0 + + # Handle ESC for back navigation (only on initial PRESSED) + if last_state == lv.INDEV_STATE.PRESSED: + if current_key == lv.KEY.ESC and since_last_repeat == 0: + mpos.ui.back_screen() + elif current_key == lv.KEY.RIGHT: + mpos.ui.focus_direction.move_focus_direction(90) + elif current_key == lv.KEY.LEFT: + mpos.ui.focus_direction.move_focus_direction(270) + elif current_key == lv.KEY.UP: + mpos.ui.focus_direction.move_focus_direction(0) + elif current_key == lv.KEY.DOWN: + mpos.ui.focus_direction.move_focus_direction(180) + +group = lv.group_create() +group.set_default() + +# Create and set up the input device +indev = lv.indev_create() +indev.set_type(lv.INDEV_TYPE.KEYPAD) +indev.set_read_cb(keypad_read_cb) +indev.set_group(group) # is this needed? maybe better to move the default group creation to main.py so it's available everywhere... +disp = lv.display_get_default() +indev.set_display(disp) # different from display +indev.enable(True) +InputManager.register_indev(indev) + +import mpos.sdcard +mpos.sdcard.init(spi_bus=spi_bus, cs_pin=14) + +# === AUDIO HARDWARE === +from mpos import AudioManager + +# 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 + +# recording and playback at the same time: +# - no issue for the headset +# - communicator: must be same sample rate because shared sck 17 BUT this will result in feedback, probably +# fix: playback headset speaker, record communicator microphone: should work +# fix: playback communicator speaker, record headset microphone: should work +# TODO: +# - revamp to multiple audio framework so all 4 items can be defined: 2 speakers (hss, cs) and 2 microphones (hsm, cm) +# - try each 4 of the items separately: hss, hsm, cs, cm +# - try trivial combinations: hss + hsm, cs + cm +# - try similar combinations: hss + cs, cm + hsm +# - try cross combinations: hss + cm, cs + hsm + +i2s_output_pins = { + 'ws': 47, # Word Select / LRCLK shared between DAC and mic (mandatory) + 'sd': 16, # Serial Data OUT (speaker/DAC) + 'sck': 17, # SCLK aka BCLK (appears mandatory) BUT this pin is sck_in on the communicator + 'mck': 2, # MCLK (mandatory) BUT this pin is sck on the communicator +} + +speaker_output = AudioManager.add( + AudioManager.Output( + name="speaker", + kind="i2s", + i2s_pins=i2s_output_pins, + ) +) + +buzzer_output = AudioManager.add( + AudioManager.Output( + name="buzzer", + kind="buzzer", + buzzer_pin=38, + ) +) + +# ADC microphone is on GPIO 1 +mic_input = AudioManager.add( + AudioManager.Input( + name="mic", + kind="adc", + adc_mic_pin=1, + ) +) + +# === SENSOR HARDWARE === +from mpos import SensorManager +# Create I2C bus for IMU (LSM6DSOTR-C / LSM6DSO) +SensorManager.init(i2c_bus, address=0x6A, mounted_position=SensorManager.FACING_EARTH) + +# === LED HARDWARE === +import mpos.lights as LightsManager +# Initialize 5 NeoPixel LEDs (GPIO 12) +LightsManager.init(neopixel_pin=12, num_leds=5) + +print("Fri3d hardware: Audio, LEDs, and sensors initialized") + +# === STARTUP "WOW" EFFECT === +import time +import _thread + +def startup_wow_effect(): + 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" + #startup_jingle = "ShortBeeps:d=32,o=5,b=320:c6,c7" + + # Start the jingle + player = AudioManager.player( + rtttl=startup_jingle, + stream_type=AudioManager.STREAM_NOTIFICATION, + volume=60, + output=buzzer_output, + ) + player.start() + + # 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}") + +from mpos import TaskManager +_thread.stack_size(TaskManager.good_stack_size()) # default stack size won't work, crashes! +_thread.start_new_thread(startup_wow_effect, ()) + +print("fri3d_2026.py finished") diff --git a/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py b/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py new file mode 100644 index 00000000..4c9e115b --- /dev/null +++ b/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py @@ -0,0 +1,214 @@ +# LilyGo T-Display non-touch edition + +print("lilygo_t_display_s3.py running") + +import lcd_bus +import lvgl as lv +import machine +import time + +print("lilygo_t_display_s3.py display bus initialization") +try: + display_bus = lcd_bus.I80Bus( + dc=7, + wr=8, + cs=6, + data0=39, + data1=40, + data2=41, + data3=42, + data4=45, + data5=46, + data6=47, + data7=48, + #reverse_color_bits=False # doesnt seem to do anything? + ) +except Exception as e: + print(f"Error initializing display bus: {e}") + print("Attempting hard reset in 3sec...") + time.sleep(3) + machine.reset() + +_BUFFER_SIZE = const(320 * 170 * 2 + 1) # + 1 is needed to avoid render_mode = lv.DISPLAY_RENDER_MODE.FULL which is buggy +fb1 = display_bus.allocate_framebuffer(_BUFFER_SIZE, lcd_bus.MEMORY_INTERNAL | lcd_bus.MEMORY_DMA) + +import drivers.display.st7789 as st7789 +import mpos.ui +mpos.ui.main_display = st7789.ST7789( + data_bus=display_bus, + frame_buffer1=fb1, + # frame_buffer_2 doesn't seem to improve anything + display_width=170, + display_height=320, + color_space=lv.COLOR_FORMAT.RGB565, + # color_space=lv.COLOR_FORMAT.RGB888, # not supported on qemu + color_byte_order=st7789.BYTE_ORDER_BGR, + # rgb565_byte_swap=False, # always False is data_bus.get_lane_count() == 8 + power_pin=9, # Must set RD pin to high, otherwise blank screen as soon as LVGL's task_handler starts + reset_pin=5, + reset_state=st7789.STATE_LOW, # needs low: high will not enable the display + backlight_pin=38, # needed + backlight_on_state=st7789.STATE_PWM, + offset_x=0, + offset_y=35 +) # this will trigger lv.init() +mpos.ui.main_display.set_power(True) # set RD pin to high before the rest, otherwise garbled output +mpos.ui.main_display.init() +mpos.ui.main_display.set_backlight(100) # works + +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_rotation(lv.DISPLAY_ROTATION._180) # doesnt suffer from the qemu full buffer issue +mpos.ui.main_display.set_color_inversion(True) + +# Button handling code: +from machine import Pin +btn_a = Pin(0, Pin.IN, Pin.PULL_UP) +btn_b = Pin(14, Pin.IN, Pin.PULL_UP) + +# Key repeat configuration +# This whole debounce logic is only necessary because LVGL 9.2.2 seems to have an issue where +# the lv_keyboard widget doesn't handle PRESSING (long presses) properly, it loses focus. +REPEAT_INITIAL_DELAY_MS = 300 # Delay before first repeat +REPEAT_RATE_MS = 100 # Interval between repeats +REPEAT_PREV_BECOMES_BACK = 700 # Long previous press becomes back button +COMBO_GRACE_MS = 60 # Accept near-simultaneous A+B as ENTER +last_key = None +last_state = lv.INDEV_STATE.RELEASED +key_press_start = 0 # Time when key was first pressed +last_repeat_time = 0 # Time of last repeat event +last_a_down_time = 0 +last_b_down_time = 0 +last_a_pressed = False +last_b_pressed = False + +# Read callback +# Warning: This gets called several times per second, and if it outputs continuous debugging on the serial line, +# that will break tools like mpremote from working properly to upload new files over the serial line, thus needing a reflash. +def keypad_read_cb(indev, data): + global last_key, last_state, key_press_start, last_repeat_time, last_a_down_time, last_b_down_time + global last_a_pressed, last_b_pressed + + # Check buttons + current_time = time.ticks_ms() + btn_a_pressed = btn_a.value() == 0 + btn_b_pressed = btn_b.value() == 0 + if btn_a_pressed and not last_a_pressed: + last_a_down_time = current_time + if btn_b_pressed and not last_b_pressed: + last_b_down_time = current_time + last_a_pressed = btn_a_pressed + last_b_pressed = btn_b_pressed + + near_simul = False + if btn_a_pressed and btn_b_pressed: + near_simul = True + elif btn_a_pressed and last_b_down_time and time.ticks_diff(current_time, last_b_down_time) <= COMBO_GRACE_MS: + near_simul = True + elif btn_b_pressed and last_a_down_time and time.ticks_diff(current_time, last_a_down_time) <= COMBO_GRACE_MS: + near_simul = True + + single_press_wait = False + if btn_a_pressed ^ btn_b_pressed: + if btn_a_pressed and time.ticks_diff(current_time, last_a_down_time) < COMBO_GRACE_MS: + single_press_wait = True + elif btn_b_pressed and time.ticks_diff(current_time, last_b_down_time) < COMBO_GRACE_MS: + single_press_wait = True + + if near_simul or single_press_wait: + dt_a = time.ticks_diff(current_time, last_a_down_time) if last_a_down_time else None + dt_b = time.ticks_diff(current_time, last_b_down_time) if last_b_down_time else None + #print(f"combo guard: a={btn_a_pressed} b={btn_b_pressed} near={near_simul} wait={single_press_wait} dt_a={dt_a} dt_b={dt_b}") + + # While in an on-screen keyboard, PREV button is LEFT and NEXT button is RIGHT + focus_group = lv.group_get_default() + focus_keyboard = False + if focus_group: + current_focused = focus_group.get_focused() + if isinstance(current_focused, lv.keyboard): + #print("focus is on a keyboard") + focus_keyboard = True + + if near_simul: + current_key = lv.KEY.ENTER + elif single_press_wait: + current_key = None + elif btn_a_pressed: + if focus_keyboard: + current_key = lv.KEY.LEFT + else: + current_key = lv.KEY.PREV + elif btn_b_pressed: + if focus_keyboard: + current_key = lv.KEY.RIGHT + else: + current_key = lv.KEY.NEXT + else: + current_key = None + + if current_key is None: + # No key pressed + data.key = last_key if last_key else -1 + data.state = lv.INDEV_STATE.RELEASED + last_key = None + last_state = lv.INDEV_STATE.RELEASED + key_press_start = 0 + last_repeat_time = 0 + elif last_key is None or current_key != last_key: + #print(f"New key press: {current_key}") + data.key = current_key + data.state = lv.INDEV_STATE.PRESSED + last_key = current_key + last_state = lv.INDEV_STATE.PRESSED + key_press_start = current_time + last_repeat_time = current_time + else: + #print(f"key repeat because current_key {current_key} == last_key {last_key}") + elapsed = time.ticks_diff(current_time, key_press_start) + since_last_repeat = time.ticks_diff(current_time, last_repeat_time) + if elapsed >= REPEAT_INITIAL_DELAY_MS and since_last_repeat >= REPEAT_RATE_MS: + next_state = lv.INDEV_STATE.PRESSED if last_state == lv.INDEV_STATE.RELEASED else lv.INDEV_STATE.RELEASED + if current_key == lv.KEY.PREV: + #print("Repeated PREV does not do anything, instead it triggers ESC (back) if long enough") + if since_last_repeat > REPEAT_PREV_BECOMES_BACK: + print("Long press on PREV triggered back button") + data.key = lv.KEY.ESC + data.state = next_state + last_key = current_key + last_state = data.state + last_repeat_time = current_time + else: + #print("repeat PREV ignored because not pressed long enough") + pass + else: + #print("Send a new PRESSED/RELEASED pair for repeat") + data.key = current_key + data.state = next_state + last_key = current_key + last_state = data.state + last_repeat_time = current_time + else: + # This doesn't seem to make the key navigation in on-screen keyboards work, unlike on the m5stack_fire...? + #print("No repeat yet, send RELEASED to avoid PRESSING, which breaks keyboard navigation...") + data.state = lv.INDEV_STATE.RELEASED + last_state = lv.INDEV_STATE.RELEASED + + # Handle ESC for back navigation (only on initial PRESSED) + if data.state == lv.INDEV_STATE.PRESSED and data.key == lv.KEY.ESC: + mpos.ui.back_screen() + + +group = lv.group_create() +group.set_default() + +# Create and set up the input device +indev = lv.indev_create() +indev.set_type(lv.INDEV_TYPE.KEYPAD) +indev.set_read_cb(keypad_read_cb) +indev.set_group(group) # is this needed? maybe better to move the default group creation to main.py so it's available everywhere... +disp = lv.display_get_default() # NOQA +indev.set_display(disp) # different from display +indev.enable(True) # NOQA +from mpos import InputManager +InputManager.register_indev(indev) + +print("lilygo_t_display_s3.py finished") diff --git a/internal_filesystem/lib/mpos/board/lilygo_t_hmi.py b/internal_filesystem/lib/mpos/board/lilygo_t_hmi.py new file mode 100644 index 00000000..62e6705f --- /dev/null +++ b/internal_filesystem/lib/mpos/board/lilygo_t_hmi.py @@ -0,0 +1,132 @@ +print("lilygo_t_hmi.py initialization") +# Manufacturer: https://lilygo.cc/en-us/products/t-hmi +# Hardware reference: https://www.tinytronics.nl/en/development-boards/microcontroller-boards/with-wi-fi/lilygo-t-hmi-esp32-s3-2.8-inch-ips-tft-display-met-touchscreen +# Vendor repository: https://github.com/Xinyuan-LilyGO/T-HMI + + +# --- POWER HOLD --- +from machine import Pin + +Pin(10, Pin.OUT, value=1) +Pin(14, Pin.OUT, value=1) + +import lcd_bus +import machine +from drivers.indev.xpt2046 import XPT2046 + +import mpos.ui + +import lvgl as lv + +from machine import Pin +from micropython import const +from mpos import BatteryManager + +# display settings +_WIDTH = const(240) +_HEIGHT = const(320) +_BL = const(38) +_RST = -1 +_CS = const(6) +_DC = const(7) +_WR = const(8) +_FREQ = const(20000000) +_DATA0 = const(48) +_DATA1 = const(47) +_DATA2 = const(39) +_DATA3 = const(40) +_DATA4 = const(41) +_DATA5 = const(42) +_DATA6 = const(45) +_DATA7 = const(46) +_BATTERY_PIN = const(5) + +_TOUCH_CS = const(2) + +_BUFFER_SIZE = const(28800) + +display_bus = lcd_bus.I80Bus( + dc=_DC, + wr=_WR, + cs=_CS, + data0=_DATA0, + data1=_DATA1, + data2=_DATA2, + data3=_DATA3, + data4=_DATA4, + data5=_DATA5, + data6=_DATA6, + data7=_DATA7 +) + +fb1 = display_bus.allocate_framebuffer(_BUFFER_SIZE, lcd_bus.MEMORY_SPIRAM) +fb2 = display_bus.allocate_framebuffer(_BUFFER_SIZE, lcd_bus.MEMORY_SPIRAM) + +import drivers.display.st7789 as st7789 + +mpos.ui.main_display = st7789.ST7789( + data_bus=display_bus, + frame_buffer1=fb1, + frame_buffer2=fb2, + display_width=_WIDTH, + display_height=_HEIGHT, + backlight_pin=_BL, + color_byte_order=st7789.BYTE_ORDER_RGB, + rgb565_byte_swap=False, +) + +spi_bus = machine.SPI.Bus( + host=2, + mosi=3, + miso=4, + sck=1 +) + +touch_dev = machine.SPI.Device( + spi_bus=spi_bus, + freq=const(1000000), + cs=_TOUCH_CS +) + +indev = XPT2046( + touch_dev, + lcd_cs=_CS, + touch_cs=_TOUCH_CS, + display_width=_WIDTH, + display_height=_HEIGHT, + startup_rotation=lv.DISPLAY_ROTATION._0 +) + +mpos.ui.main_display.init() +mpos.ui.main_display.set_color_inversion(False) +mpos.ui.main_display.set_backlight(100) +mpos.ui.main_display.set_rotation(lv.DISPLAY_ROTATION._0) # must be done after initializing display and creating the touch drivers, to ensure proper handling + +lv.init() + +print("lilygo_t_hmi.py SDCard initialization...") + +# Initialize SD card in SDIO mode +from mpos import sdcard +sdcard.init(cmd_pin=11,clk_pin=12,d0_pin=13) + +print("lilygo_t_hmi.py Battery initialization...") + + +def adc_to_voltage(raw_adc_value): + """ + The percentage calculation uses MIN_VOLTAGE = 3.15 and MAX_VOLTAGE = 4.15 + 0% at 3.15V -> raw_adc_value = 210 + 100% at 4.15V -> raw_adc_value = 310 + + 4.15 - 3.15 = 1V + 310 - 210 = 100 raw ADC steps + + So each raw ADC step is 1V / 100 = 0.01V + Offset calculation: + """ + return raw_adc_value * 0.001651 + 0.08709 + +BatteryManager.init_adc(_BATTERY_PIN, adc_to_voltage) + +print("lilygo_t_hmi.py finished") \ No newline at end of file diff --git a/internal_filesystem/lib/mpos/board/lilygo_t_watch_s3_plus.py b/internal_filesystem/lib/mpos/board/lilygo_t_watch_s3_plus.py new file mode 100644 index 00000000..87557cdc --- /dev/null +++ b/internal_filesystem/lib/mpos/board/lilygo_t_watch_s3_plus.py @@ -0,0 +1,115 @@ +print("lilygo_t_watch_s3_plus.py initialization") +# Manufacturer's website at https://lilygo.cc/products/t-watch-s3-plus +import lcd_bus +import machine + +import lvgl as lv +import task_handler + +import mpos.ui + +spi_bus = machine.SPI.Bus( + host=2, + mosi=13, + sck=18 +) +display_bus = lcd_bus.SPIBus( + spi_bus=spi_bus, + freq=40000000, + dc=38, + cs=12, +) + +_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) + +import drivers.display.st7789 as st7789 +mpos.ui.main_display = st7789.ST7789( + data_bus=display_bus, + frame_buffer1=fb1, + frame_buffer2=fb2, + display_width=240, + display_height=240, + color_space=lv.COLOR_FORMAT.RGB565, + color_byte_order=st7789.BYTE_ORDER_BGR, + rgb565_byte_swap=True, + backlight_pin=45, + backlight_on_state=st7789.STATE_PWM, + offset_y=80 +) # triggers lv.init() +mpos.ui.main_display.init() +mpos.ui.main_display.set_power(True) +mpos.ui.main_display.set_backlight(100) + +import i2c +import drivers.indev.ft6x36 as ft6x36 +i2c_bus = i2c.I2C.Bus(host=0, sda=39, scl=40, freq=400000, use_locks=False) +touch_dev = i2c.I2C.Device(bus=i2c_bus, dev_id=ft6x36.I2C_ADDR, reg_bits=ft6x36.BITS) +import pointer_framework +indev = ft6x36.FT6x36(touch_dev, startup_rotation=pointer_framework.lv.DISPLAY_ROTATION._180) +from mpos import InputManager +InputManager.register_indev(indev) + +mpos.ui.main_display.set_rotation(lv.DISPLAY_ROTATION._180) + +# Audio: +from mpos import AudioManager +i2s_output_pins = { + 'ws': 15, # Word Select / LRCLK shared between DAC and mic (mandatory) + 'sck': 48, # SCLK or BCLK - Bit Clock for DAC output (mandatory) + 'sd': 46, # Serial Data OUT (speaker/DAC) +} +speaker_output = AudioManager.add( + AudioManager.Output( + name="speaker", + kind="i2s", + i2s_pins=i2s_output_pins, + ) +) + +i2s_input_pins = { + 'ws': 15, # Word Select / LRCLK shared between DAC and mic (mandatory) + 'sck_in': 44, # SCLK - Serial Clock for microphone input + 'sd_in': 47, # DIN - Serial Data IN (microphone) +} +mic_input = AudioManager.add( + AudioManager.Input( + name="mic", + kind="i2s", + i2s_pins=i2s_input_pins, + ) +) + +# Vibrator test + +# One strong & fairly long buzz (repeat as needed) +from machine import I2C, Pin +i2c = I2C(1, sda=Pin(10), scl=Pin(11), freq=400000) + +def write_reg(reg, val): + i2c.writeto_mem(0x5A, reg, bytes([val])) + +write_reg(0x01, 0x00) # internal trigger +write_reg(0x03, 0) # Library A +write_reg(0x04, 47) # Strong Buzz 100% +write_reg(0x0C, 1) # GO +import time +time.sleep(1) # ~0.8s strong buzz +write_reg(0x0C, 0) # stop (optional) + +# IMU: +import drivers.imu_sensor.bma423.bma423 as bma423 +sensor = bma423.BMA423(i2c, address=0x19) +time.sleep_ms(500) # some sleep is needed before reading values +print("temperature: ", sensor.get_temperature()) +print("steps: ", sensor.get_steps()) +print("(x,y,z): ", sensor.get_xyz()) + +# TODO: +# - battery +# - real IMU driver (instead of proof-of-concept above) +# - GPS +# - LoRa + +print("lilygo_t_watch_s3_plus.py finished") diff --git a/internal_filesystem/lib/mpos/board/linux.py b/internal_filesystem/lib/mpos/board/linux.py index 0b055568..7cc72bb8 100644 --- a/internal_filesystem/lib/mpos/board/linux.py +++ b/internal_filesystem/lib/mpos/board/linux.py @@ -3,10 +3,12 @@ import lvgl as lv import sdl_display +from drivers.indev.sdl_keyboard import MposSDLKeyboard + import mpos.clipboard -import mpos.indev.mpos_sdl_keyboard import mpos.ui import mpos.ui.focus_direction +from mpos import InputManager # Same as Waveshare ESP32-S3-Touch-LCD-2 and Fri3d Camp 2026 Badge TFT_HOR_RES=320 @@ -16,9 +18,13 @@ #TFT_HOR_RES=296 #TFT_VER_RES=240 -# Bigger screen -#TFT_HOR_RES=640 -#TFT_VER_RES=480 +# Makerfabs / Matouch +#TFT_HOR_RES=240 +#TFT_VER_RES=320 + +# LilyGo T-Display-S3 +#TFT_HOR_RES=320 +#TFT_VER_RES=170 # 4:3 DVD resolution: #TFT_HOR_RES=720 @@ -44,9 +50,10 @@ # 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() +InputManager.register_indev(mouse) + def catch_escape_key(indev, indev_data): global sdlkeyboard @@ -71,14 +78,15 @@ def catch_escape_key(indev, indev_data): sdlkeyboard._read(indev, indev_data) -#import sdl_keyboard -sdlkeyboard = mpos.indev.mpos_sdl_keyboard.MposSDLKeyboard() +sdlkeyboard = MposSDLKeyboard() sdlkeyboard._indev_drv.set_read_cb(catch_escape_key) # check for escape +InputManager.register_indev(sdlkeyboard) try: sdlkeyboard.set_paste_text_callback(mpos.clipboard.paste_text) except Exception as e: print("Warning: could not set paste_text callback for sdlkeyboard, copy-paste won't work") + #def keyboard_cb(event): # global canvas # event_code=event.get_code() @@ -87,36 +95,76 @@ def catch_escape_key(indev, indev_data): # Simulated battery voltage ADC measuring -import mpos.battery_voltage +from mpos import BatteryManager 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) +BatteryManager.init_adc(999, adc_to_voltage) # === AUDIO HARDWARE === -import mpos.audio.audioflinger as AudioFlinger - -# Note: Desktop builds have no audio hardware -# AudioFlinger functions will return False (no-op) -AudioFlinger.init( - device_type=AudioFlinger.DEVICE_NULL, - i2s_pins=None, - buzzer_instance=None -) +from mpos import AudioManager + +# 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 microphone simulation + +output_i2s_pins = { + 'sck': 0, # Simulated - not used on desktop + 'ws': 0, # Simulated - not used on desktop + 'sd': 0, # Simulated - not used on desktop +} +input_i2s_pins = { + 'sck_in': 0, # Simulated - not used on desktop + 'ws': 0, # Simulated - not used on desktop + 'sd_in': 0, # Simulated - enables microphone simulation +} + +AudioManager.add(AudioManager.Output("speaker", "i2s", i2s_pins=output_i2s_pins)) +AudioManager.add(AudioManager.Input("mic", "i2s", i2s_pins=input_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 +from mpos import SensorManager + +SensorManager.init_iio() + +# === CAMERA HARDWARE === + +def init_cam(width, height, colormode): + try: + # Try to initialize webcam to verify it's available + import webcam + return webcam.init("/dev/video0", width=width, height=height) + except Exception as e: + print(f"Info: webcam initialization failed, camera will not be available: {e}") + +def deinit_cam(cam_obj): + import webcam + webcam.deinit(cam_obj) + +def capture_cam(cam_obj, colormode): + import webcam + return webcam.capture_frame(cam_obj, "rgb565" if colormode else "grayscale") + +def apply_cam_settings(cam_obj, prefs): + print("V4L Camera doesn't support settings for now, skipping...") + +from mpos import CameraManager +CameraManager.add_camera(CameraManager.Camera( + lens_facing=CameraManager.CameraCharacteristics.LENS_FACING_FRONT, + name="Video4Linux2 Camera", + vendor="ACME", + init=init_cam, + deinit=deinit_cam, + capture=capture_cam, + apply_settings=apply_cam_settings +)) -# 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, mounted_position=SensorManager.FACING_EARTH) print("linux.py finished") diff --git a/internal_filesystem/lib/mpos/board/m5stack_core2.py b/internal_filesystem/lib/mpos/board/m5stack_core2.py new file mode 100644 index 00000000..e4368ffa --- /dev/null +++ b/internal_filesystem/lib/mpos/board/m5stack_core2.py @@ -0,0 +1,178 @@ +# Hardware initialization for M5Stack Core2 +# Manufacturer's website at https://docs.m5stack.com/en/core/core2 +# ESP32-D0WDQ6-V3, 16MB Flash, 8MB PSRAM +# Display: ILI9342C 320x240 SPI +# Touch: FT6336U (I2C 0x38) +# Power: AXP192 (I2C 0x34) +# Speaker: NS4168 (I2S) +# Mic: SPM1423 (PDM, CLK=0, DATA=34) +# IMU: MPU6886 (I2C 0x68) + +import time + +import drivers.display.ili9341 as ili9341 +import lcd_bus +import lvgl as lv +import machine +import mpos.ui +from machine import I2C, Pin +from micropython import const +from mpos import AudioManager, InputManager, SensorManager + +# I2C bus (shared: AXP192, Touch, IMU, RTC) +I2C_SDA = const(21) +I2C_SCL = const(22) +I2C_FREQ = const(400000) + +# Display settings (SPI) +SPI_BUS = const(1) # SPI2 +SPI_FREQ = const(40000000) +LCD_SCLK = const(18) +LCD_MOSI = const(23) +LCD_DC = const(15) +LCD_CS = const(5) +LCD_TYPE = const(2) # ILI9341 type 2 + +TFT_HOR_RES = const(320) +TFT_VER_RES = const(240) + +# I2S Speaker (NS4168) +I2S_BCLK = const(12) +I2S_LRCK = const(0) +I2S_DATA_OUT = const(2) + +# Mic (SPM1423 PDM) +MIC_CLK = const(0) +MIC_DATA = const(34) + +# IMU +MPU6886_ADDR = const(0x68) + +# ============================== +# Step 1: AXP192 Power Management +# ============================== +print("m5stack_core2.py init AXP192 power management") +# All I2C devices (AXP192, Touch, IMU) share one bus on host 0 +i2c_bus = I2C(0, scl=Pin(I2C_SCL), sda=Pin(I2C_SDA), freq=I2C_FREQ) + +from drivers.power.axp192 import AXP192 +axp = AXP192(i2c_bus) +axp.init_core2() + +# ============================== +# Step 2: Display (ILI9342C via SPI) +# ============================== +print("m5stack_core2.py init SPI display") +try: + spi_bus = machine.SPI.Bus(host=SPI_BUS, mosi=LCD_MOSI, sck=LCD_SCLK) +except Exception as e: + print(f"Error initializing SPI bus: {e}") + print("Attempting hard reset in 3sec...") + time.sleep(3) + machine.reset() + +display_bus = lcd_bus.SPIBus(spi_bus=spi_bus, freq=SPI_FREQ, dc=LCD_DC, cs=LCD_CS) + + +# M5Stack Core2 uses ILI9342C with same orientation table as Fire +class ILI9341(ili9341.ILI9341): + _ORIENTATION_TABLE = ( + 0x00, + 0x40 | 0x20, # _MADCTL_MX | _MADCTL_MV + 0x80 | 0x40, # _MADCTL_MY | _MADCTL_MX + 0x80 | 0x20, # _MADCTL_MY | _MADCTL_MV + ) + + +# Note: LCD reset and backlight are handled by AXP192 (GPIO4=reset, DCDC3=backlight) +# No reset_pin or backlight_pin needed here +mpos.ui.main_display = ILI9341( + data_bus=display_bus, + display_width=TFT_HOR_RES, + display_height=TFT_VER_RES, + color_space=lv.COLOR_FORMAT.RGB565, + color_byte_order=ili9341.BYTE_ORDER_BGR, + rgb565_byte_swap=True, +) +mpos.ui.main_display.init(LCD_TYPE) +mpos.ui.main_display.set_power(True) +mpos.ui.main_display.set_color_inversion(True) + +lv.init() + +# ============================== +# Step 3: Touch (FT6336U) +# ============================== +print("m5stack_core2.py init touch (FT6336U)") +import i2c as i2c_lvgl +import drivers.indev.ft6x36 as ft6x36 +import pointer_framework + +# Create LVGL I2C bus wrapper, then replace its internal bus with our shared I2C(0) +# instance so all devices (AXP192, touch, IMU) share the same hardware I2C controller. +touch_i2c_bus = i2c_lvgl.I2C.Bus(host=0, sda=I2C_SDA, scl=I2C_SCL, freq=I2C_FREQ, use_locks=False) +touch_i2c_bus._bus = i2c_bus + +touch_dev = i2c_lvgl.I2C.Device(bus=touch_i2c_bus, dev_id=ft6x36.I2C_ADDR, reg_bits=ft6x36.BITS) +indev = ft6x36.FT6x36(touch_dev, startup_rotation=pointer_framework.lv.DISPLAY_ROTATION._0) +InputManager.register_indev(indev) + +# ============================== +# Step 4: Audio (I2S Speaker + PDM Mic) +# ============================== +print("m5stack_core2.py init audio") + +# I2S speaker output (NS4168, enabled via AXP192 GPIO2) +i2s_output_pins = { + 'ws': I2S_LRCK, + 'sck': I2S_BCLK, + 'sd': I2S_DATA_OUT, +} +AudioManager.add( + AudioManager.Output( + name="speaker", + kind="i2s", + i2s_pins=i2s_output_pins, + ) +) +AudioManager.set_volume(40) + +# PDM microphone input (SPM1423) +i2s_input_pins = { + 'ws': MIC_CLK, + 'sd_in': MIC_DATA, +} +AudioManager.add( + AudioManager.Input( + name="mic", + kind="i2s", + i2s_pins=i2s_input_pins, + ) +) + +# TODO: add startup sound (RTTTL not supported via I2S, needs WAV file) + +# ============================== +# Step 5: IMU (MPU6886) +# ============================== +print("m5stack_core2.py init IMU") +SensorManager.init( + i2c_bus=i2c_bus, + address=MPU6886_ADDR, + mounted_position=SensorManager.FACING_EARTH, +) + +# ============================== +# Step 6: Battery (via AXP192) +# ============================== +print("m5stack_core2.py init battery monitoring") +from mpos import BatteryManager + +def axp_adc_to_voltage(adc_value): + """Read battery voltage from AXP192 instead of ADC pin.""" + return axp.get_battery_voltage() + +# Use a dummy pin (35) - the actual reading comes from axp via the conversion function +BatteryManager.init_adc(35, axp_adc_to_voltage) + +print("m5stack_core2.py finished") diff --git a/internal_filesystem/lib/mpos/board/m5stack_fire.py b/internal_filesystem/lib/mpos/board/m5stack_fire.py new file mode 100644 index 00000000..6c95bf09 --- /dev/null +++ b/internal_filesystem/lib/mpos/board/m5stack_fire.py @@ -0,0 +1,200 @@ +# Hardware initialization for ESP32 M5Stack-Fire board +# Manufacturer's website at https://https://docs.m5stack.com/en/core/fire_v2.7 +# Original author: https://github.com/ancebfer +import time + +import drivers.display.ili9341 as ili9341 +import lcd_bus +import lvgl as lv +import machine +import mpos.ui +import mpos.ui.focus_direction +from machine import I2C, PWM, Pin +from micropython import const +from mpos import AudioManager, InputManager, SensorManager + +# Display settings: +SPI_BUS = const(1) # SPI2 +SPI_FREQ = const(40000000) + +LCD_SCLK = const(18) +LCD_MOSI = const(23) +LCD_DC = const(27) +LCD_CS = const(14) +LCD_BL = const(32) +LCD_RST = const(33) +LCD_TYPE = const(2) # ILI9341 type 2 + +TFT_HOR_RES = const(320) +TFT_VER_RES = const(240) + +# Button settings: +BUTTON_A = const(39) # A +BUTTON_B = const(38) # B +BUTTON_C = const(37) # C + +# Misc settings: +BATTERY_PIN = const(35) + +# Buzzer +BUZZER_PIN = const(25) + +# MPU6886 Sensor settings: +MPU6886_I2C_ADDR = const(0x68) +MPU6886_I2C_SCL = const(22) +MPU6886_I2C_SDA = const(21) +MPU6886_I2C_FREQ = const(400000) + + +print("m5stack_fire.py init buzzer") +buzzer = PWM(Pin(BUZZER_PIN, Pin.OUT, value=1), duty=5) +AudioManager.add(AudioManager.Output("buzzer", "buzzer", buzzer_pin=BUZZER_PIN)) +AudioManager.set_volume(40) + +player = AudioManager.player( + rtttl="Star Trek:o=4,d=20,b=200:8f.,a#,4d#6.,8d6,a#.,g.,c6.,4f6", + stream_type=AudioManager.STREAM_NOTIFICATION, +) +player.start() +while player.is_playing(): + time.sleep(0.1) + + +print("m5stack_fire.py init IMU") +i2c_bus = I2C(0, scl=Pin(MPU6886_I2C_SCL), sda=Pin(MPU6886_I2C_SDA), freq=MPU6886_I2C_FREQ) +SensorManager.init( + i2c_bus=i2c_bus, + address=MPU6886_I2C_ADDR, + mounted_position=SensorManager.FACING_EARTH, +) + + +print("m5stack_fire.py machine.SPI.Bus() initialization") +try: + spi_bus = machine.SPI.Bus(host=SPI_BUS, mosi=LCD_MOSI, sck=LCD_SCLK) +except Exception as e: + print(f"Error initializing SPI bus: {e}") + print("Attempting hard reset in 3sec...") + time.sleep(3) + machine.reset() + + +display_bus = lcd_bus.SPIBus(spi_bus=spi_bus, freq=SPI_FREQ, dc=LCD_DC, cs=LCD_CS) + + +# M5Stack-Fire ILI9342 uses ILI9341 type 2 with a modified orientation table. +class ILI9341(ili9341.ILI9341): + _ORIENTATION_TABLE = ( + 0x00, + 0x40 | 0x20, # _MADCTL_MX | _MADCTL_MV + 0x80 | 0x40, # _MADCTL_MY | _MADCTL_MX + 0x80 | 0x20, # _MADCTL_MY | _MADCTL_MV + ) + + +mpos.ui.main_display = ILI9341( + data_bus=display_bus, + display_width=TFT_HOR_RES, + display_height=TFT_VER_RES, + color_space=lv.COLOR_FORMAT.RGB565, + color_byte_order=ili9341.BYTE_ORDER_BGR, + rgb565_byte_swap=True, + reset_pin=LCD_RST, + reset_state=ili9341.STATE_LOW, + backlight_pin=LCD_BL, + backlight_on_state=ili9341.STATE_PWM, +) +mpos.ui.main_display.init(LCD_TYPE) +mpos.ui.main_display.set_power(True) +mpos.ui.main_display.set_color_inversion(True) +mpos.ui.main_display.set_backlight(25) + +lv.init() + +# Button handling code: +btn_a = Pin(BUTTON_A, Pin.IN, Pin.PULL_UP) # A +btn_b = Pin(BUTTON_B, Pin.IN, Pin.PULL_UP) # B +btn_c = Pin(BUTTON_C, Pin.IN, Pin.PULL_UP) # C + +# Key repeat configuration +# This whole debounce logic is only necessary because LVGL 9.2.2 seems to have an issue where +# the lv_keyboard widget doesn't handle PRESSING (long presses) properly, it loses focus. +REPEAT_INITIAL_DELAY_MS = 300 # Delay before first repeat +REPEAT_RATE_MS = 100 # Interval between repeats +last_key = None +last_state = lv.INDEV_STATE.RELEASED +key_press_start = 0 # Time when key was first pressed +last_repeat_time = 0 # Time of last repeat event + +# Read callback +# Warning: This gets called several times per second, and if it outputs continuous debugging on the serial line, +# that will break tools like mpremote from working properly to upload new files over the serial line, thus needing a reflash. +def keypad_read_cb(indev, data): + global last_key, last_state, key_press_start, last_repeat_time + since_last_repeat = 0 + + # Check buttons + current_key = None + current_time = time.ticks_ms() + if btn_a.value() == 0: + current_key = lv.KEY.PREV + elif btn_b.value() == 0: + current_key = lv.KEY.ENTER + elif btn_c.value() == 0: + current_key = lv.KEY.NEXT + + if (btn_a.value() == 0) and (btn_c.value() == 0): + current_key = lv.KEY.ESC + + # Key repeat logic + if current_key: + if current_key != last_key: + # New key press + data.key = current_key + data.state = lv.INDEV_STATE.PRESSED + last_key = current_key + last_state = lv.INDEV_STATE.PRESSED + key_press_start = current_time + last_repeat_time = current_time + else: # same key + # Key held: Check for repeat + elapsed = time.ticks_diff(current_time, key_press_start) + since_last_repeat = time.ticks_diff(current_time, last_repeat_time) + if elapsed >= REPEAT_INITIAL_DELAY_MS and since_last_repeat >= REPEAT_RATE_MS: + # Send a new PRESSED/RELEASED pair for repeat + data.key = current_key + data.state = lv.INDEV_STATE.PRESSED if last_state == lv.INDEV_STATE.RELEASED else lv.INDEV_STATE.RELEASED + last_state = data.state + last_repeat_time = current_time + else: + # No repeat yet, send RELEASED to avoid PRESSING + data.state = lv.INDEV_STATE.RELEASED + last_state = lv.INDEV_STATE.RELEASED + else: + # No key pressed + data.key = last_key if last_key else lv.KEY.ENTER + data.state = lv.INDEV_STATE.RELEASED + last_key = None + last_state = lv.INDEV_STATE.RELEASED + key_press_start = 0 + last_repeat_time = 0 + + # Handle ESC for back navigation (only on initial PRESSED) + if last_state == lv.INDEV_STATE.PRESSED: + if current_key == lv.KEY.ESC and since_last_repeat == 0: + mpos.ui.back_screen() + +group = lv.group_create() +group.set_default() + +# Create and set up the input device +indev = lv.indev_create() +indev.set_type(lv.INDEV_TYPE.KEYPAD) +indev.set_read_cb(keypad_read_cb) +indev.set_group(group) # is this needed? maybe better to move the default group creation to main.py so it's available everywhere... +disp = lv.display_get_default() # NOQA +indev.set_display(disp) # different from display +indev.enable(True) # NOQA +InputManager.register_indev(indev) + +print("m5stack_fire.py finished") diff --git a/internal_filesystem/lib/mpos/board/matouch_esp32_s3_spi_ips_2_8_with_camera_ov3660.py b/internal_filesystem/lib/mpos/board/matouch_esp32_s3_spi_ips_2_8_with_camera_ov3660.py new file mode 100644 index 00000000..73578a65 --- /dev/null +++ b/internal_filesystem/lib/mpos/board/matouch_esp32_s3_spi_ips_2_8_with_camera_ov3660.py @@ -0,0 +1,215 @@ +print("matouch_esp32_s3_spi_ips_2_8_with_camera_ov3660.py initialization") +# Hardware initialization for Makerfabs MaTouch ESP32-S3 SPI 2.8" with Camera +# Manufacturer's website: https://www.makerfabs.com/matouch-esp32-s3-spi-ips-2-8-with-camera-ov3660.html +# Hardware Specifications: +# - MCU: ESP32-S3 with 16MB Flash, 8MB Octal PSRAM +# - Display: 2.8" IPS LCD, 320x240 resolution, ST7789 driver, SPI interface +# - Touch: GT911 capacitive touch controller (5-point), I2C interface +# - Camera: OV3660 (3MP, up to 2048x1536) +# - No IMU sensor (unlike Fri3d and Waveshare boards) +# - No NeoPixel LEDs +# - No buzzer or I2S audio + +from micropython import const +import drivers.display.st7789 as st7789 +import lcd_bus +import machine + +import lvgl as lv +import task_handler + +import mpos.ui + +# Pin configuration for Display (SPI) +# Correct pins from hardware schematic +SPI_BUS = 1 +SPI_FREQ = 40000000 +LCD_SCLK = 14 +LCD_MOSI = 13 +LCD_MISO = 12 +LCD_DC = 21 +LCD_CS = 15 +LCD_BL = 48 + +I2C_FREQ = 400000 + +# Display resolution +TFT_HOR_RES = 320 +TFT_VER_RES = 240 + +# Initialize SPI bus for display +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, +) + +# Allocate frame buffers +# Buffer size calculation: 2 bytes per pixel (RGB565) * width * height / divisor +# Using 28800 bytes (same as Waveshare and Fri3d) for good performance +_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) + +# Initialize ST7789 display +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 +def init_touch(): + try: + import i2c + i2c_bus = i2c.I2C.Bus(host=0, scl=38, sda=39, freq=I2C_FREQ, use_locks=False) + import drivers.indev.gt911 as gt911 + touch_dev = i2c.I2C.Device(bus=i2c_bus, dev_id=gt911.I2C_ADDR, reg_bits=gt911.BITS) + indev = gt911.GT911(touch_dev, reset_pin=1, interrupt_pin=40, debug=False) # debug makes it slower + from mpos import InputManager + InputManager.register_indev(indev) + except Exception as e: + print(f"Touch init got exception: {e}") +init_touch() + +# IO0 Button interrupt handler +def io0_interrupt_handler(pin): + print("IO0 button pressed!") + from mpos import back_screen + back_screen() + +io0_pin = machine.Pin(0, machine.Pin.IN, machine.Pin.PULL_UP) +io0_pin.irq(trigger=machine.Pin.IRQ_FALLING, handler=io0_interrupt_handler) + +# Initialize LVGL +lv.init() + +# Initialize SD card in SDIO mode +from mpos import sdcard +sdcard.init(cmd_pin=2,clk_pin=42,d0_pin=41) + +# === LED HARDWARE === +# Note: MaTouch ESP32-S3 has no NeoPixel LEDs +# LightsManager will not be initialized (functions will return False) + +# === CAMERA HARDWARE === +from mpos import CameraManager + +def init_cam(width, height, colormode): + toreturn = None + try: + from camera import Camera, GrabMode, PixelFormat, FrameSize, GainCeiling + + # Map resolution to FrameSize enum using CameraManager + frame_size = CameraManager.resolution_to_framesize(width, height) + print(f"init_internal_cam: Using FrameSize {frame_size} 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=[7,5,4,6,16,8,3,46], + vsync_pin=11, + href_pin=10, + sda_pin=39, + scl_pin=38, + pclk_pin=17, + xclk_pin=9, + xclk_freq=20000000, + powerdown_pin=-1, + reset_pin=-1, + pixel_format=PixelFormat.RGB565 if colormode else PixelFormat.GRAYSCALE, + frame_size=frame_size, + #grab_mode=GrabMode.WHEN_EMPTY, + grab_mode=GrabMode.LATEST, + fb_count=1 + ) + cam.set_vflip(True) + + toreturn=cam + break + 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}") + break + + if toreturn: + # disable and enable touch pad because camera initialization breaks it + try: + from mpos import InputManager + indev = InputManager.list_indevs()[0] + indev.enable(False) + InputManager.unregister_indev(indev) + print("input disabled") + except Exception as e: + print(f"init_cam: disabling indev got exception: {e}") + init_touch() + + except Exception as e: + print(f"init_cam exception: {e}") + + return toreturn + +def deinit_cam(cam): + cam.deinit() + # Power off, otherwise it keeps using a lot of current + try: + from machine import Pin, I2C + i2c = I2C(1, scl=Pin(38), sda=Pin(39)) # Adjust pins and frequency + camera_addr = 0x3C # for OV3660 + 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}") + import time + time.sleep_ms(100) + init_touch() + +def capture_cam(cam_obj, colormode): + return cam_obj.capture() + +def apply_cam_settings(cam_obj, prefs): + return CameraManager.ov_apply_camera_settings(cam_obj, prefs) + +# MaTouch ESP32-S3 has OV3660 camera (3MP, up to 2048x1536) +# Camera pins are available but initialization is handled by the camera driver +CameraManager.add_camera(CameraManager.Camera( + lens_facing=CameraManager.CameraCharacteristics.LENS_FACING_FRONT, + name="OV3660", + vendor="OmniVision", + init=init_cam, + deinit=deinit_cam, + capture=capture_cam, + apply_settings=apply_cam_settings +)) + +print("matouch_esp32_s3_spi_ips_2_8_with_camera_ov3660.py finished") +print("Board capabilities:") +print(" - Display: 320x240 ST7789 with GT911 touch") +print(" - Camera: OV3660 (3MP)") +print(" - No LEDs") diff --git a/internal_filesystem/lib/mpos/board/odroid_go.py b/internal_filesystem/lib/mpos/board/odroid_go.py new file mode 100644 index 00000000..70db69d7 --- /dev/null +++ b/internal_filesystem/lib/mpos/board/odroid_go.py @@ -0,0 +1,318 @@ +print("odroid_go.py initialization") + +# Hardware initialization for Hardkernel ODROID-Go +# https://github.com/hardkernel/ODROID-GO/ +# https://wiki.odroid.com/odroid_go/odroid_go +# Original author: https://github.com/jedie + +import time + +import drivers.display.ili9341 as ili9341 +import lcd_bus +import lvgl as lv +import machine +import mpos.ui +from machine import ADC, PWM, Pin +from micropython import const +from mpos import AudioManager, BatteryManager, InputManager + +# Display settings: +SPI_HOST = const(1) +SPI_FREQ = const(40000000) + +LCD_SCLK = const(18) +LCD_MOSI = const(23) +LCD_DC = const(21) +LCD_CS = const(5) +LCD_BL = const(32) +LCD_RST = const(33) +LCD_TYPE = const(2) # ILI9341 type 2 + +TFT_VER_RES = const(320) +TFT_HOR_RES = const(240) + + +# Button settings: +BUTTON_MENU = const(13) +BUTTON_VOLUME = const(0) +BUTTON_SELECT = const(27) +BUTTON_START = const(39) + +BUTTON_B = const(33) +BUTTON_A = const(32) + +# The crossbar pin numbers: +CROSSBAR_X = const(34) +CROSSBAR_Y = const(35) + + +# Misc settings: +LED_BLUE = const(2) +BATTERY_PIN = const(36) + +# Buzzer +BUZZER_PIN = const(26) +BUZZER_DAC_PIN = const(25) +BUZZER_TONE_CHANNEL = const(0) + + +print("odroid_go.py turn on blue LED") +blue_led = machine.Pin(LED_BLUE, machine.Pin.OUT) +blue_led.on() + +print("odroid_go.py init buzzer") + + +class BuzzerCallbacks: + __slots__ = ("dac_pin",) + + def __init__(self): + self.dac_pin = Pin(BUZZER_DAC_PIN, Pin.OUT, value=1) + + def unmute(self): + print("Unmute buzzer") + self.dac_pin.value(1) # Unmute + + def mute(self, unused=None): # This is used as rtttl's on_complete function, which passes a string message argument + print("Mute buzzer") + self.dac_pin.value(0) # Mute + + +buzzer_callbacks = BuzzerCallbacks() + +buzzer_output = AudioManager.add( + AudioManager.Output( + name="buzzer", + kind="buzzer", + buzzer_pin=BUZZER_PIN, + ) +) +AudioManager.set_volume(40) +player = AudioManager.player( + rtttl="Star Trek:o=4,d=20,b=200:8f.,a#,4d#6.,8d6,a#.,g.,c6.,4f6", + output=buzzer_output, + on_complete=buzzer_callbacks.mute, +) +buzzer_callbacks.unmute() +player.start() +while player.is_playing(): + time.sleep(0.1) + +print("odroid_go.py machine.SPI.Bus() initialization") +try: + spi_bus = machine.SPI.Bus(host=SPI_HOST, mosi=LCD_MOSI, sck=LCD_SCLK) +except Exception as e: + print(f"Error initializing SPI bus: {e}") + print("Attempting hard reset in 3sec...") + time.sleep(3) + machine.reset() + +print("odroid_go.py lcd_bus.SPIBus() initialization") +display_bus = lcd_bus.SPIBus(spi_bus=spi_bus, freq=SPI_FREQ, dc=LCD_DC, cs=LCD_CS) + +print("odroid_go.py ili9341.ILI9341() initialization") +try: + mpos.ui.main_display = ili9341.ILI9341( + data_bus=display_bus, + display_width=TFT_HOR_RES, + display_height=TFT_VER_RES, + color_space=lv.COLOR_FORMAT.RGB565, + color_byte_order=ili9341.BYTE_ORDER_BGR, + rgb565_byte_swap=True, + reset_pin=LCD_RST, + reset_state=ili9341.STATE_LOW, + backlight_pin=LCD_BL, + backlight_on_state=ili9341.STATE_PWM, + ) +except Exception as e: + print(f"Error initializing ILI9341: {e}") + print("Attempting hard reset in 3sec...") + time.sleep(3) + machine.reset() + +print("odroid_go.py display.init()") +mpos.ui.main_display.init(type=LCD_TYPE) +mpos.ui.main_display.set_rotation(lv.DISPLAY_ROTATION._270) +mpos.ui.main_display.set_power(True) +mpos.ui.main_display.set_color_inversion(False) +mpos.ui.main_display.set_backlight(25) + +print("odroid_go.py lv.init() initialization") +lv.init() + + +print("odroid_go.py Battery initialization...") + + +def adc_to_voltage(raw_adc_value): + """ + The percentage calculation uses MIN_VOLTAGE = 3.15 and MAX_VOLTAGE = 4.15 + 0% at 3.15V -> raw_adc_value = 210 + 100% at 4.15V -> raw_adc_value = 310 + + 4.15 - 3.15 = 1V + 310 - 210 = 100 raw ADC steps + + So each raw ADC step is 1V / 100 = 0.01V + Offset calculation: + 210 * 0.01 = 2.1V. but we want it to be 3.15V + So the offset is 3.15V - 2.1V = 1.05V + """ + voltage = raw_adc_value * 0.01 + 1.05 + return voltage + + +BatteryManager.init_adc(BATTERY_PIN, adc_to_voltage) + + +print("odroid_go.py button initialization...") + +button_menu = Pin(BUTTON_MENU, Pin.IN, Pin.PULL_UP) +button_volume = Pin(BUTTON_VOLUME, Pin.IN, Pin.PULL_UP) +button_select = Pin(BUTTON_SELECT, Pin.IN, Pin.PULL_UP) +button_start = Pin(BUTTON_START, Pin.IN, Pin.PULL_UP) # -> ENTER + +# PREV <- B | A -> NEXT +button_b = Pin(BUTTON_B, Pin.IN, Pin.PULL_UP) +button_a = Pin(BUTTON_A, Pin.IN, Pin.PULL_UP) + + +class CrossbarHandler: + # ADC values are around low: ~236 and high ~511 + # So the mid value is around (236+511)/2 = 373.5 + CROSSBAR_MIN_ADC_LOW = const(100) + CROSSBAR_MIN_ADC_MID = const(370) + + def __init__(self, pin, high_key, low_key): + self.adc = ADC(Pin(pin, mode=Pin.IN)) + self.adc.width(ADC.WIDTH_9BIT) + self.adc.atten(ADC.ATTN_11DB) + + self.high_key = high_key + self.low_key = low_key + + def poll(self): + value = self.adc.read() + if value > self.CROSSBAR_MIN_ADC_LOW: + if value > self.CROSSBAR_MIN_ADC_MID: + return self.high_key + elif value < self.CROSSBAR_MIN_ADC_MID: + return self.low_key + + +class Crossbar: + def __init__(self, *, up, down, left, right): + self.joy_x = CrossbarHandler(CROSSBAR_X, high_key=left, low_key=right) + self.joy_y = CrossbarHandler(CROSSBAR_Y, high_key=up, low_key=down) + + def poll(self): + crossbar_pressed = self.joy_x.poll() or self.joy_y.poll() + return crossbar_pressed + + +# see: internal_filesystem/lib/drivers/indev/sdl_keyboard.py +# lv.KEY.UP +# lv.KEY.LEFT - lv.KEY.RIGHT +# lv.KEY.DOWN +# +crossbar = Crossbar( + up=lv.KEY.UP, down=lv.KEY.DOWN, left=lv.KEY.LEFT, right=lv.KEY.RIGHT +) + +REPEAT_INITIAL_DELAY_MS = 300 # Delay before first repeat +REPEAT_RATE_MS = 100 # Interval between repeats +next_repeat = None # Used for auto-repeat key handling + + +def input_callback(indev, data): + global next_repeat + + current_key = None + + if crossbar_pressed := crossbar.poll(): + current_key = crossbar_pressed + + elif button_menu.value() == 0: + current_key = lv.KEY.ESC + elif button_volume.value() == 0: + print("Volume button pressed -> reset") + blue_led.on() + player = AudioManager.player( + rtttl="Outro:o=5,d=32,b=160,b=160:c6,b,a,g,f,e,d,c", + stream_type=AudioManager.STREAM_ALARM, + volume=40, + output=buzzer_output, + on_complete=buzzer_callbacks.mute, + ) + buzzer_callbacks.unmute() + player.start() + while player.is_playing(): + time.sleep(0.1) + machine.reset() + elif button_select.value() == 0: + current_key = lv.KEY.BACKSPACE + elif button_start.value() == 0: + current_key = lv.KEY.ENTER + + elif button_b.value() == 0: + current_key = lv.KEY.PREV + elif button_a.value() == 0: + current_key = lv.KEY.NEXT + else: + # No crossbar/buttons pressed + if data.key: # A key was previously pressed and now released + # print(f"Key {data.key=} released") + data.key = 0 + data.state = lv.INDEV_STATE.RELEASED + next_repeat = None + blue_led.off() + return + + # A key is currently pressed + + blue_led.on() # Blink on key press and auto repeat for feedback + + current_time = time.ticks_ms() + repeat = current_time > next_repeat if next_repeat else False # Auto repeat? + if repeat or current_key != data.key: + print(f"Key {current_key} pressed {repeat=}") + + data.key = current_key + data.state = lv.INDEV_STATE.PRESSED + + if current_key == lv.KEY.ESC: # Handle ESC for back navigation + mpos.ui.back_screen() + elif current_key == lv.KEY.RIGHT: + mpos.ui.focus_direction.move_focus_direction(90) + elif current_key == lv.KEY.LEFT: + mpos.ui.focus_direction.move_focus_direction(270) + elif current_key == lv.KEY.UP: + mpos.ui.focus_direction.move_focus_direction(0) + elif current_key == lv.KEY.DOWN: + mpos.ui.focus_direction.move_focus_direction(180) + + if not repeat: + # Initial press: Delay before first repeat + next_repeat = current_time + REPEAT_INITIAL_DELAY_MS + else: + # Faster auto repeat after initial press + next_repeat = current_time + REPEAT_RATE_MS + blue_led.off() # Blink the LED, too + + +group = lv.group_create() +group.set_default() + +# Create and set up the input device +indev = lv.indev_create() +indev.set_type(lv.INDEV_TYPE.KEYPAD) +indev.set_read_cb(input_callback) +indev.set_group( + group +) # is this needed? maybe better to move the default group creation to main.py so it's available everywhere... +disp = lv.display_get_default() # NOQA +indev.set_display(disp) # different from display +indev.enable(True) # NOQA +InputManager.register_indev(indev) + +print("odroid_go.py finished") diff --git a/internal_filesystem/lib/mpos/board/pinstates.py b/internal_filesystem/lib/mpos/board/pinstates.py new file mode 100644 index 00000000..0ca1ff99 --- /dev/null +++ b/internal_filesystem/lib/mpos/board/pinstates.py @@ -0,0 +1,139 @@ +import sys +import time + +import machine + + +def _adc_read(adc): + if hasattr(adc, "read_u16"): + return adc.read_u16() + return adc.read() + + +def _pin_snapshot(pin_id): + pin = machine.Pin(pin_id) + snapshot = {"pin": pin, "mode": None, "pull": None, "value": None} + for attr in ("mode", "pull"): + getter = getattr(pin, attr, None) + if callable(getter): + try: + snapshot[attr] = getter() + except Exception: + pass + try: + snapshot["value"] = pin.value() + except Exception: + pass + return snapshot + + +def _try_pin_snapshot(pin_id): + try: + return _pin_snapshot(pin_id), None + except Exception as exc: + return None, exc + + +def _restore_pin(snapshot): + pin = snapshot["pin"] + mode = snapshot.get("mode") + pull = snapshot.get("pull") + value = snapshot.get("value") + + try: + if hasattr(pin, "init"): + kwargs = {} + if mode is not None: + kwargs["mode"] = mode + if pull is not None: + kwargs["pull"] = pull + if value is not None and mode in (machine.Pin.OUT, getattr(machine.Pin, "OPEN_DRAIN", None)): + kwargs["value"] = value + if kwargs: + pin.init(**kwargs) + return + if value is not None and mode in (machine.Pin.OUT, getattr(machine.Pin, "OPEN_DRAIN", None)): + pin.value(value) + except Exception as exc: + print("pinstates: WARNING: failed to restore GPIO%02d: %r" % (pin.id(), exc)) + + +def _detect_board(): + impl = [repr(sys.implementation)] + impl.append(getattr(sys.implementation, "_machine", "")) + impl.append(getattr(sys.implementation, "machine", "")) + haystack = " ".join(impl).upper() + if "ESP32S3" in haystack: + return "esp32s3" + return "esp32" + + +def _candidate_pins(board, skiplist=None): + extra_skip = set(skiplist or []) + if board in ("esp32", "esp32-wroom", "esp32-wrover"): + skip = {6, 7, 8, 9, 10, 11, 20, 24, 28, 29, 30, 31} + return [p for p in range(0, 40) if p not in skip and p not in extra_skip] + if board in ("esp32s3", "esp32-s3"): + skip = {22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 45, 46} + return [p for p in range(0, 49) if p not in skip and p not in extra_skip] + raise ValueError("Unsupported board type: %r" % board) + + +def read_all_pins(skiplist=None): + board = _detect_board() + pins = _candidate_pins(board, skiplist=skiplist) + results = {"digital": {}, "analog": {}, "errors": {"digital": {}, "analog": {}}} + + for p in pins: + pin_snapshot, snapshot_error = _try_pin_snapshot(p) + if snapshot_error is not None: + results["errors"]["digital"][p] = repr(snapshot_error) + continue + try: + print("Reading digital GPIO%02d..." % p) + pin = machine.Pin(p, machine.Pin.IN) + results["digital"][p] = pin.value() + #time.sleep(1) + except Exception as exc: + results["errors"]["digital"][p] = repr(exc) + finally: + try: + _restore_pin(pin_snapshot) + except Exception as exc: + results["errors"]["digital"][p] = repr(exc) + + for p in pins: + pin_snapshot, snapshot_error = _try_pin_snapshot(p) + if snapshot_error is not None: + results["errors"]["analog"][p] = repr(snapshot_error) + continue + try: + print("Reading analog GPIO%02d..." % p) + adc = machine.ADC(machine.Pin(p)) + results["analog"][p] = _adc_read(adc) + #time.sleep(1) + except Exception as exc: + results["errors"]["analog"][p] = repr(exc) + finally: + try: + _restore_pin(pin_snapshot) + except Exception as exc: + results["errors"]["analog"][p] = repr(exc) + + print("=== Pin State Readout ===") + print("Board:", board) + print("=== Digital Reads ===") + for p in pins: + if p in results["digital"]: + print("GPIO%02d:" % p, results["digital"][p]) + else: + print("GPIO%02d:" % p, "ERR", results["errors"]["digital"].get(p)) + + print("=== Analog Reads ===") + for p in pins: + if p in results["analog"]: + print("GPIO%02d:" % p, results["analog"][p]) + else: + print("GPIO%02d:" % p, "ERR", results["errors"]["analog"].get(p)) + + return results diff --git a/internal_filesystem/lib/mpos/board/qemu.py b/internal_filesystem/lib/mpos/board/qemu.py new file mode 100644 index 00000000..9cd5412e --- /dev/null +++ b/internal_filesystem/lib/mpos/board/qemu.py @@ -0,0 +1,212 @@ +print("qemu.py running") + +import lcd_bus +import lvgl as lv +import machine +import time + +print("qemu.py display bus initialization") +try: + display_bus = lcd_bus.I80Bus( + dc=7, + wr=8, + cs=6, + data0=39, + data1=40, + data2=41, + data3=42, + data4=45, + data5=46, + data6=47, + data7=48, + #reverse_color_bits=False # doesnt seem to do anything? + ) +except Exception as e: + print(f"Error initializing display bus: {e}") + print("Attempting hard reset in 3sec...") + time.sleep(3) + machine.reset() + +_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) + +import drivers.display.st7789 as st7789 +import mpos.ui +mpos.ui.main_display = st7789.ST7789( + data_bus=display_bus, + frame_buffer1=fb1, + frame_buffer2=fb2, + display_width=170, + display_height=320, + color_space=lv.COLOR_FORMAT.RGB565, + #color_space=lv.COLOR_FORMAT.RGB888, # not supported by qemu + color_byte_order=st7789.BYTE_ORDER_RGB, + # rgb565_byte_swap=False, # always False is data_bus.get_lane_count() == 8 + power_pin=9, # Must set RD pin to high, otherwise blank screen as soon as LVGL's task_handler starts + reset_pin=5, + reset_state=st7789.STATE_LOW, # needs low: high will not enable the display + backlight_pin=38, # needed + backlight_on_state=st7789.STATE_PWM, + offset_x=0, + offset_y=35 +) +mpos.ui.main_display.set_power(True) # set RD pin to high before the rest, otherwise garbled output +mpos.ui.main_display.init() +mpos.ui.main_display.set_backlight(100) # works + +lv.init() +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_color_inversion(True) + + +# Button handling code: +from machine import Pin +btn_a = Pin(0, Pin.IN, Pin.PULL_UP) +btn_b = Pin(14, Pin.IN, Pin.PULL_UP) + +# Key repeat configuration +# This whole debounce logic is only necessary because LVGL 9.2.2 seems to have an issue where +# the lv_keyboard widget doesn't handle PRESSING (long presses) properly, it loses focus. +REPEAT_INITIAL_DELAY_MS = 300 # Delay before first repeat +REPEAT_RATE_MS = 100 # Interval between repeats +REPEAT_PREV_BECOMES_BACK = 700 # Long previous press becomes back button +COMBO_GRACE_MS = 60 # Accept near-simultaneous A+B as ENTER +last_key = None +last_state = lv.INDEV_STATE.RELEASED +key_press_start = 0 # Time when key was first pressed +last_repeat_time = 0 # Time of last repeat event +last_a_down_time = 0 +last_b_down_time = 0 +last_a_pressed = False +last_b_pressed = False + +# Read callback +# Warning: This gets called several times per second, and if it outputs continuous debugging on the serial line, +# that will break tools like mpremote from working properly to upload new files over the serial line, thus needing a reflash. +def keypad_read_cb(indev, data): + global last_key, last_state, key_press_start, last_repeat_time, last_a_down_time, last_b_down_time + global last_a_pressed, last_b_pressed + + # Check buttons + current_time = time.ticks_ms() + btn_a_pressed = btn_a.value() == 0 + btn_b_pressed = btn_b.value() == 0 + if btn_a_pressed and not last_a_pressed: + last_a_down_time = current_time + if btn_b_pressed and not last_b_pressed: + last_b_down_time = current_time + last_a_pressed = btn_a_pressed + last_b_pressed = btn_b_pressed + + near_simul = False + if btn_a_pressed and btn_b_pressed: + near_simul = True + elif btn_a_pressed and last_b_down_time and time.ticks_diff(current_time, last_b_down_time) <= COMBO_GRACE_MS: + near_simul = True + elif btn_b_pressed and last_a_down_time and time.ticks_diff(current_time, last_a_down_time) <= COMBO_GRACE_MS: + near_simul = True + + single_press_wait = False + if btn_a_pressed ^ btn_b_pressed: + if btn_a_pressed and time.ticks_diff(current_time, last_a_down_time) < COMBO_GRACE_MS: + single_press_wait = True + elif btn_b_pressed and time.ticks_diff(current_time, last_b_down_time) < COMBO_GRACE_MS: + single_press_wait = True + + if near_simul or single_press_wait: + dt_a = time.ticks_diff(current_time, last_a_down_time) if last_a_down_time else None + dt_b = time.ticks_diff(current_time, last_b_down_time) if last_b_down_time else None + print(f"combo guard: a={btn_a_pressed} b={btn_b_pressed} near={near_simul} wait={single_press_wait} dt_a={dt_a} dt_b={dt_b}") + + # While in an on-screen keyboard, PREV button is LEFT and NEXT button is RIGHT + focus_group = lv.group_get_default() + focus_keyboard = False + if focus_group: + current_focused = focus_group.get_focused() + if isinstance(current_focused, lv.keyboard): + #print("focus is on a keyboard") + focus_keyboard = True + + if near_simul: + current_key = lv.KEY.ENTER + elif single_press_wait: + current_key = None + elif btn_a_pressed: + if focus_keyboard: + current_key = lv.KEY.LEFT + else: + current_key = lv.KEY.PREV + elif btn_b_pressed: + if focus_keyboard: + current_key = lv.KEY.RIGHT + else: + current_key = lv.KEY.NEXT + else: + current_key = None + + if current_key is None: + # No key pressed + data.key = last_key if last_key else -1 + data.state = lv.INDEV_STATE.RELEASED + last_key = None + last_state = lv.INDEV_STATE.RELEASED + key_press_start = 0 + last_repeat_time = 0 + elif last_key is None or current_key != last_key: + print(f"New key press: {current_key}") + data.key = current_key + data.state = lv.INDEV_STATE.PRESSED + last_key = current_key + last_state = lv.INDEV_STATE.PRESSED + key_press_start = current_time + last_repeat_time = current_time + else: + print(f"key repeat because current_key {current_key} == last_key {last_key}") + elapsed = time.ticks_diff(current_time, key_press_start) + since_last_repeat = time.ticks_diff(current_time, last_repeat_time) + if elapsed >= REPEAT_INITIAL_DELAY_MS and since_last_repeat >= REPEAT_RATE_MS: + next_state = lv.INDEV_STATE.PRESSED if last_state == lv.INDEV_STATE.RELEASED else lv.INDEV_STATE.RELEASED + if current_key == lv.KEY.PREV: + print("Repeated PREV does not do anything, instead it triggers ESC (back) if long enough") + if since_last_repeat > REPEAT_PREV_BECOMES_BACK: + print("back button trigger!") + data.key = lv.KEY.ESC + data.state = next_state + last_key = current_key + last_state = data.state + last_repeat_time = current_time + else: + print("repeat PREV ignored because not pressed long enough") + else: + print("Send a new PRESSED/RELEASED pair for repeat") + data.key = current_key + data.state = next_state + last_key = current_key + last_state = data.state + last_repeat_time = current_time + else: + # This doesn't seem to make the key navigation in on-screen keyboards work, unlike on the m5stack_fire...? + #print("No repeat yet, send RELEASED to avoid PRESSING, which breaks keyboard navigation...") + data.state = lv.INDEV_STATE.RELEASED + last_state = lv.INDEV_STATE.RELEASED + + # Handle ESC for back navigation (only on initial PRESSED) + if data.state == lv.INDEV_STATE.PRESSED and data.key == lv.KEY.ESC: + mpos.ui.back_screen() + +group = lv.group_create() +group.set_default() + +# Create and set up the input device +indev = lv.indev_create() +indev.set_type(lv.INDEV_TYPE.KEYPAD) +indev.set_read_cb(keypad_read_cb) +indev.set_group(group) # is this needed? maybe better to move the default group creation to main.py so it's available everywhere... +disp = lv.display_get_default() # NOQA +indev.set_display(disp) # different from display +indev.enable(True) # NOQA +from mpos import InputManager +InputManager.register_indev(indev) + +print("qemu.py finished") diff --git a/internal_filesystem/lib/mpos/board/unphone.py b/internal_filesystem/lib/mpos/board/unphone.py new file mode 100644 index 00000000..299afda0 --- /dev/null +++ b/internal_filesystem/lib/mpos/board/unphone.py @@ -0,0 +1,536 @@ +print("unphone.py initialization") +""" +Hardware initialization for the unPhone 9 +https://unphone.net/ + +Based on C++ implementation (unPhone.h, unPhone.cpp) from: +https://gitlab.com/hamishcunningham/unphonelibrary/ + +other references: +https://gitlab.com/hamishcunningham/unphone/-/blob/master/examples/circuitpython/LCD.py +https://www.espboards.dev/esp32/unphone9/ +https://github.com/espressif/arduino-esp32/blob/master/variants/unphone9/pins_arduino.h +https://github.com/meshtastic/device-ui/blob/master/include/graphics/LGFX/LGFX_UNPHONE.h + +Original author: https://github.com/jedie +""" + +import struct +import sys +import time + +import esp32 +import i2c +import lcd_bus +import lvgl as lv +import machine +import mpos.ui +from drivers.display.hx8357d import hx8357d +from drivers.indev.xpt2046 import XPT2046 +from machine import Pin +from micropython import const +from mpos import InputManager + +SDA = const(3) +SCL = const(4) +SCK = const(39) +MOSI = const(40) +MISO = const(41) + +SPI_HOST = const(1) # Shared SPI for hx8357d display and xpt2046 touch controller + +# 27Mhz used in extras/port-lvgl/lib9/TFT_eSPI_files/Setup15_HX8357D.h +SPI_LCD_FREQ = const(27_000_000) +# SPI_LCD_FREQ = const(20_000_000) +# SPI_LCD_FREQ = const(10_000_000) +# SPI_LCD_FREQ = const(1_000_000) + +I2C_BUS = const(0) +I2C_FREQ = const(100_000) # rates > 100k used to trigger an unPhoneTCA bug...? + +LCD_CS = const(48) # Chip select control pin +LCD_DC = const(47) # Data Command control pin +LCD_RESET = const(46) + +# FIXME: Two backlights? One on the TCA9555 expander, one directly controlled by the ESP32? +LCD_BACKLIGHT = const(2) # 0x02 +BACKLIGHT = const(0x42) + +TFT_WIDTH = const(320) +TFT_HEIGHT = const(480) + +TOUCH_I2C_ADDR = const(106) # 0x6a - Touchscreen controller +TOUCH_REGBITS = const(8) +TOUCH_CS = const(38) # Chip select pin (T_CS) of touch screen + +# 2,5Mhz used in extras/port-lvgl/lib9/TFT_eSPI_files/Setup15_HX8357D.h +SPI_TOUCH_FREQ = const(2_500_000) +# SPI_TOUCH_FREQ = const(500_000) +# SPI_TOUCH_FREQ = const(100_000) + +EXPANDER_POWER = const(0x40) +LED_GREEN = const(0x49) +LED_BLUE = const(0x4D) # 13 | 0x40 +LED_RED = const(13) + +# Power management (known variously as PMU, BMU or just BM): +BM_I2C_ADDR = const(107) # 0x6b + +LORA_CS = const(44) +LORA_RESET = const(42) +SD_CS = const(43) +VIBE = const(0x47) +IR_LEDS = const(12) +USB_VSENSE = const(78) # 14 | 0x40 + +POWER_SWITCH = const(18) +BUTTON_LEFT = const(45) +BUTTON_MIDDLE = const(0) +BUTTON_RIGHT = const(21) + + +print("unphone.py turn on red LED") +machine.Pin(LED_RED, machine.Pin.OUT).on() +time.sleep(1) +print("unphone.py init...") + + +class UnPhoneTCA: + """ + unPhone spin 9 - TCA9555 IO expansion chip + """ + + I2C_DEV_ID = const(38) # 0x26 - TI TCA9555's I²C addr + + # Register addresses + REG_INPUT = const(0x00) + REG_OUTPUT = const(0x02) + REG_CONFIG = const(0x06) + + def __init__(self, i2c_bus: i2c.I2C.Bus): + self.tca_dev = i2c.I2C.Device(bus=i2c_bus, dev_id=self.I2C_DEV_ID) + self.directions = 0xFFFF # All inputs by default + self.output_states = 0x0000 # All low by default + + # Set IO expander initially as all inputs + self._write_word(0x06, self.directions) + + # Read current directions and states + self.directions = self._read_word(0x06) + self.output_states = self._read_word(0x02) + + def _write_word(self, reg, value): + print(f"Writing to TCA9555: reg={reg:#02x}, value={value:#04x}") + self.tca_dev.write(bytes([reg, value & 0xFF, (value >> 8) & 0xFF])) + + def _read_word(self, reg): + self.tca_dev.write(bytes([reg])) + data = self.tca_dev.read(2) + return struct.unpack("= self.STORE_SIZE: + # self.current_store_index = 0 + # self.nvs.set_i8("unPhoneStoreIdx", self.current_store_index) + # self.nvs.commit() + + def power_switch_is_on(self): + return bool(self.tca.digital_read(POWER_SWITCH)) + + def usb_power_connected(self): + status = self.i2c.readfrom_mem(self.BM_I2CADD, self.BM_STATUS, 1)[0] + connected = bool((status >> 2) & 1) # Bit 2 indicates USB connection + print(f"USB power connected: {connected}") + return connected + + def _wake_on_power_switch(self): + print("Configuring ESP32 wake on power switch...") + wake_pin = machine.Pin(POWER_SWITCH, machine.Pin.IN) + esp32.wake_on_ext0(pin=wake_pin, level=esp32.WAKEUP_ALL_LOW) + + def set_shipping(self, *, enable): + print(f"Setting shipping mode to: {enable=}") + wdt = self.i2c.readfrom_mem(self.BM_I2CADD, self.BM_WATCHDOG, 1)[0] + opcon = self.i2c.readfrom_mem(self.BM_I2CADD, self.BM_OPCON, 1)[0] + if enable: + print("Asks BM chip to powering down and shutting off USB power") + wdt = wdt & ~(1 << 5) & ~(1 << 4) # Clear bits 5 and 4 + opcon = opcon | (1 << 5) # Set bit 5 + else: + print("Asks BM chip to power up and enable USB power") + wdt = (wdt & ~(1 << 5)) | (1 << 4) # Clear 5, Set 4 + opcon = opcon & ~(1 << 5) # Clear bit 5 + self.i2c.writeto_mem(self.BM_I2CADD, self.BM_WATCHDOG, bytes([wdt])) + self.i2c.writeto_mem(self.BM_I2CADD, self.BM_OPCON, bytes([opcon])) + + def turn_peripherals_off(self): + print("Turning off peripherals...") + self.expander_power(on=False) + self.backlight(on=False) + self.ir(on=False) + self.rgb(0, 0, 0) + + def turn_off(self): + print("turning unPhone off...") + self.turn_peripherals_off() + if not self.usb_power_connected(): + print("switch is off, power is OFF: going to shipping mode") + self.set_shipping(enable=True) + else: + print("switch is off, but power is ON: going to deep sleep") + self._wake_on_power_switch() + machine.deepsleep(60000) # Deep sleep + + def check_power_switch(self): + if not self.power_switch_is_on(): + print("Power switch is OFF, initiating shutdown sequence...") + self.turn_off() + + def reset(self): + print("Resetting unPhone TCA9555 to default state...") + + # Setup pins: + self.tca.pin_mode(EXPANDER_POWER, machine.Pin.OUT) + self.tca.pin_mode(VIBE, machine.Pin.OUT) + self.tca.pin_mode(BUTTON_LEFT, machine.Pin.IN) + self.tca.pin_mode(BUTTON_MIDDLE, machine.Pin.IN) + self.tca.pin_mode(BUTTON_RIGHT, machine.Pin.IN) + self.tca.pin_mode(IR_LEDS, machine.Pin.OUT) + self.tca.pin_mode(LED_RED, machine.Pin.OUT) + self.tca.pin_mode(LED_GREEN, machine.Pin.OUT) + self.tca.pin_mode(LED_BLUE, machine.Pin.OUT) + + # Initialise unPhone hardware to default state: + self.backlight(on=True) + self.expander_power(on=True) + self.vibe(on=False) + self.ir(on=False) + + # Mute devices on the SPI bus by deselecting them: + for pin in [LCD_CS, TOUCH_CS, LORA_CS, SD_CS]: + machine.Pin(pin, machine.Pin.OUT, value=1) + + time.sleep_ms(200) # Short delay to help things settle + + # Turn RGB LED blue to indicate reset is done: + self.rgb(0, 0, 1) + + +def recover_i2c(): + """ + NOTE: only do this in setup **BEFORE** Wire.begin! + from: https://gitlab.com/hamishcunningham/unphonelibrary/-/blob/main/unPhone.cpp#L220 + """ + print("try to recover I2C bus in case it's locked up...") + scl = machine.Pin(SCL, machine.Pin.OUT) + sda = machine.Pin(SDA, machine.Pin.OUT) + sda.value(1) + + for _ in range(10): # 9th cycle acts as NACK + scl.value(1) + time.sleep_us(5) + scl.value(0) + time.sleep_us(5) + + # STOP signal (SDA from low to high while SCL is high) + sda.value(0) + time.sleep_us(5) + scl.value(1) + time.sleep_us(2) + sda.value(1) + time.sleep_us(2) + + # Short delay to help things settle + time.sleep_ms(200) + + +try: + recover_i2c() + print(f"unphone.py init i2c Bus with: scl={SCL}, sda={SDA}...") + i2c_bus = i2c.I2C.Bus( + host=I2C_BUS, scl=SCL, sda=SDA, freq=I2C_FREQ, use_locks=False + ) +except Exception as e: + sys.print_exception(e) + print("Attempting hard reset in 3sec...") + time.sleep(3) + machine.reset() +else: + print("Scanning I2C bus for devices...") + for dev in i2c_bus.scan(): + print(f"Found I2C device at address: {dev} (${dev:#02X})") + # Typical output here is: + # Found I2C device at address: 38 ($0x26) -> TCA9555 IO expansion chip + # Found I2C device at address: 106 ($0x6A) -> Touchscreen controller + # Found I2C device at address: 107 ($0x6B) -> Power management unit (PMU/BMU) + + unphone = UnPhone(i2c=i2c_bus) + + +# Manually set MISO pin to input with pull-up to avoid it floating and causing issues on the SPI bus, +# since it's shared between display and touch controller: +Pin(MISO, Pin.IN, Pin.PULL_UP) + + +print("unphone.py shared SPI bus initialization") +time.sleep_ms(200) # Short delay to help things settle +try: + spi_bus = machine.SPI.Bus(host=SPI_HOST, sck=SCK, mosi=MOSI, miso=MISO) +except Exception as e: + sys.print_exception(e) + print("Attempting hard reset in 3sec...") + time.sleep(3) + machine.reset() + + +print("unphone.py HX8357D() display initialization") +try: + display_bus = lcd_bus.SPIBus( + spi_bus=spi_bus, # Use **the same** SPI bus hx8357d display + freq=SPI_LCD_FREQ, + dc=LCD_DC, + cs=LCD_CS, + ) + mpos.ui.main_display = hx8357d.HX8357D( + data_bus=display_bus, + display_width=TFT_WIDTH, + display_height=TFT_HEIGHT, + color_space=lv.COLOR_FORMAT.RGB565, + color_byte_order=hx8357d.BYTE_ORDER_BGR, + rgb565_byte_swap=True, + reset_pin=LCD_RESET, + reset_state=hx8357d.STATE_LOW, + backlight_pin=LCD_BACKLIGHT, + backlight_on_state=hx8357d.STATE_PWM, + ) +except Exception as e: + sys.print_exception(e) + print("Attempting hard reset in 3sec...") + time.sleep(3) + machine.reset() + + +print("unphone.py display.init()") +mpos.ui.main_display.init() + +print("unphone.py XPT2046() touch controller initialization") +time.sleep_ms(200) # Short delay to help things settle +startup_rotation = lv.DISPLAY_ROTATION._0 +try: + touch_dev = machine.SPI.Device( + spi_bus=spi_bus, # Use **the same** SPI bus for xpt2046 touch + freq=SPI_TOUCH_FREQ, + cs=TOUCH_CS, + ) +except Exception as e: + sys.print_exception(e) + print("Attempting hard reset in 3sec...") + time.sleep(3) + machine.reset() +else: + print(f"unphone.py init touch...") + touch_input_dev = XPT2046( + device=touch_dev, + lcd_cs=LCD_CS, + touch_cs=TOUCH_CS, + display_width=TFT_WIDTH, + display_height=TFT_HEIGHT, + startup_rotation=startup_rotation, + # debug=True, + ) + print(f"{touch_input_dev.is_calibrated=}") + # FIXME: Persistent calibration data is not working yet? + # if touch_input_dev.is_calibrated: + # print('Touch input is already calibrated, skipping calibration step.') + # else: + # print("Starting touch calibration...") + # touch_input_dev.calibrate() + InputManager.register_indev(touch_input_dev) + + +print("unphone.py display.set_rotation() initialization") +mpos.ui.main_display.set_rotation( + startup_rotation +) # must be done after initializing display and creating the touch drivers, to ensure proper handling + + +print("unphone.py button initialization...") +button_left = Pin(BUTTON_LEFT, Pin.IN, Pin.PULL_UP) +button_middle = Pin(BUTTON_MIDDLE, Pin.IN, Pin.PULL_UP) +button_right = Pin(BUTTON_RIGHT, Pin.IN, Pin.PULL_UP) + + +REPEAT_INITIAL_DELAY_MS = 300 # Delay before first repeat +REPEAT_RATE_MS = 100 # Interval between repeats +next_repeat = None # Used for auto-repeat key handling +last_power_switch = None +next_check = time.time() + 1 + + +def input_callback(indev, data): + global next_repeat, last_power_switch, next_check + + current_key = None + + if button_left.value() == 0: + current_key = lv.KEY.ESC + elif button_middle.value() == 0: + current_key = lv.KEY.NEXT + elif button_right.value() == 0: + current_key = lv.KEY.ENTER + + else: + # No buttons pressed + + if data.key: # A key was previously pressed and now released + # print(f"Key {data.key=} released") + data.key = 0 + data.state = lv.INDEV_STATE.RELEASED + next_repeat = None + + if time.time() > next_check: + # Check power switch state and update backlight accordingly + unphone.check_power_switch() + next_check = time.time() + 1 # Check every second + + return + + # A key is currently pressed + + current_time = time.ticks_ms() + repeat = current_time > next_repeat if next_repeat else False # Auto repeat? + if repeat or current_key != data.key: + print(f"Key {current_key} pressed {repeat=}") + + data.key = current_key + data.state = lv.INDEV_STATE.PRESSED + + if current_key == lv.KEY.ESC: # Handle ESC for back navigation + mpos.ui.back_screen() + elif current_key == lv.KEY.RIGHT: + mpos.ui.focus_direction.move_focus_direction(90) + elif current_key == lv.KEY.LEFT: + mpos.ui.focus_direction.move_focus_direction(270) + elif current_key == lv.KEY.UP: + mpos.ui.focus_direction.move_focus_direction(0) + elif current_key == lv.KEY.DOWN: + mpos.ui.focus_direction.move_focus_direction(180) + + if not repeat: + # Initial press: Delay before first repeat + next_repeat = current_time + REPEAT_INITIAL_DELAY_MS + else: + # Faster auto repeat after initial press + next_repeat = current_time + REPEAT_RATE_MS + + +group = lv.group_create() +group.set_default() + +# Create and set up the input device +indev = lv.indev_create() +indev.set_type(lv.INDEV_TYPE.KEYPAD) +indev.set_read_cb(input_callback) +indev.set_group( + group +) # is this needed? maybe better to move the default group creation to main.py so it's available everywhere... +disp = lv.display_get_default() # NOQA +indev.set_display(disp) # different from display +indev.enable(True) # NOQA +InputManager.register_indev(indev) + +unphone.rgb(0, 1, 0) # Green to indicate init is done + +print("\nunphone.py init finished\n") 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 index 096e64c9..547f36d2 100644 --- 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 @@ -1,15 +1,17 @@ +print("waveshare_esp32_s3_touch_lcd_2.py initialization") # 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 time +import drivers.display.st7789 as st7789 +import drivers.indev.cst816s as cst816s +import i2c +import lcd_bus +import lvgl as lv +import machine import mpos.ui +from mpos import InputManager # Pin configuration SPI_BUS = 2 @@ -21,22 +23,16 @@ 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 -) +print("waveshare_esp32_s3_touch_lcd_2.py machine.SPI.Bus() initialization") +try: + spi_bus = machine.SPI.Bus(host=SPI_BUS, mosi=LCD_MOSI, miso=LCD_MISO, sck=LCD_SCLK) +except Exception as e: + print(f"Error initializing SPI bus: {e}") + print("Attempting hard reset in 3sec...") + time.sleep(3) + machine.reset() + + display_bus = lcd_bus.SPIBus( spi_bus=spi_bus, freq=SPI_FREQ, @@ -51,7 +47,14 @@ # /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) + +# Max buffer size (breaks SPI camera because it also needs DMA memory) +# 148480 (320*232*2) is too much +# 147841 (320*231*2) is too much +# 147200 (320*230*2) is fine! +# 140800 (320*220*2) is fine! + +_BUFFER_SIZE = const(320 * 45 * 2) # 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) @@ -59,28 +62,28 @@ 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, + display_width=240, + display_height=320, 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, +) # triggers lv.init() 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 +i2c_bus = i2c.I2C.Bus(host=0, scl=47, sda=48, freq=400000, use_locks=False) +touch_dev = i2c.I2C.Device(bus=i2c_bus, dev_id=0x15, reg_bits=8) +indev = cst816s.CST816S(touch_dev, startup_rotation=lv.DISPLAY_ROTATION._180) # button in top left, good +InputManager.register_indev(indev) -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 +from mpos import BatteryManager def adc_to_voltage(adc_value): """ @@ -93,7 +96,7 @@ def adc_to_voltage(adc_value): """ return adc_value * 0.00262 -mpos.battery_voltage.init_adc(5, adc_to_voltage) +BatteryManager.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. @@ -110,27 +113,92 @@ def adc_to_voltage(adc_value): 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 LEDs, only I2S audio -# I2S pin configuration will be determined by the board's audio hardware -# For now, initialize with I2S only (pins will be configured per-stream if available) -AudioFlinger.init( - device_type=AudioFlinger.DEVICE_I2S, - i2s_pins={'sck': 2, 'ws': 47, 'sd': 16}, # Default ESP32-S3 I2S pins - buzzer_instance=None -) - -# === 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 +from mpos import 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) - -print("boot.py finished") +SensorManager.init(i2c_bus, address=0x6B, mounted_position=SensorManager.FACING_EARTH) + +# === CAMERA HARDWARE === +from mpos import CameraManager + +def init_cam(width, height, colormode): + toreturn = None + try: + from camera import Camera, GrabMode, PixelFormat, FrameSize, GainCeiling + + # Map resolution to FrameSize enum using CameraManager + frame_size = CameraManager.resolution_to_framesize(width, height) + print(f"init_internal_cam: Using FrameSize {frame_size} 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 colormode else PixelFormat.GRAYSCALE, + frame_size=frame_size, + #grab_mode=GrabMode.WHEN_EMPTY, + grab_mode=GrabMode.LATEST, + fb_count=1 + ) + cam.set_vflip(True) + toreturn=cam + break + 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}") + break + except Exception as e: + print(f"init_cam exception: {e}") + + return toreturn + +def deinit_cam(cam): + cam.deinit() + # Power off, otherwise it keeps using a lot of current + try: + from machine import Pin, I2C + i2c = I2C(1, scl=Pin(16), sda=Pin(21)) # Adjust pins and frequency + 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}") + import time + time.sleep_ms(100) + +def capture_cam(cam_obj, colormode): + return cam_obj.capture() + +def apply_cam_settings(cam_obj, prefs): + return CameraManager.ov_apply_camera_settings(cam_obj, prefs) + +# Waveshare ESP32-S3-Touch-LCD-2 has OV5640 camera +CameraManager.add_camera(CameraManager.Camera( + lens_facing=CameraManager.CameraCharacteristics.LENS_FACING_BACK, + name="OV5640", + vendor="OmniVision", + init=init_cam, + deinit=deinit_cam, + capture=capture_cam, + apply_settings=apply_cam_settings, + rotation_degrees=-90 # camera is rotated 90 degrees counterclockwise so -90 degrees clockwise +)) + +print("waveshare_esp32_s3_touch_lcd_2.py finished") diff --git a/internal_filesystem/lib/mpos/build_info.py b/internal_filesystem/lib/mpos/build_info.py new file mode 100644 index 00000000..4a3cd0eb --- /dev/null +++ b/internal_filesystem/lib/mpos/build_info.py @@ -0,0 +1,13 @@ +""" +BuildInfo - OS version and build information +""" + + +class BuildInfo: + """OS version and build information.""" + + class version: + """Version information.""" + + release = "0.9.0" + api_level = 0 # subject to change until API Level 1 diff --git a/internal_filesystem/lib/mpos/camera_manager.py b/internal_filesystem/lib/mpos/camera_manager.py new file mode 100644 index 00000000..9460ae5c --- /dev/null +++ b/internal_filesystem/lib/mpos/camera_manager.py @@ -0,0 +1,419 @@ +"""Android-inspired CameraManager for MicroPythonOS. + +Provides unified access to camera devices (back-facing, front-facing, external). +Follows singleton pattern with class method delegation. + +Example usage: + from mpos import CameraManager + + # In board init file: + CameraManager.add_camera(CameraManager.Camera( + lens_facing=CameraManager.CameraCharacteristics.LENS_FACING_BACK, + name="OV5640", + vendor="OmniVision" + )) + + # In app: + cam_list = CameraManager.get_cameras() + if len(cam_list) > 0: + print("we have a camera!") + +MIT License +Copyright (c) 2024 MicroPythonOS contributors +""" + + +# Camera lens facing constants (matching Android Camera2 API) +class CameraCharacteristics: + """Camera characteristics and constants.""" + LENS_FACING_BACK = 0 # Back-facing camera (primary) + LENS_FACING_FRONT = 1 # Front-facing camera (selfie) + LENS_FACING_EXTERNAL = 2 # External USB camera + + +class Camera: + """Camera metadata (lightweight data class, Android-inspired). + + Represents a camera device with its characteristics. + """ + + def __init__(self, lens_facing, name=None, vendor=None, version=None, init=None, deinit=None, capture=None, apply_settings=None, rotation_degrees=0): + """Initialize camera metadata. + + Args: + lens_facing: Camera orientation (LENS_FACING_BACK, LENS_FACING_FRONT, etc.) + name: Human-readable camera name (e.g., "OV5640", "Front Camera") + vendor: Camera vendor/manufacturer (e.g., "OmniVision") + version: Driver version (default 1) + rotation_degrees: how many degrees the camera is rotated clockwise + """ + self.lens_facing = lens_facing + self.name = name or "Camera" + self.vendor = vendor or "Unknown" + self.version = version or 1 + self.init_function = init + self.deinit_function = deinit + self.capture_function = capture + self.apply_settings_function = apply_settings + self.rotation_degrees = rotation_degrees + + def __repr__(self): + facing_names = { + CameraCharacteristics.LENS_FACING_BACK: "BACK", + CameraCharacteristics.LENS_FACING_FRONT: "FRONT", + CameraCharacteristics.LENS_FACING_EXTERNAL: "EXTERNAL" + } + facing_str = facing_names.get(self.lens_facing, f"UNKNOWN({self.lens_facing})") + return f"Camera({self.name}, facing={facing_str})" + + def init(self, width, height, colormode): + if self.init_function: + return self.init_function(width, height, colormode) + + def deinit(self, cam_obj=None): + if self.deinit_function: + return self.deinit_function(cam_obj) + + def capture(self, cam_obj, colormode=None): + if self.capture_function: + return self.capture_function(cam_obj, colormode) + + def apply_settings(self, cam_obj, prefs): + if self.apply_settings_function: + return self.apply_settings_function(cam_obj, prefs) + + def get_rotation_degrees(self): + return self.rotation_degrees + + +class CameraManager: + """ + Centralized camera device management service. + Implements singleton pattern for unified camera access. + + Usage: + from mpos import CameraManager + + # Register a camera + CameraManager.add_camera(CameraManager.Camera( + lens_facing=CameraManager.CameraCharacteristics.LENS_FACING_BACK, + name="OV5640" + )) + + # Get all cameras + cameras = CameraManager.get_cameras() + """ + + # Expose inner classes as class attributes + Camera = Camera + CameraCharacteristics = CameraCharacteristics + + _instance = None + _cameras = [] # Class-level camera list for singleton + + def __init__(self): + """Initialize CameraManager singleton instance.""" + if CameraManager._instance: + return + CameraManager._instance = self + + self._initialized = False + self.init() + + @classmethod + def get(cls): + """Get or create the singleton instance.""" + if cls._instance is None: + cls._instance = cls() + return cls._instance + + def init(self): + """Initialize CameraManager. + + Returns: + bool: True if initialized successfully + """ + self._initialized = True + return True + + def is_available(self): + """Check if CameraManager is initialized. + + Returns: + bool: True if CameraManager is initialized + """ + return self._initialized + + def add_camera(self, camera): + """Register a camera device. + + Args: + camera: Camera object to register + + Returns: + bool: True if camera added successfully + """ + if not isinstance(camera, Camera): + print(f"[CameraManager] Error: add_camera() requires Camera object, got {type(camera)}") + return False + + # Check if camera with same facing already exists + for existing in CameraManager._cameras: + if existing.lens_facing == camera.lens_facing: + print(f"[CameraManager] Warning: Camera with facing {camera.lens_facing} already registered") + # Still add it (allow multiple cameras with same facing) + + CameraManager._cameras.append(camera) + print(f"[CameraManager] Registered camera: {camera}") + return True + + def get_cameras(self): + """Get list of all registered cameras. + + Returns: + list: List of Camera objects (copy of internal list) + """ + return CameraManager._cameras.copy() if CameraManager._cameras else [] + + def get_camera_by_facing(self, lens_facing): + """Get first camera with specified lens facing. + + Args: + lens_facing: Camera orientation (LENS_FACING_BACK, LENS_FACING_FRONT, etc.) + + Returns: + Camera object or None if not found + """ + for camera in CameraManager._cameras: + if camera.lens_facing == lens_facing: + return camera + return None + + def has_camera(self): + """Check if any camera is registered. + + Returns: + bool: True if at least one camera available + """ + return len(CameraManager._cameras) > 0 + + def get_camera_count(self): + """Get number of registered cameras. + + Returns: + int: Number of cameras + """ + return len(CameraManager._cameras) + + @staticmethod + def resolution_to_framesize(width, height): + """Map resolution (width, height) to FrameSize enum. + + Args: + width: Image width in pixels + height: Image height in pixels + + Returns: + FrameSize enum value corresponding to the resolution, or R240X240 as default + """ + try: + from camera import FrameSize + except ImportError: + print("Warning: camera module not available") + return None + + # 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, + (1024, 768): FrameSize.XGA, + (960, 960): FrameSize.R960X960, + (1280, 720): FrameSize.HD, + (1024, 1024): FrameSize.R1024X1024, + # These are disabled in camera_settings.py because they use a lot of RAM: + (1280, 1024): FrameSize.SXGA, + (1280, 1280): FrameSize.R1280X1280, + (1600, 1200): FrameSize.UXGA, + (1920, 1080): FrameSize.FHD, + } + + return resolution_map.get((width, height), FrameSize.R240X240) + + @staticmethod + def ov_apply_camera_settings(cam, prefs): + if not cam or not prefs: + print("ov_apply_camera_settings: Skipping because invalid prefs or cam object") + return + + try: + # Basic image adjustments + brightness = prefs.get_int("brightness") + if brightness is not None: + cam.set_brightness(brightness) + + contrast = prefs.get_int("contrast") + if contrast is not None: + cam.set_contrast(contrast) + + saturation = prefs.get_int("saturation") + if saturation is not None: + cam.set_saturation(saturation) + + # Orientation + hmirror = prefs.get_bool("hmirror") + if hmirror is not None: + cam.set_hmirror(hmirror) + + vflip = prefs.get_bool("vflip") + if vflip is not None: + cam.set_vflip(vflip) + + # Special effect + special_effect = prefs.get_int("special_effect") + if special_effect is not None: + cam.set_special_effect(special_effect) + + # Exposure control (apply master switch first, then manual value) + exposure_ctrl = prefs.get_bool("exposure_ctrl") + if exposure_ctrl is not None: + cam.set_exposure_ctrl(exposure_ctrl) + else: + aec_value = prefs.get_int("aec_value") + if aec_value is not None: + cam.set_aec_value(aec_value) + + # Mode-specific default comes from constructor + ae_level = prefs.get_int("ae_level") + if ae_level is not None: + cam.set_ae_level(ae_level) + + aec2 = prefs.get_bool("aec2") + if aec2 is not None: + cam.set_aec2(aec2) + + # Gain control (apply master switch first, then manual value) + gain_ctrl = prefs.get_bool("gain_ctrl") + if gain_ctrl is not None: + cam.set_gain_ctrl(gain_ctrl) + else: + agc_gain = prefs.get_int("agc_gain") + if agc_gain is None: + cam.set_agc_gain(agc_gain) + + gainceiling = prefs.get_int("gainceiling") + if gainceiling is not None: + cam.set_gainceiling(gainceiling) + + # White balance (apply master switch first, then mode) + whitebal = prefs.get_bool("whitebal") + if whitebal is not None: + cam.set_whitebal(whitebal) + else: + wb_mode = prefs.get_int("wb_mode") + if wb_mode is not None: + cam.set_wb_mode(wb_mode) + + awb_gain = prefs.get_bool("awb_gain") + if awb_gain is not None: + cam.set_awb_gain(awb_gain) + + # Sensor-specific settings (try/except for unsupported sensors) + try: + sharpness = prefs.get_int("sharpness") + if sharpness is not None: + cam.set_sharpness(sharpness) + except: + pass # Not supported on OV2640? + + try: + denoise = prefs.get_int("denoise") + if denoise is not None: + cam.set_denoise(denoise) + except: + pass # Not supported on OV2640? + + # Advanced corrections + colorbar = prefs.get_bool("colorbar") + if colorbar is not None: + cam.set_colorbar(colorbar) + + dcw = prefs.get_bool("dcw") + if dcw is not None: + cam.set_dcw(dcw) + + bpc = prefs.get_bool("bpc") + if bpc is not None: + cam.set_bpc(bpc) + + wpc = prefs.get_bool("wpc") + if wpc is not None: + cam.set_wpc(wpc) + + # Mode-specific default comes from constructor + raw_gma = prefs.get_bool("raw_gma") + if raw_gma is not None: + print(f"applying raw_gma: {raw_gma}") + cam.set_raw_gma(raw_gma) + + lenc = prefs.get_bool("lenc") + if lenc is not None: + cam.set_lenc(lenc) + + # JPEG quality (only relevant for JPEG format) + #try: + # quality = prefs.get_int("quality", 85) + # if quality is not None: + # 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}") + + +# ============================================================================ +# Class method delegation (at module level) +# ============================================================================ + +_original_methods = {} +_methods_to_delegate = [ + 'init', 'is_available', 'add_camera', 'get_cameras', + 'get_camera_by_facing', 'has_camera', 'get_camera_count' +] + +for method_name in _methods_to_delegate: + _original_methods[method_name] = getattr(CameraManager, method_name) + +def _make_class_method(method_name): + """Create a class method that delegates to the singleton instance.""" + original_method = _original_methods[method_name] + + @classmethod + def class_method(cls, *args, **kwargs): + instance = cls.get() + return original_method(instance, *args, **kwargs) + + return class_method + +for method_name in _methods_to_delegate: + setattr(CameraManager, method_name, _make_class_method(method_name)) + + +# Initialize on module load +CameraManager.init() diff --git a/internal_filesystem/lib/mpos/config.py b/internal_filesystem/lib/mpos/config.py index e42f45e6..a649ccf0 100644 --- a/internal_filesystem/lib/mpos/config.py +++ b/internal_filesystem/lib/mpos/config.py @@ -172,7 +172,7 @@ def __init__(self, preferences): def put_string(self, key, value): """Store a string value.""" - self.temp_data[key] = str(value) + self.temp_data[key] = None if value is None else str(value) return self def put_int(self, key, value): diff --git a/internal_filesystem/lib/mpos/content/package_manager.py b/internal_filesystem/lib/mpos/content/app_manager.py similarity index 50% rename from internal_filesystem/lib/mpos/content/package_manager.py rename to internal_filesystem/lib/mpos/content/app_manager.py index 7efdc2b7..377f87d1 100644 --- a/internal_filesystem/lib/mpos/content/package_manager.py +++ b/internal_filesystem/lib/mpos/content/app_manager.py @@ -27,7 +27,7 @@ ''' -class PackageManager: +class AppManager: _registry = {} # action → [ActivityClass, ...] @@ -51,9 +51,9 @@ def query_intent_activities(cls, intent): """Registry of all discovered apps. - * PackageManager.get_app_list() -> list of App objects (sorted by name) - * PackageManager[fullname] -> App (raises KeyError if missing) - * PackageManager.get(fullname) -> App or None + * AppManager.get_app_list() -> list of App objects (sorted by name) + * AppManager[fullname] -> App (raises KeyError if missing) + * AppManager.get(fullname) -> App or None """ _app_list = [] # sorted by app.name @@ -92,7 +92,7 @@ def clear(cls): @classmethod def refresh_apps(cls): - print("PackageManager finding apps...") + print("AppManager finding apps...") cls.clear() # <-- this guarantees both containers are empty seen = set() # avoid processing the same fullname twice @@ -116,7 +116,7 @@ def refresh_apps(cls): if not (st[0] & 0x4000): continue except Exception as e: - print("PackageManager: stat of {} got exception: {}".format(full_path, e)) + print("AppManager: stat of {} got exception: {}".format(full_path, e)) continue fullname = name @@ -131,16 +131,16 @@ def refresh_apps(cls): from ..app.app import App app = App.from_manifest(full_path) except Exception as e: - print("PackageManager: parsing {} failed: {}".format(full_path, e)) + print("AppManager: parsing {} failed: {}".format(full_path, e)) continue # ---- store in both containers --------------------------- cls._app_list.append(app) cls._by_fullname[fullname] = app - print("added app {}".format(app)) + #print("added app {}".format(app)) except Exception as e: - print("PackageManager: handling {} got exception: {}".format(base, e)) + print("AppManager: handling {} got exception: {}".format(base, e)) # ---- sort the list by display name (case-insensitive) ------------ cls._app_list.sort(key=lambda a: a.name.lower()) @@ -152,11 +152,30 @@ def uninstall_app(app_fullname): shutil.rmtree(f"apps/{app_fullname}") # never in builtin/apps because those can't be uninstalled except Exception as e: print(f"Removing app_folder {app_folder} got error: {e}") - PackageManager.refresh_apps() + AppManager.refresh_apps() @staticmethod def install_mpk(temp_zip_path, dest_folder): try: + # Step 1: Remove any existing (possibly partial) install or symlink + try: + st = os.stat(dest_folder) + if st[0] & 0x4000: # It's a real directory + import shutil + shutil.rmtree(dest_folder) + print("Removed existing folder:", dest_folder) + else: + os.remove(dest_folder) + print("Removed existing file:", dest_folder) + except OSError: + pass # Doesn't exist, that's fine + # Also remove if it's a symlink (broken or otherwise) + try: + os.remove(dest_folder) + print("Removed symlink:", dest_folder) + except OSError: + pass # Not a symlink or already removed + # Step 2: Unzip the file print("Unzipping it to:", dest_folder) with zipfile.ZipFile(temp_zip_path, "r") as zip_ref: @@ -168,7 +187,7 @@ def install_mpk(temp_zip_path, dest_folder): except Exception as e: print(f"Unzip and cleanup failed: {e}") # Would be good to show error message here if it fails... - PackageManager.refresh_apps() + AppManager.refresh_apps() @staticmethod def compare_versions(ver1: str, ver2: str) -> bool: @@ -199,20 +218,20 @@ def compare_versions(ver1: str, ver2: str) -> bool: @staticmethod def is_builtin_app(app_fullname): - return PackageManager.is_installed_by_path(f"builtin/apps/{app_fullname}") + return AppManager.is_installed_by_path(f"builtin/apps/{app_fullname}") @staticmethod def is_overridden_builtin_app(app_fullname): - return PackageManager.is_installed_by_path(f"apps/{app_fullname}") and PackageManager.is_installed_by_path(f"builtin/apps/{app_fullname}") + return AppManager.is_installed_by_path(f"apps/{app_fullname}") and AppManager.is_installed_by_path(f"builtin/apps/{app_fullname}") @staticmethod def is_update_available(app_fullname, new_version): appdir = f"apps/{app_fullname}" builtinappdir = f"builtin/apps/{app_fullname}" - installed_app=PackageManager.get(app_fullname) + installed_app=AppManager.get(app_fullname) if not installed_app: return False - return PackageManager.compare_versions(new_version, installed_app.version) + return AppManager.compare_versions(new_version, installed_app.version) @staticmethod def is_installed_by_path(dir_path): @@ -230,5 +249,115 @@ def is_installed_by_path(dir_path): @staticmethod 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}") + return AppManager.is_installed_by_path(f"apps/{app_fullname}") or AppManager.is_installed_by_path(f"builtin/apps/{app_fullname}") + + @staticmethod + def execute_script(script_source, is_file, classname, cwd=None): + """Run the script in the current thread. Returns True if successful.""" + import utime # for timing read and compile + import lvgl as lv + import mpos.ui + import _thread + thread_id = _thread.get_ident() + compile_name = 'script' if not is_file else script_source + print(f"Thread {thread_id}: executing script with cwd: {cwd}") + try: + if is_file: + print(f"Thread {thread_id}: reading script from file {script_source}") + with open(script_source, 'r') as f: # No need to check if it exists as exceptions are caught + start_time = utime.ticks_ms() + script_source = f.read() + read_time = utime.ticks_diff(utime.ticks_ms(), start_time) + print(f"execute_script: reading script_source took {read_time}ms") + script_globals = { + 'lv': lv, + '__name__': "__main__", # in case the script wants this + '__file__': compile_name + } + print(f"Thread {thread_id}: starting script") + import sys + path_before = sys.path[:] # Make a copy, not a reference + if cwd: + sys.path.append(cwd) + try: + start_time = utime.ticks_ms() + compiled_script = compile(script_source, compile_name, 'exec') + compile_time = utime.ticks_diff(utime.ticks_ms(), start_time) + print(f"execute_script: compiling script_source took {compile_time}ms") + start_time = utime.ticks_ms() + exec(compiled_script, script_globals) + end_time = utime.ticks_diff(utime.ticks_ms(), start_time) + print(f"apps.py execute_script: exec took {end_time}ms") + # Introspect globals + classes = {k: v for k, v in script_globals.items() if isinstance(v, type)} + functions = {k: v for k, v in script_globals.items() if callable(v) and not isinstance(v, type)} + variables = {k: v for k, v in script_globals.items() if not callable(v)} + print("Classes:", classes.keys()) # This lists a whole bunch of classes, including lib/mpos/ stuff + print("Functions:", functions.keys()) + print("Variables:", variables.keys()) + main_activity = script_globals.get(classname) + if main_activity: + from ..app.activity import Activity + from .intent import Intent + 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 {classname}") + return False + except Exception as e: + print(f"Thread {thread_id}: exception during execution:") + sys.print_exception(e) + 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 + @staticmethod + def start_app(fullname): + """Start an app by fullname. Returns True if successful.""" + import mpos.ui + mpos.ui.set_foreground_app(fullname) + import utime + start_time = utime.ticks_ms() + app = AppManager.get(fullname) + if not app: + print(f"Warning: start_app can't find app {fullname}") + return + if not app.installed_path: + print(f"Warning: start_app can't start {fullname} because no it doesn't have an installed_path") + return + entrypoint = "assets/main.py" + classname = "Main" + if not app.main_launcher_activity: + print(f"WARNING: app {fullname} doesn't have a main_launcher_activity, defaulting to class {classname} in {entrypoint}") + else: + entrypoint = app.main_launcher_activity.get('entrypoint') + classname = app.main_launcher_activity.get("classname") + result = AppManager.execute_script(app.installed_path + "/" + entrypoint, True, classname, app.installed_path + "/assets/") + # Launchers have the bar, other apps don't have it + if app.is_valid_launcher(): + mpos.ui.topmenu.open_bar() + else: + 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 + + @staticmethod + def restart_launcher(): + """Restart the launcher by stopping all activities and starting the launcher app.""" + import mpos.ui + print("restart_launcher") + # 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 + return AppManager.start_app(AppManager.get_launcher().fullname) diff --git a/internal_filesystem/lib/mpos/device_info.py b/internal_filesystem/lib/mpos/device_info.py new file mode 100644 index 00000000..b4f4296c --- /dev/null +++ b/internal_filesystem/lib/mpos/device_info.py @@ -0,0 +1,29 @@ +""" +DeviceInfo - Device hardware information +""" + + +class DeviceInfo: + """Device hardware information.""" + + hardware_id = "missing-hardware-info" + + @classmethod + def set_hardware_id(cls, device_id): + """ + Set the device/hardware identifier (called during boot). + + Args: + device_id: The hardware identifier string + """ + cls.hardware_id = device_id + + @classmethod + def get_hardware_id(cls): + """ + Get the hardware identifier. + + Returns: + str: The hardware identifier + """ + return cls.hardware_id diff --git a/internal_filesystem/lib/mpos/hardware/drivers/__init__.py b/internal_filesystem/lib/mpos/hardware/drivers/__init__.py deleted file mode 100644 index 119fb43d..00000000 --- a/internal_filesystem/lib/mpos/hardware/drivers/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# IMU and sensor drivers for MicroPythonOS diff --git a/internal_filesystem/lib/mpos/hardware/fri3d/__init__.py b/internal_filesystem/lib/mpos/hardware/fri3d/__init__.py index 18919b17..792a35be 100644 --- a/internal_filesystem/lib/mpos/hardware/fri3d/__init__.py +++ b/internal_filesystem/lib/mpos/hardware/fri3d/__init__.py @@ -1,5 +1,5 @@ # Fri3d Camp 2024 Badge Hardware Drivers -# These are simple wrappers that can be used by services like AudioFlinger +# These are simple wrappers that can be used by services like AudioManager from .buzzer import BuzzerConfig from .leds import LEDConfig diff --git a/internal_filesystem/lib/mpos/imu/__init__.py b/internal_filesystem/lib/mpos/imu/__init__.py new file mode 100644 index 00000000..49ff04f9 --- /dev/null +++ b/internal_filesystem/lib/mpos/imu/__init__.py @@ -0,0 +1,29 @@ +from mpos.imu.constants import ( + TYPE_ACCELEROMETER, + TYPE_MAGNETIC_FIELD, + TYPE_GYROSCOPE, + TYPE_TEMPERATURE, + TYPE_IMU_TEMPERATURE, + TYPE_SOC_TEMPERATURE, + FACING_EARTH, + FACING_SKY, + GRAVITY, + IMU_CALIBRATION_FILENAME, +) +from mpos.imu.sensor import Sensor +from mpos.imu.manager import ImuManager + +__all__ = [ + "TYPE_ACCELEROMETER", + "TYPE_MAGNETIC_FIELD", + "TYPE_GYROSCOPE", + "TYPE_TEMPERATURE", + "TYPE_IMU_TEMPERATURE", + "TYPE_SOC_TEMPERATURE", + "FACING_EARTH", + "FACING_SKY", + "GRAVITY", + "IMU_CALIBRATION_FILENAME", + "Sensor", + "ImuManager", +] diff --git a/internal_filesystem/lib/mpos/imu/constants.py b/internal_filesystem/lib/mpos/imu/constants.py new file mode 100644 index 00000000..e4e145ea --- /dev/null +++ b/internal_filesystem/lib/mpos/imu/constants.py @@ -0,0 +1,15 @@ +TYPE_ACCELEROMETER = 1 # Units: m/s² (meters per second squared) +TYPE_MAGNETIC_FIELD = 2 # Units: μT (micro teslas) +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" diff --git a/internal_filesystem/lib/mpos/imu/drivers/base.py b/internal_filesystem/lib/mpos/imu/drivers/base.py new file mode 100644 index 00000000..fb375f21 --- /dev/null +++ b/internal_filesystem/lib/mpos/imu/drivers/base.py @@ -0,0 +1,86 @@ +import time + +from mpos.imu.constants import GRAVITY, FACING_EARTH + + +class IMUDriverBase: + """Base class for IMU drivers with shared calibration logic.""" + + def __init__(self): + self.accel_offset = [0.0, 0.0, 0.0] + self.gyro_offset = [0.0, 0.0, 0.0] + + 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_magnetometer(self): + """Returns (x, y, z) in uT""" + raise NotImplementedError + + def read_temperature(self): + """Returns temperature in °C""" + raise NotImplementedError + + def _raw_acceleration_mps2(self): + """Returns raw (x, y, z) in m/s² for calibration sampling.""" + raise NotImplementedError + + def _raw_gyroscope_dps(self): + """Returns raw (x, y, z) in deg/s for calibration sampling.""" + raise NotImplementedError + + def calibrate_accelerometer(self, samples): + """Calibrate accel, return (x, y, z) offsets in m/s²""" + sum_x, sum_y, sum_z = 0.0, 0.0, 0.0 + + for _ in range(samples): + ax, ay, az = self._raw_acceleration_mps2() + sum_x += ax + sum_y += ay + sum_z += az + time.sleep_ms(10) + + if FACING_EARTH == FACING_EARTH: + sum_z *= -1 + + 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 gyro, return (x, y, z) offsets in deg/s""" + sum_x, sum_y, sum_z = 0.0, 0.0, 0.0 + + for _ in range(samples): + gx, gy, gz = self._raw_gyroscope_dps() + sum_x += gx + sum_y += gy + sum_z += gz + time.sleep_ms(10) + + 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): + """Return dict with 'accel_offsets' and 'gyro_offsets' keys""" + return { + "accel_offsets": self.accel_offset, + "gyro_offsets": self.gyro_offset, + } + + def set_calibration(self, accel_offsets, gyro_offsets): + """Set calibration offsets from saved values""" + if accel_offsets: + self.accel_offset = list(accel_offsets) + if gyro_offsets: + self.gyro_offset = list(gyro_offsets) diff --git a/internal_filesystem/lib/mpos/imu/drivers/iio.py b/internal_filesystem/lib/mpos/imu/drivers/iio.py new file mode 100644 index 00000000..37a0a0db --- /dev/null +++ b/internal_filesystem/lib/mpos/imu/drivers/iio.py @@ -0,0 +1,302 @@ +import os + +from mpos.imu.drivers.base import IMUDriverBase + + +class IIODriver(IMUDriverBase): + """ + Read sensor data via Linux IIO sysfs. + + Typical base path: + /sys/bus/iio/devices/iio:device0 + """ + + accel_path: str + mag_path: str + gyro_path: str + + def __init__(self): + super().__init__() + self.accel_path = self.find_iio_device_with_file("in_accel_x_raw") + self.mag_path = self.find_iio_device_with_file("in_magn_x_raw") + self.gyro_path = self.find_iio_device_with_file("in_anglvel_x_raw") + self.available = any((self.accel_path, self.mag_path, self.gyro_path)) + + if not self.available: + print("IIO: no IIO sensors detected") + return + + if self.accel_path: + self.ensure_sampling_frequency_max(self.accel_path) + if self.mag_path: + self.ensure_sampling_frequency_max(self.mag_path) + if self.gyro_path: + self.ensure_sampling_frequency_max(self.gyro_path) + + def _p(self, name: str): + return self.accel_path + "/" + name + + def _exists(self, name): + try: + os.stat(name) + return True + except OSError: + return False + + def _is_dir(self, path): + # MicroPython: stat tuple, mode is [0] + try: + st = os.stat(path) + mode = st[0] + # directory bit (POSIX): 0o040000 + return (mode & 0o170000) == 0o040000 + except OSError: + return False + + def find_iio_device_with_file(self, filename, base_dir="/sys/bus/iio/devices/"): + """ + Returns full path to iio:deviceX that contains given filename, + e.g. "/sys/bus/iio/devices/iio:device0" + + Returns None if not found. + """ + + print("Is dir? ", self._is_dir(base_dir), base_dir) + try: + entries = os.listdir(base_dir) + except OSError: + print("Error listing dir") + return None + + for entry in entries: + print("Entry:", entry) + if not entry.startswith("iio:device"): + continue + + dev_path = base_dir + "/" + entry + if not self._is_dir(dev_path): + continue + + if self._exists(dev_path + "/" + filename): + return dev_path + + return None + + def _read_text(self, name: str) -> str: + if False: + print("Read: ", name) + f = open(name, "r") + try: + return f.readline().strip() + finally: + f.close() + + def _parse_available_freqs(self, text): + """ + IIO typically uses either: + "12.5 25 50 100" + or + "0.5 1 2 4 8 16" + + Returns list of floats. + """ + out = [] + for tok in text.replace(",", " ").split(): + out.append(float(tok)) + return out + + def _format_freq_for_sysfs(self, f): + """ + Kernel sysfs usually accepts either integer or decimal. + We'll keep it minimal: + - if f is whole number -> "100" + - else -> "12.5" + """ + if int(f) == f: + return str(int(f)) + # avoid scientific notation + s = ("%.6f" % f).rstrip("0").rstrip(".") + return s + + def _try_set_via_sudo_tee(self, path, value_str): + """ + Executes: + sh -c 'echo VALUE | sudo tee PATH' + Returns True if command returns 0. + """ + cmd = "sh -c 'echo %s | sudo tee %s >/dev/null'" % (value_str, path) + rc = os.system(cmd) + return rc == 0 + + def ensure_sampling_frequency_max(self, dev_path): + """ + dev_path: "/sys/bus/iio/devices/iio:deviceX" + + Returns: + (changed: bool, max_freq: float or None, current: float or None) + """ + if not dev_path: + return (False, None, None) + + sf = dev_path + "/sampling_frequency" + sfa = dev_path + "/sampling_frequency_available" + + # read current + cur_s = self._read_text(sf) + cur = float(cur_s) + + avail_s = self._read_text(sfa) + avail = self._parse_available_freqs(avail_s) + + maxf = max(avail) + + # already max (tolerate float fuzz) + if abs(cur - maxf) < 1e-6: + print("Already at max frequency") + return (False, maxf, cur) + + max_str = self._format_freq_for_sysfs(maxf) + + # Fallback: sudo tee + ok = self._try_set_via_sudo_tee(sf, max_str) + if not ok: + print("Can't switch to max frequency") + return (False, maxf, cur) + + new_cur = float(self._read_text(sf)) + + return (True, maxf, new_cur) + + def ensure_sampling_frequency_max_for_device_with_file(self, filename): + """ + Convenience wrapper: + - finds iio device containing filename + - sets sampling_frequency to maximum + """ + dev = self.find_iio_device_with_file(filename) + if dev is None: + return (None, False, None, None) + + changed, maxf, cur = self.ensure_sampling_frequency_max(dev) + return (dev, changed, maxf, cur) + + def _read_float(self, name: str) -> float: + return float(self._read_text(name)) + + def _read_int(self, name: str) -> int: + return int(self._read_text(name), 10) + + def _read_raw_scaled(self, raw_name: str, scale_name: str) -> float: + raw = self._read_int(raw_name) + scale = self._read_float(scale_name) + return raw * scale + + def read_temperature(self) -> float: + """ + Tries common IIO patterns: + - in_temp_input (already scaled, usually millidegree C) + - in_temp_raw + in_temp_scale + """ + return 12.34 + if not self.accel_path: + return None + + raw_path = self.accel_path + "/" + "in_temp_raw" + scale_path = self.accel_path + "/" + "in_temp_scale" + if not self._exists(raw_path) or not self._exists(scale_path): + return None + return self._read_raw_scaled(raw_path, scale_path) + + def _read_mount_matrix(self, p): + """ + Reads IIO mount matrix from *mount_matrix + + Format example: + "0, 1, 0; -1, 0, 0; 0, 0, 1" + + Returns: + 3x3 matrix as tuple of tuples (float) + """ + path = p + "/" + "in_accel_mount_matrix" + if not self._exists(path): + # Strange, librem 5 has different filename + path = self.accel_path + "/" + "mount_matrix" + if not self._exists(path): + return None + + text = self._read_text(path).strip() + + rows = [] + for row in text.split(";"): + rows.append(tuple(float(x.strip()) for x in row.split(","))) + + if len(rows) != 3 or any(len(r) != 3 for r in rows): + raise ValueError("Invalid mount matrix format") + + return tuple(rows) + + + def _apply_mount_matrix(self, ax, ay, az, p): + """ + Applies IIO mount matrix to acceleration vector. + + Returns rotated (ax, ay, az). + """ + M = self._read_mount_matrix(p) + if M is None: + return (ax, ay, az) + + x = M[0][0]*ax + M[0][1]*ay + M[0][2]*az + y = M[1][0]*ax + M[1][1]*ay + M[1][2]*az + z = M[2][0]*ax + M[2][1]*ay + M[2][2]*az + + return (x, y, z) + + def _raw_acceleration_mps2(self): + if not self.accel_path: + return (0.0, 0.0, 0.0) + scale_name = self.accel_path + "/" + "in_accel_scale" + + ax = self._read_raw_scaled(self.accel_path + "/" + "in_accel_x_raw", scale_name) + ay = self._read_raw_scaled(self.accel_path + "/" + "in_accel_y_raw", scale_name) + az = self._read_raw_scaled(self.accel_path + "/" + "in_accel_z_raw", scale_name) + + return self._apply_mount_matrix(ax, ay, az, self.accel_path) + + def _raw_gyroscope_dps(self): + if not self.gyro_path: + return (0.0, 0.0, 0.0) + scale_name = self.gyro_path + "/" + "in_anglvel_scale" + mul = 57.2957795 + + gx = mul * self._read_raw_scaled(self.gyro_path + "/" + "in_anglvel_x_raw", scale_name) + gy = mul * self._read_raw_scaled(self.gyro_path + "/" + "in_anglvel_y_raw", scale_name) + gz = mul * self._read_raw_scaled(self.gyro_path + "/" + "in_anglvel_z_raw", scale_name) + + return self._apply_mount_matrix(gx, gy, gz, self.gyro_path) + + def read_acceleration(self): + ax, ay, az = self._raw_acceleration_mps2() + return ( + ax - self.accel_offset[0], + ay - self.accel_offset[1], + az - self.accel_offset[2], + ) + + def read_gyroscope(self): + gx, gy, gz = self._raw_gyroscope_dps() + return ( + gx - self.gyro_offset[0], + gy - self.gyro_offset[1], + gz - self.gyro_offset[2], + ) + + def read_magnetometer(self) -> tuple[float, float, float]: + if not self.mag_path: + return (0.0, 0.0, 0.0) + + gx = self._read_raw_scaled(self.mag_path + "/" + "in_magn_x_raw", self.mag_path + "/" + "in_magn_x_scale") + gy = self._read_raw_scaled(self.mag_path + "/" + "in_magn_y_raw", self.mag_path + "/" + "in_magn_y_scale") + gz = self._read_raw_scaled(self.mag_path + "/" + "in_magn_z_raw", self.mag_path + "/" + "in_magn_z_scale") + + return self._apply_mount_matrix(gx, gy, gz, self.mag_path) diff --git a/internal_filesystem/lib/mpos/imu/drivers/mpu6886.py b/internal_filesystem/lib/mpos/imu/drivers/mpu6886.py new file mode 100644 index 00000000..9a3ef588 --- /dev/null +++ b/internal_filesystem/lib/mpos/imu/drivers/mpu6886.py @@ -0,0 +1,39 @@ +from mpos.imu.constants import GRAVITY +from mpos.imu.drivers.base import IMUDriverBase + + +class MPU6886Driver(IMUDriverBase): + """Wrapper for MPU6886 IMU (Waveshare board).""" + + def __init__(self, i2c_bus, address): + super().__init__() + from drivers.imu_sensor.mpu6886 import MPU6886 + + self.sensor = MPU6886(i2c_bus, address=address) + + def _raw_acceleration_mps2(self): + ax, ay, az = self.sensor.acceleration + return (ax * GRAVITY, ay * GRAVITY, az * GRAVITY) + + def _raw_gyroscope_dps(self): + gx, gy, gz = self.sensor.gyro + return (gx, gy, gz) + + def read_temperature(self): + return self.sensor.temperature + + def read_acceleration(self): + ax, ay, az = self._raw_acceleration_mps2() + return ( + ax - self.accel_offset[0], + ay - self.accel_offset[1], + az - self.accel_offset[2], + ) + + def read_gyroscope(self): + gx, gy, gz = self._raw_gyroscope_dps() + return ( + gx - self.gyro_offset[0], + gy - self.gyro_offset[1], + gz - self.gyro_offset[2], + ) diff --git a/internal_filesystem/lib/mpos/imu/drivers/qmi8658.py b/internal_filesystem/lib/mpos/imu/drivers/qmi8658.py new file mode 100644 index 00000000..cb061b23 --- /dev/null +++ b/internal_filesystem/lib/mpos/imu/drivers/qmi8658.py @@ -0,0 +1,46 @@ +from mpos.imu.constants import GRAVITY +from mpos.imu.drivers.base import IMUDriverBase + + +class QMI8658Driver(IMUDriverBase): + """Wrapper for QMI8658 IMU (Waveshare board).""" + + def __init__(self, i2c_bus, address): + super().__init__() + from drivers.imu_sensor.qmi8658 import QMI8658 + + _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, + ) + + def _raw_acceleration_mps2(self): + ax, ay, az = self.sensor.acceleration + return (ax * GRAVITY, ay * GRAVITY, az * GRAVITY) + + def _raw_gyroscope_dps(self): + gx, gy, gz = self.sensor.gyro + return (gx, gy, gz) + + def read_acceleration(self): + ax, ay, az = self._raw_acceleration_mps2() + return ( + ax - self.accel_offset[0], + ay - self.accel_offset[1], + az - self.accel_offset[2], + ) + + def read_gyroscope(self): + gx, gy, gz = self._raw_gyroscope_dps() + return ( + gx - self.gyro_offset[0], + gy - self.gyro_offset[1], + gz - self.gyro_offset[2], + ) + + def read_temperature(self): + return self.sensor.temperature diff --git a/internal_filesystem/lib/mpos/imu/drivers/wsen_isds.py b/internal_filesystem/lib/mpos/imu/drivers/wsen_isds.py new file mode 100644 index 00000000..6e8c4262 --- /dev/null +++ b/internal_filesystem/lib/mpos/imu/drivers/wsen_isds.py @@ -0,0 +1,46 @@ +from mpos.imu.constants import GRAVITY +from mpos.imu.drivers.base import IMUDriverBase + + +class WsenISDSDriver(IMUDriverBase): + """Wrapper for WSEN_ISDS IMU (Fri3d badge).""" + + def __init__(self, i2c_bus, address): + super().__init__() + from drivers.imu_sensor.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", + ) + + def _raw_acceleration_mps2(self): + ax, ay, az = self.sensor._read_raw_accelerations() + return ((ax / 1000) * GRAVITY, (ay / 1000) * GRAVITY, (az / 1000) * GRAVITY) + + def _raw_gyroscope_dps(self): + gx, gy, gz = self.sensor.read_angular_velocities() + return (gx / 1000.0, gy / 1000.0, gz / 1000.0) + + def read_acceleration(self): + ax, ay, az = self._raw_acceleration_mps2() + return ( + ax - self.accel_offset[0], + ay - self.accel_offset[1], + az - self.accel_offset[2], + ) + + def read_gyroscope(self): + gx, gy, gz = self._raw_gyroscope_dps() + return ( + gx - self.gyro_offset[0], + gy - self.gyro_offset[1], + gz - self.gyro_offset[2], + ) + + def read_temperature(self): + return self.sensor.temperature diff --git a/internal_filesystem/lib/mpos/imu/manager.py b/internal_filesystem/lib/mpos/imu/manager.py new file mode 100644 index 00000000..87eb9b95 --- /dev/null +++ b/internal_filesystem/lib/mpos/imu/manager.py @@ -0,0 +1,569 @@ +import time + +from mpos.imu.constants import ( + TYPE_ACCELEROMETER, + TYPE_GYROSCOPE, + TYPE_MAGNETIC_FIELD, + TYPE_IMU_TEMPERATURE, + TYPE_SOC_TEMPERATURE, + TYPE_TEMPERATURE, + FACING_EARTH, + FACING_SKY, + GRAVITY, + IMU_CALIBRATION_FILENAME, +) +from mpos.imu.sensor import Sensor +from mpos.imu.drivers.iio import IIODriver +from mpos.imu.drivers.qmi8658 import QMI8658Driver +from mpos.imu.drivers.wsen_isds import WsenISDSDriver +from mpos.imu.drivers.mpu6886 import MPU6886Driver + + +class ImuManager: + """Internal IMU manager (for SensorManager delegation).""" + + def __init__(self): + self._initialized = False + self._imu_driver = None + self._sensor_list = [] + self._i2c_bus = None + self._i2c_address = None + self._mounted_position = FACING_SKY + self._has_mcu_temperature = False + + def init(self, i2c_bus, address=0x6B, mounted_position=FACING_SKY): + self._i2c_bus = i2c_bus + self._i2c_address = address + self._mounted_position = mounted_position + + try: + import esp32 + + _ = esp32.mcu_temperature() + self._has_mcu_temperature = True + self._register_mcu_temperature_sensor() + except: + pass + + self._initialized = True + return True + + def init_iio(self): + self._imu_driver = IIODriver() + if not getattr(self._imu_driver, "available", True): + self._imu_driver = None + self._sensor_list = [] + self._initialized = False + return False + + self._sensor_list = [ + Sensor( + name="Magnetometer", + sensor_type=TYPE_MAGNETIC_FIELD, + vendor="Linux IIO", + version=1, + max_range="?", + resolution="?", + power_ma=0.2 + ), + Sensor( + name="Accelerometer", + sensor_type=TYPE_ACCELEROMETER, + vendor="Linux IIO", + version=1, + max_range="?", + resolution="?", + power_ma=10, + ), + Sensor( + name="Gyroscope", + sensor_type=TYPE_GYROSCOPE, + vendor="Linux IIO", + version=1, + max_range="?", + resolution="?", + power_ma=10, + ), + Sensor( + name="Temperature", + sensor_type=TYPE_IMU_TEMPERATURE, + vendor="Linux IIO", + version=1, + max_range="?", + resolution="?", + power_ma=10, + ), + ] + + self._load_calibration() + + self._initialized = True + return True + + def _ensure_imu_initialized(self): + if not self._initialized or self._imu_driver is not None: + return self._imu_driver is not None + + if self._i2c_bus: + try: + print("Try QMI8658 first (Waveshare board)") + chip_id = self._i2c_bus.readfrom_mem(self._i2c_address, 0x00, 1)[0] + print(f"{chip_id=:#04x}") + if chip_id == 0x05: + self._imu_driver = QMI8658Driver(self._i2c_bus, self._i2c_address) + self._register_qmi8658_sensors() + self._load_calibration() + print("Use QMI8658, ok") + return True + except Exception as exc: + print("No QMI8658:", exc) + + try: + print("Try WSEN_ISDS (fri3d_2024) or LSM6DSO (fri3d_2026)") + chip_id = self._i2c_bus.readfrom_mem(self._i2c_address, 0x0F, 1)[0] + print(f"{chip_id=:#04x}") + if chip_id == 0x6A or chip_id == 0x6C: + self._imu_driver = WsenISDSDriver(self._i2c_bus, self._i2c_address) + self._register_wsen_isds_sensors() + self._load_calibration() + print("Use WSEN_ISDS/LSM6DSO, ok") + return True + except Exception as exc: + print("No WSEN_ISDS or LSM6DSO:", exc) + + try: + print("Try MPU6886 (M5Stack FIRE)") + chip_id = self._i2c_bus.readfrom_mem(self._i2c_address, 0x75, 1)[0] + print(f"{chip_id=:#04x}") + if chip_id == 0x19: + self._imu_driver = MPU6886Driver(self._i2c_bus, self._i2c_address) + self._register_mpu6886_sensors() + self._load_calibration() + print("Use MPU6886, ok") + return True + except Exception as exc: + print("No MPU6886:", exc) + + return False + + def is_available(self): + return self._initialized + + def get_sensor_list(self): + self._ensure_imu_initialized() + return self._sensor_list.copy() if self._sensor_list else [] + + def get_default_sensor(self, sensor_type): + if self._initialized and sensor_type in (TYPE_ACCELEROMETER, TYPE_GYROSCOPE): + self._ensure_imu_initialized() + + for sensor in self._sensor_list: + if sensor.type == sensor_type: + return sensor + return None + + def read_sensor_once(self, sensor): + if sensor.type == TYPE_ACCELEROMETER: + if self._imu_driver: + ax, ay, az = self._imu_driver.read_acceleration() + if self._mounted_position == FACING_EARTH: + az *= -1 + return (ax, ay, az) + elif sensor.type == TYPE_GYROSCOPE: + if self._imu_driver: + return self._imu_driver.read_gyroscope() + elif sensor.type == TYPE_MAGNETIC_FIELD: + if self._imu_driver: + return self._imu_driver.read_magnetometer() + elif sensor.type == TYPE_IMU_TEMPERATURE: + if self._imu_driver: + return self._imu_driver.read_temperature() + elif sensor.type == TYPE_SOC_TEMPERATURE: + if self._has_mcu_temperature: + import esp32 + + return esp32.mcu_temperature() + elif sensor.type == TYPE_TEMPERATURE: + if self._imu_driver: + temp = self._imu_driver.read_temperature() + if temp is not None: + return temp + if self._has_mcu_temperature: + import esp32 + + return esp32.mcu_temperature() + return None + + def read_sensor(self, sensor): + if sensor is None: + return None + + if sensor.type in (TYPE_ACCELEROMETER, TYPE_GYROSCOPE): + self._ensure_imu_initialized() + + max_retries = 3 + retry_delay_ms = 20 + + for attempt in range(max_retries): + try: + return self.read_sensor_once(sensor) + except Exception as exc: + import sys + + sys.print_exception(exc) + error_msg = str(exc) + if "data not ready" in error_msg and attempt < max_retries - 1: + time.sleep_ms(retry_delay_ms) + continue + print("Exception reading sensor:", error_msg) + return None + + return None + + def calibrate_sensor(self, sensor, samples=100): + self._ensure_imu_initialized() + if not self.is_available() or sensor is None: + return None + + if sensor.type == TYPE_ACCELEROMETER: + offsets = self._imu_driver.calibrate_accelerometer(samples) + elif sensor.type == TYPE_GYROSCOPE: + offsets = self._imu_driver.calibrate_gyroscope(samples) + else: + return None + + if offsets: + self._save_calibration() + + return offsets + + def check_calibration_quality(self, samples=50): + self._ensure_imu_initialized() + if not self.is_available(): + return None + + try: + accel = self.get_default_sensor(TYPE_ACCELEROMETER) + gyro = self.get_default_sensor(TYPE_GYROSCOPE) + + accel_samples = [[], [], []] + gyro_samples = [[], [], []] + + for _ in range(samples): + if accel: + data = self.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 = self.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) + + 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) + + issues = [] + scores = [] + + if accel: + accel_max_variance = max(accel_variance) + variance_score = max(0.0, 1.0 - (accel_max_variance / 1.0)) + scores.append(variance_score) + if accel_max_variance > 0.5: + issues.append( + f"High accelerometer variance: {accel_max_variance:.3f} m/s²" + ) + + 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)) + 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²") + + if gyro: + gyro_max_variance = max(gyro_variance) + variance_score = max(0.0, 1.0 - (gyro_max_variance / 10.0)) + scores.append(variance_score) + if gyro_max_variance > 5.0: + issues.append( + f"High gyroscope variance: {gyro_max_variance:.3f} deg/s" + ) + + gx, gy, gz = gyro_mean + error = (abs(gx) + abs(gy) + abs(gz)) / 3.0 + expected_score = max(0.0, 1.0 - (error / 10.0)) + 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" + ) + + quality_score = sum(scores) / len(scores) if scores else 0.0 + + 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 exc: + print(f"[SensorManager] Error checking calibration quality: {exc}") + return None + + def check_stationarity( + self, + samples=30, + variance_threshold_accel=0.5, + variance_threshold_gyro=5.0, + ): + self._ensure_imu_initialized() + if not self.is_available(): + return None + + try: + accel = self.get_default_sensor(TYPE_ACCELEROMETER) + gyro = self.get_default_sensor(TYPE_GYROSCOPE) + + accel_samples = [[], [], []] + gyro_samples = [[], [], []] + + for _ in range(samples): + if accel: + data = self.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 = self.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) + + 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 + + accel_stationary = max_accel_var < variance_threshold_accel + gyro_stationary = max_gyro_var < variance_threshold_gyro + is_stationary = accel_stationary and gyro_stationary + + 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 exc: + print(f"[SensorManager] Error checking stationarity: {exc}") + return None + + def _register_qmi8658_sensors(self): + self._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_mpu6886_sensors(self): + self._sensor_list = [ + Sensor( + name="MPU6886 Accelerometer", + sensor_type=TYPE_ACCELEROMETER, + vendor="InvenSense", + version=1, + max_range="±16g", + resolution="0.0024 m/s²", + power_ma=0.2, + ), + Sensor( + name="MPU6886 Gyroscope", + sensor_type=TYPE_GYROSCOPE, + vendor="InvenSense", + version=1, + max_range="±256 deg/s", + resolution="0.002 deg/s", + power_ma=0.7, + ), + Sensor( + name="MPU6886 Temperature", + sensor_type=TYPE_IMU_TEMPERATURE, + vendor="InvenSense", + version=1, + max_range="-40°C to +85°C", + resolution="0.05°C", + power_ma=0, + ), + ] + + def _register_wsen_isds_sensors(self): + self._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(self): + self._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, + ) + ) + + def _load_calibration(self): + if not self._imu_driver: + return + + try: + from mpos.config import SharedPreferences + + 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: + self._imu_driver.set_calibration(accel_offsets, gyro_offsets) + except: + pass + + def _save_calibration(self): + if not self._imu_driver: + return + + try: + from mpos.config import SharedPreferences + + prefs = SharedPreferences( + "com.micropythonos.settings", filename=IMU_CALIBRATION_FILENAME + ) + editor = prefs.edit() + + cal = self._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 + + +def _calc_mean_variance(samples_list): + 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): + 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 diff --git a/internal_filesystem/lib/mpos/imu/sensor.py b/internal_filesystem/lib/mpos/imu/sensor.py new file mode 100644 index 00000000..dc819f7d --- /dev/null +++ b/internal_filesystem/lib/mpos/imu/sensor.py @@ -0,0 +1,25 @@ +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})" diff --git a/internal_filesystem/lib/mpos/info.py b/internal_filesystem/lib/mpos/info.py deleted file mode 100644 index 22bb09cd..00000000 --- a/internal_filesystem/lib/mpos/info.py +++ /dev/null @@ -1,11 +0,0 @@ -CURRENT_OS_VERSION = "0.5.1" - -# Unique string that defines the hardware, used by OSUpdate and the About app -_hardware_id = "missing-hardware-info" - -def set_hardware_id(value): - global _hardware_id - _hardware_id = value - -def get_hardware_id(): - return _hardware_id diff --git a/internal_filesystem/lib/mpos/main.py b/internal_filesystem/lib/mpos/main.py index 36ea885a..10c007ca 100644 --- a/internal_filesystem/lib/mpos/main.py +++ b/internal_filesystem/lib/mpos/main.py @@ -1,46 +1,273 @@ -import task_handler import _thread import lvgl as lv -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" + +from mpos import AppearanceManager, AppManager, BuildInfo, DeviceInfo, DisplayMetrics, SharedPreferences, TaskManager + +def init_rootscreen(): + """Initialize the root screen and set display metrics.""" + screen = lv.screen_active() + disp = screen.get_display() + width = disp.get_horizontal_resolution() + height = disp.get_vertical_resolution() + dpi = disp.get_dpi() + + # Initialize DisplayMetrics with actual display values + DisplayMetrics.set_resolution(width, height) + DisplayMetrics.set_dpi(dpi) + print(f"init_rootscreen set resolution to {width}x{height} at {dpi} DPI") + + # Show logo + img = lv.image(screen) + img.set_src("M:builtin/res/mipmap-mdpi/MicroPythonOS-logo-white-long-w296.png") # from the MPOS-logo repo + if width < 296: + img.set_scale(int(256 * width/296)) + img.set_blend_mode(lv.BLEND_MODE.DIFFERENCE) + img.center() + +def single_address_i2c_scan(i2c_bus, address): + """ + Scan a specific I2C address to check if a device is present. + + Args: + i2c_bus: An I2C bus object (machine.I2C instance) + address: Integer address to scan (0-127) + + Returns: + True if a device responds at the specified address, False otherwise + """ + print(f"Attempt to write a single byte to I2C bus address 0x{address:02x}...") + try: + # Attempt to write a single byte to the address + # This will raise an exception if no device responds + i2c_bus.writeto(address, b"") + print("Write test successful") + return True + except OSError as e: + print(f"No device at this address: {e}") + return False + except Exception as e: + # Handle any other exceptions gracefully + print(f"scan error: {e}") + return False + +def detect_lilygo_t_hmi(): + from machine import Pin, SoftSPI + import time + + try: + sck = Pin(1) + mosi = Pin(3) + miso = Pin(4) + cs = Pin(2, Pin.OUT, value=1) + irq = Pin(9, Pin.IN, Pin.PULL_UP) + + spi = SoftSPI( + baudrate=500000, + polarity=0, + phase=0, + sck=sck, + mosi=mosi, + miso=miso, + ) + + def read_cmd(cmd): + tx = bytearray([cmd, 0x00, 0x00]) + rx = bytearray(3) + + cs(0) + spi.write_readinto(tx, rx) + cs(1) + + return ((rx[1] << 8) | rx[2]) >> 3 + + samples = [] + for _ in range(5): + vals = ( + read_cmd(0xD0), # X + read_cmd(0x90), # Y + read_cmd(0xB0), # Z1 + irq.value(), + ) + samples.append(vals) + print("T-HMI touch sample:", vals) + time.sleep_ms(20) + + # Observed stable idle signature on LilyGO T-HMI: + # X=0, Y=4095, Z1=0/1, IRQ=1 + signature_hits = sum( + x == 0 and y == 4095 and z in (0, 1) and irqv == 1 + for x, y, z, irqv in samples + ) + + print(f"T-HMI signature hits: {signature_hits}/5") + + if signature_hits >= 4: + print("LilyGO T-HMI touch signature matched") + return True + + except Exception as e: + print(f"LilyGO T-HMI detection failed: {e}") + + finally: + try: + Pin(1, Pin.IN, pull=None) + Pin(2, Pin.IN, pull=None) + Pin(3, Pin.IN, pull=None) + Pin(4, Pin.IN, pull=None) + Pin(9, Pin.IN, pull=None) + except Exception: + pass + + return False + +def fail_save_i2c(sda, scl): + from machine import I2C, Pin + + print(f"Try to I2C initialized on {sda=} {scl=}") + try: + i2c0 = I2C(0, sda=Pin(sda), scl=Pin(scl)) + except Exception as e: + print(f"fail_save_i2c failed: {e}") + return None 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}") + print("fail_save_i2c ok") + return i2c0 + +def restore_i2c(sda, scl): + from machine import Pin + + Pin(sda, Pin.IN, pull=None) + Pin(scl, Pin.IN, pull=None) + +def detect_board(): + import sys + if sys.platform == "linux" or sys.platform == "darwin": # linux and macOS + return "linux" + elif sys.platform == "esp32": + + ''' + # Reading and storing all pinstates can be useful for board detection + # But reading some pins can break peripherals + # So it's disabled by default - it's more for development + try: + import mpos + from mpos.board import pinstates + mpos.pinstates = pinstates.read_all_pins(skiplist = [7,8]) + except Exception as e: + print("pinstates: WARNING: failed to read pins:", e) + ''' + + # First do unique_id-based board detections because they're fast and don't mess with actual hardware configurations + import machine + unique_id_prefixes = machine.unique_id()[0:3] + + print("unPhone ?") + if unique_id_prefixes == b'\x30\x30\xf9': + return "unphone" + + print("odroid_go ?") + if unique_id_prefixes == b'\x30\xae\xa4': + return "odroid_go" + + + # Do I2C-based board detection + # IMPORTANT: ESP32 GPIO 6-11 are internal SPI flash pins and will cause WDT reset if used. + # ESP32-S3 has more usable GPIOs (up to 48). Detect chip variant first to skip unsafe probes. + is_esp32s3 = "S3" in sys.implementation._machine.upper() + + if is_esp32s3: + print("lilygo_t_hmi ?") + if detect_lilygo_t_hmi(): + return "lilygo_t_hmi" + + # Do I2C-based board detection + print("lilygo_t_watch_s3_plus ?") + if i2c0 := fail_save_i2c(sda=10, scl=11): + if single_address_i2c_scan(i2c0, 0x19): # IMU on 0x19, vibrator on 0x5A and scan also shows: [52, 81] + return "lilygo_t_watch_s3_plus" # example MAC address: D0:CF:13:33:36:306 + restore_i2c(sda=10, scl=11) + + print("matouch_esp32_s3_spi_ips_2_8_with_camera_ov3660 ?") + if i2c0 := fail_save_i2c(sda=39, scl=38): + if single_address_i2c_scan(i2c0, 0x14) or single_address_i2c_scan(i2c0, 0x5D): # "ghost" or real GT911 touch screen + return "matouch_esp32_s3_spi_ips_2_8_with_camera_ov3660" + restore_i2c(sda=39, scl=38) # fix pin 39 (data0) breaking lilygo_t_display_s3's display + + print("waveshare_esp32_s3_touch_lcd_2 ?") + if i2c0 := fail_save_i2c(sda=48, scl=47): + # IO48 is floating on matouch_esp32_s3_spi_ips_2_8_with_camera_ov3660 and therefore, using that for I2C will find many devices, so do this after matouch_esp32_s3_spi_ips_2_8_with_camera_ov3660 + if single_address_i2c_scan(i2c0, 0x15) and single_address_i2c_scan(i2c0, 0x6B): # CST816S touch screen and IMU + return "waveshare_esp32_s3_touch_lcd_2" + restore_i2c(sda=48, scl=47) # fix pin 47 (data6) and 48 (data7) breaking lilygo_t_display_s3's display + + print("fri3d_2024 ?") + if i2c0 := fail_save_i2c(sda=9, scl=18): + if single_address_i2c_scan(i2c0, 0x6A): # ) 0x15: CST8 touch, 0x6A: IMU + return "fri3d_2026" + if single_address_i2c_scan(i2c0, 0x6B): # IMU (plus possibly the Communicator's LANA TNY at 0x38) + return "fri3d_2024" + restore_i2c(sda=9, scl=18) + + else: # not is_esp32s3 + + print("m5stack_core2 ?") + if i2c0 := fail_save_i2c(sda=21, scl=22): + if single_address_i2c_scan(i2c0, 0x34): # AXP192 power management (Core2 has it, Fire doesn't) + return "m5stack_core2" + + print("m5stack_fire ?") + if i2c0 := fail_save_i2c(sda=21, scl=22): + if single_address_i2c_scan(i2c0, 0x68): # IMU (MPU6886) + return "m5stack_fire" + restore_i2c(sda=21, scl=22) + + # On devices without I2C, we use known GPIO states + from machine import Pin + + if is_esp32s3: + print("(emulated) lilygo_t_display_s3 ?") + try: + # 2 buttons have PCB pull-ups so they'll be high unless pressed + pin0 = Pin(0, Pin.IN) + pin14 = Pin(14, Pin.IN) + if pin0.value() == 1 and pin14.value() == 1: + return "lilygo_t_display_s3" # display gets confused by the i2c stuff below + except Exception as e: + print(f"lilygo_t_display_s3 detection got exception: {e}") + + print("Unknown board: couldn't detect known I2C devices or unique_id prefix") + + +# EXECUTION STARTS HERE + +print(f"MicroPythonOS {BuildInfo.version.release} running lib/mpos/main.py") +board = detect_board() +if board: + print(f"Detected {board} system, importing mpos.board.{board}") + DeviceInfo.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") +# Needed to load the logo from storage: +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) + +prefs = SharedPreferences("com.micropythonos.settings") # if not value is set, it will start the HowTo app -mpos.ui.set_theme(prefs) -init_rootscreen() +AppearanceManager.init(prefs) +init_rootscreen() # shows the boot logo mpos.ui.topmenu.create_notification_bar() -mpos.ui.topmenu.create_drawer(mpos.ui.display) +mpos.ui.topmenu.create_drawer() mpos.ui.handle_back_swipe() mpos.ui.handle_top_swipe() @@ -50,47 +277,79 @@ 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: +# Custom exception handler that does not deinit() the TaskHandler because then the UI hangs: 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) + print(f"TaskHandler's custom_exception_handler called: {e}") + import sys + sys.print_exception(e) # NOQA + # No need to deinit() and re-init LVGL: + #mpos.ui.task_handler.deinit() # default task handler does this, but then things hang + #focusgroup = lv.group_get_default() + #if focusgroup: # on esp32 this may not be set + # otherwise it does focus_next and then crashes while doing lv.deinit() + #focusgroup.remove_all_objs() + #focusgroup.delete() + #lv.deinit() -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) +import task_handler +# 5ms is recommended for MicroPython+LVGL on desktop (less results in lower framerate but still okay) +# 1ms gives highest framerate on esp32-s3's but might have side effects? +mpos.ui.task_handler = task_handler.TaskHandler(duration=1, exception_hook=custom_exception_handler) +# Convenient for apps to be able to access these: +mpos.ui.task_handler.TASK_HANDLER_STARTED = task_handler.TASK_HANDLER_STARTED +mpos.ui.task_handler.TASK_HANDLER_FINISHED = task_handler.TASK_HANDLER_FINISHED try: from mpos.net.wifi_service import WifiService - _thread.stack_size(mpos.apps.good_stack_size()) + _thread.stack_size(TaskManager.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: - mpos.apps.start_app(auto_start_app) +# Start launcher first so it's always at bottom of stack +launcher_app = AppManager.get_launcher() +started_launcher = AppManager.start_app(launcher_app.fullname) +# Then start auto_start_app_early if configured +auto_start_app_early = prefs.get_string("auto_start_app_early", "com.micropythonos.howto") +if auto_start_app_early and launcher_app.fullname != auto_start_app_early: + result = AppManager.start_app(auto_start_app_early) + if result is not True: + print(f"WARNING: could not run {auto_start_app_early} app") +else: # if no auto_start_app_early was configured (this could be improved to start it *after* auto_start_app_early finishes) + auto_start_app = prefs.get_string("auto_start_app", None) + if auto_start_app and launcher_app.fullname != auto_start_app and auto_start_app_early != auto_start_app: + result = AppManager.start_app(auto_start_app) + if result is not True: + print(f"WARNING: could not run {auto_start_app} app") -if not started_launcher: - print(f"WARNING: launcher {launcher_app} failed to start, not cancelling OTA update rollback") -else: +# 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() +TaskManager.create_task(asyncio_repl()) # only gets started after TaskManager.start() + +try: + from mpos import WebServer + WebServer.auto_start() +except Exception as e: + print(f"Could not start webserver - this is normal on desktop systems: {e}") + +async def ota_rollback_cancel(): try: - import ota.rollback - ota.rollback.cancel() + from esp32 import Partition + Partition.mark_app_valid_cancel_rollback() 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: + TaskManager.create_task(ota_rollback_cancel()) # only gets started after TaskManager.start() + +try: + TaskManager.start() # do this at the end because it doesn't return +except KeyboardInterrupt as k: + print(f"TaskManager.start() got KeyboardInterrupt, falling back to REPL shell...") # only works if no aiorepl is running +except Exception as e: + print(f"TaskManager.start() got exception: {e}") diff --git a/internal_filesystem/lib/mpos/net/__init__.py b/internal_filesystem/lib/mpos/net/__init__.py index 0cc7f355..1af8d8e5 100644 --- a/internal_filesystem/lib/mpos/net/__init__.py +++ b/internal_filesystem/lib/mpos/net/__init__.py @@ -1 +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 index ffb6dd3b..b26c88da 100644 --- a/internal_filesystem/lib/mpos/net/connectivity_manager.py +++ b/internal_filesystem/lib/mpos/net/connectivity_manager.py @@ -3,9 +3,6 @@ import sys import time -import requests -import usocket -from machine import Timer try: import network @@ -37,6 +34,7 @@ def __init__(self): 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) + from machine import Timer # Import Timer lazily to allow test mocks to be set up first 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) @@ -89,11 +87,39 @@ 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 + 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 + + +# ============================================================================ +# Class method delegation (at module level) +# ============================================================================ + +_original_methods = {} +_methods_to_delegate = [ + 'is_online', 'is_wifi_connected', 'wait_until_online', + 'register_callback', 'unregister_callback' +] + +for method_name in _methods_to_delegate: + _original_methods[method_name] = getattr(ConnectivityManager, method_name) + +def _make_class_method(method_name): + """Create a class method that delegates to the singleton instance.""" + original_method = _original_methods[method_name] + + @classmethod + def class_method(cls, *args, **kwargs): + instance = cls.get() + return original_method(instance, *args, **kwargs) + + return class_method + +for method_name in _methods_to_delegate: + setattr(ConnectivityManager, method_name, _make_class_method(method_name)) 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..b813ac28 --- /dev/null +++ b/internal_filesystem/lib/mpos/net/download_manager.py @@ -0,0 +1,304 @@ +""" +download_manager.py - HTTP download service for MicroPythonOS + +Provides synchronous and asynchronous HTTP downloads with flexible output modes: +- Download to memory (returns bytes) +- Download to file (returns bool) +- Streaming with chunk callback (returns bool) + +Features: +- Retry logic (3 attempts per chunk, 10s timeout) +- Progress tracking with 2-decimal precision +- Download speed reporting +- Resume support via Range headers +- Network error detection utilities +""" + +# Constants +_DEFAULT_CHUNK_SIZE = 4 * 1024 +_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 + + +class DownloadManager: + """Centralized HTTP download service with flexible output modes.""" + + @classmethod + def download_url(cls, url, outfile=None, total_size=None, + progress_callback=None, chunk_callback=None, headers=None, + speed_callback=None): + """Download a URL with flexible output modes (sync or async wrapper). + + This method automatically detects whether it's being called from an async context + and either returns a coroutine (for await) or runs synchronously. + + Args: + url (str): URL to download (required) + outfile (str, optional): Path to write file. If None, returns bytes. + total_size (int, optional): Expected size in bytes for progress tracking. + progress_callback (coroutine, optional): async def callback(percent: float) + chunk_callback (coroutine, optional): async def callback(chunk: bytes) + headers (dict, optional): HTTP headers (e.g., {'Range': 'bytes=1000-'}) + speed_callback (coroutine, optional): async def callback(bytes_per_second: float) + + Returns: + bytes: Downloaded content (if outfile and chunk_callback are None) + bool: True if successful (when using outfile or chunk_callback) + coroutine: If called from async context, returns awaitable + + Raises: + ValueError: If both outfile and chunk_callback are provided + """ + # Check if we're in an async context + try: + import asyncio + try: + asyncio.current_task() + # We're in an async context, return the coroutine + return cls._download_url_async(url, outfile, total_size, + progress_callback, chunk_callback, headers, + speed_callback) + except RuntimeError: + # No running event loop, run synchronously + return asyncio.run(cls._download_url_async(url, outfile, total_size, + progress_callback, chunk_callback, headers, + speed_callback)) + except ImportError: + # asyncio not available, shouldn't happen but handle gracefully + raise ImportError("asyncio module not available") + + @classmethod + async def _download_url_async(cls, url, outfile=None, total_size=None, + progress_callback=None, chunk_callback=None, headers=None, + speed_callback=None): + """Download a URL with flexible output modes. + + Args: + url (str): URL to download (required) + outfile (str, optional): Path to write file. If None, returns bytes. + total_size (int, optional): Expected size in bytes for progress tracking. + progress_callback (coroutine, optional): async def callback(percent: float) + chunk_callback (coroutine, optional): async def callback(chunk: bytes) + headers (dict, optional): HTTP headers (e.g., {'Range': 'bytes=1000-'}) + speed_callback (coroutine, optional): async def callback(bytes_per_second: float) + + Returns: + bytes: Downloaded content (if outfile and chunk_callback are None) + bool: True if successful (when using outfile or chunk_callback) + + Raises: + ValueError: If both outfile and chunk_callback are provided + """ + # 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." + ) + + import aiohttp + session = aiohttp.ClientSession() + sslctx = None # for http + if url.lower().startswith("https"): + import ssl + sslctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + sslctx.verify_mode = ssl.CERT_OPTIONAL # CERT_REQUIRED might fail because MBEDTLS_ERR_SSL_CA_CHAIN_REQUIRED + + 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, ssl=sslctx) as response: + if response.status < 200 or response.status >= 400: + print(f"DownloadManager: HTTP error {response.status}") + raise RuntimeError(f"HTTP {response.status}") + + # Figure out total size and starting offset (for resume support) + print("DownloadManager: Response headers:", response.headers) + resume_offset = 0 # Starting byte offset (0 for new downloads, >0 for resumed) + + if total_size is None: + # response.headers is a dict (after parsing) or None/list (before parsing) + try: + if isinstance(response.headers, dict): + # Check for Content-Range first (used when resuming with Range header) + # Format: 'bytes 1323008-3485807/3485808' + # START is the resume offset, TOTAL is the complete file size + content_range = response.headers.get('Content-Range') + if content_range: + # Parse total size and starting offset from Content-Range header + # Example: 'bytes 1323008-3485807/3485808' -> offset=1323008, total=3485808 + if '/' in content_range and ' ' in content_range: + # Extract the range part: '1323008-3485807' + range_part = content_range.split(' ')[1].split('/')[0] + # Extract starting offset + resume_offset = int(range_part.split('-')[0]) + # Extract total size + total_size = int(content_range.split('/')[-1]) + print(f"DownloadManager: Resuming from byte {resume_offset}, total size: {total_size}") + + # Fall back to Content-Length if Content-Range not present + if total_size is None: + content_length = response.headers.get('Content-Length') + if content_length: + total_size = int(content_length) + print(f"DownloadManager: Using Content-Length: {total_size}") + except (AttributeError, TypeError, ValueError, IndexError) as e: + print(f"DownloadManager: Could not parse Content-Range/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 = resume_offset # Start from resume offset for accurate progress + 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() + raise OSError(-110, "Failed to download chunk after retries") + + 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 + print(f"DownloadManager: Speed: {bytes_per_second} bytes / second") + 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() + raise # Re-raise the exception instead of suppressing it + + @staticmethod + def is_network_error(exception): + """Check if exception is a recoverable network error. + + Args: + exception: Exception to check + + Returns: + bool: True if this is a network error that can be retried + """ + error_str = str(exception).lower() + error_repr = repr(exception).lower() + + # Common network error codes and messages + network_indicators = [ + '-113', '-104', '-110', '-118', '-202', # Error codes + 'econnaborted', 'econnreset', 'etimedout', 'ehostunreach', # Error names + 'connection reset', 'connection aborted', # Error messages + 'broken pipe', 'network unreachable', 'host unreachable', + 'failed to download chunk' # From download_manager OSError(-110) + ] + + return any(indicator in error_str or indicator in error_repr + for indicator in network_indicators) + + @staticmethod + def get_resume_position(outfile): + """Get the current size of a partially downloaded file. + + Args: + outfile: Path to file + + Returns: + int: File size in bytes, or 0 if file doesn't exist + """ + try: + import os + return os.stat(outfile)[6] # st_size + except OSError: + return 0 + + +# Module-level exports for convenience +is_network_error = DownloadManager.is_network_error +get_resume_position = DownloadManager.get_resume_position diff --git a/internal_filesystem/lib/mpos/net/wifi_service.py b/internal_filesystem/lib/mpos/net/wifi_service.py index 25d777a7..e1905339 100644 --- a/internal_filesystem/lib/mpos/net/wifi_service.py +++ b/internal_filesystem/lib/mpos/net/wifi_service.py @@ -17,13 +17,17 @@ import mpos.config import mpos.time +WIFI_SERVICE_PREFS_KEY = "com.micropythonos.system.wifiservice" # com.micropythonos.settings.wifi would make more sense but legacy devices use this +HOTSPOT_PREFS_KEY = "com.micropythonos.settings.hotspot" + # 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)") + pass + #print("WifiService: network module not available (desktop mode)") class WifiService: @@ -36,48 +40,185 @@ class WifiService: """ # Class-level lock to prevent concurrent WiFi operations - # Used by WiFi app when scanning to avoid conflicts with connection attempts + # Use is_busy() to check state; operations like scan_networks() manage this automatically wifi_busy = False # Dictionary of saved access points {ssid: {password: "..."}} access_points = {} + # Desktop mode: simulated connected SSID (None = not connected) + _desktop_connected_ssid = None + + # Hotspot state tracking + hotspot_enabled = False + _temp_disable_state = None + _needs_hotspot_restore = False + + @staticmethod + def _is_desktop_mode(network_module=None): + return not HAS_NETWORK_MODULE and network_module is None + @staticmethod - def connect(network_module=None): + def _get_network_module(network_module=None): + return network_module if network_module else network + + @staticmethod + def _get_sta_wlan(net): + return net.WLAN(net.STA_IF) + + @staticmethod + def _get_ap_wlan(net): + return net.WLAN(net.AP_IF) + + @staticmethod + def _restore_hotspot_if_needed(network_module=None): + if WifiService._needs_hotspot_restore: + WifiService._needs_hotspot_restore = False + WifiService.enable_hotspot(network_module=network_module) + + @staticmethod + def _get_hotspot_config(): + prefs = mpos.config.SharedPreferences(HOTSPOT_PREFS_KEY) + return { + "enabled": prefs.get_bool("enabled", False), + "ssid": prefs.get_string("ssid", "MicroPythonOS"), + "password": prefs.get_string("password", ""), + "authmode": prefs.get_string("authmode", None), + } + + @staticmethod + def _resolve_hotspot_authmode(net, password, authmode_value): + if isinstance(authmode_value, int): + return authmode_value + if isinstance(authmode_value, str): + authmode_key = authmode_value.lower().strip() + if authmode_key == "none": + return net.AUTH_OPEN + return net.AUTH_WPA2_PSK + if authmode_value is None: + if password: + return net.AUTH_WPA2_PSK + return net.AUTH_OPEN + return net.AUTH_WPA2_PSK + + @staticmethod + def enable_hotspot(network_module=None): + if WifiService.wifi_busy: + print("WifiService: Cannot enable hotspot, WiFi is busy") + return False + + if WifiService._is_desktop_mode(network_module): + WifiService.hotspot_enabled = True + print("WifiService: Desktop mode, hotspot enabled (simulated)") + return True + + net = WifiService._get_network_module(network_module) + config = WifiService._get_hotspot_config() + + try: + sta = WifiService._get_sta_wlan(net) + if sta.active() or sta.isconnected(): + sta.disconnect() + sta.active(False) + + ap = WifiService._get_ap_wlan(net) + ap.active(True) + + authmode = WifiService._resolve_hotspot_authmode( + net, config.get("password"), config.get("authmode") + ) + + ap_config = { + "essid": config.get("ssid"), + "authmode": authmode, + } + if config.get("password"): + ap_config["password"] = config.get("password") + + ap.config(**ap_config) + ap.ifconfig(("192.168.4.1", "255.255.255.0", "192.168.4.1", "8.8.8.8")) + + WifiService.hotspot_enabled = True + print("WifiService: Hotspot enabled") + return True + except Exception as e: + try: + ap = WifiService._get_ap_wlan(net) + ap.active(False) + except Exception: + pass + WifiService.hotspot_enabled = False + print(f"WifiService: Failed to enable hotspot: {e}") + return False + + @staticmethod + def disable_hotspot(network_module=None): + if WifiService._is_desktop_mode(network_module): + WifiService.hotspot_enabled = False + print("WifiService: Desktop mode, hotspot disabled (simulated)") + return + + try: + net = WifiService._get_network_module(network_module) + ap = WifiService._get_ap_wlan(net) + ap.active(False) + WifiService.hotspot_enabled = False + print("WifiService: Hotspot disabled") + except Exception: + WifiService.hotspot_enabled = False + + @staticmethod + def is_hotspot_enabled(network_module=None): + if WifiService._is_desktop_mode(network_module): + return WifiService.hotspot_enabled + try: + net = WifiService._get_network_module(network_module) + ap = WifiService._get_ap_wlan(net) + WifiService.hotspot_enabled = ap.active() + return WifiService.hotspot_enabled + except Exception: + return WifiService.hotspot_enabled + + @staticmethod + def connect(network_module=None, time_module=None): """ Scan for available networks and connect to the first saved network found. Networks are tried in order of signal strength (strongest first). + Hidden networks are also tried even if they don't appear in the scan. Args: network_module: Network module for dependency injection (testing) + time_module: Time 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() + # Scan for available networks using internal method + networks = WifiService._scan_networks_raw(network_module) # 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) + # Track which SSIDs we've tried (to avoid retrying hidden networks) + tried_ssids = set() + for n in networks: ssid = n[0].decode() rssi = n[3] + tried_ssids.add(ssid) 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): + if WifiService.attempt_connecting( + ssid, + password, + network_module=network_module, + time_module=time_module, + ): print(f"WifiService: Connected to '{ssid}'") return True else: @@ -85,6 +226,23 @@ def connect(network_module=None): else: print(f"WifiService: Skipping '{ssid}' (not configured)") + # Try hidden networks that weren't in the scan results + for ssid, config in WifiService.access_points.items(): + if config.get("hidden") and ssid not in tried_ssids: + password = config.get("password") + print(f"WifiService: Attempting hidden network '{ssid}'") + + if WifiService.attempt_connecting( + ssid, + password, + network_module=network_module, + time_module=time_module, + ): + print(f"WifiService: Connected to hidden network '{ssid}'") + return True + else: + print(f"WifiService: Failed to connect to hidden network '{ssid}'") + print("WifiService: No saved networks found or connected") return False @@ -104,17 +262,30 @@ def attempt_connecting(ssid, password, network_module=None, time_module=None): """ print(f"WifiService: Connecting to SSID: {ssid}") - net = network_module if network_module else network time_mod = time_module if time_module else time + if WifiService.is_hotspot_enabled(network_module=network_module): + WifiService._needs_hotspot_restore = True + WifiService.disable_hotspot(network_module=network_module) + + # Desktop mode - simulate successful connection + if WifiService._is_desktop_mode(network_module): + print("WifiService: Desktop mode, simulating connection...") + time_mod.sleep(2) + WifiService._desktop_connected_ssid = ssid + print(f"WifiService: Simulated connection to '{ssid}' successful") + return True + + net = WifiService._get_network_module(network_module) + try: - wlan = net.WLAN(net.STA_IF) + wlan = WifiService._get_sta_wlan(net) 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") + print(f"WifiService: Connected to '{ssid}' after {i+1} seconds with IP: {wlan.ipconfig('addr4')}") # Sync time from NTP server if possible try: @@ -122,21 +293,25 @@ def attempt_connecting(ssid, password, network_module=None, time_module=None): except Exception as e: print(f"WifiService: Could not sync time: {e}") + WifiService._needs_hotspot_restore = False return True elif not wlan.active(): # WiFi was disabled during connection attempt print("WifiService: WiFi disabled during connection, aborting") + WifiService._restore_hotspot_if_needed(network_module=network_module) return False print(f"WifiService: Waiting for connection, attempt {i+1}/10") time_mod.sleep(1) print(f"WifiService: Connection timeout for '{ssid}'") + WifiService._restore_hotspot_if_needed(network_module=network_module) return False except Exception as e: print(f"WifiService: Connection error: {e}") + WifiService._restore_hotspot_if_needed(network_module=network_module) return False @staticmethod @@ -153,47 +328,62 @@ def auto_connect(network_module=None, time_module=None): """ print("WifiService: Auto-connect thread starting") + hotspot_config = WifiService._get_hotspot_config() + if hotspot_config.get("enabled"): + print("WifiService: Hotspot enabled, skipping STA auto-connect") + WifiService.enable_hotspot(network_module=network_module) + return + if WifiService.is_hotspot_enabled(network_module=network_module): + WifiService._needs_hotspot_restore = True + WifiService.disable_hotspot(network_module=network_module) + # Load saved access points from config WifiService.access_points = mpos.config.SharedPreferences( - "com.micropythonos.system.wifiservice" + WIFI_SERVICE_PREFS_KEY ).get_dict("access_points") if not len(WifiService.access_points): + WifiService._restore_hotspot_if_needed(network_module=network_module) print("WifiService: No access points configured, exiting") return # Check if WiFi is busy (e.g., WiFi app is scanning) if WifiService.wifi_busy: + WifiService._restore_hotspot_if_needed(network_module=network_module) print("WifiService: WiFi busy, auto-connect aborted") return WifiService.wifi_busy = True + connected = False try: - if not HAS_NETWORK_MODULE and network_module is None: + if WifiService._is_desktop_mode(network_module): # Desktop mode - simulate connection delay print("WifiService: Desktop mode, simulating connection...") time_mod = time_module if time_module else time time_mod.sleep(2) + connected = True print("WifiService: Simulated connection complete") else: # Attempt to connect to saved networks - if WifiService.connect(network_module=network_module): + if WifiService.connect( + network_module=network_module, + time_module=time_module, + ): + connected = True 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) + net = WifiService._get_network_module(network_module) + wlan = WifiService._get_sta_wlan(net) wlan.active(False) print("WifiService: WiFi disabled to conserve power") finally: + if not connected: + WifiService._restore_hotspot_if_needed(network_module=network_module) WifiService.wifi_busy = False print("WifiService: Auto-connect thread finished") @@ -217,16 +407,23 @@ def temporarily_disable(network_module=None): if WifiService.wifi_busy: raise RuntimeError("Cannot disable WiFi: WifiService is already busy") - # Check actual connection status BEFORE setting wifi_busy was_connected = False + hotspot_was_enabled = False if HAS_NETWORK_MODULE or network_module: try: - net = network_module if network_module else network - wlan = net.WLAN(net.STA_IF) + net = WifiService._get_network_module(network_module) + wlan = WifiService._get_sta_wlan(net) was_connected = wlan.isconnected() + ap = WifiService._get_ap_wlan(net) + hotspot_was_enabled = ap.active() except Exception as e: print(f"WifiService: Error checking connection: {e}") + WifiService._temp_disable_state = { + "was_connected": was_connected, + "hotspot_was_enabled": hotspot_was_enabled, + } + # Now set busy flag and disconnect WifiService.wifi_busy = True WifiService.disconnect(network_module=network_module) @@ -246,6 +443,12 @@ def temporarily_enable(was_connected, network_module=None): """ WifiService.wifi_busy = False + state = WifiService._temp_disable_state or {} + WifiService._temp_disable_state = None + + if state.get("hotspot_was_enabled"): + WifiService.enable_hotspot(network_module=network_module) + # Only reconnect if WiFi was connected before we disabled it if was_connected: try: @@ -273,18 +476,73 @@ def is_connected(network_module=None): return False # Desktop mode - always report connected - if not HAS_NETWORK_MODULE and network_module is None: + if WifiService._is_desktop_mode(network_module): return True - # Check actual connection status try: - net = network_module if network_module else network - wlan = net.WLAN(net.STA_IF) + net = WifiService._get_network_module(network_module) + if WifiService.is_hotspot_enabled(network_module=network_module): + ap = WifiService._get_ap_wlan(net) + return ap.active() + wlan = WifiService._get_sta_wlan(net) return wlan.isconnected() except Exception as e: print(f"WifiService: Error checking connection: {e}") return False + + @staticmethod + def _get_ipv4_value(network_module, ap_index, sta_key, desktop_value, label): + if WifiService.wifi_busy: + return None + + if WifiService._is_desktop_mode(network_module): + return desktop_value + + try: + net = WifiService._get_network_module(network_module) + if WifiService.is_hotspot_enabled(network_module=network_module): + ap = WifiService._get_ap_wlan(net) + return ap.ifconfig()[ap_index] + wlan = WifiService._get_sta_wlan(net) + value = wlan.ipconfig(sta_key) + if isinstance(value, tuple): + return value[0] if value else None + return value + except Exception as e: + print(f"WifiService: Error retrieving ip4v {label}: {e}") + return None + + @staticmethod + def get_ipv4_address(network_module=None): + return WifiService._get_ipv4_value( + network_module=network_module, + ap_index=0, + sta_key="addr4", + desktop_value="127.0.0.1", + label="address", + ) + + @staticmethod + def get_ipv4_netmask(network_module=None): + return WifiService._get_ipv4_value( + network_module=network_module, + ap_index=1, + sta_key="addr4", + desktop_value="255.255.255.0", + label="netmask", + ) + + @staticmethod + def get_ipv4_gateway(network_module=None): + return WifiService._get_ipv4_value( + network_module=network_module, + ap_index=2, + sta_key="gw4", + desktop_value="", + label="gateway", + ) + @staticmethod def disconnect(network_module=None): """ @@ -293,50 +551,192 @@ def disconnect(network_module=None): Args: network_module: Network module for dependency injection (testing) """ - if not HAS_NETWORK_MODULE and network_module is None: + if WifiService._is_desktop_mode(network_module): print("WifiService: Desktop mode, cannot disconnect") return try: - net = network_module if network_module else network - wlan = net.WLAN(net.STA_IF) + net = WifiService._get_network_module(network_module) + wlan = WifiService._get_sta_wlan(net) wlan.disconnect() wlan.active(False) + ap = WifiService._get_ap_wlan(net) + ap.active(False) + WifiService.hotspot_enabled = 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(): + def is_busy(): """ - Get list of saved network SSIDs. - + Check if WiFi operations are currently in progress. + + Use this to check if scanning or other WiFi operations can be started. + Operations like scan_networks() manage the busy flag automatically. + Returns: - list: List of saved SSIDs + bool: True if WiFi is busy, False if available """ + return WifiService.wifi_busy + + @staticmethod + def _ensure_access_points_loaded(): if not WifiService.access_points: WifiService.access_points = mpos.config.SharedPreferences( - "com.micropythonos.system.wifiservice" + WIFI_SERVICE_PREFS_KEY ).get_dict("access_points") + @staticmethod + def get_saved_networks(): + """ + Get list of saved network SSIDs. + + Returns: + list: List of saved SSIDs + """ + WifiService._ensure_access_points_loaded() return list(WifiService.access_points.keys()) @staticmethod - def save_network(ssid, password): + def _scan_networks_raw(network_module=None): + """ + Internal method to scan for available WiFi networks and return raw data. + + Args: + network_module: Network module for dependency injection (testing) + + Returns: + list: Raw network tuples from wlan.scan(), or empty list on desktop + """ + if WifiService._is_desktop_mode(network_module): + # Desktop mode - return empty (no raw data available) + return [] + + net = WifiService._get_network_module(network_module) + wlan = WifiService._get_sta_wlan(net) + + # Restart WiFi hardware in case it is in a bad state (only if not connected) + if not wlan.isconnected(): + wlan.active(False) + wlan.active(True) + + return wlan.scan() + + @staticmethod + def scan_networks(network_module=None): + """ + Scan for available WiFi networks. + + This method manages the wifi_busy flag internally. If WiFi is already busy, + returns an empty list. The busy flag is automatically cleared when scanning + completes (even on error). + + Args: + network_module: Network module for dependency injection (testing) + + Returns: + list: List of SSIDs found, empty list if busy, or mock data on desktop + """ + # Desktop mode - return mock SSIDs (no busy flag needed) + if WifiService._is_desktop_mode(network_module): + time.sleep(1) + return ["Home WiFi", "Pretty Fly for a Wi Fi", "Winternet is coming", "The Promised LAN"] + + # Check if already busy + if WifiService.wifi_busy: + print("WifiService: scan_networks() - WiFi is busy, returning empty list") + return [] + + WifiService.wifi_busy = True + try: + networks = WifiService._scan_networks_raw(network_module) + # Return unique SSIDs, filtering out empty ones and invalid lengths + ssids = list(set(n[0].decode() for n in networks if n[0])) + return [s for s in ssids if 0 < len(s) <= 32] + finally: + WifiService.wifi_busy = False + + @staticmethod + def get_current_ssid(network_module=None): + """ + Get the SSID of the currently connected network. + + Args: + network_module: Network module for dependency injection (testing) + + Returns: + str or None: Current SSID if connected, None otherwise + """ + if WifiService._is_desktop_mode(network_module): + # Desktop mode - return simulated connected SSID + return WifiService._desktop_connected_ssid + + net = WifiService._get_network_module(network_module) + try: + wlan = WifiService._get_sta_wlan(net) + if wlan.isconnected(): + return wlan.config("essid") + except Exception as e: + print(f"WifiService: Error getting current SSID: {e}") + return None + + @staticmethod + def get_network_password(ssid): + """ + Get the saved password for a network. + + Args: + ssid: Network SSID + + Returns: + str or None: Password if found, None otherwise + """ + WifiService._ensure_access_points_loaded() + + ap = WifiService.access_points.get(ssid) + if ap: + return ap.get("password") + return None + + @staticmethod + def get_network_hidden(ssid): + """ + Get the hidden flag for a network. + + Args: + ssid: Network SSID + + Returns: + bool: True if network is hidden, False otherwise + """ + WifiService._ensure_access_points_loaded() + + ap = WifiService.access_points.get(ssid) + if ap: + return ap.get("hidden", False) + return False + + @staticmethod + def save_network(ssid, password, hidden=False): """ Save a new WiFi network credential. Args: ssid: Network SSID password: Network password + hidden: Whether this is a hidden network (always try connecting) """ # Load current saved networks - prefs = mpos.config.SharedPreferences("com.micropythonos.system.wifiservice") + prefs = mpos.config.SharedPreferences(WIFI_SERVICE_PREFS_KEY) access_points = prefs.get_dict("access_points") # Add or update the network - access_points[ssid] = {"password": password} + network_config = {"password": password} + if hidden: + network_config["hidden"] = True + access_points[ssid] = network_config # Save back to config editor = prefs.edit() @@ -346,7 +746,7 @@ def save_network(ssid, password): # Update class-level cache WifiService.access_points = access_points - print(f"WifiService: Saved network '{ssid}'") + print(f"WifiService: Saved network '{ssid}' (hidden={hidden})") @staticmethod def forget_network(ssid): @@ -360,7 +760,7 @@ def forget_network(ssid): bool: True if network was found and removed, False otherwise """ # Load current saved networks - prefs = mpos.config.SharedPreferences("com.micropythonos.system.wifiservice") + prefs = mpos.config.SharedPreferences(WIFI_SERVICE_PREFS_KEY) access_points = prefs.get_dict("access_points") # Remove the network if it exists @@ -380,3 +780,4 @@ def forget_network(ssid): else: print(f"WifiService: Network '{ssid}' not found in saved networks") return False + diff --git a/internal_filesystem/lib/mpos/number_format.py b/internal_filesystem/lib/mpos/number_format.py new file mode 100644 index 00000000..a4829a5b --- /dev/null +++ b/internal_filesystem/lib/mpos/number_format.py @@ -0,0 +1,111 @@ +from . import config + + +NUMBER_FORMAT_MAP = { + "comma_dot": (".", ","), # 1,234.56 US/UK + "dot_comma": (",", "."), # 1.234,56 Europe + "space_comma": (",", " "), # 1 234,56 French + "apos_dot": (".", "'"), # 1'234.56 Swiss + "under_dot": (".", "_"), # 1_234.56 Tech + "none_dot": (".", ""), # 1234.56 No thousands + "none_comma": (",", ""), # 1234,56 No thousands +} + +DEFAULT_FORMAT = "comma_dot" + + +class NumberFormat: + """Number formatting utility using the system number format preference.""" + + number_format_preference = None + + @staticmethod + def refresh_preference(): + """Refresh the number format preference from SharedPreferences.""" + NumberFormat.number_format_preference = config.SharedPreferences( + "com.micropythonos.settings" + ).get_string("number_format") + if not NumberFormat.number_format_preference: + NumberFormat.number_format_preference = DEFAULT_FORMAT + + @staticmethod + def get_separators(): + """Return (decimal_sep, thousands_sep) for the current preference.""" + if NumberFormat.number_format_preference is None: + NumberFormat.refresh_preference() + return NUMBER_FORMAT_MAP.get( + NumberFormat.number_format_preference, + NUMBER_FORMAT_MAP[DEFAULT_FORMAT], + ) + + @staticmethod + def format_number(value, decimals=None): + """Format a number using the current number format preference. + + Args: + value: int or float to format. + decimals: number of decimal places (None = auto for ints, strip trailing zeros for floats). + + Returns: + Formatted string. + """ + dec_sep, thou_sep = NumberFormat.get_separators() + + if isinstance(value, int) and decimals is None: + negative = value < 0 + s = str(abs(value)) + s = _insert_thousands(s, thou_sep) + return ("-" + s) if negative else s + + # Float formatting + if decimals is None: + decimals = 2 + s = "{:.{}f}".format(float(value), decimals) + + negative = s.startswith("-") + if negative: + s = s[1:] + + # Split on the Python decimal point + if "." in s: + int_part, frac_part = s.split(".") + # Strip trailing zeros from fractional part + frac_part = frac_part.rstrip("0") + else: + int_part = s + frac_part = "" + + int_part = _insert_thousands(int_part, thou_sep) + + if frac_part: + result = int_part + dec_sep + frac_part + else: + result = int_part + + return ("-" + result) if negative else result + + @staticmethod + def get_format_options(): + """Return a list of (label, key) tuples for the settings dropdown.""" + return [ + ("1,234.56 (US/UK)", "comma_dot"), + ("1.234,56 (Europe)", "dot_comma"), + ("1 234,56 (French)", "space_comma"), + ("1'234.56 (Swiss)", "apos_dot"), + ("1_234.56 (Tech)", "under_dot"), + ("1234.56 (No separator)", "none_dot"), + ("1234,56 (No separator)", "none_comma"), + ] + + +def _insert_thousands(int_str, separator): + """Insert thousands separator into an integer string.""" + if not separator or len(int_str) <= 3: + return int_str + parts = [] + while len(int_str) > 3: + parts.append(int_str[-3:]) + int_str = int_str[:-3] + parts.append(int_str) + parts.reverse() + return separator.join(parts) diff --git a/internal_filesystem/lib/mpos/sdcard.py b/internal_filesystem/lib/mpos/sdcard.py index 0f7c93bb..28731d3e 100644 --- a/internal_filesystem/lib/mpos/sdcard.py +++ b/internal_filesystem/lib/mpos/sdcard.py @@ -3,17 +3,133 @@ import vfs class SDCardManager: - def __init__(self, spi_bus, cs_pin): + def __init__(self, mode=None, spi_bus=None, cs_pin=None, cmd_pin=None, clk_pin=None, + d0_pin=None, d1_pin=None, d2_pin=None, d3_pin=None, slot=1, width=None, freq=20000000): self._sdcard = None + self._mode = None + + # Auto-detect mode: if SDIO pins provided, use SDIO; otherwise use SPI + if cmd_pin is not None or clk_pin is not None or d0_pin is not None: + self._mode = 'sdio' + else: + self._mode = 'spi' + + # Allow explicit mode override only if explicitly provided (not default) + if mode is not None and mode in ('spi', 'sdio'): + self._mode = mode + + print(f"SD card mode: {self._mode.upper()}") + + if self._mode == 'spi': + self._init_spi(spi_bus, cs_pin) + elif self._mode == 'sdio': + self._init_sdio(cmd_pin, clk_pin, d0_pin, d1_pin, d2_pin, d3_pin, slot, width, freq) + + def _init_spi(self, spi_bus, cs_pin): + """Initialize SD card in SPI mode.""" + if spi_bus is None or cs_pin is None: + print("ERROR: SPI mode requires spi_bus and cs_pin parameters") + print(" - Provide: init(spi_bus=machine.SPI(...), cs_pin=pin_number)") + return + try: self._sdcard = machine.SDCard(spi_bus=spi_bus, cs=cs_pin) self._sdcard.info() - print("SD card initialized successfully") + print("SD card initialized successfully in SPI mode") except Exception as e: - print(f"ERROR: Failed to initialize SD card: {e}") + print(f"ERROR: Failed to initialize SD card in SPI mode: {e}") print(" - Possible causes: Invalid SPI configuration, SD card not inserted, faulty wiring, or firmware issue") print(f" - Check: SPI pins for the SPI bus, card insertion, VCC (3.3V/5V), GND") print(" - Try: Hard reset ESP32, test with known-good SD card") + + def _init_sdio(self, cmd_pin, clk_pin, d0_pin, d1_pin=None, d2_pin=None, d3_pin=None, + slot=1, width=None, freq=20000000): + """Initialize SD card in SDIO mode.""" + # Validate required SDIO parameters + if cmd_pin is None or clk_pin is None or d0_pin is None: + print("ERROR: SDIO mode requires cmd_pin, clk_pin, and d0_pin parameters") + print(" - Provide: init(mode='sdio', cmd_pin=X, clk_pin=Y, d0_pin=Z, ...)") + return + + # Auto-detect SDIO width based on provided data pins + # This happens BEFORE explicit width validation to allow user override + if width is None: + # Count how many data pins are provided + data_pins_provided = sum([ + d0_pin is not None, + d1_pin is not None, + d2_pin is not None, + d3_pin is not None + ]) + + if data_pins_provided == 1: + # Only d0_pin provided: use 1-bit mode + width = 1 + print("INFO: Auto-detected SDIO width=1 (only d0_pin provided)") + elif data_pins_provided == 4: + # All four data pins provided: use 4-bit mode + width = 4 + print("INFO: Auto-detected SDIO width=4 (all four data pins provided)") + else: + # Partial pins provided: this is an error + print(f"ERROR: Invalid SDIO pin configuration - {data_pins_provided} data pins provided") + print(" - For 1-bit mode: provide only d0_pin") + print(" - For 4-bit mode: provide all four pins (d0_pin, d1_pin, d2_pin, d3_pin)") + print(" - Or explicitly specify width parameter to override auto-detection") + return + + # Validate width parameter + if width not in (1, 4): + print(f"ERROR: SDIO width must be 1 or 4, got {width}") + return + + # Validate slot parameter + if slot not in (0, 1): + print(f"ERROR: SDIO slot must be 0 or 1, got {slot}") + return + + # Validate that provided pins match the requested width + if width == 4: + if d1_pin is None or d2_pin is None or d3_pin is None: + print("ERROR: SDIO 4-bit mode requires all four data pins (d0_pin, d1_pin, d2_pin, d3_pin)") + print(" - Provide all four data pins for 4-bit mode") + print(" - Or use 1-bit mode with only d0_pin") + return + elif width == 1: + if d1_pin is not None or d2_pin is not None or d3_pin is not None: + print("ERROR: SDIO 1-bit mode should only have d0_pin, but extra pins were provided") + print(" - For 1-bit mode: provide only d0_pin") + print(" - For 4-bit mode: provide all four pins (d0_pin, d1_pin, d2_pin, d3_pin)") + return + + try: + # For 4-bit mode, all data pins are required + if width == 4: + self._sdcard = machine.SDCard( + slot=slot, + cmd=cmd_pin, + clk=clk_pin, + data_pins=(d0_pin,d1_pin,d2_pin,d3_pin,), + width=width, + freq=freq + ) + else: # 1-bit mode + self._sdcard = machine.SDCard( + slot=slot, + cmd=cmd_pin, + clk=clk_pin, + data_pins=(d0_pin,), + width=width, + freq=freq + ) + + self._sdcard.info() + print(f"SD card initialized successfully in SDIO mode (slot={slot}, width={width}-bit, freq={freq}Hz)") + except Exception as e: + print(f"ERROR: Failed to initialize SD card in SDIO mode: {e}") + print(" - Possible causes: Invalid SDIO pin configuration, SD card not inserted, faulty wiring, or firmware issue") + print(f" - Check: SDIO pins (CMD, CLK, D0-D3), card insertion, VCC (3.3V), GND") + print(" - Try: Hard reset ESP32, verify pin assignments, test with known-good SD card") def _try_mount(self, mount_point): try: @@ -119,11 +235,42 @@ def list(self, mount_point): # --- Singleton pattern --- _manager = None -def init(spi_bus, cs_pin): - """Initialize the global SD card manager.""" +def init(mode=None, spi_bus=None, cs_pin=None, cmd_pin=None, clk_pin=None, + d0_pin=None, d1_pin=None, d2_pin=None, d3_pin=None, slot=1, width=None, freq=20000000): + """ + Initialize the global SD card manager. + + SPI mode (default): + init(spi_bus=machine.SPI(...), cs_pin=pin_number) + + SDIO mode with auto-detection: + init(mode='sdio', cmd_pin=X, clk_pin=Y, d0_pin=Z, d1_pin=A, d2_pin=B, d3_pin=C, slot=1, freq=20000000) + + SDIO width auto-detection: + - If only d0_pin is provided: width is auto-set to 1 (1-bit mode) + - If all four data pins (d0, d1, d2, d3) are provided: width is auto-set to 4 (4-bit mode) + - If width parameter is explicitly provided: that value is used (overrides auto-detection) + - If partial data pins are provided (e.g., only d0 and d1): raises an error + + Auto-detection of mode: + If SDIO pins are provided, SDIO mode is used automatically. + """ global _manager if _manager is None: - _manager = SDCardManager(spi_bus, cs_pin) + _manager = SDCardManager( + mode=mode, + spi_bus=spi_bus, + cs_pin=cs_pin, + cmd_pin=cmd_pin, + clk_pin=clk_pin, + d0_pin=d0_pin, + d1_pin=d1_pin, + d2_pin=d2_pin, + d3_pin=d3_pin, + slot=slot, + width=width, + freq=freq + ) else: print("WARNING: SDCardManager already initialized") print(" - Use existing instance via get()") @@ -133,22 +280,32 @@ 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 lib/mpos/board/*.py") + print(" - Call init() with appropriate parameters first in lib/mpos/board/*.py") + print(" - SPI mode: init(spi_bus=machine.SPI(...), cs_pin=pin_number)") + print(" - SDIO mode: init(mode='sdio', cmd_pin=X, clk_pin=Y, d0_pin=Z, ...)") return _manager +def get_mode(): + """Get the current SD card mode ('spi' or 'sdio').""" + mgr = get() + if mgr is None: + print("ERROR: Cannot get mode - SDCardManager not initialized") + return None + return mgr._mode + def mount(mount_point): mgr = get() if mgr is None: print("ERROR: Cannot mount - SDCardManager not initialized") - print(" - Call init(spi_bus, cs_pin) first") + print(" - Call init() with appropriate parameters first") return False - return mgr.mount(mount_point) + return mgr.mount_with_optional_format(mount_point) def mount_with_optional_format(mount_point): mgr = get() if mgr is None: print("ERROR: Cannot mount with format - SDCardManager not initialized") - print(" - Call init(spi_bus, cs_pin) first") + print(" - Call init() with appropriate parameters first") return False success = mgr.mount_with_optional_format(mount_point) if not success: diff --git a/internal_filesystem/lib/mpos/sensor_manager.py b/internal_filesystem/lib/mpos/sensor_manager.py index cf10b70c..6b96bce7 100644 --- a/internal_filesystem/lib/mpos/sensor_manager.py +++ b/internal_filesystem/lib/mpos/sensor_manager.py @@ -1,10 +1,10 @@ """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). +Follows singleton pattern with class method delegation. Example usage: - import mpos.sensor_manager as SensorManager + from mpos import SensorManager # In board init file: SensorManager.init(i2c_bus, address=0x6B) @@ -18,866 +18,274 @@ 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² - -# 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 +from mpos.imu.constants import ( + TYPE_ACCELEROMETER, + TYPE_MAGNETIC_FIELD, + TYPE_GYROSCOPE, + TYPE_TEMPERATURE, + TYPE_IMU_TEMPERATURE, + TYPE_SOC_TEMPERATURE, + FACING_EARTH, + FACING_SKY, +) +from mpos.imu.manager import ImuManager +from mpos.imu.sensor import Sensor + + +class SensorManager: """ - 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 + Centralized sensor management service. + Implements singleton pattern for unified sensor access. + + Usage: + from mpos import SensorManager + + # Initialize + SensorManager.init(i2c_bus, address=0x6B) + + # Get sensor + accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) + + # Read sensor + ax, ay, az = SensorManager.read_sensor(accel) """ - 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 + + _instance = None + + # Class-level state variables (for testing and singleton pattern) + _initialized = False + _imu_manager = None + + # Class-level constants + TYPE_ACCELEROMETER = TYPE_ACCELEROMETER + TYPE_MAGNETIC_FIELD = TYPE_MAGNETIC_FIELD + TYPE_GYROSCOPE = TYPE_GYROSCOPE + TYPE_TEMPERATURE = TYPE_TEMPERATURE + TYPE_IMU_TEMPERATURE = TYPE_IMU_TEMPERATURE + TYPE_SOC_TEMPERATURE = TYPE_SOC_TEMPERATURE + FACING_EARTH = FACING_EARTH + FACING_SKY = FACING_SKY + + def __init__(self): + """Initialize SensorManager singleton instance.""" + if SensorManager._instance: + return + SensorManager._instance = self + + @classmethod + def get(cls): + """Get or create the singleton instance.""" + if cls._instance is None: + cls._instance = cls() + return cls._instance + + def init(self, 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) -def is_available(): - """Check if sensors are available. + Returns: + bool: True if initialized successfully + """ + self._ensure_imu_manager() + self._initialized = self._imu_manager.init( + i2c_bus, + address=address, + mounted_position=mounted_position, + ) + return self._initialized - Does NOT trigger IMU initialization (to avoid boot-time initialization). - Use get_default_sensor() or read_sensor() to lazily initialize IMU. + def init_iio(self): + self._ensure_imu_manager() + self._initialized = self._imu_manager.init_iio() + return self._initialized - Returns: - bool: True if SensorManager is initialized (may only have MCU temp, not IMU) - """ - return _initialized + def _ensure_imu_manager(self): + if self._imu_manager is None: + self._imu_manager = ImuManager() + + def is_available(self): + """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. -def get_sensor_list(): - """Get list of all available sensors. + Returns: + bool: True if SensorManager is initialized (may only have MCU temp, not IMU) + """ + return self._initialized - Performs lazy IMU initialization on first call. + def get_sensor_list(self): + """Get list of all available sensors. - Returns: - list: List of Sensor objects - """ - _ensure_imu_initialized() - return _sensor_list.copy() if _sensor_list else [] + Performs lazy IMU initialization on first call. + Returns: + list: List of Sensor objects + """ + if not self._imu_manager: + return [] + return self._imu_manager.get_sensor_list() -def get_default_sensor(sensor_type): - """Get default sensor of given type. + def get_default_sensor(self, sensor_type): + """Get default sensor of given type. - Performs lazy IMU initialization on first call. + Performs lazy IMU initialization on first call. - Args: - sensor_type: Sensor type constant (TYPE_ACCELEROMETER, etc.) + 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() + Returns: + Sensor object or None if not available + """ + if not self._imu_manager: + return None + return self._imu_manager.get_default_sensor(sensor_type) - for sensor in _sensor_list: - if sensor.type == sensor_type: - return sensor - return None + def read_sensor_once(self, sensor): + if not self._imu_manager: + return None + return self._imu_manager.read_sensor_once(sensor) + def read_sensor(self, sensor): + """Read sensor data synchronously. -def read_sensor(sensor): - """Read sensor data synchronously. + Performs lazy IMU initialization on first call for IMU sensors. - Performs lazy IMU initialization on first call for IMU sensors. + Args: + sensor: Sensor object from get_default_sensor() - 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 - 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 += _GRAVITY - 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() - + _lock.acquire() -def calibrate_sensor(sensor, samples=100): - """Calibrate sensor and save to SharedPreferences. + try: + return self._imu_manager.read_sensor(sensor) if self._imu_manager else None + finally: + if _lock: + _lock.release() + + def calibrate_sensor(self, sensor, samples=100): + """Calibrate sensor and save to SharedPreferences. - Performs lazy IMU initialization on first call. - Device must be stationary for accelerometer/gyroscope calibration. + 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) + 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: + Returns: + tuple: Calibration offsets (x, y, z) or None if failed + """ + if not self._imu_manager: 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 + _lock.acquire() - 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) - - # 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" - ) - - def read_acceleration(self): - """Read acceleration in m/s² (converts from mg).""" - ax, ay, az = self.sensor.read_accelerations() - # Convert mg to m/s²: mg → g → m/s² - return ( - (ax / 1000.0) * _GRAVITY, - (ay / 1000.0) * _GRAVITY, - (az / 1000.0) * _GRAVITY - ) - - def read_gyroscope(self): - """Read gyroscope in deg/s (converts from mdps).""" - gx, gy, gz = self.sensor.read_angular_velocities() - # Convert mdps to deg/s - return ( - gx / 1000.0, - gy / 1000.0, - gz / 1000.0 - ) + try: + return self._imu_manager.calibrate_sensor(sensor, samples=samples) + except Exception as e: + import sys - def read_temperature(self): - """Read temperature in °C (not implemented in WSEN_ISDS driver).""" - # WSEN_ISDS has temperature sensor but not exposed in current driver - return None - - def calibrate_accelerometer(self, samples): - """Calibrate accelerometer using hardware calibration.""" - self.sensor.acc_calibrate(samples) - # Return offsets in m/s² (convert from raw offsets) - return ( - (self.sensor.acc_offset_x * self.sensor.acc_sensitivity / 1000.0) * _GRAVITY, - (self.sensor.acc_offset_y * self.sensor.acc_sensitivity / 1000.0) * _GRAVITY, - (self.sensor.acc_offset_z * self.sensor.acc_sensitivity / 1000.0) * _GRAVITY - ) + sys.print_exception(e) + print(f"[SensorManager] Calibration error: {e}") + return None + finally: + if _lock: + _lock.release() - def calibrate_gyroscope(self, samples): - """Calibrate gyroscope using hardware calibration.""" - self.sensor.gyro_calibrate(samples) - # Return offsets in deg/s (convert from raw offsets) - return ( - (self.sensor.gyro_offset_x * self.sensor.gyro_sensitivity) / 1000.0, - (self.sensor.gyro_offset_y * self.sensor.gyro_sensitivity) / 1000.0, - (self.sensor.gyro_offset_z * self.sensor.gyro_sensitivity) / 1000.0 - ) + def check_calibration_quality(self, samples=50): + """Check quality of current calibration. - def get_calibration(self): - """Get current calibration (raw offsets from hardware).""" - return { - 'accel_offsets': [ - self.sensor.acc_offset_x, - self.sensor.acc_offset_y, - self.sensor.acc_offset_z - ], - 'gyro_offsets': [ - self.sensor.gyro_offset_x, - self.sensor.gyro_offset_y, - self.sensor.gyro_offset_z - ] - } - - def set_calibration(self, accel_offsets, gyro_offsets): - """Set calibration from saved values (raw offsets).""" - if accel_offsets: - self.sensor.acc_offset_x = accel_offsets[0] - self.sensor.acc_offset_y = accel_offsets[1] - self.sensor.acc_offset_z = accel_offsets[2] - if gyro_offsets: - self.sensor.gyro_offset_x = gyro_offsets[0] - self.sensor.gyro_offset_y = gyro_offsets[1] - self.sensor.gyro_offset_z = gyro_offsets[2] + 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 + """ + if not self._imu_manager: + return None + return self._imu_manager.check_calibration_quality(samples=samples) -# ============================================================================ -# Sensor registration (internal) -# ============================================================================ + def check_stationarity( + self, samples=30, variance_threshold_accel=0.5, variance_threshold_gyro=5.0 + ): + """Check if device is stationary (required for calibration). -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 - ) - ] - - -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 + 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 + """ + if not self._imu_manager: + return None + return self._imu_manager.check_stationarity( + samples=samples, + variance_threshold_accel=variance_threshold_accel, + variance_threshold_gyro=variance_threshold_gyro, ) - ) # ============================================================================ -# Calibration persistence (internal) +# Class method delegation (at module level) # ============================================================================ -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="sensors.json") - accel_offsets = prefs_new.get_list("accel_offsets") - gyro_offsets = prefs_new.get_list("gyro_offsets") - - # If not found, try OLD location and migrate - if not accel_offsets and not gyro_offsets: - prefs_old = SharedPreferences("com.micropythonos.sensors") - accel_offsets = prefs_old.get_list("accel_offsets") - gyro_offsets = prefs_old.get_list("gyro_offsets") - - if accel_offsets or gyro_offsets: - # Save to new location - editor = prefs_new.edit() - if accel_offsets: - editor.put_list("accel_offsets", accel_offsets) - if gyro_offsets: - editor.put_list("gyro_offsets", gyro_offsets) - editor.commit() - - 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="sensors.json") - 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 +_original_methods = {} +_methods_to_delegate = [ + 'init', 'init_iio', 'is_available', 'get_sensor_list', 'get_default_sensor', + 'read_sensor', 'read_sensor_once', 'calibrate_sensor', 'check_calibration_quality', + 'check_stationarity' +] + +for method_name in _methods_to_delegate: + _original_methods[method_name] = getattr(SensorManager, method_name) + +def _make_class_method(method_name): + """Create a class method that delegates to the singleton instance.""" + original_method = _original_methods[method_name] + + @classmethod + def class_method(cls, *args, **kwargs): + instance = cls.get() + return original_method(instance, *args, **kwargs) + + return class_method + +for method_name in _methods_to_delegate: + setattr(SensorManager, method_name, _make_class_method(method_name)) diff --git a/internal_filesystem/lib/mpos/task_manager.py b/internal_filesystem/lib/mpos/task_manager.py new file mode 100644 index 00000000..b4eb3d41 --- /dev/null +++ b/internal_filesystem/lib/mpos/task_manager.py @@ -0,0 +1,76 @@ +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 + +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 + 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) + + @staticmethod + def good_stack_size(): + 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 + diff --git a/internal_filesystem/lib/mpos/testing/__init__.py b/internal_filesystem/lib/mpos/testing/__init__.py new file mode 100644 index 00000000..71d9f7ee --- /dev/null +++ b/internal_filesystem/lib/mpos/testing/__init__.py @@ -0,0 +1,87 @@ +""" +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, + MockNeoPixel, + + # 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', + 'MockNeoPixel', + + # 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..94863e62 --- /dev/null +++ b/internal_filesystem/lib/mpos/testing/mocks.py @@ -0,0 +1,876 @@ +""" +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 MockNeoPixel: + """Mock neopixel.NeoPixel for testing LED operations.""" + + def __init__(self, pin, num_leds, bpp=3, timing=1): + self.pin = pin + self.num_leds = num_leds + self.bpp = bpp + self.timing = timing + self.pixels = [(0, 0, 0)] * num_leds + self.write_count = 0 + + def __setitem__(self, index, value): + """Set LED color (R, G, B) or (R, G, B, W) 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 __len__(self): + """Return number of LEDs.""" + return self.num_leds + + def fill(self, color): + """Fill all LEDs with the same color.""" + for i in range(self.num_leds): + self.pixels[i] = color + + def write(self): + """Update hardware (mock - just increment counter).""" + self.write_count += 1 + + def get_all_colors(self): + """Get all LED colors (for testing assertions).""" + return self.pixels.copy() + + def reset_write_count(self): + """Reset the write counter (for testing).""" + self.write_count = 0 + + +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 ipconfig(self, key=None): + """Return IP configuration details, mirroring network.WLAN.ipconfig.""" + config = self.ifconfig() + mapping = { + 'addr4': config[0], + 'netmask4': config[1], + 'gateway4': config[2], + 'dns4': config[3], + } + if key is None: + return mapping + return mapping.get(key) + + 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 (deprecated, use MockAppManager instead). + + This is kept for backward compatibility with existing tests. + + Usage: + sys.modules['mpos.apps'] = MockApps + """ + + @staticmethod + def start_app(fullname): + """Mock start_app function.""" + return True + + @staticmethod + def restart_launcher(): + """Mock restart_launcher function.""" + return True + + @staticmethod + def execute_script(script_source, is_file, classname, cwd=None): + """Mock execute_script function.""" + return True + + +class MockAppManager: + """ + Mock mpos.content.app_manager module for testing. + + Usage: + sys.modules['mpos.content.app_manager'] = MockAppManager + """ + + @staticmethod + def start_app(fullname): + """Mock start_app function.""" + return True + + @staticmethod + def restart_launcher(): + """Mock restart_launcher function.""" + return True + + @staticmethod + def execute_script(script_source, is_file, classname, cwd=None): + """Mock execute_script function.""" + return True \ No newline at end of file diff --git a/internal_filesystem/lib/mpos/time.py b/internal_filesystem/lib/mpos/time.py index 4afa51a9..d3faacd2 100644 --- a/internal_filesystem/lib/mpos/time.py +++ b/internal_filesystem/lib/mpos/time.py @@ -1,11 +1,8 @@ import time -import mpos.config -from mpos.timezones import TIMEZONE_MAP +from .time_zone import TimeZone import localPTZtime -timezone_preference = None - def epoch_seconds(): import sys if sys.platform == "esp32": @@ -23,22 +20,14 @@ def sync_time(): print('Syncing time with', ntptime.host) ntptime.settime() # Fetch and set time (in UTC) print("Time sync'ed successfully") - refresh_timezone_preference() # if the time was sync'ed, then it needs refreshing + TimeZone.refresh_timezone_preference() # if the time was sync'ed, then it needs refreshing except Exception as e: print('Failed to sync time:', e) -def refresh_timezone_preference(): - global timezone_preference - prefs = mpos.config.SharedPreferences("com.micropythonos.settings") - timezone_preference = prefs.get_string("timezone") - if not timezone_preference: - timezone_preference = "Etc/GMT" # Use a default value so that it doesn't refresh every time the time is requested - def localtime(): - global timezone_preference - if not timezone_preference: # if it's the first time, then it needs refreshing - refresh_timezone_preference() - ptz = timezone_to_posix_time_zone(timezone_preference) + if not TimeZone.timezone_preference: # if it's the first time, then it needs refreshing + TimeZone.refresh_timezone_preference() + ptz = TimeZone.timezone_to_posix_time_zone(TimeZone.timezone_preference) t = time.time() try: localtime = localPTZtime.tztime(t, ptz) @@ -47,26 +36,3 @@ def localtime(): return time.localtime() return localtime -def timezone_to_posix_time_zone(timezone): - """ - Convert a timezone name to its POSIX timezone string. - - Args: - timezone (str or None): Timezone name (e.g., 'Africa/Abidjan') or None. - - Returns: - str: POSIX timezone string (e.g., 'GMT0'). Returns 'GMT0' if timezone is None or not found. - """ - if timezone is None or timezone not in TIMEZONE_MAP: - return "GMT0" - return TIMEZONE_MAP[timezone] - -def get_timezones(): - """ - Get a list of all available timezone names. - - Returns: - list: List of timezone names (e.g., ['Africa/Abidjan', 'Africa/Accra', ...]). - """ - return sorted(TIMEZONE_MAP.keys()) # even though they are defined alphabetical, the order isn't maintained in MicroPython - diff --git a/internal_filesystem/lib/mpos/time_zone.py b/internal_filesystem/lib/mpos/time_zone.py new file mode 100644 index 00000000..876ccbb3 --- /dev/null +++ b/internal_filesystem/lib/mpos/time_zone.py @@ -0,0 +1,42 @@ +from .time_zones import TIME_ZONE_MAP +from . import config + + +class TimeZone: + """Timezone utility class for converting and managing timezone information.""" + + timezone_preference = None + + @staticmethod + def timezone_to_posix_time_zone(timezone): + """ + Convert a timezone name to its POSIX timezone string. + + Args: + timezone (str or None): Timezone name (e.g., 'Africa/Abidjan') or None. + + Returns: + str: POSIX timezone string (e.g., 'GMT0'). Returns 'GMT0' if timezone is None or not found. + """ + if timezone is None or timezone not in TIME_ZONE_MAP: + return "GMT0" + return TIME_ZONE_MAP[timezone] + + @staticmethod + def get_timezones(): + """ + Get a list of all available timezone names. + + Returns: + list: List of timezone names (e.g., ['Africa/Abidjan', 'Africa/Accra', ...]). + """ + return sorted(TIME_ZONE_MAP.keys()) # even though they are defined alphabetical, the order isn't maintained in MicroPython + + @staticmethod + def refresh_timezone_preference(): + """ + Refresh the timezone preference from SharedPreferences. + """ + TimeZone.timezone_preference = config.SharedPreferences("com.micropythonos.settings").get_string("timezone") + if not TimeZone.timezone_preference: + TimeZone.timezone_preference = "Etc/GMT" # Use a default value so that it doesn't refresh every time the time is requested diff --git a/internal_filesystem/lib/mpos/timezones.py b/internal_filesystem/lib/mpos/time_zones.py similarity index 99% rename from internal_filesystem/lib/mpos/timezones.py rename to internal_filesystem/lib/mpos/time_zones.py index 27b10716..5f0674bb 100644 --- a/internal_filesystem/lib/mpos/timezones.py +++ b/internal_filesystem/lib/mpos/time_zones.py @@ -2,7 +2,7 @@ # and then asked an LLM to shorten the list (otherwise it's a huge scroll) # by keeping only the commonly used cities. -TIMEZONE_MAP = { +TIME_ZONE_MAP = { "Africa/Abidjan": "GMT0", # West Africa, GMT0 "Africa/Accra": "GMT0", # Ghana’s capital "Africa/Addis_Ababa": "EAT-3", # Ethiopia’s capital diff --git a/internal_filesystem/lib/mpos/ui/__init__.py b/internal_filesystem/lib/mpos/ui/__init__.py index 0a7ce711..6f38a949 100644 --- a/internal_filesystem/lib/mpos/ui/__init__.py +++ b/internal_filesystem/lib/mpos/ui/__init__.py @@ -3,28 +3,31 @@ 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 .appearance_manager import AppearanceManager +from .topmenu import open_bar, close_bar, open_drawer, drawer_open from .focus import save_and_clear_current_focusgroup -from .display import ( - get_display_width, get_display_height, - pct_of_display_width, pct_of_display_height, - min_resolution, max_resolution, - get_pointer_xy # ← now correct -) +from .display_metrics import DisplayMetrics from .event import get_event_name, print_event from .util import shutdown, set_foreground_app, get_foreground_app +from .setting_activity import SettingActivity +from .settings_activity import SettingsActivity +from .widget_animator import WidgetAnimator +from . import focus_direction + +# main_display is assigned by board-specific initialization code +main_display = None __all__ = [ - "setContentView", "back_screen", "remove_and_stop_current_activity", "remove_and_stop_all_activities" + "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", + "AppearanceManager", + "open_bar", "close_bar", "open_drawer", "drawer_open", "save_and_clear_current_focusgroup", - "get_display_width", "get_display_height", - "pct_of_display_width", "pct_of_display_height", - "min_resolution", "max_resolution", - "get_pointer_xy", + "DisplayMetrics", "get_event_name", "print_event", - "shutdown", "set_foreground_app", "get_foreground_app" + "shutdown", "set_foreground_app", "get_foreground_app", + "SettingActivity", + "SettingsActivity", + "WidgetAnimator", + "focus_direction" ] diff --git a/internal_filesystem/lib/mpos/ui/anim.py b/internal_filesystem/lib/mpos/ui/anim.py deleted file mode 100644 index 1f8310ac..00000000 --- a/internal_filesystem/lib/mpos/ui/anim.py +++ /dev/null @@ -1,138 +0,0 @@ -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): -# self.animations = {} # Store animations for each widget - -# def stop_animation(self, widget): -# """Stop any running animation for the widget.""" -# if widget in self.animations: -# self.animations[widget].delete() -# del self.animations[widget] - - - # 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): - 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.set_values(0, 255) - 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: 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.set_values(original_y - height, original_y) - 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: 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.set_values(original_y + height, original_y) - 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: safe_widget_access(lambda: widget.set_y(original_y))) - - 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.set_values(255, 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: 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.set_values(original_y, original_y + height) - 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: 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.set_values(original_y, original_y - height) - 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: safe_widget_access(lambda: WidgetAnimator.hide_complete_cb(widget, original_y, hide))) - - anim.start() - return anim - - @staticmethod - def hide_complete_cb(widget, original_y=None, hide=True): - #print("hide_complete_cb") - if hide: - widget.add_flag(lv.obj.FLAG.HIDDEN) - if original_y: - widget.set_y(original_y) # in case it shifted slightly due to rounding etc - - -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, 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/appearance_manager.py b/internal_filesystem/lib/mpos/ui/appearance_manager.py new file mode 100644 index 00000000..1024894e --- /dev/null +++ b/internal_filesystem/lib/mpos/ui/appearance_manager.py @@ -0,0 +1,305 @@ +# lib/mpos/ui/appearance_manager.py +""" +AppearanceManager - Android-inspired appearance management singleton. + +Manages all aspects of the app's visual appearance: +- Light/dark mode (UI appearance) +- Theme colors (primary, secondary, accent) +- UI dimensions (notification bar height, etc.) +- LVGL theme initialization +- Keyboard styling workarounds + +This is a singleton implemented using class methods and class variables. +No instance creation is needed - all methods are class methods. + +Example: + from mpos import AppearanceManager + + # Check light/dark mode + if AppearanceManager.is_light_mode(): + print("Light mode enabled") + + # Get UI dimensions + bar_height = AppearanceManager.get_notification_bar_height() + + # Initialize appearance from preferences + AppearanceManager.init(prefs) +""" + +import lvgl as lv + + +class AppearanceManager: + """ + Android-inspired appearance management singleton. + + Centralizes all UI appearance settings including theme colors, light/dark mode, + and UI dimensions. Follows the singleton pattern using class methods and class + variables, similar to Android's Configuration and Resources classes. + + All methods are class methods - no instance creation needed. + """ + + # ========== UI Dimensions ========== + # These are constants that define the layout of the UI + NOTIFICATION_BAR_HEIGHT = 24 # Height of the notification bar in pixels + DEFAULT_PRIMARY_COLOR = "f0a010" + + # ========== Private Class Variables ========== + # State variables shared across all "instances" (there is only one logical instance) + _is_light_mode = True + _primary_color = None + _accent_color = None + _keyboard_button_fix_style = None + + # ========== Initialization ========== + + @classmethod + def init(cls, prefs): + """ + Initialize AppearanceManager from preferences. + + Called during system startup to load theme settings from SharedPreferences + and initialize the LVGL theme. This should be called once during boot. + + Args: + prefs: SharedPreferences object containing theme settings + - "theme_light_dark": "light" or "dark" (default: "light") + - "theme_primary_color": hex color string like "0xFF5722" or "#FF5722" + + Example: + from mpos import AppearanceManager + import mpos.config + + prefs = mpos.config.get_shared_preferences() + AppearanceManager.init(prefs) + """ + # Load light/dark mode preference + theme_light_dark = prefs.get_string("theme_light_dark", "light") + theme_dark_bool = (theme_light_dark == "dark") + cls._is_light_mode = not theme_dark_bool + + primary_color = lv.theme_get_color_primary(None) # Load primary color from LVGL default + + # Try to get a valid color from the preferences + color_string = prefs.get_string("theme_primary_color", cls.DEFAULT_PRIMARY_COLOR) + try: + color_string = color_string.replace("0x", "").replace("#", "").strip().lower() + color_int = int(color_string, 16) + print(f"[AppearanceManager] Setting primary color: {color_int}") + primary_color = lv.color_hex(color_int) + cls._primary_color = primary_color + except Exception as e: + print(f"[AppearanceManager] Converting color setting '{color_string}' failed: {e}") + + # Initialize LVGL theme with loaded settings + # Get the display driver from the active screen + screen = lv.screen_active() + disp = screen.get_display() + lv.theme_default_init( + disp, + primary_color, + lv.color_hex(0xFBDC05), # Accent color (yellow) + theme_dark_bool, + lv.font_montserrat_12 + ) + # Reset keyboard button fix style so it's recreated with new theme colors + cls._keyboard_button_fix_style = None + + print(f"[AppearanceManager] Initialized: light_mode={cls._is_light_mode}, primary_color={primary_color}") + + # ========== Light/Dark Mode ========== + + @classmethod + def is_light_mode(cls): + """ + Check if light mode is currently enabled. + + Returns: + bool: True if light mode is enabled, False if dark mode is enabled + + Example: + from mpos import AppearanceManager + + if AppearanceManager.is_light_mode(): + print("Using light theme") + else: + print("Using dark theme") + """ + return cls._is_light_mode + + @classmethod + def set_light_mode(cls, is_light, prefs=None): + """ + Set light/dark mode and update the theme. + + Args: + is_light (bool): True for light mode, False for dark mode + prefs (SharedPreferences, optional): If provided, saves the setting + + Example: + from mpos import AppearanceManager + + AppearanceManager.set_light_mode(False) # Switch to dark mode + """ + cls._is_light_mode = is_light + + # Save to preferences if provided + if prefs: + theme_str = "light" if is_light else "dark" + prefs.set_string("theme_light_dark", theme_str) + + # Reinitialize LVGL theme with new mode + if prefs: + cls.init(prefs) + + print(f"[AppearanceManager] Light mode set to: {is_light}") + + @classmethod + def set_theme(cls, prefs): + """ + Set the theme from preferences and reinitialize LVGL theme. + + This is a convenience method that loads theme settings from SharedPreferences + and applies them. It's equivalent to calling init() with the preferences. + + Args: + prefs: SharedPreferences object containing theme settings + + Example: + from mpos import AppearanceManager + import mpos.config + + prefs = mpos.config.SharedPreferences("theme_settings") + AppearanceManager.set_theme(prefs) + """ + cls.init(prefs) + + # ========== Theme Colors ========== + + @classmethod + def get_primary_color(cls): + """ + Get the primary theme color. + + Returns: + lv.color_t: The primary color, or None if not set + + Example: + from mpos import AppearanceManager + + color = AppearanceManager.get_primary_color() + if color: + button.set_style_bg_color(color, lv.PART.MAIN) + """ + return cls._primary_color + + @classmethod + def set_primary_color(cls, color, prefs=None): + """ + Set the primary theme color. + + Args: + color (lv.color_t or int): The new primary color + prefs (SharedPreferences, optional): If provided, saves the setting + + Example: + from mpos import AppearanceManager + import lvgl as lv + + AppearanceManager.set_primary_color(lv.color_hex(0xFF5722)) + """ + cls._primary_color = color + + # Save to preferences if provided + if prefs and isinstance(color, int): + prefs.set_string("theme_primary_color", f"0x{color:06X}") + + print(f"[AppearanceManager] Primary color set to: {color}") + + # ========== UI Dimensions ========== + + @classmethod + def get_notification_bar_height(cls): + """ + Get the height of the notification bar. + + The notification bar is the top bar that displays system information + (time, battery, signal, etc.). This method returns its height in pixels. + + Returns: + int: Height of the notification bar in pixels (default: 24) + + Example: + from mpos import AppearanceManager + + bar_height = AppearanceManager.get_notification_bar_height() + content_y = bar_height # Position content below the bar + """ + return cls.NOTIFICATION_BAR_HEIGHT + + # ========== Keyboard Styling Workarounds ========== + + @classmethod + def get_keyboard_button_fix_style(cls): + """ + 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 method returns a custom style to override that. + + Returns: + lv.style_t: Style to apply to keyboard buttons, or None if not needed + + Note: + This is a workaround for an LVGL/MicroPython issue. It only applies + in light mode. In dark mode, the default LVGL styling is fine. + + Example: + from mpos import AppearanceManager + + style = AppearanceManager.get_keyboard_button_fix_style() + if style: + keyboard.add_style(style, lv.PART.ITEMS) + """ + # Only return style in light mode + if not cls._is_light_mode: + return None + + # Create style if it doesn't exist + if cls._keyboard_button_fix_style is None: + cls._keyboard_button_fix_style = lv.style_t() + cls._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) + cls._keyboard_button_fix_style.set_bg_color(gray_color) + cls._keyboard_button_fix_style.set_bg_opa(lv.OPA.COVER) + + return cls._keyboard_button_fix_style + + @classmethod + def apply_keyboard_fix(cls, 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 + + Example: + from mpos import AppearanceManager + import lvgl as lv + + keyboard = lv.keyboard(screen) + AppearanceManager.apply_keyboard_fix(keyboard) + """ + style = cls.get_keyboard_button_fix_style() + if style: + keyboard.add_style(style, lv.PART.ITEMS) + print(f"[AppearanceManager] Applied keyboard button fix for light mode") diff --git a/internal_filesystem/lib/mpos/ui/camera_activity.py b/internal_filesystem/lib/mpos/ui/camera_activity.py new file mode 100644 index 00000000..ba312f2c --- /dev/null +++ b/internal_filesystem/lib/mpos/ui/camera_activity.py @@ -0,0 +1,389 @@ +import lvgl as lv +import time + +from ..time import epoch_seconds +from .camera_settings import CameraSettingsActivity +from ..camera_manager import CameraManager +from .. import ui as mpos_ui +from ..app.activity import Activity + +class CameraActivity(Activity): + + PACKAGE = "com.micropythonos.camera" + CONFIGFILE = "config.json" + SCANQR_CONFIG = "config_scanqr_mode.json" + + 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 garba + width = None + height = None + colormode = False + + image_dsc = None + scanqr_mode = False + scanqr_intent = 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 + status_label = None + status_label_cont = None + + def onCreate(self): + self.main_screen = lv.obj() + self.main_screen.set_style_pad_all(1, lv.PART.MAIN) + self.main_screen.set_style_border_width(0, lv.PART.MAIN) + self.main_screen.set_size(lv.pct(100), lv.pct(100)) + self.main_screen.remove_flag(lv.obj.FLAG.SCROLLABLE) + + # Initialize LVGL image widget + self.image = lv.image(self.main_screen) + self.image.align(lv.ALIGN.TOP_LEFT, 0, 0) + self.close_button = lv.button(self.main_screen) + close_label = lv.label(self.close_button) + close_label.set_text(lv.SYMBOL.CLOSE) + close_label.center() + self.close_button.add_event_cb(lambda e: self.finish(),lv.EVENT.CLICKED,None) + # Settings button + self.settings_button = lv.button(self.main_screen) + settings_label = lv.label(self.settings_button) + settings_label.set_text(lv.SYMBOL.SETTINGS) + settings_label.center() + self.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.add_flag(lv.obj.FLAG.HIDDEN) + 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.set_text("QR") + self.qr_label.center() + + self.snap_button = lv.button(self.main_screen) + 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) + self.status_label_cont.set_style_bg_color(lv.color_white(), lv.PART.MAIN) + self.status_label_cont.set_style_bg_opa(66, lv.PART.MAIN) + self.status_label_cont.set_style_border_width(0, lv.PART.MAIN) + self.status_label = lv.label(self.status_label_cont) + 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() + + if mpos_ui.DisplayMetrics.width() < mpos_ui.DisplayMetrics.height(): + # poster + self.button_width = int((mpos_ui.DisplayMetrics.width() / 4 ) - 5) + self.button_height = 50 + self.resize_buttons() + self.snap_button.set_size(self.button_height, self.button_height) + self.close_button.align(lv.ALIGN.BOTTOM_RIGHT, 0, -5) + self.settings_button.align_to(self.close_button, lv.ALIGN.OUT_LEFT_MID, -5, 0) + self.qr_button.align(lv.ALIGN.BOTTOM_LEFT, 0, -5) + self.snap_button.align_to(self.qr_button, lv.ALIGN.OUT_RIGHT_MID, 5, 0) # needs -2 to avoid being too low + width = mpos_ui.DisplayMetrics.pct_of_width(85) + height = mpos_ui.DisplayMetrics.pct_of_height(45) + center_w = round((mpos_ui.DisplayMetrics.width() - width)/2) + center_h = round((mpos_ui.DisplayMetrics.height() - self.button_height - 10 - height)/2) + else: + # landscape + self.button_width = 75 + self.button_height = int((mpos_ui.DisplayMetrics.height() / 4 ) - 10) + self.resize_buttons() + self.snap_button.set_size(self.button_height, self.button_height) + self.close_button.align(lv.ALIGN.TOP_RIGHT, 0, 0) + self.settings_button.align_to(self.close_button, lv.ALIGN.OUT_BOTTOM_MID, 0, 10) + self.qr_button.align(lv.ALIGN.BOTTOM_RIGHT, 0, 0) + self.snap_button.align_to(self.qr_button, lv.ALIGN.OUT_TOP_MID, 0, -10) + width = mpos_ui.DisplayMetrics.pct_of_width(70) + height = mpos_ui.DisplayMetrics.pct_of_height(60) + center_w = round((mpos_ui.DisplayMetrics.width() - self.button_width - 5 - width)/2) + center_h = round((mpos_ui.DisplayMetrics.height() - height)/2) + + self.status_label_cont.set_pos(center_w,center_h) + self.status_label_cont.set_size(width,height) + self.setContentView(self.main_screen) + + def onResume(self, screen): + 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: + self.status_label.set_text(self.STATUS_NO_CAMERA) + # leave it open so the user can read the error and maybe open the settings + 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 resize_buttons(self): + self.close_button.set_size(self.button_width, self.button_height) + self.settings_button.set_size(self.button_width, self.button_height) + self.qr_button.set_size(self.button_width, self.button_height) + self.snap_button.set_style_radius(self.button_width, lv.PART.MAIN) + + def start_cam(self): + # Init camera: + firstcam = CameraManager.get_cameras()[0] + self.cam = firstcam.init(self.width, self.height, self.colormode) + if self.cam: + self.image.set_rotation(-10 * firstcam.get_rotation_degrees()) # counter the rotation so * -1 and convert to tens-of-a-degree for LVGL + # Apply saved camera settings, only for internal camera for now: + firstcam.apply_settings(self.cam, self.scanqr_prefs if self.scanqr_mode else self.prefs) # needs to be done AFTER the camera is initialized + # Start refreshing: + print("Camera app initialized, continuing...") + self.update_preview_image() + self.capture_timer = lv.timer_create(self.try_capture, 100, None) + + def stop_cam(self): + if self.capture_timer: + self.capture_timer.delete() + if self.cam: + CameraManager.get_cameras()[0].deinit(self.cam) + 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 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 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) + if mpos_ui.DisplayMetrics.width() < mpos_ui.DisplayMetrics.height(): + target_h = mpos_ui.DisplayMetrics.width() + else: + target_h = mpos_ui.DisplayMetrics.height() + 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) + print(f"scale_factors: {scale_factor_w},{scale_factor_h}") + self.image.set_size(target_w, target_h) + #self.image.set_scale(max(scale_factor_w,scale_factor_h)) # fills the entire screen but cuts off borders + self.image.set_scale(min(scale_factor_w,scale_factor_h)) + + def qrdecode_one(self): + try: + result = None + before = time.ticks_ms() + import qrdecode + if self.colormode: + # exceptions from this one are not caught - see comments in quirc_decode.c + result = qrdecode.qrdecode_rgb565(self.current_cam_buffer, self.width, self.height) + else: + 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_SEARCHING_QR) + except TypeError as e: + print("QR TypeError: ", e) + 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}") + if self.scanqr_intent: + self.stop_qr_decoding(activate_non_qr_mode=False) + 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() + + def snap_button_click(self, e): + 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(path) + except OSError: + pass + 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_{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.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_SEARCHING_QR) + + def stop_qr_decoding(self, activate_non_qr_mode=True): + print("Deactivating live QR decoding...") + self.scanqr_mode = False + self.qr_label.set_text(lv.SYMBOL.EYE_OPEN) + 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: + if activate_non_qr_mode is False: + return + # Instead of checking if any setting changed, just reload and restart the camera: + self.load_settings_cached() + self.stop_cam() + self.start_cam() + + def qr_button_click(self, e): + if not self.scanqr_mode: + self.start_qr_decoding() + else: + self.stop_qr_decoding() + + def open_settings(self): + from ..content.intent import Intent + intent = Intent(activity_class=CameraSettingsActivity, extras={"prefs": self.prefs if not self.scanqr_mode else self.scanqr_prefs, "scanqr_mode": self.scanqr_mode}) + self.startActivity(intent) + + def try_capture(self, event): + if not self.cam: + return + try: + self.current_cam_buffer = CameraManager.get_cameras()[0].capture(self.cam, self.colormode) + 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: + try: + # Due to buggy behavior in MicroPython and/or qrdecode_rgb565 of quirc_decode.c + # the exceptions are not caught in self.qrdecode_one() so must be done here + self.qrdecode_one() + except Exception as e: + print(f"self.qrdecode_one() was unable to catch exception from qrdecode_rgb565(): {e}") + try: + self.cam.free_buffer() # After QR decoding, free the old buffer, otherwise the camera doesn't provide a new one + except Exception as e: + pass # some camera API's don't have this + + 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 diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py b/internal_filesystem/lib/mpos/ui/camera_settings.py similarity index 87% rename from internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py rename to internal_filesystem/lib/mpos/ui/camera_settings.py index 8bf90ecc..6931d79b 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py +++ b/internal_filesystem/lib/mpos/ui/camera_settings.py @@ -1,9 +1,9 @@ import lvgl as lv -import mpos.ui -from mpos.apps import Activity -from mpos.config import SharedPreferences -from mpos.content.intent import Intent +from ..config import SharedPreferences +from ..app.activity import Activity +from .display_metrics import DisplayMetrics +from .widget_animator import WidgetAnimator class CameraSettingsActivity(Activity): @@ -67,15 +67,14 @@ class CameraSettingsActivity(Activity): # Scanqr mode specific defaults SCANQR_DEFAULTS = { - "resolution_width": 960, - "resolution_height": 960, + "resolution_width": 640, + "resolution_height": 640, "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 + # Resolution options are the same for all cameras for now (can be split later) RESOLUTIONS = [ ("96x96", "96x96"), ("160x120", "160x120"), @@ -91,23 +90,19 @@ class CameraSettingsActivity(Activity): ("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"), + #("800x600", "800x600"), # somehow this fails to initialize + #("800x800", "800x800"), # somehow this fails to initialize + #("1024x768", "1024x768"), # this resolution is lower than 960x960 but it looks higher + ("960x960", "960x960"), # ideal for QR scanning, quick and high quality scaling (binning) + #("1280x720", "1280x720"), # too thin (16:9) and same pixel area as 960x960 + #("1024x1024", "1024x1024"), # somehow this fails to initialize + # Disabled because they use a lot of RAM and are very slow: + #("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 @@ -118,34 +113,31 @@ def __init__(self): 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) + screen.set_style_pad_all(1, lv.PART.MAIN) # 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)) + tabview.set_tab_bar_size(DisplayMetrics.pct_of_height(15)) + #tabview.set_size(lv.pct(100), 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) + 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) + 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) + #raw_tab = tabview.add_tab("Raw") + #self.create_raw_tab(raw_tab, self.prefs) self.setContentView(screen) @@ -153,7 +145,7 @@ def create_slider(self, parent, label_text, min_val, max_val, default_val, pref_ """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) + cont.set_style_pad_all(3, lv.PART.MAIN) label = lv.label(cont) label.set_text(f"{label_text}: {default_val}") @@ -177,7 +169,7 @@ 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) + cont.set_style_pad_all(3, lv.PART.MAIN) checkbox = lv.checkbox(cont) checkbox.set_text(label_text) @@ -191,7 +183,7 @@ 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) + cont.set_style_pad_all(2, lv.PART.MAIN) label = lv.label(cont) label.set_text(label_text) @@ -219,7 +211,7 @@ def create_dropdown(self, parent, label_text, options, default_idx, pref_key): 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) + cont.set_style_pad_all(3, lv.PART.MAIN) label = lv.label(cont) label.set_text(f"{label_text}:") @@ -243,26 +235,24 @@ def create_textarea(self, parent, label_text, min_val, max_val, default_val, pre 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.set_size(lv.pct(100), DisplayMetrics.pct_of_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) + button_cont.set_style_border_width(0, lv.PART.MAIN) - 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() + erase_button = lv.button(button_cont) + erase_button.set_size(DisplayMetrics.pct_of_width(20), lv.SIZE_CONTENT) + erase_button.align(lv.ALIGN.BOTTOM_LEFT, 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() cancel_button = lv.button(button_cont) - cancel_button.set_size(mpos.ui.pct_of_display_width(25), lv.SIZE_CONTENT) + cancel_button.set_size(DisplayMetrics.pct_of_width(25), lv.SIZE_CONTENT) + cancel_button.set_style_opa(lv.OPA._70, lv.PART.MAIN) if self.scanqr_mode: - cancel_button.align(lv.ALIGN.BOTTOM_MID, mpos.ui.pct_of_display_width(10), 0) + cancel_button.align(lv.ALIGN.BOTTOM_MID, DisplayMetrics.pct_of_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) @@ -270,20 +260,24 @@ def add_buttons(self, parent): 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() + save_button = lv.button(button_cont) + save_button.set_size(lv.SIZE_CONTENT, lv.SIZE_CONTENT) + save_button.align(lv.ALIGN.BOTTOM_RIGHT, 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() + 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) + tab.set_style_pad_all(1, lv.PART.MAIN) # Color Mode colormode = prefs.get_bool("colormode") @@ -337,7 +331,7 @@ def create_basic_tab(self, tab, prefs): 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) + tab.set_style_pad_all(1, lv.PART.MAIN) # Auto Exposure Control (master switch) exposure_ctrl = prefs.get_bool("exposure_ctrl") @@ -358,11 +352,11 @@ def create_advanced_tab(self, tab, prefs): 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) + WidgetAnimator.smooth_hide(me_cont, duration=1000) + WidgetAnimator.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) + WidgetAnimator.smooth_hide(ae_cont, duration=1000) + WidgetAnimator.smooth_show(me_cont, delay=1000) aec_checkbox.add_event_cb(exposure_ctrl_changed, lv.EVENT.VALUE_CHANGED, None) exposure_ctrl_changed() @@ -386,9 +380,9 @@ 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) + WidgetAnimator.smooth_hide(agc_cont, duration=1000) else: - mpos.ui.anim.smooth_show(agc_cont, duration=1000) + WidgetAnimator.smooth_show(agc_cont, duration=1000) agc_checkbox.add_event_cb(gain_ctrl_changed, lv.EVENT.VALUE_CHANGED, None) gain_ctrl_changed() @@ -418,9 +412,9 @@ def gain_ctrl_changed(e=None): 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) + WidgetAnimator.smooth_hide(wb_cont, duration=1000) else: - mpos.ui.anim.smooth_show(wb_cont, duration=1000) + WidgetAnimator.smooth_show(wb_cont, duration=1000) wbcheckbox.add_event_cb(whitebal_changed, lv.EVENT.VALUE_CHANGED, None) whitebal_changed() @@ -445,7 +439,7 @@ 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) + tab.set_style_pad_all(1, lv.PART.MAIN) # Sharpness sharpness = prefs.get_int("sharpness") @@ -497,7 +491,7 @@ def create_expert_tab(self, tab, prefs): def create_raw_tab(self, tab, prefs): tab.set_flex_flow(lv.FLEX_FLOW.COLUMN) - tab.set_style_pad_all(0, 0) + tab.set_style_pad_all(0, lv.PART.MAIN) # This would be nice but does not provide adequate resolution: #startX, label, cont = self.create_slider(tab, "startX", 0, 2844, startX, "startX") diff --git a/internal_filesystem/lib/mpos/ui/display.py b/internal_filesystem/lib/mpos/ui/display.py deleted file mode 100644 index 991e1657..00000000 --- a/internal_filesystem/lib/mpos/ui/display.py +++ /dev/null @@ -1,50 +0,0 @@ -# lib/mpos/ui/display.py -import lvgl as lv - -_horizontal_resolution = None -_vertical_resolution = None - -def init_rootscreen(): - global _horizontal_resolution, _vertical_resolution - screen = lv.screen_active() - disp = screen.get_display() - _horizontal_resolution = disp.get_horizontal_resolution() - _vertical_resolution = disp.get_vertical_resolution() - print(f"init_rootscreen set _vertical_resolution to {_vertical_resolution}") - label = lv.label(screen) - label.set_text("Welcome to MicroPythonOS") - label.center() - -def get_pointer_xy(): - indev = lv.indev_active() - if indev: - p = lv.point_t() - indev.get_point(p) - return p.x, p.y - 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(): - return min(_horizontal_resolution, _vertical_resolution) - -def max_resolution(): - return max(_horizontal_resolution, _vertical_resolution) - -def get_display_width(): - if _horizontal_resolution is None: - _init_resolution() - return _horizontal_resolution - -def get_display_height(): - if _vertical_resolution is None: - _init_resolution() - return _vertical_resolution diff --git a/internal_filesystem/lib/mpos/ui/display_metrics.py b/internal_filesystem/lib/mpos/ui/display_metrics.py new file mode 100644 index 00000000..a01502f4 --- /dev/null +++ b/internal_filesystem/lib/mpos/ui/display_metrics.py @@ -0,0 +1,71 @@ +# lib/mpos/ui/display_metrics.py +""" +DisplayMetrics - Android-inspired display metrics singleton. + +Provides a clean, unified API for accessing display properties like width, height, and DPI. +All methods are class methods, so no instance creation is needed. +""" + + +class DisplayMetrics: + """ + Display metrics singleton (Android-inspired). + + Provides static/class methods for accessing display properties. + Initialized by display.init_rootscreen() which calls set_resolution() and set_dpi(). + """ + + _width = None + _height = None + _dpi = None + + @classmethod + def set_resolution(cls, width, height): + """Set the display resolution (called by init_rootscreen).""" + cls._width = width + cls._height = height + + @classmethod + def set_dpi(cls, dpi): + """Set the display DPI (called by init_rootscreen).""" + cls._dpi = dpi + + @classmethod + def width(cls): + """Get display width in pixels.""" + return cls._width + + @classmethod + def height(cls): + """Get display height in pixels.""" + return cls._height + + @classmethod + def dpi(cls): + """Get display DPI (dots per inch).""" + return cls._dpi + + @classmethod + def pct_of_width(cls, pct): + """Get percentage of display width.""" + if pct == 100: + return cls._width + return round(cls._width * pct / 100) + + @classmethod + def pct_of_height(cls, pct): + """Get percentage of display height.""" + if pct == 100: + return cls._height + return round(cls._height * pct / 100) + + @classmethod + def min_dimension(cls): + """Get minimum dimension (width or height).""" + return min(cls._width, cls._height) + + @classmethod + def max_dimension(cls): + """Get maximum dimension (width or height).""" + return max(cls._width, cls._height) + diff --git a/internal_filesystem/lib/mpos/ui/event.py b/internal_filesystem/lib/mpos/ui/event.py index eb21fb4b..713fc90d 100644 --- a/internal_filesystem/lib/mpos/ui/event.py +++ b/internal_filesystem/lib/mpos/ui/event.py @@ -82,4 +82,4 @@ def print_event(event): name = get_event_name(code) target = event.get_target_obj() key = f", key: {event.get_key()}" if code == lv.EVENT.KEY else "" - print(f"{target} → {name}{key}") + print(f"{target} → {code}:{name}{key}") diff --git a/internal_filesystem/lib/mpos/ui/focus_direction.py b/internal_filesystem/lib/mpos/ui/focus_direction.py index f99a00a0..af439908 100644 --- a/internal_filesystem/lib/mpos/ui/focus_direction.py +++ b/internal_filesystem/lib/mpos/ui/focus_direction.py @@ -144,37 +144,8 @@ def process_object(obj, depth=0): return closest_obj - - - - - - - - - - - - -# This function is missing so emulate it using focus_next(): -def emulate_focus_obj(focusgroup, target): - if not focusgroup: - print("emulate_focus_obj needs a focusgroup, returning...") - return - if not target: - print("emulate_focus_obj needs a target, returning...") - return - for objnr in range(focusgroup.get_obj_count()): - currently_focused = focusgroup.get_focused() - #print ("emulate_focus_obj: currently focused:") ; mpos.util.print_lvgl_widget(currently_focused) - if currently_focused is target: - print("emulate_focus_obj: found target, stopping") - return - else: - focusgroup.focus_next() - print("WARNING: emulate_focus_obj failed to find target") - def move_focus_direction(angle): + from .input_manager import InputManager focus_group = lv.group_get_default() if not focus_group: print("move_focus_direction: no default focus_group found, returning...") @@ -203,4 +174,4 @@ def move_focus_direction(angle): if o: #print("move_focus_direction: moving focus to:") #mpos.util.print_lvgl_widget(o) - emulate_focus_obj(focus_group, o) + InputManager.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 c43a25ad..003c8d6d 100644 --- a/internal_filesystem/lib/mpos/ui/gesture_navigation.py +++ b/internal_filesystem/lib/mpos/ui/gesture_navigation.py @@ -1,9 +1,10 @@ import lvgl as lv from lvgl import LvReferenceError -from .anim import smooth_show, smooth_hide +from .widget_animator import WidgetAnimator from .view import back_screen -from .topmenu import open_drawer, drawer_open, NOTIFICATION_BAR_HEIGHT -from .display import get_display_width, get_display_height +from mpos.ui import topmenu as topmenu +from .display_metrics import DisplayMetrics +from .appearance_manager import AppearanceManager downbutton = None backbutton = None @@ -31,10 +32,6 @@ def _passthrough_click(x, y, indev): print(f"Object to click is gone: {e}") def _back_swipe_cb(event): - if drawer_open: - print("ignoring back gesture because drawer is open") - return - global backbutton, back_start_y, back_start_x, backbutton_visible event_code = event.get_code() indev = lv.indev_active() @@ -54,20 +51,23 @@ def _back_swipe_cb(event): 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) + WidgetAnimator.smooth_show(backbutton) if should_show else WidgetAnimator.smooth_hide(backbutton) backbutton.set_pos(round(x / 10), back_start_y) elif event_code == lv.EVENT.RELEASED: if backbutton_visible: backbutton_visible = False - smooth_hide(backbutton) - if x > get_display_width() / 5: - back_screen() + WidgetAnimator.smooth_hide(backbutton) + if x > DisplayMetrics.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) def _top_swipe_cb(event): - if drawer_open: + if topmenu.drawer_open: print("ignoring top swipe gesture because drawer is open") return @@ -90,16 +90,16 @@ def _top_swipe_cb(event): 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) + WidgetAnimator.smooth_show(downbutton) if should_show else WidgetAnimator.smooth_hide(downbutton) downbutton.set_pos(down_start_x, round(y / 10)) elif event_code == lv.EVENT.RELEASED: if downbutton_visible: downbutton_visible = False - smooth_hide(downbutton) + WidgetAnimator.smooth_hide(downbutton) dx = abs(x - down_start_x) dy = abs(y - down_start_y) - if y > get_display_height() / 5: - open_drawer() + if y > DisplayMetrics.height() / 5: + topmenu.open_drawer() elif is_short_movement(dx, dy): # print("Short movement - treating as tap") _passthrough_click(x, y, indev) @@ -107,10 +107,10 @@ def _top_swipe_cb(event): def handle_back_swipe(): global backbutton rect = lv.obj(lv.layer_top()) - rect.set_size(NOTIFICATION_BAR_HEIGHT, lv.layer_top().get_height()-NOTIFICATION_BAR_HEIGHT) # narrow because it overlaps buttons + rect.set_size(AppearanceManager.NOTIFICATION_BAR_HEIGHT, lv.layer_top().get_height()-AppearanceManager.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, AppearanceManager.NOTIFICATION_BAR_HEIGHT) style = lv.style_t() style.init() style.set_bg_opa(lv.OPA.TRANSP) @@ -132,13 +132,13 @@ def handle_back_swipe(): backbutton.add_state(lv.STATE.DISABLED) backlabel = lv.label(backbutton) backlabel.set_text(lv.SYMBOL.LEFT) - backlabel.set_style_text_font(lv.font_montserrat_18, 0) + backlabel.set_style_text_font(lv.font_montserrat_18, lv.PART.MAIN) backlabel.center() def handle_top_swipe(): global downbutton rect = lv.obj(lv.layer_top()) - rect.set_size(lv.pct(100), NOTIFICATION_BAR_HEIGHT) + rect.set_size(lv.pct(100), AppearanceManager.NOTIFICATION_BAR_HEIGHT) rect.set_pos(0, 0) rect.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) style = lv.style_t() @@ -162,5 +162,5 @@ def handle_top_swipe(): downbutton.add_state(lv.STATE.DISABLED) downlabel = lv.label(downbutton) downlabel.set_text(lv.SYMBOL.DOWN) - downlabel.set_style_text_font(lv.font_montserrat_18, 0) + downlabel.set_style_text_font(lv.font_montserrat_18, lv.PART.MAIN) downlabel.center() diff --git a/internal_filesystem/lib/mpos/ui/input_manager.py b/internal_filesystem/lib/mpos/ui/input_manager.py new file mode 100644 index 00000000..ded60e63 --- /dev/null +++ b/internal_filesystem/lib/mpos/ui/input_manager.py @@ -0,0 +1,104 @@ +# lib/mpos/ui/input_manager.py +""" +InputManager - Framework for managing input device interactions. + +Provides a clean API for accessing input device data like pointer/touch coordinates, +focus management, and input device registration. +All methods are class methods, so no instance creation is needed. +""" + +import lvgl as lv + + +class InputManager: + """ + Input manager singleton for handling input device interactions. + + Provides static/class methods for accessing input device properties and data. + """ + + _registered_indevs = [] # List of registered input devices + + @classmethod + def register_indev(cls, indev): + """ + Register an input device for later querying. + Called by board initialization code. + + Parameters: + - indev: LVGL input device object + """ + if indev and indev not in cls._registered_indevs: + cls._registered_indevs.append(indev) + + @classmethod + def unregister_indev(cls, indev): + """ + Unregister an input device. + + Parameters: + - indev: LVGL input device object to remove + """ + if indev in cls._registered_indevs: + indev.enable(False) + cls._registered_indevs.remove(indev) + + @classmethod + def list_indevs(cls): + """ + Get list of all registered input devices. + + Returns: list of LVGL input device objects + """ + return cls._registered_indevs + + @classmethod + def has_indev_type(cls, indev_type): + """ + Check if any registered input device has the specified type. + + Parameters: + - indev_type: LVGL input device type (e.g., lv.INDEV_TYPE.KEYPAD) + + Returns: bool - True if device type is available + """ + for indev in cls._registered_indevs: + if indev.get_type() == indev_type: + return True + return False + + @classmethod + def has_pointer(cls): + """Check if any registered input device is a pointer/touch device.""" + return cls.has_indev_type(lv.INDEV_TYPE.POINTER) + + @classmethod + def pointer_xy(cls): + """Get current pointer/touch coordinates.""" + indev = lv.indev_active() + if indev: + p = lv.point_t() + indev.get_point(p) + return p.x, p.y + return -1, -1 + + @classmethod + def emulate_focus_obj(cls, focusgroup, target): + """ + Emulate setting focus to a specific object in the focus group. + This function is needed because the current version of LVGL doesn't have a direct set_focus method. + It should exist, according to the API, so maybe it will be available in the next release and this function might no longer be needed someday. + """ + if not focusgroup: + print("emulate_focus_obj needs a focusgroup, returning...") + return + if not target: + print("emulate_focus_obj needs a target, returning...") + return + for objnr in range(focusgroup.get_obj_count()): + currently_focused = focusgroup.get_focused() + if currently_focused is target: + return + else: + focusgroup.focus_next() + print("WARNING: emulate_focus_obj failed to find target") diff --git a/internal_filesystem/lib/mpos/ui/keyboard.py b/internal_filesystem/lib/mpos/ui/keyboard.py index 50164b4b..625c3e6c 100644 --- a/internal_filesystem/lib/mpos/ui/keyboard.py +++ b/internal_filesystem/lib/mpos/ui/keyboard.py @@ -11,12 +11,14 @@ # Create keyboard keyboard = MposKeyboard(parent_obj) keyboard.set_textarea(my_textarea) - keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + keyboard.add_flag(lv.obj.FLAG.HIDDEN) # shows up when textarea is clicked """ import lvgl as lv -import mpos.ui.theme + +from .appearance_manager import AppearanceManager +from .widget_animator import WidgetAnimator class MposKeyboard: """ @@ -101,15 +103,17 @@ class MposKeyboard: } _current_mode = None + _parent = None # used for scroll_to_y + _saved_scroll_y = 0 + # Store textarea reference (we DON'T pass it to LVGL to avoid double-typing) + _textarea = None def __init__(self, parent): # Create underlying LVGL keyboard widget self._keyboard = lv.keyboard(parent) + self._parent = parent # store it for later # 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._keyboard.set_style_text_font(lv.font_montserrat_20, lv.PART.MAIN) self.set_mode(self.MODE_LOWERCASE) @@ -119,14 +123,21 @@ def __init__(self, parent): 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) + AppearanceManager.apply_keyboard_fix(self._keyboard) # Set good default height - self._keyboard.set_style_min_height(175, 0) + self._keyboard.set_style_min_height(175, lv.PART.MAIN) def _handle_events(self, event): code = event.get_code() - #print(f"keyboard event code = {code}") + + ''' + # DEBUG: + from .event import get_event_name + name = get_event_name(code) + print(f"keyboard event code = {code} is {name}") + ''' + if code == lv.EVENT.READY or code == lv.EVENT.CANCEL: self.hide_keyboard() return @@ -230,6 +241,37 @@ def set_mode(self, mode): self._keyboard.set_map(mode, key_map, ctrl_map) self._keyboard.set_mode(mode) + def scroll_after_show(self, timer): + #self._textarea.scroll_to_view_recursive(True) # makes sense but doesn't work and breaks the keyboard scroll + self._keyboard.scroll_to_view_recursive(True) + + def focus_on_keyboard(self, timer=None): + default_group = lv.group_get_default() + if default_group: + from .input_manager import InputManager + from .focus_direction import move_focus_direction + InputManager.emulate_focus_obj(default_group, self._keyboard) + + def scroll_back_after_hide(self, timer): + self._parent.scroll_to_y(self._saved_scroll_y, True) + + def show_keyboard(self): + self._saved_scroll_y = self._parent.get_scroll_y() + WidgetAnimator.smooth_show(self._keyboard, duration=500) + # Scroll to view on a timer because it will be hidden initially + lv.timer_create(self.scroll_after_show, 250, None).set_repeat_count(1) + # When this is done from a timer, focus styling is not applied so the user doesn't see which button is selected. + # Maybe because there's no active indev anymore? + # Maybe it will be fixed in an update of LVGL 9.3? + # focus_timer = lv.timer_create(self.focus_on_keyboard,750,None).set_repeat_count(1) + # Workaround: show the keyboard immediately and then focus on it - that works, and doesn't seem to flicker as feared: + self._keyboard.remove_flag(lv.obj.FLAG.HIDDEN) + self.focus_on_keyboard() + + def hide_keyboard(self): + WidgetAnimator.smooth_hide(self._keyboard, duration=500) + # Do this after the hide so the scrollbars disappear automatically if not needed + scroll_timer = lv.timer_create(self.scroll_back_after_hide,550,None).set_repeat_count(1) # Python magic method for automatic method forwarding def __getattr__(self, name): @@ -244,14 +286,8 @@ def __getattr__(self, name): Examples: keyboard.set_textarea(ta) # Works keyboard.align(lv.ALIGN.CENTER) # Works - keyboard.set_style_opa(128, 0) # Works + keyboard.set_style_opa(128, lv.PART.MAIN) # 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/setting_activity.py b/internal_filesystem/lib/mpos/ui/setting_activity.py new file mode 100644 index 00000000..0bac18c6 --- /dev/null +++ b/internal_filesystem/lib/mpos/ui/setting_activity.py @@ -0,0 +1,224 @@ +import lvgl as lv + +from ..app.activity import Activity +from .camera_activity import CameraActivity +from .display_metrics import DisplayMetrics +from .widget_animator import WidgetAnimator +from ..camera_manager import CameraManager + +""" +SettingActivity is used to edit one setting. +For now, it only supports strings. +""" +class SettingActivity(Activity): + + active_radio_index = -1 # Track active radio button index + prefs = None # taken from the intent + + # Widgets: + keyboard = None + textarea = None + dropdown = None + radio_container = None + + def onCreate(self): + self.prefs = self.getIntent().extras.get("prefs") + setting = self.getIntent().extras.get("setting") + print(setting) + + settings_screen_detail = lv.obj() + settings_screen_detail.set_style_pad_all(0, lv.PART.MAIN) + settings_screen_detail.set_flex_flow(lv.FLEX_FLOW.COLUMN) + + top_cont = lv.obj(settings_screen_detail) + top_cont.set_width(lv.pct(100)) + top_cont.set_style_border_width(0, lv.PART.MAIN) + top_cont.set_height(lv.SIZE_CONTENT) + top_cont.set_style_pad_all(0, lv.PART.MAIN) + top_cont.set_flex_flow(lv.FLEX_FLOW.ROW) + top_cont.set_style_flex_main_place(lv.FLEX_ALIGN.SPACE_BETWEEN, lv.PART.MAIN) + 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_16, lv.PART.MAIN) + + ui = setting.get("ui") + ui_options = setting.get("ui_options") + current_setting = self.prefs.get_string(setting["key"], setting.get("default_value")) + if ui and ui == "radiobuttons" and ui_options: + # Create container for radio buttons + self.radio_container = lv.obj(settings_screen_detail) + 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.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): + cb = self.create_radio_button(self.radio_container, option_text, i) + if current_setting == option_value: + self.active_radio_index = i + cb.add_state(lv.STATE.CHECKED) + elif ui and ui == "dropdown" and ui_options: + self.dropdown = lv.dropdown(settings_screen_detail) + self.dropdown.set_width(lv.pct(100)) + options_with_newlines = "" + for option in ui_options: + if option[0] != option[1]: + options_with_newlines += (f"{option[0]} ({option[1]})\n") + else: # don't show identical options + options_with_newlines += (f"{option[0]}\n") + self.dropdown.set_options(options_with_newlines) + # select the right one: + for i, (option_text, option_value) in enumerate(ui_options): + if current_setting == option_value: + self.dropdown.set_selected(i) + break # no need to check the rest because only one can be selected + else: # Textarea for other settings + ui = "textarea" + self.textarea = lv.textarea(settings_screen_detail) + self.textarea.set_width(lv.pct(100)) + self.textarea.set_style_pad_all(DisplayMetrics.pct_of_width(2), lv.PART.MAIN) + self.textarea.set_style_margin_left(DisplayMetrics.pct_of_width(2), lv.PART.MAIN) + self.textarea.set_style_margin_right(DisplayMetrics.pct_of_width(2), lv.PART.MAIN) + self.textarea.set_one_line(True) + if current_setting: + self.textarea.set_text(current_setting) + placeholder = setting.get("placeholder") + if placeholder: + self.textarea.set_placeholder_text(placeholder) + from mpos import MposKeyboard + self.keyboard = MposKeyboard(settings_screen_detail) + self.keyboard.add_flag(lv.obj.FLAG.HIDDEN) + self.keyboard.set_textarea(self.textarea) + + # Button container + btn_cont = lv.obj(settings_screen_detail) + btn_cont.set_width(lv.pct(100)) + btn_cont.set_style_border_width(0, lv.PART.MAIN) + btn_cont.set_height(lv.SIZE_CONTENT) + btn_cont.set_flex_flow(lv.FLEX_FLOW.ROW) + btn_cont.set_style_flex_main_place(lv.FLEX_ALIGN.SPACE_BETWEEN, lv.PART.MAIN) + # Cancel button + cancel_btn = lv.button(btn_cont) + cancel_btn.set_size(lv.pct(45), lv.SIZE_CONTENT) + cancel_btn.set_style_opa(lv.OPA._70, lv.PART.MAIN) + cancel_label = lv.label(cancel_btn) + cancel_label.set_text("Cancel") + cancel_label.center() + cancel_btn.add_event_cb(lambda e: self.finish(), lv.EVENT.CLICKED, None) + # Save button + save_btn = lv.button(btn_cont) + save_btn.set_size(lv.pct(45), lv.SIZE_CONTENT) + save_label = lv.label(save_btn) + save_label.set_text("Save") + save_label.center() + save_btn.add_event_cb(lambda e, s=setting: self.save_setting(s), lv.EVENT.CLICKED, None) + + if ui == "textarea" and CameraManager.has_camera(): # Scan QR button for text settings (only if camera available) + cambutton = lv.button(settings_screen_detail) + cambutton.align(lv.ALIGN.BOTTOM_MID, 0, 0) + cambutton.set_size(lv.pct(100), lv.pct(30)) + cambuttonlabel = lv.label(cambutton) + cambuttonlabel.set_text("Scan data from QR code") + cambuttonlabel.set_style_text_font(lv.font_montserrat_18, lv.PART.MAIN) + cambuttonlabel.align(lv.ALIGN.TOP_MID, 0, 0) + cambuttonlabel2 = lv.label(cambutton) + cambuttonlabel2.set_text("Tip: Create your own QR code,\nusing https://genqrcode.com or another tool.") + cambuttonlabel2.set_style_text_font(lv.font_montserrat_10, lv.PART.MAIN) + cambuttonlabel2.align(lv.ALIGN.BOTTOM_MID, 0, 0) + cambutton.add_event_cb(self.cambutton_cb, lv.EVENT.CLICKED, None) + + self.setContentView(settings_screen_detail) + + def onStop(self, screen): + if self.keyboard: + WidgetAnimator.smooth_hide(self.keyboard) + + def radio_event_handler(self, event): + print("radio_event_handler called") + 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): + # A fix for the "checkbox unchecks when arrow up is pressed" + # can be implemented like in the wifi.py app: manually adding a clickable label + cb = lv.checkbox(parent) + cb.set_text(text) + cb.add_flag(lv.obj.FLAG.EVENT_BUBBLE) + # Add circular style to indicator for radio button appearance + style_radio = lv.style_t() + style_radio.init() + style_radio.set_radius(lv.RADIUS_CIRCLE) + cb.add_style(style_radio, lv.PART.INDICATOR) + style_radio_chk = lv.style_t() + style_radio_chk.init() + style_radio_chk.set_bg_image_src(None) + cb.add_style(style_radio_chk, lv.PART.INDICATOR | lv.STATE.CHECKED) + return cb + + def gotqr_result_callback(self, result): + print(f"QR capture finished, result: {result}") + if result.get("result_code"): + data = result.get("data") + print(f"Setting textarea data: {data}") + self.textarea.set_text(data) + + def cambutton_cb(self, event): + from ..content.intent import Intent + print("cambutton clicked!") + self.startActivityForResult(Intent(activity_class=CameraActivity).putExtra("scanqr_intent", True), self.gotqr_result_callback) + + def save_setting(self, setting): + ui = setting.get("ui") + ui_options = setting.get("ui_options") + if ui and ui == "radiobuttons" and ui_options: + selected_idx = self.active_radio_index + new_value = "" + if selected_idx >= 0: + new_value = ui_options[selected_idx][1] + elif ui and ui == "dropdown" and ui_options: + selected_index = self.dropdown.get_selected() + print(f"selected item: {selected_index}") + new_value = ui_options[selected_index][1] + elif self.textarea: + new_value = self.textarea.get_text() + else: + new_value = "" + old_value = self.prefs.get_string(setting["key"]) + + # Save it + if setting.get("dont_persist") is not True: + editor = self.prefs.edit() + editor.put_string(setting["key"], new_value) + editor.commit() + + # Update model for UI + value_label = setting.get("value_label") + if value_label: + value_label.set_text(new_value if new_value else "(not set)") + + # self.finish (= back action) should happen before callback, in case it happens to start a new activity + self.finish() + + # Call changed_callback if set + changed_callback = setting.get("changed_callback") + 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(new_value) diff --git a/internal_filesystem/lib/mpos/ui/settings_activity.py b/internal_filesystem/lib/mpos/ui/settings_activity.py new file mode 100644 index 00000000..064133f0 --- /dev/null +++ b/internal_filesystem/lib/mpos/ui/settings_activity.py @@ -0,0 +1,112 @@ +import lvgl as lv + +from ..app.activity import Activity +from .setting_activity import SettingActivity +import mpos.ui + +# Used to list and edit all settings: +class SettingsActivity(Activity): + + # Taken from the Intent (initialized in onCreate) + prefs = None + settings = () + + def onCreate(self): + extras = self.getIntent().extras or {} + self.prefs = extras.get("prefs") + self.settings = extras.get("settings") or () + if not self.prefs: + print("ERROR: SettingsActivity missing 'prefs' in Intent extras") + if not self.settings: + print("WARNING: SettingsActivity has no settings to display") + + print("creating SettingsActivity ui...") + screen = lv.obj() + screen.set_style_pad_all(mpos.ui.DisplayMetrics.pct_of_width(2), lv.PART.MAIN) + screen.set_flex_flow(lv.FLEX_FLOW.COLUMN) + screen.set_style_border_width(0, lv.PART.MAIN) + self.setContentView(screen) + + def onResume(self, screen): + # Create settings entries + screen.clean() + if not self.prefs: + print("ERROR: SettingsActivity cannot render without prefs") + return + # Get the group for focusable objects + focusgroup = lv.group_get_default() + if not focusgroup: + print("WARNING: could not get default focusgroup") + + for setting in self.settings: + # Check if it should be shown: + should_show_function = setting.get("should_show") + if should_show_function: + should_show = should_show_function(setting) + if should_show is False: + continue + # Container for each setting + setting_cont = lv.obj(screen) + setting_cont.set_width(lv.pct(100)) + setting_cont.set_height(lv.SIZE_CONTENT) + setting_cont.set_style_border_width(1, lv.PART.MAIN) + setting_cont.set_style_pad_all(mpos.ui.DisplayMetrics.pct_of_width(2), lv.PART.MAIN) + setting_cont.add_flag(lv.obj.FLAG.CLICKABLE) + setting["cont"] = setting_cont # Store container reference for visibility control + + # Title label (bold, larger) + title = lv.label(setting_cont) + title.set_text(setting["title"]) + title.set_style_text_font(lv.font_montserrat_16, lv.PART.MAIN) + title.set_pos(0, 0) + + # Value label (smaller, below title) + value = lv.label(setting_cont) + if setting.get("activity_class"): + placeholder = setting.get("placeholder") or "" + value_text = placeholder + elif setting.get("dont_persist"): + value_text = "(not persisted)" + else: + stored_value = self.prefs.get_string(setting["key"]) + if stored_value is None: + default_value = setting.get("default_value") + if default_value is not None: + value_text = f"(defaults to {default_value})" + else: + value_text = "(not set)" + else: + value_text = stored_value + value.set_text(value_text) + value.set_style_text_font(lv.font_montserrat_12, lv.PART.MAIN) + value.set_style_text_color(lv.color_hex(0x666666), lv.PART.MAIN) + value.set_pos(0, 20) + setting["value_label"] = value # Store reference for updating + setting_cont.add_event_cb(lambda e, s=setting: self.startSettingActivity(s), lv.EVENT.CLICKED, None) + setting_cont.add_event_cb(lambda e, container=setting_cont: self.focus_container(container),lv.EVENT.FOCUSED,None) + setting_cont.add_event_cb(lambda e, container=setting_cont: self.defocus_container(container),lv.EVENT.DEFOCUSED,None) + if focusgroup: + focusgroup.add_obj(setting_cont) + + 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) + container.set_style_border_width(1, lv.PART.MAIN) + container.scroll_to_view(True) # scroll to bring it into view + + def defocus_container(self, container): + #print(f"container {container} defocused, unsetting border...") + container.set_style_border_width(0, lv.PART.MAIN) + + def startSettingActivity(self, setting): + from ..content.intent import Intent + activity_class = SettingActivity + if setting.get("ui") == "activity": + activity_class = setting.get("activity_class") + if not activity_class: + print("ERROR: Setting is defined as 'activity' ui without 'activity_class', aborting...") + + intent = Intent(activity_class=activity_class) + intent.putExtra("setting", setting) + intent.putExtra("prefs", self.prefs) + self.startActivity(intent) diff --git a/internal_filesystem/lib/mpos/ui/testing.py b/internal_filesystem/lib/mpos/ui/testing.py index dc3fa063..fbc2163b 100644 --- a/internal_filesystem/lib/mpos/ui/testing.py +++ b/internal_filesystem/lib/mpos/ui/testing.py @@ -13,9 +13,10 @@ Usage in tests: from mpos.ui.testing import wait_for_render, capture_screenshot + from mpos import AppManager # Start your app - mpos.apps.start_app("com.example.myapp") + AppManager.start_app("com.example.myapp") # Wait for UI to render wait_for_render() @@ -41,6 +42,12 @@ """ import lvgl as lv +import time + +try: + import unittest +except ImportError: # pragma: no cover - fallback for device builds without unittest + unittest = None # Simulation globals for touch input _touch_x = 0 @@ -49,6 +56,227 @@ _touch_indev = None +class GraphicalTestCase(unittest.TestCase if unittest else object): + """ + Base class for graphical tests. + + Provides: + - Automatic screen creation and cleanup + - Common UI testing utilities + + Class Attributes: + SCREEN_WIDTH: Default screen width (320) + SCREEN_HEIGHT: Default screen height (240) + DEFAULT_RENDER_ITERATIONS: Default iterations for wait_for_render (5) + + Instance Attributes: + screen: The LVGL screen object for the test + """ + + SCREEN_WIDTH = 320 + SCREEN_HEIGHT = 240 + DEFAULT_RENDER_ITERATIONS = 5 + + def setUp(self): + """Set up test fixtures before each test method.""" + self.screen = lv.obj() + self.screen.set_size(self.SCREEN_WIDTH, self.SCREEN_HEIGHT) + lv.screen_load(self.screen) + self.wait_for_render() + + def tearDown(self): + """Clean up after each test method.""" + lv.screen_load(lv.obj()) + self.wait_for_render() + + def wait_for_render(self, iterations=None): + """Wait for LVGL to render.""" + if iterations is None: + iterations = self.DEFAULT_RENDER_ITERATIONS + wait_for_render(iterations) + + def find_label_with_text(self, text, parent=None): + """Find a label containing the specified text.""" + if parent is None: + parent = lv.screen_active() + return find_label_with_text(parent, text) + + def verify_text_present(self, text, parent=None): + """Verify that text is present on screen.""" + if parent is None: + parent = lv.screen_active() + return verify_text_present(parent, text) + + def print_screen_labels(self, parent=None): + """Print all labels on screen (for debugging).""" + if parent is None: + parent = lv.screen_active() + print_screen_labels(parent) + + def click_button(self, text, use_send_event=True): + """Click a button by its text.""" + return click_button(text, use_send_event=use_send_event) + + def click_label(self, text, use_send_event=True): + """Click a label by its text.""" + return click_label(text, use_send_event=use_send_event) + + def simulate_click(self, x, y): + """Simulate a click at specific coordinates.""" + simulate_click(x, y) + self.wait_for_render() + + def assertTextPresent(self, text, msg=None): + """Assert that text is present on screen.""" + if msg is None: + msg = f"Text '{text}' not found on screen" + self.assertTrue(self.verify_text_present(text), msg) + + def assertTextNotPresent(self, text, msg=None): + """Assert that text is NOT present on screen.""" + if msg is None: + msg = f"Text '{text}' should not be on screen" + self.assertFalse(self.verify_text_present(text), msg) + + +class KeyboardTestCase(GraphicalTestCase): + """ + Base class for keyboard tests. + + Extends GraphicalTestCase with keyboard-specific functionality. + + Instance Attributes: + keyboard: The MposKeyboard instance (after create_keyboard_scene) + textarea: The textarea widget (after create_keyboard_scene) + """ + + DEFAULT_RENDER_ITERATIONS = 10 + + def setUp(self): + """Set up test fixtures.""" + super().setUp() + self.keyboard = None + self.textarea = None + + def create_keyboard_scene(self, initial_text="", textarea_width=200, textarea_height=30): + """ + Create a standard keyboard test scene with textarea and keyboard. + + Args: + initial_text: Initial text in the textarea + textarea_width: Width of the textarea + textarea_height: Height of the textarea + + Returns: + tuple: (keyboard, textarea) + """ + from mpos import MposKeyboard + + self.textarea = lv.textarea(self.screen) + self.textarea.set_size(textarea_width, textarea_height) + self.textarea.set_one_line(True) + self.textarea.align(lv.ALIGN.TOP_MID, 0, 10) + self.textarea.set_text(initial_text) + self.wait_for_render() + + self.keyboard = MposKeyboard(self.screen) + self.keyboard.set_textarea(self.textarea) + self.keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + self.wait_for_render() + + return self.keyboard, self.textarea + + def click_keyboard_button(self, button_text): + """ + Click a keyboard button by its text. + + Args: + button_text: The text of the button to click (e.g., "q", "a", "Enter") + + Returns: + bool: True if button was clicked successfully + """ + if self.keyboard is None: + raise RuntimeError("No keyboard created. Call create_keyboard_scene() first.") + + return click_keyboard_button(self.keyboard, button_text) + + def get_textarea_text(self): + """Get the current text in the textarea.""" + if self.textarea is None: + raise RuntimeError("No textarea created. Call create_keyboard_scene() first.") + return self.textarea.get_text() + + def set_textarea_text(self, text): + """Set the textarea text.""" + if self.textarea is None: + raise RuntimeError("No textarea created. Call create_keyboard_scene() first.") + self.textarea.set_text(text) + self.wait_for_render() + + def clear_textarea(self): + """Clear the textarea.""" + self.set_textarea_text("") + + def type_text(self, text): + """Type a string by clicking each character on the keyboard.""" + for char in text: + if not self.click_keyboard_button(char): + return False + return True + + def assertTextareaText(self, expected, msg=None): + """Assert that the textarea contains the expected text.""" + actual = self.get_textarea_text() + if msg is None: + msg = f"Textarea text mismatch. Expected '{expected}', got '{actual}'" + self.assertEqual(actual, expected, msg) + + def assertTextareaEmpty(self, msg=None): + """Assert that the textarea is empty.""" + if msg is None: + msg = f"Textarea should be empty, but contains '{self.get_textarea_text()}'" + self.assertEqual(self.get_textarea_text(), "", msg) + + def assertTextareaContains(self, substring, msg=None): + """Assert that the textarea contains a substring.""" + actual = self.get_textarea_text() + if msg is None: + msg = f"Textarea should contain '{substring}', but has '{actual}'" + self.assertIn(substring, actual, msg) + + def get_keyboard_button_text(self, index): + """Get the text of a keyboard button by index.""" + if self.keyboard is None: + raise RuntimeError("No keyboard created. Call create_keyboard_scene() first.") + + try: + return self.keyboard.get_button_text(index) + except: + return None + + def find_keyboard_button_index(self, button_text): + """Find the index of a keyboard button by its text.""" + for i in range(100): + text = self.get_keyboard_button_text(i) + if text is None: + break + if text == button_text: + return i + return None + + def get_all_keyboard_buttons(self): + """Get all keyboard buttons as a list of (index, text) tuples.""" + buttons = [] + for i in range(100): + text = self.get_keyboard_button_text(i) + if text is None: + break + if text: + buttons.append((i, text)) + return buttons + + def wait_for_render(iterations=10): """ Wait for LVGL to process UI events and render. @@ -61,7 +289,8 @@ def wait_for_render(iterations=10): iterations: Number of task handler iterations to run (default: 10) Example: - mpos.apps.start_app("com.example.myapp") + from mpos import AppManager + AppManager.start_app("com.example.myapp") wait_for_render() # Ensure UI is ready assert verify_text_present(lv.screen_active(), "Welcome") """ @@ -256,7 +485,6 @@ def get_screen_text_content(obj): pass # Error getting text return texts - def verify_text_present(obj, expected_text): """ Verify that expected text is present somewhere on screen. @@ -278,6 +506,110 @@ def verify_text_present(obj, expected_text): return find_label_with_text(obj, expected_text) is not None +def find_setting_value_label(obj, setting_title_text): + """ + Find the value label associated with a SettingsActivity setting title. + + SettingsActivity renders each setting as a container with two labels: + a title label (large) and a value label (smaller) directly below it. + This helper finds the title label, then returns the sibling value label. + + Args: + obj: LVGL object to search (typically lv.screen_active()) + setting_title_text: Text of the setting title (exact or substring) + + Returns: + LVGL label object for the value if found, None otherwise + + Example: + value_label = find_setting_value_label(lv.screen_active(), "Auth Mode") + if value_label: + assert value_label.get_text() == "(defaults to none)" + """ + title_label = find_label_with_text(obj, setting_title_text) + if not title_label: + return None + try: + parent = title_label.get_parent() + if not parent: + return None + child_count = parent.get_child_count() + for i in range(child_count): + child = parent.get_child(i) + if child is title_label: + continue + try: + if hasattr(child, "get_text"): + text = child.get_text() + if text: + return child + except: + pass + except: + pass + return None + + +def get_setting_value_text(obj, setting_title_text): + """ + Get the value text associated with a SettingsActivity setting title. + + Args: + obj: LVGL object to search (typically lv.screen_active()) + setting_title_text: Text of the setting title (exact or substring) + + Returns: + str or None: The value label text if found + """ + value_label = find_setting_value_label(obj, setting_title_text) + if value_label: + try: + return value_label.get_text() + except: + return None + return None + + +def verify_setting_value_text(obj, setting_title_text, expected_text): + """ + Verify a SettingsActivity value label matches expected text. + + Args: + obj: LVGL object to search (typically lv.screen_active()) + setting_title_text: Text of the setting title (exact or substring) + expected_text: Expected text for the value label (exact match) + + Returns: + bool: True if value label text matches expected, False otherwise + """ + value_text = get_setting_value_text(obj, setting_title_text) + return value_text == expected_text + + + +def text_to_hex(text): + """ + Convert text to hex representation for debugging. + + Useful for identifying Unicode symbols like lv.SYMBOL.SETTINGS + which may not display correctly in terminal output. + + Args: + text: String to convert + + Returns: + str: Hex representation of the text bytes (UTF-8 encoded) + + Example: + >>> text_to_hex("⚙") # lv.SYMBOL.SETTINGS + 'e29a99' + """ + try: + return text.encode('utf-8').hex() + except: + return "" + + def print_screen_labels(obj): """ Debug helper: Print all text found on screen from any widget. @@ -285,6 +617,10 @@ def print_screen_labels(obj): 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. + + For each text, also prints the hex representation to help identify + Unicode symbols (like lv.SYMBOL.SETTINGS) that may not display + correctly in terminal output. Args: obj: LVGL object to search (typically lv.screen_active()) @@ -294,16 +630,17 @@ def print_screen_labels(obj): 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 + # 0: MicroPythonOS (hex: 4d6963726f507974686f6e4f53) + # 1: Version 0.3.3 (hex: 56657273696f6e20302e332e33) + # 2: ⚙ (hex: e29a99) <- lv.SYMBOL.SETTINGS + # 3: Force Update (hex: 466f7263652055706461746) + # 4: WiFi (hex: 57694669) """ 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}") + hex_repr = text_to_hex(text) + print(f" {i}: {text} (hex: {hex_repr})") def get_widget_coords(widget): @@ -383,6 +720,116 @@ def find_button_with_text(obj, search_text): return None +def find_dropdown_widget(obj): + """ + Find a dropdown widget in the object hierarchy. + + Args: + obj: LVGL object to search (typically lv.screen_active()) + + Returns: + LVGL dropdown object if found, None otherwise + """ + def find_dropdown_recursive(node): + try: + if node.__class__.__name__ == "dropdown" or hasattr(node, "get_selected"): + if hasattr(node, "get_options"): + return node + except: + pass + + try: + child_count = node.get_child_count() + except: + return None + + for i in range(child_count): + child = node.get_child(i) + result = find_dropdown_recursive(child) + if result: + return result + return None + + return find_dropdown_recursive(obj) + + +def get_dropdown_options(dropdown): + """ + Get dropdown options as a list of strings. + + Args: + dropdown: LVGL dropdown widget + + Returns: + list: List of option strings (order preserved) + """ + try: + options = dropdown.get_options() + if options: + lines = options.split("\n") + return [line for line in lines if line] + except: + pass + return [] + + +def find_dropdown_option_index(dropdown, option_text, allow_partial=True): + """ + Find the index of an option in a dropdown by text. + + Args: + dropdown: LVGL dropdown widget + option_text: Text to search for + allow_partial: If True, match substring (default: True) + + Returns: + int or None: Index of matching option + """ + options = get_dropdown_options(dropdown) + if options: + for idx, text in enumerate(options): + if (allow_partial and option_text in text) or (not allow_partial and option_text == text): + return idx + return None + + try: + option_count = dropdown.get_option_count() + except: + option_count = 0 + + for idx in range(option_count): + try: + text = dropdown.get_option_text(idx) + if (allow_partial and option_text in text) or (not allow_partial and option_text == text): + return idx + except: + pass + + return None + + +def select_dropdown_option_by_text(dropdown, option_text, allow_partial=True): + """ + Select a dropdown option by its text. + + Args: + dropdown: LVGL dropdown widget + option_text: Text to select + allow_partial: If True, match substring (default: True) + + Returns: + bool: True if option was found and selected + """ + idx = find_dropdown_option_index(dropdown, option_text, allow_partial=allow_partial) + if idx is None: + return False + try: + dropdown.set_selected(idx) + return True + except: + return False + + def get_keyboard_button_coords(keyboard, button_text): """ Get the coordinates of a specific button on an LVGL keyboard/buttonmatrix. @@ -517,7 +964,7 @@ def _ensure_touch_indev(): print("Created simulated touch input device") -def simulate_click(x, y, press_duration_ms=50): +def simulate_click(x, y, press_duration_ms=100): """ Simulate a touch/click at the specified coordinates. @@ -542,7 +989,7 @@ def simulate_click(x, y, press_duration_ms=50): 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: 50ms) + press_duration_ms: How long to hold the press (default: 100ms) Example: from mpos.ui.testing import simulate_click, wait_for_render @@ -567,15 +1014,306 @@ def simulate_click(x, y, press_duration_ms=50): _touch_y = y _touch_pressed = True - # Process the press immediately + # Process the press event + lv.task_handler() + time.sleep(0.02) lv.task_handler() - def release_timer_cb(timer): - """Timer callback to release the touch press.""" - global _touch_pressed - _touch_pressed = False - lv.task_handler() # Process the release immediately + # Wait for press duration + time.sleep(press_duration_ms / 1000.0) + + # Release the touch + _touch_pressed = False - # Schedule the release - timer = lv.timer_create(release_timer_cb, press_duration_ms, None) - timer.set_repeat_count(1) + # 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 + + +def click_keyboard_button(keyboard, button_text, use_direct=True): + """ + Click a keyboard button reliably. + + This function handles the complexity of clicking keyboard buttons. + For MposKeyboard, it directly manipulates the textarea (most reliable). + For raw lv.keyboard, it uses simulate_click with coordinates. + + Args: + keyboard: MposKeyboard instance or lv.keyboard widget + button_text: Text of the button to click (e.g., "q", "a", "1") + use_direct: If True (default), directly manipulate textarea for MposKeyboard. + If False, use simulate_click with coordinates. + + Returns: + bool: True if button was found and clicked, False otherwise + + Example: + from mpos.ui.keyboard import MposKeyboard + from mpos.ui.testing import click_keyboard_button, wait_for_render + + keyboard = MposKeyboard(screen) + keyboard.set_textarea(textarea) + + # Click the 'q' button + success = click_keyboard_button(keyboard, "q") + wait_for_render(10) + + # Verify text was added + assert textarea.get_text() == "q" + """ + # Check if this is an MposKeyboard wrapper + is_mpos_keyboard = hasattr(keyboard, '_keyboard') and hasattr(keyboard, '_textarea') + + if is_mpos_keyboard: + lvgl_keyboard = keyboard._keyboard + else: + lvgl_keyboard = keyboard + + # Find button index by searching through all buttons + 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: + print(f"click_keyboard_button: Button '{button_text}' not found on keyboard") + return False + + if use_direct and is_mpos_keyboard: + # For MposKeyboard, directly manipulate the textarea + # This is the most reliable approach for testing + textarea = keyboard._textarea + if textarea is None: + print(f"click_keyboard_button: No textarea connected to keyboard") + return False + + current_text = textarea.get_text() + + # Handle special keys (matching keyboard.py logic) + if button_text == lv.SYMBOL.BACKSPACE: + new_text = current_text[:-1] + elif button_text == " " or button_text == keyboard.LABEL_SPACE: + new_text = current_text + " " + elif button_text in [lv.SYMBOL.UP, lv.SYMBOL.DOWN, keyboard.LABEL_LETTERS, + keyboard.LABEL_NUMBERS_SPECIALS, keyboard.LABEL_SPECIALS, + lv.SYMBOL.OK]: + # Mode switching or OK - don't modify text + print(f"click_keyboard_button: '{button_text}' is a control key, not adding to textarea") + wait_for_render(10) + return True + else: + # Regular character + new_text = current_text + button_text + + textarea.set_text(new_text) + wait_for_render(10) + print(f"click_keyboard_button: Clicked '{button_text}' at index {button_idx} using direct textarea manipulation") + else: + # Use coordinate-based clicking + coords = get_keyboard_button_coords(keyboard, button_text) + if coords: + simulate_click(coords['center_x'], coords['center_y']) + wait_for_render(20) # More time for event processing + print(f"click_keyboard_button: Clicked '{button_text}' at ({coords['center_x']}, {coords['center_y']}) using simulate_click") + else: + print(f"click_keyboard_button: Could not get coordinates for '{button_text}'") + return False + + return True diff --git a/internal_filesystem/lib/mpos/ui/theme.py b/internal_filesystem/lib/mpos/ui/theme.py deleted file mode 100644 index 8de2ed84..00000000 --- a/internal_filesystem/lib/mpos/ui/theme.py +++ /dev/null @@ -1,81 +0,0 @@ -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 7911c957..1744316c 100644 --- a/internal_filesystem/lib/mpos/ui/topmenu.py +++ b/internal_filesystem/lib/mpos/ui/topmenu.py @@ -1,13 +1,14 @@ import lvgl as lv -import mpos.ui -import mpos.battery_voltage -from .display import (get_display_width, get_display_height) +import mpos.time +from ..battery_manager import BatteryManager +from .display_metrics import DisplayMetrics +from .appearance_manager import AppearanceManager from .util import (get_foreground_app) - -from mpos.ui.anim import WidgetAnimator - -NOTIFICATION_BAR_HEIGHT=24 +from .input_manager import InputManager +from . import focus_direction +from .widget_animator import WidgetAnimator +from mpos.content.app_manager import AppManager 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 @@ -20,7 +21,7 @@ hide_bar_animation = None show_bar_animation = None -show_bar_animation_start_value = -NOTIFICATION_BAR_HEIGHT +show_bar_animation_start_value = -AppearanceManager.NOTIFICATION_BAR_HEIGHT show_bar_animation_end_value = 0 hide_bar_animation_start_value = show_bar_animation_end_value hide_bar_animation_end_value = show_bar_animation_start_value @@ -46,7 +47,8 @@ def close_drawer(to_launcher=False): global drawer_open, drawer if drawer_open: drawer_open=False - if not to_launcher and not "launcher" in get_foreground_app(): + fg = get_foreground_app() + if not to_launcher and fg is not None and not "launcher" in fg: print(f"close_drawer: also closing bar because to_launcher is {to_launcher} and foreground_app_name is {get_foreground_app()}") close_bar(False) WidgetAnimator.hide_widget(drawer, anim_type="slide_up", duration=1000, delay=0) @@ -79,23 +81,23 @@ def create_notification_bar(): global notification_bar # Create notification bar notification_bar = lv.obj(lv.layer_top()) - notification_bar.set_size(lv.pct(100), NOTIFICATION_BAR_HEIGHT) + notification_bar.set_size(lv.pct(100), AppearanceManager.NOTIFICATION_BAR_HEIGHT) notification_bar.set_pos(0, show_bar_animation_start_value) notification_bar.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) notification_bar.set_scroll_dir(lv.DIR.NONE) - notification_bar.set_style_border_width(0, 0) - notification_bar.set_style_radius(0, 0) + notification_bar.set_style_border_width(0, lv.PART.MAIN) + notification_bar.set_style_radius(0, lv.PART.MAIN) # Time label time_label = lv.label(notification_bar) time_label.set_text("00:00:00") - time_label.align(lv.ALIGN.LEFT_MID, mpos.ui.pct_of_display_width(10), 0) + time_label.align(lv.ALIGN.LEFT_MID, DisplayMetrics.pct_of_width(10), 0) 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) + temp_label.align_to(time_label, lv.ALIGN.OUT_RIGHT_MID, DisplayMetrics.pct_of_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) + memfree_label.align_to(temp_label, lv.ALIGN.OUT_RIGHT_MID, DisplayMetrics.pct_of_width(7), 0) #style = lv.style_t() #style.init() #style.set_text_font(lv.font_montserrat_8) # tiny font @@ -104,22 +106,49 @@ def create_notification_bar(): #notif_icon = lv.label(notification_bar) #notif_icon.set_text(lv.SYMBOL.BELL) #notif_icon.align_to(time_label, lv.ALIGN.OUT_RIGHT_MID, PADDING_TINY, 0) - # Battery percentage - #battery_label = lv.label(notification_bar) - #battery_label.set_text("100%") - #battery_label.align(lv.ALIGN.RIGHT_MID, 0, 0) - #battery_label.add_flag(lv.obj.FLAG.HIDDEN) - # Battery icon - battery_icon = lv.label(notification_bar) - battery_icon.set_text(lv.SYMBOL.BATTERY_FULL) - #battery_icon.align_to(battery_label, lv.ALIGN.OUT_LEFT_MID, 0, 0) - battery_icon.align(lv.ALIGN.RIGHT_MID, -mpos.ui.pct_of_display_width(10), 0) - battery_icon.add_flag(lv.obj.FLAG.HIDDEN) # keep it hidden until it has a correct value + # WiFi icon wifi_icon = lv.label(notification_bar) wifi_icon.set_text(lv.SYMBOL.WIFI) - wifi_icon.align_to(battery_icon, lv.ALIGN.OUT_LEFT_MID, -mpos.ui.pct_of_display_width(1), 0) wifi_icon.add_flag(lv.obj.FLAG.HIDDEN) + wifi_icon.align(lv.ALIGN.RIGHT_MID, -DisplayMetrics.pct_of_width(10), 0) + + # Battery percentage + if BatteryManager.has_battery(): + #battery_label = lv.label(notification_bar) + #battery_label.set_text("100%") + #battery_label.align(lv.ALIGN.RIGHT_MID, 0, 0) + #battery_label.add_flag(lv.obj.FLAG.HIDDEN) + # Battery icon + battery_icon = lv.label(notification_bar) + battery_icon.set_text(lv.SYMBOL.BATTERY_FULL) + #battery_icon.align_to(battery_label, lv.ALIGN.OUT_LEFT_MID, 0, 0) + battery_icon.align(lv.ALIGN.RIGHT_MID, -DisplayMetrics.pct_of_width(10), 0) + wifi_icon.align_to(battery_icon, lv.ALIGN.OUT_LEFT_MID, -DisplayMetrics.pct_of_width(1), 0) + battery_icon.add_flag(lv.obj.FLAG.HIDDEN) # keep it hidden until it has a correct value + def update_battery_icon(timer=None): + try: + percent = BatteryManager.get_battery_percentage() + except Exception as e: + print(f"BatteryManager.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: + battery_icon.set_text(lv.SYMBOL.BATTERY_3) + elif percent > 40: + battery_icon.set_text(lv.SYMBOL.BATTERY_2) + elif percent > 20: + battery_icon.set_text(lv.SYMBOL.BATTERY_1) + else: + battery_icon.set_text(lv.SYMBOL.BATTERY_EMPTY) + battery_icon.remove_flag(lv.obj.FLAG.HIDDEN) + # Percentage is not shown for now: + #battery_label.set_text(f"{round(percent)}%") + #battery_label.remove_flag(lv.obj.FLAG.HIDDEN) + update_battery_icon() # run it immediately instead of waiting for the timer + lv.timer_create(update_battery_icon, BATTERY_ICON_UPDATE_INTERVAL, None) + # Update time def update_time(timer): hours = mpos.time.localtime()[3] @@ -134,37 +163,15 @@ def update_time(timer): except Exception as e: print("Warning: could not check WLAN status:", str(e)) - def update_battery_icon(timer=None): - 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: - battery_icon.set_text(lv.SYMBOL.BATTERY_3) - elif percent > 40: - battery_icon.set_text(lv.SYMBOL.BATTERY_2) - elif percent > 20: - battery_icon.set_text(lv.SYMBOL.BATTERY_1) - else: - battery_icon.set_text(lv.SYMBOL.BATTERY_EMPTY) - battery_icon.remove_flag(lv.obj.FLAG.HIDDEN) - # Percentage is not shown for now: - #battery_label.set_text(f"{round(percent)}%") - #battery_label.remove_flag(lv.obj.FLAG.HIDDEN) - update_battery_icon() # run it immediately instead of waiting for the timer - def update_wifi_icon(timer): - from mpos.net.wifi_service import WifiService + from mpos import WifiService if WifiService.is_connected(): wifi_icon.remove_flag(lv.obj.FLAG.HIDDEN) else: wifi_icon.add_flag(lv.obj.FLAG.HIDDEN) # Get temperature sensor via SensorManager - import mpos.sensor_manager as SensorManager + from mpos import SensorManager temp_sensor = None if SensorManager.is_available(): # Prefer MCU temperature (more stable) over IMU temperature @@ -195,14 +202,13 @@ def update_memfree(timer): lv.timer_create(update_temperature, TEMPERATURE_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) # hide bar animation global hide_bar_animation hide_bar_animation = lv.anim_t() hide_bar_animation.init() hide_bar_animation.set_var(notification_bar) - hide_bar_animation.set_values(0, -NOTIFICATION_BAR_HEIGHT) + hide_bar_animation.set_values(0, -AppearanceManager.NOTIFICATION_BAR_HEIGHT) hide_bar_animation.set_duration(2000) hide_bar_animation.set_custom_exec_cb(lambda not_used, value : notification_bar.set_y(value)) @@ -217,16 +223,16 @@ def update_memfree(timer): -def create_drawer(display=None): +def create_drawer(): global drawer drawer=lv.obj(lv.layer_top()) drawer.set_size(lv.pct(100),lv.pct(90)) - drawer.set_pos(0,NOTIFICATION_BAR_HEIGHT) + drawer.set_pos(0,AppearanceManager.NOTIFICATION_BAR_HEIGHT) drawer.set_scroll_dir(lv.DIR.VER) drawer.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) - drawer.set_style_pad_all(15, 0) - drawer.set_style_border_width(0, 0) - drawer.set_style_radius(0, 0) + drawer.set_style_pad_all(15, lv.PART.MAIN) + drawer.set_style_border_width(0, lv.PART.MAIN) + drawer.set_style_radius(0, lv.PART.MAIN) drawer.add_flag(lv.obj.FLAG.HIDDEN) drawer.add_event_cb(drawer_scroll_callback, lv.EVENT.SCROLL_BEGIN, None) drawer.add_event_cb(drawer_scroll_callback, lv.EVENT.SCROLL, None) @@ -267,7 +273,7 @@ def brightness_slider_released(e): wifi_label.center() def wifi_event(e): close_drawer() - mpos.apps.start_app("com.micropythonos.wifi") + AppManager.start_app("com.micropythonos.settings.wifi") wifi_btn.add_event_cb(wifi_event,lv.EVENT.CLICKED,None) settings_btn=lv.button(drawer) settings_btn.set_size(lv.pct(drawer_button_pct),lv.pct(20)) @@ -277,7 +283,7 @@ def wifi_event(e): settings_label.center() def settings_event(e): close_drawer() - mpos.apps.start_app("com.micropythonos.settings") + AppManager.start_app("com.micropythonos.settings") settings_btn.add_event_cb(settings_event,lv.EVENT.CLICKED,None) launcher_btn=lv.button(drawer) launcher_btn.set_size(lv.pct(drawer_button_pct),lv.pct(20)) @@ -288,7 +294,7 @@ def settings_event(e): def launcher_event(e): print("Launch button pressed!") close_drawer(True) - mpos.apps.restart_launcher() + AppManager.restart_launcher() launcher_btn.add_event_cb(launcher_event,lv.EVENT.CLICKED,None) ''' sleep_btn=lv.button(drawer) @@ -307,7 +313,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) - mpos.apps.restart_launcher() + AppManager.restart_launcher() sleep_btn.add_event_cb(sleep_event,lv.EVENT.CLICKED,None) ''' restart_btn=lv.button(drawer) @@ -349,19 +355,29 @@ def poweroff_cb(e): print("Entering deep sleep...") machine.deepsleep() # sleep forever else: # assume unix: - lv.deinit() # Deinitialize LVGL (if supported) - sys.exit(0) + import mpos ; mpos.TaskManager.stop() # fallback to a regular (non aiorepl) REPL shell + lv.deinit() # Deinitialize LVGL (if supported) so the window closes instead of hanging because of LvReferenceError + # On linux, and hopefully on macOS too, this seems to be the only way to kill the process, as sys.exit(0) just throws an exception: + import os + os.system("kill $PPID") # environment variable PPID seems to contain the process ID + return + # This is disable because it doesn't work - just throws an exception: + try: + print("Doing sys.exit(0)") + sys.exit(0) # throws "SystemExit: 0" exception + except Exception as e: + print(f"sys.exit(0) threw exception: {e}") # can't seem to catch it poweroff_btn.add_event_cb(poweroff_cb,lv.EVENT.CLICKED,None) # Add invisible padding at the bottom to make the drawer scrollable l2 = lv.label(drawer) l2.set_text("\n") - l2.set_pos(0,get_display_height()) + l2.set_pos(0, DisplayMetrics.height()) def drawer_scroll_callback(event): global scroll_start_y event_code=event.get_code() - x, y = mpos.ui.get_pointer_xy() + x, y = InputManager.pointer_xy() #name = mpos.ui.get_event_name(event_code) #print(f"drawer_scroll: code={event_code}, name={name}, ({x},{y})") if event_code == lv.EVENT.SCROLL_BEGIN and scroll_start_y == None: @@ -370,7 +386,7 @@ def drawer_scroll_callback(event): elif event_code == lv.EVENT.SCROLL and scroll_start_y != None: diff = y - scroll_start_y #print(f"scroll distance: {diff}") - if diff < -NOTIFICATION_BAR_HEIGHT: + if diff < -AppearanceManager.NOTIFICATION_BAR_HEIGHT: close_drawer() elif event_code == lv.EVENT.SCROLL_END: scroll_start_y = None diff --git a/internal_filesystem/lib/mpos/ui/util.py b/internal_filesystem/lib/mpos/ui/util.py index 5b125a3c..81904e47 100644 --- a/internal_filesystem/lib/mpos/ui/util.py +++ b/internal_filesystem/lib/mpos/ui/util.py @@ -1,7 +1,6 @@ # lib/mpos/ui/util.py import lvgl as lv import sys -from ..apps import restart_launcher _foreground_app_name = None diff --git a/internal_filesystem/lib/mpos/ui/view.py b/internal_filesystem/lib/mpos/ui/view.py index 8315ca16..aced0765 100644 --- a/internal_filesystem/lib/mpos/ui/view.py +++ b/internal_filesystem/lib/mpos/ui/view.py @@ -1,5 +1,6 @@ import lvgl as lv -from ..apps import restart_launcher +import sys + from .focus import save_and_clear_current_focusgroup from .topmenu import open_bar @@ -9,8 +10,16 @@ def setContentView(new_activity, new_screen): global screen_stack if screen_stack: current_activity, current_screen, current_focusgroup, _ = screen_stack[-1] - current_activity.onPause(current_screen) - current_activity.onStop(current_screen) + try: + current_activity.onPause(current_screen) + except Exception as e: + print(f"onPause caught exception:") + sys.print_exception(e) + try: + current_activity.onStop(current_screen) + except Exception as e: + print(f"onStop caught exception:") + sys.print_exception(e) from .util import close_top_layer_msgboxes close_top_layer_msgboxes() @@ -18,10 +27,18 @@ def setContentView(new_activity, new_screen): screen_stack.append((new_activity, new_screen, lv.group_create(), None)) if new_activity: - new_activity.onStart(new_screen) + try: + new_activity.onStart(new_screen) + except Exception as e: + print(f"onStart caught exception:") + sys.print_exception(e) lv.screen_load_anim(new_screen, lv.SCR_LOAD_ANIM.OVER_LEFT, 500, 0, False) if new_activity: - new_activity.onResume(new_screen) + try: + new_activity.onResume(new_screen) + except Exception as e: + print(f"onResume caught exception:") + sys.print_exception(e) def remove_and_stop_all_activities(): global screen_stack @@ -31,9 +48,21 @@ def remove_and_stop_all_activities(): def remove_and_stop_current_activity(): current_activity, current_screen, current_focusgroup, _ = screen_stack.pop() if current_activity: - current_activity.onPause(current_screen) - current_activity.onStop(current_screen) - current_activity.onDestroy(current_screen) + try: + current_activity.onPause(current_screen) + except Exception as e: + print(f"onPause caught exception:") + sys.print_exception(e) + try: + current_activity.onStop(current_screen) + except Exception as e: + print(f"onStop caught exception:") + sys.print_exception(e) + try: + current_activity.onDestroy(current_screen) + except Exception as e: + print(f"onDestroy caught exception:") + sys.print_exception(e) if current_screen: current_screen.clean() @@ -50,14 +79,15 @@ def back_screen(): # Load previous prev_activity, prev_screen, prev_focusgroup, prev_focused = screen_stack[-1] + print(f"back_screen got {prev_activity}, {prev_screen}, {prev_focusgroup}, {prev_focused}") lv.screen_load_anim(prev_screen, lv.SCR_LOAD_ANIM.OVER_RIGHT, 500, 0, True) default_group = lv.group_get_default() if default_group: from .focus import move_focusgroup_objects move_focusgroup_objects(prev_focusgroup, default_group) - from .focus_direction import emulate_focus_obj - emulate_focus_obj(default_group, prev_focused) + from .input_manager import InputManager + InputManager.emulate_focus_obj(default_group, prev_focused) if prev_activity: prev_activity.onResume(prev_screen) diff --git a/internal_filesystem/lib/mpos/ui/widget_animator.py b/internal_filesystem/lib/mpos/ui/widget_animator.py new file mode 100644 index 00000000..8761f826 --- /dev/null +++ b/internal_filesystem/lib/mpos/ui/widget_animator.py @@ -0,0 +1,225 @@ +import lvgl as lv + + +class WidgetAnimator: + """ + Utility for creating smooth, non-blocking animations on LVGL widgets. + + Provides fade, slide, and value interpolation animations with automatic + cleanup and safe widget access handling. + """ + + @staticmethod + 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 + + @staticmethod + def show_widget(widget, anim_type="fade", duration=500, delay=0): + """ + Show a widget with an animation. + + Args: + widget (lv.obj): The widget to show + anim_type (str): Animation type - "fade", "slide_down", or "slide_up" (default: "fade") + duration (int): Animation duration in milliseconds (default: 500) + delay (int): Animation delay in milliseconds (default: 0) + + Returns: + The animation object + """ + 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: WidgetAnimator._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.set_values(0, 255) + anim.set_custom_exec_cb(lambda anim, value: WidgetAnimator._safe_widget_access(lambda: widget.set_style_opa(value, lv.PART.MAIN))) + anim.set_path_cb(lv.anim_t.path_ease_in_out) + # Ensure opacity is reset after animation + anim.set_completed_cb(lambda *args: WidgetAnimator._safe_widget_access(lambda: widget.set_style_opa(255, lv.PART.MAIN))) + elif anim_type == "slide_down": + # Create slide-down animation (y from -height to original y) + original_y = widget.get_y() + height = widget.get_height() + anim.set_values(original_y - height, original_y) + anim.set_custom_exec_cb(lambda anim, value: WidgetAnimator._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: WidgetAnimator._safe_widget_access(lambda: widget.set_y(original_y))) + else: # "slide_up" + # Create slide-up animation (y from +height to original y) + original_y = widget.get_y() + height = widget.get_height() + anim.set_values(original_y + height, original_y) + anim.set_custom_exec_cb(lambda anim, value: WidgetAnimator._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: WidgetAnimator._safe_widget_access(lambda: widget.set_y(original_y))) + + anim.start() + return anim + + @staticmethod + def hide_widget(widget, anim_type="fade", duration=500, delay=0, hide=True): + """ + Hide a widget with an animation. + + Args: + widget (lv.obj): The widget to hide + anim_type (str): Animation type - "fade", "slide_down", or "slide_up" (default: "fade") + duration (int): Animation duration in milliseconds (default: 500) + delay (int): Animation delay in milliseconds (default: 0) + hide (bool): If True, adds HIDDEN flag after animation. If False, only animates opacity/position (default: True) + + Returns: + The animation object + """ + 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) + + if anim_type == "fade": + # Create fade-out animation (opacity from 255 to 0) + anim.set_values(255, 0) + anim.set_custom_exec_cb(lambda anim, value: WidgetAnimator._safe_widget_access(lambda: widget.set_style_opa(value, lv.PART.MAIN))) + anim.set_path_cb(lv.anim_t.path_ease_in_out) + # Set HIDDEN flag after animation + anim.set_completed_cb(lambda *args: WidgetAnimator._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) + original_y = widget.get_y() + height = widget.get_height() + anim.set_values(original_y, original_y + height) + anim.set_custom_exec_cb(lambda anim, value: WidgetAnimator._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._safe_widget_access(lambda: WidgetAnimator._hide_complete_cb(widget, original_y, hide))) + else: # "slide_up" + # Create slide-up animation (y from original y to -height) + original_y = widget.get_y() + height = widget.get_height() + anim.set_values(original_y, original_y - height) + anim.set_custom_exec_cb(lambda anim, value: WidgetAnimator._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._safe_widget_access(lambda: WidgetAnimator._hide_complete_cb(widget, original_y, hide))) + + anim.start() + return anim + + @staticmethod + def change_widget(widget, anim_type="interpolate", duration=5000, delay=0, begin_value=0, end_value=100, display_change=None): + """ + Animate a widget's text by interpolating between begin_value and end_value. + + Args: + widget: The widget to animate (should have set_text method) + anim_type: Type of animation (currently "interpolate" is supported) + duration: Animation duration in milliseconds + delay: Animation delay in milliseconds + begin_value: Starting value for interpolation + end_value: Ending value for interpolation + display_change: callback to display the change in the UI + + Returns: + The animation object + """ + 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) + + if anim_type == "interpolate": + anim.set_values(begin_value, end_value) + if display_change is not None: + anim.set_custom_exec_cb(lambda anim, value: WidgetAnimator._safe_widget_access(lambda: display_change(value))) + # Ensure final value is set after animation + anim.set_completed_cb(lambda *args: WidgetAnimator._safe_widget_access(lambda: display_change(end_value))) + else: + anim.set_custom_exec_cb(lambda anim, value: WidgetAnimator._safe_widget_access(lambda: widget.set_text(str(value)))) + # Ensure final value is set after animation + anim.set_completed_cb(lambda *args: WidgetAnimator._safe_widget_access(lambda: widget.set_text(str(end_value)))) + anim.set_path_cb(lv.anim_t.path_ease_in_out) + else: + return + + anim.start() + return anim + + @staticmethod + def smooth_show(widget, duration=500, delay=0): + """ + Fade in a widget (shorthand for show_widget with fade animation). + + Args: + widget: The widget to show + duration: Animation duration in milliseconds (default: 500) + delay: Animation delay in milliseconds (default: 0) + + Returns: + The animation object + """ + return WidgetAnimator.show_widget(widget, anim_type="fade", duration=duration, delay=delay) + + @staticmethod + def smooth_hide(widget, hide=True, duration=500, delay=0): + """ + Fade out a widget (shorthand for hide_widget with fade animation). + + Args: + widget: The widget to hide + hide: If True, adds HIDDEN flag after animation (default: True) + duration: Animation duration in milliseconds (default: 500) + delay: Animation delay in milliseconds (default: 0) + + Returns: + The animation object + """ + return WidgetAnimator.hide_widget(widget, anim_type="fade", duration=duration, delay=delay, hide=hide) + + @staticmethod + def _hide_complete_cb(widget, original_y=None, hide=True): + """ + Internal callback for hide animation completion. + + Args: + widget: The widget being hidden + original_y: Original Y position (for slide animations) + hide: Whether to add HIDDEN flag + """ + if hide: + widget.add_flag(lv.obj.FLAG.HIDDEN) + if original_y: + widget.set_y(original_y) # in case it shifted slightly due to rounding etc diff --git a/internal_filesystem/lib/mpos/util.py b/internal_filesystem/lib/mpos/util.py index 054f5784..eb79d521 100644 --- a/internal_filesystem/lib/mpos/util.py +++ b/internal_filesystem/lib/mpos/util.py @@ -1,4 +1,5 @@ import lvgl as lv +import os def urldecode(s): result = "" @@ -31,3 +32,40 @@ def print_lvgl_widget(obj, depth=0): print_lvgl_widget(obj.get_child(childnr), depth+1) else: print("print_lvgl_widget called on 'None'") + + +def mkdir_parents(path): + """ + Create directory and all parent directories like `mkdir -p`. + + Creates intermediate directories as needed, does nothing if the path + already exists, and raises if any component exists as a non-directory. + """ + if not path: + return + + def _is_dir(stat_result): + return (stat_result[0] & 0x4000) != 0 + + parts = path.split("/") + current = "/" if path.startswith("/") else "" + + for part in parts: + if not part: + continue + if current in ("", "/"): + current = f"{current}{part}" + else: + current = f"{current}/{part}" + try: + stat_result = os.stat(current) + except OSError: + try: + os.mkdir(current) + except OSError: + stat_result = os.stat(current) + if not _is_dir(stat_result): + raise + else: + if not _is_dir(stat_result): + raise OSError("Path component exists and is not a directory") diff --git a/internal_filesystem/lib/mpos/webserver/__init__.py b/internal_filesystem/lib/mpos/webserver/__init__.py new file mode 100644 index 00000000..8d44cd85 --- /dev/null +++ b/internal_filesystem/lib/mpos/webserver/__init__.py @@ -0,0 +1,6 @@ +"""Web server helpers for MicroPythonOS.""" + +from .webrepl_http import accept_handler +from .webserver import WebServer + +__all__ = ["accept_handler", "WebServer"] diff --git a/internal_filesystem/lib/mpos/webserver/webrepl.py b/internal_filesystem/lib/mpos/webserver/webrepl.py new file mode 100644 index 00000000..28501674 --- /dev/null +++ b/internal_filesystem/lib/mpos/webserver/webrepl.py @@ -0,0 +1,228 @@ +# This module should be imported from REPL, not run from command line. +import binascii +import hashlib +from micropython import const +try: + import network +except ImportError: + network = None +import errno +import os +import socket +import sys +import websocket +import _webrepl +from mpos import TaskManager + +listen_s = None +client_s = None + +DEBUG = 0 + +_DEFAULT_STATIC_HOST = const("https://micropython.org/webrepl/") +static_host = _DEFAULT_STATIC_HOST + + +def server_handshake(cl): + req = cl.makefile("rwb", 0) + # Skip HTTP GET line. + l = req.readline() + if DEBUG: + sys.stdout.write(repr(l)) + + webkey = None + upgrade = False + websocket = False + + while True: + l = req.readline() + if not l: + # EOF in headers. + return False + if l == b"\r\n": + break + if DEBUG: + sys.stdout.write(l) + h, v = [x.strip() for x in l.split(b":", 1)] + if DEBUG: + print((h, v)) + if h == b"Sec-WebSocket-Key": + webkey = v + elif h == b"Connection" and b"Upgrade" in v: + upgrade = True + elif h == b"Upgrade" and v == b"websocket": + websocket = True + + if not (upgrade and websocket and webkey): + return False + + if DEBUG: + print("Sec-WebSocket-Key:", webkey, len(webkey)) + + d = hashlib.sha1(webkey) + d.update(b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11") + respkey = d.digest() + respkey = binascii.b2a_base64(respkey)[:-1] + if DEBUG: + print("respkey:", respkey) + + cl.send( + b"""\ +HTTP/1.1 101 Switching Protocols\r +Upgrade: websocket\r +Connection: Upgrade\r +Sec-WebSocket-Accept: """ + ) + cl.send(respkey) + cl.send("\r\n\r\n") + + return True + + +def send_html(cl): + cl.send( + b"""\ +HTTP/1.0 200 OK\r +\r +\r +\r +""" + ) + cl.close() + + +def setup_conn(port, accept_handler): + global listen_s + listen_s = socket.socket() + listen_s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + + ai = socket.getaddrinfo("0.0.0.0", port) + addr = ai[0][4] + + listen_s.bind(addr) + listen_s.listen(1) + accept_installed = False + if accept_handler: + # Unix/macOS (and Windows) ports don't support the accept-handler sockopt, + # which raises buffer-protocol errors. We fall back to a TaskManager loop. + if sys.platform in ("linux", "darwin", "win32"): + accept_installed = False + else: + try: + listen_s.setsockopt(socket.SOL_SOCKET, 20, accept_handler) + accept_installed = True + except (TypeError, OSError): + accept_installed = False + if network: + for i in (network.WLAN.IF_AP, network.WLAN.IF_STA): + iface = network.WLAN(i) + if iface.active(): + print("WebREPL server started on http://%s:%d/" % (iface.ifconfig()[0], port)) + return listen_s, accept_installed + + +def accept_conn(listen_sock): + global client_s + cl, remote_addr = listen_sock.accept() + + if not server_handshake(cl): + send_html(cl) + return False + + prev = os.dupterm(None) + os.dupterm(prev) + if prev: + print("\nConcurrent WebREPL connection from", remote_addr, "rejected") + cl.close() + return False + print("\nWebREPL connection from:", remote_addr) + client_s = cl + + ws = websocket.websocket(cl, True) + ws = _webrepl._webrepl(ws) + cl.setblocking(False) + # notify REPL on socket incoming data (ESP32/ESP8266-only) + if hasattr(os, "dupterm_notify"): + cl.setsockopt(socket.SOL_SOCKET, 20, os.dupterm_notify) + os.dupterm(ws) + + return True + + +async def _accept_loop(listen_sock, handler): + try: + listen_sock.setblocking(False) + except Exception: + try: + listen_sock.settimeout(0) + except Exception: + pass + while True: + try: + handler(listen_sock) + except OSError as exc: + # Non-blocking accept: ignore EAGAIN-style errors and back off briefly. + err = exc.args[0] if exc.args else None + exc_errno = getattr(exc, "errno", None) + eagain = getattr(errno, "EAGAIN", 11) + retry_errors = (eagain, 11) + if (err in retry_errors) or (exc_errno in retry_errors): + await TaskManager.sleep_ms(100) + continue + break + except Exception: + pass + await TaskManager.sleep_ms(100) + + +def stop(): + global listen_s, client_s + os.dupterm(None) + if client_s: + client_s.close() + if listen_s: + listen_s.close() + + +def start(port=8266, password=None, accept_handler=accept_conn): + global static_host + stop() + webrepl_pass = password + if webrepl_pass is None: + try: + import webrepl_cfg + + webrepl_pass = webrepl_cfg.PASS + if hasattr(webrepl_cfg, "BASE"): + static_host = webrepl_cfg.BASE + except: + print("WebREPL is not configured, run 'import webrepl_setup'") + + _webrepl.password(webrepl_pass) + s, accept_installed = setup_conn(port, accept_handler) + + if accept_handler is None: + print("Starting webrepl in foreground mode") + # Run accept_conn to serve HTML until we get a websocket connection. + while not accept_conn(s): + pass + elif not accept_installed: + # TaskManager workaround: drive accept() in a non-blocking loop when the + # platform cannot install an accept_handler via socket options (Unix/macOS). + TaskManager.create_task(_accept_loop(s, accept_handler)) + if password is None: + print("Started webrepl in normal mode") + else: + print("Started webrepl in manual override mode") + elif password is None: + print("Started webrepl in normal mode") + else: + print("Started webrepl in manual override mode") + + +def start_foreground(port=8266, password=None): + start(port, password, None) diff --git a/internal_filesystem/lib/mpos/webserver/webrepl_http.py b/internal_filesystem/lib/mpos/webserver/webrepl_http.py new file mode 100644 index 00000000..0c5f9f64 --- /dev/null +++ b/internal_filesystem/lib/mpos/webserver/webrepl_http.py @@ -0,0 +1,136 @@ +import os +import socket +import uio + +import _webrepl +from . import webrepl +import websocket + +WEBREPL_HTML_PATH = "builtin/html/webrepl_inlined_minified.html" +''' +# Unused as these files are minified and inlined: +#WEBREPL_HTML_PATH = "/builtin/html/webrepl.html" +WEBREPL_CONTENT_PATH = "/builtin/html/webrepl.js" +WEBREPL_TERM_PATH = "/builtin/html/term.js" +WEBREPL_CSS_PATH = "/builtin/html/webrepl.css" +WEBREPL_FILE_SAVER_PATH = "/builtin/html/FileSaver.js" +''' + +WEBREPL_ASSETS = { + b"/": (WEBREPL_HTML_PATH, b"text/html"), + b"/index.html": (WEBREPL_HTML_PATH, b"text/html"), + #b"/webrepl.css": (WEBREPL_CSS_PATH, b"text/css"), + #b"/webrepl.js": (WEBREPL_CONTENT_PATH, b"application/javascript"), + #b"/term.js": (WEBREPL_TERM_PATH, b"application/javascript"), + #b"/FileSaver.js": (WEBREPL_FILE_SAVER_PATH, b"application/javascript"), +} + + +class _MakefileSocket: + def __init__(self, sock, raw_request): + self._sock = sock + self._raw_request = raw_request + + def makefile(self, *args, **kwargs): + return uio.BytesIO(self._raw_request) + + def __getattr__(self, name): + return getattr(self._sock, name) + + +def _read_http_request(cl): + req = cl.makefile("rwb", 0) + first_line = req.readline() + if not first_line: + return None, None, b"" + + raw_request = first_line + headers = {} + while True: + line = req.readline() + if not line: + break + raw_request += line + if line == b"\r\n": + break + if b":" in line: + key, value = line.split(b":", 1) + headers[key.strip().lower()] = value.strip().lower() + + parts = first_line.split() + path = parts[1] if len(parts) >= 2 else b"/" + if b"?" in path: + path = path.split(b"?", 1)[0] + + return path, headers, raw_request + + +def _is_websocket_request(headers): + connection = headers.get(b"connection", b"") + upgrade = headers.get(b"upgrade", b"") + return b"upgrade" in connection and upgrade == b"websocket" + + +def _send_response(cl, status, content_type, body): + cl.send(b"HTTP/1.0 " + status + b"\r\n") + cl.send(b"Server: MicroPythonOS\r\n") + cl.send(b"Content-Type: " + content_type + b"\r\n") + cl.send(b"Content-Length: %d\r\n\r\n" % len(body)) + cl.send(body) + cl.close() + + +def _send_file_response(cl, path, content_type): + try: + with open(path, "rb") as handle: + body = handle.read() + except OSError: + _send_response(cl, b"404 Not Found", b"text/plain", b"Not Found") + return False + + _send_response(cl, b"200 OK", content_type, body) + return False + + +def _start_webrepl_session(cl, remote_addr): + print("\nWebREPL connection from:", remote_addr) + webrepl.client_s = cl + + ws = websocket.websocket(cl, True) + ws = _webrepl._webrepl(ws) + cl.setblocking(False) + if hasattr(os, "dupterm_notify"): + cl.setsockopt(socket.SOL_SOCKET, 20, os.dupterm_notify) + os.dupterm(ws) + + return True + + +def accept_handler(listen_sock): + cl, remote_addr = listen_sock.accept() + print("\webrepl_http connection from:", remote_addr) + try: + path, headers, raw_request = _read_http_request(cl) + if not path: + cl.close() + return False + + if _is_websocket_request(headers): + if not webrepl.server_handshake(_MakefileSocket(cl, raw_request)): + cl.close() + return False + return _start_webrepl_session(cl, remote_addr) + + if path in WEBREPL_ASSETS: + asset_path, content_type = WEBREPL_ASSETS[path] + return _send_file_response(cl, asset_path, content_type) + + _send_response(cl, b"404 Not Found", b"text/plain", b"Not Found") + return False + except Exception as exc: + print("webrepl_http: error handling connection:", exc) + try: + cl.close() + except Exception: + pass + return False diff --git a/internal_filesystem/lib/mpos/webserver/webserver.py b/internal_filesystem/lib/mpos/webserver/webserver.py new file mode 100644 index 00000000..6518614a --- /dev/null +++ b/internal_filesystem/lib/mpos/webserver/webserver.py @@ -0,0 +1,115 @@ +"""WebServer control for MicroPythonOS.""" + +from ..config import SharedPreferences +from .webrepl_http import accept_handler + + +class WebServer: + PREFS_NAMESPACE = "com.micropythonos.settings.webserver" + DEFAULTS = { + "autostart": "False", + "port": "7890", + "password": "MPOSweb26", + } + + _started = False + _port = None + _password = None + _autostart = None + _last_error = None + + @classmethod + def _prefs(cls): + return SharedPreferences(cls.PREFS_NAMESPACE, defaults=cls.DEFAULTS) + + @classmethod + def _parse_bool(cls, value): + return str(value).lower() in ("true", "1", "yes", "on") + + @classmethod + def _parse_port(cls, value): + try: + return int(value) + except Exception: + return int(cls.DEFAULTS["port"]) + + @classmethod + def _sanitize_password(cls, value): + if not value: + value = cls.DEFAULTS["password"] + if len(value) > 9: + value = value[:9] + return value + + @classmethod + def load_settings(cls): + prefs = cls._prefs() + cls._autostart = cls._parse_bool(prefs.get_string("autostart", cls.DEFAULTS["autostart"])) + cls._port = cls._parse_port(prefs.get_string("port", cls.DEFAULTS["port"])) + cls._password = cls._sanitize_password(prefs.get_string("password", cls.DEFAULTS["password"])) + + @classmethod + def status(cls): + cls.load_settings() + return { + "state": "started" if cls._started else "stopped", + "started": cls._started, + "port": cls._port, + "password": cls._password, + "autostart": cls._autostart, + "last_error": cls._last_error, + } + + @classmethod + def is_started(cls): + return cls._started + + @classmethod + def start(cls): + cls.load_settings() + try: + from . import webrepl + + webrepl.start(port=cls._port, password=cls._password, accept_handler=accept_handler) + cls._started = True + cls._last_error = None + print(f"WebServer started on port {cls._port}") + return True + except Exception as exc: + cls._last_error = exc + cls._started = False + print(f"WebServer start failed: {exc}") + return False + + @classmethod + def stop(cls): + try: + from . import webrepl + + if hasattr(webrepl, "stop"): + webrepl.stop() + cls._started = False + cls._last_error = None + print("WebServer stopped") + return True + except Exception as exc: + cls._last_error = exc + print(f"WebServer stop failed: {exc}") + return False + + @classmethod + def apply_settings(cls, restart_if_running=True): + was_running = cls._started + cls.load_settings() + if was_running and restart_if_running: + cls.stop() + cls.start() + return cls.status() + + @classmethod + def auto_start(cls): + cls.load_settings() + if cls._autostart: + return cls.start() + print("WebServer autostart disabled") + return False diff --git a/internal_filesystem/lib/ota/blockdev_writer.py b/internal_filesystem/lib/ota/blockdev_writer.py deleted file mode 100644 index e0f98ce5..00000000 --- a/internal_filesystem/lib/ota/blockdev_writer.py +++ /dev/null @@ -1,163 +0,0 @@ -# partition_writer module for MicroPython on ESP32 -# MIT license; Copyright (c) 2023 Glenn Moloney @glenn20 - -# Based on OTA class by Thorsten von Eicken (@tve): -# https://github.com/tve/mqboard/blob/master/mqrepl/mqrepl.py - -import hashlib -import io - -from micropython import const - -IOCTL_BLOCK_COUNT: int = const(4) # type: ignore -IOCTL_BLOCK_SIZE: int = const(5) # type: ignore -IOCTL_BLOCK_ERASE: int = const(6) # type: ignore - - -# An IOBase compatible class to wrap access to an os.AbstractBlockdev() device -# such as a partition on the device flash. Writes must be aligned to block -# boundaries. -# https://docs.micropython.org/en/latest/library/os.html#block-device-interface -# Extend IOBase so we can wrap this with io.BufferedWriter in BlockdevWriter -class Blockdev(io.IOBase): - def __init__(self, device): - self.device = device - self.blocksize = int(device.ioctl(IOCTL_BLOCK_SIZE, None)) - self.blockcount = int(device.ioctl(IOCTL_BLOCK_COUNT, None)) - self.pos = 0 # Current position (bytes from beginning) of device - self.end = 0 # Current end of the data written to the device - - # Data must be a multiple of blocksize unless it is the last write to the - # device. The next write after a partial block will raise ValueError. - def write(self, data: bytes | bytearray | memoryview) -> int: - block, remainder = divmod(self.pos, self.blocksize) - if remainder: - raise ValueError(f"Block {block} write not aligned at block boundary.") - data_len = len(data) - nblocks, remainder = divmod(data_len, self.blocksize) - mv = memoryview(data) - if nblocks: # Write whole blocks - self.device.writeblocks(block, mv[: nblocks * self.blocksize]) - block += nblocks - if remainder: # Write left over data as a partial block - self.device.ioctl(IOCTL_BLOCK_ERASE, block) # Erase block first - self.device.writeblocks(block, mv[-remainder:], 0) - self.pos += data_len - self.end = self.pos # The "end" of the data written to the device - return data_len - - # Read data from the block device. - def readinto(self, data: bytearray | memoryview): - size = min(len(data), self.end - self.pos) - block, remainder = divmod(self.pos, self.blocksize) - self.device.readblocks(block, memoryview(data)[:size], remainder) - self.pos += size - return size - - # Set the current file position for reading or writing - def seek(self, offset: int, whence: int = 0): - start = [0, self.pos, self.end] - self.pos = start[whence] + offset - - -# Calculate the SHA256 sum of a file (has a readinto() method) -def sha_file(f, buffersize=4096) -> str: - mv = memoryview(bytearray(buffersize)) - read_sha = hashlib.sha256() - while (n := f.readinto(mv)) > 0: - read_sha.update(mv[:n]) - return read_sha.digest().hex() - - -# BlockdevWriter provides a convenient interface to writing images to any block -# device which implements the micropython os.AbstractBlockDev interface (eg. -# Partition on flash storage on ESP32). -# https://docs.micropython.org/en/latest/library/os.html#block-device-interface -# https://docs.micropython.org/en/latest/library/esp32.html#flash-partitions -class BlockDevWriter: - def __init__( - self, - device, # Block device to recieve the data (eg. esp32.Partition) - verify: bool = True, # Should we read back and verify data after writing - verbose: bool = True, - ): - self.device = Blockdev(device) - self.writer = io.BufferedWriter( - self.device, self.device.blocksize # type: ignore - ) - self._sha = hashlib.sha256() - self.verify = verify - self.verbose = verbose - self.sha: str = "" - self.length: int = 0 - blocksize, blockcount = self.device.blocksize, self.device.blockcount - if self.verbose: - print(f"Device capacity: {blockcount} x {blocksize} byte blocks.") - - def set_sha_length(self, sha: str, length: int): - self.sha = sha - self.length = length - blocksize, blockcount = self.device.blocksize, self.device.blockcount - if length > blocksize * blockcount: - raise ValueError(f"length ({length} bytes) is > size of partition.") - if self.verbose and length: - blocks, remainder = divmod(length, blocksize) - print(f"Writing {blocks} blocks + {remainder} bytes.") - - def print_progress(self): - if self.verbose: - block, remainder = divmod(self.device.pos, self.device.blocksize) - print(f"\rBLOCK {block}", end="") - if remainder: - print(f" + {remainder} bytes") - - # Append data to the block device - def write(self, data: bytearray | bytes | memoryview) -> int: - self._sha.update(data) - n = self.writer.write(data) - self.print_progress() - return n - - # Append data from f (a stream object) to the block device - def write_from_stream(self, f: io.BufferedReader) -> int: - mv = memoryview(bytearray(self.device.blocksize)) - tot = 0 - while (n := f.readinto(mv)) != 0: - tot += self.write(mv[:n]) - return tot - - # Flush remaining data to the block device and confirm all checksums - # Raises: - # ValueError("SHA mismatch...") if SHA of received data != expected sha - # ValueError("SHA verify fail...") if verified SHA != written sha - def close(self) -> None: - self.writer.flush() - self.print_progress() - # Check the checksums (SHA256) - nbytes: int = self.device.end - if self.length and self.length != nbytes: - raise ValueError(f"Received {nbytes} bytes (expect {self.length}).") - write_sha = self._sha.digest().hex() - if not self.sha: - self.sha = write_sha - if self.sha != write_sha: - raise ValueError(f"SHA mismatch recv={write_sha} expect={self.sha}.") - if self.verify: - if self.verbose: - print("Verifying SHA of the written data...", end="") - self.device.seek(0) # Reset to start of partition - read_sha = sha_file(self.device, self.device.blocksize) - if read_sha != write_sha: - raise ValueError(f"SHA verify failed write={write_sha} read={read_sha}") - if self.verbose: - print("Passed.") - if self.verbose or not self.sha: - print(f"SHA256={self.sha}") - self.device.seek(0) # Reset to start of partition - - def __enter__(self): - return self - - def __exit__(self, e_t, e_v, e_tr): - if e_t is None: - self.close() diff --git a/internal_filesystem/lib/ota/rollback.py b/internal_filesystem/lib/ota/rollback.py deleted file mode 100644 index fc8667d9..00000000 --- a/internal_filesystem/lib/ota/rollback.py +++ /dev/null @@ -1,27 +0,0 @@ -from esp32 import Partition - - -# Mark this boot as successful: prevent rollback to last image on next reboot. -# Raises OSError(-261) if bootloader is not OTA capable. -def cancel() -> None: - try: - Partition.mark_app_valid_cancel_rollback() - except OSError as e: - if e.args[0] == -261: - print(f"{__name__}.cancel(): The bootloader does not support OTA rollback.") - else: - raise e - - -# Force a rollback on the next reboot to the previously booted ota partition -def force() -> None: - from .status import force_rollback - - force_rollback() - - -# Undo a previous force rollback: ie. boot off the current partition on next reboot -def cancel_force() -> None: - from .status import current_ota - - current_ota.set_boot() diff --git a/internal_filesystem/lib/ota/status.py b/internal_filesystem/lib/ota/status.py deleted file mode 100644 index 3c204db9..00000000 --- a/internal_filesystem/lib/ota/status.py +++ /dev/null @@ -1,164 +0,0 @@ -# esp32_ota module for MicroPython on ESP32 -# MIT license; Copyright (c) 2023 Glenn Moloney @glenn20 - -# Based on OTA class by Thorsten von Eicken (@tve): -# https://github.com/tve/mqboard/blob/master/mqrepl/mqrepl.py - - -import binascii -import struct -import sys -import time - -import machine -from esp32 import Partition -from flashbdev import bdev -from micropython import const - -OTA_UNSUPPORTED = const(-261) -ESP_ERR_OTA_VALIDATE_FAILED = const(-5379) -OTA_MIN: int = const(16) # type: ignore -OTA_MAX: int = const(32) # type: ignore - -OTA_SIZE = 0x20 # The size of an OTA record in bytes (32 bytes) -OTA_BLOCKS = (0, 1) # The offsets of the OTA records in the otadata partition -OTA_FMT = b" Partition: # Partition we will boot from on next boot - if next_ota: # Avoid IDF debug messages by checking for otadata partition - try: - return Partition(Partition.BOOT) - except OSError: # OTA support is not available, return current partition - pass - return Partition(Partition.RUNNING) - - -# Return True if the device is configured for OTA updates -def ready() -> bool: - return next_ota is not None - - -def partition_table() -> list[tuple[int, int, int, int, str, bool]]: - partitions = [p.info() for p in Partition.find(Partition.TYPE_APP)] - partitions.extend([p.info() for p in Partition.find(Partition.TYPE_DATA)]) - partitions.sort(key=lambda i: i[2]) # Sort by address - return partitions - - -def partition_table_print() -> None: - ptype = {Partition.TYPE_APP: "app", Partition.TYPE_DATA: "data"} - subtype = [ - {0: "factory"} | {i: f"ota_{i-OTA_MIN}" for i in range(OTA_MIN, OTA_MAX)}, - {0: "ota", 1: "phy", 2: "nvs", 129: "fat"}, # DATA subtypes - ] - print("Partition table:") - print("# Name Type SubType Offset Size (bytes)") - for p in partition_table(): - print( - f" {p[4]:10s} {ptype[p[0]]:8s} {subtype[p[0]][p[1]]:8} " - + f"{p[2]:#10x} {p[3]:#10x} {p[3]:10,}" - ) - - -# Return a list of OTA partitions sorted by partition subtype number -def ota_partitions() -> list[Partition]: - partitions: list[Partition] = [ - p - for p in Partition.find(Partition.TYPE_APP) - if OTA_MIN <= p.info()[1] < OTA_MAX - ] - # Sort by the OTA partition subtype: ota_0 (16), ota_1 (17), ota_2 (18), ... - partitions.sort(key=lambda p: p.info()[1]) - return partitions - - -# Print the status of the otadata partition -def otadata_check() -> None: - if not otadata_part: - return - valid_seq = 1 - for i in (0, 1): - otadata_part.readblocks(i, (b := bytearray(OTA_SIZE))) - seq, _, state_num, crc = struct.unpack(OTA_FMT, b) - state = otastate[state_num] - is_valid = ( - state == "VALID" - and binascii.crc32(struct.pack(b" valid_seq: - valid_seq = seq - print(f"OTA record: state={state}, seq={seq}, crc={crc}, valid={is_valid}") - print( - f"OTA record is {state}." - + (" Will be updated on next boot." if state == "VALID" else "") - ) - p = ota_partitions() - print(f"Next boot is '{p[(valid_seq - 1) % len(p)].info()[4]}'.") - - -# Print a detailed summary of the OTA status of the device -def status() -> None: - upyversion, pname = sys.version.split(" ")[2], current_ota.info()[4] - print(f"Micropython {upyversion} has booted from partition '{pname}'.") - print(f"Will boot from partition '{boot_ota().info()[4]}' on next reboot.") - if not ota_partitions(): - print("There are no OTA partitions available.") - elif not next_ota: - print("No spare OTA partition is available for update.") - else: - print(f"The next OTA partition for update is '{next_ota.info()[4]}'.") - print(f"The / filesystem is mounted from partition '{bdev.info()[4]}'.") - partition_table_print() - otadata_check() - - -# The functions below are used by `ota.rollback` and are here to make -# `ota.rollback` as lightweight as possible for the common use case: -# calling `ota.rollback.cancel()` on every boot. - - -# Reboot the device after the provided delay -def ota_reboot(delay=10) -> None: - for i in range(delay, 0, -1): - print(f"\rRebooting in {i:2} seconds (ctrl-C to cancel)", end="") - time.sleep(1) - print() - machine.reset() # Reboot into the new image - - -# Micropython does not support forcing an OTA rollback so we do it by hand: -# - find the previous ota partition, validate the image and set it bootable. -# Raises OSError(-5379) if validation of the boot image fails. -# Raises OSError(-261) if no OTA partitions are available. -def force_rollback(reboot=False) -> None: - partitions = ota_partitions() - for i, p in enumerate(partitions): - if p.info() == current_ota.info(): # Compare by partition offset - partitions[i - 1].set_boot() # Set the previous partition to be bootable - if reboot: - ota_reboot() - return - raise OSError(OTA_UNSUPPORTED) diff --git a/internal_filesystem/lib/ota/update.py b/internal_filesystem/lib/ota/update.py deleted file mode 100644 index fbd760ae..00000000 --- a/internal_filesystem/lib/ota/update.py +++ /dev/null @@ -1,152 +0,0 @@ -# esp32_ota module for MicroPython on ESP32 -# MIT license; Copyright (c) 2023 Glenn Moloney @glenn20 - -# Inspired by OTA class by Thorsten von Eicken (@tve): -# https://github.com/tve/mqboard/blob/master/mqrepl/mqrepl.py - -import gc -import io - -from esp32 import Partition - -from .blockdev_writer import BlockDevWriter -from .status import ota_reboot - - -# Micropython sockets don't have context manager methods. This wrapper provides -# those. -class SocketWrapper: - def __init__(self, f: io.BufferedReader): - self.f = f - - def __enter__(self) -> io.BufferedReader: - return self.f - - def __exit__(self, e_t, e_v, e_tr): - self.f.close() - - -# Open a file or a URL and return a File-like object for reading -def open_url(url_or_filename: str, **kw) -> io.BufferedReader: - if url_or_filename.split(":", 1)[0] in ("http", "https"): - import requests - - r = requests.get(url_or_filename, **kw) - code: int = r.status_code - if code != 200: - r.close() - raise ValueError(f"HTTP Error: {code}") - return SocketWrapper(r.raw) # type: ignore - else: - return open(url_or_filename, "rb") - - -# OTA manages a MicroPython firmware update over-the-air. It checks that there -# are at least two "ota" "app" partitions in the partition table and writes new -# firmware into the partition that is not currently running. When the update is -# complete, it sets the new partition as the next one to boot. Set reboot=True -# to force a reset/restart, or call machine.reset() explicitly. Remember to call -# ota.rollback.cancel() after a successful reboot to the new image. -class OTA: - def __init__(self, verify=True, verbose=True, reboot=False, sha="", length=0): - self.reboot = reboot - self.verbose = verbose - # Get the next free OTA partition - # Raise OSError(ENOENT) if no OTA partition available - self.part = Partition(Partition.RUNNING).get_next_update() - if verbose: - name: str = self.part.info()[4] - print(f"Writing new micropython image to OTA partition '{name}'...") - self.writer = BlockDevWriter(self.part, verify, verbose) - if sha or length: - self.writer.set_sha_length(sha, length) - - # Append the data to the OTA partition - def write(self, data: bytearray | bytes | memoryview) -> int: - return self.writer.write(data) - - # Flush any buffered data to the ota partition and set it as the boot - # partition. If verify is True, will read back the written firmware data to - # check the sha256 of the written data. If reboot is True, will reboot the - # device after 10 seconds. - def close(self) -> None: - if self.writer is None: - return - self.writer.close() - # Set as boot partition for next reboot - name: str = self.part.info()[4] - print(f"OTA Partition '{name}' updated successfully.") - self.part.set_boot() # Raise OSError(-5379) if image on part is not valid - bootname = Partition(Partition.BOOT).info()[4] - if name != bootname: - print(f"Warning: failed to set {name} as the next boot partition.") - print(f"Micropython will boot from '{bootname}' partition on next boot.") - print("Remember to call ota.rollback.cancel() after successful reboot.") - if self.reboot: - ota_reboot() - - def __enter__(self): - return self - - def __exit__(self, e_t, e_v, e_tr): - if e_t is None: # If exception is thrown, don't flush data or set bootable - self.close() - - # Load a firmware file from the provided io stream - # - f: an io stream (supporting the f.readinto() method) - # - sha: (optional) the sha256sum of the firmware file - # - length: (optional) the length (in bytes) of the firmware file - def from_stream(self, f: io.BufferedReader, sha: str = "", length: int = 0) -> int: - if sha or length: - self.writer.set_sha_length(sha, length) - gc.collect() - return self.writer.write_from_stream(f) - - # Write new firmware to the OTA partition from the given url - # - url: a filename or a http[s] url for the micropython.bin firmware. - # - sha: the sha256sum of the firmware file - # - length: the length (in bytes) of the firmware file - def from_firmware_file(self, url: str, sha: str = "", length: int = 0, **kw) -> int: - if self.verbose: - print(f"Opening firmware file {url}...") - with open_url(url, **kw) as f: - return self.from_stream(f, sha, length) - - # Load a firmware file, the location of which is read from a json file - # containing the url for the firmware file, the sha and length of the file. - # - url: the name of a file or url containing the json. - # - kw: extra keywords arguments that will be passed to `requests.get()` - def from_json(self, url: str, **kw) -> int: - if not url.endswith(".json"): - raise ValueError("Url does not end with '.json'") - if self.verbose: - print(f"Opening json file {url}...") - with open_url(url, **kw) as f: - from json import load - - data: dict = load(f) - try: - firmware: str = data["firmware"] - sha: str = data["sha"] - length: int = data["length"] - if not any(firmware.startswith(s) for s in ("https:", "http:", "/")): - # If firmware filename is relative, append to base of url of json file - baseurl, *_ = url.rsplit("/", 1) - firmware = f"{baseurl}/{firmware}" - return self.from_firmware_file(firmware, sha, length, **kw) - except KeyError as err: - print('OTA json must include "firmware", "sha" and "length" keys.') - raise err - - -# Convenience functions which use the OTA class to perform OTA updates. -def from_file( - url: str, sha="", length=0, verify=True, verbose=True, reboot=True, **kw -) -> None: - with OTA(verify, verbose, reboot) as ota_update: - ota_update.from_firmware_file(url, sha, length, **kw) - - -def from_json(url: str, verify=True, verbose=True, reboot=True, **kw) -> None: - with OTA(verify, verbose, reboot) as ota_update: - ota_update.from_json(url, **kw) diff --git a/internal_filesystem/lib/pathlib.py b/internal_filesystem/lib/pathlib.py new file mode 100644 index 00000000..e0f96137 --- /dev/null +++ b/internal_filesystem/lib/pathlib.py @@ -0,0 +1,210 @@ +import errno +import os + +from micropython import const + +_SEP = const("/") + + +def _mode_if_exists(path): + try: + return os.stat(path)[0] + except OSError as e: + if e.errno == errno.ENOENT: + return 0 + raise e + + +def _clean_segment(segment): + segment = str(segment) + if not segment: + return "." + segment = segment.rstrip(_SEP) + if not segment: + return _SEP + while True: + no_double = segment.replace(_SEP + _SEP, _SEP) + if no_double == segment: + break + segment = no_double + return segment + + +class Path: + def __init__(self, *segments): + segments_cleaned = [] + for segment in segments: + segment = _clean_segment(segment) + if segment[0] == _SEP: + segments_cleaned = [segment] + elif segment == ".": + continue + else: + segments_cleaned.append(segment) + + self._path = _clean_segment(_SEP.join(segments_cleaned)) + + def __truediv__(self, other): + return Path(self._path, str(other)) + + def __rtruediv__(self, other): + return Path(other, self._path) + + def __repr__(self): + return f'{type(self).__name__}("{self._path}")' + + def __str__(self): + return self._path + + def __eq__(self, other): + return self.absolute() == Path(other).absolute() + + def absolute(self): + path = self._path + cwd = os.getcwd() + if not path or path == ".": + return cwd + if path[0] == _SEP: + return path + return _SEP + path if cwd == _SEP else cwd + _SEP + path + + def resolve(self): + return self.absolute() + + def open(self, mode="r", encoding=None): + return open(self._path, mode, encoding=encoding) + + def exists(self): + return bool(_mode_if_exists(self._path)) + + def mkdir(self, parents=False, exist_ok=False): + try: + os.mkdir(self._path) + return + except OSError as e: + if e.errno == errno.EEXIST and exist_ok: + return + elif e.errno == errno.ENOENT and parents: + pass # handled below + else: + raise e + + segments = self._path.split(_SEP) + progressive_path = "" + if segments[0] == "": + segments = segments[1:] + progressive_path = _SEP + for segment in segments: + progressive_path += _SEP + segment + try: + os.mkdir(progressive_path) + except OSError as e: + if e.errno != errno.EEXIST: + raise e + + def is_dir(self): + return bool(_mode_if_exists(self._path) & 0x4000) + + def is_file(self): + return bool(_mode_if_exists(self._path) & 0x8000) + + def _glob(self, path, pattern, recursive): + # Currently only supports a single "*" pattern. + n_wildcards = pattern.count("*") + n_single_wildcards = pattern.count("?") + + if n_single_wildcards: + raise NotImplementedError("? single wildcards not implemented.") + + if n_wildcards == 0: + raise ValueError + elif n_wildcards > 1: + raise NotImplementedError("Multiple * wildcards not implemented.") + + prefix, suffix = pattern.split("*") + + for name, mode, *_ in os.ilistdir(path): + full_path = path + _SEP + name + if name.startswith(prefix) and name.endswith(suffix): + yield full_path + if recursive and mode & 0x4000: # is_dir + yield from self._glob(full_path, pattern, recursive=recursive) + + def glob(self, pattern): + """Iterate over this subtree and yield all existing files (of any + kind, including directories) matching the given relative pattern. + + Currently only supports a single "*" pattern. + """ + return self._glob(self._path, pattern, recursive=False) + + def rglob(self, pattern): + return self._glob(self._path, pattern, recursive=True) + + def stat(self): + return os.stat(self._path) + + def read_bytes(self): + with open(self._path, "rb") as f: + return f.read() + + def read_text(self, encoding=None): + with open(self._path, "r", encoding=encoding) as f: + return f.read() + + def rename(self, target): + os.rename(self._path, target) + + def rmdir(self): + os.rmdir(self._path) + + def touch(self, exist_ok=True): + if self.exists(): + if exist_ok: + return # TODO: should update timestamp + else: + # In lieue of FileExistsError + raise OSError(errno.EEXIST) + with open(self._path, "w"): + pass + + def unlink(self, missing_ok=False): + try: + os.unlink(self._path) + except OSError as e: + if not (missing_ok and e.errno == errno.ENOENT): + raise e + + def write_bytes(self, data): + with open(self._path, "wb") as f: + f.write(data) + + def write_text(self, data, encoding=None): + with open(self._path, "w", encoding=encoding) as f: + f.write(data) + + def with_suffix(self, suffix): + index = -len(self.suffix) or None + return Path(self._path[:index] + suffix) + + @property + def stem(self): + return self.name.rsplit(".", 1)[0] + + @property + def parent(self): + tokens = self._path.rsplit(_SEP, 1) + if len(tokens) == 2: + if not tokens[0]: + tokens[0] = _SEP + return Path(tokens[0]) + return Path(".") + + @property + def name(self): + return self._path.rsplit(_SEP, 1)[-1] + + @property + def suffix(self): + elems = self._path.rsplit(".", 1) + return "" if len(elems) == 1 else "." + elems[1] diff --git a/internal_filesystem/lib/shutil.mpy b/internal_filesystem/lib/shutil.mpy deleted file mode 100644 index b87577e7..00000000 Binary files a/internal_filesystem/lib/shutil.mpy and /dev/null differ diff --git a/internal_filesystem/lib/shutil.py b/internal_filesystem/lib/shutil.py new file mode 100644 index 00000000..9e72c8ea --- /dev/null +++ b/internal_filesystem/lib/shutil.py @@ -0,0 +1,48 @@ +# Reimplement, because CPython3.3 impl is rather bloated +import os +from collections import namedtuple + +_ntuple_diskusage = namedtuple("usage", ("total", "used", "free")) + + +def rmtree(d): + if not d: + raise ValueError + + for name, type, *_ in os.ilistdir(d): + path = d + "/" + name + if type & 0x4000: # dir + rmtree(path) + else: # file + os.unlink(path) + os.rmdir(d) + + +def copyfileobj(src, dest, length=512): + if hasattr(src, "readinto"): + buf = bytearray(length) + while True: + sz = src.readinto(buf) + if not sz: + break + if sz == length: + dest.write(buf) + else: + b = memoryview(buf)[:sz] + dest.write(b) + else: + while True: + buf = src.read(length) + if not buf: + break + dest.write(buf) + + +def disk_usage(path): + bit_tuple = os.statvfs(path) + blksize = bit_tuple[0] # system block size + total = bit_tuple[2] * blksize + free = bit_tuple[3] * blksize + used = total - free + + return _ntuple_diskusage(total, used, free) diff --git a/internal_filesystem/lib/threading.py b/internal_filesystem/lib/threading.py index fb509768..2f02d254 100644 --- a/internal_filesystem/lib/threading.py +++ b/internal_filesystem/lib/threading.py @@ -1,6 +1,8 @@ +# Lightweight replacement for CPython's Thread module + import _thread -import mpos.apps +from mpos.task_manager import TaskManager class Thread: def __init__(self, group=None, target=None, name=None, args=(), kwargs=None, daemon=None): @@ -21,7 +23,7 @@ def start(self): # small stack sizes 8KB gives segfault directly # 22KB or less is too tight on desktop, 23KB and more is fine #stacksize = 24*1024 - stacksize = mpos.apps.good_stack_size() + stacksize = TaskManager.good_stack_size() #stacksize = 20*1024 print(f"starting thread with stacksize {stacksize}") _thread.stack_size(stacksize) diff --git a/internal_filesystem/lib/traceback.mpy b/internal_filesystem/lib/traceback.mpy deleted file mode 100644 index ff0e4c68..00000000 Binary files a/internal_filesystem/lib/traceback.mpy and /dev/null differ diff --git a/internal_filesystem/lib/websocket.py b/internal_filesystem/lib/uaiowebsocket.py similarity index 98% rename from internal_filesystem/lib/websocket.py rename to internal_filesystem/lib/uaiowebsocket.py index 01930275..66dc30cd 100644 --- a/internal_filesystem/lib/websocket.py +++ b/internal_filesystem/lib/uaiowebsocket.py @@ -229,7 +229,9 @@ async def run_forever( # Run the event loop in the main thread try: - self._loop.run_until_complete(self._async_main()) + print("websocket's run_forever creating _async_main task") + #self._loop.run_until_complete(self._async_main()) # this doesn't always finish! + asyncio.create_task(self._async_main()) except KeyboardInterrupt: _log_debug("run_forever got KeyboardInterrupt") self.close() @@ -272,7 +274,7 @@ async def _async_main(self): _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") diff --git a/internal_filesystem/lib/unittest/__init__.mpy b/internal_filesystem/lib/unittest/__init__.mpy deleted file mode 100644 index ce71760c..00000000 Binary files a/internal_filesystem/lib/unittest/__init__.mpy and /dev/null differ diff --git a/internal_filesystem/lib/unittest/__init__.py b/internal_filesystem/lib/unittest/__init__.py new file mode 100644 index 00000000..61b31578 --- /dev/null +++ b/internal_filesystem/lib/unittest/__init__.py @@ -0,0 +1,464 @@ +import io +import os +import sys + +try: + import traceback +except ImportError: + traceback = None + + +class SkipTest(Exception): + pass + + +class AssertRaisesContext: + def __init__(self, exc): + self.expected = exc + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, tb): + self.exception = exc_value + if exc_type is None: + assert False, "%r not raised" % self.expected + if issubclass(exc_type, self.expected): + # store exception for later retrieval + self.exception = exc_value + return True + return False + + +# These are used to provide required context to things like subTest +__current_test__ = None +__test_result__ = None + + +class SubtestContext: + def __init__(self, msg=None, params=None): + self.msg = msg + self.params = params + + def __enter__(self): + pass + + def __exit__(self, *exc_info): + if exc_info[0] is not None: + # Exception raised + global __test_result__, __current_test__ + test_details = __current_test__ + if self.msg: + test_details += (f" [{self.msg}]",) + if self.params: + detail = ", ".join(f"{k}={v}" for k, v in self.params.items()) + test_details += (f" ({detail})",) + + _handle_test_exception(test_details, __test_result__, exc_info, False) + # Suppress the exception as we've captured it above + return True + + +class NullContext: + def __enter__(self): + pass + + def __exit__(self, exc_type, exc_value, traceback): + pass + + +class TestCase: + def __init__(self): + pass + + def addCleanup(self, func, *args, **kwargs): + if not hasattr(self, "_cleanups"): + self._cleanups = [] + self._cleanups.append((func, args, kwargs)) + + def doCleanups(self): + if hasattr(self, "_cleanups"): + while self._cleanups: + func, args, kwargs = self._cleanups.pop() + func(*args, **kwargs) + + def subTest(self, msg=None, **params): + return SubtestContext(msg=msg, params=params) + + def skipTest(self, reason): + raise SkipTest(reason) + + def fail(self, msg=""): + assert False, msg + + def assertEqual(self, x, y, msg=""): + if not msg: + msg = "%r vs (expected) %r" % (x, y) + assert x == y, msg + + def assertNotEqual(self, x, y, msg=""): + if not msg: + msg = "%r not expected to be equal %r" % (x, y) + assert x != y, msg + + def assertLessEqual(self, x, y, msg=None): + if msg is None: + msg = "%r is expected to be <= %r" % (x, y) + assert x <= y, msg + + def assertGreaterEqual(self, x, y, msg=None): + if msg is None: + msg = "%r is expected to be >= %r" % (x, y) + assert x >= y, msg + + def assertAlmostEqual(self, x, y, places=None, msg="", delta=None): + if x == y: + return + if delta is not None and places is not None: + raise TypeError("specify delta or places not both") + + if delta is not None: + if abs(x - y) <= delta: + return + if not msg: + msg = "%r != %r within %r delta" % (x, y, delta) + else: + if places is None: + places = 7 + if round(abs(y - x), places) == 0: + return + if not msg: + msg = "%r != %r within %r places" % (x, y, places) + + assert False, msg + + def assertNotAlmostEqual(self, x, y, places=None, msg="", delta=None): + if delta is not None and places is not None: + raise TypeError("specify delta or places not both") + + if delta is not None: + if not (x == y) and abs(x - y) > delta: + return + if not msg: + msg = "%r == %r within %r delta" % (x, y, delta) + else: + if places is None: + places = 7 + if not (x == y) and round(abs(y - x), places) != 0: + return + if not msg: + msg = "%r == %r within %r places" % (x, y, places) + + assert False, msg + + def assertIs(self, x, y, msg=""): + if not msg: + msg = "%r is not %r" % (x, y) + assert x is y, msg + + def assertIsNot(self, x, y, msg=""): + if not msg: + msg = "%r is %r" % (x, y) + assert x is not y, msg + + def assertIsNone(self, x, msg=""): + if not msg: + msg = "%r is not None" % x + assert x is None, msg + + def assertIsNotNone(self, x, msg=""): + if not msg: + msg = "%r is None" % x + assert x is not None, msg + + def assertTrue(self, x, msg=""): + if not msg: + msg = "Expected %r to be True" % x + assert x, msg + + def assertFalse(self, x, msg=""): + if not msg: + msg = "Expected %r to be False" % x + assert not x, msg + + def assertIn(self, x, y, msg=""): + if not msg: + msg = "Expected %r to be in %r" % (x, y) + assert x in y, msg + + def assertIsInstance(self, x, y, msg=""): + assert isinstance(x, y), msg + + def assertRaises(self, exc, func=None, *args, **kwargs): + if func is None: + return AssertRaisesContext(exc) + + try: + func(*args, **kwargs) + except Exception as e: + if isinstance(e, exc): + return + raise e + + assert False, "%r not raised" % exc + + def assertWarns(self, warn): + return NullContext() + + +def skip(msg): + def _decor(fun): + # We just replace original fun with _inner + def _inner(self): + raise SkipTest(msg) + + return _inner + + return _decor + + +def skipIf(cond, msg): + if not cond: + return lambda x: x + return skip(msg) + + +def skipUnless(cond, msg): + if cond: + return lambda x: x + return skip(msg) + + +def expectedFailure(test): + def test_exp_fail(*args, **kwargs): + try: + test(*args, **kwargs) + except: + pass + else: + assert False, "unexpected success" + + return test_exp_fail + + +class TestSuite: + def __init__(self, name=""): + self._tests = [] + self.name = name + + def addTest(self, cls): + self._tests.append(cls) + + def run(self, result): + for c in self._tests: + _run_suite(c, result, self.name) + return result + + def _load_module(self, mod): + for tn in dir(mod): + c = getattr(mod, tn) + if isinstance(c, object) and isinstance(c, type) and issubclass(c, TestCase): + self.addTest(c) + elif tn.startswith("test") and callable(c): + self.addTest(c) + + +class TestRunner: + def run(self, suite: TestSuite): + res = TestResult() + suite.run(res) + + res.printErrors() + print("----------------------------------------------------------------------") + print("Ran %d tests\n" % res.testsRun) + if res.failuresNum > 0 or res.errorsNum > 0: + print("FAILED (failures=%d, errors=%d)" % (res.failuresNum, res.errorsNum)) + else: + msg = "OK" + if res.skippedNum > 0: + msg += " (skipped=%d)" % res.skippedNum + print(msg) + + return res + + +TextTestRunner = TestRunner + + +class TestResult: + def __init__(self): + self.errorsNum = 0 + self.failuresNum = 0 + self.skippedNum = 0 + self.testsRun = 0 + self.errors = [] + self.failures = [] + self.skipped = [] + self._newFailures = 0 + + def wasSuccessful(self): + return self.errorsNum == 0 and self.failuresNum == 0 + + def printErrors(self): + if self.errors or self.failures: + print() + self.printErrorList(self.errors) + self.printErrorList(self.failures) + + def printErrorList(self, lst): + sep = "----------------------------------------------------------------------" + for c, e in lst: + detail = " ".join((str(i) for i in c)) + print("======================================================================") + print(f"FAIL: {detail}") + print(sep) + print(e) + + def __repr__(self): + # Format is compatible with CPython. + return "" % ( + self.testsRun, + self.errorsNum, + self.failuresNum, + ) + + def __add__(self, other): + self.errorsNum += other.errorsNum + self.failuresNum += other.failuresNum + self.skippedNum += other.skippedNum + self.testsRun += other.testsRun + self.errors.extend(other.errors) + self.failures.extend(other.failures) + self.skipped.extend(other.skipped) + return self + + +def _capture_exc(exc, exc_traceback): + buf = io.StringIO() + if hasattr(sys, "print_exception"): + sys.print_exception(exc, buf) + elif traceback is not None: + traceback.print_exception(None, exc, exc_traceback, file=buf) + return buf.getvalue() + + +def _handle_test_exception( + current_test: tuple, test_result: TestResult, exc_info: tuple, verbose=True +): + exc = exc_info[1] + traceback = exc_info[2] + ex_str = _capture_exc(exc, traceback) + if isinstance(exc, SkipTest): + reason = exc.args[0] + test_result.skippedNum += 1 + test_result.skipped.append((current_test, reason)) + print(" skipped:", reason) + return + elif isinstance(exc, AssertionError): + test_result.failuresNum += 1 + test_result.failures.append((current_test, ex_str)) + if verbose: + print(" FAIL") + else: + test_result.errorsNum += 1 + test_result.errors.append((current_test, ex_str)) + if verbose: + print(" ERROR") + test_result._newFailures += 1 + + +def _run_suite(c, test_result: TestResult, suite_name=""): + if isinstance(c, TestSuite): + c.run(test_result) + return + + if isinstance(c, type): + o = c() + else: + o = c + set_up_class = getattr(o, "setUpClass", lambda: None) + tear_down_class = getattr(o, "tearDownClass", lambda: None) + set_up = getattr(o, "setUp", lambda: None) + tear_down = getattr(o, "tearDown", lambda: None) + exceptions = [] + try: + suite_name += "." + c.__qualname__ + except AttributeError: + pass + + def run_one(test_function): + global __test_result__, __current_test__ + print("%s (%s) ..." % (name, suite_name), end="") + set_up() + __test_result__ = test_result + test_container = f"({suite_name})" + __current_test__ = (name, test_container) + try: + test_result._newFailures = 0 + test_result.testsRun += 1 + test_function() + # No exception occurred, test passed + if test_result._newFailures: + print(" FAIL") + else: + print(" ok") + except Exception as ex: + _handle_test_exception( + current_test=(name, c), test_result=test_result, exc_info=(type(ex), ex, None) + ) + # Uncomment to investigate failure in detail + # raise ex + finally: + __test_result__ = None + __current_test__ = None + tear_down() + try: + o.doCleanups() + except AttributeError: + pass + + set_up_class() + try: + if hasattr(o, "runTest"): + name = str(o) + run_one(o.runTest) + return + + for name in dir(o): + if name.startswith("test"): + m = getattr(o, name) + if not callable(m): + continue + run_one(m) + + if callable(o): + name = o.__name__ + run_one(o) + finally: + tear_down_class() + + return exceptions + + +# This supports either: +# +# >>> import mytest +# >>> unitttest.main(mytest) +# +# >>> unittest.main("mytest") +# +# Or, a script that ends with: +# if __name__ == "__main__": +# unittest.main() +# e.g. run via `mpremote run mytest.py` +def main(module="__main__", testRunner=None): + if testRunner is None: + testRunner = TestRunner() + elif isinstance(testRunner, type): + testRunner = testRunner() + + if isinstance(module, str): + module = __import__(module) + suite = TestSuite(module.__name__) + suite._load_module(module) + return testRunner.run(suite) diff --git a/internal_filesystem/lib/zipfile.py b/internal_filesystem/lib/zipfile.py index 1f411716..fb867356 100644 --- a/internal_filesystem/lib/zipfile.py +++ b/internal_filesystem/lib/zipfile.py @@ -1940,7 +1940,10 @@ def makedirs(self, path): if parent: self.makedirs(parent) # Recursively create parent directories if not self.path_exists(path): - os.mkdir(path) + try: + os.mkdir(path) + except OSError: + pass # Directory may already exist def _extract_member(self, member, targetpath, pwd): """Extract the ZipInfo object 'member' to a physical @@ -1972,7 +1975,10 @@ def _extract_member(self, member, targetpath, pwd): # Handle directories if member.is_dir(): if not self.path_isdir(targetpath): - os.mkdir(targetpath) + try: + os.mkdir(targetpath) + except OSError: + pass # Directory may already exist from makedirs return targetpath # Extract file diff --git a/internal_filesystem/main.py b/internal_filesystem/main.py index e768d64d..c44ee8a6 100644 --- a/internal_filesystem/main.py +++ b/internal_filesystem/main.py @@ -2,9 +2,30 @@ # 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/ + +# Copy this file to / on the device's internal storage to have it run automatically instead of relying on the frozen-in files. +import gc +import os import sys -sys.path.insert(0, 'lib') -print("Passing execution over to mpos.main") -import mpos.main +sys.path.insert(0, "lib") + +print(f"{sys.version=}") +print(f"{sys.implementation=}") + +print("Free space on root filesystem:") +stat = os.statvfs("/") +total_space = stat[0] * stat[2] +free_space = stat[0] * stat[3] +used_space = total_space - free_space +print(f"{total_space=} / {used_space=} / {free_space=} bytes") + + +gc.collect() +print( + f"RAM: {gc.mem_free()} free, {gc.mem_alloc()} allocated, {gc.mem_alloc() + gc.mem_free()} total" +) + +print("Passing execution over to mpos.main") +import mpos.main # noqa: F401 diff --git a/lvgl_micropython b/lvgl_micropython index b886c333..2981c1ab 160000 --- a/lvgl_micropython +++ b/lvgl_micropython @@ -1 +1 @@ -Subproject commit b886c3334890ce3e7eeb9d9588580104eda92c8a +Subproject commit 2981c1ab91375ce58ae2bc52fe951b17d31e913c diff --git a/manifests/manifest_fri3d-2024.py b/manifests/manifest_fri3d-2024.py deleted file mode 100644 index 6b5c3aa1..00000000 --- a/manifests/manifest_fri3d-2024.py +++ /dev/null @@ -1,4 +0,0 @@ -freeze('/tmp/', 'boot.py') # Hardware initialization - this file is copied from boot_fri3d-2024.py to /tmp by the build script to have it named boot.py -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 a84c8459..f88b29d7 160000 --- a/micropython-camera-API +++ b/micropython-camera-API @@ -1 +1 @@ -Subproject commit a84c84595b415894b9b4ca3dc05ffd3d7d9d9a22 +Subproject commit f88b29d7ce9bb0c3733532bbb31fde794a51e6df diff --git a/micropython-nostr b/micropython-nostr index 99be5ce9..3da5987f 160000 --- a/micropython-nostr +++ b/micropython-nostr @@ -1 +1 @@ -Subproject commit 99be5ce94d3815e344a8dda9307db2e1a406e3ed +Subproject commit 3da5987fcc4a38c0467f00e03c1731075b51500c 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/ruff.toml b/ruff.toml new file mode 100644 index 00000000..f1c43c0a --- /dev/null +++ b/ruff.toml @@ -0,0 +1,2 @@ +[format] +quote-style = "double" diff --git a/scripts/addr2line.sh b/scripts/addr2line.sh old mode 100644 new mode 100755 diff --git a/scripts/build_all.sh b/scripts/build_all.sh index c7a5ea8c..e5150418 100755 --- a/scripts/build_all.sh +++ b/scripts/build_all.sh @@ -47,6 +47,23 @@ if [ $result -ne 0 ]; then fi cp "$buildfile" "$outdir"/MicroPythonOS_waveshare-esp32-s3-touch-lcd-2_dev_"$version".bin +./scripts/build_lvgl_micropython.sh esp32 prod matouch-esp32-s3-2-8 +result=$? +if [ $result -ne 0 ]; then + echo "build_lvgl_micropython.sh esp32 prod matouch-esp32-s3-2-8 got error: $result" + exit 1 +fi +cp "$buildfile" "$outdir"/MicroPythonOS_matouch-esp32-s3-2-8_prod_"$version".bin +cp "$updatefile" "$updatesdir"/MicroPythonOS_matouch-esp32-s3-2-8_prod_"$version".ota + +./scripts/build_lvgl_micropython.sh esp32 dev matouch-esp32-s3-2-8 +result=$? +if [ $result -ne 0 ]; then + echo "build_lvgl_micropython.sh esp32 dev matouch-esp32-s3-2-8 got error: $result" + exit 1 +fi +cp "$buildfile" "$outdir"/MicroPythonOS_matouch-esp32-s3-2-8_dev_"$version".bin + ./scripts/build_lvgl_micropython.sh unix dev cp "$builddir"/lvgl_micropy_unix "$outdir"/MicroPythonOS_amd64_linux_"$version".elf result=$? diff --git a/scripts/build_mpos.sh b/scripts/build_mpos.sh index 4ee57487..59d4054f 100755 --- a/scripts/build_mpos.sh +++ b/scripts/build_mpos.sh @@ -6,7 +6,6 @@ codebasedir=$(readlink -f "$mydir"/..) # build process needs absolute paths target="$1" buildtype="$2" -subtarget="$3" if [ -z "$target" ]; then echo "Usage: $0 target" @@ -14,11 +13,19 @@ if [ -z "$target" ]; then echo "Example: $0 unix" echo "Example: $0 macOS" echo "Example: $0 esp32" - echo + echo "Example: $0 esp32s3" + echo "Example: $0 unphone" + echo "Example: $0 clean" exit 1 fi +if [ "$target" == "clean" ]; then + rm -rf "$mydir"/../lvgl_micropython/lib/micropython/ports/unix/build-standard/ + rm -rf "$mydir"/../lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC_S3-SPIRAM_OCT/ + exit 0 +fi + # This assumes all the git submodules have been checked out recursively echo "Fetch tags for lib/SDL, otherwise lvgl_micropython's make.py script can't checkout a specific tag..." @@ -28,18 +35,29 @@ git fetch --unshallow origin 2>/dev/null # will give error if already done git fetch origin 'refs/tags/*:refs/tags/*' popd -echo "Check need to add esp32-camera..." idfile="$codebasedir"/lvgl_micropython/lib/micropython/ports/esp32/main/idf_component.yml +echo "Patching $idfile"... + +echo "Check need to add esp32-camera to $idfile" if ! grep esp32-camera "$idfile"; then echo "Adding esp32-camera to $idfile" - echo " espressif/esp32-camera: + echo " mpos/esp32-camera: git: https://github.com/MicroPythonOS/esp32-camera" >> "$idfile" - echo "Resulting file:" - cat "$idfile" else echo "No need to add esp32-camera to $idfile" fi +echo "Check need to add adc_mic to $idfile" +if ! grep adc_mic "$idfile"; then + echo "Adding adc_mic to $idfile" + echo ' espressif/adc_mic: "*"' >> "$idfile" +else + echo "No need to add adc_mic to $idfile" +fi + +echo "Resulting $idfile file:" +cat "$idfile" + echo "Check need to add lvgl_micropython manifest to micropython-camera-API's manifest..." camani="$codebasedir"/micropython-camera-API/src/manifest.py rellvglmani=lvgl_micropython/build/manifest.py @@ -74,20 +92,53 @@ ln -sf ../../c_mpos "$codebasedir"/lvgl_micropython/ext_mod/c_mpos #echo "Applying lvgl_micropython i2c patch..." #patch -p0 --forward < "$codebasedir"/patches/i2c_ng.patch +echo "Minifying and inlining HTML..." +pushd "$codebasedir"/webrepl/ +python3 inline_minify_webrepl.py +result=$0 +if [ $? -ne 0 ]; then + echo "ERROR: webrepl/inline_minify_webrepl.py failed with exit code $result, webrepl won't work" +else + mv webrepl_inlined_minified.html ../internal_filesystem/builtin/html/ +fi +popd + echo "Refreshing freezefs..." "$codebasedir"/scripts/freezefs_mount_builtin.sh -manifest="" -if [ "$target" == "esp32" ]; then +if [ "$target" == "esp32" -o "$target" == "esp32s3" -o "$target" == "unphone" ]; then + partition_size="4194304" + flash_size="16" + otasupport="--ota" + extra_configs="" + if [ "$target" == "esp32" ]; then + BOARD=ESP32_GENERIC + BOARD_VARIANT=SPIRAM + else # esp32s3 or unphone + if [ "$target" == "unphone" ]; then + partition_size="3900000" + flash_size="8" + otasupport="" # too small for 2 OTA partitions + internal storage + fi + BOARD=ESP32_GENERIC_S3 + BOARD_VARIANT=SPIRAM_OCT + # These options disable hardware AES, SHA and MPI because they give warnings in QEMU: [AES] Error reading from GDMA buffer + # There's a 25% https download speed penalty for this, but that's usually not the bottleneck. + extra_configs="CONFIG_MBEDTLS_HARDWARE_AES=n CONFIG_MBEDTLS_HARDWARE_SHA=n CONFIG_MBEDTLS_HARDWARE_MPI=n" + # --py-freertos: add MicroPython FreeRTOS module to expose internals + extra_configs="$extra_configs --py-freertos" + fi manifest=$(readlink -f "$codebasedir"/manifests/manifest.py) - frozenmanifest="FROZEN_MANIFEST=$manifest" + frozenmanifest="FROZEN_MANIFEST=$manifest" # Comment this out if you want to make a build without any frozen files, just an empty MicroPython + whatever files you have on the internal storage 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 + pushd "$codebasedir"/lvgl_micropython/ + rm -rf lib/micropython/ports/esp32/build-$BOARD-$BOARD_VARIANT + + # For more info on the options, see https://github.com/lvgl-micropython/lvgl_micropython # --ota: support Over-The-Air updates # --partition size: both OTA partitions are 4MB # --flash-size: total flash size is 16MB - # --debug: enable debugging from ESP-IDF but makes copying files to it very slow + # --debug: enable debugging from ESP-IDF but makes copying files to it very slow so that's not added # --dual-core-threads: disabled GIL, run code on both CPUs # --task-stack-size={stack size in bytes} # CONFIG_* sets ESP-IDF options @@ -95,30 +146,77 @@ if [ "$target" == "esp32" ]; then # CONFIG_FREERTOS_USE_TRACE_FACILITY=y # CONFIG_FREERTOS_VTASKLIST_INCLUDE_COREID=y # CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS=y - pushd "$codebasedir"/lvgl_micropython/ - 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" + # CONFIG_ADC_MIC_TASK_CORE=1 because with the default (-1) it hangs the CPU + # CONFIG_SPIRAM_XIP_FROM_PSRAM: load entire firmware into RAM to reduce SD vs PSRAM contention (recommended at https://github.com/MicroPythonOS/MicroPythonOS/issues/17) + python3 make.py "$otasupport" --optimize-size --partition-size=$partition_size --flash-size=$flash_size esp32 BOARD=$BOARD BOARD_VARIANT=$BOARD_VARIANT \ + 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 \ + CONFIG_ADC_MIC_TASK_CORE=1 \ + $extra_configs \ + "$frozenmanifest" + popd elif [ "$target" == "unix" -o "$target" == "macOS" ]; then manifest=$(readlink -f "$codebasedir"/manifests/manifest.py) frozenmanifest="FROZEN_MANIFEST=$manifest" + # Ensure WebREPL and dupterm are enabled for unix/macOS builds. + mpconfig_unix="$codebasedir"/lvgl_micropython/lib/micropython/ports/unix/mpconfigport.h + ensure_mpconfig_define() { + local name="$1" + if ! grep -q "$name" "$mpconfig_unix"; then + echo "Enabling $name in $mpconfig_unix" + python3 - "$mpconfig_unix" "$name" <<'PY' +import pathlib +import sys + +path = pathlib.Path(sys.argv[1]) +name = sys.argv[2] +text = path.read_text() +needle = '#include "mpconfigvariant.h"' +insert = f"\n\n#ifndef {name}\n#define {name} (1)\n#endif\n" +if needle in text and name not in text: + path.write_text(text.replace(needle, needle + insert)) +PY + else + echo "$name already configured in $mpconfig_unix" + fi + } + ensure_mpconfig_define MICROPY_PY_WEBREPL + ensure_mpconfig_define MICROPY_PY_OS_DUPTERM + # 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 's/^@micropython\.viper$/#@micropython.viper/' "$stream_wav_file" + sed -i.backup 's/^@micropython\.viper$/#@micropython.viper/' "$stream_wav_file" - # LV_CFLAGS are passed to USER_C_MODULES + # If it's still running, kill it, otherwise "text file busy" + pkill -9 -f /lvgl_micropy_unix + # LV_CFLAGS are passed to USER_C_MODULES (compiler flags only, no linker flags) # 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 "$frozenmanifest" + # To avoid X11/Wayland being loaded dynamically at runtime, you can use: -DSDL_LOADSO=OFF + # but then those need to be provided at compile time, or excluded by using: -DSDL_WAYLAND=OFF -DSDL_X11=OFF + python3 make.py "$target" \ + LV_CFLAGS="-g -O0 -ggdb" \ + STRIP= \ + DISPLAY=sdl_display \ + INDEV=sdl_pointer \ + SDL_FLAGS="-DSDL_OPENGL=OFF -DSDL_OPENGLES=OFF -DSDL_VULKAN=OFF -DSDL_KMSDRM=OFF -DSDL_IBUS=OFF -DSDL_DBUS=OFF -DSDL_ALSA=OFF -DSDL_PULSEAUDIO=OFF -DSDL_SNDIO=OFF -DSDL_LIBSAMPLERATE=OFF" \ + "$frozenmanifest" + popd # Restore @micropython.viper decorator after build echo "Restoring @micropython.viper decorator..." - sed -i 's/^#@micropython\.viper$/@micropython.viper/' "$stream_wav_file" + sed -i.backup 's/^#@micropython\.viper$/@micropython.viper/' "$stream_wav_file" + rm "$stream_wav_file".backup else echo "invalid target $target" -fi - +fi \ No newline at end of file diff --git a/scripts/bundle_apps.sh b/scripts/bundle_apps.sh index e939ebc3..f64e58a9 100755 --- a/scripts/bundle_apps.sh +++ b/scripts/bundle_apps.sh @@ -17,12 +17,11 @@ rm "$outputjson" # 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" +# com.micropythonos.nostr isn't ready for release yet +blacklist="com.micropythonos.filemanager com.quasikili.quasidoodle com.micropythonos.errortest com.micropythonos.nostr" +blacklist="$blacklist com.micropythonos.doom_launcher com.micropythonos.doom com.micropythonos.breakout" # not ready yet +blacklist="$blacklist cz.ucw.pavel.calendar cz.ucw.pavel.cellular cz.ucw.pavel.compass cz.ucw.pavel.navstar cz.ucw.pavel.weather" # not ready yet echo "[" | tee -a "$outputjson" @@ -39,6 +38,11 @@ for apprepo in internal_filesystem/apps; do pushd "$apprepo"/"$appdir" manifest=META-INF/MANIFEST.JSON version=$( jq -r '.version' "$manifest" ) + result=$? + if [ $result -ne 0 ]; then + echo "Failed to parse $apprepo/$appdir/$manifest !" + exit 1 + fi cat "$manifest" | tee -a "$outputjson" echo -n "," | tee -a "$outputjson" thisappdir="$output"/apps/"$appdir" diff --git a/scripts/cleanup_pyc.sh b/scripts/cleanup_pyc.sh new file mode 100755 index 00000000..6235fb75 --- /dev/null +++ b/scripts/cleanup_pyc.sh @@ -0,0 +1,2 @@ +find internal_filesystem/ -iname "*.pyc" -exec rm {} \; +find internal_filesystem/ -iname "__pycache__" -exec rmdir {} \; diff --git a/scripts/compile_py.sh b/scripts/compile_py.sh new file mode 100755 index 00000000..f0c44bc0 --- /dev/null +++ b/scripts/compile_py.sh @@ -0,0 +1 @@ +./lvgl_micropython/lib/micropython/mpy-cross/build/mpy-cross internal_filesystem/lib/mpos/main.py diff --git a/scripts/flash_over_usb.sh b/scripts/flash_over_usb.sh index 8033bbf0..baf1b3bf 100755 --- a/scripts/flash_over_usb.sh +++ b/scripts/flash_over_usb.sh @@ -9,5 +9,6 @@ ls -al $fwfile echo "Add --erase-all if needed" sleep 5 # This needs python and the esptool -~/.espressif/python_env/*/bin/python -m esptool --chip esp32s3 --before default_reset --after hard_reset write_flash --flash_mode dio --flash_size 16MB --flash_freq 80m 0 $fwfile $1 +python=$(ls -tr ~/.espressif/python_env/*/bin/python|tail -1) +$python -m esptool --chip esp32s3 --before default_reset --after hard_reset write_flash --flash_mode dio --flash_size 16MB --flash_freq 80m 0 $fwfile $@ diff --git a/scripts/install.sh b/scripts/install.sh index 7dd15113..0818d9e5 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,3 +1,6 @@ +#!/bin/bash +# Bash is used for pushd and popd + mydir=$(readlink -f "$0") mydir=$(dirname "$mydir") @@ -13,15 +16,16 @@ echo "Example: $0 com.micropythonos.about" mpremote=$(readlink -f "$mydir/../lvgl_micropython/lib/micropython/tools/mpremote/mpremote.py") -pushd internal_filesystem/ +pushd "$mydir"/../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 "mpos.net.wifi_service.WifiService.disconnect()" +$mpremote exec "import mpos ; mpos.net.wifi_service.WifiService.disconnect()" sleep 2 if [ ! -z "$appname" ]; then echo "Installing one app: $appname" - appdir="apps/$appname/" + appdir="apps/$appname" target="apps/" if [ ! -d "$appdir" ]; then echo "$appdir doesn't exist so taking the builtin/" @@ -35,7 +39,12 @@ if [ ! -z "$appname" ]; then $mpremote mkdir "/apps" #$mpremote mkdir "/builtin" # dont do this because it breaks the mount! #$mpremote mkdir "/builtin/apps" - $mpremote fs cp -r "$appdir" :/"$target" + if test -L "$appdir"; then + $mpremote fs mkdir :/"$appdir" + $mpremote fs cp -r "$appdir"/* :/"$appdir"/ + else + $mpremote fs cp -r "$appdir" :/"$target" + fi echo "start_app(\"/$appdir\")" $mpremote popd @@ -47,31 +56,46 @@ fi # The issue is that this brings all the .git folders with it: #$mpremote fs cp -r apps :/ -$mpremote fs mkdir :/apps -$mpremote fs cp -r apps/com.micropythonos.* :/apps/ -find apps/ -maxdepth 1 -type l | while read symlink; do - echo "Handling symlink $symlink" - $mpremote fs mkdir :/"$symlink" - $mpremote fs cp -r "$symlink"/* :/"$symlink"/ +$mpremote fs cp -r lib :/ -done #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 data :/ #$mpremote fs cp -r data/images :/data/ -popd +$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/ + +$mpremote fs mkdir :/apps -# Install test infrastructure (for running ondevice tests) -echo "Installing test infrastructure..." -$mpremote fs mkdir :/tests -$mpremote fs mkdir :/tests/screenshots +# Use this to install just a few apps: +#$mpremote fs cp -r apps/com.micropythonos.musicplayer :/apps/ +#$mpremote fs cp -r apps/com.micropythonos.soundrecorder :/apps/ +#$mpremote fs cp -r apps/com.micropythonos.breakout :/apps/ if [ ! -z "$appname" ]; then echo "Not resetting so the installed app can be used immediately." $mpremote reset fi + +# Uncomment this line if you really want all apps the be installed: +echo "Not installing all apps by default because it takes a long time, uses lots of storage and makes the boot slower..." ; popd ; exit 1 + +$mpremote fs cp -r apps/com.micropythonos.* :/apps/ +find apps/ -maxdepth 1 -type l | while read symlink; do + if echo $symlink | grep quasiboats; then + echo "Skipping $symlink because it's needlessly big..." + continue + fi + echo "Handling symlink $symlink" + $mpremote fs mkdir :/"$symlink" + $mpremote fs cp -r "$symlink"/* :/"$symlink"/ + +done + +popd + diff --git a/scripts/make_image.sh b/scripts/make_image.sh new file mode 100755 index 00000000..cb6841e2 --- /dev/null +++ b/scripts/make_image.sh @@ -0,0 +1,39 @@ +# Experimental quick and dirty script to assemble an ESP32 firmware image based on a partition table, internal_filesystem directory, bootloader, and ESP32 "app" binary +mydir=$(readlink -f "$0") +mydir=$(dirname "$mydir") +# This needs python and the esptool + +python3 lvgl_micropython/lib/esp-idf/components/partition_table/gen_esp32part.py --flash-size 16MB partitions_with_retro-go_16mb.csv > partitions_with_retro-go_16mb.bin +#python3 lvgl_micropython/lib/esp-idf/components/partition_table/gen_esp32part.py --flash-size 4MB partitions_4mb.csv > partitions_4mb.bin +#python3 lvgl_micropython/lib/esp-idf/components/partition_table/gen_esp32part.py --flash-size 8MB partitions_8mb.csv > partitions_8mb.bin + +if [ $? -ne 0 ]; then + echo "ERROR: Converting partition csv to bin failed!" + exit 1 +fi + +rm "$mydir"/../internal_filesystem.bin +"$mydir"/../scripts/mklittlefs.sh + +prboom="~/projects/MicroPythonOS/claude/retro-go/prboom-go/build/prboom-go.bin" +launcher="~/projects/MicroPythonOS/claude/retro-go/launcher/build/launcher.bin" +core="~/projects/MicroPythonOS/claude/retro-go/retro-core/build/retro-core.bin" +#ls -al "$launcher" "$core" "$prboom" + + +#outdir=lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC-SPIRAM/ +#~/.espressif/python_env/*/bin/python -m esptool --chip esp32 merge_bin --fill-flash-size=16MB --output image_esp32.bin 0x1000 "$outdir"/bootloader/bootloader.bin 0x8000 partitions_with_retro-go.bin 0x20000 "$outdir"/micropython.bin 0x820000 "$launcher" 0x930000 "$core" 0xa00000 "$prboom" # 0xae0000 "$mydir"/../internalsd_zips_removed_gb_romart.bin $@ + +outdir=lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC_S3-SPIRAM_OCT/ +rm image_esp32s3.bin +#~/.espressif/python_env/*/bin/python -m esptool --chip esp32s3 merge_bin --fill-flash-size=4MB --output image_esp32s3.bin 0x0 "$outdir"/bootloader/bootloader.bin 0x8000 partitions_for_qemu.bin 0x20000 "$outdir"/micropython.bin # 0x820000 "$launcher" 0x930000 "$core" 0xa00000 "$prboom" # 0xae0000 "$mydir"/../internalsd_zips_removed_gb_romart.bin $@ +#~/.espressif/python_env/*/bin/python -m esptool --chip esp32s3 merge_bin --fill-flash-size=8MB --output image_esp32s3.bin 0x0 "$outdir"/bootloader/bootloader.bin 0x8000 partitions_for_qemu.bin 0x20000 "$outdir"/micropython.bin 0x3A0000 "$mydir"/../internal_filesystem.bin $@ +#~/.espressif/python_env/*/bin/python -m esptool --chip esp32s3 merge_bin --fill-flash-size=16MB --output image_esp32s3.bin 0x0 "$outdir"/bootloader/bootloader.bin 0x8000 partitions_with_retro-go_16mb.bin 0x20000 "$outdir"/micropython.bin 0x820000 "$launcher" 0x930000 "$core" 0xa00000 "$prboom" 0xae0000 "$mydir"/../internal_filesystem.bin $@ +~/.espressif/python_env/*/bin/python -m esptool --chip esp32s3 merge_bin --fill-flash-size=16MB --output image_esp32s3.bin 0x0 "$outdir"/bootloader/bootloader.bin 0x8000 partitions_with_retro-go_16mb.bin 0x20000 "$outdir"/micropython.bin 0xae0000 "$mydir"/../internal_filesystem.bin $@ + +# Building an image based on an Arduino IDE build also works, although I only tried arduino-esp32 v2.x and not to v3.x +#outdir=~/.cache/arduino/sketches/4012C161135E5B60169BDFEA7F67E0C6 +#sketch=Test_Read_Flash.ino +#outdir=~/.cache/arduino/sketches/DF69B76A41013B091A4C9C10734C1710 +#sketch=Test_Download_File.ino +#~/.espressif/python_env/*/bin/python -m esptool --chip esp32s3 merge_bin --fill-flash-size=16MB --output image_esp32s3.bin 0x0 "$outdir"/"$sketch".bootloader.bin 0x8000 "$outdir"/"$sketch".partitions.bin 0x10000 "$outdir"/"$sketch".bin diff --git a/scripts/mklittlefs.sh b/scripts/mklittlefs.sh index 1f7be0c4..7f1bf890 100755 --- a/scripts/mklittlefs.sh +++ b/scripts/mklittlefs.sh @@ -3,6 +3,12 @@ mydir=$(readlink -f "$0") mydir=$(dirname "$mydir") -size=0x200000 # 2MB -~/sources/mklittlefs/mklittlefs -c "$mydir"/../internal_filesystem/ -s "$size" internal_filesystem.bin +#size=0x200000 # 2MB +#~/sources/mklittlefs/mklittlefs -c "$mydir"/../internal_filesystem/ -s "$size" internal_filesystem.bin + +#size=0x520000 +#~/sources/mklittlefs/mklittlefs -c "$mydir"/../../../internalsd_zips_removed_gb_romart -s "$size" internalsd_zips_removed_gb_romart.bin +size=0x520000 # 16MB filesystem +#size=0x460000 # 8MB filesystem +~/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 177cd29b..537737dd 100755 --- a/scripts/run_desktop.sh +++ b/scripts/run_desktop.sh @@ -1,9 +1,9 @@ -#!/bin/bash -scriptdir=$(readlink -f "$0") -scriptdir=$(dirname "$scriptdir") +#!/bin/bash + +scriptdir=$(cd "$(dirname "$0")" && pwd -P) script="$1" if [ -f "$script" ]; then - script=$(readlink -f "$script") + script="$(cd "$(dirname "$script")" && pwd -P)/$(basename "$script")" fi echo "Usage:" @@ -14,57 +14,47 @@ echo "$0 appname # starts the app by appname, for example: com.example.helloworl #export SDL_WINDOW_FULLSCREEN=true export HEAPSIZE=8M # default, same a PSRAM on many ESP32-S3 boards -#export HEAPSIZE=9M # 9MB is not enough for slides, 10MB is okay for 5, 16 for 10, 64 for 100s -#export HEAPSIZE=10M # 9MB is not enough for slides, 10MB is okay for 5, 16 for 10, 64 for 100s -#export HEAPSIZE=11M # 9MB is not enough for slides, 10MB is okay for 5, 16 for 10, 64 for 100s -#export HEAPSIZE=12M # 9MB is not enough for slides, 10MB is okay for 5, 16 for 10, 64 for 100s -#export HEAPSIZE=13M # 9MB is not enough for slides, 10MB is okay for 5, 16 for 10, 64 for 100s -#export HEAPSIZE=14M # 9MB is not enough for slides, 10MB is okay for 5, 16 for 10, 64 for 100s -#export HEAPSIZE=15M # 9MB is not enough for slides, 10MB is okay for 5, 15 ok for all - -# 15 works infinite with 8 images -# 12 seems to work fine with all images now, doing only gc.collect() -# 10-11 works infinite with 7 images but as soon as I add the next one (big PNG slide 2) it hangs memory alloc - -# Makes semse because the error is: -# MemoryError: memory allocation failed, allocating 2518043 bytes -# So every new slide needs 2.5MB extra RAM! - -# Fixed by adding lv.image.cache_drop(None) # This helps a lot! - -# Now it works with 10M with infinite slides! - -# Now not anymore... let's try increasing it. -#export HEAPSIZE=20M # this is fine for 1024x576 -#export HEAPSIZE=15M # fine too +#export HEAPSIZE=64M # fine for fullscreen 1280x720 slides -#export HEAPSIZE=32M # for 1280x720 images in the image viewer -#export HEAPSIZE=128M # for 1280x720 images in the image viewer - -# print os and set binary os_name=$(uname -s) if [ "$os_name" = "Darwin" ]; then echo "Running on macOS" binary="$scriptdir"/../lvgl_micropython/build/lvgl_micropy_macOS else - # other cases can be added here echo "Running on $os_name" binary="$scriptdir"/../lvgl_micropython/build/lvgl_micropy_unix fi - -binary=$(readlink -f "$binary") +binary="$(cd "$(dirname "$binary")" && pwd -P)/$(basename "$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 main.py) ; import mpos.apps; mpos.apps.start_app('$scriptdir')" +pushd "$scriptdir"/../internal_filesystem/ + +if [ -f "$script" ]; then + echo "Running script $script" + "$binary" -v -i "$script" +else + CONFIG_FILE="data/com.micropythonos.settings/config.json" + if [ -n "$script" ]; then + echo "run_desktop.sh: running app $script" + if [ -f "$CONFIG_FILE" ]; then + if grep -q '"auto_start_app"' "$CONFIG_FILE"; then + echo "Updating auto_start_app field using sed" + sed -i.backup -e 's/"auto_start_app": ".*"/"auto_start_app": "'$script'"/' "$CONFIG_FILE" + else + echo "Adding auto_start_app to config file" + sed -i.backup -E 's/[[:space:]]*}[[:space:]]*$/,"auto_start_app": "'$script'"}/' "$CONFIG_FILE" + fi + else + mkdir -p "$(dirname "$CONFIG_FILE")" + echo '{"auto_start_app": "'$script'"}' > "$CONFIG_FILE" + fi else - "$binary" -X heapsize=$HEAPSIZE -v -i -c "$(cat main.py)" + if [ -f "$CONFIG_FILE" ]; then + echo "Removing auto_start_app from config file" + sed -i.backup -E 's/[[:space:]]*,?[[:space:]]*"auto_start_app"[[:space:]]*:[[:space:]]*"[^"]*"[[:space:]]*//g; s/\{[[:space:]]*,/\{/g; s/,[[:space:]]*\}/\}/g' "$CONFIG_FILE" + fi fi - + "$binary" -X heapsize=$HEAPSIZE -v -i -m main # internal_filesystem/main.py is frozen in and can't be changed at runtime +fi popd diff --git a/secp256k1-embedded-ecdh b/secp256k1-embedded-ecdh index 956c014d..f86eb16a 160000 --- a/secp256k1-embedded-ecdh +++ b/secp256k1-embedded-ecdh @@ -1 +1 @@ -Subproject commit 956c014d44a3efaa0fcceeb91a7ea1f93df7a012 +Subproject commit f86eb16aae68bc2656cfdfa4b6d6c87a4524afb7 diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..dcb344b9 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,300 @@ +# MicroPythonOS Testing Guide + +This directory contains the test suite for MicroPythonOS. Tests can run on both desktop (for fast iteration) and on-device (for hardware verification). + +## Quick Start + +```bash +# Run all tests +./tests/unittest.sh + +# Run a specific test +./tests/unittest.sh tests/test_graphical_keyboard_q_button_bug.py + +# Run on device +./tests/unittest.sh tests/test_graphical_keyboard_q_button_bug.py --ondevice +``` + +## Test Architecture + +### Directory Structure + +``` +tests/ +├── base/ # Base test classes (DRY patterns) +│ ├── __init__.py # Exports GraphicalTestBase, KeyboardTestBase +│ ├── graphical_test_base.py +│ └── keyboard_test_base.py +├── screenshots/ # Captured screenshots for visual regression +├── test_*.py # Test files +├── unittest.sh # Test runner script +└── README.md # This file +``` + +### Testing Modules + +MicroPythonOS provides two testing modules: + +1. **`mpos.testing`** - Hardware and system mocks + - Location: `internal_filesystem/lib/mpos/testing/` + - Use for: Mocking hardware (Pin, PWM, I2S, NeoPixel), network, async operations + +2. **`mpos.ui.testing`** - LVGL/UI testing utilities + - Location: `internal_filesystem/lib/mpos/ui/testing.py` + - Use for: UI interaction, screenshots, widget inspection + +## Base Test Classes + +### GraphicalTestBase + +Base class for all graphical (LVGL) tests. Provides: +- Automatic screen creation/cleanup +- Screenshot capture +- Widget finding utilities +- Custom assertions + +```python +from base import GraphicalTestBase + +class TestMyUI(GraphicalTestBase): + def test_something(self): + # self.screen is already created + label = lv.label(self.screen) + label.set_text("Hello") + + self.wait_for_render() + self.assertTextPresent("Hello") + self.capture_screenshot("my_test.raw") +``` + +**Key Methods:** +- `wait_for_render(iterations=5)` - Process LVGL tasks +- `capture_screenshot(filename)` - Save screenshot +- `find_label_with_text(text)` - Find label widget +- `click_button(button)` - Simulate button click +- `assertTextPresent(text)` - Assert text is on screen +- `assertWidgetVisible(widget)` - Assert widget is visible + +### KeyboardTestBase + +Extends GraphicalTestBase for keyboard tests. Provides: +- Keyboard and textarea creation +- Reliable keyboard button clicking +- Textarea assertions + +```python +from base import KeyboardTestBase + +class TestMyKeyboard(KeyboardTestBase): + def test_typing(self): + self.create_keyboard_scene() + + self.click_keyboard_button("h") + self.click_keyboard_button("i") + + self.assertTextareaText("hi") +``` + +**Key Methods:** +- `create_keyboard_scene()` - Create textarea + MposKeyboard +- `click_keyboard_button(text)` - Click keyboard button reliably +- `type_text(text)` - Type a string +- `get_textarea_text()` - Get textarea content +- `clear_textarea()` - Clear textarea +- `assertTextareaText(expected)` - Assert textarea content +- `assertTextareaEmpty()` - Assert textarea is empty + +## Mock Classes + +Import mocks from `mpos.testing`: + +```python +from mpos.testing import ( + # Hardware mocks + MockMachine, # Full machine module mock + MockPin, # GPIO pins + MockPWM, # PWM for buzzer + MockI2S, # Audio I2S + MockTimer, # Hardware timers + MockNeoPixel, # LED strips + MockSocket, # Network sockets + + # MPOS mocks + MockTaskManager, # Async task management + MockDownloadManager, # HTTP downloads + + # Network mocks + MockNetwork, # WiFi/network module + MockRequests, # HTTP requests + MockResponse, # HTTP responses + + # Utility mocks + MockTime, # Time functions + MockJSON, # JSON parsing + + # Helpers + inject_mocks, # Inject mocks into sys.modules + create_mock_module, # Create mock module +) +``` + +### Injecting Mocks + +```python +from mpos.testing import inject_mocks, MockMachine, MockNetwork + +# Inject before importing modules that use hardware +inject_mocks({ + 'machine': MockMachine(), + 'network': MockNetwork(connected=True), +}) + +# Now import the module under test +from mpos.hardware import some_module +``` + +### Mock Examples + +**MockNeoPixel:** +```python +from mpos.testing import MockNeoPixel, MockPin + +pin = MockPin(5) +leds = MockNeoPixel(pin, 10) + +leds[0] = (255, 0, 0) # Set first LED to red +leds.write() + +assert leds.write_count == 1 +assert leds[0] == (255, 0, 0) +``` + +**MockRequests:** +```python +from mpos.testing import MockRequests + +mock_requests = MockRequests() +mock_requests.set_next_response( + status_code=200, + text='{"status": "ok"}', + headers={'Content-Type': 'application/json'} +) + +response = mock_requests.get("https://api.example.com/data") +assert response.status_code == 200 +``` + +**MockTimer:** +```python +from mpos.testing import MockTimer + +timer = MockTimer(0) +timer.init(period=1000, mode=MockTimer.PERIODIC, callback=my_callback) + +# Manually trigger for testing +timer.trigger() + +# Or trigger all timers +MockTimer.trigger_all() +``` + +## Test Naming Conventions + +- `test_*.py` - Standard unit tests +- `test_graphical_*.py` - Tests requiring LVGL/UI (detected by unittest.sh) +- `manual_test_*.py` - Manual tests (not run automatically) + +## Writing New Tests + +### Simple Unit Test + +```python +import unittest + +class TestMyFeature(unittest.TestCase): + def test_something(self): + result = my_function() + self.assertEqual(result, expected) +``` + +### Graphical Test + +```python +from base import GraphicalTestBase +import lvgl as lv + +class TestMyUI(GraphicalTestBase): + def test_button_click(self): + button = lv.button(self.screen) + label = lv.label(button) + label.set_text("Click Me") + + self.wait_for_render() + self.click_button(button) + + # Verify result +``` + +### Keyboard Test + +```python +from base import KeyboardTestBase + +class TestMyKeyboard(KeyboardTestBase): + def test_input(self): + self.create_keyboard_scene() + + self.type_text("hello") + self.assertTextareaText("hello") + + self.click_keyboard_button("Enter") +``` + +### Test with Mocks + +```python +import unittest +from mpos.testing import MockNetwork, inject_mocks + +class TestNetworkFeature(unittest.TestCase): + def setUp(self): + self.mock_network = MockNetwork(connected=True) + inject_mocks({'network': self.mock_network}) + + def test_connected(self): + from my_module import check_connection + self.assertTrue(check_connection()) + + def test_disconnected(self): + self.mock_network.set_connected(False) + from my_module import check_connection + self.assertFalse(check_connection()) +``` + +## Best Practices + +1. **Use base classes** - Extend `GraphicalTestBase` or `KeyboardTestBase` for UI tests +2. **Use mpos.testing mocks** - Don't create inline mocks; use the centralized ones +3. **Clean up in tearDown** - Base classes handle this, but custom tests should clean up +4. **Don't include `if __name__ == '__main__'`** - The test runner handles this +5. **Use descriptive test names** - `test_keyboard_q_button_works` not `test_1` +6. **Add docstrings** - Explain what the test verifies and why + +## Debugging Tests + +```bash +# Run with verbose output +./tests/unittest.sh tests/test_my_test.py + +# Run with GDB (desktop only) +gdb --args ./lvgl_micropython/build/lvgl_micropy_unix -X heapsize=8M tests/test_my_test.py +``` + +## Screenshots + +Screenshots are saved to `tests/screenshots/` in raw format. Convert to PNG: + +```bash +cd tests/screenshots +./convert_to_png.sh +``` diff --git a/tests/manual_test_camera.py b/tests/manual_test_camera.py index 70a2ec11..01fe0bc4 100644 --- a/tests/manual_test_camera.py +++ b/tests/manual_test_camera.py @@ -1,6 +1,6 @@ import unittest -from mpos import App, PackageManager +from mpos import App, AppManager from camera import Camera, GrabMode, PixelFormat, FrameSize, GainCeiling diff --git a/tests/manual_test_duplex_audio.py b/tests/manual_test_duplex_audio.py new file mode 100644 index 00000000..f1f2c707 --- /dev/null +++ b/tests/manual_test_duplex_audio.py @@ -0,0 +1,123 @@ +"""Minimal duplex I2S test for Fri3d 2024 with communicator. + +Creates TX + RX I2S instances simultaneously using merged pin config +from the fri3d_2024 board setup. Intended for quick validation only. + +To get this working, the I2S needs to be changed, see plan at https://github.com/orgs/micropython/discussions/12473 +""" + +import time + +try: + import machine + _HAS_MACHINE = True +except ImportError: + _HAS_MACHINE = False + + +# Merged pin map from internal_filesystem/lib/mpos/board/fri3d_2024.py +I2S_PINS = { + "ws": 47, # shared LRCLK + "sck": 2, # DAC bit clock + "sd": 16, # DAC data out + "sck_in": 17, # mic bit clock + "sd_in": 15, # mic data in +} + + +class DuplexI2STest: + """Minimal duplex setup: one TX I2S + one RX I2S running together.""" + + def __init__(self, sample_rate=16000, duration_ms=3000): + self.sample_rate = sample_rate + self.duration_ms = duration_ms + self._tx = None + self._rx = None + + def _init_write(self): + self._tx = machine.I2S( + 0, + sck=machine.Pin(I2S_PINS["sck"], machine.Pin.OUT), + ws=machine.Pin(I2S_PINS["ws"], machine.Pin.OUT), + sd=machine.Pin(I2S_PINS["sd"], machine.Pin.OUT), + mode=machine.I2S.TX, + bits=16, + format=machine.I2S.MONO, + rate=self.sample_rate, + ibuf=16000, + ) + + def _init_read(self): + self._rx = machine.I2S( + 1, + sck=machine.Pin(I2S_PINS["sck_in"], machine.Pin.OUT), + ws=machine.Pin(I2S_PINS["ws"], machine.Pin.OUT), + sd=machine.Pin(I2S_PINS["sd_in"], machine.Pin.IN), + mode=machine.I2S.RX, + bits=16, + format=machine.I2S.MONO, + rate=self.sample_rate, + ibuf=16000, + ) + + def _init_i2s(self): + if not _HAS_MACHINE: + raise RuntimeError("machine.I2S not available") + + self._init_read() + self._init_write() + + def _deinit_i2s(self): + if self._tx: + self._tx.deinit() + self._tx = None + if self._rx: + self._rx.deinit() + self._rx = None + + def run(self): + """Run a short duplex session: play a tone while reading mic data.""" + self._init_i2s() + try: + tone = self._make_tone_buffer(freq_hz=440, ms=50) + read_buf = bytearray(1024) + recorded = bytearray() + t_end = time.ticks_add(time.ticks_ms(), self.duration_ms) + + while time.ticks_diff(t_end, time.ticks_ms()) > 0: + #self._tx.write(tone) # works but saturates the microphone + read_len = self._rx.readinto(read_buf) + if read_len: + recorded.extend(read_buf[:read_len]) + + print("waiting a bit") + time.sleep(1) + if recorded: + print("playing the recording") + playback = memoryview(recorded) + offset = 0 + while offset < len(playback): + if not self._tx: + self._init_write() + offset += self._tx.write(playback[offset:]) + finally: + self._deinit_i2s() + + def _make_tone_buffer(self, freq_hz=440, ms=50): + samples = int(self.sample_rate * (ms / 1000)) + buf = bytearray(samples * 2) + for i in range(samples): + phase = 2 * 3.14159265 * freq_hz * (i / self.sample_rate) + sample = int(12000 * __import__("math").sin(phase)) + buf[i * 2] = sample & 0xFF + buf[i * 2 + 1] = (sample >> 8) & 0xFF + return buf + + +def run_duplex_test(sample_rate=16000, duration_ms=3000): + """Convenience entry point for quick manual tests.""" + DuplexI2STest(sample_rate=sample_rate, duration_ms=duration_ms).run() + + +if __name__ == "__main__": + run_duplex_test() diff --git a/tests/manual_test_nostr_asyncio.py b/tests/manual_test_nostr_asyncio.py index 7962afa5..4ef5f86f 100644 --- a/tests/manual_test_nostr_asyncio.py +++ b/tests/manual_test_nostr_asyncio.py @@ -5,8 +5,7 @@ import time import unittest -from mpos import App, PackageManager -import mpos.apps +from mpos import App, AppManager from nostr.relay_manager import RelayManager from nostr.message_type import ClientMessageType diff --git a/tests/mocks.py b/tests/mocks.py new file mode 100644 index 00000000..1965d729 --- /dev/null +++ b/tests/mocks.py @@ -0,0 +1,350 @@ +class MockSharedPreferences: + """Mock SharedPreferences for testing.""" + + _all_data = {} # Class-level storage + + def __init__(self, app_id, filename=None): + self.app_id = app_id + self.filename = filename + if app_id not in MockSharedPreferences._all_data: + MockSharedPreferences._all_data[app_id] = {} + + def _get_value(self, key, default): + return MockSharedPreferences._all_data.get(self.app_id, {}).get(key, default) + + def get_dict(self, key): + return self._get_value(key, {}) + + def get_list(self, key, default=None): + return self._get_value(key, default) + + def get_bool(self, key, default=False): + value = self._get_value(key, default) + return bool(value) + + def get_string(self, key, default=""): + value = self._get_value(key, default) + return value if value is not None else default + + def get_int(self, key, default=0): + value = self._get_value(key, default) + try: + return int(value) + except (TypeError, ValueError): + return default + + 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 put_list(self, key, value): + self.pending[key] = value + + def put_bool(self, key, value): + self.pending[key] = bool(value) + + def put_string(self, key, value): + self.pending[key] = value + + def put_int(self, key, value): + self.pending[key] = int(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) + + +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 + + +class HotspotMockNetwork: + """Mock network module with AP/STA support for hotspot tests.""" + + STA_IF = 0 + AP_IF = 1 + + AUTH_OPEN = 0 + AUTH_WPA_PSK = 1 + AUTH_WPA2_PSK = 2 + AUTH_WPA_WPA2_PSK = 3 + + class MockWLAN: + def __init__(self, interface): + self.interface = interface + self._active = False + self._connected = False + self._config = {} + self._scan_results = [] + self._ifconfig = ("0.0.0.0", "0.0.0.0", "0.0.0.0", "0.0.0.0") + + def active(self, is_active=None): + if is_active is None: + return self._active + self._active = is_active + return None + + def isconnected(self): + return self._connected + + def connect(self, ssid, password): + self._connected = True + self._config["essid"] = ssid + + def disconnect(self): + self._connected = False + + def config(self, *args, **kwargs): + if kwargs: + self._config.update(kwargs) + return None + if args: + return self._config.get(args[0]) + return self._config + + def ifconfig(self, cfg=None): + if cfg is None: + return self._ifconfig + self._ifconfig = cfg + return None + + def ipconfig(self, key=None): + config = self.ifconfig() + mapping = { + "addr4": config[0], + "netmask4": config[1], + "gw4": config[2], + "dns4": config[3], + } + if key is None: + return mapping + return mapping.get(key) + + def scan(self): + return self._scan_results + + def __init__(self): + self._wlan_instances = {} + + def WLAN(self, interface): + if interface not in self._wlan_instances: + self._wlan_instances[interface] = self.MockWLAN(interface) + return self._wlan_instances[interface] + + +class MockADC: + """Mock ADC for testing.""" + + ATTN_11DB = 3 + + def __init__(self, pin): + self.pin = pin + self._atten = None + self._read_value = 2048 + + 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 MockMachineADC: + """Mock machine module with ADC/Pin.""" + + 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 + + @classmethod + def reset(cls): + """Test helper to reset state.""" + cls.wifi_busy = False + cls._connected = False + cls._temporarily_disabled = False + + +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 = {} + + 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 + + @property + def acceleration(self): + """Return mock acceleration (in G).""" + return (0.0, 0.0, 1.0) + + @property + def gyro(self): + """Return mock gyroscope (in deg/s).""" + return (0.0, 0.0, 0.0) + + +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 + self.gyro_sensitivity = 17.5 + 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) + + 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 + + +def make_machine_i2c_module(i2c_cls, pin_cls=None): + if pin_cls is None: + pin_cls = type("Pin", (), {}) + return type("module", (), {"I2C": i2c_cls, "Pin": pin_cls})() + + +def make_machine_timer_module(timer_cls): + return type("module", (), {"Timer": timer_cls})() + + +def make_usocket_module(socket_cls): + class MockUsocket: + """Mock usocket module.""" + + AF_INET = socket_cls.AF_INET + SOCK_STREAM = socket_cls.SOCK_STREAM + + @staticmethod + def socket(af, sock_type): + return socket_cls(af, sock_type) + + return MockUsocket + + +def make_config_module(shared_prefs_cls): + return type("module", (), {"SharedPreferences": shared_prefs_cls})() diff --git a/tests/mocks/hardware_mocks.py b/tests/mocks/hardware_mocks.py deleted file mode 100644 index b2d2e97e..00000000 --- a/tests/mocks/hardware_mocks.py +++ /dev/null @@ -1,102 +0,0 @@ -# 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 index c811c1fe..1a6d235b 100644 --- a/tests/network_test_helper.py +++ b/tests/network_test_helper.py @@ -2,592 +2,50 @@ Network testing helper module for MicroPythonOS. This module provides mock implementations of network-related modules -for testing without requiring actual network connectivity. These mocks -are designed to be used with dependency injection in the classes being tested. +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 - - # Create mocks - mock_network = MockNetwork(connected=True) - mock_requests = MockRequests() - - # Configure mock responses - mock_requests.set_next_response(status_code=200, text='{"key": "value"}') - - # Pass to class being tested - obj = MyClass(network_module=mock_network, requests_module=mock_requests) - - # Test behavior - result = obj.fetch_data() - assert mock_requests.last_url == "http://expected.url" + + # Or use the centralized module directly: + from mpos.testing import MockNetwork, MockRequests, MockTimer """ -import time - - -class MockNetwork: - """ - Mock network module for testing network connectivity. - - Simulates the MicroPython 'network' module with WLAN interface. - """ - - STA_IF = 0 # Station interface constant - AP_IF = 1 # Access Point interface constant - - 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 = [] # Can be configured for testing - - 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): - """ - Initialize mock network module. - - Args: - connected: Initial connection state (default: True) - """ - self._connected = connected - self._wlan_instances = {} - - def WLAN(self, interface): - """ - Create or return a WLAN interface. - - Args: - interface: Interface type (STA_IF or AP_IF) - - Returns: - MockWLAN instance - """ - 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. - - Args: - connected: New connection state - """ - self._connected = connected - for wlan in self._wlan_instances.values(): - wlan._connected = connected - - -class MockRaw: - """ - Mock raw HTTP response for streaming. - - Simulates the 'raw' attribute of requests.Response for chunked reading. - """ - - def __init__(self, content, fail_after_bytes=None): - """ - Initialize mock raw response. - - Args: - content: Binary content to stream - fail_after_bytes: If set, raise OSError(-113) after reading this many bytes - """ - self.content = content - self.position = 0 - self.fail_after_bytes = fail_after_bytes - - def read(self, size): - """ - Read a chunk of data. - - Args: - size: Number of bytes to read - - Returns: - bytes: Chunk of data (may be smaller than size at end of stream) - - Raises: - OSError: If fail_after_bytes is set and reached - """ - # Check if we should simulate network failure - 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. - - Simulates requests.Response object with status code, text, headers, etc. - """ - - def __init__(self, status_code=200, text='', headers=None, content=b'', fail_after_bytes=None): - """ - Initialize mock response. - - Args: - status_code: HTTP status code (default: 200) - text: Response text content (default: '') - headers: Response headers dict (default: {}) - content: Binary response content (default: b'') - fail_after_bytes: If set, raise OSError after reading this many bytes - """ - self.status_code = status_code - self.text = text - self.headers = headers or {} - self.content = content - self._closed = False - - # Mock raw attribute for streaming - 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. - - Provides configurable mock responses and exception injection for testing - HTTP client code without making actual network requests. - """ - - def __init__(self): - """Initialize mock requests module.""" - self.last_url = None - self.last_headers = None - self.last_timeout = None - self.last_stream = None - self.last_request = None # Full request info dict - self.next_response = None - self.raise_exception = None - self.call_history = [] - - def get(self, url, stream=False, timeout=None, headers=None): - """ - Mock GET request. - - Args: - url: URL to fetch - stream: Whether to stream the response - timeout: Request timeout in seconds - headers: Request headers dict - - Returns: - MockResponse object - - Raises: - Exception: If an exception was configured via set_exception() - """ - self.last_url = url - self.last_headers = headers - self.last_timeout = timeout - self.last_stream = stream - - # Store full request info - self.last_request = { - 'method': 'GET', - 'url': url, - 'stream': stream, - 'timeout': timeout, - 'headers': headers or {} - } - - # Record call in history - self.call_history.append(self.last_request.copy()) - - if self.raise_exception: - exc = self.raise_exception - self.raise_exception = None # Clear after raising - raise exc - - if self.next_response: - response = self.next_response - self.next_response = None # Clear after returning - return response - - # Default response - return MockResponse() - - def post(self, url, data=None, json=None, timeout=None, headers=None): - """ - Mock POST request. - - Args: - url: URL to post to - data: Form data to send - json: JSON data to send - timeout: Request timeout in seconds - headers: Request headers dict - - Returns: - MockResponse object - - Raises: - Exception: If an exception was configured via set_exception() - """ - self.last_url = url - self.last_headers = headers - self.last_timeout = timeout - - # Record call in history - 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. - - Args: - status_code: HTTP status code (default: 200) - text: Response text (default: '') - headers: Response headers dict (default: {}) - content: Binary response content (default: b'') - fail_after_bytes: If set, raise OSError after reading this many bytes - - Returns: - MockResponse: The configured response object - """ - 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. - - Args: - exception: Exception instance to raise - """ - self.raise_exception = exception - - def clear_history(self): - """Clear the call history.""" - self.call_history = [] - - -class MockJSON: - """ - Mock JSON module for testing JSON parsing. - - Allows injection of parse errors for testing error handling. - """ - - def __init__(self): - """Initialize mock JSON module.""" - self.raise_exception = None - - def loads(self, text): - """ - Parse JSON string. - - Args: - text: JSON string to parse - - Returns: - Parsed JSON object - - Raises: - Exception: If an exception was configured via set_exception() - """ - if self.raise_exception: - exc = self.raise_exception - self.raise_exception = None - raise exc - - # Use Python's real json module for actual parsing - import json - return json.loads(text) - - def dumps(self, obj): - """ - Serialize object to JSON string. - - Args: - obj: Object to serialize - - Returns: - str: JSON string - """ - import json - return json.dumps(obj) - - def set_exception(self, exception): - """ - Configure an exception to raise on the next loads() call. - - Args: - exception: Exception instance to raise - """ - self.raise_exception = exception - - -class MockTimer: - """ - Mock Timer for testing periodic callbacks. - - Simulates machine.Timer without actual delays. Useful for testing - code that uses timers for periodic tasks. - """ - - # Class-level registry of all timers - _all_timers = {} - _next_timer_id = 0 - - PERIODIC = 1 - ONE_SHOT = 0 - - def __init__(self, timer_id): - """ - Initialize mock timer. - - Args: - timer_id: Timer ID (0-3 on most MicroPython platforms) - """ - self.timer_id = timer_id - self.callback = None - self.period = None - self.mode = None - self.active = False - MockTimer._all_timers[timer_id] = self - - def init(self, period=None, mode=None, callback=None): - """ - Initialize/configure the timer. - - Args: - period: Timer period in milliseconds - mode: Timer mode (PERIODIC or ONE_SHOT) - callback: Callback function to call on timer fire - """ - 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). - - Args: - *args: Arguments to pass to callback - **kwargs: Keyword arguments to pass to callback - """ - if self.callback and self.active: - self.callback(*args, **kwargs) - - @classmethod - def get_timer(cls, timer_id): - """ - Get a timer by ID. - - Args: - timer_id: Timer ID to retrieve - - Returns: - MockTimer instance or None if not found - """ - 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 MockSocket: - """ - Mock socket for testing socket operations. - - Simulates usocket module without actual network I/O. - """ - - AF_INET = 2 - SOCK_STREAM = 1 - - def __init__(self, af=None, sock_type=None): - """ - Initialize mock socket. - - Args: - af: Address family (AF_INET, etc.) - sock_type: Socket type (SOCK_STREAM, etc.) - """ - self.af = af - self.sock_type = sock_type - self.connected = False - self.bound = False - self.listening = False - self.address = None - self.port = None - self._send_exception = None - self._recv_data = b'' - self._recv_position = 0 - - def connect(self, address): - """ - Simulate connecting to an address. - - Args: - address: Tuple of (host, port) - """ - self.connected = True - self.address = address - - def bind(self, address): - """ - Simulate binding to an address. - - Args: - address: Tuple of (host, port) - """ - self.bound = True - self.address = address - - def listen(self, backlog): - """ - Simulate listening for connections. - - Args: - backlog: Maximum number of queued connections - """ - self.listening = True - - def send(self, data): - """ - Simulate sending data. - - Args: - data: Bytes to send - - Returns: - int: Number of bytes sent - - Raises: - Exception: If configured via set_send_exception() - """ - if self._send_exception: - exc = self._send_exception - self._send_exception = None - raise exc - return len(data) - - def recv(self, size): - """ - Simulate receiving data. - - Args: - size: Maximum bytes to receive - - Returns: - bytes: Received 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(). - - Args: - exception: Exception instance to raise - """ - self._send_exception = exception - - def set_recv_data(self, data): - """ - Configure data to return from recv(). - - Args: - data: Bytes to return from recv() calls - """ - self._recv_data = data - self._recv_position = 0 - - +# 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. @@ -602,81 +60,33 @@ def socket(af=MockSocket.AF_INET, sock_type=MockSocket.SOCK_STREAM): return MockSocket(af, sock_type) -class MockTime: - """ - Mock time module for testing time-dependent code. - - Allows manual control of time progression for deterministic testing. - """ - - def __init__(self, start_time=0): - """ - Initialize mock time module. - - Args: - start_time: Initial time in milliseconds (default: 0) - """ - self._current_time_ms = start_time - self._sleep_calls = [] - - def ticks_ms(self): - """ - Get current time in milliseconds. - - Returns: - int: Current time in milliseconds - """ - return self._current_time_ms - - def ticks_diff(self, ticks1, ticks2): - """ - Calculate difference between two tick values. - - Args: - ticks1: End time - ticks2: Start time - - Returns: - int: Difference in milliseconds - """ - return ticks1 - ticks2 - - def sleep(self, seconds): - """ - Simulate sleep (doesn't actually sleep). - - Args: - seconds: Number of seconds to sleep - """ - self._sleep_calls.append(seconds) - - def sleep_ms(self, milliseconds): - """ - Simulate sleep in milliseconds. - - Args: - milliseconds: Number of milliseconds to sleep - """ - self._sleep_calls.append(milliseconds / 1000.0) - - def advance(self, milliseconds): - """ - Advance the mock time. - - Args: - milliseconds: Number of milliseconds to advance - """ - self._current_time_ms += milliseconds - - def get_sleep_calls(self): - """ - Get history of sleep calls. - - Returns: - list: List of sleep durations in seconds - """ - return self._sleep_calls - - def clear_sleep_calls(self): - """Clear the sleep call history.""" - self._sleep_calls = [] +__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/test_adc_recording.py b/tests/test_adc_recording.py new file mode 100644 index 00000000..ea95823f --- /dev/null +++ b/tests/test_adc_recording.py @@ -0,0 +1,88 @@ +# Test ADC Recording Integration +# Tests the new ADCRecordStream with adaptive frequency control +# Run with: ./MicroPythonOS/tests/unittest.sh MicroPythonOS/tests/test_adc_recording.py + +import unittest +import time +import os +import sys + +# Add lib path for imports +# In MicroPython, os.path doesn't exist, so we construct the path manually +# This assumes the test is run from the project root or via unittest.sh +sys.path.append('MicroPythonOS/internal_filesystem/lib') + +from mpos import AudioManager + +class TestADCRecording(unittest.TestCase): + """Test ADC recording functionality.""" + + def setUp(self): + """Set up test fixtures.""" + self.test_file = "test_recording.wav" + + # Ensure AudioManager is initialized (mocking pins if needed) + # On desktop, it will use simulation mode + if not AudioManager._instance: + # Initialize with dummy values if needed, but adc_mic_pin is supported + AudioManager(adc_mic_pin=1) + + def tearDown(self): + """Clean up test files.""" + try: + os.remove(self.test_file) + except: + pass + + def test_record_wav_adc(self): + """Test recording a short WAV file using ADC.""" + + # Record for 200ms + duration_ms = 200 + sample_rate = 16000 + + print(f"Starting recording for {duration_ms}ms...") + + # Start recording + # Note: On desktop this will use the simulation mode in ADCRecordStream + success = AudioManager.record_wav_adc( + self.test_file, + duration_ms=duration_ms, + sample_rate=sample_rate + ) + + self.assertTrue(success, "AudioManager.record_wav_adc returned False") + + # Wait for recording to finish (plus a buffer for thread startup/shutdown) + # Simulation mode might be slower or faster depending on system load + time.sleep(duration_ms / 1000.0 + 1.0) + + # Verify file exists + try: + st = os.stat(self.test_file) + file_size = st[6] + file_exists = True + except OSError: + file_exists = False + file_size = 0 + + self.assertTrue(file_exists, f"Recording file {self.test_file} was not created") + + # Verify file size is reasonable + # Header is 44 bytes + # 200ms at 16000Hz, 16-bit mono = 0.2 * 16000 * 2 = 6400 bytes + # Total should be around 6444 bytes + + expected_data_size = int(duration_ms / 1000.0 * sample_rate * 2) + expected_total_size = 44 + expected_data_size + + print(f"Created WAV file size: {file_size} bytes (Expected approx: {expected_total_size})") + + self.assertTrue(file_size > 44, "File contains only header or is empty") + + # Allow some margin of error for timing differences in test environment + # But it should have recorded *something* significant + self.assertTrue(file_size > 1000, f"File size {file_size} seems too small (expected ~{expected_total_size})") + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_audioflinger.py b/tests/test_audioflinger.py deleted file mode 100644 index 039d6b1d..00000000 --- a/tests/test_audioflinger.py +++ /dev/null @@ -1,243 +0,0 @@ -# Unit tests for AudioFlinger service -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 - - def freq(self, value=None): - if value is not None: - self.last_freq = value - return self.last_freq - - def duty_u16(self, value=None): - if value is not None: - self.last_duty = value - return self.last_duty - - -class MockPin: - IN = 0 - OUT = 1 - - def __init__(self, pin_number, mode=None): - self.pin_number = pin_number - self.mode = mode - - -# Inject mocks -class MockMachine: - PWM = MockPWM - Pin = MockPin -sys.modules['machine'] = MockMachine() - -class MockLock: - def acquire(self): - pass - def release(self): - pass - -class MockThread: - @staticmethod - def allocate_lock(): - return MockLock() - @staticmethod - def start_new_thread(func, args, **kwargs): - pass # No-op for testing - @staticmethod - def stack_size(size=None): - return 16384 if size is None else None - -sys.modules['_thread'] = MockThread() - -class MockMposApps: - @staticmethod - def good_stack_size(): - return 16384 - -sys.modules['mpos.apps'] = MockMposApps() - - -# 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( - device_type=AudioFlinger.DEVICE_BOTH, - 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.get_device_type(), AudioFlinger.DEVICE_BOTH) - self.assertEqual(AudioFlinger._i2s_pins, self.i2s_pins) - self.assertEqual(AudioFlinger._buzzer_instance, self.buzzer) - - def test_device_types(self): - """Test device type constants.""" - self.assertEqual(AudioFlinger.DEVICE_NULL, 0) - self.assertEqual(AudioFlinger.DEVICE_I2S, 1) - self.assertEqual(AudioFlinger.DEVICE_BUZZER, 2) - self.assertEqual(AudioFlinger.DEVICE_BOTH, 3) - - 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_device_null_rejects_playback(self): - """Test that DEVICE_NULL rejects all playback requests.""" - # Re-initialize with no device - AudioFlinger.init( - device_type=AudioFlinger.DEVICE_NULL, - i2s_pins=None, - buzzer_instance=None - ) - - # WAV should be rejected - result = AudioFlinger.play_wav("test.wav") - self.assertFalse(result) - - # RTTTL should be rejected - result = AudioFlinger.play_rtttl("Test:d=4,o=5,b=120:c") - self.assertFalse(result) - - def test_device_i2s_only_rejects_rtttl(self): - """Test that DEVICE_I2S rejects buzzer playback.""" - # Re-initialize with I2S only - AudioFlinger.init( - device_type=AudioFlinger.DEVICE_I2S, - 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_device_buzzer_only_rejects_wav(self): - """Test that DEVICE_BUZZER rejects I2S playback.""" - # Re-initialize with buzzer only - AudioFlinger.init( - device_type=AudioFlinger.DEVICE_BUZZER, - i2s_pins=None, - buzzer_instance=self.buzzer - ) - - # WAV should be rejected (no I2S) - result = AudioFlinger.play_wav("test.wav") - self.assertFalse(result) - - def test_missing_i2s_pins_rejects_wav(self): - """Test that missing I2S pins rejects WAV playback.""" - # Re-initialize with I2S device but no pins - AudioFlinger.init( - device_type=AudioFlinger.DEVICE_I2S, - i2s_pins=None, - buzzer_instance=None - ) - - 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_get_device_type(self): - """Test that get_device_type() returns correct value.""" - # Test DEVICE_BOTH - AudioFlinger.init( - device_type=AudioFlinger.DEVICE_BOTH, - i2s_pins=self.i2s_pins, - buzzer_instance=self.buzzer - ) - self.assertEqual(AudioFlinger.get_device_type(), AudioFlinger.DEVICE_BOTH) - - # Test DEVICE_I2S - AudioFlinger.init( - device_type=AudioFlinger.DEVICE_I2S, - i2s_pins=self.i2s_pins, - buzzer_instance=None - ) - self.assertEqual(AudioFlinger.get_device_type(), AudioFlinger.DEVICE_I2S) - - # Test DEVICE_BUZZER - AudioFlinger.init( - device_type=AudioFlinger.DEVICE_BUZZER, - i2s_pins=None, - buzzer_instance=self.buzzer - ) - self.assertEqual(AudioFlinger.get_device_type(), AudioFlinger.DEVICE_BUZZER) - - # Test DEVICE_NULL - AudioFlinger.init( - device_type=AudioFlinger.DEVICE_NULL, - i2s_pins=None, - buzzer_instance=None - ) - self.assertEqual(AudioFlinger.get_device_type(), AudioFlinger.DEVICE_NULL) - - 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_init_creates_lock(self): - """Test that initialization creates a stream lock.""" - self.assertIsNotNone(AudioFlinger._stream_lock) - - def test_volume_default_value(self): - """Test that default volume is reasonable.""" - # After init, volume should be at default (70) - AudioFlinger.init( - device_type=AudioFlinger.DEVICE_NULL, - i2s_pins=None, - buzzer_instance=None - ) - self.assertEqual(AudioFlinger.get_volume(), 70) diff --git a/tests/test_audiomanager.py b/tests/test_audiomanager.py new file mode 100644 index 00000000..43c664fb --- /dev/null +++ b/tests/test_audiomanager.py @@ -0,0 +1,195 @@ +# Unit tests for AudioManager service +import unittest +import sys + +# Import centralized mocks +from mpos.testing import ( + MockMachine, + MockThread, + inject_mocks, +) + +# Inject mocks before importing AudioManager +inject_mocks({ + 'machine': MockMachine(), + '_thread': MockThread, +}) + +# Now import the module to test +from mpos.audio.audiomanager import AudioManager + + +class TestAudioManager(unittest.TestCase): + """Test cases for AudioManager service.""" + + def setUp(self): + """Initialize AudioManager before each test.""" + self.buzzer_pin = 46 + self.i2s_pins = {'sck': 2, 'ws': 47, 'sd': 16} + + # Reset singleton instance for each test + AudioManager._instance = None + + AudioManager() + AudioManager.add(AudioManager.Output("speaker", "i2s", i2s_pins=self.i2s_pins)) + AudioManager.add(AudioManager.Output("buzzer", "buzzer", buzzer_pin=self.buzzer_pin)) + + # Reset volume to default after creating instance + AudioManager.set_volume(70) + + def tearDown(self): + """Clean up after each test.""" + AudioManager.stop() + + def test_initialization(self): + """Test that AudioManager initializes correctly.""" + am = AudioManager.get() + self.assertEqual(len(am._outputs), 2) + self.assertEqual(am._outputs[0].i2s_pins, self.i2s_pins) + self.assertEqual(am._outputs[1].buzzer_pin, self.buzzer_pin) + + def test_get_outputs(self): + """Test that get_outputs() returns configured outputs.""" + outputs = AudioManager.get_outputs() + self.assertEqual(len(outputs), 2) + self.assertEqual(outputs[0].kind, "i2s") + self.assertEqual(outputs[1].kind, "buzzer") + + def test_default_output(self): + """Test default output selection.""" + default_output = AudioManager.get_default_output() + self.assertIsNotNone(default_output) + self.assertEqual(default_output.kind, "i2s") + + def test_stream_types(self): + """Test stream type constants and priority order.""" + self.assertEqual(AudioManager.STREAM_MUSIC, 0) + self.assertEqual(AudioManager.STREAM_NOTIFICATION, 1) + self.assertEqual(AudioManager.STREAM_ALARM, 2) + + # Higher number = higher priority + self.assertTrue(AudioManager.STREAM_MUSIC < AudioManager.STREAM_NOTIFICATION) + self.assertTrue(AudioManager.STREAM_NOTIFICATION < AudioManager.STREAM_ALARM) + + def test_volume_control(self): + """Test volume get/set operations.""" + # Set volume + AudioManager.set_volume(50) + self.assertEqual(AudioManager.get_volume(), 50) + + # Test clamping to 0-100 range + AudioManager.set_volume(150) + self.assertEqual(AudioManager.get_volume(), 100) + + AudioManager.set_volume(-10) + self.assertEqual(AudioManager.get_volume(), 0) + + def test_no_hardware_rejects_playback(self): + """Test that no hardware rejects all playback requests.""" + # Re-initialize with no hardware + AudioManager._instance = None + AudioManager() + + with self.assertRaises(ValueError): + AudioManager.player(file_path="test.wav").start() + + with self.assertRaises(ValueError): + AudioManager.player(rtttl="Test:d=4,o=5,b=120:c").start() + + def test_i2s_only_rejects_rtttl(self): + """Test that I2S-only config rejects buzzer playback.""" + # Re-initialize with I2S only + AudioManager._instance = None + AudioManager() + AudioManager.add(AudioManager.Output("speaker", "i2s", i2s_pins=self.i2s_pins)) + + with self.assertRaises(ValueError): + AudioManager.player(rtttl="Test:d=4,o=5,b=120:c").start() + + def test_buzzer_only_rejects_wav(self): + """Test that buzzer-only config rejects I2S playback.""" + # Re-initialize with buzzer only + AudioManager._instance = None + AudioManager() + AudioManager.add(AudioManager.Output("buzzer", "buzzer", buzzer_pin=self.buzzer_pin)) + + with self.assertRaises(ValueError): + AudioManager.player(file_path="test.wav").start() + + def test_is_playing_initially_false(self): + """Test that is_playing() returns False initially.""" + # Reset to ensure clean state + AudioManager._instance = None + AudioManager() + AudioManager.add(AudioManager.Output("speaker", "i2s", i2s_pins=self.i2s_pins)) + self.assertFalse(AudioManager.player(file_path="test.wav").is_playing()) + + def test_stop_with_no_playback(self): + """Test that stop() can be called when nothing is playing.""" + # Should not raise exception + AudioManager.stop() + + def test_volume_default_value(self): + """Test that default volume is reasonable.""" + # After init, volume should be at default (50) + AudioManager._instance = None + AudioManager() + self.assertEqual(AudioManager.get_volume(), 50) + + +class TestAudioManagerRecording(unittest.TestCase): + """Test cases for AudioManager recording functionality.""" + + def setUp(self): + """Initialize AudioManager with microphone before each test.""" + # I2S pins with microphone input + self.i2s_pins_with_mic = {'sck': 2, 'ws': 47, 'sd_in': 15} + + # Reset singleton instance for each test + AudioManager._instance = None + + AudioManager() + AudioManager.add(AudioManager.Input("mic", "i2s", i2s_pins=self.i2s_pins_with_mic)) + + # Reset volume to default after creating instance + AudioManager.set_volume(70) + + def tearDown(self): + """Clean up after each test.""" + AudioManager.stop() + + def test_get_inputs(self): + """Test get_inputs() returns configured inputs.""" + inputs = AudioManager.get_inputs() + self.assertEqual(len(inputs), 1) + self.assertEqual(inputs[0].kind, "i2s") + + def test_default_input(self): + """Test default input selection.""" + default_input = AudioManager.get_default_input() + self.assertIsNotNone(default_input) + self.assertEqual(default_input.kind, "i2s") + + def test_is_recording_initially_false(self): + """Test that is_recording() returns False initially.""" + recorder = AudioManager.recorder(file_path="test.wav") + self.assertFalse(recorder.is_recording()) + + def test_record_wav_no_microphone(self): + """Test that recorder() fails when no microphone is configured.""" + AudioManager._instance = None + AudioManager() + with self.assertRaises(ValueError): + AudioManager.recorder(file_path="test.wav").start() + + def test_record_wav_no_i2s(self): + AudioManager._instance = None + AudioManager() + AudioManager.add(AudioManager.Input("mic", "adc", adc_mic_pin=4)) + recorder = AudioManager.recorder(file_path="test.wav") + self.assertFalse(recorder.is_recording()) + + def test_stop_with_no_recording(self): + """Test that stop() can be called when nothing is recording.""" + # Should not raise exception + AudioManager.stop() diff --git a/tests/test_battery_voltage.py b/tests/test_battery_voltage.py index 3f3336af..17d87d60 100644 --- a/tests/test_battery_voltage.py +++ b/tests/test_battery_voltage.py @@ -1,5 +1,5 @@ """ -Unit tests for mpos.battery_voltage module. +Unit tests for mpos.battery_manager.BatteryManager class. Tests ADC1/ADC2 detection, caching, WiFi coordination, and voltage calculations. """ @@ -7,89 +7,20 @@ 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 +# Allow importing shared test mocks +sys.path.insert(0, "../tests") + +from mocks import MockADC, MockMachineADC, MockWifiService +# Add parent directory to path for imports +sys.path.insert(0, "../internal_filesystem") # Inject mocks -sys.modules['machine'] = MockMachine -sys.modules['mpos.net.wifi_service'] = type('module', (), {'WifiService': MockWifiService})() +sys.modules["machine"] = MockMachineADC +sys.modules["mpos.net.wifi_service"] = type("module", (), {"WifiService": MockWifiService})() -# Now import battery_voltage -import mpos.battery_voltage as bv +# Now import BatteryManager +from mpos.battery_manager import BatteryManager class TestADC2Detection(unittest.TestCase): @@ -97,20 +28,23 @@ class TestADC2Detection(unittest.TestCase): def test_adc1_pins_detected(self): """Test that ADC1 pins (GPIO1-10) are detected correctly.""" + from mpos.battery_manager import _is_adc2_pin for pin in range(1, 11): - self.assertFalse(bv._is_adc2_pin(pin), f"GPIO{pin} should be ADC1") + self.assertFalse(_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.""" + from mpos.battery_manager import _is_adc2_pin for pin in range(11, 21): - self.assertTrue(bv._is_adc2_pin(pin), f"GPIO{pin} should be ADC2") + self.assertTrue(_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)) + from mpos.battery_manager import _is_adc2_pin + self.assertFalse(_is_adc2_pin(0)) + self.assertFalse(_is_adc2_pin(21)) + self.assertFalse(_is_adc2_pin(30)) + self.assertFalse(_is_adc2_pin(100)) class TestInitADC(unittest.TestCase): @@ -118,40 +52,44 @@ class TestInitADC(unittest.TestCase): def setUp(self): """Reset module state.""" - bv.adc = None - bv.conversion_func = None - bv.adc_pin = None + import mpos.battery_manager as bm + bm._adc = None + bm._conversion_func = None + bm._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) + BatteryManager.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) + import mpos.battery_manager as bm + self.assertIsNotNone(bm._adc) + self.assertEqual(bm._conversion_func, adc_to_voltage) + self.assertEqual(bm._adc_pin, 5) + self.assertEqual(bm._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) + BatteryManager.init_adc(13, adc_to_voltage) - self.assertIsNotNone(bv.adc) - self.assertIsNotNone(bv.conversion_func) - self.assertEqual(bv.adc_pin, 13) + import mpos.battery_manager as bm + self.assertIsNotNone(bm._adc) + self.assertIsNotNone(bm._conversion_func) + self.assertEqual(bm._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) + BatteryManager.init_adc(5, my_conversion) + import mpos.battery_manager as bm + self.assertEqual(bm._conversion_func, my_conversion) class TestCaching(unittest.TestCase): @@ -159,53 +97,57 @@ class TestCaching(unittest.TestCase): def setUp(self): """Reset module state.""" - bv.clear_cache() + BatteryManager.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 + BatteryManager.init_adc(5, adc_to_voltage) # Use ADC1 to avoid WiFi complexity MockWifiService.reset() def tearDown(self): """Clean up.""" - bv.clear_cache() + BatteryManager.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) + import mpos.battery_manager as bm + self.assertIsNotNone(bm._cached_raw_adc) + raw = BatteryManager.read_raw_adc() + self.assertIsNotNone(bm._cached_raw_adc) + self.assertEqual(raw, bm._cached_raw_adc) def test_cache_hit_within_duration(self): """Test that subsequent reads use cache within duration.""" - raw1 = bv.read_raw_adc() + raw1 = BatteryManager.read_raw_adc() # Change ADC value but should still get cached value - bv.adc.set_read_value(3000) - raw2 = bv.read_raw_adc() + import mpos.battery_manager as bm + bm._adc.set_read_value(3000) + raw2 = BatteryManager.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() + import mpos.battery_manager as bm + bm._adc.set_read_value(2000) + raw1 = BatteryManager.read_raw_adc() # Change value and force refresh - bv.adc.set_read_value(3000) - raw2 = bv.read_raw_adc(force_refresh=True) + bm._adc.set_read_value(3000) + raw2 = BatteryManager.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) + BatteryManager.read_raw_adc() + import mpos.battery_manager as bm + self.assertIsNotNone(bm._cached_raw_adc) - bv.clear_cache() - self.assertIsNone(bv._cached_raw_adc) - self.assertEqual(bv._last_read_time, 0) + BatteryManager.clear_cache() + self.assertIsNone(bm._cached_raw_adc) + self.assertEqual(bm._last_read_time, 0) class TestADC1Reading(unittest.TestCase): @@ -213,23 +155,23 @@ class TestADC1Reading(unittest.TestCase): def setUp(self): """Reset module state.""" - bv.clear_cache() + BatteryManager.clear_cache() def adc_to_voltage(adc_value): return adc_value * 0.00161 - bv.init_adc(5, adc_to_voltage) # GPIO5 is ADC1 + BatteryManager.init_adc(5, adc_to_voltage) # GPIO5 is ADC1 MockWifiService.reset() MockWifiService._connected = True def tearDown(self): """Clean up.""" - bv.clear_cache() + BatteryManager.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) + BatteryManager.read_raw_adc(force_refresh=True) # WiFi should still be connected self.assertTrue(MockWifiService.is_connected()) @@ -241,7 +183,7 @@ def test_adc1_ignores_wifi_busy(self): # Should not raise error try: - raw = bv.read_raw_adc(force_refresh=True) + raw = BatteryManager.read_raw_adc(force_refresh=True) self.assertIsNotNone(raw) except RuntimeError: self.fail("ADC1 should not raise error when WiFi is busy") @@ -252,22 +194,22 @@ class TestADC2Reading(unittest.TestCase): def setUp(self): """Reset module state.""" - bv.clear_cache() + BatteryManager.clear_cache() def adc_to_voltage(adc_value): return adc_value * 0.00197 - bv.init_adc(13, adc_to_voltage) # GPIO13 is ADC2 + BatteryManager.init_adc(13, adc_to_voltage) # GPIO13 is ADC2 MockWifiService.reset() def tearDown(self): """Clean up.""" - bv.clear_cache() + BatteryManager.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) + BatteryManager.read_raw_adc(force_refresh=True) # WiFi should be reconnected after reading (if it was connected before) self.assertTrue(MockWifiService.is_connected()) @@ -279,7 +221,7 @@ def test_adc2_sets_wifi_busy_flag(self): # wifi_busy should be False before self.assertFalse(MockWifiService.wifi_busy) - bv.read_raw_adc(force_refresh=True) + BatteryManager.read_raw_adc(force_refresh=True) # wifi_busy should be False after (cleared in finally) self.assertFalse(MockWifiService.wifi_busy) @@ -289,7 +231,7 @@ def test_adc2_raises_error_if_wifi_busy(self): MockWifiService.wifi_busy = True with self.assertRaises(RuntimeError) as ctx: - bv.read_raw_adc(force_refresh=True) + BatteryManager.read_raw_adc(force_refresh=True) self.assertIn("WifiService is already busy", str(ctx.exception)) @@ -297,13 +239,13 @@ 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) + raw1 = BatteryManager.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() + raw2 = BatteryManager.read_raw_adc() self.assertEqual(raw1, raw2) def test_adc2_only_reconnects_if_was_connected(self): @@ -311,7 +253,7 @@ def test_adc2_only_reconnects_if_was_connected(self): # WiFi is NOT connected MockWifiService._connected = False - bv.read_raw_adc(force_refresh=True) + BatteryManager.read_raw_adc(force_refresh=True) # WiFi should still be disconnected (no unwanted reconnection) self.assertFalse(MockWifiService.is_connected()) @@ -322,58 +264,63 @@ class TestVoltageCalculations(unittest.TestCase): def setUp(self): """Reset module state.""" - bv.clear_cache() + BatteryManager.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 + BatteryManager.init_adc(5, adc_to_voltage) # ADC1 pin, scale factor for 2:1 divider + import mpos.battery_manager as bm + bm._adc.set_read_value(2048) # Mid-range def tearDown(self): """Clean up.""" - bv.clear_cache() + BatteryManager.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() + import mpos.battery_manager as bm + bm._adc.set_read_value(2048) # Mid-range + BatteryManager.clear_cache() - voltage = bv.read_battery_voltage(force_refresh=True) + voltage = BatteryManager.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() + import mpos.battery_manager as bm + bm._adc.set_read_value(0) + BatteryManager.clear_cache() - voltage = bv.read_battery_voltage(force_refresh=True) + voltage = BatteryManager.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 + import mpos.battery_manager as bm + mid_voltage = (bm.MIN_VOLTAGE + bm.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() + bm._adc.set_read_value(int(raw_adc)) + BatteryManager.clear_cache() - percentage = bv.get_battery_percentage() + percentage = BatteryManager.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.""" + import mpos.battery_manager as bm # Test minimum - bv.adc.set_read_value(0) - bv.clear_cache() - percentage = bv.get_battery_percentage() + bm._adc.set_read_value(0) + BatteryManager.clear_cache() + percentage = BatteryManager.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() + bm._adc.set_read_value(4095) + BatteryManager.clear_cache() + percentage = BatteryManager.get_battery_percentage() self.assertGreaterEqual(percentage, 0.0) self.assertLessEqual(percentage, 100.0) @@ -383,21 +330,22 @@ class TestAveragingLogic(unittest.TestCase): def setUp(self): """Reset module state.""" - bv.clear_cache() + BatteryManager.clear_cache() def adc_to_voltage(adc_value): return adc_value * 0.00161 - bv.init_adc(5, adc_to_voltage) + BatteryManager.init_adc(5, adc_to_voltage) def tearDown(self): """Clean up.""" - bv.clear_cache() + BatteryManager.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() + import mpos.battery_manager as bm + bm._adc.set_read_value(2000) + BatteryManager.clear_cache() - raw = bv.read_raw_adc(force_refresh=True) + raw = BatteryManager.read_raw_adc(force_refresh=True) # Should be average of 10 reads self.assertEqual(raw, 2000.0) @@ -408,27 +356,28 @@ class TestDesktopMode(unittest.TestCase): def setUp(self): """Disable ADC.""" - bv.adc = None + import mpos.battery_manager as bm + bm._adc = None def adc_to_voltage(adc_value): return adc_value * 0.00161 - bv.conversion_func = adc_to_voltage + bm._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() + raw = BatteryManager.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() + voltage = BatteryManager.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() + percentage = BatteryManager.get_battery_percentage() self.assertIsNotNone(percentage) self.assertGreaterEqual(percentage, 0) self.assertLessEqual(percentage, 100) diff --git a/tests/test_calibration_check_bug.py b/tests/test_calibration_check_bug.py index 14e72d80..2446d3ed 100644 --- a/tests/test_calibration_check_bug.py +++ b/tests/test_calibration_check_bug.py @@ -90,7 +90,7 @@ def _mock_mcu_temperature(*args, **kwargs): sys.modules['_thread'] = mock_thread # Now import the module to test -import mpos.sensor_manager as SensorManager +from mpos import SensorManager class TestCalibrationCheckBug(unittest.TestCase): diff --git a/tests/test_camera_manager.py b/tests/test_camera_manager.py new file mode 100644 index 00000000..244dcd49 --- /dev/null +++ b/tests/test_camera_manager.py @@ -0,0 +1,298 @@ + +import unittest +import sys +import os + +from mpos import CameraManager + +class TestCameraClass(unittest.TestCase): + """Test Camera class functionality.""" + + def test_camera_creation_with_all_params(self): + """Test creating a camera with all parameters.""" + cam = CameraManager.Camera( + lens_facing=CameraManager.CameraCharacteristics.LENS_FACING_BACK, + name="OV5640", + vendor="OmniVision", + version=2 + ) + self.assertEqual(cam.lens_facing, CameraManager.CameraCharacteristics.LENS_FACING_BACK) + self.assertEqual(cam.name, "OV5640") + self.assertEqual(cam.vendor, "OmniVision") + self.assertEqual(cam.version, 2) + + def test_camera_creation_with_defaults(self): + """Test creating a camera with default parameters.""" + cam = CameraManager.Camera( + lens_facing=CameraManager.CameraCharacteristics.LENS_FACING_FRONT + ) + self.assertEqual(cam.lens_facing, CameraManager.CameraCharacteristics.LENS_FACING_FRONT) + self.assertEqual(cam.name, "Camera") + self.assertEqual(cam.vendor, "Unknown") + self.assertEqual(cam.version, 1) + + def test_camera_repr(self): + """Test Camera __repr__ method.""" + cam = CameraManager.Camera( + lens_facing=CameraManager.CameraCharacteristics.LENS_FACING_BACK, + name="TestCam" + ) + repr_str = repr(cam) + self.assertIn("TestCam", repr_str) + self.assertIn("BACK", repr_str) + + def test_camera_repr_front(self): + """Test Camera __repr__ with front-facing camera.""" + cam = CameraManager.Camera( + lens_facing=CameraManager.CameraCharacteristics.LENS_FACING_FRONT, + name="FrontCam" + ) + repr_str = repr(cam) + self.assertIn("FrontCam", repr_str) + self.assertIn("FRONT", repr_str) + + def test_camera_repr_external(self): + """Test Camera __repr__ with external camera.""" + cam = CameraManager.Camera( + lens_facing=CameraManager.CameraCharacteristics.LENS_FACING_EXTERNAL, + name="USBCam" + ) + repr_str = repr(cam) + self.assertIn("USBCam", repr_str) + self.assertIn("EXTERNAL", repr_str) + + +class TestCameraCharacteristics(unittest.TestCase): + """Test CameraCharacteristics constants.""" + + def test_lens_facing_constants(self): + """Test that lens facing constants are defined.""" + self.assertEqual(CameraManager.CameraCharacteristics.LENS_FACING_BACK, 0) + self.assertEqual(CameraManager.CameraCharacteristics.LENS_FACING_FRONT, 1) + self.assertEqual(CameraManager.CameraCharacteristics.LENS_FACING_EXTERNAL, 2) + + def test_constants_are_unique(self): + """Test that all constants are unique.""" + constants = [ + CameraManager.CameraCharacteristics.LENS_FACING_BACK, + CameraManager.CameraCharacteristics.LENS_FACING_FRONT, + CameraManager.CameraCharacteristics.LENS_FACING_EXTERNAL + ] + self.assertEqual(len(constants), len(set(constants))) + + +class TestCameraManagerFunctionality(unittest.TestCase): + """Test CameraManager core functionality.""" + + def setUp(self): + """Clear cameras before each test.""" + # Reset the module state + CameraManager._cameras = [] + + def tearDown(self): + """Clean up after each test.""" + CameraManager._cameras = [] + + def test_is_available(self): + """Test is_available() returns True after initialization.""" + self.assertTrue(CameraManager.is_available()) + + def test_add_camera_single(self): + """Test adding a single camera.""" + cam = CameraManager.Camera( + lens_facing=CameraManager.CameraCharacteristics.LENS_FACING_BACK, + name="TestCam" + ) + result = CameraManager.add_camera(cam) + self.assertTrue(result) + self.assertEqual(CameraManager.get_camera_count(), 1) + + def test_add_camera_multiple(self): + """Test adding multiple cameras.""" + back_cam = CameraManager.Camera( + lens_facing=CameraManager.CameraCharacteristics.LENS_FACING_BACK, + name="BackCam" + ) + front_cam = CameraManager.Camera( + lens_facing=CameraManager.CameraCharacteristics.LENS_FACING_FRONT, + name="FrontCam" + ) + + CameraManager.add_camera(back_cam) + CameraManager.add_camera(front_cam) + + self.assertEqual(CameraManager.get_camera_count(), 2) + + def test_add_camera_invalid_type(self): + """Test adding invalid object as camera.""" + result = CameraManager.add_camera("not a camera") + self.assertFalse(result) + self.assertEqual(CameraManager.get_camera_count(), 0) + + def test_get_cameras_empty(self): + """Test getting cameras when none registered.""" + cameras = CameraManager.get_cameras() + self.assertEqual(len(cameras), 0) + self.assertIsInstance(cameras, list) + + def test_get_cameras_returns_copy(self): + """Test that get_cameras() returns a copy, not reference.""" + cam = CameraManager.Camera( + lens_facing=CameraManager.CameraCharacteristics.LENS_FACING_BACK + ) + CameraManager.add_camera(cam) + + cameras1 = CameraManager.get_cameras() + cameras2 = CameraManager.get_cameras() + + # Should be equal but not the same object + self.assertEqual(len(cameras1), len(cameras2)) + self.assertIsNot(cameras1, cameras2) + + def test_get_cameras_multiple(self): + """Test getting multiple cameras.""" + back_cam = CameraManager.Camera( + lens_facing=CameraManager.CameraCharacteristics.LENS_FACING_BACK, + name="BackCam" + ) + front_cam = CameraManager.Camera( + lens_facing=CameraManager.CameraCharacteristics.LENS_FACING_FRONT, + name="FrontCam" + ) + + CameraManager.add_camera(back_cam) + CameraManager.add_camera(front_cam) + + cameras = CameraManager.get_cameras() + self.assertEqual(len(cameras), 2) + names = [c.name for c in cameras] + self.assertIn("BackCam", names) + self.assertIn("FrontCam", names) + + def test_get_camera_by_facing_back(self): + """Test getting back-facing camera.""" + back_cam = CameraManager.Camera( + lens_facing=CameraManager.CameraCharacteristics.LENS_FACING_BACK, + name="BackCam" + ) + CameraManager.add_camera(back_cam) + + found = CameraManager.get_camera_by_facing( + CameraManager.CameraCharacteristics.LENS_FACING_BACK + ) + self.assertIsNotNone(found) + self.assertEqual(found.name, "BackCam") + + def test_get_camera_by_facing_front(self): + """Test getting front-facing camera.""" + front_cam = CameraManager.Camera( + lens_facing=CameraManager.CameraCharacteristics.LENS_FACING_FRONT, + name="FrontCam" + ) + CameraManager.add_camera(front_cam) + + found = CameraManager.get_camera_by_facing( + CameraManager.CameraCharacteristics.LENS_FACING_FRONT + ) + self.assertIsNotNone(found) + self.assertEqual(found.name, "FrontCam") + + def test_get_camera_by_facing_not_found(self): + """Test getting camera that doesn't exist.""" + back_cam = CameraManager.Camera( + lens_facing=CameraManager.CameraCharacteristics.LENS_FACING_BACK + ) + CameraManager.add_camera(back_cam) + + found = CameraManager.get_camera_by_facing( + CameraManager.CameraCharacteristics.LENS_FACING_FRONT + ) + self.assertIsNone(found) + + def test_get_camera_by_facing_returns_first(self): + """Test that get_camera_by_facing returns first matching camera.""" + # Add two back-facing cameras + back_cam1 = CameraManager.Camera( + lens_facing=CameraManager.CameraCharacteristics.LENS_FACING_BACK, + name="BackCam1" + ) + back_cam2 = CameraManager.Camera( + lens_facing=CameraManager.CameraCharacteristics.LENS_FACING_BACK, + name="BackCam2" + ) + + CameraManager.add_camera(back_cam1) + CameraManager.add_camera(back_cam2) + + found = CameraManager.get_camera_by_facing( + CameraManager.CameraCharacteristics.LENS_FACING_BACK + ) + self.assertEqual(found.name, "BackCam1") + + def test_has_camera_empty(self): + """Test has_camera() when no cameras registered.""" + self.assertFalse(CameraManager.has_camera()) + + def test_has_camera_with_cameras(self): + """Test has_camera() when cameras registered.""" + cam = CameraManager.Camera( + lens_facing=CameraManager.CameraCharacteristics.LENS_FACING_BACK + ) + CameraManager.add_camera(cam) + self.assertTrue(CameraManager.has_camera()) + + def test_get_camera_count_empty(self): + """Test get_camera_count() when no cameras.""" + self.assertEqual(CameraManager.get_camera_count(), 0) + + def test_get_camera_count_multiple(self): + """Test get_camera_count() with multiple cameras.""" + for i in range(3): + cam = CameraManager.Camera( + lens_facing=CameraManager.CameraCharacteristics.LENS_FACING_BACK, + name=f"Camera{i}" + ) + CameraManager.add_camera(cam) + + self.assertEqual(CameraManager.get_camera_count(), 3) + + +class TestCameraManagerUsagePattern(unittest.TestCase): + """Test the usage pattern from the task description.""" + + def setUp(self): + """Clear cameras before each test.""" + CameraManager._cameras = [] + + def tearDown(self): + """Clean up after each test.""" + CameraManager._cameras = [] + + def test_task_usage_pattern(self): + """Test the exact usage pattern from the task description.""" + # Register a camera (as done in board init) + CameraManager.add_camera(CameraManager.Camera( + lens_facing=CameraManager.CameraCharacteristics.LENS_FACING_BACK + )) + + # App usage pattern + cam_list = CameraManager.get_cameras() + + if len(cam_list) > 0: + has_camera = True + else: + has_camera = False + + self.assertTrue(has_camera) + + def test_task_usage_pattern_no_camera(self): + """Test usage pattern when no camera available.""" + cam_list = CameraManager.get_cameras() + + if len(cam_list) > 0: + has_camera = True + else: + has_camera = False + + self.assertFalse(has_camera) + diff --git a/tests/test_connectivity_manager.py b/tests/test_connectivity_manager.py index 99ffd720..d49a3b06 100644 --- a/tests/test_connectivity_manager.py +++ b/tests/test_connectivity_manager.py @@ -1,36 +1,28 @@ import unittest import sys -# Add parent directory to path so we can import network_test_helper +# Add parent directory to path so we can import shared mocks/network_test_helper # When running from unittest.sh, we're in internal_filesystem/, so tests/ is ../tests/ -sys.path.insert(0, '../tests') +sys.path.insert(0, "../tests") + +from mocks import make_machine_timer_module, make_usocket_module # 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 +sys.modules["machine"] = make_machine_timer_module(MockTimer) +sys.modules["usocket"] = make_usocket_module(MockSocket) # Mock requests module mock_requests = MockRequests() sys.modules['requests'] = mock_requests +# These tests need: +# from mpos.net.connectivity_manager import ConnectivityManager +# ...instead of +# from mpos import ConnectivityManager +# ...to make the mocking work. class TestConnectivityManagerWithNetwork(unittest.TestCase): """Test ConnectivityManager with network module available.""" @@ -87,15 +79,15 @@ def test_initialization_with_network_module(self): cm = self.ConnectivityManager() # Should have network checking capability - self.assertTrue(cm.can_check_network) + self.assertTrue(cm.can_check_network, "a") # Should have created WLAN instance - self.assertIsNotNone(cm.wlan) + self.assertIsNotNone(cm.wlan, "b") # Should have created timer timer = MockTimer.get_timer(1) self.assertIsNotNone(timer) - self.assertTrue(timer.active) + self.assertTrue(timer.active, "c") self.assertEqual(timer.period, 8000) self.assertEqual(timer.mode, MockTimer.PERIODIC) @@ -520,13 +512,13 @@ def test_online_offline_online_transitions(self): # Go offline self.mock_network.set_connected(False) timer.callback(timer) - self.assertFalse(cm.is_online()) + self.assertFalse(cm.is_online(), "a") self.assertEqual(notifications[-1], False) # Go online self.mock_network.set_connected(True) timer.callback(timer) - self.assertTrue(cm.is_online()) + self.assertTrue(cm.is_online(), "b") self.assertEqual(notifications[-1], True) # Go offline again diff --git a/tests/test_download_manager.py b/tests/test_download_manager.py new file mode 100644 index 00000000..a7014487 --- /dev/null +++ b/tests/test_download_manager.py @@ -0,0 +1,549 @@ +""" +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') +from mpos.net.download_manager import DownloadManager +from mpos.testing.mocks import MockDownloadManager + + +class TestDownloadManager(unittest.TestCase): + """Test cases for DownloadManager module.""" + + def setUp(self): + """Reset module state before each test.""" + # 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.""" + # 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 for each download (per-request design).""" + import asyncio + + async def run_test(): + # Perform a download + try: + data = await DownloadManager.download_url("https://httpbin.org/bytes/100") + except Exception as e: + # Skip test if httpbin is unavailable + self.skipTest(f"httpbin.org unavailable: {e}") + return + + # Verify download succeeded + 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 + try: + data1 = await DownloadManager.download_url("https://httpbin.org/bytes/50") + except Exception as e: + self.skipTest(f"httpbin.org unavailable: {e}") + return + self.assertIsNotNone(data1) + + # Perform second download + try: + data2 = await DownloadManager.download_url("https://httpbin.org/bytes/75") + except Exception as e: + self.skipTest(f"httpbin.org unavailable: {e}") + return + self.assertIsNotNone(data2) + + # Verify different data was downloaded + self.assertEqual(len(data1), 50) + self.assertEqual(len(data2), 75) + + 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(): + try: + data = await DownloadManager.download_url("https://httpbin.org/bytes/1024") + except Exception as e: + self.skipTest(f"httpbin.org unavailable: {e}") + return + + 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" + + try: + success = await DownloadManager.download_url( + "https://httpbin.org/bytes/2048", + outfile=outfile + ) + except Exception as e: + self.skipTest(f"httpbin.org unavailable: {e}") + return + + 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) + + try: + success = await DownloadManager.download_url( + "https://httpbin.org/bytes/512", + chunk_callback=collect_chunks + ) + except Exception as e: + self.skipTest(f"httpbin.org unavailable: {e}") + return + + 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) + + try: + data = await DownloadManager.download_url( + "https://httpbin.org/bytes/5120", # 5KB + progress_callback=track_progress + ) + except Exception as e: + self.skipTest(f"httpbin.org unavailable: {e}") + return + + 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 using mock.""" + import asyncio + + async def run_test(): + # Use mock to avoid external service dependency + mock_dm = MockDownloadManager() + mock_dm.set_download_data(b'x' * 3072) # 3KB of data + + progress_calls = [] + + async def track_progress(percent): + progress_calls.append(percent) + + data = await mock_dm.download_url( + "https://example.com/bytes/3072", + total_size=3072, + progress_callback=track_progress + ) + + self.assertIsNotNone(data) + self.assertTrue(len(progress_calls) > 0) + self.assertEqual(len(data), 3072) + + asyncio.run(run_test()) + + # ==================== Error Handling Tests ==================== + + def test_http_error_status(self): + """Test handling of HTTP error status codes using mock.""" + import asyncio + + async def run_test(): + # Use mock to avoid external service dependency + mock_dm = MockDownloadManager() + # Set fail_after_bytes to 0 to trigger immediate failure + mock_dm.set_fail_after_bytes(0) + + # Should raise RuntimeError for HTTP error + with self.assertRaises(OSError): + data = await mock_dm.download_url("https://example.com/status/404") + + asyncio.run(run_test()) + + def test_http_error_with_file_output(self): + """Test that file download raises exception on HTTP error using mock.""" + import asyncio + + async def run_test(): + outfile = f"{self.temp_dir}/error_test.bin" + + # Use mock to avoid external service dependency + mock_dm = MockDownloadManager() + # Set fail_after_bytes to 0 to trigger immediate failure + mock_dm.set_fail_after_bytes(0) + + # Should raise OSError for network error + with self.assertRaises(OSError): + success = await mock_dm.download_url( + "https://example.com/status/500", + outfile=outfile + ) + + # 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 an exception + with self.assertRaises(Exception): + data = await DownloadManager.download_url("http://invalid-url-that-does-not-exist.local/") + + 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(): + # Use real httpbin.org for this test since it specifically tests header echoing + 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 using mock.""" + import asyncio + + async def run_test(): + # Use mock to avoid external service dependency + mock_dm = MockDownloadManager() + mock_dm.set_download_data(b'') # Empty data + + data = await mock_dm.download_url("https://example.com/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) using mock.""" + import asyncio + + async def run_test(): + # Use mock to avoid external service dependency + mock_dm = MockDownloadManager() + mock_dm.set_download_data(b'x' * 10) # 10 bytes + + data = await mock_dm.download_url("https://example.com/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(): + # Use real httpbin.org for this test since it specifically tests JSON parsing + 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" + + # Should raise exception because directory doesn't exist + with self.assertRaises(Exception): + try: + success = await DownloadManager.download_url( + "https://httpbin.org/bytes/100", + outfile=outfile + ) + except Exception as e: + # Re-raise to let assertRaises catch it + raise + + 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 + try: + success = await DownloadManager.download_url( + "https://httpbin.org/bytes/100", + outfile=outfile + ) + except Exception as e: + self.skipTest(f"httpbin.org unavailable: {e}") + return + + 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()) + + # ==================== Async/Sync Compatibility Tests ==================== + + def test_async_download_with_await(self): + """Test async download using await (traditional async usage).""" + import asyncio + + async def run_test(): + try: + # Traditional async usage with await + data = await DownloadManager.download_url("https://MicroPythonOS.com") + except Exception as e: + self.skipTest(f"MicroPythonOS.com unavailable: {e}") + return + + self.assertIsNotNone(data) + self.assertIsInstance(data, bytes) + self.assertTrue(len(data) > 0) + # Verify it's HTML content + self.assertIn(b'html', data.lower()) + + asyncio.run(run_test()) + + def test_sync_download_without_await(self): + """Test synchronous download without await (auto-detects sync context).""" + # This is a synchronous function (no async def) + # The wrapper should detect no running event loop and run synchronously + try: + # Synchronous usage without await + data = DownloadManager.download_url("https://MicroPythonOS.com") + except Exception as e: + self.skipTest(f"MicroPythonOS.com unavailable: {e}") + return + + self.assertIsNotNone(data) + self.assertIsInstance(data, bytes) + self.assertTrue(len(data) > 0) + # Verify it's HTML content + self.assertIn(b'html', data.lower()) + + def test_async_and_sync_return_same_data(self): + """Test that async and sync methods return identical data.""" + import asyncio + + # First, get data synchronously + try: + sync_data = DownloadManager.download_url("https://MicroPythonOS.com") + except Exception as e: + self.skipTest(f"MicroPythonOS.com unavailable: {e}") + return + + # Then, get data asynchronously + async def run_async_test(): + try: + async_data = await DownloadManager.download_url("https://MicroPythonOS.com") + except Exception as e: + self.skipTest(f"MicroPythonOS.com unavailable: {e}") + return + return async_data + + async_data = asyncio.run(run_async_test()) + + # Both should return the same data + self.assertEqual(sync_data, async_data) + self.assertEqual(len(sync_data), len(async_data)) + + def test_sync_download_to_file(self): + """Test synchronous file download without await.""" + outfile = f"{self.temp_dir}/sync_download.html" + + try: + # Synchronous file download + success = DownloadManager.download_url( + "https://MicroPythonOS.com", + outfile=outfile + ) + except Exception as e: + self.skipTest(f"MicroPythonOS.com unavailable: {e}") + return + + self.assertTrue(success) + # Check file exists using os.stat instead of os.path.exists + try: + file_size = os.stat(outfile)[6] + self.assertTrue(file_size > 0) + except OSError: + self.fail("File should exist after successful download") + + # Verify it's HTML content + with open(outfile, 'rb') as f: + content = f.read() + self.assertIn(b'html', content.lower()) + + # Clean up + os.remove(outfile) + + def test_sync_download_with_progress_callback(self): + """Test synchronous download with progress callback.""" + progress_calls = [] + + async def track_progress(percent): + progress_calls.append(percent) + + try: + # Synchronous download with async progress callback + data = DownloadManager.download_url( + "https://MicroPythonOS.com", + progress_callback=track_progress + ) + except Exception as e: + self.skipTest(f"MicroPythonOS.com unavailable: {e}") + return + + self.assertIsNotNone(data) + self.assertIsInstance(data, bytes) + # Progress callbacks should have been called + self.assertTrue(len(progress_calls) > 0) + # Verify progress values are in valid range + for pct in progress_calls: + self.assertTrue(0 <= pct <= 100) diff --git a/tests/test_download_manager_utils.py b/tests/test_download_manager_utils.py new file mode 100644 index 00000000..463964b0 --- /dev/null +++ b/tests/test_download_manager_utils.py @@ -0,0 +1,203 @@ +""" +Unit tests for DownloadManager utility functions. + +Tests the network error detection and resume position helpers. +""" + +import unittest +import os +import sys + +# Handle both CPython and MicroPython path handling +try: + # CPython has os.path + from os.path import join, dirname +except ImportError: + # MicroPython doesn't have os.path, use string concatenation + def join(*parts): + return '/'.join(parts) + def dirname(path): + parts = path.split('/') + return '/'.join(parts[:-1]) if len(parts) > 1 else '.' + +# Add parent directory to path for imports +sys.path.insert(0, join(dirname(__file__), '..', 'internal_filesystem', 'lib')) + +# Import functions directly from the module file to avoid mpos.__init__ dependencies +try: + import importlib.util + spec = importlib.util.spec_from_file_location( + "download_manager", + join(dirname(__file__), '..', 'internal_filesystem', 'lib', 'mpos', 'net', 'download_manager.py') + ) + download_manager = importlib.util.module_from_spec(spec) + spec.loader.exec_module(download_manager) +except (ImportError, AttributeError): + # MicroPython doesn't have importlib.util, import directly + sys.path.insert(0, join(dirname(__file__), '..', 'internal_filesystem', 'lib', 'mpos', 'net')) + import download_manager + +is_network_error = download_manager.is_network_error +get_resume_position = download_manager.get_resume_position + + +class TestIsNetworkError(unittest.TestCase): + """Test network error detection utility.""" + + def test_detects_timeout_error_code(self): + """Should detect OSError with -110 (ETIMEDOUT) as network error.""" + error = OSError(-110, "Connection timed out") + self.assertTrue(is_network_error(error)) + + def test_detects_connection_aborted_error_code(self): + """Should detect OSError with -113 (ECONNABORTED) as network error.""" + error = OSError(-113, "Connection aborted") + self.assertTrue(is_network_error(error)) + + def test_detects_connection_reset_error_code(self): + """Should detect OSError with -104 (ECONNRESET) as network error.""" + error = OSError(-104, "Connection reset by peer") + self.assertTrue(is_network_error(error)) + + def test_detects_host_unreachable_error_code(self): + """Should detect OSError with -118 (EHOSTUNREACH) as network error.""" + error = OSError(-118, "No route to host") + self.assertTrue(is_network_error(error)) + + def test_detects_dns_error_code(self): + """Should detect OSError with -202 (DNS/connection error) as network error.""" + error = OSError(-202, "DNS lookup failed") + self.assertTrue(is_network_error(error)) + + def test_detects_connection_reset_message(self): + """Should detect 'connection reset' in error message.""" + error = Exception("Connection reset by peer") + self.assertTrue(is_network_error(error)) + + def test_detects_connection_aborted_message(self): + """Should detect 'connection aborted' in error message.""" + error = Exception("Connection aborted") + self.assertTrue(is_network_error(error)) + + def test_detects_broken_pipe_message(self): + """Should detect 'broken pipe' in error message.""" + error = Exception("Broken pipe") + self.assertTrue(is_network_error(error)) + + def test_detects_network_unreachable_message(self): + """Should detect 'network unreachable' in error message.""" + error = Exception("Network unreachable") + self.assertTrue(is_network_error(error)) + + def test_detects_failed_to_download_chunk_message(self): + """Should detect 'failed to download chunk' message from download_manager.""" + error = OSError(-110, "Failed to download chunk after retries") + self.assertTrue(is_network_error(error)) + + def test_rejects_value_error(self): + """Should not detect ValueError as network error.""" + error = ValueError("Invalid value") + self.assertFalse(is_network_error(error)) + + def test_rejects_http_404_error(self): + """Should not detect HTTP 404 as network error.""" + error = RuntimeError("HTTP 404") + self.assertFalse(is_network_error(error)) + + def test_rejects_file_not_found_error(self): + """Should not detect ENOENT (-2) as network error.""" + error = OSError(-2, "No such file or directory") + self.assertFalse(is_network_error(error)) + + def test_rejects_permission_error(self): + """Should not detect permission errors as network error.""" + error = OSError(-13, "Permission denied") + self.assertFalse(is_network_error(error)) + + def test_case_insensitive_detection(self): + """Should detect network errors regardless of case.""" + error1 = Exception("CONNECTION RESET") + error2 = Exception("connection reset") + error3 = Exception("Connection Reset") + self.assertTrue(is_network_error(error1)) + self.assertTrue(is_network_error(error2)) + self.assertTrue(is_network_error(error3)) + + +class TestGetResumePosition(unittest.TestCase): + """Test resume position utility.""" + + def setUp(self): + """Create test directory.""" + self.test_dir = "tmp_test_download_manager" + # Handle both CPython and MicroPython + try: + os.makedirs(self.test_dir, exist_ok=True) + except (AttributeError, TypeError): + # MicroPython doesn't have makedirs or exist_ok parameter + try: + os.mkdir(self.test_dir) + except OSError: + pass # Directory already exists + + def tearDown(self): + """Clean up test files.""" + # Handle both CPython and MicroPython + try: + import shutil + if os.path.exists(self.test_dir): + shutil.rmtree(self.test_dir) + except (ImportError, AttributeError): + # MicroPython doesn't have shutil, manually remove files + try: + import os as os_module + for f in os_module.listdir(self.test_dir): + os_module.remove(join(self.test_dir, f)) + os_module.rmdir(self.test_dir) + except (OSError, AttributeError): + pass # Ignore errors during cleanup + + def test_returns_zero_for_nonexistent_file(self): + """Should return 0 for files that don't exist.""" + nonexistent = join(self.test_dir, "nonexistent.bin") + self.assertEqual(get_resume_position(nonexistent), 0) + + def test_returns_file_size_for_existing_file(self): + """Should return file size for existing files.""" + test_file = join(self.test_dir, "test.bin") + test_data = b"x" * 1024 + with open(test_file, "wb") as f: + f.write(test_data) + + self.assertEqual(get_resume_position(test_file), 1024) + + def test_returns_zero_for_empty_file(self): + """Should return 0 for empty files.""" + test_file = join(self.test_dir, "empty.bin") + with open(test_file, "wb") as f: + pass # Create empty file + + self.assertEqual(get_resume_position(test_file), 0) + + def test_returns_correct_size_for_large_file(self): + """Should return correct size for larger files.""" + test_file = join(self.test_dir, "large.bin") + test_data = b"x" * (1024 * 1024) # 1 MB (reduced from 10 MB to avoid memory issues) + with open(test_file, "wb") as f: + f.write(test_data) + + self.assertEqual(get_resume_position(test_file), 1024 * 1024) + + def test_returns_size_after_partial_write(self): + """Should return current size after partial write.""" + test_file = join(self.test_dir, "partial.bin") + + # Write 1KB + with open(test_file, "wb") as f: + f.write(b"x" * 1024) + self.assertEqual(get_resume_position(test_file), 1024) + + # Append another 1KB + with open(test_file, "ab") as f: + f.write(b"y" * 1024) + self.assertEqual(get_resume_position(test_file), 2048) diff --git a/tests/test_graphical_abc_button_debug.py b/tests/test_graphical_abc_button_debug.py index dc8575da..83008c4b 100644 --- a/tests/test_graphical_abc_button_debug.py +++ b/tests/test_graphical_abc_button_debug.py @@ -9,8 +9,7 @@ import unittest import lvgl as lv -from mpos.ui.keyboard import MposKeyboard -from mpos.ui.testing import wait_for_render +from mpos import MposKeyboard, wait_for_render class TestAbcButtonDebug(unittest.TestCase): diff --git a/tests/test_graphical_about_app.py b/tests/test_graphical_about_app.py index 98c82308..165affc7 100644 --- a/tests/test_graphical_about_app.py +++ b/tests/test_graphical_about_app.py @@ -7,8 +7,7 @@ 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 +3. Works on both desktop and device Usage: Desktop: ./tests/unittest.sh tests/test_graphical_about_app.py @@ -17,16 +16,15 @@ import unittest import lvgl as lv -import mpos.apps -import mpos.info import mpos.ui -import os -from mpos.ui.testing import ( +from mpos import ( wait_for_render, - capture_screenshot, find_label_with_text, verify_text_present, - print_screen_labels + print_screen_labels, + DeviceInfo, + BuildInfo, + AppManager, ) @@ -35,23 +33,8 @@ class TestGraphicalAboutApp(unittest.TestCase): 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() + self.hardware_id = DeviceInfo.hardware_id print(f"Testing with hardware ID: {self.hardware_id}") def tearDown(self): @@ -72,12 +55,11 @@ def test_about_app_shows_correct_hardware_id(self): 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") + result = AppManager.start_app("com.micropythonos.about") self.assertTrue(result, "Failed to start About app") # Wait for UI to fully render @@ -115,25 +97,6 @@ def test_about_app_shows_correct_hardware_id(self): 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): @@ -145,23 +108,23 @@ def test_about_app_shows_os_version(self): print("\n=== Starting About app OS version test ===") # Start the About app - result = mpos.apps.start_app("com.micropythonos.about") + result = AppManager.start_app("com.micropythonos.about") self.assertTrue(result, "Failed to start About app") # Wait for UI to render - wait_for_render(iterations=15) + wait_for_render(iterations=150) # 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_text_present(screen, "Release version:"), + "Could not find 'Release version:' on screen" ) # Verify the actual version string is present - os_version = mpos.info.CURRENT_OS_VERSION + os_version = BuildInfo.version.release self.assertTrue( verify_text_present(screen, os_version), f"OS version '{os_version}' not found on screen" diff --git a/tests/test_graphical_animation_deleted_widget.py b/tests/test_graphical_animation_deleted_widget.py index 4fe367b3..46e4cabf 100644 --- a/tests/test_graphical_animation_deleted_widget.py +++ b/tests/test_graphical_animation_deleted_widget.py @@ -17,9 +17,9 @@ import unittest import lvgl as lv -import mpos.ui.anim +from mpos.ui.widget_animator import WidgetAnimator import time -from mpos.ui.testing import wait_for_render +from mpos import wait_for_render class TestAnimationDeletedWidget(unittest.TestCase): @@ -57,7 +57,7 @@ def test_smooth_show_with_deleted_widget(self): # Start fade-in animation (500ms duration) print("Starting smooth_show animation...") - mpos.ui.anim.smooth_show(widget) + WidgetAnimator.smooth_show(widget) # Give animation time to start wait_for_render(2) @@ -97,7 +97,7 @@ def test_smooth_hide_with_deleted_widget(self): # Start fade-out animation print("Starting smooth_hide animation...") - mpos.ui.anim.smooth_hide(widget) + WidgetAnimator.smooth_hide(widget) # Give animation time to start wait_for_render(2) @@ -130,7 +130,7 @@ def test_keyboard_scenario(self): """ print("Testing keyboard deletion scenario...") - from mpos.ui.keyboard import MposKeyboard + from mpos import MposKeyboard # Create textarea and keyboard (like QuasiNametag does) textarea = lv.textarea(self.screen) @@ -144,7 +144,7 @@ def test_keyboard_scenario(self): # User clicks textarea - keyboard shows with animation print("Showing keyboard with animation...") - mpos.ui.anim.smooth_show(keyboard) + WidgetAnimator.smooth_show(keyboard) # Give animation time to start wait_for_render(2) @@ -189,7 +189,7 @@ def test_multiple_animations_deleted(self): # Start animations on all widgets print("Starting animations on 5 widgets...") for w in widgets: - mpos.ui.anim.smooth_show(w) + WidgetAnimator.smooth_show(w) wait_for_render(2) diff --git a/tests/test_graphical_camera_settings.py b/tests/test_graphical_camera_settings.py index 9ccd7955..3f44e373 100644 --- a/tests/test_graphical_camera_settings.py +++ b/tests/test_graphical_camera_settings.py @@ -19,21 +19,20 @@ import unittest import lvgl as lv -import mpos.apps import mpos.ui -import os -from mpos.ui.testing import ( +import sys +from mpos 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 + get_widget_coords, + AppManager ) - +@unittest.skipIf(sys.platform == 'darwin', "Camera tests not supported on macOS (no camera available)") class TestGraphicalCameraSettings(unittest.TestCase): """Test suite for Camera app settings verification.""" @@ -50,19 +49,6 @@ def setUp(self): 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) @@ -72,6 +58,32 @@ def tearDown(self): except: pass # Already on launcher or error + def _find_and_click_settings_button(self, screen, use_send_event=True): + """Find and click the settings button using lv.SYMBOL.SETTINGS. + + Args: + screen: LVGL screen object to search + use_send_event: If True (default), use send_event() which is more reliable. + If False, use simulate_click() with coordinates. + + Returns True if button was found and clicked, False otherwise. + """ + settings_button = find_button_with_text(screen, lv.SYMBOL.SETTINGS) + if settings_button: + coords = get_widget_coords(settings_button) + print(f"Found settings button at ({coords['center_x']}, {coords['center_y']})") + if use_send_event: + # Use send_event for more reliable button triggering + settings_button.send_event(lv.EVENT.CLICKED, None) + print("Clicked settings button using send_event()") + else: + simulate_click(coords['center_x'], coords['center_y'], press_duration_ms=100) + print("Clicked settings button using simulate_click()") + return True + else: + print("Settings button not found via lv.SYMBOL.SETTINGS") + return False + def test_settings_button_click_no_crash(self): """ Test that clicking the settings button doesn't cause a segfault. @@ -82,15 +94,14 @@ def test_settings_button_click_no_crash(self): 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 + 3. Click settings button (found dynamically by lv.SYMBOL.SETTINGS) + 4. Verify settings dialog opened + 5. 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") + result = AppManager.start_app("com.micropythonos.camera") self.assertTrue(result, "Failed to start Camera app") # Wait for camera to initialize and first frame to render @@ -103,23 +114,12 @@ def test_settings_button_click_no_crash(self): 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 + # Find and click settings button dynamically + found = self._find_and_click_settings_button(screen) + self.assertTrue(found, "Settings button with lv.SYMBOL.SETTINGS not found on screen") - 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) + # Wait for settings dialog to appear - needs more time for Activity transition + wait_for_render(iterations=50) # Get screen again (might have changed after navigation) screen = lv.screen_active() @@ -128,38 +128,93 @@ def test_settings_button_click_no_crash(self): 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") - ) + # Verify settings screen opened by looking for the Save button + # This is more reliable than text search since buttons are always present + save_button = find_button_with_text(screen, "Save") + cancel_button = find_button_with_text(screen, "Cancel") + + has_settings_ui = save_button is not None or cancel_button is not None + + # Also try text-based verification as fallback + if not has_settings_ui: + 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, "Basic") or # Tab name + verify_text_present(screen, "Color Mode") # Setting name + ) self.assertTrue( has_settings_ui, - "Settings screen did not open (no expected UI elements found)" + "Settings screen did not open (no Save/Cancel buttons or 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 _find_and_click_button(self, screen, text, use_send_event=True): + """Find and click a button by its text label. + + Args: + screen: LVGL screen object to search + text: Text to search for in button labels + use_send_event: If True (default), use send_event() which is more reliable. + If False, use simulate_click() with coordinates. + + Returns True if button was found and clicked, False otherwise. + """ + button = find_button_with_text(screen, text) + if button: + coords = get_widget_coords(button) + print(f"Found '{text}' button 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) + print(f"Clicked '{text}' button using send_event()") + else: + simulate_click(coords['center_x'], coords['center_y'], press_duration_ms=100) + print(f"Clicked '{text}' button using simulate_click()") + return True + else: + print(f"Button with text '{text}' not found") + return False + + def _find_dropdown(self, screen): + """Find a dropdown widget on the screen. + + Returns the dropdown widget or None if not found. + """ + def find_dropdown_recursive(obj): + # Check if this object is a dropdown + try: + if obj.__class__.__name__ == 'dropdown' or hasattr(obj, 'get_selected'): + # Verify it's actually a dropdown by checking for dropdown-specific method + if hasattr(obj, 'get_options'): + return obj + except: + pass + + # Check children + child_count = obj.get_child_count() + for i in range(child_count): + child = obj.get_child(i) + result = find_dropdown_recursive(child) + if result: + return result + return None + + return find_dropdown_recursive(screen) + 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 + 2. Open settings (found dynamically by lv.SYMBOL.SETTINGS) + 3. Change resolution via dropdown + 4. Save settings (found dynamically by "Save" text) 5. Verify camera continues working This verifies fixes for: @@ -170,86 +225,84 @@ def test_resolution_change_no_crash(self): print("\n=== Testing resolution change (no crash) ===") # Start the Camera app - result = mpos.apps.start_app("com.micropythonos.camera") + result = AppManager.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 + # Click settings button dynamically + screen = lv.screen_active() print("\nOpening settings...") - simulate_click(290, 90, press_duration_ms=100) + found = self._find_and_click_settings_button(screen) + self.assertTrue(found, "Settings button with lv.SYMBOL.SETTINGS not found on screen") 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 + # Try to find the dropdown/resolution selector dynamically print("\nLooking for resolution dropdown...") + dropdown = self._find_dropdown(screen) + + if dropdown: + # Click the dropdown to open it + coords = get_widget_coords(dropdown) + print(f"Found dropdown at ({coords['center_x']}, {coords['center_y']})") + simulate_click(coords['center_x'], coords['center_y'], press_duration_ms=100) + wait_for_render(iterations=15) + + # Get current selection and try to change it + try: + current = dropdown.get_selected() + option_count = dropdown.get_option_count() + print(f"Dropdown has {option_count} options, current selection: {current}") + + # Select a different option (next one, or first if at end) + new_selection = (current + 1) % option_count + dropdown.set_selected(new_selection) + print(f"Changed selection to: {new_selection}") + except Exception as e: + print(f"Could not change dropdown selection: {e}") + # Fallback: click below current position to select different option + simulate_click(coords['center_x'], coords['center_y'] + 30, press_duration_ms=100) + else: + print("Dropdown not found, test may not fully exercise resolution change") - # 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("\nScreen after dropdown interaction:") 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 + # Find and click the Save button dynamically 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) + save_found = self._find_and_click_button(lv.screen_active(), "Save") + + if not save_found: + # Try "OK" as alternative + save_found = self._find_and_click_button(lv.screen_active(), "OK") + + self.assertTrue(save_found, "Save/OK button not found on settings screen") # 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 + # Verify camera is still showing something by checking for camera UI elements 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 - + # Check for camera-specific buttons (close, settings, snap, qr) + has_camera_ui = ( + find_button_with_text(screen, lv.SYMBOL.CLOSE) or + find_button_with_text(screen, lv.SYMBOL.SETTINGS) or + find_button_with_text(screen, lv.SYMBOL.OK) or + find_button_with_text(screen, lv.SYMBOL.EYE_OPEN) + ) + + self.assertTrue(has_camera_ui, "Camera app UI not found after resolution change - app may have crashed") print("\n✓ Camera app still running after resolution change!") diff --git a/tests/test_graphical_custom_keyboard.py b/tests/test_graphical_custom_keyboard.py index 55d564db..3ccf329d 100644 --- a/tests/test_graphical_custom_keyboard.py +++ b/tests/test_graphical_custom_keyboard.py @@ -2,7 +2,7 @@ Graphical tests for MposKeyboard. Tests keyboard visual appearance, text input via simulated button presses, -and mode switching. Captures screenshots for regression testing. +and mode switching. Usage: Desktop: ./tests/unittest.sh tests/test_graphical_custom_keyboard.py @@ -11,13 +11,7 @@ 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, -) +from mpos import MposKeyboard, wait_for_render, AppearanceManager class TestGraphicalMposKeyboard(unittest.TestCase): @@ -25,20 +19,7 @@ class TestGraphicalMposKeyboard(unittest.TestCase): 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.""" @@ -106,7 +87,7 @@ def test_keyboard_lowercase_appearance(self): """ Test keyboard appearance in lowercase mode. - Verifies that the keyboard renders correctly and captures screenshot. + Verifies that the keyboard renders correctly. """ print("\n=== Testing lowercase keyboard appearance ===") @@ -116,16 +97,6 @@ def test_keyboard_lowercase_appearance(self): 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): @@ -138,16 +109,6 @@ def test_keyboard_uppercase_appearance(self): 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): @@ -160,16 +121,6 @@ def test_keyboard_numbers_appearance(self): 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): @@ -182,16 +133,6 @@ def test_keyboard_specials_appearance(self): 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): @@ -204,12 +145,11 @@ def test_keyboard_visibility_light_mode(self): # 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) + AppearanceManager.set_theme(prefs) wait_for_render(10) # Create keyboard @@ -280,11 +220,6 @@ def test_keyboard_with_standard_comparison(self): 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) @@ -306,11 +241,6 @@ def test_keyboard_with_standard_comparison(self): 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 index bad39108..1f3cc25e 100644 --- a/tests/test_graphical_custom_keyboard_basic.py +++ b/tests/test_graphical_custom_keyboard_basic.py @@ -10,8 +10,7 @@ import unittest import lvgl as lv -from mpos.ui.keyboard import MposKeyboard -from mpos.ui.testing import simulate_click, wait_for_render +from mpos import MposKeyboard, simulate_click, wait_for_render class TestMposKeyboard(unittest.TestCase): diff --git a/tests/test_graphical_hotspot_password.py b/tests/test_graphical_hotspot_password.py new file mode 100644 index 00000000..0dded516 --- /dev/null +++ b/tests/test_graphical_hotspot_password.py @@ -0,0 +1,294 @@ +""" +Graphical test for hotspot settings password defaults. + +This test verifies that the hotspot settings screen shows the +"(defaults to none)" value under the "Auth Mode" setting. + +Usage: + Desktop: ./tests/unittest.sh tests/test_graphical_hotspot_password.py + Device: ./tests/unittest.sh tests/test_graphical_hotspot_password.py --ondevice +""" + +import unittest +import lvgl as lv +import mpos.ui +from mpos import ( + AppManager, + wait_for_render, + print_screen_labels, + click_button, + verify_text_present, + find_setting_value_label, + get_setting_value_text, + click_label, + simulate_click, + get_widget_coords, + select_dropdown_option_by_text, + find_dropdown_widget, + SharedPreferences, +) + + +class TestGraphicalHotspotPassword(unittest.TestCase): + """Test suite for hotspot password defaults in settings UI.""" + + def _reset_hotspot_preferences(self): + """Clear hotspot preferences to ensure default values are shown.""" + prefs = SharedPreferences("com.micropythonos.settings.hotspot") + editor = prefs.edit() + editor.remove_all() + editor.commit() + + def _open_hotspot_settings_screen(self): + """Start hotspot app and open the Settings screen.""" + result = AppManager.start_app("com.micropythonos.settings.hotspot") + self.assertTrue(result, "Failed to start hotspot settings app") + wait_for_render(iterations=20) + + screen = lv.screen_active() + print("\nInitial screen labels:") + print_screen_labels(screen) + + self.assertTrue( + click_button("Settings"), + "Could not find Settings button in hotspot app", + ) + wait_for_render(iterations=40) + + screen = lv.screen_active() + print("\nSettings screen labels:") + print_screen_labels(screen) + return screen + + 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) + except: + pass + + def test_auth_mode_defaults_label(self): + """Verify Auth Mode shows defaults to none in hotspot settings.""" + print("\n=== Starting Hotspot Settings Auth Mode default test ===") + + self._reset_hotspot_preferences() + screen = self._open_hotspot_settings_screen() + + self.assertTrue( + verify_text_present(screen, "Auth Mode"), + "Auth Mode setting title not found on settings screen", + ) + + value_label = find_setting_value_label(screen, "Auth Mode") + self.assertIsNotNone( + value_label, + "Could not find value label for Auth Mode setting", + ) + + value_text = get_setting_value_text(screen, "Auth Mode") + print(f"Auth Mode value text: {value_text}") + self.assertEqual( + value_text, + "(defaults to none)", + "Auth Mode value text did not match expected default", + ) + + print("\n=== Hotspot settings Auth Mode default test completed ===") + + def test_auth_mode_change_hides_password_setting(self): + """Verify Password setting disappears after switching Auth Mode to None.""" + print("\n=== Starting Hotspot Settings Password hide test ===") + + self._reset_hotspot_preferences() + screen = self._open_hotspot_settings_screen() + + self.assertFalse( + verify_text_present(screen, "Password"), + "Password setting should not be visible with Auth Mode None", + ) + + self.assertTrue( + click_label("Auth Mode"), + "Could not click Auth Mode setting", + ) + wait_for_render(iterations=40) + + screen = lv.screen_active() + dropdown = find_dropdown_widget(screen) + self.assertIsNotNone(dropdown, "Auth Mode dropdown not found") + + coords = get_widget_coords(dropdown) + self.assertIsNotNone(coords, "Could not get dropdown coordinates") + + print(f"Clicking dropdown at ({coords['center_x']}, {coords['center_y']})") + simulate_click(coords["center_x"], coords["center_y"], press_duration_ms=100) + wait_for_render(iterations=20) + + self.assertTrue( + select_dropdown_option_by_text(dropdown, "WPA2", allow_partial=True), + "Could not select WPA2 option in dropdown", + ) + wait_for_render(iterations=20) + + self.assertTrue( + click_button("Save"), + "Could not click Save button in Auth Mode settings", + ) + wait_for_render(iterations=40) + + screen = lv.screen_active() + self.assertTrue( + verify_text_present(screen, "Password"), + "Password setting did not appear after selecting WPA2", + ) + + self.assertTrue( + click_label("Auth Mode"), + "Could not click Auth Mode setting to revert", + ) + wait_for_render(iterations=40) + + screen = lv.screen_active() + dropdown = find_dropdown_widget(screen) + self.assertIsNotNone(dropdown, "Auth Mode dropdown not found on revert") + + coords = get_widget_coords(dropdown) + self.assertIsNotNone(coords, "Could not get dropdown coordinates on revert") + + print(f"Clicking dropdown at ({coords['center_x']}, {coords['center_y']})") + simulate_click(coords["center_x"], coords["center_y"], press_duration_ms=100) + wait_for_render(iterations=20) + + self.assertTrue( + select_dropdown_option_by_text(dropdown, "None", allow_partial=True), + "Could not select None option in dropdown", + ) + wait_for_render(iterations=20) + + self.assertTrue( + click_button("Save"), + "Could not click Save button in Auth Mode settings (revert)", + ) + wait_for_render(iterations=40) + + screen = lv.screen_active() + print("\nSettings screen labels after Auth Mode revert:") + print_screen_labels(screen) + + self.assertFalse( + verify_text_present(screen, "Password"), + "Password setting did not disappear after selecting None", + ) + + print("\n=== Hotspot settings Password hide test completed ===") + + def test_auth_mode_change_shows_password_setting(self): + """Verify Password setting appears after switching Auth Mode to WPA2.""" + print("\n=== Starting Hotspot Settings Password visibility test ===") + + self._reset_hotspot_preferences() + screen = self._open_hotspot_settings_screen() + + self.assertFalse( + verify_text_present(screen, "Password"), + "Password setting should not be visible with Auth Mode None", + ) + + self.assertTrue( + click_label("Auth Mode"), + "Could not click Auth Mode setting", + ) + wait_for_render(iterations=40) + + screen = lv.screen_active() + dropdown = find_dropdown_widget(screen) + self.assertIsNotNone(dropdown, "Auth Mode dropdown not found") + + coords = get_widget_coords(dropdown) + self.assertIsNotNone(coords, "Could not get dropdown coordinates") + + print(f"Clicking dropdown at ({coords['center_x']}, {coords['center_y']})") + simulate_click(coords["center_x"], coords["center_y"], press_duration_ms=100) + wait_for_render(iterations=20) + + self.assertTrue( + select_dropdown_option_by_text(dropdown, "WPA2", allow_partial=True), + "Could not select WPA2 option in dropdown", + ) + wait_for_render(iterations=20) + + self.assertTrue( + click_button("Save"), + "Could not click Save button in Auth Mode settings", + ) + wait_for_render(iterations=40) + + screen = lv.screen_active() + print("\nSettings screen labels after Auth Mode change:") + print_screen_labels(screen) + + self.assertTrue( + verify_text_present(screen, "Password"), + "Password setting did not appear after selecting WPA2", + ) + + print("\n=== Hotspot settings Password visibility test completed ===") + + def test_auth_mode_dropdown_select_wpa2(self): + """Change Auth Mode via dropdown and verify stored value label.""" + print("\n=== Starting Hotspot Settings Auth Mode dropdown test ===") + + self._reset_hotspot_preferences() + screen = self._open_hotspot_settings_screen() + + self.assertTrue( + click_label("Auth Mode"), + "Could not click Auth Mode setting", + ) + wait_for_render(iterations=40) + + screen = lv.screen_active() + print("\nAuth Mode edit screen labels:") + print_screen_labels(screen) + + dropdown = find_dropdown_widget(screen) + self.assertIsNotNone(dropdown, "Auth Mode dropdown not found") + + coords = get_widget_coords(dropdown) + self.assertIsNotNone(coords, "Could not get dropdown coordinates") + + print(f"Clicking dropdown at ({coords['center_x']}, {coords['center_y']})") + simulate_click(coords["center_x"], coords["center_y"], press_duration_ms=100) + wait_for_render(iterations=20) + + self.assertTrue( + select_dropdown_option_by_text(dropdown, "WPA2", allow_partial=True), + "Could not select WPA2 option in dropdown", + ) + wait_for_render(iterations=20) + + self.assertTrue( + click_button("Save"), + "Could not click Save button in Auth Mode settings", + ) + wait_for_render(iterations=40) + + screen = lv.screen_active() + print("\nSettings screen labels after save:") + print_screen_labels(screen) + + value_text = get_setting_value_text(screen, "Auth Mode") + print(f"Auth Mode value text after save: {value_text}") + self.assertEqual( + value_text, + "wpa2", + "Auth Mode value did not update to wpa2", + ) + + print("\n=== Hotspot settings Auth Mode dropdown test completed ===") + + +if __name__ == "__main__": + pass diff --git a/tests/test_graphical_hotspot_security_none.py b/tests/test_graphical_hotspot_security_none.py new file mode 100644 index 00000000..a3367054 --- /dev/null +++ b/tests/test_graphical_hotspot_security_none.py @@ -0,0 +1,150 @@ +""" +Graphical test for hotspot start flow with security none and invalid password handling. + +This test verifies: +1) Starting hotspot with default settings and Security: None succeeds. +2) Starting hotspot with an invalid WPA2 password fails and leaves hotspot disabled. + +Usage: + Desktop: ./tests/unittest.sh tests/test_graphical_hotspot_security_none.py + Device: ./tests/unittest.sh tests/test_graphical_hotspot_security_none.py --ondevice +""" + +import unittest +import lvgl as lv +import mpos.ui +from mpos import ( + AppManager, + WifiService, + SharedPreferences, + wait_for_render, + click_button, + print_screen_labels, + verify_text_present, +) + + +class TestGraphicalHotspotSecurityNone(unittest.TestCase): + """Graphical tests for hotspot security handling.""" + + def _reset_hotspot_preferences(self): + prefs = SharedPreferences("com.micropythonos.settings.hotspot") + editor = prefs.edit() + editor.remove_all() + editor.commit() + + def _set_hotspot_preferences(self, ssid=None, password=None, authmode=None): + prefs = SharedPreferences("com.micropythonos.settings.hotspot") + editor = prefs.edit() + if ssid is not None: + editor.put_string("ssid", ssid) + if password is not None: + editor.put_string("password", password) + if authmode is not None: + editor.put_string("authmode", authmode) + editor.commit() + + def _open_hotspot_screen(self): + result = AppManager.start_app("com.micropythonos.settings.hotspot") + self.assertTrue(result, "Failed to start hotspot settings app") + wait_for_render(iterations=20) + screen = lv.screen_active() + print("\nHotspot screen labels:") + print_screen_labels(screen) + return screen + + def tearDown(self): + try: + WifiService.disable_hotspot() + except Exception: + pass + + try: + mpos.ui.back_screen() + wait_for_render(5) + except Exception: + pass + + def test_security_none_allows_open_hotspot(self): + """Ensure Security: None starts an open hotspot successfully.""" + print("\n=== Starting hotspot security none test ===") + + self._reset_hotspot_preferences() + screen = self._open_hotspot_screen() + + self.assertFalse( + WifiService.is_hotspot_enabled(), + "Hotspot should be disabled before pressing Start", + ) + + WifiService.wifi_busy = False + + self.assertTrue( + click_button("Start"), + "Could not find Start button in hotspot app", + ) + wait_for_render(iterations=40) + + self.assertTrue( + WifiService.is_hotspot_enabled(), + "Hotspot should be enabled with Security: None", + ) + + screen = lv.screen_active() + print("\nHotspot screen labels after Start:") + print_screen_labels(screen) + self.assertTrue( + verify_text_present(screen, "Security: None"), + "Hotspot should display Security: None after start", + ) + self.assertTrue( + verify_text_present(screen, "Status: Running"), + "Hotspot should display Status: Running after start", + ) + + print("\n=== Hotspot security none test completed ===") + + @unittest.skipIf( + WifiService._is_desktop_mode(None), + "Invalid password handling requires device network stack", + ) + def test_invalid_password_fails_and_reports_disabled(self): + """Ensure invalid WPA2 password fails and hotspot remains disabled.""" + print("\n=== Starting hotspot invalid password test ===") + + self._reset_hotspot_preferences() + self._set_hotspot_preferences(password="123", authmode="wpa2") + + screen = self._open_hotspot_screen() + + self.assertFalse( + WifiService.is_hotspot_enabled(), + "Hotspot should be disabled before pressing Start", + ) + + WifiService.wifi_busy = False + + self.assertTrue( + click_button("Start"), + "Could not find Start button in hotspot app", + ) + wait_for_render(iterations=40) + + self.assertFalse( + WifiService.is_hotspot_enabled(), + "Hotspot should remain disabled when password is invalid", + ) + + screen = lv.screen_active() + print("\nHotspot screen labels after invalid password attempt:") + print_screen_labels(screen) + self.assertTrue( + verify_text_present(screen, "Status: Stopped"), + "Hotspot should display Status: Stopped after failed start", + ) + + print("\n=== Hotspot invalid password test completed ===") + + +if __name__ == "__main__": + pass diff --git a/tests/test_graphical_hotspot_settings.py b/tests/test_graphical_hotspot_settings.py new file mode 100644 index 00000000..4a7b12e9 --- /dev/null +++ b/tests/test_graphical_hotspot_settings.py @@ -0,0 +1,185 @@ +""" +Graphical test for hotspot settings refreshing overview values. + +This test verifies: +1) Auth Mode changes in settings are reflected on the hotspot overview. +2) SSID changes in settings are reflected on the hotspot overview. + +Usage: + Desktop: ./tests/unittest.sh tests/test_graphical_hotspot_settings.py + Device: ./tests/unittest.sh tests/test_graphical_hotspot_settings.py --ondevice +""" + +import unittest +import lvgl as lv +import mpos.ui +from mpos import ( + AppManager, + SharedPreferences, + WifiService, + wait_for_render, + click_button, + click_label, + print_screen_labels, + verify_text_present, + find_dropdown_widget, + select_dropdown_option_by_text, + get_widget_coords, + simulate_click, +) + + +class TestGraphicalHotspotSettings(unittest.TestCase): + """Graphical tests for hotspot settings refresh.""" + + def _reset_hotspot_preferences(self): + prefs = SharedPreferences("com.micropythonos.settings.hotspot") + editor = prefs.edit() + editor.remove_all() + editor.commit() + + def _open_hotspot_settings_screen(self): + result = AppManager.start_app("com.micropythonos.settings.hotspot") + self.assertTrue(result, "Failed to start hotspot settings app") + wait_for_render(iterations=20) + + screen = lv.screen_active() + print("\nHotspot overview labels:") + print_screen_labels(screen) + + self.assertTrue( + click_button("Settings"), + "Could not find Settings button in hotspot app", + ) + wait_for_render(iterations=40) + + screen = lv.screen_active() + print("\nHotspot settings labels:") + print_screen_labels(screen) + return screen + + def _find_textarea(self, node): + try: + if node.__class__.__name__ == "textarea": + return node + if hasattr(node, "set_one_line") and hasattr(node, "set_text") and hasattr(node, "get_text"): + return node + except Exception: + pass + + try: + child_count = node.get_child_count() + except Exception: + return None + + for i in range(child_count): + child = node.get_child(i) + result = self._find_textarea(child) + if result: + return result + return None + + def tearDown(self): + try: + WifiService.disable_hotspot() + except Exception: + pass + + try: + mpos.ui.back_screen() + wait_for_render(5) + except Exception: + pass + + def test_auth_mode_change_updates_overview_security(self): + """Verify Auth Mode change is reflected on the hotspot overview.""" + print("\n=== Starting hotspot Auth Mode overview refresh test ===") + + self._reset_hotspot_preferences() + self._open_hotspot_settings_screen() + + self.assertTrue( + click_label("Auth Mode"), + "Could not click Auth Mode setting", + ) + wait_for_render(iterations=40) + + screen = lv.screen_active() + dropdown = find_dropdown_widget(screen) + self.assertIsNotNone(dropdown, "Auth Mode dropdown not found") + + coords = get_widget_coords(dropdown) + self.assertIsNotNone(coords, "Could not get dropdown coordinates") + + print(f"Clicking dropdown at ({coords['center_x']}, {coords['center_y']})") + simulate_click(coords["center_x"], coords["center_y"], press_duration_ms=100) + wait_for_render(iterations=20) + + self.assertTrue( + select_dropdown_option_by_text(dropdown, "WPA2", allow_partial=True), + "Could not select WPA2 option in dropdown", + ) + wait_for_render(iterations=20) + + self.assertTrue( + click_button("Save"), + "Could not click Save button in Auth Mode settings", + ) + wait_for_render(iterations=40) + + mpos.ui.back_screen() + wait_for_render(iterations=20) + + screen = lv.screen_active() + print("\nHotspot overview labels after Auth Mode change:") + print_screen_labels(screen) + self.assertTrue( + verify_text_present(screen, "Security: WPA2"), + "Hotspot overview did not update Security after Auth Mode change", + ) + + print("\n=== Hotspot Auth Mode overview refresh test completed ===") + + def test_ssid_change_updates_overview_name(self): + """Verify SSID change is reflected on the hotspot overview.""" + print("\n=== Starting hotspot SSID overview refresh test ===") + + new_ssid = "MPOS-Test-SSID" + + self._reset_hotspot_preferences() + self._open_hotspot_settings_screen() + + self.assertTrue( + click_label("Network Name (SSID)"), + "Could not click Network Name (SSID) setting", + ) + wait_for_render(iterations=40) + + screen = lv.screen_active() + textarea = self._find_textarea(screen) + self.assertIsNotNone(textarea, "SSID textarea not found") + textarea.set_text(new_ssid) + wait_for_render(iterations=10) + + self.assertTrue( + click_button("Save"), + "Could not click Save button in SSID settings", + ) + wait_for_render(iterations=40) + + mpos.ui.back_screen() + wait_for_render(iterations=20) + + screen = lv.screen_active() + print("\nHotspot overview labels after SSID change:") + print_screen_labels(screen) + self.assertTrue( + verify_text_present(screen, f"Hotspot name: {new_ssid}"), + "Hotspot overview did not update SSID after settings change", + ) + + print("\n=== Hotspot SSID overview refresh test completed ===") + + +if __name__ == "__main__": + pass diff --git a/tests/test_graphical_hotspot_then_station.py b/tests/test_graphical_hotspot_then_station.py new file mode 100644 index 00000000..c5360512 --- /dev/null +++ b/tests/test_graphical_hotspot_then_station.py @@ -0,0 +1,151 @@ +""" +Graphical test for enabling hotspot from the Hotspot Settings app. + +This test launches the hotspot settings app, verifies the hotspot is initially +stopped, clicks the "Start" button, then verifies the hotspot is running. + +Usage: + Desktop: ./tests/unittest.sh tests/test_graphical_hotspot_then_station.py + Device: ./tests/unittest.sh tests/test_graphical_hotspot_then_station.py --ondevice +""" + +import unittest +import time +import lvgl as lv +import mpos.ui +from mpos import ( + AppManager, + WifiService, + wait_for_render, + click_button, + print_screen_labels, + get_widget_coords, + simulate_click, +) + + +class TestGraphicalHotspotThenStation(unittest.TestCase): + """Test hotspot start flow via the hotspot settings app.""" + + def _find_first_list_item(self, screen): + def find_list(node): + try: + if node.__class__.__name__ == "list": + return node + except Exception: + pass + try: + if hasattr(node, "add_button") and hasattr(node, "get_child_count"): + return node + except Exception: + pass + try: + child_count = node.get_child_count() + except Exception: + child_count = 0 + for i in range(child_count): + child = node.get_child(i) + found = find_list(child) + if found: + return found + return None + + wifi_list = find_list(screen) + if wifi_list is None: + return None + try: + if wifi_list.get_child_count() < 1: + return None + return wifi_list.get_child(0) + except Exception: + return None + + def tearDown(self): + """Clean up after each test method.""" + try: + WifiService.disable_hotspot() + except Exception: + pass + + try: + mpos.ui.back_screen() + wait_for_render(5) + except Exception: + pass + + def test_hotspot_start_button_enables_hotspot(self): + """Start the hotspot app and verify hotspot toggles on.""" + print("\n=== Starting hotspot start-flow test ===") + + WifiService.disable_hotspot() + wait_for_render(5) + + result = AppManager.start_app("com.micropythonos.settings.hotspot") + self.assertTrue(result, "Failed to start hotspot settings app") + wait_for_render(iterations=20) + + screen = lv.screen_active() + print("\nHotspot screen labels:") + print_screen_labels(screen) + + self.assertFalse( + WifiService.is_hotspot_enabled(), + "Hotspot should be disabled before pressing Start", + ) + + WifiService.wifi_busy = False + + self.assertTrue( + click_button("Start"), + "Could not find Start button in hotspot app", + ) + wait_for_render(iterations=20) + + self.assertTrue( + WifiService.is_hotspot_enabled(), + "Hotspot should be enabled after pressing Start", + ) + + result = AppManager.start_app("com.micropythonos.settings.wifi") + self.assertTrue(result, "Failed to start WiFi settings app") + wait_for_render(iterations=20) + + screen = lv.screen_active() + print("\nWiFi screen labels (before scan wait):") + print_screen_labels(screen) + + print("\nWaiting 10 seconds for WiFi scan to finish...") + time.sleep(10) + wait_for_render(iterations=20) + + screen = lv.screen_active() + print("\nWiFi screen labels (after scan wait):") + print_screen_labels(screen) + + first_item = self._find_first_list_item(screen) + self.assertIsNotNone(first_item, "Could not find first WiFi access point") + + coords = get_widget_coords(first_item) + if coords: + print(f"Clicking first WiFi access point at ({coords['center_x']}, {coords['center_y']})") + first_item.send_event(lv.EVENT.CLICKED, None) + else: + first_item.send_event(lv.EVENT.CLICKED, None) + wait_for_render(iterations=40) + + self.assertTrue( + click_button("Connect"), + "Could not find Connect button in WiFi edit screen", + ) + wait_for_render(iterations=40) + + self.assertFalse( + WifiService.is_hotspot_enabled(), + "Hotspot should be disabled after connecting to a WiFi access point", + ) + + print("\n=== Hotspot start-flow test completed ===") + + +if __name__ == "__main__": + pass diff --git a/tests/test_graphical_imu_calibration.py b/tests/test_graphical_imu_calibration.py index be761b35..204ddf63 100644 --- a/tests/test_graphical_imu_calibration.py +++ b/tests/test_graphical_imu_calibration.py @@ -11,40 +11,26 @@ import unittest import lvgl as lv -import mpos.apps import mpos.ui -import os -import sys import time -from mpos.ui.testing import ( +from mpos 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 + find_button_with_text, + click_label, + click_button, + find_text_on_screen, + AppManager ) 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 @@ -60,7 +46,7 @@ def test_check_calibration_activity_loads(self): print("\n=== Testing CheckIMUCalibrationActivity ===") # Navigate: Launcher -> Settings -> Check IMU Calibration - result = mpos.apps.start_app("com.micropythonos.settings") + result = AppManager.start_app("com.micropythonos.settings") self.assertTrue(result, "Failed to start Settings app") wait_for_render(15) @@ -68,16 +54,9 @@ def test_check_calibration_activity_loads(self): simulate_click(10, 10) wait_for_render(10) - # Find and click "Check IMU Calibration" setting - screen = lv.screen_active() - check_cal_label = find_label_with_text(screen, "Check IMU Calibration") - self.assertIsNotNone(check_cal_label, "Could not find 'Check IMU Calibration' setting") - - # Click on the setting container - coords = get_widget_coords(check_cal_label.get_parent()) - self.assertIsNotNone(coords, "Could not get coordinates of setting") - simulate_click(coords['center_x'], coords['center_y']) - wait_for_render(30) + 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() @@ -86,15 +65,6 @@ def test_check_calibration_activity_loads(self): 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): @@ -102,7 +72,7 @@ def test_calibrate_activity_flow(self): print("\n=== Testing CalibrateIMUActivity Flow ===") # Navigate: Launcher -> Settings -> Calibrate IMU - result = mpos.apps.start_app("com.micropythonos.settings") + result = AppManager.start_app("com.micropythonos.settings") self.assertTrue(result, "Failed to start Settings app") wait_for_render(15) @@ -110,15 +80,9 @@ def test_calibrate_activity_flow(self): simulate_click(10, 10) wait_for_render(10) - # Find and click "Calibrate IMU" setting - screen = lv.screen_active() - calibrate_label = find_label_with_text(screen, "Calibrate IMU") - self.assertIsNotNone(calibrate_label, "Could not find 'Calibrate IMU' setting") - - coords = get_widget_coords(calibrate_label.get_parent()) - self.assertIsNotNone(coords) - simulate_click(coords['center_x'], coords['center_y']) - wait_for_render(30) + 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() @@ -128,20 +92,16 @@ def test_calibrate_activity_flow(self): 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']) + # Use send_event instead of simulate_click (more reliable) + calibrate_btn.send_event(lv.EVENT.CLICKED, None) wait_for_render(10) # Wait for calibration to complete (mock takes ~3 seconds) - time.sleep(3.5) - wait_for_render(20) + time.sleep(4) + wait_for_render(40) # Verify calibration completed screen = lv.screen_active() @@ -154,10 +114,6 @@ def test_calibrate_activity_flow(self): 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): @@ -165,7 +121,7 @@ def test_navigation_from_check_to_calibrate(self): print("\n=== Testing Check -> Calibrate Navigation ===") # Navigate to Check activity - result = mpos.apps.start_app("com.micropythonos.settings") + result = AppManager.start_app("com.micropythonos.settings") self.assertTrue(result) wait_for_render(15) @@ -173,17 +129,12 @@ def test_navigation_from_check_to_calibrate(self): simulate_click(10, 10) wait_for_render(10) - screen = lv.screen_active() - check_cal_label = find_label_with_text(screen, "Check IMU Calibration") - coords = get_widget_coords(check_cal_label.get_parent()) - simulate_click(coords['center_x'], coords['center_y']) - wait_for_render(30) # Wait for real-time updates - - # Verify Check activity loaded - screen = lv.screen_active() - self.assertTrue(verify_text_present(screen, "on flat surface"), "Check activity did not load") + 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") diff --git a/tests/test_graphical_imu_calibration_ui_bug.py b/tests/test_graphical_imu_calibration_ui_bug.py new file mode 100644 index 00000000..ec44511f --- /dev/null +++ b/tests/test_graphical_imu_calibration_ui_bug.py @@ -0,0 +1,181 @@ +#!/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 import ( + wait_for_render, + simulate_click, + find_button_with_text, + find_label_with_text, + get_widget_coords, + print_screen_labels, + click_label, + click_button, + find_text_on_screen, + AppManager +) + + +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...") + + # Start Settings app by name + AppManager.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() + + # Look for actual values (not "--") + has_values_before = False + widgets = [] + from mpos 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() + + # 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 index f1e0c54b..31e415d6 100644 --- a/tests/test_graphical_keyboard_animation.py +++ b/tests/test_graphical_keyboard_animation.py @@ -1,8 +1,8 @@ """ -Test MposKeyboard animation support (show/hide with mpos.ui.anim). +Test MposKeyboard animation support (show/hide with WidgetAnimator). This test reproduces the bug where MposKeyboard is missing methods -required by mpos.ui.anim.smooth_show() and smooth_hide(). +required by WidgetAnimator.smooth_show() and smooth_hide(). Usage: Desktop: ./tests/unittest.sh tests/test_graphical_keyboard_animation.py @@ -12,59 +12,37 @@ 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 +from mpos.ui.widget_animator import WidgetAnimator +from mpos.ui.testing import KeyboardTestCase -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") +class TestKeyboardAnimation(KeyboardTestCase): + """Test MposKeyboard compatibility with animation system.""" 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. + This method is required by WidgetAnimator 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) + self.create_keyboard_scene() + self.keyboard.add_flag(lv.obj.FLAG.HIDDEN) # Verify method exists self.assertTrue( - hasattr(keyboard, 'set_style_opa'), + hasattr(self.keyboard, 'set_style_opa'), "MposKeyboard missing set_style_opa method" ) self.assertTrue( - callable(getattr(keyboard, 'set_style_opa')), + callable(getattr(self.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) + self.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}") @@ -79,15 +57,13 @@ def test_keyboard_smooth_show(self): """ 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) + self.create_keyboard_scene() + self.keyboard.add_flag(lv.obj.FLAG.HIDDEN) # This should work without raising AttributeError try: - mpos.ui.anim.smooth_show(keyboard) - wait_for_render(100) + WidgetAnimator.smooth_show(self.keyboard) + self.wait_for_render(100) print("smooth_show called successfully") except AttributeError as e: self.fail(f"smooth_show raised AttributeError: {e}\n" @@ -95,7 +71,7 @@ def test_keyboard_smooth_show(self): # Verify keyboard is no longer hidden self.assertFalse( - keyboard.has_flag(lv.obj.FLAG.HIDDEN), + self.keyboard.has_flag(lv.obj.FLAG.HIDDEN), "Keyboard should not be hidden after smooth_show" ) @@ -109,15 +85,13 @@ def test_keyboard_smooth_hide(self): """ print("Testing smooth_hide animation...") - keyboard = MposKeyboard(self.screen) - keyboard.set_textarea(self.textarea) - keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + self.create_keyboard_scene() # Start visible - keyboard.remove_flag(lv.obj.FLAG.HIDDEN) + self.keyboard.remove_flag(lv.obj.FLAG.HIDDEN) # This should work without raising AttributeError try: - mpos.ui.anim.smooth_hide(keyboard) + WidgetAnimator.smooth_hide(self.keyboard) print("smooth_hide called successfully") except AttributeError as e: self.fail(f"smooth_hide raised AttributeError: {e}\n" @@ -135,28 +109,26 @@ def test_keyboard_show_hide_cycle(self): """ 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) + self.create_keyboard_scene() + self.keyboard.add_flag(lv.obj.FLAG.HIDDEN) # Initial state: hidden - self.assertTrue(keyboard.has_flag(lv.obj.FLAG.HIDDEN)) + self.assertTrue(self.keyboard.has_flag(lv.obj.FLAG.HIDDEN)) # Show keyboard (simulates textarea click) try: - mpos.ui.anim.smooth_show(keyboard) - wait_for_render(100) + WidgetAnimator.smooth_show(self.keyboard) + self.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)) + self.assertFalse(self.keyboard.has_flag(lv.obj.FLAG.HIDDEN)) # Hide keyboard (simulates pressing Enter) try: - mpos.ui.anim.smooth_hide(keyboard) - wait_for_render(100) + WidgetAnimator.smooth_hide(self.keyboard) + self.wait_for_render(100) except AttributeError as e: self.fail(f"Failed during smooth_hide: {e}") @@ -170,22 +142,19 @@ def test_keyboard_has_get_y_and_set_y(self): """ print("Testing get_y and set_y methods...") - keyboard = MposKeyboard(self.screen) - keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + self.create_keyboard_scene() # Verify methods exist - self.assertTrue(hasattr(keyboard, 'get_y'), "Missing get_y method") - self.assertTrue(hasattr(keyboard, 'set_y'), "Missing set_y method") + self.assertTrue(hasattr(self.keyboard, 'get_y'), "Missing get_y method") + self.assertTrue(hasattr(self.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() + y = self.keyboard.get_y() + self.keyboard.set_y(y + 10) + new_y = self.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 index 0710735f..4c37138e 100644 --- a/tests/test_graphical_keyboard_crash_reproduction.py +++ b/tests/test_graphical_keyboard_crash_reproduction.py @@ -9,8 +9,7 @@ import unittest import lvgl as lv -from mpos.ui.keyboard import MposKeyboard -from mpos.ui.testing import wait_for_render +from mpos import MposKeyboard, wait_for_render class TestKeyboardCrash(unittest.TestCase): diff --git a/tests/test_graphical_keyboard_default_vs_custom.py b/tests/test_graphical_keyboard_default_vs_custom.py index 5fba3b9b..4751ae0d 100644 --- a/tests/test_graphical_keyboard_default_vs_custom.py +++ b/tests/test_graphical_keyboard_default_vs_custom.py @@ -10,24 +10,20 @@ import unittest import lvgl as lv -from mpos.ui.keyboard import MposKeyboard -from mpos.ui.testing import wait_for_render +from mpos import MposKeyboard +from mpos.ui.testing import GraphicalTestCase -class TestDefaultVsCustomKeyboard(unittest.TestCase): +class TestDefaultVsCustomKeyboard(GraphicalTestCase): """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) + super().setUp() def tearDown(self): """Clean up.""" - lv.screen_load(lv.obj()) - wait_for_render(5) + super().tearDown() def test_default_lvgl_keyboard_layout(self): """ @@ -42,13 +38,13 @@ def test_default_lvgl_keyboard_layout(self): textarea.set_size(280, 40) textarea.align(lv.ALIGN.TOP_MID, 0, 10) textarea.set_one_line(True) - wait_for_render(5) + self.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) + self.wait_for_render(10) print("\nDefault LVGL keyboard buttons (first 40):") found_special_labels = {} @@ -88,13 +84,13 @@ def test_custom_mpos_keyboard_layout(self): textarea.set_size(280, 40) textarea.align(lv.ALIGN.TOP_MID, 0, 10) textarea.set_one_line(True) - wait_for_render(5) + self.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) + self.wait_for_render(10) print("\nCustom MposKeyboard buttons (first 40):") found_special_labels = {} @@ -131,12 +127,12 @@ def test_mode_switching_bug_reproduction(self): textarea.set_size(280, 40) textarea.align(lv.ALIGN.TOP_MID, 0, 10) textarea.set_one_line(True) - wait_for_render(5) + self.wait_for_render(5) keyboard = MposKeyboard(self.screen) keyboard.set_textarea(textarea) keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) - wait_for_render(10) + self.wait_for_render(10) # Step 1: Start in lowercase print("\nStep 1: Initial lowercase mode") @@ -147,7 +143,7 @@ def test_mode_switching_bug_reproduction(self): # Step 2: Switch to numbers print("\nStep 2: Switch to numbers mode") keyboard.set_mode(MposKeyboard.MODE_NUMBERS) - wait_for_render(5) + self.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") @@ -155,7 +151,7 @@ def test_mode_switching_bug_reproduction(self): # 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) + self.wait_for_render(5) labels_step3 = self._get_special_labels(keyboard) print(f" Labels: {list(labels_step3.keys())}") diff --git a/tests/test_graphical_keyboard_layout_switching.py b/tests/test_graphical_keyboard_layout_switching.py index 83c2bcec..d1a0d922 100644 --- a/tests/test_graphical_keyboard_layout_switching.py +++ b/tests/test_graphical_keyboard_layout_switching.py @@ -11,8 +11,7 @@ import unittest import lvgl as lv -from mpos.ui.keyboard import MposKeyboard -from mpos.ui.testing import wait_for_render +from mpos import MposKeyboard, wait_for_render class TestKeyboardLayoutSwitching(unittest.TestCase): diff --git a/tests/test_graphical_keyboard_method_forwarding.py b/tests/test_graphical_keyboard_method_forwarding.py index e96b3d63..883eadbf 100644 --- a/tests/test_graphical_keyboard_method_forwarding.py +++ b/tests/test_graphical_keyboard_method_forwarding.py @@ -10,7 +10,7 @@ import unittest import lvgl as lv -from mpos.ui.keyboard import MposKeyboard +from mpos import MposKeyboard class TestMethodForwarding(unittest.TestCase): diff --git a/tests/test_graphical_keyboard_mode_switch.py b/tests/test_graphical_keyboard_mode_switch.py index 85967d1d..774a194f 100644 --- a/tests/test_graphical_keyboard_mode_switch.py +++ b/tests/test_graphical_keyboard_mode_switch.py @@ -11,8 +11,7 @@ import unittest import lvgl as lv -from mpos.ui.keyboard import MposKeyboard -from mpos.ui.testing import wait_for_render +from mpos import MposKeyboard, wait_for_render class TestKeyboardModeSwitch(unittest.TestCase): diff --git a/tests/test_graphical_keyboard_q_button_bug.py b/tests/test_graphical_keyboard_q_button_bug.py index dae8e307..18620d30 100644 --- a/tests/test_graphical_keyboard_q_button_bug.py +++ b/tests/test_graphical_keyboard_q_button_bug.py @@ -14,32 +14,11 @@ """ 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).""" +from mpos.ui.testing import KeyboardTestCase - 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) +class TestKeyboardQButton(KeyboardTestCase): + """Test keyboard button functionality (especially 'q' which was at index 0).""" def test_q_button_works(self): """ @@ -51,82 +30,50 @@ def test_q_button_works(self): 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) + 2. Click 'q' button using click_keyboard_button helper + 3. Verify 'q' appears in textarea (should PASS after fix) + 4. Repeat with 'a' button + 5. 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 + # Create keyboard scene (textarea + keyboard) + self.create_keyboard_scene() - print(f"Initial textarea: '{textarea.get_text()}'") - self.assertEqual(textarea.get_text(), "", "Textarea should start empty") + print(f"Initial textarea: '{self.get_textarea_text()}'") + self.assertTextareaEmpty("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 + # Click the 'q' button using the reliable click_keyboard_button helper + success = self.click_keyboard_button("q") + self.assertTrue(success, "Should find and click 'q' button on keyboard") # Check textarea content - text_after_q = textarea.get_text() + text_after_q = self.get_textarea_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") + self.assertTextareaText("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) + self.clear_textarea() 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) + # Click the 'a' button using the reliable click_keyboard_button helper + success = self.click_keyboard_button("a") + self.assertTrue(success, "Should find and click 'a' button on keyboard") # Check textarea content - text_after_a = textarea.get_text() + text_after_a = self.get_textarea_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") + self.assertTextareaText("a", "Clicking 'a' button should add 'a' to textarea") print("\nSummary:") print(f" 'q' button result: '{text_after_q}' (expected 'q') ✓") @@ -142,26 +89,16 @@ def test_keyboard_button_discovery(self): """ 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) + # Create keyboard scene + self.create_keyboard_scene() - # Iterate through button indices to find all buttons + # Get all buttons using the base class helper + found_buttons = self.get_all_keyboard_buttons() + + # Print first 20 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 + for idx, text in found_buttons[:20]: + print(f" Button {idx}: '{text}'") if len(found_buttons) > 20: print(f" ... (showing first 20 of {len(found_buttons)} buttons)") @@ -173,16 +110,12 @@ def test_keyboard_button_discovery(self): 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: + idx = self.find_keyboard_button_index(letter) + if idx is not None: + print(f" '{letter}' at index {idx}") + else: 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 index 7cded668..ab1204de 100644 --- a/tests/test_graphical_keyboard_rapid_mode_switch.py +++ b/tests/test_graphical_keyboard_rapid_mode_switch.py @@ -11,8 +11,7 @@ import unittest import lvgl as lv -from mpos.ui.keyboard import MposKeyboard -from mpos.ui.testing import wait_for_render +from mpos import MposKeyboard, wait_for_render class TestRapidModeSwitching(unittest.TestCase): diff --git a/tests/test_graphical_keyboard_styling.py b/tests/test_graphical_keyboard_styling.py index 1f925972..a895eb1e 100644 --- a/tests/test_graphical_keyboard_styling.py +++ b/tests/test_graphical_keyboard_styling.py @@ -5,9 +5,8 @@ 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 +The test uses a programmatic approach: Query LVGL style properties to verify +button background colors. This test should INITIALLY FAIL, demonstrating the bug before the fix is applied. @@ -21,10 +20,9 @@ import mpos.ui import mpos.config import sys -import os -from mpos.ui.testing import ( +from mpos import ( wait_for_render, - capture_screenshot, + AppearanceManager, ) @@ -33,18 +31,6 @@ class TestKeyboardStyling(unittest.TestCase): 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") @@ -62,7 +48,7 @@ def tearDown(self): editor.commit() # Reapply original theme - mpos.ui.theme.set_theme(prefs) + AppearanceManager.set_theme(prefs) print("=== Test cleanup complete ===\n") @@ -90,7 +76,7 @@ def _create_test_keyboard(self): keyboard.set_style_min_height(160, 0) # Apply the keyboard button fix - mpos.ui.theme.fix_keyboard_button_style(keyboard) + AppearanceManager.apply_keyboard_fix(keyboard) # Load the screen and wait for rendering lv.screen_load(screen) @@ -228,7 +214,7 @@ def test_keyboard_buttons_visible_in_light_mode(self): editor.commit() # Apply theme - mpos.ui.theme.set_theme(prefs) + AppearanceManager.set_theme(prefs) wait_for_render(iterations=10) # Create test keyboard @@ -242,11 +228,6 @@ def test_keyboard_buttons_visible_in_light_mode(self): 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) @@ -282,7 +263,7 @@ def test_keyboard_buttons_visible_in_dark_mode(self): editor.commit() # Apply theme - mpos.ui.theme.set_theme(prefs) + AppearanceManager.set_theme(prefs) wait_for_render(iterations=10) # Create test keyboard @@ -296,11 +277,6 @@ def test_keyboard_buttons_visible_in_dark_mode(self): 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) @@ -335,7 +311,7 @@ def test_keyboard_buttons_not_pure_white_in_light_mode(self): editor.commit() # Apply theme - mpos.ui.theme.set_theme(prefs) + AppearanceManager.set_theme(prefs) wait_for_render(iterations=10) # Create test keyboard diff --git a/tests/test_graphical_launch_all_apps.py b/tests/test_graphical_launch_all_apps.py index 6dcae3bb..d7c1d53e 100644 --- a/tests/test_graphical_launch_all_apps.py +++ b/tests/test_graphical_launch_all_apps.py @@ -13,10 +13,7 @@ # 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 +from mpos import wait_for_render, ui, AppManager class TestLaunchAllApps(unittest.TestCase): @@ -33,9 +30,20 @@ def setUp(self): def _discover_apps(self): """Discover all installed apps.""" # Use PackageManager to get all apps - all_packages = PackageManager.get_app_list() + all_packages = AppManager.get_app_list() + skipped_packages = { + 'com.micropythonos.breakout', + 'com.micropythonos.doom', + 'cz.ucw.pavel.cellular', + 'cz.ucw.pavel.compass', + 'cz.ucw.pavel.navstar', + } for package in all_packages: + # Skip apps that should not be launched by this test + if package.fullname in skipped_packages: + continue + # Get the main activity for each app if package.activities: # Use first activity as the main one (activities are dicts) @@ -67,7 +75,7 @@ def test_launch_all_apps(self): try: # Launch the app by package name - result = mpos.apps.start_app(package_name) + result = AppManager.start_app(package_name) # Wait for UI to render wait_for_render(iterations=5) @@ -124,16 +132,24 @@ def test_launch_all_apps(self): ] # On macOS, musicplayer is known to fail due to @micropython.viper issue + # and camera app fails due to no camera hardware is_macos = sys.platform == 'darwin' musicplayer_failures = [ fail for fail in failed_apps if fail['info']['package_name'] == 'com.micropythonos.musicplayer' and is_macos ] + + camera_failures = [ + fail for fail in failed_apps + if fail['info']['package_name'] == 'com.micropythonos.camera' 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) + not (fail['info']['package_name'] == 'com.micropythonos.musicplayer' and is_macos) and + not (fail['info']['package_name'] == 'com.micropythonos.breakout' and is_macos) and + not (fail['info']['package_name'] == 'com.micropythonos.camera' and is_macos) ] # Check if errortest app exists @@ -149,6 +165,10 @@ def test_launch_all_apps(self): # Report on musicplayer failures on macOS (known issue) if musicplayer_failures: print("⚠ Skipped musicplayer failure on macOS (known @micropython.viper issue)") + + # Report on camera failures on macOS (no camera hardware) + if camera_failures: + print("⚠ Skipped camera app failure on macOS (no camera hardware available)") # Fail the test if any non-errortest apps have errors if other_failures: @@ -180,7 +200,7 @@ def _launch_and_check_app(self, package_name, expected_error=False): try: # Launch the app by package name - result = mpos.apps.start_app(package_name) + result = AppManager.start_app(package_name) wait_for_render(iterations=5) # Check if start_app returned False (indicates error) diff --git a/tests/test_graphical_osupdate.py b/tests/test_graphical_osupdate.py index 3f718e73..f489e1f1 100644 --- a/tests/test_graphical_osupdate.py +++ b/tests/test_graphical_osupdate.py @@ -1,17 +1,15 @@ import unittest import lvgl as lv import mpos -import time -import sys -import os # Import graphical test helper -from mpos.ui.testing import ( +from mpos import ( wait_for_render, - capture_screenshot, find_label_with_text, verify_text_present, - print_screen_labels + print_screen_labels, + BuildInfo, + AppManager ) @@ -26,7 +24,7 @@ def tearDown(self): def test_app_launches_successfully(self): """Test that OSUpdate app launches without errors.""" - result = mpos.apps.start_app("com.micropythonos.osupdate") + result = AppManager.start_app("com.micropythonos.osupdate") self.assertTrue(result, "Failed to start OSUpdate app") wait_for_render(10) @@ -37,7 +35,7 @@ def test_app_launches_successfully(self): def test_ui_elements_exist(self): """Test that all required UI elements are created.""" - result = mpos.apps.start_app("com.micropythonos.osupdate") + result = AppManager.start_app("com.micropythonos.osupdate") self.assertTrue(result) wait_for_render(15) @@ -47,96 +45,31 @@ def test_ui_elements_exist(self): 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") + # Button text will be "Update OS", "Reinstall\nsame version", or "Install\nolder version" + update_button_found = verify_text_present(screen, "Update") or verify_text_present(screen, "update") or \ + verify_text_present(screen, "Reinstall") or verify_text_present(screen, "Install") 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") + def test_install_button_text_exists(self): + """Test that install button with update text exists on screen.""" + result = AppManager.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") + # Verify the button text is present - it will be "Update OS" initially + # (or "Reinstall\nsame version" or "Install\nolder version" depending on version comparison) + button_text_found = verify_text_present(screen, "Update OS") or \ + verify_text_present(screen, "Reinstall") or \ + verify_text_present(screen, "Install") + self.assertTrue(button_text_found, "Install button text should be present on screen") def test_current_version_displayed(self): """Test that current OS version is displayed correctly.""" - result = mpos.apps.start_app("com.micropythonos.osupdate") + result = AppManager.start_app("com.micropythonos.osupdate") self.assertTrue(result) wait_for_render(15) @@ -148,7 +81,7 @@ def test_current_version_displayed(self): # Check that it contains the current version label_text = version_label.get_text() - current_version = mpos.info.CURRENT_OS_VERSION + current_version = BuildInfo.version.release self.assertIn(current_version, label_text, f"Current version {current_version} not in label text: {label_text}") @@ -156,7 +89,7 @@ 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") + result = AppManager.start_app("com.micropythonos.osupdate") self.assertTrue(result) wait_for_render(15) @@ -169,9 +102,9 @@ def test_initial_status_message_without_wifi(self): 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") + def test_initial_state_labels(self): + """Print initial app labels for debugging.""" + result = AppManager.start_app("com.micropythonos.osupdate") self.assertTrue(result) wait_for_render(20) @@ -184,19 +117,6 @@ def test_screenshot_initial_state(self): 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() @@ -204,7 +124,7 @@ def tearDown(self): def test_status_label_exists(self): """Test that status label is created and visible.""" - result = mpos.apps.start_app("com.micropythonos.osupdate") + result = AppManager.start_app("com.micropythonos.osupdate") self.assertTrue(result) wait_for_render(15) @@ -223,7 +143,7 @@ def test_status_label_exists(self): def test_all_labels_readable(self): """Test that all labels are readable (no truncation issues).""" - result = mpos.apps.start_app("com.micropythonos.osupdate") + result = AppManager.start_app("com.micropythonos.osupdate") self.assertTrue(result) wait_for_render(15) @@ -238,49 +158,3 @@ def test_all_labels_readable(self): 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_screenshot.py b/tests/test_graphical_screenshot.py new file mode 100644 index 00000000..b96565a4 --- /dev/null +++ b/tests/test_graphical_screenshot.py @@ -0,0 +1,83 @@ +""" +Graphical test for screenshot capture. + +This test focuses on screenshot capture for visual regression testing. + +Usage: + Desktop: ./tests/unittest.sh tests/test_screenshot.py + Device: ./tests/unittest.sh tests/test_screenshot.py --ondevice +""" + +import os +import sys +import unittest +import mpos.ui +from mpos import AppManager, DeviceInfo, DisplayMetrics, capture_screenshot, wait_for_render + + +class TestScreenshotCapture(unittest.TestCase): + """Test suite for screenshot capture.""" + + def setUp(self): + """Set up test fixtures before each test method.""" + if sys.platform == "esp32": + self.screenshot_dir = "screenshots" + else: + self.screenshot_dir = "../tests/screenshots" + + try: + os.mkdir(self.screenshot_dir) + except OSError: + pass + + self.hardware_id = DeviceInfo.hardware_id + print(f"Testing with hardware ID: {self.hardware_id}") + + def tearDown(self): + """Clean up after each test method.""" + try: + mpos.ui.back_screen() + wait_for_render(5) + except Exception: + pass + + def test_capture_about_app_screenshot(self): + """Capture screenshot of the About app for regression testing.""" + print("\n=== Starting About app screenshot test ===") + + result = AppManager.start_app("com.micropythonos.about") + self.assertTrue(result, "Failed to start About app") + + wait_for_render(iterations=15) + + screenshot_path = f"{self.screenshot_dir}/about_app_{self.hardware_id}.raw" + print(f"\nCapturing screenshot to: {screenshot_path}") + + try: + width = DisplayMetrics.width() + height = DisplayMetrics.height() + buffer = capture_screenshot(screenshot_path, width=width, height=height) + print(f"Screenshot captured: {len(buffer)} bytes") + + stat = os.stat(screenshot_path) + self.assertTrue( + stat[6] > 0, + "Screenshot file is empty", + ) + expected_size = width * height * 2 + self.assertEqual( + stat[6], + expected_size, + f"Screenshot file size {stat[6]} does not match expected {expected_size}", + ) + print(f"Screenshot file size: {stat[6]} bytes") + except Exception as exc: + self.fail(f"Failed to capture screenshot: {exc}") + finally: + try: + print(f"Removing screenshot {screenshot_path}") + os.remove(screenshot_path) + except OSError: + pass + + print("\n=== About app screenshot test completed successfully ===") diff --git a/tests/test_graphical_start_app.py b/tests/test_graphical_start_app.py index e8634d7a..9ad18a16 100644 --- a/tests/test_graphical_start_app.py +++ b/tests/test_graphical_start_app.py @@ -13,9 +13,7 @@ """ import unittest -import mpos.apps -import mpos.ui -from mpos.ui.testing import wait_for_render +from mpos import ui, wait_for_render, AppManager class TestStartApp(unittest.TestCase): @@ -41,7 +39,7 @@ def test_normal(self): """Test that launching an existing app succeeds.""" print("Testing normal app launch...") - result = mpos.apps.start_app("com.micropythonos.launcher") + result = AppManager.start_app("com.micropythonos.launcher") wait_for_render(10) # Wait for app to load self.assertTrue(result, "com.micropythonos.launcher should start") @@ -51,7 +49,7 @@ 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") + result = AppManager.start_app("com.micropythonos.nonexistent") self.assertFalse(result, "com.micropythonos.nonexistent should not start") print("Non-existent app handled correctly") @@ -60,7 +58,7 @@ def test_restart_launcher(self): """Test that restarting the launcher succeeds.""" print("Testing launcher restart...") - result = mpos.apps.restart_launcher() + result = AppManager.restart_launcher() wait_for_render(10) # Wait for launcher to load self.assertTrue(result, "restart_launcher() should succeed") diff --git a/tests/test_graphical_wifi_keyboard.py b/tests/test_graphical_wifi_keyboard.py index 59fd910a..cf9afaaf 100644 --- a/tests/test_graphical_wifi_keyboard.py +++ b/tests/test_graphical_wifi_keyboard.py @@ -11,8 +11,7 @@ import unittest import lvgl as lv -from mpos.ui.keyboard import MposKeyboard -from mpos.ui.testing import wait_for_render +from mpos import MposKeyboard, wait_for_render class TestWiFiKeyboard(unittest.TestCase): diff --git a/tests/test_imu_calibration_ui_bug.py b/tests/test_imu_calibration_ui_bug.py deleted file mode 100755 index 59e55d70..00000000 --- a/tests/test_imu_calibration_ui_bug.py +++ /dev/null @@ -1,230 +0,0 @@ -#!/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 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 -) - -def click_button(button_text, timeout=5): - """Find and click a button with given text.""" - 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']})") - 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): - """Find a label with given text and click on it (or its clickable parent).""" - start = time.time() - while time.time() - start < timeout: - label = find_label_with_text(lv.screen_active(), label_text) - if label: - coords = get_widget_coords(label) - if coords: - print(f"Clicking label '{label_text}' at ({coords['center_x']}, {coords['center_y']})") - simulate_click(coords['center_x'], 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 - -def main(): - 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") - - 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 to return...") - if not click_button("Back"): - print("WARNING: Could not find Back button, trying Cancel...") - if not click_button("Cancel"): - print("FAILED: Could not navigate back to Settings main") - return False - 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...") - if not click_label("Check IMU Calibration"): - print("FAILED: Could not find Check IMU Calibration menu item") - return False - print("Check IMU Calibration opened\n") - - # Wait for quality check to complete - time.sleep(0.5) - wait_for_render(iterations=30) - - 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") - if not calibrate_btn: - print("FAILED: Could not find Calibrate button") - return False - - 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...") - if not click_button("Calibrate Now"): - print("FAILED: Could not find 'Calibrate Now' button") - return False - 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...") - if not click_button("Done"): - print("FAILED: Could not find Done button") - return False - 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 - -if __name__ == '__main__': - success = main() - sys.exit(0 if success else 1) diff --git a/tests/test_intent.py b/tests/test_intent.py index 34ef5de5..c1b6dfd0 100644 --- a/tests/test_intent.py +++ b/tests/test_intent.py @@ -1,5 +1,5 @@ import unittest -from mpos.content.intent import Intent +from mpos import Intent class TestIntent(unittest.TestCase): diff --git a/tests/test_logging.py b/tests/test_logging.py new file mode 100644 index 00000000..72a68b7f --- /dev/null +++ b/tests/test_logging.py @@ -0,0 +1,135 @@ +"""Tests for the logging module to ensure logger and handler level filtering works correctly.""" + +import unittest +import sys +import io +import logging + +# Add lib to path so we can import logging +sys.path.insert(0, 'MicroPythonOS/internal_filesystem/lib') + +class TestLoggingLevels(unittest.TestCase): + """Test that logger levels work correctly with handlers.""" + + def test_child_logger_info_level_with_root_handlers(self): + """Test that a child logger can set INFO level and log INFO messages using root handlers.""" + # Capture output + stream = io.StringIO() + logging.basicConfig(stream=stream, level=logging.WARNING, force=True) + + # Create child logger and set to INFO + logger = logging.getLogger("test_child") + logger.setLevel(logging.INFO) + + # Log at different levels + logger.debug("debug message") + logger.info("info message") + logger.warning("warning message") + + output = stream.getvalue() + + # Should NOT have debug (below INFO) + self.assertTrue("debug message" not in output) + # Should have info (at INFO level) + self.assertTrue("info message" in output) + # Should have warning (above INFO) + self.assertTrue("warning message" in output) + + def test_root_logger_warning_level(self): + """Test that root logger at WARNING level filters correctly.""" + stream = io.StringIO() + logging.basicConfig(stream=stream, level=logging.WARNING, force=True) + + logger = logging.getLogger() + + logger.debug("debug message") + logger.info("info message") + logger.warning("warning message") + + output = stream.getvalue() + + # Should NOT have debug or info + self.assertTrue("debug message" not in output) + self.assertTrue("info message" not in output) + # Should have warning + self.assertTrue("warning message" in output) + + def test_child_logger_debug_level(self): + """Test that a child logger can set DEBUG level.""" + stream = io.StringIO() + logging.basicConfig(stream=stream, level=logging.WARNING, force=True) + + logger = logging.getLogger("test_debug") + logger.setLevel(logging.DEBUG) + + logger.debug("debug message") + logger.info("info message") + logger.warning("warning message") + + output = stream.getvalue() + + # Should have all messages + self.assertIn("debug message", output) + self.assertIn("info message", output) + self.assertIn("warning message", output) + + def test_multiple_child_loggers_different_levels(self): + """Test that multiple child loggers can have different levels.""" + stream = io.StringIO() + logging.basicConfig(stream=stream, level=logging.WARNING, force=True) + + logger1 = logging.getLogger("app1") + logger1.setLevel(logging.DEBUG) + + logger2 = logging.getLogger("app2") + logger2.setLevel(logging.ERROR) + + logger1.debug("app1 debug") + logger1.info("app1 info") + logger2.debug("app2 debug") + logger2.info("app2 info") + logger2.error("app2 error") + + output = stream.getvalue() + + # app1 should log debug and info + self.assertTrue("app1 debug" in output) + self.assertTrue("app1 info" in output) + # app2 should NOT log debug or info + self.assertTrue("app2 debug" not in output) + self.assertTrue("app2 info" not in output) + # app2 should log error + self.assertTrue("app2 error" in output) + + def test_handler_level_does_not_filter(self): + """Test that handler level is NOTSET and doesn't filter messages.""" + stream = io.StringIO() + logging.basicConfig(stream=stream, level=logging.INFO, force=True) + + # Get the root logger and check handler level + root_logger = logging.getLogger() + self.assertEqual(len(root_logger.handlers), 1) + handler = root_logger.handlers[0] + + # Handler level should be NOTSET (0) so it doesn't filter + self.assertEqual(handler.level, logging.NOTSET) + + def test_child_logger_notset_level_uses_root_level(self): + """Test that a child logger with NOTSET level uses root logger's level.""" + stream = io.StringIO() + logging.basicConfig(stream=stream, level=logging.WARNING, force=True) + + logger = logging.getLogger("test_notset") + # Don't set logger level, it should default to NOTSET + + logger.debug("debug message") + logger.info("info message") + logger.warning("warning message") + + output = stream.getvalue() + + # Should use root logger's WARNING level + self.assertTrue("debug message" not in output) + self.assertTrue("info message" not in output) + self.assertTrue("warning message" in output) + diff --git a/tests/test_multi_connect.py b/tests/test_multi_connect.py index 1559f7c4..2b836515 100644 --- a/tests/test_multi_connect.py +++ b/tests/test_multi_connect.py @@ -2,11 +2,9 @@ import _thread import time -from mpos import App, PackageManager -import mpos.apps - -from websocket import WebSocketApp +from mpos import App, AppManager, TaskManager +from uaiowebsocket import WebSocketApp # demo_multiple_ws.py import asyncio @@ -137,13 +135,16 @@ def newthread(self): asyncio.run(self.main()) def test_it(self): - _thread.stack_size(mpos.apps.good_stack_size()) + _thread.stack_size(TaskManager.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(): +class TestCrashingSeparateThreads(): # Disabled # ---------------------------------------------------------------------- # Configuration @@ -250,6 +251,6 @@ def newthread(self, url): def test_it(self): for url in self.WS_URLS: - _thread.stack_size(mpos.apps.good_stack_size()) + _thread.stack_size(TaskManager.good_stack_size()) _thread.start_new_thread(self.newthread, (url,)) time.sleep(15) diff --git a/tests/test_multi_websocket_with_bad_ones.py b/tests/test_multi_websocket_with_bad_ones.py new file mode 100644 index 00000000..d6811bd0 --- /dev/null +++ b/tests/test_multi_websocket_with_bad_ones.py @@ -0,0 +1,141 @@ +import unittest +import _thread +import time + +from mpos import App, AppManager +from mpos import TaskManager + +from uaiowebsocket import WebSocketApp + +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): + + # ---------------------------------------------------------------------- + # 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.websAcket.org", # duplicate on purpose – shows concurrency + "wss://echo.websUcket.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(TaskManager.good_stack_size()) + _thread.start_new_thread(self.newthread, ()) + time.sleep(10) + + + diff --git a/tests/test_number_format.py b/tests/test_number_format.py new file mode 100644 index 00000000..f557e371 --- /dev/null +++ b/tests/test_number_format.py @@ -0,0 +1,82 @@ +import unittest +from mpos.number_format import NumberFormat, NUMBER_FORMAT_MAP, DEFAULT_FORMAT + + +class TestNumberFormat(unittest.TestCase): + + def setUp(self): + NumberFormat.number_format_preference = DEFAULT_FORMAT + + def test_default_is_us_format(self): + self.assertEqual(NumberFormat.get_separators(), (".", ",")) + + def test_format_int_default(self): + self.assertEqual(NumberFormat.format_number(1234), "1,234") + self.assertEqual(NumberFormat.format_number(0), "0") + self.assertEqual(NumberFormat.format_number(999), "999") + self.assertEqual(NumberFormat.format_number(1000), "1,000") + self.assertEqual(NumberFormat.format_number(1234567), "1,234,567") + + def test_format_negative_int(self): + self.assertEqual(NumberFormat.format_number(-1234), "-1,234") + self.assertEqual(NumberFormat.format_number(-5), "-5") + + def test_format_float_default(self): + self.assertEqual(NumberFormat.format_number(1234.56, 2), "1,234.56") + self.assertEqual(NumberFormat.format_number(1234.50, 2), "1,234.5") + self.assertEqual(NumberFormat.format_number(1234.00, 2), "1,234") + self.assertEqual(NumberFormat.format_number(0.5, 2), "0.5") + + def test_format_europe(self): + NumberFormat.number_format_preference = "dot_comma" + self.assertEqual(NumberFormat.format_number(1234), "1.234") + self.assertEqual(NumberFormat.format_number(1234.56, 2), "1.234,56") + + def test_format_french(self): + NumberFormat.number_format_preference = "space_comma" + self.assertEqual(NumberFormat.format_number(1234), "1 234") + self.assertEqual(NumberFormat.format_number(1234.56, 2), "1 234,56") + + def test_format_swiss(self): + NumberFormat.number_format_preference = "apos_dot" + self.assertEqual(NumberFormat.format_number(1234), "1'234") + self.assertEqual(NumberFormat.format_number(1234.56, 2), "1'234.56") + + def test_format_tech(self): + NumberFormat.number_format_preference = "under_dot" + self.assertEqual(NumberFormat.format_number(1234), "1_234") + self.assertEqual(NumberFormat.format_number(1234.56, 2), "1_234.56") + + def test_format_no_thousands_dot(self): + NumberFormat.number_format_preference = "none_dot" + self.assertEqual(NumberFormat.format_number(1234567), "1234567") + self.assertEqual(NumberFormat.format_number(1234.56, 2), "1234.56") + + def test_format_no_thousands_comma(self): + NumberFormat.number_format_preference = "none_comma" + self.assertEqual(NumberFormat.format_number(1234567), "1234567") + self.assertEqual(NumberFormat.format_number(1234.56, 2), "1234,56") + + def test_strip_trailing_zeros(self): + NumberFormat.number_format_preference = DEFAULT_FORMAT + self.assertEqual(NumberFormat.format_number(1.10, 2), "1.1") + self.assertEqual(NumberFormat.format_number(1.00, 2), "1") + self.assertEqual(NumberFormat.format_number(1.00, 8), "1") + + def test_large_number(self): + self.assertEqual(NumberFormat.format_number(100000000), "100,000,000") + + def test_get_format_options_returns_list(self): + options = NumberFormat.get_format_options() + self.assertIsInstance(options, list) + self.assertTrue(len(options) == len(NUMBER_FORMAT_MAP)) + for label, key in options: + self.assertIn(key, NUMBER_FORMAT_MAP) + + def test_unknown_preference_falls_back_to_default(self): + NumberFormat.number_format_preference = "nonexistent" + self.assertEqual(NumberFormat.get_separators(), (".", ",")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_osupdate.py b/tests/test_osupdate.py index 16e52fd2..2dc2b2dd 100644 --- a/tests/test_osupdate.py +++ b/tests/test_osupdate.py @@ -1,12 +1,16 @@ 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 +from network_test_helper import MockNetwork, MockRequests, MockJSON, MockDownloadManager + +# Import the real DownloadManager for is_network_error function +from mpos import DownloadManager class MockPartition: @@ -34,7 +38,7 @@ def set_boot(self): # 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 +from mpos import AppManager # Import the actual classes we're testing # Tests run from internal_filesystem/, so we add the assets directory to path @@ -42,14 +46,19 @@ def set_boot(self): 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_download_manager = MockDownloadManager() self.mock_json = MockJSON() self.checker = UpdateChecker( - requests_module=self.mock_requests, + download_manager=self.mock_download_manager, json_module=self.mock_json ) @@ -57,7 +66,7 @@ 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") + self.assertEqual(url, "https://updates.micropythonos.com/osupdate_waveshare_esp32_s3_touch_lcd_2.json") def test_get_update_url_other_hardware(self): """Test URL generation for other hardware.""" @@ -73,12 +82,12 @@ def test_fetch_update_info_success(self): "download_url": "https://example.com/update.bin", "changelog": "Bug fixes" } - self.mock_requests.set_next_response( - status_code=200, - text=json.dumps(update_data) - ) + self.mock_download_manager.set_download_data(json.dumps(update_data).encode()) - result = self.checker.fetch_update_info("waveshare_esp32_s3_touch_lcd_2") + async def run_test(): + return await self.checker.fetch_update_info("waveshare_esp32_s3_touch_lcd_2") + + result = run_async(run_test()) self.assertEqual(result["version"], "0.3.3") self.assertEqual(result["download_url"], "https://example.com/update.bin") @@ -86,38 +95,43 @@ def test_fetch_update_info_success(self): def test_fetch_update_info_http_error(self): """Test fetch with HTTP error response.""" - self.mock_requests.set_next_response(status_code=404) + self.mock_download_manager.set_should_fail(True) + + async def run_test(): + return await self.checker.fetch_update_info("waveshare_esp32_s3_touch_lcd_2") # MicroPython doesn't have ConnectionError, so catch generic Exception try: - self.checker.fetch_update_info("waveshare_esp32_s3_touch_lcd_2") + run_async(run_test()) 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)) + # MockDownloadManager returns None on failure, which causes an error + pass 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 {" - ) + self.mock_download_manager.set_download_data(b"not valid json {") + + async def run_test(): + return await self.checker.fetch_update_info("waveshare_esp32_s3_touch_lcd_2") with self.assertRaises(ValueError) as cm: - self.checker.fetch_update_info("waveshare_esp32_s3_touch_lcd_2") + run_async(run_test()) 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"}) + self.mock_download_manager.set_download_data( + json.dumps({"download_url": "http://example.com", "changelog": "test"}).encode() ) + async def run_test(): + return await self.checker.fetch_update_info("waveshare_esp32_s3_touch_lcd_2") + with self.assertRaises(ValueError) as cm: - self.checker.fetch_update_info("waveshare_esp32_s3_touch_lcd_2") + run_async(run_test()) self.assertIn("missing required fields", str(cm.exception)) self.assertIn("version", str(cm.exception)) @@ -125,13 +139,15 @@ def test_fetch_update_info_missing_version_field(self): 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"}) + self.mock_download_manager.set_download_data( + json.dumps({"version": "1.0.0", "changelog": "test"}).encode() ) + async def run_test(): + return await self.checker.fetch_update_info("waveshare_esp32_s3_touch_lcd_2") + with self.assertRaises(ValueError) as cm: - self.checker.fetch_update_info("waveshare_esp32_s3_touch_lcd_2") + run_async(run_test()) self.assertIn("download_url", str(cm.exception)) @@ -155,54 +171,70 @@ def test_is_update_available_older_version(self): def test_fetch_update_info_timeout(self): """Test fetch with request timeout.""" - self.mock_requests.set_exception(Exception("Timeout")) + self.mock_download_manager.set_should_fail(True) + + async def run_test(): + return await self.checker.fetch_update_info("waveshare_esp32_s3_touch_lcd_2") try: - self.checker.fetch_update_info("waveshare_esp32_s3_touch_lcd_2") + run_async(run_test()) self.fail("Should have raised an exception for timeout") except Exception as e: - self.assertIn("Timeout", str(e)) + # MockDownloadManager returns None on failure, which causes an error + pass def test_fetch_update_info_connection_refused(self): """Test fetch with connection refused.""" - self.mock_requests.set_exception(Exception("Connection refused")) + self.mock_download_manager.set_should_fail(True) + + async def run_test(): + return await self.checker.fetch_update_info("waveshare_esp32_s3_touch_lcd_2") try: - self.checker.fetch_update_info("waveshare_esp32_s3_touch_lcd_2") + run_async(run_test()) self.fail("Should have raised an exception") except Exception as e: - self.assertIn("Connection refused", str(e)) + # MockDownloadManager returns None on failure, which causes an error + pass def test_fetch_update_info_empty_response(self): """Test fetch with empty response.""" - self.mock_requests.set_next_response(status_code=200, text='') + self.mock_download_manager.set_download_data(b'') + + async def run_test(): + return await self.checker.fetch_update_info("waveshare_esp32_s3_touch_lcd_2") try: - self.checker.fetch_update_info("waveshare_esp32_s3_touch_lcd_2") + run_async(run_test()) 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) + self.mock_download_manager.set_should_fail(True) + + async def run_test(): + return await self.checker.fetch_update_info("waveshare_esp32_s3_touch_lcd_2") try: - self.checker.fetch_update_info("waveshare_esp32_s3_touch_lcd_2") + run_async(run_test()) self.fail("Should have raised an exception for HTTP 500") except Exception as e: - self.assertIn("500", str(e)) + pass 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"}) + self.mock_download_manager.set_download_data( + json.dumps({"version": "1.0.0", "download_url": "http://example.com"}).encode() ) + async def run_test(): + return await self.checker.fetch_update_info("waveshare_esp32_s3_touch_lcd_2") + try: - self.checker.fetch_update_info("waveshare_esp32_s3_touch_lcd_2") + run_async(run_test()) self.fail("Should have raised exception for missing changelog") except ValueError as e: self.assertIn("changelog", str(e)) @@ -218,38 +250,37 @@ def test_get_update_url_custom_hardware(self): class TestUpdateDownloader(unittest.TestCase): - """Test UpdateDownloader class.""" + """Test UpdateDownloader class with async DownloadManager.""" def setUp(self): - self.mock_requests = MockRequests() + self.mock_download_manager = MockDownloadManager() self.mock_partition = MockPartition self.downloader = UpdateDownloader( - requests_module=self.mock_requests, - partition_module=self.mock_partition + 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_requests.set_next_response( - status_code=200, - headers={'Content-Length': '8192'}, - content=test_data - ) + self.mock_download_manager.set_download_data(test_data) + self.mock_download_manager.chunk_size = 4096 progress_calls = [] - def progress_cb(percent): + async def progress_cb(percent): progress_calls.append(percent) - result = self.downloader.download_and_install( - "http://example.com/update.bin", - progress_callback=progress_cb - ) + 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.assertEqual(result['total_size'], 8192) self.assertIsNone(result['error']) # MicroPython unittest doesn't have assertGreater self.assertTrue(len(progress_calls) > 0, "Should have progress callbacks") @@ -257,21 +288,21 @@ def progress_cb(percent): def test_download_and_install_cancelled(self): """Test cancelled download.""" test_data = b'A' * 8192 - self.mock_requests.set_next_response( - status_code=200, - headers={'Content-Length': '8192'}, - content=test_data - ) + 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 - result = self.downloader.download_and_install( - "http://example.com/update.bin", - should_continue_callback=should_continue - ) + 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()) @@ -280,44 +311,46 @@ 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_requests.set_next_response( - status_code=200, - headers={'Content-Length': '5000'}, - content=test_data - ) + self.mock_download_manager.set_download_data(test_data) + self.mock_download_manager.chunk_size = 4096 - result = self.downloader.download_and_install( - "http://example.com/update.bin" - ) + 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 rounded up to 8192 (2 * 4096) - self.assertEqual(result['total_size'], 8192) + # 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_requests.set_exception(Exception("Network error")) + self.mock_download_manager.set_should_fail(True) - result = self.downloader.download_and_install( - "http://example.com/update.bin" - ) + 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']) - self.assertIn("Network error", 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_requests.set_next_response( - status_code=200, - headers={}, # No Content-Length header - content=test_data - ) + self.mock_download_manager.set_download_data(test_data) + self.mock_download_manager.chunk_size = 1000 - result = self.downloader.download_and_install( - "http://example.com/update.bin" - ) + 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']) @@ -325,108 +358,106 @@ def test_download_with_zero_content_length(self): def test_download_progress_callback_called(self): """Test that progress callback is called during download.""" test_data = b'D' * 8192 - self.mock_requests.set_next_response( - status_code=200, - headers={'Content-Length': '8192'}, - content=test_data - ) + self.mock_download_manager.set_download_data(test_data) + self.mock_download_manager.chunk_size = 4096 progress_values = [] - def track_progress(percent): + async def track_progress(percent): progress_values.append(percent) - result = self.downloader.download_and_install( - "http://example.com/update.bin", - progress_callback=track_progress - ) + 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.0) + 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_requests.set_next_response( - status_code=200, - headers={'Content-Length': '100'}, - content=test_data - ) + self.mock_download_manager.set_download_data(test_data) + self.mock_download_manager.chunk_size = 100 - result = self.downloader.download_and_install( - "http://example.com/update.bin" - ) + 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['total_size'], 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_requests.set_next_response( - status_code=200, - headers={'Content-Length': '8192'}, - content=test_data - ) + self.mock_download_manager.set_download_data(test_data) + self.mock_download_manager.chunk_size = 4096 - result = self.downloader.download_and_install( - "http://example.com/update.bin" - ) + 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['total_size'], 8192) 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)) + self.assertTrue(DownloadManager.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)) + self.assertTrue(DownloadManager.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)) + self.assertTrue(DownloadManager.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)) + self.assertTrue(DownloadManager.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"))) + self.assertTrue(DownloadManager.is_network_error(Exception("Connection reset by peer"))) + self.assertTrue(DownloadManager.is_network_error(Exception("Connection aborted"))) + self.assertTrue(DownloadManager.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"))) + self.assertFalse(DownloadManager.is_network_error(ValueError("Invalid data"))) + self.assertFalse(DownloadManager.is_network_error(Exception("File not found"))) + self.assertFalse(DownloadManager.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_requests.set_next_response( - status_code=200, - headers={'Content-Length': '16384'}, - content=test_data, - fail_after_bytes=4096 # Fail after first chunk - ) + 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 - result = self.downloader.download_and_install( - "http://example.com/update.bin" - ) + 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']) @@ -436,29 +467,27 @@ def test_download_pauses_on_network_error_during_read(self): def test_download_resumes_from_saved_position(self): """Test that download resumes from the last written position.""" # Simulate partial download - test_data = b'H' * 12288 # 3 chunks self.downloader.bytes_written_so_far = 8192 # Already downloaded 2 chunks self.downloader.total_size_expected = 12288 - # Server should receive Range header + # Server should receive Range header - only remaining data remaining_data = b'H' * 4096 # Last chunk - self.mock_requests.set_next_response( - status_code=206, # Partial content - headers={'Content-Length': '4096'}, # Remaining bytes - content=remaining_data - ) + self.mock_download_manager.set_download_data(remaining_data) + self.mock_download_manager.chunk_size = 4096 - result = self.downloader.download_and_install( - "http://example.com/update.bin" - ) + 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 - last_request = self.mock_requests.last_request - self.assertIsNotNone(last_request) - self.assertIn('Range', last_request['headers']) - self.assertEqual(last_request['headers']['Range'], 'bytes=8192-') + 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.""" @@ -466,12 +495,16 @@ def test_resume_failure_preserves_state(self): self.downloader.bytes_written_so_far = 245760 # 60 chunks already downloaded self.downloader.total_size_expected = 3391488 - # Resume attempt fails immediately with EHOSTUNREACH (network not ready) - self.mock_requests.set_exception(OSError(-118, "EHOSTUNREACH")) + # 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 - result = self.downloader.download_and_install( - "http://example.com/update.bin" - ) + 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']) @@ -484,3 +517,336 @@ def test_resume_failure_preserves_state(self): self.assertEqual(self.downloader.bytes_written_so_far, 245760, "Must preserve internal state") +class MockLVGLButton: + """Mock LVGL button for testing button state and text.""" + + def __init__(self, initial_disabled=True): + self.disabled = initial_disabled + self.children = [] + self.hidden = False + + def add_state(self, state): + """Add a state flag (e.g., lv.STATE.DISABLED).""" + # Track if DISABLED state is being added + if state == 1: # lv.STATE.DISABLED + self.disabled = True + + def remove_state(self, state): + """Remove a state flag.""" + # Track if DISABLED state is being removed + if state == 1: # lv.STATE.DISABLED + self.disabled = False + + def add_flag(self, flag): + """Add a flag (e.g., lv.obj.FLAG.HIDDEN).""" + if flag == 1: # lv.obj.FLAG.HIDDEN + self.hidden = True + + def remove_flag(self, flag): + """Remove a flag.""" + if flag == 1: # lv.obj.FLAG.HIDDEN + self.hidden = False + + def get_child(self, index): + """Get child widget by index.""" + if index < len(self.children): + return self.children[index] + return None + + def is_disabled(self): + """Check if button is disabled.""" + return self.disabled + + def is_hidden(self): + """Check if button is hidden.""" + return self.hidden + + def set_text(self, text): + """Set button text (for compatibility with direct text setting).""" + if self.children and hasattr(self.children[0], 'set_text'): + self.children[0].set_text(text) + + +class MockLVGLLabel: + """Mock LVGL label for testing text content.""" + + def __init__(self): + self.text = "" + + def set_text(self, text): + """Set label text.""" + self.text = text + + def get_text(self): + """Get label text.""" + return self.text + + def center(self): + """Mock center method (no-op for testing).""" + pass + + +class MockAppManager: + """Mock AppManager for version comparison.""" + + @staticmethod + def compare_versions(version1, version2): + """Compare two version strings. + + Returns: + > 0 if version1 > version2 + = 0 if version1 == version2 + < 0 if version1 < version2 + """ + def parse_version(v): + return tuple(map(int, v.split('.'))) + + v1 = parse_version(version1) + v2 = parse_version(version2) + + if v1 > v2: + return 1 + elif v1 < v2: + return -1 + else: + return 0 + + +class MockBuildInfo: + """Mock BuildInfo for testing.""" + class Version: + release = "1.0.0" + version = Version() + + +class TestOSUpdateButtonBehavior(unittest.TestCase): + """Test OSUpdate button behavior with different version scenarios. + + These tests verify that handle_update_info() correctly interprets + AppManager.compare_versions() return values and sets button text accordingly. + The bug being tested: compare_versions() returns integers (-1, 0, 1), not booleans. + """ + + def setUp(self): + """Set up test fixtures.""" + # Create a mock OSUpdate instance with mocked dependencies + self.mock_app_manager = MockAppManager() + + # We'll patch AppManager.compare_versions for these tests + self.original_compare_versions = AppManager.compare_versions + AppManager.compare_versions = self.mock_app_manager.compare_versions + + # Create mock button and label + self.mock_button = MockLVGLButton(initial_disabled=True) + self.mock_label = MockLVGLLabel() + self.mock_button.children = [self.mock_label] + + def tearDown(self): + """Restore original AppManager.compare_versions.""" + AppManager.compare_versions = self.original_compare_versions + + def test_button_initially_disabled(self): + """Test that the 'Update OS' button is initially disabled.""" + # Button should start in disabled state + self.assertTrue(self.mock_button.is_disabled(), + "Button should be initially disabled") + + def test_handle_update_info_with_newer_version(self): + """Test handle_update_info() with newer version (1.1.0 vs 1.0.0). + + This test verifies that: + - compare_versions(1.1.0, 1.0.0) returns 1 (positive integer) + - The button text is set to exactly "Update OS" + - The button is enabled (remove_state called) + """ + # Create a minimal OSUpdate instance for testing + from osupdate import OSUpdate + import osupdate + + app = OSUpdate() + + # Mock the UI components with a tracking button + tracking_button = MockLVGLButton(initial_disabled=True) + tracking_button.children = [MockLVGLLabel()] + app.install_button = tracking_button + app.status_label = MockLVGLLabel() + + # Mock BuildInfo and AppManager in osupdate module + original_build_info = osupdate.BuildInfo + original_app_manager = osupdate.AppManager + try: + osupdate.BuildInfo = MockBuildInfo + osupdate.AppManager = type('MockAppManager', (), { + 'compare_versions': staticmethod(self.mock_app_manager.compare_versions) + }) + + # Call handle_update_info with newer version + app.handle_update_info("1.1.0", "http://example.com/update.bin", "Bug fixes") + + # Verify button text is exactly "Install\nnew\nversion" + button_label = tracking_button.get_child(0) + self.assertIsNotNone(button_label, "Button should have a label child") + self.assertEqual(button_label.get_text(), "Install\nnew\nversion", + "Button text must be exactly 'Install\nnew\nversion' for newer version") + finally: + osupdate.BuildInfo = original_build_info + osupdate.AppManager = original_app_manager + + def test_handle_update_info_with_same_version(self): + """Test handle_update_info() with same version (1.0.0 vs 1.0.0). + + This test verifies that: + - compare_versions(1.0.0, 1.0.0) returns 0 + - The button text is set to exactly "Reinstall\\nsame version" + - The button is enabled (remove_state called) + """ + # Create a minimal OSUpdate instance for testing + from osupdate import OSUpdate + import osupdate + + app = OSUpdate() + + # Mock the UI components with a tracking button + tracking_button = MockLVGLButton(initial_disabled=True) + tracking_button.children = [MockLVGLLabel()] + app.install_button = tracking_button + app.status_label = MockLVGLLabel() + + # Mock BuildInfo and AppManager in osupdate module + original_build_info = osupdate.BuildInfo + original_app_manager = osupdate.AppManager + try: + osupdate.BuildInfo = MockBuildInfo + osupdate.AppManager = type('MockAppManager', (), { + 'compare_versions': staticmethod(self.mock_app_manager.compare_versions) + }) + + # Call handle_update_info with same version + app.handle_update_info("1.0.0", "http://example.com/update.bin", "Reinstall") + + # Verify button text is exactly "Reinstall\nsame version" + button_label = tracking_button.get_child(0) + self.assertIsNotNone(button_label, "Button should have a label child") + self.assertEqual(button_label.get_text(), "Reinstall\nsame\nversion", + "Button text must be exactly 'Reinstall\\nsame\nversion' for same version") + finally: + osupdate.BuildInfo = original_build_info + osupdate.AppManager = original_app_manager + + def test_handle_update_info_with_older_version(self): + """Test handle_update_info() with older version (0.9.0 vs 1.0.0). + + This test verifies that: + - compare_versions(0.9.0, 1.0.0) returns -1 (negative integer) + - The button text is set to exactly "Install old version" + - The button is enabled (remove_state called) + """ + # Create a minimal OSUpdate instance for testing + from osupdate import OSUpdate + import osupdate + + app = OSUpdate() + + # Mock the UI components with a tracking button + tracking_button = MockLVGLButton(initial_disabled=True) + tracking_button.children = [MockLVGLLabel()] + app.install_button = tracking_button + app.status_label = MockLVGLLabel() + + # Mock BuildInfo and AppManager in osupdate module + original_build_info = osupdate.BuildInfo + original_app_manager = osupdate.AppManager + try: + osupdate.BuildInfo = MockBuildInfo + osupdate.AppManager = type('MockAppManager', (), { + 'compare_versions': staticmethod(self.mock_app_manager.compare_versions) + }) + + # Call handle_update_info with older version + app.handle_update_info("0.9.0", "http://example.com/update.bin", "Old version") + + # Verify button text is exactly "Install old version" + button_label = tracking_button.get_child(0) + self.assertIsNotNone(button_label, "Button should have a label child") + self.assertEqual(button_label.get_text(), "Install\nold\nversion", + "Button text must be exactly 'Install\\nold\nversion' for older version") + finally: + osupdate.BuildInfo = original_build_info + osupdate.AppManager = original_app_manager + + def test_version_comparison_returns_integers_not_booleans(self): + """Test that compare_versions() returns integers, not booleans. + + This is the core bug test: the old code treated integer return values + as booleans in if statements. This test verifies the mock returns + proper integer values that would have caught the bug. + """ + # Test that compare_versions returns integers + result_greater = self.mock_app_manager.compare_versions("1.1.0", "1.0.0") + self.assertEqual(result_greater, 1, "Should return 1 for greater version") + self.assertIsInstance(result_greater, int, "Should return int, not bool") + + result_equal = self.mock_app_manager.compare_versions("1.0.0", "1.0.0") + self.assertEqual(result_equal, 0, "Should return 0 for equal version") + self.assertIsInstance(result_equal, int, "Should return int, not bool") + + result_less = self.mock_app_manager.compare_versions("0.9.0", "1.0.0") + self.assertEqual(result_less, -1, "Should return -1 for lesser version") + self.assertIsInstance(result_less, int, "Should return int, not bool") + + def test_button_text_with_multiple_version_pairs(self): + """Test button text with various version comparison scenarios. + + This comprehensive test ensures the button text is correct for + multiple version pairs, catching any edge cases in the comparison logic. + The bug being tested: compare_versions() returns integers (-1, 0, 1), + and these must be properly interpreted in if statements. + """ + from osupdate import OSUpdate + import osupdate + + test_cases = [ + # (new_version, current_version, expected_button_text, description) + ("2.0.0", "1.0.0", "Install\nnew\nversion", "Major version upgrade"), + ("1.1.0", "1.0.0", "Install\nnew\nversion", "Minor version upgrade"), + ("1.0.1", "1.0.0", "Install\nnew\nversion", "Patch version upgrade"), + ("1.0.0", "1.0.0", "Reinstall\nsame\nversion", "Exact same version"), + ("0.9.9", "1.0.0", "Install\nold\nversion", "Downgrade to older version"), + ("0.5.0", "1.0.0", "Install\nold\nversion", "Major version downgrade"), + ("1.0.0", "2.0.0", "Install\nold\nversion", "Downgrade from major version"), + ] + + original_build_info = osupdate.BuildInfo + original_app_manager = osupdate.AppManager + try: + for new_version, current_version, expected_text, description in test_cases: + # Reset button state for each test + tracking_button = MockLVGLButton(initial_disabled=True) + tracking_button.children = [MockLVGLLabel()] + + # Set current version + osupdate.BuildInfo = MockBuildInfo + osupdate.BuildInfo.version.release = current_version + osupdate.AppManager = type('MockAppManager', (), { + 'compare_versions': staticmethod(self.mock_app_manager.compare_versions) + }) + + # Create app and mock components + app = OSUpdate() + app.install_button = tracking_button + app.status_label = MockLVGLLabel() + + # Call handle_update_info + app.handle_update_info(new_version, "http://example.com/update.bin", "Test") + + # Verify button text + button_label = tracking_button.get_child(0) + actual_text = button_label.get_text() + self.assertEqual(actual_text, expected_text, + f"Failed for {description}: {new_version} vs {current_version}. " + f"Expected '{expected_text}', got '{actual_text}'") + finally: + osupdate.BuildInfo = original_build_info + osupdate.AppManager = original_app_manager + + diff --git a/tests/test_package_manager.py b/tests/test_package_manager.py index eebca586..06218fa6 100644 --- a/tests/test_package_manager.py +++ b/tests/test_package_manager.py @@ -1,50 +1,50 @@ import unittest -from mpos import App, PackageManager +from mpos import App, AppManager class TestCompareVersions(unittest.TestCase): def test_lower_short(self): - self.assertFalse(PackageManager.compare_versions("1" , "4")) + self.assertFalse(AppManager.compare_versions("1" , "4")) def test_lower(self): - self.assertFalse(PackageManager.compare_versions("1.2.3" , "4.5.6")) + self.assertFalse(AppManager.compare_versions("1.2.3" , "4.5.6")) def test_equal(self): - self.assertFalse(PackageManager.compare_versions("1.2.3" , "1.2.3")) + self.assertFalse(AppManager.compare_versions("1.2.3" , "1.2.3")) def test_higher(self): - self.assertTrue(PackageManager.compare_versions("4.5.6", "1.2.3")) + self.assertTrue(AppManager.compare_versions("4.5.6", "1.2.3")) def test_higher_medium_and_long(self): - self.assertTrue(PackageManager.compare_versions("4.5", "1.2.3")) + self.assertTrue(AppManager.compare_versions("4.5", "1.2.3")) def test_words(self): - self.assertFalse(PackageManager.compare_versions("weird" , "input")) + self.assertFalse(AppManager.compare_versions("weird" , "input")) def test_one_empty(self): - self.assertFalse(PackageManager.compare_versions("1.2.3" , "")) + self.assertFalse(AppManager.compare_versions("1.2.3" , "")) -class TestPackageManager_is_installed_by_name(unittest.TestCase): +class TestAppManager_is_installed_by_name(unittest.TestCase): def test_installed_builtin(self): - self.assertTrue(PackageManager.is_installed_by_name("com.micropythonos.appstore")) + self.assertTrue(AppManager.is_installed_by_name("com.micropythonos.appstore")) def test_installed_not_builtin(self): - self.assertTrue(PackageManager.is_installed_by_name("com.micropythonos.helloworld")) + self.assertTrue(AppManager.is_installed_by_name("com.micropythonos.helloworld")) def test_not_installed(self): - self.assertFalse(PackageManager.is_installed_by_name("com.micropythonos.badname")) + self.assertFalse(AppManager.is_installed_by_name("com.micropythonos.badname")) -class TestPackageManager_get_app_list(unittest.TestCase): +class TestAppManager_get_app_list(unittest.TestCase): def test_get_app_list(self): - app_list = PackageManager.get_app_list() + app_list = AppManager.get_app_list() self.assertGreaterEqual(len(app_list), 13) # more if the symlinks in internal_filesystem/app aren't dangling def test_get_app(self): - app_list = PackageManager.get_app_list() - hello_world_app = PackageManager.get("com.micropythonos.helloworld") + app_list = AppManager.get_app_list() + hello_world_app = AppManager.get("com.micropythonos.helloworld") self.assertIsInstance(hello_world_app, App) - self.assertEqual(hello_world_app.icon_path, "apps/com.micropythonos.helloworld/res/mipmap-mdpi/icon_64x64.png") - self.assertEqual(len(hello_world_app.icon_data), 5378) + self.assertEqual(hello_world_app.icon_path, "apps/com.micropythonos.helloworld/res/mipmap-mdpi/icon_64x64.png") + self.assertEqual(len(hello_world_app.icon_data), 5499) diff --git a/tests/test_rtttl.py b/tests/test_rtttl.py index 07dbc801..7b5e03c1 100644 --- a/tests/test_rtttl.py +++ b/tests/test_rtttl.py @@ -30,7 +30,7 @@ def duty_u16(self, value=None): # Now import the module to test -from mpos.audio.stream_rtttl import RTTTLStream +from mpos.audio.stream_rtttl import RTTTLStream # Keep this as-is since it's a specific internal module class TestRTTTL(unittest.TestCase): diff --git a/tests/test_sensor_manager.py b/tests/test_sensor_manager.py index 1584e22b..d35b5850 100644 --- a/tests/test_sensor_manager.py +++ b/tests/test_sensor_manager.py @@ -2,91 +2,17 @@ import unittest import sys +# Allow importing shared test mocks +sys.path.insert(0, "../tests") -# 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_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 +from mocks import ( + MockI2C, + MockQMI8658, + MockSharedPreferences, + MockWsenIsds, + make_config_module, + make_machine_i2c_module, +) # Mock constants from drivers @@ -96,23 +22,20 @@ def gyro_calibrate(self, samples=None): _GYROSCALE_RANGE_256DPS = 0b100 -# Create mock modules -mock_machine = type('module', (), { - 'I2C': MockI2C, - 'Pin': type('Pin', (), {}) -})() +mock_config = make_config_module(MockSharedPreferences) -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 +# Create mock modules +mock_machine = make_machine_i2c_module(MockI2C) + +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_wsen_isds = type("module", (), {"Wsen_Isds": MockWsenIsds})() # Mock esp32 module def _mock_mcu_temperature(*args, **kwargs): @@ -125,9 +48,16 @@ def _mock_mcu_temperature(*args, **kwargs): # 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 + +# Mock parent packages for driver imports +# These need to exist for the import path to work +sys.modules['drivers'] = type('module', (), {})() +sys.modules['drivers.imu_sensor'] = type('module', (), {})() + +sys.modules['drivers.imu_sensor.qmi8658'] = mock_qmi8658 +sys.modules['drivers.imu_sensor.wsen_isds'] = mock_wsen_isds sys.modules['esp32'] = mock_esp32 +sys.modules['mpos.config'] = mock_config # Mock _thread for thread safety testing try: @@ -142,7 +72,7 @@ def _mock_mcu_temperature(*args, **kwargs): sys.modules['_thread'] = mock_thread # Now import the module to test -import mpos.sensor_manager as SensorManager +from mpos import SensorManager class TestSensorManagerQMI8658(unittest.TestCase): @@ -150,11 +80,16 @@ class TestSensorManagerQMI8658(unittest.TestCase): def setUp(self): """Set up test fixtures before each test.""" - # Reset SensorManager state + # Reset SensorManager singleton instance + SensorManager._instance = None + + # Reset SensorManager class state SensorManager._initialized = False SensorManager._imu_driver = None SensorManager._sensor_list = [] SensorManager._has_mcu_temperature = False + SensorManager._i2c_bus = None + SensorManager._i2c_address = None # Create mock I2C bus with QMI8658 self.i2c_bus = MockI2C(0, sda=48, scl=47) @@ -262,11 +197,16 @@ class TestSensorManagerWsenIsds(unittest.TestCase): def setUp(self): """Set up test fixtures before each test.""" - # Reset SensorManager state + # Reset SensorManager singleton instance + SensorManager._instance = None + + # Reset SensorManager class state SensorManager._initialized = False SensorManager._imu_driver = None SensorManager._sensor_list = [] SensorManager._has_mcu_temperature = False + SensorManager._i2c_bus = None + SensorManager._i2c_address = None # Create mock I2C bus with WSEN_ISDS self.i2c_bus = MockI2C(0, sda=9, scl=18) @@ -312,11 +252,16 @@ class TestSensorManagerNoHardware(unittest.TestCase): def setUp(self): """Set up test fixtures before each test.""" - # Reset SensorManager state + # Reset SensorManager singleton instance + SensorManager._instance = None + + # Reset SensorManager class state SensorManager._initialized = False SensorManager._imu_driver = None SensorManager._sensor_list = [] SensorManager._has_mcu_temperature = False + SensorManager._i2c_bus = None + SensorManager._i2c_address = None # Create mock I2C bus with no devices self.i2c_bus = MockI2C(0, sda=48, scl=47) @@ -353,11 +298,16 @@ class TestSensorManagerMultipleInit(unittest.TestCase): def setUp(self): """Set up test fixtures before each test.""" - # Reset SensorManager state + # Reset SensorManager singleton instance + SensorManager._instance = None + + # Reset SensorManager class state SensorManager._initialized = False SensorManager._imu_driver = None SensorManager._sensor_list = [] SensorManager._has_mcu_temperature = False + SensorManager._i2c_bus = None + SensorManager._i2c_address = None # Create mock I2C bus with QMI8658 self.i2c_bus = MockI2C(0, sda=48, scl=47) diff --git a/tests/test_shared_preferences.py b/tests/test_shared_preferences.py index f8e28215..4c577dea 100644 --- a/tests/test_shared_preferences.py +++ b/tests/test_shared_preferences.py @@ -1,6 +1,7 @@ import unittest import os -from mpos.config import SharedPreferences, Editor +from mpos import SharedPreferences +from mpos.config import Editor class TestSharedPreferences(unittest.TestCase): @@ -431,6 +432,14 @@ def test_none_values(self): # Getting a nonexistent key should return None or default self.assertIsNone(prefs.get_string("nonexistent")) + def test_put_string_none_value(self): + """Test that putting None stores and returns a real None.""" + prefs = SharedPreferences(self.test_app_name) + prefs.edit().put_string("auto_start_app_early", None).commit() + + prefs2 = SharedPreferences(self.test_app_name) + self.assertIsNone(prefs2.get_string("auto_start_app_early")) + def test_special_characters_in_keys(self): """Test keys with special characters.""" prefs = SharedPreferences(self.test_app_name) diff --git a/tests/test_syspath_restore.py b/tests/test_syspath_restore.py index 36d668d8..afe837db 100644 --- a/tests/test_syspath_restore.py +++ b/tests/test_syspath_restore.py @@ -8,7 +8,7 @@ class TestSysPathRestore(unittest.TestCase): 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 + from mpos import AppManager # Capture original sys.path original_path = sys.path[:] @@ -31,11 +31,11 @@ def test_syspath_restored_after_execute_script(self): # 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( + result = AppManager.execute_script( test_script, is_file=False, - cwd=test_cwd, - classname="NonExistentClass" + classname="NonExistentClass", + cwd=test_cwd ) # After execution, sys.path should be restored @@ -56,7 +56,7 @@ def test_syspath_restored_after_execute_script(self): def test_syspath_not_affected_when_no_cwd(self): """Test that sys.path is unchanged when cwd is None""" - import mpos.apps + from mpos import AppManager # Capture original sys.path original_path = sys.path[:] @@ -66,11 +66,11 @@ def test_syspath_not_affected_when_no_cwd(self): ''' # Call without cwd parameter - result = mpos.apps.execute_script( + result = AppManager.execute_script( test_script, is_file=False, - cwd=None, - classname="NonExistentClass" + classname="NonExistentClass", + cwd=None ) # sys.path should be unchanged diff --git a/tests/test_webserver.py b/tests/test_webserver.py new file mode 100644 index 00000000..d5849f67 --- /dev/null +++ b/tests/test_webserver.py @@ -0,0 +1,96 @@ +""" +Unit tests for the MicroPythonOS webserver. +""" + +import _thread +import sys +import time +import unittest + +sys.path.insert(0, "../internal_filesystem/lib") + +from mpos import TaskManager +from mpos.net.download_manager import DownloadManager +from mpos.webserver.webserver import WebServer + + +class TestWebServer(unittest.TestCase): + """Test cases for WebServer.""" + + def tearDown(self): + """Ensure the webserver is stopped after tests.""" + if WebServer.is_started(): + WebServer.stop() + TaskManager.stop() + + def test_webserver_serves_webrepl_page(self): + """Webserver should serve the WebREPL HTML page on root.""" + + def start_task_manager(): + try: + TaskManager.enable() + TaskManager.start() + except KeyboardInterrupt: + print("TaskManager got KeyboardInterrupt, falling back to REPL shell...") + except Exception as exc: + print(f"TaskManager got exception: {exc}") + + TaskManager.enable() + _thread.stack_size(TaskManager.good_stack_size()) + _thread.start_new_thread(start_task_manager, ()) + + startup_timeout = 5.0 + start_time = time.time() + while TaskManager.keep_running is not True and (time.time() - start_time) < startup_timeout: + time.sleep(0.05) + + if TaskManager.keep_running is not True: + self.fail("TaskManager failed to start") + + started = WebServer.start() + if not started: + self.fail("WebServer failed to start") + + startup_wait = 1.0 + startup_wait_start = time.time() + while (time.time() - startup_wait_start) < startup_wait: + time.sleep(0.05) + + response_state = {"data": None, "error": None, "done": False} + + async def download_task(): + response_bytes = None + last_error = None + url_attempts = ["http://localhost:7890/", "http://127.0.0.1:7890/"] + for url in url_attempts: + for _ in range(15): + try: + response_bytes = await DownloadManager.download_url(url) + break + except Exception as exc: + last_error = exc + await TaskManager.sleep(0.5) + if response_bytes is not None: + break + + if response_bytes is None: + response_state["error"] = last_error + else: + response_state["data"] = response_bytes + response_state["done"] = True + + TaskManager.create_task(download_task()) + + timeout_seconds = 30.0 + start_wait = time.time() + while not response_state["done"] and (time.time() - start_wait) < timeout_seconds: + time.sleep(0.1) + + if response_state["data"] is None: + raise response_state["error"] + + response_text = response_state["data"].decode("utf-8", "replace") + self.assertIn("MicroPythonOS WebREPL", response_text) + + WebServer.stop() + self.assertFalse(WebServer.is_started()) diff --git a/tests/test_websocket.py b/tests/test_websocket.py index 8f7cd4c5..258cdff5 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -3,15 +3,17 @@ import _thread import time -from mpos import App, PackageManager -import mpos.apps +from mpos import App, AppManager +from mpos import TaskManager -from websocket import WebSocketApp +from uaiowebsocket 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 = [] @@ -51,7 +53,7 @@ async def closeall(self): for ws in self.wslist: await ws.close() - async def main(self) -> None: + async def run_main(self) -> None: tasks = [] self.wslist = [] for idx, wsurl in enumerate(self.relays): @@ -89,10 +91,12 @@ async def main(self) -> None: 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, len(self.relays) - self.max_allowed_connections, "expecting one error per failed connection") + 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): @@ -105,12 +109,5 @@ def wait_for_ping(self): time.sleep(1) self.assertTrue(self.on_ping_called) - def test_it_loop(self): - for testnr in range(1): - print(f"starting iteration {testnr}") - asyncio.run(self.do_two()) - print(f"finished iteration {testnr}") - - def do_two(self): - await self.main() - + def test_it(self): + asyncio.run(self.run_main()) diff --git a/tests/test_wifi_service.py b/tests/test_wifi_service.py index 705b85d4..71b8028c 100644 --- a/tests/test_wifi_service.py +++ b/tests/test_wifi_service.py @@ -2,70 +2,20 @@ import sys # Add tests directory to path for network_test_helper -sys.path.insert(0, '../tests') +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 - +from mocks import HotspotMockNetwork, MockMpos, MockSharedPreferences # Inject mocks before importing WifiService -sys.modules['mpos'] = MockMpos -sys.modules['mpos.config'] = MockMpos.config -sys.modules['mpos.time'] = MockMpos.time +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') +sys.path.append("lib/mpos/net") # Import WifiService from wifi_service import WifiService @@ -102,7 +52,7 @@ def mock_connect(ssid, password): mock_wlan.connect = mock_connect - result = WifiService.connect(network_module=mock_network) + result = WifiService.connect(network_module=mock_network, time_module=MockTime()) self.assertTrue(result) @@ -114,7 +64,7 @@ def test_connect_with_no_saved_networks(self): 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) + result = WifiService.connect(network_module=mock_network, time_module=MockTime()) self.assertFalse(result) @@ -128,7 +78,7 @@ def test_connect_when_no_saved_networks_available(self): 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) + result = WifiService.connect(network_module=mock_network, time_module=MockTime()) self.assertFalse(result) @@ -166,6 +116,8 @@ def mock_isconnected(): ) self.assertTrue(result) + # Should not sleep once connected immediately + self.assertEqual(len(mock_time.get_sleep_calls()), 0) def test_connection_timeout(self): """Test connection timeout after 10 attempts.""" @@ -227,6 +179,8 @@ def mock_active(state=None): self.assertFalse(result) # Should have checked less than 10 times (aborted early) self.assertTrue(check_count[0] < 10) + # Should have slept only until abort + self.assertEqual(len(mock_time.get_sleep_calls()), 2) def test_connection_error_handling(self): """Test handling of connection errors.""" @@ -327,7 +281,9 @@ def test_is_connected_when_connected(self): def test_is_connected_when_disconnected(self): """Test is_connected returns False when WiFi is disconnected.""" - mock_network = MockNetwork(connected=False) + mock_network = HotspotMockNetwork() + ap_wlan = mock_network.WLAN(mock_network.AP_IF) + ap_wlan.active(False) result = WifiService.is_connected(network_module=mock_network) @@ -343,6 +299,17 @@ def test_is_connected_when_wifi_busy(self): # Should return False even though connected self.assertFalse(result) + def test_is_connected_when_hotspot_enabled(self): + """Test is_connected checks AP state when hotspot is enabled.""" + mock_network = HotspotMockNetwork() + ap_wlan = mock_network.WLAN(mock_network.AP_IF) + ap_wlan.active(True) + WifiService.hotspot_enabled = True + + result = WifiService.is_connected(network_module=mock_network) + + self.assertTrue(result) + def test_is_connected_desktop_mode(self): """Test is_connected in desktop mode.""" result = WifiService.is_connected(network_module=None) @@ -420,6 +387,315 @@ def test_get_saved_networks_empty(self): self.assertEqual(len(saved), 0) +class TestWifiServiceHotspot(unittest.TestCase): + """Test hotspot configuration and mode switching.""" + + def setUp(self): + """Set up test fixtures.""" + MockSharedPreferences.reset_all() + WifiService.hotspot_enabled = False + WifiService.wifi_busy = False + WifiService._needs_hotspot_restore = False + + def tearDown(self): + """Clean up after test.""" + WifiService.hotspot_enabled = False + WifiService.wifi_busy = False + WifiService._needs_hotspot_restore = False + MockSharedPreferences.reset_all() + + def test_enable_hotspot_applies_config(self): + """Test enable_hotspot reads config and configures AP.""" + prefs = MockSharedPreferences("com.micropythonos.settings.hotspot") + editor = prefs.edit() + editor.put_bool("enabled", True) + editor.put_string("ssid", "MyAP") + editor.put_string("password", "ap-pass") + editor.put_string("authmode", "wpa2") + editor.commit() + + mock_network = HotspotMockNetwork() + ap_wlan = mock_network.WLAN(mock_network.AP_IF) + sta_wlan = mock_network.WLAN(mock_network.STA_IF) + sta_wlan.active(True) + sta_wlan._connected = True + + result = WifiService.enable_hotspot(network_module=mock_network) + + self.assertTrue(result) + self.assertTrue(WifiService.hotspot_enabled) + self.assertTrue(ap_wlan.active()) + self.assertFalse(sta_wlan.active()) + self.assertEqual(ap_wlan._config.get("essid"), "MyAP") + self.assertEqual(ap_wlan._config.get("authmode"), mock_network.AUTH_WPA2_PSK) + self.assertEqual(ap_wlan._config.get("password"), "ap-pass") + + def test_enable_hotspot_respects_busy_flag(self): + """Test enable_hotspot returns False when WiFi is busy.""" + WifiService.wifi_busy = True + mock_network = HotspotMockNetwork() + + result = WifiService.enable_hotspot(network_module=mock_network) + + self.assertFalse(result) + self.assertFalse(WifiService.hotspot_enabled) + + def test_disable_hotspot_deactivates_ap(self): + """Test disable_hotspot turns off AP and updates flag.""" + mock_network = HotspotMockNetwork() + ap_wlan = mock_network.WLAN(mock_network.AP_IF) + ap_wlan.active(True) + WifiService.hotspot_enabled = True + + WifiService.disable_hotspot(network_module=mock_network) + + self.assertFalse(ap_wlan.active()) + self.assertFalse(WifiService.hotspot_enabled) + + def test_enable_hotspot_desktop_mode(self): + """Test enable_hotspot in desktop mode uses simulated flag.""" + result = WifiService.enable_hotspot(network_module=None) + + self.assertTrue(result) + self.assertTrue(WifiService.hotspot_enabled) + + def test_disable_hotspot_desktop_mode(self): + """Test disable_hotspot in desktop mode uses simulated flag.""" + WifiService.hotspot_enabled = True + + WifiService.disable_hotspot(network_module=None) + + self.assertFalse(WifiService.hotspot_enabled) + + def test_auto_connect_with_hotspot_enabled_prefers_ap_mode(self): + """Test auto_connect uses hotspot mode when enabled in config.""" + prefs = MockSharedPreferences("com.micropythonos.settings.hotspot") + editor = prefs.edit() + editor.put_bool("enabled", True) + editor.commit() + + mock_network = HotspotMockNetwork() + + WifiService.auto_connect(network_module=mock_network, time_module=MockTime()) + + ap_wlan = mock_network.WLAN(mock_network.AP_IF) + self.assertTrue(ap_wlan.active()) + self.assertTrue(WifiService.hotspot_enabled) + + def test_attempt_connecting_temporarily_disables_hotspot(self): + """Test STA connect disables hotspot and leaves it off on success.""" + mock_network = HotspotMockNetwork() + ap_wlan = mock_network.WLAN(mock_network.AP_IF) + ap_wlan.active(True) + WifiService.hotspot_enabled = True + + sta_wlan = mock_network.WLAN(mock_network.STA_IF) + call_count = [0] + + def mock_isconnected(): + call_count[0] += 1 + return call_count[0] >= 1 + + sta_wlan.isconnected = mock_isconnected + + result = WifiService.attempt_connecting( + "TestSSID", + "pass", + network_module=mock_network, + time_module=MockTime(), + ) + + self.assertTrue(result) + self.assertFalse(WifiService.hotspot_enabled) + self.assertFalse(ap_wlan.active()) + + def test_attempt_connecting_restores_hotspot_on_timeout(self): + """Test STA connect restores hotspot when connection times out.""" + mock_network = HotspotMockNetwork() + ap_wlan = mock_network.WLAN(mock_network.AP_IF) + ap_wlan.active(True) + WifiService.hotspot_enabled = True + + sta_wlan = mock_network.WLAN(mock_network.STA_IF) + + def mock_isconnected(): + return False + + sta_wlan.isconnected = mock_isconnected + + result = WifiService.attempt_connecting( + "TestSSID", + "pass", + network_module=mock_network, + time_module=MockTime(), + ) + + self.assertFalse(result) + self.assertTrue(WifiService.hotspot_enabled) + self.assertTrue(ap_wlan.active()) + + def test_attempt_connecting_restores_hotspot_on_abort(self): + """Test STA connect restores hotspot if WiFi is disabled mid-try.""" + mock_network = HotspotMockNetwork() + ap_wlan = mock_network.WLAN(mock_network.AP_IF) + ap_wlan.active(True) + WifiService.hotspot_enabled = True + + sta_wlan = mock_network.WLAN(mock_network.STA_IF) + + def mock_isconnected(): + return False + + def mock_active(state=None): + if state is not None: + sta_wlan._active = state + return None + return False + + sta_wlan.isconnected = mock_isconnected + sta_wlan.active = mock_active + + result = WifiService.attempt_connecting( + "TestSSID", + "pass", + network_module=mock_network, + time_module=MockTime(), + ) + + self.assertFalse(result) + self.assertTrue(WifiService.hotspot_enabled) + self.assertTrue(ap_wlan.active()) + + +class TestWifiServiceTemporaryDisable(unittest.TestCase): + """Test temporarily_disable/temporarily_enable behavior.""" + + def setUp(self): + """Set up test fixtures.""" + WifiService.wifi_busy = False + WifiService._temp_disable_state = None + WifiService.hotspot_enabled = False + + def tearDown(self): + """Clean up after test.""" + WifiService.wifi_busy = False + WifiService._temp_disable_state = None + WifiService.hotspot_enabled = False + + def test_temporarily_disable_raises_when_busy(self): + """Test temporarily_disable raises if wifi_busy is set.""" + WifiService.wifi_busy = True + + with self.assertRaises(RuntimeError): + WifiService.temporarily_disable(network_module=HotspotMockNetwork()) + + def test_temporarily_disable_disconnects_and_tracks_state(self): + """Test temporarily_disable stores state and disconnects.""" + mock_network = HotspotMockNetwork() + sta_wlan = mock_network.WLAN(mock_network.STA_IF) + ap_wlan = mock_network.WLAN(mock_network.AP_IF) + sta_wlan._connected = True + ap_wlan.active(True) + WifiService.hotspot_enabled = True + + disconnect_called = [False] + + def mock_disconnect(network_module=None): + disconnect_called[0] = True + + original_disconnect = WifiService.disconnect + WifiService.disconnect = mock_disconnect + try: + was_connected = WifiService.temporarily_disable(network_module=mock_network) + finally: + WifiService.disconnect = original_disconnect + + self.assertTrue(was_connected) + self.assertTrue(WifiService.wifi_busy) + self.assertEqual( + WifiService._temp_disable_state, + {"was_connected": True, "hotspot_was_enabled": True}, + ) + self.assertTrue(disconnect_called[0]) + + def test_temporarily_enable_restores_hotspot_and_reconnects(self): + """Test temporarily_enable restores hotspot and triggers reconnect.""" + mock_network = HotspotMockNetwork() + WifiService._temp_disable_state = {"was_connected": True, "hotspot_was_enabled": True} + WifiService.wifi_busy = True + + thread_calls = [] + + class MockThreadModule: + @staticmethod + def start_new_thread(func, args): + thread_calls.append((func, args)) + + original_thread = sys.modules.get("_thread") + sys.modules["_thread"] = MockThreadModule + + try: + WifiService.temporarily_enable(True, network_module=mock_network) + finally: + if original_thread is not None: + sys.modules["_thread"] = original_thread + else: + sys.modules.pop("_thread", None) + + ap_wlan = mock_network.WLAN(mock_network.AP_IF) + self.assertFalse(WifiService.wifi_busy) + self.assertIsNone(WifiService._temp_disable_state) + self.assertTrue(ap_wlan.active()) + self.assertTrue(WifiService.hotspot_enabled) + self.assertEqual(thread_calls[0][0], WifiService.auto_connect) + + +class TestWifiServiceIPv4Info(unittest.TestCase): + """Test IPv4 info accessors for AP/STA modes.""" + + def setUp(self): + """Set up test fixtures.""" + WifiService.wifi_busy = False + WifiService.hotspot_enabled = False + + def tearDown(self): + """Clean up after test.""" + WifiService.wifi_busy = False + WifiService.hotspot_enabled = False + + def test_get_ipv4_info_from_ap_when_hotspot_enabled(self): + """Test IPv4 getters use AP info when hotspot is enabled.""" + mock_network = HotspotMockNetwork() + ap_wlan = mock_network.WLAN(mock_network.AP_IF) + ap_wlan.active(True) + ap_wlan.ifconfig(("192.168.4.1", "255.255.255.0", "192.168.4.1", "8.8.4.4")) + WifiService.hotspot_enabled = True + + address = WifiService.get_ipv4_address(network_module=mock_network) + gateway = WifiService.get_ipv4_gateway(network_module=mock_network) + + self.assertEqual(address, "192.168.4.1") + self.assertEqual(gateway, "192.168.4.1") + + def test_get_ipv4_info_returns_none_when_busy(self): + """Test IPv4 getters return None when wifi_busy is set.""" + WifiService.wifi_busy = True + + address = WifiService.get_ipv4_address(network_module=HotspotMockNetwork()) + gateway = WifiService.get_ipv4_gateway(network_module=HotspotMockNetwork()) + + self.assertIsNone(address) + self.assertIsNone(gateway) + + def test_get_ipv4_info_desktop_mode(self): + """Test IPv4 getters return simulated values in desktop mode.""" + address = WifiService.get_ipv4_address(network_module=None) + gateway = WifiService.get_ipv4_gateway(network_module=None) + + self.assertEqual(address, "127.0.0.1") + self.assertEqual(gateway, "") + + class TestWifiServiceDisconnect(unittest.TestCase): """Test WifiService.disconnect() method.""" @@ -449,6 +725,21 @@ def mock_active(state=None): self.assertTrue(disconnect_called[0]) self.assertTrue(active_false_called[0]) + def test_disconnect_disables_ap(self): + """Test disconnect also disables AP and clears hotspot flag.""" + mock_network = HotspotMockNetwork() + ap_wlan = mock_network.WLAN(mock_network.AP_IF) + sta_wlan = mock_network.WLAN(mock_network.STA_IF) + ap_wlan.active(True) + sta_wlan._connected = True + + WifiService.hotspot_enabled = True + + WifiService.disconnect(network_module=mock_network) + + self.assertFalse(ap_wlan.active()) + self.assertFalse(WifiService.hotspot_enabled) + def test_disconnect_desktop_mode(self): """Test disconnect in desktop mode.""" # Should not raise an error @@ -501,7 +792,7 @@ def mock_connect(ssid, password): mock_wlan.connect = mock_connect - result = WifiService.connect(network_module=mock_network) + result = WifiService.connect(network_module=mock_network, time_module=MockTime()) self.assertTrue(result) # Should try strongest first (-45 dBm) @@ -538,7 +829,7 @@ def mock_connect(ssid, password): mock_wlan.connect = mock_connect - result = WifiService.connect(network_module=mock_network) + result = WifiService.connect(network_module=mock_network, time_module=MockTime()) self.assertTrue(result) # Verify order: strongest to weakest @@ -572,7 +863,7 @@ def mock_connect(ssid, password): mock_wlan.connect = mock_connect - result = WifiService.connect(network_module=mock_network) + result = WifiService.connect(network_module=mock_network, time_module=MockTime()) self.assertTrue(result) # Should only try once (first is strongest and succeeds) @@ -618,7 +909,7 @@ def mock_connect(ssid, password): mock_wlan.connect = mock_connect - result = WifiService.connect(network_module=mock_network) + result = WifiService.connect(network_module=mock_network, time_module=MockTime()) self.assertTrue(result) # Expected order: Channel 8 (-47), Baptistus (-48), telenet (-70), Galaxy (-83) @@ -654,7 +945,7 @@ def mock_connect(ssid, password): mock_wlan.connect = mock_connect - result = WifiService.connect(network_module=mock_network) + result = WifiService.connect(network_module=mock_network, time_module=MockTime()) self.assertFalse(result) # No connection succeeded # Verify all 3 were attempted in RSSI order @@ -684,7 +975,7 @@ def mock_connect(ssid, password): mock_wlan.connect = mock_connect - result = WifiService.connect(network_module=mock_network) + result = WifiService.connect(network_module=mock_network, time_module=MockTime()) self.assertFalse(result) # No attempts should be made @@ -706,7 +997,7 @@ def test_rssi_logging_shows_signal_strength(self): # 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) + result = WifiService.connect(network_module=mock_network, time_module=MockTime()) # 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/unittest.sh b/tests/unittest.sh index f93cc111..952b45da 100755 --- a/tests/unittest.sh +++ b/tests/unittest.sh @@ -3,6 +3,7 @@ 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/ mpremote="$mydir"/../lvgl_micropython/lib/micropython/tools/mpremote/mpremote.py @@ -37,6 +38,9 @@ fi binary=$(readlink -f "$binary") chmod +x "$binary" +# make sure no autostart is configured: +rm "$scriptdir"/../internal_filesystem/data/com.micropythonos.settings/config.json + one_test() { file="$1" if [ ! -f "$file" ]; then @@ -59,23 +63,23 @@ one_test() { if [ -z "$ondevice" ]; then # Desktop execution if [ $is_graphical -eq 1 ]; then - # Graphical test: include boot_unix.py and main.py - "$binary" -X heapsize=8M -c "$(cat main.py) ; import mpos.main ; import mpos.apps; sys.path.append(\"$tests_abs_path\") + echo "Graphical test: include main.py" + "$binary" -X heapsize=8M -c "import sys ; sys.path.insert(0, 'lib') ; sys.path.append(\"$tests_abs_path\") ; import mpos ; mpos.TaskManager.disable() ; $(cat main.py) $(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 "$(cat main.py) + echo "Regular test: no boot files" + "$binary" -X heapsize=8M -c "import sys ; sys.path.insert(0, 'lib') ; sys.path.append(\"$tests_abs_path\") ; import mpos ; mpos.TaskManager.disable() ; $(cat main.py) $(cat $file) result = unittest.main() ; sys.exit(0 if result.wasSuccessful() else 1) " - result=$? + 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 + sleep 30 fi echo "Device execution" @@ -86,23 +90,23 @@ result = unittest.main() ; sys.exit(0 if result.wasSuccessful() else 1) " echo "$test logging to $testlog" if [ $is_graphical -eq 1 ]; then # Graphical test: system already initialized, just add test paths - "$mpremote" exec "$(cat main.py) ; sys.path.append('tests') + "$mpremote" exec "import sys ; sys.path.insert(0, 'lib') ; sys.path.append('tests') ; import mpos ; mpos.TaskManager.disable() ; $(cat main.py) $(cat $file) result = unittest.main() if result.wasSuccessful(): - print('TEST WAS A SUCCESS') + print('TEST WAS A SUCCESS') else: - print('TEST WAS A FAILURE') + print('TEST WAS A FAILURE') " | tee "$testlog" else # Regular test: no boot files - "$mpremote" exec "$(cat main.py) + "$mpremote" exec "import sys ; sys.path.insert(0, 'lib') ; sys.path.append('tests') ; import mpos ; mpos.TaskManager.disable() ; $(cat main.py) $(cat $file) result = unittest.main() if result.wasSuccessful(): - print('TEST WAS A SUCCESS') + print('TEST WAS A SUCCESS') else: - print('TEST WAS A FAILURE') + print('TEST WAS A FAILURE') " | tee "$testlog" fi grep -q "TEST WAS A SUCCESS" "$testlog" @@ -124,7 +128,8 @@ if [ -z "$onetest" ]; then echo "If no test is specified: run all tests from $testdir on local machine." echo 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." - while read file; do + files=$(find "$testdir" -iname "test_*.py" ) + for file in $files; do one_test "$file" result=$? if [ $result -ne 0 ]; then @@ -134,11 +139,15 @@ if [ -z "$onetest" ]; then else ran=$(expr $ran \+ 1) fi - done < <( find "$testdir" -iname "test_*.py" ) + done else echo "doing $onetest" one_test $(readlink -f "$onetest") - [ $? -ne 0 ] && failed=1 + result=$? + if [ $result -ne 0 ]; then + echo "Test returned result: $result" + failed=1 + fi fi diff --git a/webrepl/FileSaver.js b/webrepl/FileSaver.js new file mode 100644 index 00000000..239db122 --- /dev/null +++ b/webrepl/FileSaver.js @@ -0,0 +1,188 @@ +/* FileSaver.js + * A saveAs() FileSaver implementation. + * 1.3.2 + * 2016-06-16 18:25:19 + * + * By Eli Grey, http://eligrey.com + * License: MIT + * See https://github.com/eligrey/FileSaver.js/blob/master/LICENSE.md + */ + +/*global self */ +/*jslint bitwise: true, indent: 4, laxbreak: true, laxcomma: true, smarttabs: true, plusplus: true */ + +/*! @source http://purl.eligrey.com/github/FileSaver.js/blob/master/FileSaver.js */ + +var saveAs = saveAs || (function(view) { + "use strict"; + // IE <10 is explicitly unsupported + if (typeof view === "undefined" || typeof navigator !== "undefined" && /MSIE [1-9]\./.test(navigator.userAgent)) { + return; + } + var + doc = view.document + // only get URL when necessary in case Blob.js hasn't overridden it yet + , get_URL = function() { + return view.URL || view.webkitURL || view; + } + , save_link = doc.createElementNS("http://www.w3.org/1999/xhtml", "a") + , can_use_save_link = "download" in save_link + , click = function(node) { + var event = new MouseEvent("click"); + node.dispatchEvent(event); + } + , is_safari = /constructor/i.test(view.HTMLElement) + , is_chrome_ios =/CriOS\/[\d]+/.test(navigator.userAgent) + , throw_outside = function(ex) { + (view.setImmediate || view.setTimeout)(function() { + throw ex; + }, 0); + } + , force_saveable_type = "application/octet-stream" + // the Blob API is fundamentally broken as there is no "downloadfinished" event to subscribe to + , arbitrary_revoke_timeout = 1000 * 40 // in ms + , revoke = function(file) { + var revoker = function() { + if (typeof file === "string") { // file is an object URL + get_URL().revokeObjectURL(file); + } else { // file is a File + file.remove(); + } + }; + setTimeout(revoker, arbitrary_revoke_timeout); + } + , dispatch = function(filesaver, event_types, event) { + event_types = [].concat(event_types); + var i = event_types.length; + while (i--) { + var listener = filesaver["on" + event_types[i]]; + if (typeof listener === "function") { + try { + listener.call(filesaver, event || filesaver); + } catch (ex) { + throw_outside(ex); + } + } + } + } + , auto_bom = function(blob) { + // prepend BOM for UTF-8 XML and text/* types (including HTML) + // note: your browser will automatically convert UTF-16 U+FEFF to EF BB BF + if (/^\s*(?:text\/\S*|application\/xml|\S*\/\S*\+xml)\s*;.*charset\s*=\s*utf-8/i.test(blob.type)) { + return new Blob([String.fromCharCode(0xFEFF), blob], {type: blob.type}); + } + return blob; + } + , FileSaver = function(blob, name, no_auto_bom) { + if (!no_auto_bom) { + blob = auto_bom(blob); + } + // First try a.download, then web filesystem, then object URLs + var + filesaver = this + , type = blob.type + , force = type === force_saveable_type + , object_url + , dispatch_all = function() { + dispatch(filesaver, "writestart progress write writeend".split(" ")); + } + // on any filesys errors revert to saving with object URLs + , fs_error = function() { + if ((is_chrome_ios || (force && is_safari)) && view.FileReader) { + // Safari doesn't allow downloading of blob urls + var reader = new FileReader(); + reader.onloadend = function() { + var url = is_chrome_ios ? reader.result : reader.result.replace(/^data:[^;]*;/, 'data:attachment/file;'); + var popup = view.open(url, '_blank'); + if(!popup) view.location.href = url; + url=undefined; // release reference before dispatching + filesaver.readyState = filesaver.DONE; + dispatch_all(); + }; + reader.readAsDataURL(blob); + filesaver.readyState = filesaver.INIT; + return; + } + // don't create more object URLs than needed + if (!object_url) { + object_url = get_URL().createObjectURL(blob); + } + if (force) { + view.location.href = object_url; + } else { + var opened = view.open(object_url, "_blank"); + if (!opened) { + // Apple does not allow window.open, see https://developer.apple.com/library/safari/documentation/Tools/Conceptual/SafariExtensionGuide/WorkingwithWindowsandTabs/WorkingwithWindowsandTabs.html + view.location.href = object_url; + } + } + filesaver.readyState = filesaver.DONE; + dispatch_all(); + revoke(object_url); + } + ; + filesaver.readyState = filesaver.INIT; + + if (can_use_save_link) { + object_url = get_URL().createObjectURL(blob); + setTimeout(function() { + save_link.href = object_url; + save_link.download = name; + click(save_link); + dispatch_all(); + revoke(object_url); + filesaver.readyState = filesaver.DONE; + }); + return; + } + + fs_error(); + } + , FS_proto = FileSaver.prototype + , saveAs = function(blob, name, no_auto_bom) { + return new FileSaver(blob, name || blob.name || "download", no_auto_bom); + } + ; + // IE 10+ (native saveAs) + if (typeof navigator !== "undefined" && navigator.msSaveOrOpenBlob) { + return function(blob, name, no_auto_bom) { + name = name || blob.name || "download"; + + if (!no_auto_bom) { + blob = auto_bom(blob); + } + return navigator.msSaveOrOpenBlob(blob, name); + }; + } + + FS_proto.abort = function(){}; + FS_proto.readyState = FS_proto.INIT = 0; + FS_proto.WRITING = 1; + FS_proto.DONE = 2; + + FS_proto.error = + FS_proto.onwritestart = + FS_proto.onprogress = + FS_proto.onwrite = + FS_proto.onabort = + FS_proto.onerror = + FS_proto.onwriteend = + null; + + return saveAs; +}( + typeof self !== "undefined" && self + || typeof window !== "undefined" && window + || this.content +)); +// `self` is undefined in Firefox for Android content script context +// while `this` is nsIContentFrameMessageManager +// with an attribute `content` that corresponds to the window + +if (typeof module !== "undefined" && module.exports) { + module.exports.saveAs = saveAs; +} else if ((typeof define !== "undefined" && define !== null) && (define.amd !== null)) { + define([], function() { + return saveAs; + }); +} diff --git a/webrepl/README.md b/webrepl/README.md new file mode 100644 index 00000000..4c691bbe --- /dev/null +++ b/webrepl/README.md @@ -0,0 +1,3 @@ +# WebREPL content + +These files were sourced from commit `fff7b87` of https://github.com/micropython/webrepl. diff --git a/webrepl/inline_minify_webrepl.py b/webrepl/inline_minify_webrepl.py new file mode 100755 index 00000000..26e40c08 --- /dev/null +++ b/webrepl/inline_minify_webrepl.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""Minify and inline WebREPL assets into webrepl_inlined_minified.html.""" +from __future__ import annotations + +import re +from pathlib import Path + + +def _is_alphanum(ch: str) -> bool: + return ch.isalnum() or ch in "_$\\" + + +def jsmin(js: str) -> str: + """Minify JavaScript by stripping comments and collapsing whitespace safely.""" + out: list[str] = [] + i = 0 + length = len(js) + state = "code" + quote = "" + + def peek(offset: int = 1) -> str: + idx = i + offset + if idx >= length: + return "" + return js[idx] + + def push_char(ch: str) -> None: + out.append(ch) + + while i < length: + ch = js[i] + nxt = peek(1) + + if state == "code": + if ch == "/" and nxt == "/": + state = "line_comment" + i += 2 + continue + if ch == "/" and nxt == "*": + state = "block_comment" + i += 2 + continue + if ch in ("'", '"'): + state = "string" + quote = ch + push_char(ch) + i += 1 + continue + if ch == "`": + state = "template" + push_char(ch) + i += 1 + continue + if ch.isspace(): + if out: + last = out[-1] + if last in "{}[]();,": + i += 1 + continue + if last != " ": + push_char(" ") + i += 1 + continue + if ch in "{}[]();,": + if out and out[-1] == " ": + out.pop() + push_char(ch) + i += 1 + continue + push_char(ch) + i += 1 + continue + + if state == "line_comment": + if ch in ("\n", "\r"): + if out and out[-1] != " ": + out.append(" ") + state = "code" + i += 1 + continue + + if state == "block_comment": + if ch == "*" and nxt == "/": + state = "code" + i += 2 + else: + i += 1 + continue + + if state == "string": + push_char(ch) + if ch == "\\" and nxt: + push_char(nxt) + i += 2 + continue + if ch == quote: + state = "code" + i += 1 + continue + + if state == "template": + push_char(ch) + if ch == "\\" and nxt: + push_char(nxt) + i += 2 + continue + if ch == "`": + state = "code" + i += 1 + continue + + return "".join(out).strip() + + +def cssmin(css: str) -> str: + css = re.sub(r"/\*.*?\*/", "", css, flags=re.DOTALL) + css = re.sub(r"\s+", " ", css) + css = re.sub(r"\s*([{}:;,>])\s*", r"\1", css) + return css.strip() + + +def inline_assets() -> None: + base_dir = Path(__file__).parent + html_path = base_dir / "webrepl.html" + out_path = base_dir / "webrepl_inlined_minified.html" + + html = html_path.read_text(encoding="utf-8") + css = cssmin((base_dir / "webrepl.css").read_text(encoding="utf-8")) + term_js = jsmin((base_dir / "term.js").read_text(encoding="utf-8")) + file_saver_js = jsmin((base_dir / "FileSaver.js").read_text(encoding="utf-8")) + webrepl_js = jsmin((base_dir / "webrepl.js").read_text(encoding="utf-8")) + webrepl_tweaks_js = jsmin((base_dir / "webrepl_tweaks.js").read_text(encoding="utf-8")) + + replacements = [ + (r"", f""), + (r"\s*", f""), + (r"\s*", f""), + (r"\s*", f""), + (r"\s*", f""), + ] + + for pattern, replacement in replacements: + new_html, count = re.subn( + pattern, + lambda _match, rep=replacement: rep, + html, + flags=re.IGNORECASE, + ) + if count != 1: + raise RuntimeError( + f"Expected to replace exactly one tag for pattern: {pattern}; replaced {count}" + ) + html = new_html + + out_path.write_text(html, encoding="utf-8") + + +if __name__ == "__main__": + inline_assets() diff --git a/webrepl/term.js b/webrepl/term.js new file mode 100644 index 00000000..dc535ccd --- /dev/null +++ b/webrepl/term.js @@ -0,0 +1,6010 @@ +/** + * term.js - an xterm emulator + * Copyright (c) 2012-2013, Christopher Jeffrey (MIT License) + * https://github.com/chjj/term.js + * + * 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. + * + * Originally forked from (with the author's permission): + * Fabrice Bellard's javascript vt100 for jslinux: + * http://bellard.org/jslinux/ + * Copyright (c) 2011 Fabrice Bellard + * The original design remains. The terminal itself + * has been extended to include xterm CSI codes, among + * other features. + */ + +;(function() { + +/** + * Terminal Emulation References: + * http://vt100.net/ + * http://invisible-island.net/xterm/ctlseqs/ctlseqs.txt + * http://invisible-island.net/xterm/ctlseqs/ctlseqs.html + * http://invisible-island.net/vttest/ + * http://www.inwap.com/pdp10/ansicode.txt + * http://linux.die.net/man/4/console_codes + * http://linux.die.net/man/7/urxvt + */ + +'use strict'; + +/** + * Shared + */ + +var window = this + , document = this.document; + +/** + * EventEmitter + */ + +function EventEmitter() { + this._events = this._events || {}; +} + +EventEmitter.prototype.addListener = function(type, listener) { + this._events[type] = this._events[type] || []; + this._events[type].push(listener); +}; + +EventEmitter.prototype.on = EventEmitter.prototype.addListener; + +EventEmitter.prototype.removeListener = function(type, listener) { + if (!this._events[type]) return; + + var obj = this._events[type] + , i = obj.length; + + while (i--) { + if (obj[i] === listener || obj[i].listener === listener) { + obj.splice(i, 1); + return; + } + } +}; + +EventEmitter.prototype.off = EventEmitter.prototype.removeListener; + +EventEmitter.prototype.removeAllListeners = function(type) { + if (this._events[type]) delete this._events[type]; +}; + +EventEmitter.prototype.once = function(type, listener) { + function on() { + var args = Array.prototype.slice.call(arguments); + this.removeListener(type, on); + return listener.apply(this, args); + } + on.listener = listener; + return this.on(type, on); +}; + +EventEmitter.prototype.emit = function(type) { + if (!this._events[type]) return; + + var args = Array.prototype.slice.call(arguments, 1) + , obj = this._events[type] + , l = obj.length + , i = 0; + + for (; i < l; i++) { + obj[i].apply(this, args); + } +}; + +EventEmitter.prototype.listeners = function(type) { + return this._events[type] = this._events[type] || []; +}; + +/** + * Stream + */ + +function Stream() { + EventEmitter.call(this); +} + +inherits(Stream, EventEmitter); + +Stream.prototype.pipe = function(dest, options) { + var src = this + , ondata + , onerror + , onend; + + function unbind() { + src.removeListener('data', ondata); + src.removeListener('error', onerror); + src.removeListener('end', onend); + dest.removeListener('error', onerror); + dest.removeListener('close', unbind); + } + + src.on('data', ondata = function(data) { + dest.write(data); + }); + + src.on('error', onerror = function(err) { + unbind(); + if (!this.listeners('error').length) { + throw err; + } + }); + + src.on('end', onend = function() { + dest.end(); + unbind(); + }); + + dest.on('error', onerror); + dest.on('close', unbind); + + dest.emit('pipe', src); + + return dest; +}; + +/** + * States + */ + +var normal = 0 + , escaped = 1 + , csi = 2 + , osc = 3 + , charset = 4 + , dcs = 5 + , ignore = 6 + , UDK = { type: 'udk' }; + +/** + * Terminal + */ + +function Terminal(options) { + var self = this; + + if (!(this instanceof Terminal)) { + return new Terminal(arguments[0], arguments[1], arguments[2]); + } + + Stream.call(this); + + if (typeof options === 'number') { + options = { + cols: arguments[0], + rows: arguments[1], + handler: arguments[2] + }; + } + + options = options || {}; + + each(keys(Terminal.defaults), function(key) { + if (options[key] == null) { + options[key] = Terminal.options[key]; + // Legacy: + if (Terminal[key] !== Terminal.defaults[key]) { + options[key] = Terminal[key]; + } + } + self[key] = options[key]; + }); + + if (options.colors.length === 8) { + options.colors = options.colors.concat(Terminal._colors.slice(8)); + } else if (options.colors.length === 16) { + options.colors = options.colors.concat(Terminal._colors.slice(16)); + } else if (options.colors.length === 10) { + options.colors = options.colors.slice(0, -2).concat( + Terminal._colors.slice(8, -2), options.colors.slice(-2)); + } else if (options.colors.length === 18) { + options.colors = options.colors.slice(0, -2).concat( + Terminal._colors.slice(16, -2), options.colors.slice(-2)); + } + this.colors = options.colors; + + this.options = options; + + // this.context = options.context || window; + // this.document = options.document || document; + this.parent = options.body || options.parent + || (document ? document.getElementsByTagName('body')[0] : null); + + this.cols = options.cols || options.geometry[0]; + this.rows = options.rows || options.geometry[1]; + + // Act as though we are a node TTY stream: + this.setRawMode; + this.isTTY = true; + this.isRaw = true; + this.columns = this.cols; + this.rows = this.rows; + + if (options.handler) { + this.on('data', options.handler); + } + + this.ybase = 0; + this.ydisp = 0; + this.x = 0; + this.y = 0; + this.cursorState = 0; + this.cursorHidden = false; + this.convertEol; + this.state = 0; + this.queue = ''; + this.scrollTop = 0; + this.scrollBottom = this.rows - 1; + + // modes + this.applicationKeypad = false; + this.applicationCursor = false; + this.originMode = false; + this.insertMode = false; + this.wraparoundMode = false; + this.normal = null; + + // select modes + this.prefixMode = false; + this.selectMode = false; + this.visualMode = false; + this.searchMode = false; + this.searchDown; + this.entry = ''; + this.entryPrefix = 'Search: '; + this._real; + this._selected; + this._textarea; + + // charset + this.charset = null; + this.gcharset = null; + this.glevel = 0; + this.charsets = [null]; + + // mouse properties + this.decLocator; + this.x10Mouse; + this.vt200Mouse; + this.vt300Mouse; + this.normalMouse; + this.mouseEvents; + this.sendFocus; + this.utfMouse; + this.sgrMouse; + this.urxvtMouse; + + // misc + this.element; + this.children; + this.refreshStart; + this.refreshEnd; + this.savedX; + this.savedY; + this.savedCols; + + // stream + this.readable = true; + this.writable = true; + + this.defAttr = (0 << 18) | (257 << 9) | (256 << 0); + this.curAttr = this.defAttr; + + this.params = []; + this.currentParam = 0; + this.prefix = ''; + this.postfix = ''; + + this.lines = []; + var i = this.rows; + while (i--) { + this.lines.push(this.blankLine()); + } + + this.tabs; + this.setupStops(); +} + +inherits(Terminal, Stream); + +/** + * Colors + */ + +// Colors 0-15 +Terminal.tangoColors = [ + // dark: + '#2e3436', + '#cc0000', + '#4e9a06', + '#c4a000', + '#3465a4', + '#75507b', + '#06989a', + '#d3d7cf', + // bright: + '#555753', + '#ef2929', + '#8ae234', + '#fce94f', + '#729fcf', + '#ad7fa8', + '#34e2e2', + '#eeeeec' +]; + +Terminal.xtermColors = [ + // dark: + '#000000', // black + '#cd0000', // red3 + '#00cd00', // green3 + '#cdcd00', // yellow3 + '#0000ee', // blue2 + '#cd00cd', // magenta3 + '#00cdcd', // cyan3 + '#e5e5e5', // gray90 + // bright: + '#7f7f7f', // gray50 + '#ff0000', // red + '#00ff00', // green + '#ffff00', // yellow + '#5c5cff', // rgb:5c/5c/ff + '#ff00ff', // magenta + '#00ffff', // cyan + '#ffffff' // white +]; + +// Colors 0-15 + 16-255 +// Much thanks to TooTallNate for writing this. +Terminal.colors = (function() { + var colors = Terminal.tangoColors.slice() + , r = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff] + , i; + + // 16-231 + i = 0; + for (; i < 216; i++) { + out(r[(i / 36) % 6 | 0], r[(i / 6) % 6 | 0], r[i % 6]); + } + + // 232-255 (grey) + i = 0; + for (; i < 24; i++) { + r = 8 + i * 10; + out(r, r, r); + } + + function out(r, g, b) { + colors.push('#' + hex(r) + hex(g) + hex(b)); + } + + function hex(c) { + c = c.toString(16); + return c.length < 2 ? '0' + c : c; + } + + return colors; +})(); + +// Default BG/FG +Terminal.colors[256] = '#000000'; +Terminal.colors[257] = '#f0f0f0'; + +Terminal._colors = Terminal.colors.slice(); + +Terminal.vcolors = (function() { + var out = [] + , colors = Terminal.colors + , i = 0 + , color; + + for (; i < 256; i++) { + color = parseInt(colors[i].substring(1), 16); + out.push([ + (color >> 16) & 0xff, + (color >> 8) & 0xff, + color & 0xff + ]); + } + + return out; +})(); + +/** + * Options + */ + +Terminal.defaults = { + colors: Terminal.colors, + convertEol: false, + termName: 'xterm', + geometry: [80, 24], + cursorBlink: true, + visualBell: false, + popOnBell: false, + scrollback: 1000, + screenKeys: false, + debug: false, + useStyle: false + // programFeatures: false, + // focusKeys: false, +}; + +Terminal.options = {}; + +each(keys(Terminal.defaults), function(key) { + Terminal[key] = Terminal.defaults[key]; + Terminal.options[key] = Terminal.defaults[key]; +}); + +/** + * Focused Terminal + */ + +Terminal.focus = null; + +Terminal.prototype.focus = function() { + if (Terminal.focus === this) return; + + if (Terminal.focus) { + Terminal.focus.blur(); + } + + if (this.sendFocus) this.send('\x1b[I'); + this.showCursor(); + + // try { + // this.element.focus(); + // } catch (e) { + // ; + // } + + // this.emit('focus'); + + Terminal.focus = this; +}; + +Terminal.prototype.blur = function() { + if (Terminal.focus !== this) return; + + this.cursorState = 0; + this.refresh(this.y, this.y); + if (this.sendFocus) this.send('\x1b[O'); + + // try { + // this.element.blur(); + // } catch (e) { + // ; + // } + + // this.emit('blur'); + + Terminal.focus = null; +}; + +/** + * Initialize global behavior + */ + +Terminal.prototype.initGlobal = function() { + var document = this.document; + + Terminal._boundDocs = Terminal._boundDocs || []; + if (~indexOf(Terminal._boundDocs, document)) { + return; + } + Terminal._boundDocs.push(document); + + Terminal.bindPaste(document); + + Terminal.bindKeys(document); + + Terminal.bindCopy(document); + + if (this.isMobile) { + this.fixMobile(document); + } + + if (this.useStyle) { + Terminal.insertStyle(document, this.colors[256], this.colors[257]); + } +}; + +/** + * Bind to paste event + */ + +Terminal.bindPaste = function(document) { + // This seems to work well for ctrl-V and middle-click, + // even without the contentEditable workaround. + var window = document.defaultView; + on(window, 'paste', function(ev) { + var term = Terminal.focus; + if (!term) return; + if (ev.clipboardData) { + term.send(ev.clipboardData.getData('text/plain')); + } else if (term.context.clipboardData) { + term.send(term.context.clipboardData.getData('Text')); + } + // Not necessary. Do it anyway for good measure. + term.element.contentEditable = 'inherit'; + return cancel(ev); + }); +}; + +/** + * Global Events for key handling + */ + +Terminal.bindKeys = function(document) { + // We should only need to check `target === body` below, + // but we can check everything for good measure. + on(document, 'keydown', function(ev) { + if (!Terminal.focus) return; + var target = ev.target || ev.srcElement; + if (!target) return; + if (target === Terminal.focus.element + || target === Terminal.focus.context + || target === Terminal.focus.document + || target === Terminal.focus.body + || target === Terminal._textarea + || target === Terminal.focus.parent) { + return Terminal.focus.keyDown(ev); + } + }, true); + + on(document, 'keypress', function(ev) { + if (!Terminal.focus) return; + var target = ev.target || ev.srcElement; + if (!target) return; + if (ev.ctrlKey && ev.key === 'v') { + // If we got here with Ctrl+V, then we know it's us who enabled it + // to bubble to be handled by browser as Paste, so let this happen. + return; + } + if (target === Terminal.focus.element + || target === Terminal.focus.context + || target === Terminal.focus.document + || target === Terminal.focus.body + || target === Terminal._textarea + || target === Terminal.focus.parent) { + // In case user popped up context menu, widget may be stuck in + // "contentEditable" state (as a workaround for Firefox braindeadness) + // with visual artifacts like browser's cursur. Disable it now. + Terminal.focus.element.contentEditable = 'inherit'; + return Terminal.focus.keyPress(ev); + } + }, true); + + // If we click somewhere other than a + // terminal, unfocus the terminal. + on(document, 'mousedown', function(ev) { + if (!Terminal.focus) return; + + var el = ev.target || ev.srcElement; + if (!el) return; + + do { + if (el === Terminal.focus.element) return; + } while (el = el.parentNode); + + Terminal.focus.blur(); + }); +}; + +/** + * Copy Selection w/ Ctrl-C (Select Mode) + */ + +Terminal.bindCopy = function(document) { + var window = document.defaultView; + + // if (!('onbeforecopy' in document)) { + // // Copies to *only* the clipboard. + // on(window, 'copy', function fn(ev) { + // var term = Terminal.focus; + // if (!term) return; + // if (!term._selected) return; + // var text = term.grabText( + // term._selected.x1, term._selected.x2, + // term._selected.y1, term._selected.y2); + // term.emit('copy', text); + // ev.clipboardData.setData('text/plain', text); + // }); + // return; + // } + + // Copies to primary selection *and* clipboard. + // NOTE: This may work better on capture phase, + // or using the `beforecopy` event. + on(window, 'copy', function(ev) { + var term = Terminal.focus; + if (!term) return; + if (!term._selected) return; + var textarea = term.getCopyTextarea(); + var text = term.grabText( + term._selected.x1, term._selected.x2, + term._selected.y1, term._selected.y2); + term.emit('copy', text); + textarea.focus(); + textarea.textContent = text; + textarea.value = text; + textarea.setSelectionRange(0, text.length); + setTimeout(function() { + term.element.focus(); + term.focus(); + }, 1); + }); +}; + +/** + * Fix Mobile + */ + +Terminal.prototype.fixMobile = function(document) { + var self = this; + + var textarea = document.createElement('textarea'); + textarea.style.position = 'absolute'; + textarea.style.left = '-32000px'; + textarea.style.top = '-32000px'; + textarea.style.width = '0px'; + textarea.style.height = '0px'; + textarea.style.opacity = '0'; + textarea.style.backgroundColor = 'transparent'; + textarea.style.borderStyle = 'none'; + textarea.style.outlineStyle = 'none'; + textarea.autocapitalize = 'none'; + textarea.autocorrect = 'off'; + + document.getElementsByTagName('body')[0].appendChild(textarea); + + Terminal._textarea = textarea; + + setTimeout(function() { + textarea.focus(); + }, 1000); + + if (this.isAndroid) { + on(textarea, 'change', function() { + var value = textarea.textContent || textarea.value; + textarea.value = ''; + textarea.textContent = ''; + self.send(value + '\r'); + }); + } +}; + +/** + * Insert a default style + */ + +Terminal.insertStyle = function(document, bg, fg) { + var style = document.getElementById('term-style'); + if (style) return; + + var head = document.getElementsByTagName('head')[0]; + if (!head) return; + + var style = document.createElement('style'); + style.id = 'term-style'; + + // textContent doesn't work well with IE for