diff --git a/.gitignore b/.gitignore index 5e87af82..64910910 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ trash/ conf.json* +# macOS file: +.DS_Store + # auto created when running on desktop: internal_filesystem/SDLPointer_2 internal_filesystem/SDLPointer_3 diff --git a/.gitmodules b/.gitmodules index 7ea092ac..36f11e8a 100644 --- a/.gitmodules +++ b/.gitmodules @@ -10,7 +10,8 @@ url = https://github.com/MicroPythonOS/lvgl_micropython [submodule "micropython-camera-API"] path = micropython-camera-API - url = https://github.com/cnadler86/micropython-camera-API + #url = https://github.com/cnadler86/micropython-camera-API + url = https://github.com/MicroPythonOS/micropython-camera-API [submodule "micropython-nostr"] path = micropython-nostr url = https://github.com/MicroPythonOS/micropython-nostr diff --git a/CHANGELOG.md b/CHANGELOG.md index d03f979e..05d59f0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,42 @@ +0.5.2 +===== +- AudioFlinger: optimize WAV volume scaling for speed and immediately set volume +- AudioFlinger: add support for I2S microphone recording to WAV +- AppStore app: eliminate all thread by using TaskManager +- AppStore app: add support for BadgeHub backend +- OSUpdate app: show download speed +- API: add TaskManager that wraps asyncio +- API: add DownloadManager that uses TaskManager +- API: use aiorepl to eliminate another thread + + +0.5.1 +===== +- Fri3d Camp 2024 Board: add startup light and sound +- Fri3d Camp 2024 Board: workaround ADC2+WiFi conflict by temporarily disable WiFi to measure battery level +- Fri3d Camp 2024 Board: improve battery monitor calibration to fix 0.1V delta +- Fri3d Camp 2024 Board: add WSEN-ISDS 6-Axis Inertial Measurement Unit (IMU) support (including temperature) +- API: improve and cleanup animations +- API: SharedPreferences: add erase_all() function +- API: add defaults handling to SharedPreferences and only save non-defaults +- API: restore sys.path after starting app +- API: add AudioFlinger for audio playback (i2s DAC and buzzer) +- API: add LightsManager for multicolor LEDs +- API: add SensorManager for generic handling of IMUs and temperature sensors +- UI: back swipe gesture closes topmenu when open (thanks, @Mark19000 !) +- About app: add free, used and total storage space info +- AppStore app: remove unnecessary scrollbar over publisher's name +- Camera app: massive overhaul! + - Lots of settings (basic, advanced, expert) + - Enable decoding of high density QR codes (like Nostr Wallet Connect) from small sizes (like mobile phone screens) + - Even dotted, logo-ridden and scratched *pictures* of QR codes are now decoded properly! +- ImageView app: add delete functionality +- ImageView app: add support for grayscale images +- OSUpdate app: pause download when wifi is lost, resume when reconnected +- Settings app: fix un-checking of radio button +- Settings app: add IMU calibration +- Wifi app: simplify on-screen keyboard handling, fix cancel button handling + 0.5.0 ===== - ESP32: one build to rule them all; instead of 2 builds per supported board, there is now one single build that identifies and initializes the board at runtime! diff --git a/CLAUDE.md b/CLAUDE.md index f7aa3b0e..b00e3722 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,40 +59,120 @@ The OS supports: **Content Management**: - `PackageManager`: Install/uninstall/query apps - `Intent`: Launch activities with action/category filters -- `SharedPreferences`: Per-app key-value storage (similar to Android) +- `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 -# Development build (no frozen filesystem, requires ./scripts/install.sh after flashing) -./scripts/build_mpos.sh unix dev +# Build for desktop (Linux) +./scripts/build_mpos.sh unix -# Production build (with frozen filesystem) -./scripts/build_mpos.sh unix prod +# Build for desktop (macOS) +./scripts/build_mpos.sh macOS -# ESP32 builds (specify hardware variant) -./scripts/build_mpos.sh esp32 dev waveshare-esp32-s3-touch-lcd-2 -./scripts/build_mpos.sh esp32 prod fri3d-2024 +# Build for ESP32-S3 hardware (works on both waveshare and fri3d variants) +./scripts/build_mpos.sh esp32 ``` -**Build types**: -- `dev`: No preinstalled files or builtin filesystem. Boots to black screen until you run `./scripts/install.sh` -- `prod`: Files from `manifest*.py` are frozen into firmware. Run `./scripts/freezefs_mount_builtin.sh` before building - **Targets**: -- `esp32`: ESP32-S3 hardware (requires subtarget: `waveshare-esp32-s3-touch-lcd-2` or `fri3d-2024`) +- `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 @@ -312,10 +392,10 @@ See `internal_filesystem/apps/com.micropythonos.helloworld/` for a minimal examp For rapid iteration on desktop: ```bash # Build desktop version (only needed once) -./scripts/build_mpos.sh unix dev +./scripts/build_mpos.sh unix # Install filesystem to device (run after code changes) -./scripts/install.sh waveshare-esp32-s3-touch-lcd-2 +./scripts/install.sh # Or run directly on desktop ./scripts/run_desktop.sh com.example.myapp @@ -403,29 +483,23 @@ Current stable version: 0.3.3 (as of latest CHANGELOG entry) - 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` +- Task manager: `internal_filesystem/lib/mpos/task_manager.py` - see [docs/frameworks/task-manager.md](../docs/docs/frameworks/task-manager.md) +- Download manager: `internal_filesystem/lib/mpos/net/download_manager.py` - see [docs/frameworks/download-manager.md](../docs/docs/frameworks/download-manager.md) +- Config/preferences: `internal_filesystem/lib/mpos/config.py` - see [docs/frameworks/preferences.md](../docs/docs/frameworks/preferences.md) +- Audio system: `internal_filesystem/lib/mpos/audio/audioflinger.py` - see [docs/frameworks/audioflinger.md](../docs/docs/frameworks/audioflinger.md) +- LED control: `internal_filesystem/lib/mpos/lights.py` - see [docs/frameworks/lights-manager.md](../docs/docs/frameworks/lights-manager.md) +- Sensor management: `internal_filesystem/lib/mpos/sensor_manager.py` - see [docs/frameworks/sensor-manager.md](../docs/docs/frameworks/sensor-manager.md) - Top menu/drawer: `internal_filesystem/lib/mpos/ui/topmenu.py` - Activity navigation: `internal_filesystem/lib/mpos/activity_navigator.py` +- IMU drivers: `internal_filesystem/lib/mpos/hardware/drivers/qmi8658.py` and `wsen_isds.py` ## Common Utilities and Helpers **SharedPreferences**: Persistent key-value storage per app -```python -from mpos.config import SharedPreferences -# Load preferences -prefs = SharedPreferences("com.example.myapp") -value = prefs.get_string("key", "default_value") -number = prefs.get_int("count", 0) -data = prefs.get_dict("data", {}) +šŸ“– 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. -# Save preferences -editor = prefs.edit() -editor.put_string("key", "value") -editor.put_int("count", 42) -editor.put_dict("data", {"key": "value"}) -editor.commit() -``` +**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 @@ -500,11 +574,168 @@ def defocus_handler(self, obj): - **Connect 4** (`apps/com.micropythonos.connect4/assets/connect4.py`): Game columns are focusable **Other utilities**: +- `mpos.TaskManager`: Async task management - see [docs/frameworks/task-manager.md](../docs/docs/frameworks/task-manager.md) +- `mpos.DownloadManager`: HTTP download utilities - see [docs/frameworks/download-manager.md](../docs/docs/frameworks/download-manager.md) - `mpos.apps.good_stack_size()`: Returns appropriate thread stack size for platform (16KB ESP32, 24KB desktop) - `mpos.wifi`: WiFi management utilities - `mpos.sdcard.SDCardManager`: SD card mounting and management - `mpos.clipboard`: System clipboard access - `mpos.battery_voltage`: Battery level reading (ESP32 only) +- `mpos.sensor_manager`: Unified sensor access - see [docs/frameworks/sensor-manager.md](../docs/docs/frameworks/sensor-manager.md) +- `mpos.audio.audioflinger`: Audio playback service - see [docs/frameworks/audioflinger.md](../docs/docs/frameworks/audioflinger.md) +- `mpos.lights`: LED control - see [docs/frameworks/lights-manager.md](../docs/docs/frameworks/lights-manager.md) + +## Task Management (TaskManager) + +MicroPythonOS provides a centralized async task management service called **TaskManager** for managing background operations. + +**šŸ“– User Documentation**: See [docs/frameworks/task-manager.md](../docs/docs/frameworks/task-manager.md) for complete API reference, patterns, and examples. + +### Implementation Details (for Claude Code) + +- **Location**: `lib/mpos/task_manager.py` +- **Pattern**: Wrapper around `uasyncio` module +- **Key methods**: `create_task()`, `sleep()`, `sleep_ms()`, `wait_for()`, `notify_event()` +- **Thread model**: All tasks run on main asyncio event loop (cooperative multitasking) + +### Quick Example + +```python +from mpos import TaskManager, DownloadManager + +class MyActivity(Activity): + def onCreate(self): + # Launch background task + TaskManager.create_task(self.download_data()) + + async def download_data(self): + # Download with timeout + try: + data = await TaskManager.wait_for( + DownloadManager.download_url(url), + timeout=10 + ) + self.update_ui(data) + except asyncio.TimeoutError: + print("Download timed out") +``` + +### Critical Code Locations + +- Task manager: `lib/mpos/task_manager.py` +- Used throughout OS for async operations (downloads, WebSockets, sensors) + +## HTTP Downloads (DownloadManager) + +MicroPythonOS provides a centralized HTTP download service called **DownloadManager** for async file downloads. + +**šŸ“– User Documentation**: See [docs/frameworks/download-manager.md](../docs/docs/frameworks/download-manager.md) for complete API reference, patterns, and examples. + +### Implementation Details (for Claude Code) + +- **Location**: `lib/mpos/net/download_manager.py` +- **Pattern**: Module-level singleton (similar to `audioflinger.py`, `battery_voltage.py`) +- **Session management**: Automatic lifecycle (lazy init, auto-cleanup when idle) +- **Thread-safe**: Uses `_thread.allocate_lock()` for session access +- **Three output modes**: Memory (bytes), File (bool), Streaming (callbacks) +- **Features**: Retry logic (3 attempts), progress tracking, resume support (Range headers) + +### Quick Example + +```python +from mpos import DownloadManager + +# Download to memory +data = await DownloadManager.download_url("https://api.example.com/data.json") + +# Download to file with progress +async def on_progress(percent): + print(f"Progress: {percent}%") + +success = await DownloadManager.download_url( + "https://example.com/file.bin", + outfile="/sdcard/file.bin", + progress_callback=on_progress +) +``` + +### Critical Code Locations + +- Download manager: `lib/mpos/net/download_manager.py` +- Used by: AppStore, OSUpdate, and any app needing HTTP downloads + +## Audio System (AudioFlinger) + +MicroPythonOS provides a centralized audio service called **AudioFlinger** for managing audio playback. + +**šŸ“– User Documentation**: See [docs/frameworks/audioflinger.md](../docs/docs/frameworks/audioflinger.md) for complete API reference, examples, and troubleshooting. + +### Implementation Details (for Claude Code) + +- **Location**: `lib/mpos/audio/audioflinger.py` +- **Pattern**: Module-level singleton (similar to `battery_voltage.py`) +- **Thread-safe**: Uses locks for concurrent access +- **Hardware abstraction**: Supports I2S (GPIO 2, 47, 16) and Buzzer (GPIO 46 on Fri3d) +- **Audio focus**: 3-tier priority system (ALARM > NOTIFICATION > MUSIC) +- **Configuration**: `data/com.micropythonos.settings/config.json` key: `audio_device` + +### Critical Code Locations + +- Audio service: `lib/mpos/audio/audioflinger.py` +- I2S implementation: `lib/mpos/audio/i2s_audio.py` +- Buzzer implementation: `lib/mpos/audio/buzzer.py` +- RTTTL parser: `lib/mpos/audio/rtttl.py` +- Board init (Waveshare): `lib/mpos/board/waveshare_esp32_s3_touch_lcd_2.py` (line ~105) +- Board init (Fri3d): `lib/mpos/board/fri3d_2024.py` (line ~300) + +## LED Control (LightsManager) + +MicroPythonOS provides LED control for NeoPixel RGB LEDs (Fri3d badge only). + +**šŸ“– User Documentation**: See [docs/frameworks/lights-manager.md](../docs/docs/frameworks/lights-manager.md) for complete API reference, animation patterns, and examples. + +### Implementation Details (for Claude Code) + +- **Location**: `lib/mpos/lights.py` +- **Pattern**: Module-level singleton (similar to `battery_voltage.py`) +- **Hardware**: 5 NeoPixel RGB LEDs on GPIO 12 (Fri3d badge only) +- **Buffered**: LED colors buffered until `write()` is called +- **Thread-safe**: No locking (single-threaded usage recommended) +- **Desktop**: Functions return `False` (no-op) on desktop builds + +### Critical Code Locations + +- LED service: `lib/mpos/lights.py` +- Board init (Fri3d): `lib/mpos/board/fri3d_2024.py` (line ~290) +- NeoPixel dependency: Uses `neopixel` module from MicroPython + +## Sensor System (SensorManager) + +MicroPythonOS provides a unified sensor framework called **SensorManager** for motion sensors (accelerometer, gyroscope) and temperature sensors. + +šŸ“– User Documentation: See [docs/frameworks/sensor-manager.md](../docs/docs/frameworks/sensor-manager.md) for complete API reference, calibration guide, game examples, and troubleshooting. + +### Implementation Details (for Claude Code) + +- **Location**: `lib/mpos/sensor_manager.py` +- **Pattern**: Module-level singleton (similar to `battery_voltage.py`) +- **Units**: Standard SI (m/s² for acceleration, deg/s for gyroscope, °C for temperature) +- **Calibration**: Persistent via SharedPreferences (`data/com.micropythonos.sensors/config.json`) +- **Thread-safe**: Uses locks for concurrent access +- **Auto-detection**: Identifies IMU type via chip ID registers + - QMI8658: chip_id=0x05 at reg=0x00 + - WSEN_ISDS: chip_id=0x6A at reg=0x0F +- **Desktop**: Functions return `None` (graceful fallback) on desktop builds +- **Important**: Driver constants defined with `const()` cannot be imported at runtime - SensorManager uses hardcoded values instead + +### Critical Code Locations + +- Sensor service: `lib/mpos/sensor_manager.py` +- QMI8658 driver: `lib/mpos/hardware/drivers/qmi8658.py` +- WSEN_ISDS driver: `lib/mpos/hardware/drivers/wsen_isds.py` +- Board init (Waveshare): `lib/mpos/board/waveshare_esp32_s3_touch_lcd_2.py` (line ~130) +- Board init (Fri3d): `lib/mpos/board/fri3d_2024.py` (line ~320) +- Board init (Linux): `lib/mpos/board/linux.py` (line ~115) ## Animations and Game Loops diff --git a/c_mpos/quirc/lib/quirc.c b/c_mpos/quirc/lib/quirc.c index 208746ec..8f9da73e 100644 --- a/c_mpos/quirc/lib/quirc.c +++ b/c_mpos/quirc/lib/quirc.c @@ -64,7 +64,7 @@ int quirc_resize(struct quirc *q, int w, int h) /* * alloc a new buffer for q->image. We avoid realloc(3) because we want - * on failure to be leave `q` in a consistant, unmodified state. + * on failure to be leaving `q` in a consistent, unmodified state. */ image = ps_malloc(w * h); if (!image) @@ -72,7 +72,7 @@ int quirc_resize(struct quirc *q, int w, int h) /* compute the "old" (i.e. currently allocated) and the "new" (i.e. requested) image dimensions */ - size_t olddim = q->w * q->h; + size_t olddim = q->w * q->h; // these are initialized to 0 by quirc_new() size_t newdim = w * h; size_t min = (olddim < newdim ? olddim : newdim); diff --git a/c_mpos/src/quirc_decode.c b/c_mpos/src/quirc_decode.c index 68bcccb9..06c0e3c4 100644 --- a/c_mpos/src/quirc_decode.c +++ b/c_mpos/src/quirc_decode.c @@ -17,12 +17,13 @@ size_t uxTaskGetStackHighWaterMark(void * unused) { #endif #include "../quirc/lib/quirc.h" +#include "../quirc/lib/quirc_internal.h" // Exposes full struct quirc #define QRDECODE_DEBUG_PRINT(...) mp_printf(&mp_plat_print, __VA_ARGS__) static mp_obj_t qrdecode(mp_uint_t n_args, const mp_obj_t *args) { - QRDECODE_DEBUG_PRINT("qrdecode: Starting\n"); - QRDECODE_DEBUG_PRINT("qrdecode: Stack high-water mark: %u bytes\n", uxTaskGetStackHighWaterMark(NULL)); + //QRDECODE_DEBUG_PRINT("qrdecode: Starting\n"); + //QRDECODE_DEBUG_PRINT("qrdecode: Stack high-water mark: %u bytes\n", uxTaskGetStackHighWaterMark(NULL)); if (n_args != 3) { mp_raise_ValueError(MP_ERROR_TEXT("quirc_decode expects 3 arguments: buffer, width, height")); @@ -33,36 +34,55 @@ static mp_obj_t qrdecode(mp_uint_t n_args, const mp_obj_t *args) { mp_int_t width = mp_obj_get_int(args[1]); mp_int_t height = mp_obj_get_int(args[2]); - QRDECODE_DEBUG_PRINT("qrdecode: Width=%u, Height=%u\n", width, height); + //QRDECODE_DEBUG_PRINT("qrdecode: Width=%u, Height=%u\n", width, height); if (width <= 0 || height <= 0) { mp_raise_ValueError(MP_ERROR_TEXT("width and height must be positive")); } if (bufinfo.len != (size_t)(width * height)) { + QRDECODE_DEBUG_PRINT("qrdecode wrong bufsize: %u bytes\n", bufinfo.len); mp_raise_ValueError(MP_ERROR_TEXT("buffer size must match width * height")); } - struct quirc *qr = quirc_new(); if (!qr) { mp_raise_OSError(MP_ENOMEM); } - QRDECODE_DEBUG_PRINT("qrdecode: Allocated quirc object\n"); + //QRDECODE_DEBUG_PRINT("qrdecode: Allocated quirc object\n"); if (quirc_resize(qr, width, height) < 0) { quirc_destroy(qr); mp_raise_OSError(MP_ENOMEM); } - QRDECODE_DEBUG_PRINT("qrdecode: Resized quirc object\n"); + //QRDECODE_DEBUG_PRINT("qrdecode: Resized quirc object\n"); - uint8_t *image; - image = quirc_begin(qr, NULL, NULL); + uint8_t *image = quirc_begin(qr, NULL, NULL); memcpy(image, bufinfo.buf, width * height); + // would be nice to be able to use the existing buffer (bufinfo.buf) here, avoiding memcpy, + // but that buffer is also being filled by image capture and displayed by lvgl + // and that becomes unstable... it shows black artifacts and crashes sometimes... + //uint8_t *temp_image = image; + //image = bufinfo.buf; + //qr->image = bufinfo.buf; // if this works then we can also eliminate quirc's ps_alloc() quirc_end(qr); + //qr->image = temp_image; // restore, because quirc will try to free it + + /* + // Pointer swap - NO memcpy, NO internal.h needed + uint8_t *quirc_buffer = quirc_begin(qr, NULL, NULL); + uint8_t *saved_bufinfo = bufinfo.buf; + bufinfo.buf = quirc_buffer; // quirc now uses your buffer + quirc_end(qr); // QR detection works! + // Restore your buffer pointer + //bufinfo.buf = saved_bufinfo; + */ + + // now num_grids is set, as well as others, probably int count = quirc_count(qr); if (count == 0) { + // Restore your buffer pointer quirc_destroy(qr); - QRDECODE_DEBUG_PRINT("qrdecode: No QR code found, freed quirc object\n"); + //QRDECODE_DEBUG_PRINT("qrdecode: No QR code found, freed quirc object\n"); mp_raise_ValueError(MP_ERROR_TEXT("no QR code found")); } @@ -71,8 +91,10 @@ static mp_obj_t qrdecode(mp_uint_t n_args, const mp_obj_t *args) { quirc_destroy(qr); mp_raise_OSError(MP_ENOMEM); } - QRDECODE_DEBUG_PRINT("qrdecode: Allocated quirc_code\n"); + //QRDECODE_DEBUG_PRINT("qrdecode: Allocated quirc_code\n"); quirc_extract(qr, 0, code); + // the code struct now contains the corners of the QR code, as well as the bitmap of the values + // this could be used to display debug info to the user - they might even be able to see which modules are being misidentified! struct quirc_data *data = (struct quirc_data *)malloc(sizeof(struct quirc_data)); if (!data) { @@ -80,14 +102,14 @@ static mp_obj_t qrdecode(mp_uint_t n_args, const mp_obj_t *args) { quirc_destroy(qr); mp_raise_OSError(MP_ENOMEM); } - QRDECODE_DEBUG_PRINT("qrdecode: Allocated quirc_data\n"); + //QRDECODE_DEBUG_PRINT("qrdecode: Allocated quirc_data\n"); int err = quirc_decode(code, data); if (err != QUIRC_SUCCESS) { free(data); free(code); quirc_destroy(qr); - QRDECODE_DEBUG_PRINT("qrdecode: Decode failed, freed data, code, and quirc object\n"); + //QRDECODE_DEBUG_PRINT("qrdecode: Decode failed, freed data, code, and quirc object\n"); mp_raise_TypeError(MP_ERROR_TEXT("failed to decode QR code")); } @@ -96,12 +118,12 @@ static mp_obj_t qrdecode(mp_uint_t n_args, const mp_obj_t *args) { free(data); free(code); quirc_destroy(qr); - QRDECODE_DEBUG_PRINT("qrdecode: Freed data, code, and quirc object, returning result\n"); + //QRDECODE_DEBUG_PRINT("qrdecode: Freed data, code, and quirc object, returning result\n"); return result; } static mp_obj_t qrdecode_rgb565(mp_uint_t n_args, const mp_obj_t *args) { - QRDECODE_DEBUG_PRINT("qrdecode_rgb565: Starting\n"); + //QRDECODE_DEBUG_PRINT("qrdecode_rgb565: Starting\n"); if (n_args != 3) { mp_raise_ValueError(MP_ERROR_TEXT("qrdecode_rgb565 expects 3 arguments: buffer, width, height")); @@ -112,12 +134,13 @@ static mp_obj_t qrdecode_rgb565(mp_uint_t n_args, const mp_obj_t *args) { mp_int_t width = mp_obj_get_int(args[1]); mp_int_t height = mp_obj_get_int(args[2]); - QRDECODE_DEBUG_PRINT("qrdecode_rgb565: Width=%u, Height=%u\n", width, height); + //QRDECODE_DEBUG_PRINT("qrdecode_rgb565: Width=%u, Height=%u\n", width, height); if (width <= 0 || height <= 0) { mp_raise_ValueError(MP_ERROR_TEXT("width and height must be positive")); } if (bufinfo.len != (size_t)(width * height * 2)) { + QRDECODE_DEBUG_PRINT("qrdecode_rgb565 wrong bufsize: %u bytes\n", bufinfo.len); mp_raise_ValueError(MP_ERROR_TEXT("buffer size must match width * height * 2 for RGB565")); } @@ -125,7 +148,7 @@ static mp_obj_t qrdecode_rgb565(mp_uint_t n_args, const mp_obj_t *args) { if (!gray_buffer) { mp_raise_OSError(MP_ENOMEM); } - QRDECODE_DEBUG_PRINT("qrdecode_rgb565: Allocated gray_buffer (%u bytes)\n", width * height * sizeof(uint8_t)); + //QRDECODE_DEBUG_PRINT("qrdecode_rgb565: Allocated gray_buffer (%u bytes)\n", width * height * sizeof(uint8_t)); uint16_t *rgb565 = (uint16_t *)bufinfo.buf; for (size_t i = 0; i < (size_t)(width * height); i++) { @@ -147,17 +170,37 @@ static mp_obj_t qrdecode_rgb565(mp_uint_t n_args, const mp_obj_t *args) { if (nlr_push(&exception_handler) == 0) { result = qrdecode(3, gray_args); nlr_pop(); - QRDECODE_DEBUG_PRINT("qrdecode_rgb565: qrdecode succeeded, freeing gray_buffer\n"); + //QRDECODE_DEBUG_PRINT("qrdecode_rgb565: qrdecode succeeded, freeing gray_buffer\n"); free(gray_buffer); } else { - QRDECODE_DEBUG_PRINT("qrdecode_rgb565: Exception caught, freeing gray_buffer\n"); - free(gray_buffer); + //QRDECODE_DEBUG_PRINT("qrdecode_rgb565: Exception caught, freeing gray_buffer\n"); + // Cleanup + if (gray_buffer) { + free(gray_buffer); + gray_buffer = NULL; + } + //mp_raise_TypeError(MP_ERROR_TEXT("qrdecode_rgb565: failed to decode QR code")); // Re-raising the exception results in an Unhandled exception in thread started by // which isn't caught, even when catching Exception, so this looks like a bug in MicroPython... - //nlr_pop(); - //nlr_raise(exception_handler.ret_val); + nlr_pop(); + nlr_raise(exception_handler.ret_val); + // Re-raise the original exception with optional additional message + /* + mp_raise_msg_and_obj( + mp_obj_exception_get_type(exception_handler.ret_val), + MP_OBJ_NEW_QSTR(qstr_from_str("qrdecode_rgb565: failed during processing")), + exception_handler.ret_val + ); + */ + // Re-raise as new exception of same type, with message + original as arg + // (embeds original for traceback chaining) + // crashes: + //const mp_obj_type_t *exc_type = mp_obj_get_type(exception_handler.ret_val); + //mp_raise_msg_varg(exc_type, MP_ERROR_TEXT("qrdecode_rgb565: failed during processing: %q"), exception_handler.ret_val); } + //nlr_pop(); maybe it needs to be done after instead of before the re-raise? + return result; } diff --git a/c_mpos/src/webcam.c b/c_mpos/src/webcam.c index 4ae15994..6667b3b9 100644 --- a/c_mpos/src/webcam.c +++ b/c_mpos/src/webcam.c @@ -8,117 +8,320 @@ #include #include #include +#include #include "py/obj.h" #include "py/runtime.h" #include "py/mperrno.h" -#define WIDTH 640 -#define HEIGHT 480 #define NUM_BUFFERS 1 -#define OUTPUT_WIDTH 240 -#define OUTPUT_HEIGHT 240 +#define MAX_SUPPORTED_RESOLUTIONS 32 #define WEBCAM_DEBUG_PRINT(...) mp_printf(&mp_plat_print, __VA_ARGS__) static const mp_obj_type_t webcam_type; +// Resolution structure for storing supported formats +typedef struct { + int width; + int height; +} resolution_t; + +// Cache of supported resolutions from V4L2 device +typedef struct { + resolution_t resolutions[MAX_SUPPORTED_RESOLUTIONS]; + int count; +} supported_resolutions_t; + typedef struct _webcam_obj_t { mp_obj_base_t base; int fd; + char device[64]; // Device path (e.g., "/dev/video0") void *buffers[NUM_BUFFERS]; size_t buffer_length; int frame_count; - unsigned char *gray_buffer; // For grayscale - uint16_t *rgb565_buffer; // For RGB565 -} webcam_obj_t; + unsigned char *gray_buffer; // For grayscale conversion + uint16_t *rgb565_buffer; // For RGB565 conversion -static void yuyv_to_rgb565_240x240(unsigned char *yuyv, uint16_t *rgb565, int in_width, int in_height) { - int crop_size = 480; - int crop_x_offset = (in_width - crop_size) / 2; - int crop_y_offset = (in_height - crop_size) / 2; - float x_ratio = (float)crop_size / OUTPUT_WIDTH; - float y_ratio = (float)crop_size / OUTPUT_HEIGHT; + // Separate capture and output dimensions + int capture_width; // What V4L2 actually captures + int capture_height; + int output_width; // What user requested + int output_height; - for (int y = 0; y < OUTPUT_HEIGHT; y++) { - for (int x = 0; x < OUTPUT_WIDTH; x++) { - int src_x = (int)(x * x_ratio) + crop_x_offset; - int src_y = (int)(y * y_ratio) + crop_y_offset; - int src_index = (src_y * in_width + src_x) * 2; - - int y0 = yuyv[src_index]; - int u = yuyv[src_index + 1]; - int v = yuyv[src_index + 3]; + // Supported resolutions cache + supported_resolutions_t supported_res; +} webcam_obj_t; - int c = y0 - 16; - int d = u - 128; - int e = v - 128; +// Helper function to convert single YUV pixel to RGB565 +static inline uint16_t yuv_to_rgb565(int y_val, int u, int v) { + int c = y_val - 16; + int d = u - 128; + int e = v - 128; - int r = (298 * c + 409 * e + 128) >> 8; - int g = (298 * c - 100 * d - 208 * e + 128) >> 8; - int b = (298 * c + 516 * d + 128) >> 8; + int r = (298 * c + 409 * e + 128) >> 8; + int g = (298 * c - 100 * d - 208 * e + 128) >> 8; + int b = (298 * c + 516 * d + 128) >> 8; - r = r < 0 ? 0 : (r > 255 ? 255 : r); - g = g < 0 ? 0 : (g > 255 ? 255 : g); - b = b < 0 ? 0 : (b > 255 ? 255 : b); + // Clamp to valid range + r = r < 0 ? 0 : (r > 255 ? 255 : r); + g = g < 0 ? 0 : (g > 255 ? 255 : g); + b = b < 0 ? 0 : (b > 255 ? 255 : b); - uint16_t r5 = (r >> 3) & 0x1F; - uint16_t g6 = (g >> 2) & 0x3F; - uint16_t b5 = (b >> 3) & 0x1F; + // Convert to RGB565 + return ((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3); +} - rgb565[y * OUTPUT_WIDTH + x] = (r5 << 11) | (g6 << 5) | b5; +static void yuyv_to_rgb565(unsigned char *yuyv, uint16_t *rgb565, + int capture_width, int capture_height, + int output_width, int output_height) { + // Convert YUYV to RGB565 with cropping or padding support + // YUYV format: Y0 U Y1 V (4 bytes for 2 pixels, chroma shared) + + // Clear entire output buffer to black (RGB565 0x0000) + memset(rgb565, 0, output_width * output_height * sizeof(uint16_t)); + + if (output_width <= capture_width && output_height <= capture_height) { + // Cropping case: extract center region from capture + int offset_x = (capture_width - output_width) / 2; + int offset_y = (capture_height - output_height) / 2; + offset_x = (offset_x / 2) * 2; // YUYV alignment (even offset) + + for (int y = 0; y < output_height; y++) { + for (int x = 0; x < output_width; x += 2) { + int src_y = offset_y + y; + int src_x = offset_x + x; + int src_index = (src_y * capture_width + src_x) * 2; + + int y0 = yuyv[src_index + 0]; + int u = yuyv[src_index + 1]; + int y1 = yuyv[src_index + 2]; + int v = yuyv[src_index + 3]; + + int dst_index = y * output_width + x; + rgb565[dst_index] = yuv_to_rgb565(y0, u, v); + rgb565[dst_index + 1] = yuv_to_rgb565(y1, u, v); + } + } + } else { + // Padding case: center capture in larger output buffer + int offset_x = (output_width - capture_width) / 2; + int offset_y = (output_height - capture_height) / 2; + offset_x = (offset_x / 2) * 2; // YUYV alignment (even offset) + + for (int y = 0; y < capture_height; y++) { + for (int x = 0; x < capture_width; x += 2) { + int src_index = (y * capture_width + x) * 2; + + int y0 = yuyv[src_index + 0]; + int u = yuyv[src_index + 1]; + int y1 = yuyv[src_index + 2]; + int v = yuyv[src_index + 3]; + + int dst_y = offset_y + y; + int dst_x = offset_x + x; + int dst_index = dst_y * output_width + dst_x; + rgb565[dst_index] = yuv_to_rgb565(y0, u, v); + rgb565[dst_index + 1] = yuv_to_rgb565(y1, u, v); + } } } } -static void yuyv_to_grayscale_240x240(unsigned char *yuyv, unsigned char *gray, int in_width, int in_height) { - int crop_size = 480; - int crop_x_offset = (in_width - crop_size) / 2; - int crop_y_offset = (in_height - crop_size) / 2; - float x_ratio = (float)crop_size / OUTPUT_WIDTH; - float y_ratio = (float)crop_size / OUTPUT_HEIGHT; - - for (int y = 0; y < OUTPUT_HEIGHT; y++) { - for (int x = 0; x < OUTPUT_WIDTH; x++) { - int src_x = (int)(x * x_ratio) + crop_x_offset; - int src_y = (int)(y * y_ratio) + crop_y_offset; - int src_index = (src_y * in_width + src_x) * 2; - gray[y * OUTPUT_WIDTH + x] = yuyv[src_index]; +static void yuyv_to_grayscale(unsigned char *yuyv, unsigned char *gray, + int capture_width, int capture_height, + int output_width, int output_height) { + // Extract Y (luminance) values from YUYV with cropping or padding support + // YUYV format: Y0 U Y1 V (4 bytes for 2 pixels) + + // Clear entire output buffer to black (0x00) + memset(gray, 0, output_width * output_height); + + if (output_width <= capture_width && output_height <= capture_height) { + // Cropping case: extract center region from capture + int offset_x = (capture_width - output_width) / 2; + int offset_y = (capture_height - output_height) / 2; + offset_x = (offset_x / 2) * 2; // YUYV alignment (even offset) + + for (int y = 0; y < output_height; y++) { + for (int x = 0; x < output_width; x++) { + int src_y = offset_y + y; + int src_x = offset_x + x; + // Y values are at even indices in YUYV + gray[y * output_width + x] = yuyv[(src_y * capture_width + src_x) * 2]; + } + } + } else { + // Padding case: center capture in larger output buffer + int offset_x = (output_width - capture_width) / 2; + int offset_y = (output_height - capture_height) / 2; + offset_x = (offset_x / 2) * 2; // YUYV alignment (even offset) + + for (int y = 0; y < capture_height; y++) { + for (int x = 0; x < capture_width; x++) { + int dst_y = offset_y + y; + int dst_x = offset_x + x; + // Y values are at even indices in YUYV + gray[dst_y * output_width + dst_x] = yuyv[(y * capture_width + x) * 2]; + } } } } -static void save_raw(const char *filename, unsigned char *data, int width, int height) { +static void save_raw_generic(const char *filename, void *data, size_t elem_size, int width, int height) { FILE *fp = fopen(filename, "wb"); if (!fp) { WEBCAM_DEBUG_PRINT("Cannot open file %s: %s\n", filename, strerror(errno)); return; } - fwrite(data, 1, width * height, fp); + fwrite(data, elem_size, width * height, fp); fclose(fp); } -static void save_raw_rgb565(const char *filename, uint16_t *data, int width, int height) { - FILE *fp = fopen(filename, "wb"); - if (!fp) { - WEBCAM_DEBUG_PRINT("Cannot open file %s: %s\n", filename, strerror(errno)); - return; +// Query supported YUYV resolutions from V4L2 device +static int query_supported_resolutions(int fd, supported_resolutions_t *supported) { + struct v4l2_fmtdesc fmt_desc; + struct v4l2_frmsizeenum frmsize; + int found_yuyv = 0; + + supported->count = 0; + + // First, check if device supports YUYV format + memset(&fmt_desc, 0, sizeof(fmt_desc)); + fmt_desc.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; + + for (fmt_desc.index = 0; ; fmt_desc.index++) { + if (ioctl(fd, VIDIOC_ENUM_FMT, &fmt_desc) < 0) { + break; + } + if (fmt_desc.pixelformat == V4L2_PIX_FMT_YUYV) { + found_yuyv = 1; + break; + } } - fwrite(data, sizeof(uint16_t), width * height, fp); - fclose(fp); + + if (!found_yuyv) { + WEBCAM_DEBUG_PRINT("Warning: YUYV format not found\n"); + return -1; + } + + // Enumerate frame sizes for YUYV + memset(&frmsize, 0, sizeof(frmsize)); + frmsize.pixel_format = V4L2_PIX_FMT_YUYV; + + for (frmsize.index = 0; supported->count < MAX_SUPPORTED_RESOLUTIONS; frmsize.index++) { + if (ioctl(fd, VIDIOC_ENUM_FRAMESIZES, &frmsize) < 0) { + break; + } + + if (frmsize.type == V4L2_FRMSIZE_TYPE_DISCRETE) { + supported->resolutions[supported->count].width = frmsize.discrete.width; + supported->resolutions[supported->count].height = frmsize.discrete.height; + supported->count++; + WEBCAM_DEBUG_PRINT(" Found resolution: %dx%d\n", + frmsize.discrete.width, frmsize.discrete.height); + } + } + + if (supported->count == 0) { + WEBCAM_DEBUG_PRINT("Warning: No discrete YUYV resolutions found, using common defaults\n"); + // Fallback to common resolutions if enumeration fails + const resolution_t defaults[] = { + {160, 120}, {320, 240}, {640, 480}, {1280, 720}, {1920, 1080} + }; + for (int i = 0; i < 5 && i < MAX_SUPPORTED_RESOLUTIONS; i++) { + supported->resolutions[i] = defaults[i]; + supported->count++; + } + } + + WEBCAM_DEBUG_PRINT("Total supported resolutions: %d\n", supported->count); + return 0; } -static int init_webcam(webcam_obj_t *self, const char *device) { - //WEBCAM_DEBUG_PRINT("webcam.c: init_webcam\n"); +// Find the best capture resolution for the requested output size +static resolution_t find_best_capture_resolution(int requested_width, int requested_height, + supported_resolutions_t *supported) { + resolution_t best; + int found_candidate = 0; + int min_area = INT_MAX; + + // Check for exact match first + for (int i = 0; i < supported->count; i++) { + if (supported->resolutions[i].width == requested_width && + supported->resolutions[i].height == requested_height) { + WEBCAM_DEBUG_PRINT("Found exact resolution match: %dx%d\n", + requested_width, requested_height); + return supported->resolutions[i]; + } + } + + // Find smallest resolution that contains the requested size + for (int i = 0; i < supported->count; i++) { + if (supported->resolutions[i].width >= requested_width && + supported->resolutions[i].height >= requested_height) { + int area = supported->resolutions[i].width * supported->resolutions[i].height; + if (area < min_area) { + min_area = area; + best = supported->resolutions[i]; + found_candidate = 1; + } + } + } + + if (found_candidate) { + WEBCAM_DEBUG_PRINT("Best capture resolution for %dx%d: %dx%d (will crop)\n", + requested_width, requested_height, best.width, best.height); + return best; + } + + // No containing resolution found, use largest available (will need padding) + best = supported->resolutions[0]; + for (int i = 1; i < supported->count; i++) { + int area = supported->resolutions[i].width * supported->resolutions[i].height; + int best_area = best.width * best.height; + if (area > best_area) { + best = supported->resolutions[i]; + } + } + + WEBCAM_DEBUG_PRINT("Warning: Requested %dx%d exceeds max supported, capturing at %dx%d (will pad with black)\n", + requested_width, requested_height, best.width, best.height); + return best; +} + +static int init_webcam(webcam_obj_t *self, const char *device, int requested_width, int requested_height) { + // Store device path for later use (e.g., reconfigure) + strncpy(self->device, device, sizeof(self->device) - 1); + self->device[sizeof(self->device) - 1] = '\0'; + self->fd = open(device, O_RDWR); if (self->fd < 0) { WEBCAM_DEBUG_PRINT("Cannot open device: %s\n", strerror(errno)); return -errno; } + // Query supported resolutions (first time only) + if (self->supported_res.count == 0) { + WEBCAM_DEBUG_PRINT("Querying supported resolutions...\n"); + if (query_supported_resolutions(self->fd, &self->supported_res) < 0) { + // Query failed, but continue with fallback defaults + WEBCAM_DEBUG_PRINT("Resolution query failed, continuing with defaults\n"); + } + } + + // Find best capture resolution for requested output + resolution_t best = find_best_capture_resolution(requested_width, requested_height, + &self->supported_res); + + // Store requested output dimensions + self->output_width = requested_width; + self->output_height = requested_height; + + // Configure V4L2 with capture resolution struct v4l2_format fmt = {0}; fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; - fmt.fmt.pix.width = WIDTH; - fmt.fmt.pix.height = HEIGHT; + fmt.fmt.pix.width = best.width; + fmt.fmt.pix.height = best.height; fmt.fmt.pix.pixelformat = V4L2_PIX_FMT_YUYV; fmt.fmt.pix.field = V4L2_FIELD_ANY; if (ioctl(self->fd, VIDIOC_S_FMT, &fmt) < 0) { @@ -127,6 +330,10 @@ static int init_webcam(webcam_obj_t *self, const char *device) { return -errno; } + // Store actual capture dimensions (driver may adjust) + self->capture_width = fmt.fmt.pix.width; + self->capture_height = fmt.fmt.pix.height; + struct v4l2_requestbuffers req = {0}; req.count = NUM_BUFFERS; req.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; @@ -144,6 +351,10 @@ static int init_webcam(webcam_obj_t *self, const char *device) { buf.index = i; if (ioctl(self->fd, VIDIOC_QUERYBUF, &buf) < 0) { WEBCAM_DEBUG_PRINT("Cannot query buffer: %s\n", strerror(errno)); + // Unmap any already-mapped buffers + for (int j = 0; j < i; j++) { + munmap(self->buffers[j], self->buffer_length); + } close(self->fd); return -errno; } @@ -151,6 +362,10 @@ static int init_webcam(webcam_obj_t *self, const char *device) { self->buffers[i] = mmap(NULL, buf.length, PROT_READ | PROT_WRITE, MAP_SHARED, self->fd, buf.m.offset); if (self->buffers[i] == MAP_FAILED) { WEBCAM_DEBUG_PRINT("Cannot map buffer: %s\n", strerror(errno)); + // Unmap any already-mapped buffers + for (int j = 0; j < i; j++) { + munmap(self->buffers[j], self->buffer_length); + } close(self->fd); return -errno; } @@ -174,10 +389,16 @@ static int init_webcam(webcam_obj_t *self, const char *device) { } self->frame_count = 0; - self->gray_buffer = (unsigned char *)malloc(OUTPUT_WIDTH * OUTPUT_HEIGHT * sizeof(unsigned char)); - self->rgb565_buffer = (uint16_t *)malloc(OUTPUT_WIDTH * OUTPUT_HEIGHT * sizeof(uint16_t)); + + WEBCAM_DEBUG_PRINT("Webcam initialized: capture=%dx%d, output=%dx%d\n", + self->capture_width, self->capture_height, + self->output_width, self->output_height); + + // Allocate conversion buffers based on OUTPUT dimensions + self->gray_buffer = (unsigned char *)malloc(self->output_width * self->output_height * sizeof(unsigned char)); + self->rgb565_buffer = (uint16_t *)malloc(self->output_width * self->output_height * sizeof(uint16_t)); if (!self->gray_buffer || !self->rgb565_buffer) { - WEBCAM_DEBUG_PRINT("Cannot allocate buffers: %s\n", strerror(errno)); + WEBCAM_DEBUG_PRINT("Cannot allocate conversion buffers: %s\n", strerror(errno)); free(self->gray_buffer); free(self->rgb565_buffer); close(self->fd); @@ -203,6 +424,9 @@ static void deinit_webcam(webcam_obj_t *self) { free(self->rgb565_buffer); self->rgb565_buffer = NULL; + // Clear resolution cache (device may change on reconnect) + self->supported_res.count = 0; + close(self->fd); self->fd = -1; } @@ -226,37 +450,45 @@ static mp_obj_t capture_frame(mp_obj_t self_in, mp_obj_t format) { mp_raise_OSError(-res); } - if (!self->gray_buffer) { - self->gray_buffer = (unsigned char *)malloc(OUTPUT_WIDTH * OUTPUT_HEIGHT * sizeof(unsigned char)); - if (!self->gray_buffer) { - mp_raise_OSError(MP_ENOMEM); - } - } - if (!self->rgb565_buffer) { - self->rgb565_buffer = (uint16_t *)malloc(OUTPUT_WIDTH * OUTPUT_HEIGHT * sizeof(uint16_t)); - if (!self->rgb565_buffer) { - mp_raise_OSError(MP_ENOMEM); - } + // Buffers should already be allocated in init_webcam + if (!self->gray_buffer || !self->rgb565_buffer) { + mp_raise_msg(&mp_type_RuntimeError, MP_ERROR_TEXT("Buffers not allocated")); } const char *fmt = mp_obj_str_get_str(format); if (strcmp(fmt, "grayscale") == 0) { - yuyv_to_grayscale_240x240(self->buffers[buf.index], self->gray_buffer, WIDTH, HEIGHT); - // char filename[32]; - // snprintf(filename, sizeof(filename), "frame_%03d.raw", self->frame_count++); - // save_raw(filename, self->gray_buffer, OUTPUT_WIDTH, OUTPUT_HEIGHT); - mp_obj_t result = mp_obj_new_memoryview('b', OUTPUT_WIDTH * OUTPUT_HEIGHT, self->gray_buffer); + // Pass all 6 dimensions: capture (source) and output (destination) + yuyv_to_grayscale( + self->buffers[buf.index], + self->gray_buffer, + self->capture_width, // Source dimensions + self->capture_height, + self->output_width, // Destination dimensions + self->output_height + ); + // Return memoryview with OUTPUT dimensions + mp_obj_t result = mp_obj_new_memoryview('b', + self->output_width * self->output_height, + self->gray_buffer); res = ioctl(self->fd, VIDIOC_QBUF, &buf); if (res < 0) { mp_raise_OSError(-res); } return result; } else { - yuyv_to_rgb565_240x240(self->buffers[buf.index], self->rgb565_buffer, WIDTH, HEIGHT); - // char filename[32]; - // snprintf(filename, sizeof(filename), "frame_%03d.rgb565", self->frame_count++); - // save_raw_rgb565(filename, self->rgb565_buffer, OUTPUT_WIDTH, OUTPUT_HEIGHT); - mp_obj_t result = mp_obj_new_memoryview('b', OUTPUT_WIDTH * OUTPUT_HEIGHT * 2, self->rgb565_buffer); + // Pass all 6 dimensions: capture (source) and output (destination) + yuyv_to_rgb565( + self->buffers[buf.index], + self->rgb565_buffer, + self->capture_width, // Source dimensions + self->capture_height, + self->output_width, // Destination dimensions + self->output_height + ); + // Return memoryview with OUTPUT dimensions + mp_obj_t result = mp_obj_new_memoryview('b', + self->output_width * self->output_height * 2, + self->rgb565_buffer); res = ioctl(self->fd, VIDIOC_QBUF, &buf); if (res < 0) { mp_raise_OSError(-res); @@ -265,27 +497,37 @@ static mp_obj_t capture_frame(mp_obj_t self_in, mp_obj_t format) { } } -static mp_obj_t webcam_init(size_t n_args, const mp_obj_t *args) { - mp_arg_check_num(n_args, 0, 0, 1, false); +static mp_obj_t webcam_init(size_t n_args, const mp_obj_t *pos_args, mp_map_t *kw_args) { + enum { ARG_device, ARG_width, ARG_height }; + static const mp_arg_t allowed_args[] = { + { MP_QSTR_device, MP_ARG_OBJ, {.u_obj = MP_OBJ_NULL} }, + { MP_QSTR_width, MP_ARG_REQUIRED | MP_ARG_KW_ONLY | MP_ARG_INT, {.u_int = 0} }, + { MP_QSTR_height, MP_ARG_REQUIRED | MP_ARG_KW_ONLY | MP_ARG_INT, {.u_int = 0} }, + }; + + mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; + mp_arg_parse_all(n_args, pos_args, kw_args, MP_ARRAY_SIZE(allowed_args), allowed_args, args); + const char *device = "/dev/video0"; - if (n_args == 1) { - device = mp_obj_str_get_str(args[0]); + if (args[ARG_device].u_obj != MP_OBJ_NULL) { + device = mp_obj_str_get_str(args[ARG_device].u_obj); } + int width = args[ARG_width].u_int; + int height = args[ARG_height].u_int; + webcam_obj_t *self = m_new_obj(webcam_obj_t); self->base.type = &webcam_type; self->fd = -1; - self->gray_buffer = NULL; - self->rgb565_buffer = NULL; - int res = init_webcam(self, device); + int res = init_webcam(self, device, width, height); if (res < 0) { mp_raise_OSError(-res); } return MP_OBJ_FROM_PTR(self); } -MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(webcam_init_obj, 0, 1, webcam_init); +MP_DEFINE_CONST_FUN_OBJ_KW(webcam_init_obj, 0, webcam_init); static mp_obj_t webcam_deinit(mp_obj_t self_in) { webcam_obj_t *self = MP_OBJ_TO_PTR(self_in); @@ -309,6 +551,62 @@ static mp_obj_t webcam_capture_frame(mp_obj_t self_in, mp_obj_t format) { } MP_DEFINE_CONST_FUN_OBJ_2(webcam_capture_frame_obj, webcam_capture_frame); +static mp_obj_t webcam_reconfigure(size_t n_args, const mp_obj_t *pos_args, mp_map_t *kw_args) { + /* + * Reconfigure webcam resolution by reinitializing. + * + * This elegantly reuses deinit_webcam() and init_webcam() instead of + * duplicating V4L2 setup code. + * + * Parameters: + * width, height: Resolution (optional, keeps current if not specified) + */ + + enum { ARG_self, ARG_width, ARG_height }; + static const mp_arg_t allowed_args[] = { + { MP_QSTR_self, MP_ARG_REQUIRED | MP_ARG_OBJ, {.u_obj = MP_OBJ_NULL} }, + { MP_QSTR_width, MP_ARG_KW_ONLY | MP_ARG_INT, {.u_int = 0} }, + { MP_QSTR_height, MP_ARG_KW_ONLY | MP_ARG_INT, {.u_int = 0} }, + }; + + mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; + mp_arg_parse_all(n_args, pos_args, kw_args, MP_ARRAY_SIZE(allowed_args), allowed_args, args); + + webcam_obj_t *self = MP_OBJ_TO_PTR(args[ARG_self].u_obj); + + // Get new dimensions (keep current if not specified) + int new_width = args[ARG_width].u_int; + int new_height = args[ARG_height].u_int; + + if (new_width == 0) new_width = self->output_width; + if (new_height == 0) new_height = self->output_height; + + // Validate dimensions + if (new_width <= 0 || new_height <= 0 || new_width > 3840 || new_height > 2160) { + mp_raise_ValueError(MP_ERROR_TEXT("Invalid dimensions")); + } + + // Check if anything changed + if (new_width == self->output_width && new_height == self->output_height) { + return mp_const_none; // Nothing to do + } + + WEBCAM_DEBUG_PRINT("Reconfiguring webcam: %dx%d -> %dx%d\n", + self->output_width, self->output_height, new_width, new_height); + + // Clean shutdown and reinitialize with new resolution + // Note: deinit_webcam doesn't touch self->device, so it's safe to use directly + deinit_webcam(self); + int res = init_webcam(self, self->device, new_width, new_height); + + if (res < 0) { + mp_raise_OSError(-res); + } + + return mp_const_none; +} +MP_DEFINE_CONST_FUN_OBJ_KW(webcam_reconfigure_obj, 1, webcam_reconfigure); + static const mp_obj_type_t webcam_type = { { &mp_type_type }, .name = MP_QSTR_Webcam, @@ -321,6 +619,7 @@ static const mp_rom_map_elem_t mp_module_webcam_globals_table[] = { { MP_ROM_QSTR(MP_QSTR_capture_frame), MP_ROM_PTR(&webcam_capture_frame_obj) }, { MP_ROM_QSTR(MP_QSTR_deinit), MP_ROM_PTR(&webcam_deinit_obj) }, { MP_ROM_QSTR(MP_QSTR_free_buffer), MP_ROM_PTR(&webcam_free_buffer_obj) }, + { MP_ROM_QSTR(MP_QSTR_reconfigure), MP_ROM_PTR(&webcam_reconfigure_obj) }, }; static MP_DEFINE_CONST_DICT(mp_module_webcam_globals, mp_module_webcam_globals_table); diff --git a/internal_filesystem/apps/com.micropythonos.camera/META-INF/MANIFEST.JSON b/internal_filesystem/apps/com.micropythonos.camera/META-INF/MANIFEST.JSON index 360dd3c8..0405e83b 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/META-INF/MANIFEST.JSON +++ b/internal_filesystem/apps/com.micropythonos.camera/META-INF/MANIFEST.JSON @@ -3,10 +3,10 @@ "publisher": "MicroPythonOS", "short_description": "Camera with QR decoding", "long_description": "Camera for both internal camera's and webcams, that includes QR decoding.", -"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.camera/icons/com.micropythonos.camera_0.0.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.1.0_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.camera/mpks/com.micropythonos.camera_0.1.0.mpk", "fullname": "com.micropythonos.camera", -"version": "0.0.11", +"version": "0.1.0", "category": "camera", "activities": [ { @@ -22,6 +22,11 @@ "category": "default" } ] + }, + { + "entrypoint": "assets/camera_app.py", + "classname": "CameraSettingsActivity", + "intent_filters": [] } ] } diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index 3d9eb8b0..23675283 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -1,41 +1,48 @@ -# This code grabs images from the camera in RGB565 format (2 bytes per pixel) -# and sends that to the QR decoder if QR decoding is enabled. -# The QR decoder then converts the RGB565 to grayscale, as that's what quirc operates on. -# It would be slightly more efficient to capture the images from the camera in L8/grayscale format, -# or in YUV format and discarding the U and V planes, but then the image will be gray (not great UX) -# and the performance impact of converting RGB565 to grayscale is probably minimal anyway. - import lvgl as lv +import time try: import webcam except Exception as e: print(f"Info: could not import webcam module: {e}") -from mpos.apps import Activity import mpos.time +from mpos.apps import Activity +from mpos.content.intent import Intent + +from camera_settings import CameraSettingsActivity class CameraApp(Activity): - width = 240 - height = 240 + PACKAGE = "com.micropythonos.camera" + CONFIGFILE = "config.json" + SCANQR_CONFIG = "config_scanqr_mode.json" - status_label_text = "No camera found." - status_label_text_searching = "Searching QR codes...\n\nHold still and try varying scan distance (10-25cm) and QR size (4-12cm). Ensure proper lighting." - status_label_text_found = "Decoding QR..." + button_width = 75 + button_height = 50 + + STATUS_NO_CAMERA = "No camera found." + STATUS_SEARCHING_QR = "Searching QR codes...\n\nHold still and try varying scan distance (10-25cm) and make the QR code big (4-12cm). Ensure proper lighting." + STATUS_FOUND_QR = "Found QR, trying to decode... hold still..." cam = None - current_cam_buffer = None # Holds the current memoryview to prevent garbage collection + current_cam_buffer = None # Holds the current memoryview to prevent garba + width = None + height = None + colormode = False - image = None image_dsc = None - scanqr_mode = None + scanqr_mode = False + scanqr_intent = False use_webcam = False - keepliveqrdecoding = False - capture_timer = None + + prefs = None # regular prefs + scanqr_prefs = None # qr code scanning prefs # Widgets: + main_screen = None + image = None qr_label = None qr_button = None snap_button = None @@ -43,97 +50,114 @@ class CameraApp(Activity): status_label_cont = None def onCreate(self): - self.scanqr_mode = self.getIntent().extras.get("scanqr_mode") - main_screen = lv.obj() - main_screen.set_style_pad_all(0, 0) - main_screen.set_style_border_width(0, 0) - main_screen.set_size(lv.pct(100), lv.pct(100)) - main_screen.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) - close_button = lv.button(main_screen) - close_button.set_size(60,60) + self.main_screen = lv.obj() + self.main_screen.set_style_pad_all(1, 0) + self.main_screen.set_style_border_width(0, 0) + self.main_screen.set_size(lv.pct(100), lv.pct(100)) + self.main_screen.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) + # Initialize LVGL image widget + self.image = lv.image(self.main_screen) + self.image.align(lv.ALIGN.LEFT_MID, 0, 0) + close_button = lv.button(self.main_screen) + close_button.set_size(self.button_width, self.button_height) close_button.align(lv.ALIGN.TOP_RIGHT, 0, 0) close_label = lv.label(close_button) close_label.set_text(lv.SYMBOL.CLOSE) close_label.center() close_button.add_event_cb(lambda e: self.finish(),lv.EVENT.CLICKED,None) - self.snap_button = lv.button(main_screen) - self.snap_button.set_size(60, 60) - self.snap_button.align(lv.ALIGN.RIGHT_MID, 0, 0) - self.snap_button.add_flag(lv.obj.FLAG.HIDDEN) - self.snap_button.add_event_cb(self.snap_button_click,lv.EVENT.CLICKED,None) - snap_label = lv.label(self.snap_button) - snap_label.set_text(lv.SYMBOL.OK) - snap_label.center() - self.qr_button = lv.button(main_screen) - self.qr_button.set_size(60, 60) + # Settings button + settings_button = lv.button(self.main_screen) + settings_button.set_size(self.button_width, self.button_height) + settings_button.align_to(close_button, lv.ALIGN.OUT_BOTTOM_MID, 0, 10) + settings_label = lv.label(settings_button) + settings_label.set_text(lv.SYMBOL.SETTINGS) + settings_label.center() + settings_button.add_event_cb(lambda e: self.open_settings(),lv.EVENT.CLICKED,None) + #self.zoom_button = lv.button(self.main_screen) + #self.zoom_button.set_size(self.button_width, self.button_height) + #self.zoom_button.align(lv.ALIGN.RIGHT_MID, 0, self.button_height + 5) + #self.zoom_button.add_event_cb(self.zoom_button_click,lv.EVENT.CLICKED,None) + #zoom_label = lv.label(self.zoom_button) + #zoom_label.set_text("Z") + #zoom_label.center() + self.qr_button = lv.button(self.main_screen) + self.qr_button.set_size(self.button_width, self.button_height) self.qr_button.add_flag(lv.obj.FLAG.HIDDEN) self.qr_button.align(lv.ALIGN.BOTTOM_RIGHT, 0, 0) self.qr_button.add_event_cb(self.qr_button_click,lv.EVENT.CLICKED,None) self.qr_label = lv.label(self.qr_button) self.qr_label.set_text(lv.SYMBOL.EYE_OPEN) self.qr_label.center() - # Initialize LVGL image widget - self.image = lv.image(main_screen) - self.image.align(lv.ALIGN.LEFT_MID, 0, 0) - # Create image descriptor once - self.image_dsc = lv.image_dsc_t({ - "header": { - "magic": lv.IMAGE_HEADER_MAGIC, - "w": self.width, - "h": self.height, - "stride": self.width * 2, - "cf": lv.COLOR_FORMAT.RGB565 - #"cf": lv.COLOR_FORMAT.L8 - }, - 'data_size': self.width * self.height * 2, - 'data': None # Will be updated per frame - }) - self.image.set_src(self.image_dsc) - self.status_label_cont = lv.obj(main_screen) - self.status_label_cont.set_size(lv.pct(66),lv.pct(60)) - self.status_label_cont.align(lv.ALIGN.LEFT_MID, lv.pct(5), 0) + + self.snap_button = lv.button(self.main_screen) + self.snap_button.set_size(self.button_width, self.button_height) + self.snap_button.align_to(self.qr_button, lv.ALIGN.OUT_TOP_MID, 0, -10) + self.snap_button.add_flag(lv.obj.FLAG.HIDDEN) + self.snap_button.add_event_cb(self.snap_button_click,lv.EVENT.CLICKED,None) + snap_label = lv.label(self.snap_button) + snap_label.set_text(lv.SYMBOL.OK) + snap_label.center() + + + self.status_label_cont = lv.obj(self.main_screen) + width = mpos.ui.pct_of_display_width(70) + height = mpos.ui.pct_of_display_width(60) + self.status_label_cont.set_size(width,height) + center_w = round((mpos.ui.pct_of_display_width(100) - self.button_width - 5 - width)/2) + center_h = round((mpos.ui.pct_of_display_height(100) - height)/2) + self.status_label_cont.set_pos(center_w,center_h) self.status_label_cont.set_style_bg_color(lv.color_white(), 0) self.status_label_cont.set_style_bg_opa(66, 0) self.status_label_cont.set_style_border_width(0, 0) self.status_label = lv.label(self.status_label_cont) - self.status_label.set_text("No camera found.") + self.status_label.set_text(self.STATUS_NO_CAMERA) self.status_label.set_long_mode(lv.label.LONG_MODE.WRAP) self.status_label.set_width(lv.pct(100)) self.status_label.center() - self.setContentView(main_screen) + self.setContentView(self.main_screen) def onResume(self, screen): - self.cam = init_internal_cam() - if not self.cam: - # try again because the manual i2c poweroff leaves it in a bad state - self.cam = init_internal_cam() + self.scanqr_intent = self.getIntent().extras.get("scanqr_intent") + self.status_label_cont.add_flag(lv.obj.FLAG.HIDDEN) + if self.scanqr_mode or self.scanqr_intent: + self.start_qr_decoding() + if not self.cam and self.scanqr_mode: + print("No camera found, stopping camera app") + self.finish() + else: + self.load_settings_cached() + self.start_cam() + self.qr_button.remove_flag(lv.obj.FLAG.HIDDEN) + self.snap_button.remove_flag(lv.obj.FLAG.HIDDEN) + + def onPause(self, screen): + print("camera app backgrounded, cleaning up...") + self.stop_cam() + print("camera app cleanup done.") + + def start_cam(self): + # Init camera: + self.cam = self.init_internal_cam(self.width, self.height) if self.cam: self.image.set_rotation(900) # internal camera is rotated 90 degrees + # Apply saved camera settings, only for internal camera for now: + self.apply_camera_settings(self.scanqr_prefs if self.scanqr_mode else self.prefs, self.cam, self.use_webcam) # needs to be done AFTER the camera is initialized else: print("camera app: no internal camera found, trying webcam on /dev/video0") try: - self.cam = webcam.init("/dev/video0") + # Initialize webcam with desired resolution directly + print(f"Initializing webcam at {self.width}x{self.height}") + self.cam = webcam.init("/dev/video0", width=self.width, height=self.height) self.use_webcam = True except Exception as e: print(f"camera app: webcam exception: {e}") + # Start refreshing: if self.cam: print("Camera app initialized, continuing...") - self.set_image_size() + self.update_preview_image() self.capture_timer = lv.timer_create(self.try_capture, 100, None) - self.status_label_cont.add_flag(lv.obj.FLAG.HIDDEN) - if self.scanqr_mode: - self.start_qr_decoding() - else: - self.qr_button.remove_flag(lv.obj.FLAG.HIDDEN) - self.snap_button.remove_flag(lv.obj.FLAG.HIDDEN) - else: - print("No camera found, stopping camera app") - if self.scanqr_mode: - self.finish() - - def onStop(self, screen): - print("camera app backgrounded, cleaning up...") + def stop_cam(self): if self.capture_timer: self.capture_timer.delete() if self.use_webcam: @@ -154,15 +178,58 @@ def onStop(self, screen): i2c.writeto(camera_addr, bytes([reg_high, reg_low, power_off_command])) except Exception as e: print(f"Warning: powering off camera got exception: {e}") - print("camera app cleanup done.") + self.cam = None + if self.image_dsc: # it's important to delete the image when stopping the camera, otherwise LVGL might try to display it and crash + print("emptying self.current_cam_buffer...") + self.image_dsc.data = None + + def load_settings_cached(self): + from mpos.config import SharedPreferences + if self.scanqr_mode: + print("loading scanqr settings...") + if not self.scanqr_prefs: + # Merge common and scanqr-specific defaults + scanqr_defaults = {} + scanqr_defaults.update(CameraSettingsActivity.COMMON_DEFAULTS) + scanqr_defaults.update(CameraSettingsActivity.SCANQR_DEFAULTS) + self.scanqr_prefs = SharedPreferences( + self.PACKAGE, + filename=self.SCANQR_CONFIG, + defaults=scanqr_defaults + ) + # Defaults come from constructor, no need to pass them here + self.width = self.scanqr_prefs.get_int("resolution_width") + self.height = self.scanqr_prefs.get_int("resolution_height") + self.colormode = self.scanqr_prefs.get_bool("colormode") + else: + if not self.prefs: + # Merge common and normal-specific defaults + normal_defaults = {} + normal_defaults.update(CameraSettingsActivity.COMMON_DEFAULTS) + normal_defaults.update(CameraSettingsActivity.NORMAL_DEFAULTS) + self.prefs = SharedPreferences(self.PACKAGE, defaults=normal_defaults) + # Defaults come from constructor, no need to pass them here + self.width = self.prefs.get_int("resolution_width") + self.height = self.prefs.get_int("resolution_height") + self.colormode = self.prefs.get_bool("colormode") - def set_image_size(self): + def update_preview_image(self): + self.image_dsc = lv.image_dsc_t({ + "header": { + "magic": lv.IMAGE_HEADER_MAGIC, + "w": self.width, + "h": self.height, + "stride": self.width * (2 if self.colormode else 1), + "cf": lv.COLOR_FORMAT.RGB565 if self.colormode else lv.COLOR_FORMAT.L8 + }, + 'data_size': self.width * self.height * (2 if self.colormode else 1), + 'data': None # Will be updated per frame + }) + self.image.set_src(self.image_dsc) disp = lv.display_get_default() target_h = disp.get_vertical_resolution() - target_w = target_h - if target_w == self.width and target_h == self.height: - print("Target width and height are the same as native image, no scaling required.") - return + #target_w = disp.get_horizontal_resolution() - self.button_width - 5 # leave 5px for border + target_w = target_h # square print(f"scaling to size: {target_w}x{target_h}") scale_factor_w = round(target_w * 256 / self.width) scale_factor_h = round(target_h * 256 / self.height) @@ -173,141 +240,371 @@ def set_image_size(self): def qrdecode_one(self): try: + result = None + before = time.ticks_ms() import qrdecode - import utime - before = utime.ticks_ms() - result = qrdecode.qrdecode_rgb565(self.current_cam_buffer, self.width, self.height) - after = utime.ticks_ms() - #result = bytearray("INSERT_QR_HERE", "utf-8") - if not result: - self.status_label.set_text(self.status_label_text_searching) + if self.colormode: + result = qrdecode.qrdecode_rgb565(self.current_cam_buffer, self.width, self.height) else: - print(f"SUCCESSFUL QR DECODE TOOK: {after-before}ms") - result = remove_bom(result) - result = print_qr_buffer(result) - print(f"QR decoding found: {result}") - if self.scanqr_mode: - self.setResult(True, result) - self.finish() - else: - self.status_label.set_text(result) # in the future, the status_label text should be copy-paste-able - self.stop_qr_decoding() + result = qrdecode.qrdecode(self.current_cam_buffer, self.width, self.height) + after = time.ticks_ms() + print(f"qrdecode took {after-before}ms") except ValueError as e: print("QR ValueError: ", e) - self.status_label.set_text(self.status_label_text_searching) + self.status_label.set_text(self.STATUS_SEARCHING_QR) except TypeError as e: print("QR TypeError: ", e) - self.status_label.set_text(self.status_label_text_found) + self.status_label.set_text(self.STATUS_FOUND_QR) except Exception as e: print("QR got other error: ", e) + #result = bytearray("INSERT_TEST_QR_DATA_HERE", "utf-8") + if result is None: + return + result = self.remove_bom(result) + result = self.print_qr_buffer(result) + print(f"QR decoding found: {result}") + self.stop_qr_decoding() + if self.scanqr_intent: + self.setResult(True, result) + self.finish() + else: + self.status_label.set_text(result) # in the future, the status_label text should be copy-paste-able def snap_button_click(self, e): - print("Picture taken!") + print("Taking picture...") + # Would be nice to check that there's enough free space here, and show an error if not... import os + path = "data/images" try: os.mkdir("data") except OSError: pass try: - os.mkdir("data/images") + os.mkdir(path) except OSError: pass - if self.current_cam_buffer is not None: - filename=f"data/images/camera_capture_{mpos.time.epoch_seconds()}_{self.width}x{self.height}_RGB565.raw" - try: - with open(filename, 'wb') as f: - f.write(self.current_cam_buffer) - print(f"Successfully wrote current_cam_buffer to {filename}") - except OSError as e: - print(f"Error writing to file: {e}") + if self.current_cam_buffer is None: + print("snap_button_click: won't save empty image") + return + # Check enough free space? + stat = os.statvfs("data/images") + free_space = stat[0] * stat[3] + size_needed = len(self.current_cam_buffer) + print(f"Free space {free_space} and size needed {size_needed}") + if free_space < size_needed: + self.status_label.set_text(f"Free storage space is {free_space}, need {size_needed}, not saving...") + self.status_label_cont.remove_flag(lv.obj.FLAG.HIDDEN) + return + colorname = "RGB565" if self.colormode else "GRAY" + filename=f"{path}/picture_{mpos.time.epoch_seconds()}_{self.width}x{self.height}_{colorname}.raw" + try: + with open(filename, 'wb') as f: + f.write(self.current_cam_buffer) # This takes around 17 seconds to store 921600 bytes, so ~50KB/s, so would be nice to show some progress bar + report = f"Successfully wrote image to {filename}" + print(report) + self.status_label.set_text(report) + self.status_label_cont.remove_flag(lv.obj.FLAG.HIDDEN) + except OSError as e: + print(f"Error writing to file: {e}") def start_qr_decoding(self): print("Activating live QR decoding...") - self.keepliveqrdecoding = True + self.scanqr_mode = True + oldwidth = self.width + oldheight = self.height + oldcolormode = self.colormode + # Activate QR mode settings + self.load_settings_cached() + # Check if it's necessary to restart the camera: + if not self.cam or self.width != oldwidth or self.height != oldheight or self.colormode != oldcolormode: + if self.cam: + self.stop_cam() + self.start_cam() self.qr_label.set_text(lv.SYMBOL.EYE_CLOSE) self.status_label_cont.remove_flag(lv.obj.FLAG.HIDDEN) - self.status_label.set_text(self.status_label_text_searching) + self.status_label.set_text(self.STATUS_SEARCHING_QR) def stop_qr_decoding(self): print("Deactivating live QR decoding...") - self.keepliveqrdecoding = False + self.scanqr_mode = False self.qr_label.set_text(lv.SYMBOL.EYE_OPEN) - self.status_label_text = self.status_label.get_text() - if self.status_label_text in (self.status_label_text_searching or self.status_label_text_found): # if it found a QR code, leave it + status_label_text = self.status_label.get_text() + if status_label_text in (self.STATUS_NO_CAMERA, self.STATUS_SEARCHING_QR, self.STATUS_FOUND_QR): # if it found a QR code, leave it self.status_label_cont.add_flag(lv.obj.FLAG.HIDDEN) + # Check if it's necessary to restart the camera: + oldwidth = self.width + oldheight = self.height + oldcolormode = self.colormode + # Activate non-QR mode settings + self.load_settings_cached() + # Check if it's necessary to restart the camera: + if self.width != oldwidth or self.height != oldheight or self.colormode != oldcolormode: + self.stop_cam() + self.start_cam() def qr_button_click(self, e): - if not self.keepliveqrdecoding: + if not self.scanqr_mode: self.start_qr_decoding() else: self.stop_qr_decoding() - + + def open_settings(self): + intent = Intent(activity_class=CameraSettingsActivity, extras={"prefs": self.prefs if not self.scanqr_mode else self.scanqr_prefs, "use_webcam": self.use_webcam, "scanqr_mode": self.scanqr_mode}) + self.startActivity(intent) + def try_capture(self, event): - #print("capturing camera frame") try: - if self.use_webcam: - self.current_cam_buffer = webcam.capture_frame(self.cam, "rgb565") - elif self.cam.frame_available(): + if self.use_webcam and self.cam: + self.current_cam_buffer = webcam.capture_frame(self.cam, "rgb565" if self.colormode else "grayscale") + elif self.cam and self.cam.frame_available(): self.current_cam_buffer = self.cam.capture() - if self.current_cam_buffer and len(self.current_cam_buffer): - self.image_dsc.data = self.current_cam_buffer - #image.invalidate() # does not work so do this: - self.image.set_src(self.image_dsc) - if not self.use_webcam: - self.cam.free_buffer() # Free the old buffer - if self.keepliveqrdecoding: - self.qrdecode_one() except Exception as e: print(f"Camera capture exception: {e}") + return + # Display the image: + self.image_dsc.data = self.current_cam_buffer + #self.image.invalidate() # does not work so do this: + self.image.set_src(self.image_dsc) + if self.scanqr_mode: + self.qrdecode_one() + if not self.use_webcam and self.cam: + self.cam.free_buffer() # After QR decoding, free the old buffer, otherwise the camera doesn't provide a new one + + def init_internal_cam(self, width, height): + """Initialize internal camera with specified resolution. + + Automatically retries once if initialization fails (to handle I2C poweroff issue). + """ + try: + from camera import Camera, GrabMode, PixelFormat, FrameSize, GainCeiling + + # Map resolution to FrameSize enum + # Format: (width, height): FrameSize + resolution_map = { + (96, 96): FrameSize.R96X96, + (160, 120): FrameSize.QQVGA, + (128, 128): FrameSize.R128X128, + (176, 144): FrameSize.QCIF, + (240, 176): FrameSize.HQVGA, + (240, 240): FrameSize.R240X240, + (320, 240): FrameSize.QVGA, + (320, 320): FrameSize.R320X320, + (400, 296): FrameSize.CIF, + (480, 320): FrameSize.HVGA, + (480, 480): FrameSize.R480X480, + (640, 480): FrameSize.VGA, + (640, 640): FrameSize.R640X640, + (720, 720): FrameSize.R720X720, + (800, 600): FrameSize.SVGA, + (800, 800): FrameSize.R800X800, + (960, 960): FrameSize.R960X960, + (1024, 768): FrameSize.XGA, + (1024,1024): FrameSize.R1024X1024, + (1280, 720): FrameSize.HD, + (1280, 1024): FrameSize.SXGA, + (1280, 1280): FrameSize.R1280X1280, + (1600, 1200): FrameSize.UXGA, + (1920, 1080): FrameSize.FHD, + } + + frame_size = resolution_map.get((width, height), FrameSize.QVGA) + print(f"init_internal_cam: Using FrameSize for {width}x{height}") + + # Try to initialize, with one retry for I2C poweroff issue + max_attempts = 3 + for attempt in range(max_attempts): + try: + cam = Camera( + data_pins=[12,13,15,11,14,10,7,2], + vsync_pin=6, + href_pin=4, + sda_pin=21, + scl_pin=16, + pclk_pin=9, + xclk_pin=8, + xclk_freq=20000000, + powerdown_pin=-1, + reset_pin=-1, + pixel_format=PixelFormat.RGB565 if self.colormode else PixelFormat.GRAYSCALE, + frame_size=frame_size, + #grab_mode=GrabMode.WHEN_EMPTY, + grab_mode=GrabMode.LATEST, + fb_count=1 + ) + cam.set_vflip(True) + return cam + except Exception as e: + if attempt < max_attempts-1: + print(f"init_cam attempt {attempt} failed: {e}, retrying...") + else: + print(f"init_cam final exception: {e}") + return None + except Exception as e: + print(f"init_cam exception: {e}") + return None + + def print_qr_buffer(self, buffer): + try: + # Try to decode buffer as a UTF-8 string + result = buffer.decode('utf-8') + # Check if the string is printable (ASCII printable characters) + if all(32 <= ord(c) <= 126 for c in result): + return result + except Exception as e: + pass + # If not a valid string or not printable, convert to hex + hex_str = ' '.join([f'{b:02x}' for b in buffer]) + return hex_str.lower() + + # Byte-Order-Mark is added sometimes + def remove_bom(self, buffer): + bom = b'\xEF\xBB\xBF' + if buffer.startswith(bom): + return buffer[3:] + return buffer + + + def apply_camera_settings(self, prefs, cam, use_webcam): + """Apply all saved camera settings to the camera. + + Only applies settings when use_webcam is False (ESP32 camera). + Settings are applied in dependency order (master switches before dependent values). + + Args: + cam: Camera object + use_webcam: Boolean indicating if using webcam + """ + if not cam or use_webcam: + print("apply_camera_settings: Skipping (no camera or webcam mode)") + return + + try: + # Basic image adjustments + brightness = prefs.get_int("brightness") + cam.set_brightness(brightness) + + contrast = prefs.get_int("contrast") + cam.set_contrast(contrast) + + saturation = prefs.get_int("saturation") + cam.set_saturation(saturation) + + # Orientation + hmirror = prefs.get_bool("hmirror") + cam.set_hmirror(hmirror) + vflip = prefs.get_bool("vflip") + cam.set_vflip(vflip) -# Non-class functions: -def init_internal_cam(): - try: - from camera import Camera, GrabMode, PixelFormat, FrameSize, GainCeiling - cam = Camera( - data_pins=[12,13,15,11,14,10,7,2], - vsync_pin=6, - href_pin=4, - sda_pin=21, - scl_pin=16, - pclk_pin=9, - xclk_pin=8, - xclk_freq=20000000, - powerdown_pin=-1, - reset_pin=-1, - pixel_format=PixelFormat.RGB565, - #pixel_format=PixelFormat.GRAYSCALE, - frame_size=FrameSize.R240X240, - grab_mode=GrabMode.LATEST - ) - #cam.init() automatically done when creating the Camera() - #cam.reconfigure(frame_size=FrameSize.HVGA) - #frame_size=FrameSize.HVGA, # 480x320 - #frame_size=FrameSize.QVGA, # 320x240 - #frame_size=FrameSize.QQVGA # 160x120 - cam.set_vflip(True) - return cam - except Exception as e: - print(f"init_cam exception: {e}") - return None - -def print_qr_buffer(buffer): - try: - # Try to decode buffer as a UTF-8 string - result = buffer.decode('utf-8') - # Check if the string is printable (ASCII printable characters) - if all(32 <= ord(c) <= 126 for c in result): - return result - except Exception as e: - pass - # If not a valid string or not printable, convert to hex - hex_str = ' '.join([f'{b:02x}' for b in buffer]) - return hex_str.lower() - -# Byte-Order-Mark is added sometimes -def remove_bom(buffer): - bom = b'\xEF\xBB\xBF' - if buffer.startswith(bom): - return buffer[3:] - return buffer + # Special effect + special_effect = prefs.get_int("special_effect") + cam.set_special_effect(special_effect) + + # Exposure control (apply master switch first, then manual value) + exposure_ctrl = prefs.get_bool("exposure_ctrl") + cam.set_exposure_ctrl(exposure_ctrl) + + if not exposure_ctrl: + aec_value = prefs.get_int("aec_value") + cam.set_aec_value(aec_value) + + # Mode-specific default comes from constructor + ae_level = prefs.get_int("ae_level") + cam.set_ae_level(ae_level) + + aec2 = prefs.get_bool("aec2") + cam.set_aec2(aec2) + + # Gain control (apply master switch first, then manual value) + gain_ctrl = prefs.get_bool("gain_ctrl") + cam.set_gain_ctrl(gain_ctrl) + + if not gain_ctrl: + agc_gain = prefs.get_int("agc_gain") + cam.set_agc_gain(agc_gain) + + gainceiling = prefs.get_int("gainceiling") + cam.set_gainceiling(gainceiling) + + # White balance (apply master switch first, then mode) + whitebal = prefs.get_bool("whitebal") + cam.set_whitebal(whitebal) + + if not whitebal: + wb_mode = prefs.get_int("wb_mode") + cam.set_wb_mode(wb_mode) + + awb_gain = prefs.get_bool("awb_gain") + cam.set_awb_gain(awb_gain) + + # Sensor-specific settings (try/except for unsupported sensors) + try: + sharpness = prefs.get_int("sharpness") + cam.set_sharpness(sharpness) + except: + pass # Not supported on OV2640? + + try: + denoise = prefs.get_int("denoise") + cam.set_denoise(denoise) + except: + pass # Not supported on OV2640? + + # Advanced corrections + colorbar = prefs.get_bool("colorbar") + cam.set_colorbar(colorbar) + + dcw = prefs.get_bool("dcw") + cam.set_dcw(dcw) + + bpc = prefs.get_bool("bpc") + cam.set_bpc(bpc) + + wpc = prefs.get_bool("wpc") + cam.set_wpc(wpc) + + # Mode-specific default comes from constructor + raw_gma = prefs.get_bool("raw_gma") + print(f"applying raw_gma: {raw_gma}") + cam.set_raw_gma(raw_gma) + + lenc = prefs.get_bool("lenc") + cam.set_lenc(lenc) + + # JPEG quality (only relevant for JPEG format) + #try: + # quality = prefs.get_int("quality", 85) + # cam.set_quality(quality) + #except: + # pass # Not in JPEG mode + + print("Camera settings applied successfully") + + except Exception as e: + print(f"Error applying camera settings: {e}") + + + + +""" + def zoom_button_click_unused(self, e): + print("zooming...") + if self.use_webcam: + print("zoom_button_click is not supported for webcam") + return + if self.cam: + startX = self.prefs.get_int("startX", CameraSettingsActivity.startX_default) + startY = self.prefs.get_int("startX", CameraSettingsActivity.startY_default) + endX = self.prefs.get_int("startX", CameraSettingsActivity.endX_default) + endY = self.prefs.get_int("startX", CameraSettingsActivity.endY_default) + offsetX = self.prefs.get_int("startX", CameraSettingsActivity.offsetX_default) + offsetY = self.prefs.get_int("startX", CameraSettingsActivity.offsetY_default) + totalX = self.prefs.get_int("startX", CameraSettingsActivity.totalX_default) + totalY = self.prefs.get_int("startX", CameraSettingsActivity.totalY_default) + outputX = self.prefs.get_int("startX", CameraSettingsActivity.outputX_default) + outputY = self.prefs.get_int("startX", CameraSettingsActivity.outputY_default) + scale = self.prefs.get_bool("scale", CameraSettingsActivity.scale_default) + binning = self.prefs.get_bool("binning", CameraSettingsActivity.binning_default) + result = self.cam.set_res_raw(startX,startY,endX,endY,offsetX,offsetY,totalX,totalY,outputX,outputY,scale,binning) + print(f"self.cam.set_res_raw returned {result}") +""" diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py new file mode 100644 index 00000000..8bf90ecc --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py @@ -0,0 +1,604 @@ +import lvgl as lv + +import mpos.ui +from mpos.apps import Activity +from mpos.config import SharedPreferences +from mpos.content.intent import Intent + +class CameraSettingsActivity(Activity): + + # Original: { 2560, 1920, 0, 0, 2623, 1951, 32, 16, 2844, 1968 } + # Worked for digital zoom in C: { 2560, 1920, 0, 0, 2623, 1951, 992, 736, 2844, 1968 } + startX_default=0 + startY_default=0 + endX_default=2623 + endY_default=1951 + offsetX_default=32 + offsetY_default=16 + totalX_default=2844 + totalY_default=1968 + outputX_default=640 + outputY_default=480 + scale_default=False + binning_default=False + + # Common defaults shared by both normal and scanqr modes (25 settings) + COMMON_DEFAULTS = { + # Basic image adjustments + "brightness": 0, + "contrast": 0, + "saturation": 0, + # Orientation + "hmirror": False, + "vflip": True, + # Visual effects + "special_effect": 0, + # Exposure control + "exposure_ctrl": True, + "aec_value": 300, + "aec2": False, + # Gain control + "gain_ctrl": True, + "agc_gain": 0, + "gainceiling": 0, + # White balance + "whitebal": True, + "wb_mode": 0, + "awb_gain": True, + # Sensor-specific + "sharpness": 0, + "denoise": 0, + # Advanced corrections + "colorbar": False, + "dcw": True, + "bpc": False, + "wpc": True, + "lenc": True, + } + + # Normal mode specific defaults + NORMAL_DEFAULTS = { + "resolution_width": 240, + "resolution_height": 240, + "colormode": True, + "ae_level": 0, + "raw_gma": True, + } + + # Scanqr mode specific defaults + SCANQR_DEFAULTS = { + "resolution_width": 960, + "resolution_height": 960, + "colormode": False, + "ae_level": 2, # Higher auto-exposure compensation + "raw_gma": False, # Disable raw gamma for better contrast + } + + # Resolution options for both ESP32 and webcam + # Webcam supports all ESP32 resolutions via automatic cropping/padding + RESOLUTIONS = [ + ("96x96", "96x96"), + ("160x120", "160x120"), + ("128x128", "128x128"), + ("176x144", "176x144"), + ("240x176", "240x176"), + ("240x240", "240x240"), + ("320x240", "320x240"), + ("320x320", "320x320"), + ("400x296", "400x296"), + ("480x320", "480x320"), + ("480x480", "480x480"), + ("640x480", "640x480"), + ("640x640", "640x640"), + ("720x720", "720x720"), + ("800x600", "800x600"), + ("800x800", "800x800"), + ("960x960", "960x960"), + ("1024x768", "1024x768"), + ("1024x1024","1024x1024"), + ("1280x720", "1280x720"), + ("1280x1024", "1280x1024"), + ("1280x1280", "1280x1280"), + ("1600x1200", "1600x1200"), + ("1920x1080", "1920x1080"), + ] + + # These are taken from the Intent: + use_webcam = False + prefs = None + scanqr_mode = False + + # Widgets: + button_cont = None + + def __init__(self): + super().__init__() + self.ui_controls = {} + self.control_metadata = {} # Store pref_key and option_values for each control + self.dependent_controls = {} + + def onCreate(self): + self.use_webcam = self.getIntent().extras.get("use_webcam") + self.prefs = self.getIntent().extras.get("prefs") + self.scanqr_mode = self.getIntent().extras.get("scanqr_mode") + + # Create main screen + screen = lv.obj() + screen.set_size(lv.pct(100), lv.pct(100)) + screen.set_style_pad_all(1, 0) + + # Create tabview + tabview = lv.tabview(screen) + tabview.set_tab_bar_size(mpos.ui.pct_of_display_height(15)) + #tabview.set_size(lv.pct(100), mpos.ui.pct_of_display_height(80)) + + # Create Basic tab (always) + basic_tab = tabview.add_tab("Basic") + self.create_basic_tab(basic_tab, self.prefs) + + # Create Advanced and Expert tabs only for ESP32 camera + if not self.use_webcam or True: # for now, show all tabs + advanced_tab = tabview.add_tab("Advanced") + self.create_advanced_tab(advanced_tab, self.prefs) + + expert_tab = tabview.add_tab("Expert") + self.create_expert_tab(expert_tab, self.prefs) + + #raw_tab = tabview.add_tab("Raw") + #self.create_raw_tab(raw_tab, self.prefs) + + self.setContentView(screen) + + def create_slider(self, parent, label_text, min_val, max_val, default_val, pref_key): + """Create slider with label showing current value.""" + cont = lv.obj(parent) + cont.set_size(lv.pct(100), 60) + cont.set_style_pad_all(3, 0) + + label = lv.label(cont) + label.set_text(f"{label_text}: {default_val}") + label.align(lv.ALIGN.TOP_LEFT, 0, 0) + + slider = lv.slider(cont) + slider.set_size(lv.pct(90), 15) + slider.set_range(min_val, max_val) + slider.set_value(default_val, False) + slider.align(lv.ALIGN.BOTTOM_MID, 0, -10) + + def slider_changed(e): + val = slider.get_value() + label.set_text(f"{label_text}: {val}") + + slider.add_event_cb(slider_changed, lv.EVENT.VALUE_CHANGED, None) + + return slider, label, cont + + def create_checkbox(self, parent, label_text, default_val, pref_key): + """Create checkbox with label.""" + cont = lv.obj(parent) + cont.set_size(lv.pct(100), 35) + cont.set_style_pad_all(3, 0) + + checkbox = lv.checkbox(cont) + checkbox.set_text(label_text) + if default_val: + checkbox.add_state(lv.STATE.CHECKED) + checkbox.align(lv.ALIGN.LEFT_MID, 0, 0) + + return checkbox, cont + + def create_dropdown(self, parent, label_text, options, default_idx, pref_key): + """Create dropdown with label.""" + cont = lv.obj(parent) + cont.set_size(lv.pct(100), lv.SIZE_CONTENT) + cont.set_style_pad_all(2, 0) + + label = lv.label(cont) + label.set_text(label_text) + label.set_size(lv.pct(50), lv.SIZE_CONTENT) + label.align(lv.ALIGN.LEFT_MID, 0, 0) + + dropdown = lv.dropdown(cont) + dropdown.set_size(lv.pct(50), lv.SIZE_CONTENT) + dropdown.align(lv.ALIGN.RIGHT_MID, 0, 0) + + options_str = "\n".join([text for text, _ in options]) + dropdown.set_options(options_str) + dropdown.set_selected(default_idx) + + # Store metadata separately + option_values = [val for _, val in options] + self.control_metadata[id(dropdown)] = { + "pref_key": pref_key, + "type": "dropdown", + "option_values": option_values + } + + return dropdown, cont + + def create_textarea(self, parent, label_text, min_val, max_val, default_val, pref_key): + cont = lv.obj(parent) + cont.set_size(lv.pct(100), lv.SIZE_CONTENT) + cont.set_style_pad_all(3, 0) + + label = lv.label(cont) + label.set_text(f"{label_text}:") + label.align(lv.ALIGN.TOP_LEFT, 0, 0) + + textarea = lv.textarea(cont) + textarea.set_width(lv.pct(50)) + textarea.set_one_line(True) # might not be good for all settings but it's good for most + textarea.set_text(str(default_val)) + textarea.align(lv.ALIGN.TOP_RIGHT, 0, 0) + + # Initialize keyboard (hidden initially) + from mpos.ui.keyboard import MposKeyboard + keyboard = MposKeyboard(parent) + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + keyboard.add_flag(lv.obj.FLAG.HIDDEN) + keyboard.set_textarea(textarea) + + return textarea, cont + + def add_buttons(self, parent): + # Save/Cancel buttons at bottom + button_cont = lv.obj(parent) + button_cont.set_size(lv.pct(100), mpos.ui.pct_of_display_height(20)) + button_cont.remove_flag(lv.obj.FLAG.SCROLLABLE) + button_cont.align(lv.ALIGN.BOTTOM_MID, 0, 0) + button_cont.set_style_border_width(0, 0) + + save_button = lv.button(button_cont) + save_button.set_size(lv.SIZE_CONTENT, lv.SIZE_CONTENT) + save_button.align(lv.ALIGN.BOTTOM_LEFT, 0, 0) + save_button.add_event_cb(lambda e: self.save_and_close(), lv.EVENT.CLICKED, None) + save_label = lv.label(save_button) + savetext = "Save" + if self.scanqr_mode: + savetext += " QR tweaks" + save_label.set_text(savetext) + save_label.center() + + cancel_button = lv.button(button_cont) + cancel_button.set_size(mpos.ui.pct_of_display_width(25), lv.SIZE_CONTENT) + if self.scanqr_mode: + cancel_button.align(lv.ALIGN.BOTTOM_MID, mpos.ui.pct_of_display_width(10), 0) + else: + cancel_button.align(lv.ALIGN.BOTTOM_MID, 0, 0) + cancel_button.add_event_cb(lambda e: self.finish(), lv.EVENT.CLICKED, None) + cancel_label = lv.label(cancel_button) + cancel_label.set_text("Cancel") + cancel_label.center() + + erase_button = lv.button(button_cont) + erase_button.set_size(mpos.ui.pct_of_display_width(20), lv.SIZE_CONTENT) + erase_button.align(lv.ALIGN.BOTTOM_RIGHT, 0, 0) + erase_button.add_event_cb(lambda e: self.erase_and_close(), lv.EVENT.CLICKED, None) + erase_label = lv.label(erase_button) + erase_label.set_text("Erase") + erase_label.center() + + + def create_basic_tab(self, tab, prefs): + """Create Basic settings tab.""" + tab.set_flex_flow(lv.FLEX_FLOW.COLUMN) + #tab.set_scrollbar_mode(lv.SCROLLBAR_MODE.AUTO) + tab.set_style_pad_all(1, 0) + + # Color Mode + colormode = prefs.get_bool("colormode") + checkbox, cont = self.create_checkbox(tab, "Color Mode (slower)", colormode, "colormode") + self.ui_controls["colormode"] = checkbox + + # Resolution dropdown + print(f"self.scanqr_mode: {self.scanqr_mode}") + current_resolution_width = prefs.get_int("resolution_width") + current_resolution_height = prefs.get_int("resolution_height") + dropdown_value = f"{current_resolution_width}x{current_resolution_height}" + print(f"looking for {dropdown_value}") + resolution_idx = 0 + for idx, (_, value) in enumerate(self.RESOLUTIONS): + print(f"got {value}") + if value == dropdown_value: + resolution_idx = idx + print(f"found it! {idx}") + break + + dropdown, cont = self.create_dropdown(tab, "Resolution:", self.RESOLUTIONS, resolution_idx, "resolution") + self.ui_controls["resolution"] = dropdown + + # Brightness + brightness = prefs.get_int("brightness") + slider, label, cont = self.create_slider(tab, "Brightness", -2, 2, brightness, "brightness") + self.ui_controls["brightness"] = slider + + # Contrast + contrast = prefs.get_int("contrast") + slider, label, cont = self.create_slider(tab, "Contrast", -2, 2, contrast, "contrast") + self.ui_controls["contrast"] = slider + + # Saturation + saturation = prefs.get_int("saturation") + slider, label, cont = self.create_slider(tab, "Saturation", -2, 2, saturation, "saturation") + self.ui_controls["saturation"] = slider + + # Horizontal Mirror + hmirror = prefs.get_bool("hmirror") + checkbox, cont = self.create_checkbox(tab, "Horizontal Mirror", hmirror, "hmirror") + self.ui_controls["hmirror"] = checkbox + + # Vertical Flip + vflip = prefs.get_bool("vflip") + checkbox, cont = self.create_checkbox(tab, "Vertical Flip", vflip, "vflip") + self.ui_controls["vflip"] = checkbox + + self.add_buttons(tab) + + def create_advanced_tab(self, tab, prefs): + """Create Advanced settings tab.""" + tab.set_flex_flow(lv.FLEX_FLOW.COLUMN) + tab.set_style_pad_all(1, 0) + + # Auto Exposure Control (master switch) + exposure_ctrl = prefs.get_bool("exposure_ctrl") + aec_checkbox, cont = self.create_checkbox(tab, "Auto Exposure", exposure_ctrl, "exposure_ctrl") + self.ui_controls["exposure_ctrl"] = aec_checkbox + + # Manual Exposure Value (dependent) + aec_value = prefs.get_int("aec_value") + me_slider, label, me_cont = self.create_slider(tab, "Manual Exposure", 0, 1200, aec_value, "aec_value") + self.ui_controls["aec_value"] = me_slider + + # Auto Exposure Level (dependent) + ae_level = prefs.get_int("ae_level") + ae_slider, label, ae_cont = self.create_slider(tab, "Auto Exposure Level", -2, 2, ae_level, "ae_level") + self.ui_controls["ae_level"] = ae_slider + + # Add dependency handler + def exposure_ctrl_changed(e=None): + is_auto = aec_checkbox.get_state() & lv.STATE.CHECKED + if is_auto: + mpos.ui.anim.smooth_hide(me_cont, duration=1000) + mpos.ui.anim.smooth_show(ae_cont, delay=1000) + else: + mpos.ui.anim.smooth_hide(ae_cont, duration=1000) + mpos.ui.anim.smooth_show(me_cont, delay=1000) + + aec_checkbox.add_event_cb(exposure_ctrl_changed, lv.EVENT.VALUE_CHANGED, None) + exposure_ctrl_changed() + + # Night Mode (AEC2) + aec2 = prefs.get_bool("aec2") + checkbox, cont = self.create_checkbox(tab, "Night Mode (AEC2)", aec2, "aec2") + self.ui_controls["aec2"] = checkbox + + # Auto Gain Control (master switch) + gain_ctrl = prefs.get_bool("gain_ctrl") + agc_checkbox, cont = self.create_checkbox(tab, "Auto Gain", gain_ctrl, "gain_ctrl") + self.ui_controls["gain_ctrl"] = agc_checkbox + + # Manual Gain Value (dependent) + agc_gain = prefs.get_int("agc_gain") + slider, label, agc_cont = self.create_slider(tab, "Manual Gain", 0, 30, agc_gain, "agc_gain") + self.ui_controls["agc_gain"] = slider + + def gain_ctrl_changed(e=None): + is_auto = agc_checkbox.get_state() & lv.STATE.CHECKED + gain_slider = self.ui_controls["agc_gain"] + if is_auto: + mpos.ui.anim.smooth_hide(agc_cont, duration=1000) + else: + mpos.ui.anim.smooth_show(agc_cont, duration=1000) + + agc_checkbox.add_event_cb(gain_ctrl_changed, lv.EVENT.VALUE_CHANGED, None) + gain_ctrl_changed() + + # Gain Ceiling + gainceiling_options = [ + ("2X", 0), ("4X", 1), ("8X", 2), ("16X", 3), + ("32X", 4), ("64X", 5), ("128X", 6) + ] + gainceiling = prefs.get_int("gainceiling") + dropdown, cont = self.create_dropdown(tab, "Gain Ceiling:", gainceiling_options, gainceiling, "gainceiling") + self.ui_controls["gainceiling"] = dropdown + + # Auto White Balance (master switch) + whitebal = prefs.get_bool("whitebal") + wbcheckbox, cont = self.create_checkbox(tab, "Auto White Balance", whitebal, "whitebal") + self.ui_controls["whitebal"] = wbcheckbox + + # White Balance Mode (dependent) + wb_mode_options = [ + ("Auto", 0), ("Sunny", 1), ("Cloudy", 2), ("Office", 3), ("Home", 4) + ] + wb_mode = prefs.get_int("wb_mode") + wb_dropdown, wb_cont = self.create_dropdown(tab, "WB Mode:", wb_mode_options, wb_mode, "wb_mode") + self.ui_controls["wb_mode"] = wb_dropdown + + def whitebal_changed(e=None): + is_auto = wbcheckbox.get_state() & lv.STATE.CHECKED + if is_auto: + mpos.ui.anim.smooth_hide(wb_cont, duration=1000) + else: + mpos.ui.anim.smooth_show(wb_cont, duration=1000) + wbcheckbox.add_event_cb(whitebal_changed, lv.EVENT.VALUE_CHANGED, None) + whitebal_changed() + + # AWB Gain + awb_gain = prefs.get_bool("awb_gain") + checkbox, cont = self.create_checkbox(tab, "AWB Gain", awb_gain, "awb_gain") + self.ui_controls["awb_gain"] = checkbox + + self.add_buttons(tab) + + # Special Effect + special_effect_options = [ + ("None", 0), ("Negative", 1), ("Grayscale", 2), + ("Reddish", 3), ("Greenish", 4), ("Blue", 5), ("Retro", 6) + ] + special_effect = prefs.get_int("special_effect") + dropdown, cont = self.create_dropdown(tab, "Special Effect:", special_effect_options, + special_effect, "special_effect") + self.ui_controls["special_effect"] = dropdown + + def create_expert_tab(self, tab, prefs): + """Create Expert settings tab.""" + #tab.set_scrollbar_mode(lv.SCROLLBAR_MODE.AUTO) + tab.set_flex_flow(lv.FLEX_FLOW.COLUMN) + tab.set_style_pad_all(1, 0) + + # Sharpness + sharpness = prefs.get_int("sharpness") + slider, label, cont = self.create_slider(tab, "Sharpness", -3, 3, sharpness, "sharpness") + self.ui_controls["sharpness"] = slider + + # Denoise + denoise = prefs.get_int("denoise") + slider, label, cont = self.create_slider(tab, "Denoise", 0, 8, denoise, "denoise") + self.ui_controls["denoise"] = slider + + # JPEG Quality + # Disabled because JPEG is not used right now + #quality = prefs.get_int("quality", 85) + #slider, label, cont = self.create_slider(tab, "JPEG Quality", 0, 100, quality, "quality") + #self.ui_controls["quality"] = slider + + # Color Bar + colorbar = prefs.get_bool("colorbar") + checkbox, cont = self.create_checkbox(tab, "Color Bar Test", colorbar, "colorbar") + self.ui_controls["colorbar"] = checkbox + + # DCW Mode + dcw = prefs.get_bool("dcw") + checkbox, cont = self.create_checkbox(tab, "Downsize Crop Window", dcw, "dcw") + self.ui_controls["dcw"] = checkbox + + # Black Point Compensation + bpc = prefs.get_bool("bpc") + checkbox, cont = self.create_checkbox(tab, "Black Point Compensation", bpc, "bpc") + self.ui_controls["bpc"] = checkbox + + # White Point Compensation + wpc = prefs.get_bool("wpc") + checkbox, cont = self.create_checkbox(tab, "White Point Compensation", wpc, "wpc") + self.ui_controls["wpc"] = checkbox + + # Raw Gamma Mode + raw_gma = prefs.get_bool("raw_gma") + checkbox, cont = self.create_checkbox(tab, "Raw Gamma Mode", raw_gma, "raw_gma") + self.ui_controls["raw_gma"] = checkbox + + # Lens Correction + lenc = prefs.get_bool("lenc") + checkbox, cont = self.create_checkbox(tab, "Lens Correction", lenc, "lenc") + self.ui_controls["lenc"] = checkbox + + self.add_buttons(tab) + + def create_raw_tab(self, tab, prefs): + tab.set_flex_flow(lv.FLEX_FLOW.COLUMN) + tab.set_style_pad_all(0, 0) + + # This would be nice but does not provide adequate resolution: + #startX, label, cont = self.create_slider(tab, "startX", 0, 2844, startX, "startX") + + startX = prefs.get_int("startX", self.startX_default) + textarea, cont = self.create_textarea(tab, "startX", 0, 2844, startX, "startX") + self.ui_controls["startX"] = textarea + + startY = prefs.get_int("startY", self.startY_default) + textarea, cont = self.create_textarea(tab, "startY", 0, 2844, startY, "startY") + self.ui_controls["startY"] = textarea + + endX = prefs.get_int("endX", self.endX_default) + textarea, cont = self.create_textarea(tab, "endX", 0, 2844, endX, "endX") + self.ui_controls["endX"] = textarea + + endY = prefs.get_int("endY", self.endY_default) + textarea, cont = self.create_textarea(tab, "endY", 0, 2844, endY, "endY") + self.ui_controls["endY"] = textarea + + offsetX = prefs.get_int("offsetX", self.offsetX_default) + textarea, cont = self.create_textarea(tab, "offsetX", 0, 2844, offsetX, "offsetX") + self.ui_controls["offsetX"] = textarea + + offsetY = prefs.get_int("offsetY", self.offsetY_default) + textarea, cont = self.create_textarea(tab, "offsetY", 0, 2844, offsetY, "offsetY") + self.ui_controls["offsetY"] = textarea + + totalX = prefs.get_int("totalX", self.totalX_default) + textarea, cont = self.create_textarea(tab, "totalX", 0, 2844, totalX, "totalX") + self.ui_controls["totalX"] = textarea + + totalY = prefs.get_int("totalY", self.totalY_default) + textarea, cont = self.create_textarea(tab, "totalY", 0, 2844, totalY, "totalY") + self.ui_controls["totalY"] = textarea + + outputX = prefs.get_int("outputX", self.outputX_default) + textarea, cont = self.create_textarea(tab, "outputX", 0, 2844, outputX, "outputX") + self.ui_controls["outputX"] = textarea + + outputY = prefs.get_int("outputY", self.outputY_default) + textarea, cont = self.create_textarea(tab, "outputY", 0, 2844, outputY, "outputY") + self.ui_controls["outputY"] = textarea + + scale = prefs.get_bool("scale", self.scale_default) + checkbox, cont = self.create_checkbox(tab, "Scale?", scale, "scale") + self.ui_controls["scale"] = checkbox + + binning = prefs.get_bool("binning", self.binning_default) + checkbox, cont = self.create_checkbox(tab, "Binning?", binning, "binning") + self.ui_controls["binning"] = checkbox + + self.add_buttons(tab) + + def erase_and_close(self): + self.prefs.edit().remove_all().commit() + self.setResult(True, {"settings_changed": True}) + self.finish() + + def save_and_close(self): + """Save all settings to SharedPreferences and return result.""" + editor = self.prefs.edit() + + # Save all UI control values + for pref_key, control in self.ui_controls.items(): + print(f"saving {pref_key} with {control}") + control_id = id(control) + metadata = self.control_metadata.get(control_id, {}) + + if isinstance(control, lv.slider): + value = control.get_value() + editor.put_int(pref_key, value) + elif isinstance(control, lv.checkbox): + is_checked = control.get_state() & lv.STATE.CHECKED + editor.put_bool(pref_key, bool(is_checked)) + elif isinstance(control, lv.textarea): + try: + value = int(control.get_text()) + editor.put_int(pref_key, value) + except Exception as e: + print(f"Error while trying to save {pref_key}: {e}") + elif isinstance(control, lv.dropdown): + selected_idx = control.get_selected() + option_values = metadata.get("option_values", []) + if pref_key == "resolution": + try: + # Resolution stored as 2 ints + value = option_values[selected_idx] + width_str, height_str = value.split('x') + editor.put_int("resolution_width", int(width_str)) + editor.put_int("resolution_height", int(height_str)) + except Exception as e: + print(f"Error parsing resolution '{value}': {e}") + else: + # Other dropdowns store integer enum values + value = option_values[selected_idx] + editor.put_int(pref_key, value) + + editor.commit() + print("Camera settings saved") + + # Return success result + self.setResult(True, {"settings_changed": True}) + self.finish() diff --git a/internal_filesystem/apps/com.micropythonos.imageview/META-INF/MANIFEST.JSON b/internal_filesystem/apps/com.micropythonos.imageview/META-INF/MANIFEST.JSON index a0a333f7..0ed67dcb 100644 --- a/internal_filesystem/apps/com.micropythonos.imageview/META-INF/MANIFEST.JSON +++ b/internal_filesystem/apps/com.micropythonos.imageview/META-INF/MANIFEST.JSON @@ -3,10 +3,10 @@ "publisher": "MicroPythonOS", "short_description": "Image Viewer", "long_description": "Opens and shows images on the display.", -"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.imageview/icons/com.micropythonos.imageview_0.0.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.0.5_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.imageview/mpks/com.micropythonos.imageview_0.0.5.mpk", "fullname": "com.micropythonos.imageview", -"version": "0.0.4", +"version": "0.0.5", "category": "graphics", "activities": [ { diff --git a/internal_filesystem/apps/com.micropythonos.imageview/assets/imageview.py b/internal_filesystem/apps/com.micropythonos.imageview/assets/imageview.py index 072160e0..4433b503 100644 --- a/internal_filesystem/apps/com.micropythonos.imageview/assets/imageview.py +++ b/internal_filesystem/apps/com.micropythonos.imageview/assets/imageview.py @@ -21,6 +21,7 @@ class ImageView(Activity): def onCreate(self): screen = lv.obj() + screen.remove_flag(lv.obj.FLAG.SCROLLABLE) self.image = lv.image(screen) self.image.center() self.image.add_flag(lv.obj.FLAG.CLICKABLE) @@ -33,12 +34,14 @@ def onCreate(self): self.label = lv.label(screen) self.label.set_text(f"Loading images from\n{self.imagedir}") self.label.align(lv.ALIGN.TOP_MID,0,0) + self.label.set_width(lv.pct(80)) self.prev_button = lv.button(screen) self.prev_button.align(lv.ALIGN.BOTTOM_LEFT,0,0) self.prev_button.add_event_cb(lambda e: self.show_prev_image_if_fullscreen(),lv.EVENT.FOCUSED,None) self.prev_button.add_event_cb(lambda e: self.show_prev_image(),lv.EVENT.CLICKED,None) prev_label = lv.label(self.prev_button) prev_label.set_text(lv.SYMBOL.LEFT) + prev_label.set_style_text_font(lv.font_montserrat_16, 0) self.play_button = lv.button(screen) self.play_button.align(lv.ALIGN.BOTTOM_MID,0,0) self.play_button.set_style_opa(lv.OPA.TRANSP, 0) @@ -48,6 +51,12 @@ def onCreate(self): #self.play_button.add_event_cb(lambda e: self.play(),lv.EVENT.CLICKED,None) #play_label = lv.label(self.play_button) #play_label.set_text(lv.SYMBOL.PLAY) + self.delete_button = lv.button(screen) + self.delete_button.align(lv.ALIGN.BOTTOM_MID,0,0) + self.delete_button.add_event_cb(lambda e: self.delete_image(),lv.EVENT.CLICKED,None) + delete_label = lv.label(self.delete_button) + delete_label.set_text(lv.SYMBOL.TRASH) + delete_label.set_style_text_font(lv.font_montserrat_16, 0) self.next_button = lv.button(screen) self.next_button.align(lv.ALIGN.BOTTOM_RIGHT,0,0) #self.next_button.add_event_cb(self.print_events, lv.EVENT.ALL, None) @@ -55,6 +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) #screen.add_event_cb(self.print_events, lv.EVENT.ALL, None) self.setContentView(screen) @@ -76,10 +86,12 @@ def onResume(self, screen): self.images.append(fullname) self.images.sort() - # Begin with one image: - self.show_next_image() - self.stop_fullscreen() - #self.image_timer = lv.timer_create(self.show_next_image, 1000, None) + if len(self.images) == 0: + self.no_image_mode() + else: + # Begin with one image: + self.show_next_image() + self.stop_fullscreen() except Exception as e: print(f"ImageView encountered exception for {self.imagedir}: {e}") @@ -90,9 +102,16 @@ def onStop(self, screen): print("ImageView: deleting image_timer") self.image_timer.delete() + def no_image_mode(self): + self.label.set_text(f"No images found in {self.imagedir}...") + mpos.ui.anim.smooth_hide(self.prev_button) + mpos.ui.anim.smooth_hide(self.delete_button) + mpos.ui.anim.smooth_hide(self.next_button) + def show_prev_image(self, event=None): print("showing previous image...") if len(self.images) < 1: + self.no_image_mode() return if self.image_nr is None or self.image_nr == 0: self.image_nr = len(self.images) - 1 @@ -116,6 +135,7 @@ def stop_fullscreen(self): print("stopping fullscreen") mpos.ui.anim.smooth_show(self.label) mpos.ui.anim.smooth_show(self.prev_button) + mpos.ui.anim.smooth_show(self.delete_button) #mpos.ui.anim.smooth_show(self.play_button) self.play_button.add_flag(lv.obj.FLAG.HIDDEN) # make it not accepting focus mpos.ui.anim.smooth_show(self.next_button) @@ -124,6 +144,7 @@ def start_fullscreen(self): print("starting fullscreen") mpos.ui.anim.smooth_hide(self.label) mpos.ui.anim.smooth_hide(self.prev_button, hide=False) + mpos.ui.anim.smooth_hide(self.delete_button, hide=False) #mpos.ui.anim.smooth_hide(self.play_button, hide=False) self.play_button.remove_flag(lv.obj.FLAG.HIDDEN) # make it accepting focus mpos.ui.anim.smooth_hide(self.next_button, hide=False) @@ -167,6 +188,7 @@ def unfocus(self): def show_next_image(self, event=None): print("showing next image...") if len(self.images) < 1: + self.no_image_mode() return if self.image_nr is None or self.image_nr >= len(self.images) - 1: self.image_nr = 0 @@ -176,6 +198,16 @@ def show_next_image(self, event=None): print(f"show_next_image showing {name}") self.show_image(name) + def delete_image(self, event=None): + filename = self.images[self.image_nr] + try: + os.remove(filename) + self.clear_image() + self.label.set_text(f"Deleted\n{filename}") + del self.images[self.image_nr] + except Exception as e: + print(f"Error deleting {filename}: {e}") + def extract_dimensions_and_format(self, filename): # Split the filename by '_' parts = filename.split('_') @@ -188,6 +220,7 @@ def extract_dimensions_and_format(self, filename): return width, height, color_format.upper() def show_image(self, name): + self.current_image = name try: self.label.set_text(name) self.clear_image() @@ -214,7 +247,10 @@ def show_image(self, name): print(f"Raw image has width: {width}, Height: {height}, Color Format: {color_format}") stride = width * 2 cf = lv.COLOR_FORMAT.RGB565 - if color_format != "RGB565": + if color_format == "GRAY": + cf = lv.COLOR_FORMAT.L8 + stride = width + elif color_format != "RGB565": print(f"WARNING: unknown color format {color_format}, assuming RGB565...") self.current_image_dsc = lv.image_dsc_t({ "header": { @@ -236,7 +272,7 @@ def scale_image(self): if self.fullscreen: pct = 100 else: - pct = 90 + pct = 70 lvgl_w = mpos.ui.pct_of_display_width(pct) lvgl_h = mpos.ui.pct_of_display_height(pct) print(f"scaling to size: {lvgl_w}x{lvgl_h}") @@ -259,6 +295,7 @@ def scale_image(self): def clear_image(self): """Clear current image or GIF source to free memory.""" + self.image.set_src(None) #if self.current_image_dsc: # self.current_image_dsc = None # Release reference to descriptor #self.image.set_src(None) # Clear image source diff --git a/internal_filesystem/apps/com.micropythonos.imu/META-INF/MANIFEST.JSON b/internal_filesystem/apps/com.micropythonos.imu/META-INF/MANIFEST.JSON index 21563c5e..2c4601e9 100644 --- a/internal_filesystem/apps/com.micropythonos.imu/META-INF/MANIFEST.JSON +++ b/internal_filesystem/apps/com.micropythonos.imu/META-INF/MANIFEST.JSON @@ -3,10 +3,10 @@ "publisher": "MicroPythonOS", "short_description": "Inertial Measurement Unit Visualization", "long_description": "Visualize data from the Intertial Measurement Unit, also known as the accellerometer.", -"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.imu/icons/com.micropythonos.imu_0.0.2_64x64.png", -"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.imu/mpks/com.micropythonos.imu_0.0.2.mpk", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.imu/icons/com.micropythonos.imu_0.0.3_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.imu/mpks/com.micropythonos.imu_0.0.3.mpk", "fullname": "com.micropythonos.imu", -"version": "0.0.2", +"version": "0.0.3", "category": "hardware", "activities": [ { diff --git a/internal_filesystem/apps/com.micropythonos.imu/assets/imu.py b/internal_filesystem/apps/com.micropythonos.imu/assets/imu.py index 569c47e1..4cf3cb51 100644 --- a/internal_filesystem/apps/com.micropythonos.imu/assets/imu.py +++ b/internal_filesystem/apps/com.micropythonos.imu/assets/imu.py @@ -1,8 +1,11 @@ from mpos.apps import Activity +import mpos.sensor_manager as SensorManager class IMU(Activity): - sensor = None + accel_sensor = None + gyro_sensor = None + temp_sensor = None refresh_timer = None # widgets: @@ -30,12 +33,16 @@ def onCreate(self): self.slidergz = lv.slider(screen) self.slidergz.align(lv.ALIGN.CENTER, 0, 90) try: - from machine import Pin, I2C - from qmi8658 import QMI8658 - import machine - self.sensor = QMI8658(I2C(0, sda=machine.Pin(48), scl=machine.Pin(47))) - print("IMU sensor initialized") - #print(f"{self.sensor.temperature=} {self.sensor.acceleration=} {self.sensor.gyro=}") + if SensorManager.is_available(): + self.accel_sensor = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) + self.gyro_sensor = SensorManager.get_default_sensor(SensorManager.TYPE_GYROSCOPE) + # Get IMU temperature (not MCU temperature) + self.temp_sensor = SensorManager.get_default_sensor(SensorManager.TYPE_IMU_TEMPERATURE) + print("IMU sensors initialized via SensorManager") + print(f"Available sensors: {SensorManager.get_sensor_list()}") + else: + print("Warning: No IMU sensors available") + self.templabel.set_text("No IMU sensors available") except Exception as e: warning = f"Warning: could not initialize IMU hardware:\n{e}" print(warning) @@ -68,22 +75,45 @@ def convert_percentage(self, value: float) -> int: def refresh(self, timer): #print("refresh timer") - if self.sensor: - #print(f"{self.sensor.temperature=} {self.sensor.acceleration=} {self.sensor.gyro=}") - temp = self.sensor.temperature - ax = self.sensor.acceleration[0] - axp = int((ax * 100 + 100)/2) - ay = self.sensor.acceleration[1] - ayp = int((ay * 100 + 100)/2) - az = self.sensor.acceleration[2] - azp = int((az * 100 + 100)/2) - # values between -200 and 200 => /4 becomes -50 and 50 => +50 becomes 0 and 100 - gx = self.convert_percentage(self.sensor.gyro[0]) - gy = self.convert_percentage(self.sensor.gyro[1]) - gz = self.convert_percentage(self.sensor.gyro[2]) - self.templabel.set_text(f"IMU chip temperature: {temp:.2f}°C") + if self.accel_sensor and self.gyro_sensor: + # Read sensor data via SensorManager (returns m/s² for accel, deg/s for gyro) + accel = SensorManager.read_sensor(self.accel_sensor) + gyro = SensorManager.read_sensor(self.gyro_sensor) + temp = SensorManager.read_sensor(self.temp_sensor) if self.temp_sensor else None + + if accel and gyro: + # Convert m/s² to G for display (divide by 9.80665) + # Range: ±8G → ±1G = ±10% of range → map to 0-100 + ax, ay, az = accel + ax_g = ax / 9.80665 # Convert m/s² to G + ay_g = ay / 9.80665 + az_g = az / 9.80665 + axp = int((ax_g * 100 + 100)/2) # Map ±1G to 0-100 + ayp = int((ay_g * 100 + 100)/2) + azp = int((az_g * 100 + 100)/2) + + # Gyro already in deg/s, map ±200 DPS to 0-100 + gx, gy, gz = gyro + gx = self.convert_percentage(gx) + gy = self.convert_percentage(gy) + gz = self.convert_percentage(gz) + + if temp is not None: + self.templabel.set_text(f"IMU chip temperature: {temp:.2f}°C") + else: + self.templabel.set_text("IMU active (no temperature sensor)") + else: + # Sensor read failed, show random data + import random + randomnr = random.randint(0,100) + axp = randomnr + ayp = 50 + azp = 75 + gx = 45 + gy = 50 + gz = 55 else: - #temp = 12.34 + # No sensors available, show random data import random randomnr = random.randint(0,100) axp = randomnr @@ -92,6 +122,7 @@ def refresh(self, timer): gx = 45 gy = 50 gz = 55 + self.sliderx.set_value(axp, False) self.slidery.set_value(ayp, False) self.sliderz.set_value(azp, False) diff --git a/internal_filesystem/apps/com.micropythonos.musicplayer/META-INF/MANIFEST.JSON b/internal_filesystem/apps/com.micropythonos.musicplayer/META-INF/MANIFEST.JSON index e7bf0e1e..b1d428fc 100644 --- a/internal_filesystem/apps/com.micropythonos.musicplayer/META-INF/MANIFEST.JSON +++ b/internal_filesystem/apps/com.micropythonos.musicplayer/META-INF/MANIFEST.JSON @@ -3,10 +3,10 @@ "publisher": "MicroPythonOS", "short_description": "Player audio files", "long_description": "Traverse around the filesystem and play audio files that you select.", -"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.musicplayer/icons/com.micropythonos.musicplayer_0.0.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.0.5_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.musicplayer/mpks/com.micropythonos.musicplayer_0.0.5.mpk", "fullname": "com.micropythonos.musicplayer", -"version": "0.0.4", +"version": "0.0.5", "category": "development", "activities": [ { diff --git a/internal_filesystem/apps/com.micropythonos.musicplayer/assets/audio_player.py b/internal_filesystem/apps/com.micropythonos.musicplayer/assets/audio_player.py deleted file mode 100644 index 0b298735..00000000 --- a/internal_filesystem/apps/com.micropythonos.musicplayer/assets/audio_player.py +++ /dev/null @@ -1,280 +0,0 @@ -import machine -import os -import time -import micropython - - -# ---------------------------------------------------------------------- -# AudioPlayer – robust, volume-controllable WAV player -# Supports 8 / 16 / 24 / 32-bit PCM, mono + stereo -# Auto-up-samples any rate < 22050 Hz to >=22050 Hz -# ---------------------------------------------------------------------- -class AudioPlayer: - _i2s = None - _volume = 50 # 0-100 - _keep_running = True - - # ------------------------------------------------------------------ - # WAV header parser – returns bit-depth - # ------------------------------------------------------------------ - @staticmethod - def find_data_chunk(f): - """Return (data_start, data_size, sample_rate, channels, bits_per_sample)""" - f.seek(0) - if f.read(4) != b'RIFF': - raise ValueError("Not a RIFF (standard .wav) file") - file_size = int.from_bytes(f.read(4), 'little') + 8 - if f.read(4) != b'WAVE': - raise ValueError("Not a WAVE (standard .wav) file") - - pos = 12 - sample_rate = None - channels = None - bits_per_sample = None - while pos < file_size: - f.seek(pos) - chunk_id = f.read(4) - if len(chunk_id) < 4: - break - chunk_size = int.from_bytes(f.read(4), 'little') - if chunk_id == b'fmt ': - fmt = f.read(chunk_size) - if len(fmt) < 16: - raise ValueError("Invalid fmt chunk") - if int.from_bytes(fmt[0:2], 'little') != 1: - raise ValueError("Only PCM supported") - channels = int.from_bytes(fmt[2:4], 'little') - if channels not in (1, 2): - raise ValueError("Only mono or stereo supported") - sample_rate = int.from_bytes(fmt[4:8], 'little') - bits_per_sample = int.from_bytes(fmt[14:16], 'little') - if bits_per_sample not in (8, 16, 24, 32): - raise ValueError("Only 8/16/24/32-bit PCM supported") - elif chunk_id == b'data': - return f.tell(), chunk_size, sample_rate, channels, bits_per_sample - pos += 8 + chunk_size - if chunk_size % 2: - pos += 1 - raise ValueError("No 'data' chunk found") - - # ------------------------------------------------------------------ - # Volume control - # ------------------------------------------------------------------ - @classmethod - def set_volume(cls, volume: int): - volume = max(0, min(100, volume)) - cls._volume = volume - - @classmethod - def get_volume(cls) -> int: - return cls._volume - - @classmethod - def stop_playing(cls): - print("stop_playing()") - cls._keep_running = False - - # ------------------------------------------------------------------ - # 1. Up-sample 16-bit buffer (zero-order-hold) - # ------------------------------------------------------------------ - @staticmethod - def _upsample_buffer(raw: bytearray, factor: int) -> bytearray: - if factor == 1: - return raw - upsampled = bytearray(len(raw) * factor) - out_idx = 0 - for i in range(0, len(raw), 2): - lo = raw[i] - hi = raw[i + 1] - for _ in range(factor): - upsampled[out_idx] = lo - upsampled[out_idx + 1] = hi - out_idx += 2 - return upsampled - - # ------------------------------------------------------------------ - # 2. Convert 8-bit to 16-bit (non-viper, Viper-safe) - # ------------------------------------------------------------------ - @staticmethod - def _convert_8_to_16(buf: bytearray) -> bytearray: - out = bytearray(len(buf) * 2) - j = 0 - for i in range(len(buf)): - u8 = buf[i] - s16 = (u8 - 128) << 8 - out[j] = s16 & 0xFF - out[j + 1] = (s16 >> 8) & 0xFF - j += 2 - return out - - # ------------------------------------------------------------------ - # 3. Convert 24-bit to 16-bit (non-viper) - # ------------------------------------------------------------------ - @staticmethod - def _convert_24_to_16(buf: bytearray) -> bytearray: - samples = len(buf) // 3 - out = bytearray(samples * 2) - j = 0 - for i in range(samples): - b0 = buf[j] - b1 = buf[j + 1] - b2 = buf[j + 2] - s24 = (b2 << 16) | (b1 << 8) | b0 - if b2 & 0x80: - s24 -= 0x1000000 - s16 = s24 >> 8 - out[i * 2] = s16 & 0xFF - out[i * 2 + 1] = (s16 >> 8) & 0xFF - j += 3 - return out - - # ------------------------------------------------------------------ - # 4. Convert 32-bit to 16-bit (non-viper) - # ------------------------------------------------------------------ - @staticmethod - def _convert_32_to_16(buf: bytearray) -> bytearray: - samples = len(buf) // 4 - out = bytearray(samples * 2) - j = 0 - for i in range(samples): - b0 = buf[j] - b1 = buf[j + 1] - b2 = buf[j + 2] - b3 = buf[j + 3] - s32 = (b3 << 24) | (b2 << 16) | (b1 << 8) | b0 - if b3 & 0x80: - s32 -= 0x100000000 - s16 = s32 >> 16 - out[i * 2] = s16 & 0xFF - out[i * 2 + 1] = (s16 >> 8) & 0xFF - j += 4 - return out - - # ------------------------------------------------------------------ - # Main playback routine - # ------------------------------------------------------------------ - @classmethod - def play_wav(cls, filename, result_callback=None): - cls._keep_running = True - try: - with open(filename, 'rb') as f: - st = os.stat(filename) - file_size = st[6] - print(f"File size: {file_size} bytes") - - # ----- parse header ------------------------------------------------ - data_start, data_size, original_rate, channels, bits_per_sample = \ - cls.find_data_chunk(f) - - # ----- decide playback rate (force >=22050 Hz) -------------------- - target_rate = 22050 - if original_rate >= target_rate: - playback_rate = original_rate - upsample_factor = 1 - else: - upsample_factor = (target_rate + original_rate - 1) // original_rate - playback_rate = original_rate * upsample_factor - - print(f"Original: {original_rate} Hz, {bits_per_sample}-bit, {channels}-ch " - f"to Playback: {playback_rate} Hz (factor {upsample_factor})") - - if data_size > file_size - data_start: - data_size = file_size - data_start - - # ----- I2S init (always 16-bit) ---------------------------------- - try: - i2s_format = machine.I2S.MONO if channels == 1 else machine.I2S.STEREO - cls._i2s = machine.I2S( - 0, - sck=machine.Pin(2, machine.Pin.OUT), - ws =machine.Pin(47, machine.Pin.OUT), - sd =machine.Pin(16, machine.Pin.OUT), - mode=machine.I2S.TX, - bits=16, - format=i2s_format, - rate=playback_rate, - ibuf=32000 - ) - except Exception as e: - print(f"Warning: simulating playback (I2S init failed): {e}") - - print(f"Playing {data_size} original bytes (vol {cls._volume}%) ...") - f.seek(data_start) - - # ----- Viper volume scaler (16-bit only) ------------------------- - @micropython.viper # throws "invalid micropython decorator" on macOS / darwin - def scale_audio(buf: ptr8, num_bytes: int, scale_fixed: int): - for i in range(0, num_bytes, 2): - lo = int(buf[i]) - hi = int(buf[i+1]) - sample = (hi << 8) | lo - if hi & 128: - sample -= 65536 - sample = (sample * scale_fixed) // 32768 - if sample > 32767: - sample = 32767 - elif sample < -32768: - sample = -32768 - buf[i] = sample & 255 - buf[i+1] = (sample >> 8) & 255 - - chunk_size = 4096 - bytes_per_original_sample = (bits_per_sample // 8) * channels - total_original = 0 - - while total_original < data_size: - if not cls._keep_running: - print("Playback stopped by user.") - break - - # ---- read a whole-sample chunk of original data ------------- - to_read = min(chunk_size, data_size - total_original) - to_read -= (to_read % bytes_per_original_sample) - if to_read <= 0: - break - - raw = bytearray(f.read(to_read)) - if not raw: - break - - # ---- 1. Convert bit-depth to 16-bit (non-viper) ------------- - if bits_per_sample == 8: - raw = cls._convert_8_to_16(raw) - elif bits_per_sample == 24: - raw = cls._convert_24_to_16(raw) - elif bits_per_sample == 32: - raw = cls._convert_32_to_16(raw) - # 16-bit to unchanged - - # ---- 2. Up-sample if needed --------------------------------- - if upsample_factor > 1: - raw = cls._upsample_buffer(raw, upsample_factor) - - # ---- 3. Volume scaling -------------------------------------- - scale = cls._volume / 100.0 - if scale < 1.0: - scale_fixed = int(scale * 32768) - scale_audio(raw, len(raw), scale_fixed) - - # ---- 4. Output --------------------------------------------- - if cls._i2s: - cls._i2s.write(raw) - else: - num_samples = len(raw) // (2 * channels) - time.sleep(num_samples / playback_rate) - - total_original += to_read - - print(f"Finished playing {filename}") - if result_callback: - result_callback(f"Finished playing {filename}") - except Exception as e: - print(f"Error: {e}\nwhile playing {filename}") - if result_callback: - result_callback(f"Error: {e}\nwhile playing {filename}") - finally: - if cls._i2s: - cls._i2s.deinit() - cls._i2s = None - - diff --git a/internal_filesystem/apps/com.micropythonos.musicplayer/assets/music_player.py b/internal_filesystem/apps/com.micropythonos.musicplayer/assets/music_player.py index 75ba010d..428f773f 100644 --- a/internal_filesystem/apps/com.micropythonos.musicplayer/assets/music_player.py +++ b/internal_filesystem/apps/com.micropythonos.musicplayer/assets/music_player.py @@ -1,13 +1,11 @@ import machine import os -import _thread import time from mpos.apps import Activity, Intent import mpos.sdcard import mpos.ui - -from audio_player import AudioPlayer +import mpos.audio.audioflinger as AudioFlinger class MusicPlayer(Activity): @@ -68,17 +66,17 @@ def onCreate(self): self._filename = self.getIntent().extras.get("filename") qr_screen = lv.obj() self._slider_label=lv.label(qr_screen) - self._slider_label.set_text(f"Volume: {AudioPlayer.get_volume()}%") + self._slider_label.set_text(f"Volume: {AudioFlinger.get_volume()}%") self._slider_label.align(lv.ALIGN.TOP_MID,0,lv.pct(4)) self._slider=lv.slider(qr_screen) - self._slider.set_range(0,100) - self._slider.set_value(AudioPlayer.get_volume(), False) + self._slider.set_range(0,16) + self._slider.set_value(int(AudioFlinger.get_volume()/6.25), False) self._slider.set_width(lv.pct(90)) self._slider.align_to(self._slider_label,lv.ALIGN.OUT_BOTTOM_MID,0,10) def volume_slider_changed(e): - volume_int = self._slider.get_value() + volume_int = self._slider.get_value()*6.25 self._slider_label.set_text(f"Volume: {volume_int}%") - AudioPlayer.set_volume(volume_int) + AudioFlinger.set_volume(volume_int) self._slider.add_event_cb(volume_slider_changed,lv.EVENT.VALUE_CHANGED,None) self._filename_label = lv.label(qr_screen) self._filename_label.align(lv.ALIGN.CENTER,0,0) @@ -104,11 +102,23 @@ def onResume(self, screen): if not self._filename: print("Not playing any file...") else: - print("Starting thread to play file {self._filename}") - AudioPlayer.stop_playing() + print(f"Playing file {self._filename}") + AudioFlinger.stop() time.sleep(0.1) - _thread.stack_size(mpos.apps.good_stack_size()) - _thread.start_new_thread(AudioPlayer.play_wav, (self._filename,self.player_finished,)) + + success = AudioFlinger.play_wav( + self._filename, + stream_type=AudioFlinger.STREAM_MUSIC, + on_complete=self.player_finished + ) + + if not success: + error_msg = "Error: Audio device unavailable or busy" + print(error_msg) + self.update_ui_threadsafe_if_foreground( + self._filename_label.set_text, + error_msg + ) def focus_obj(self, obj): obj.set_style_border_color(lv.theme_get_color_primary(None),lv.PART.MAIN) @@ -118,7 +128,7 @@ def defocus_obj(self, obj): obj.set_style_border_width(0, lv.PART.MAIN) def stop_button_clicked(self, event): - AudioPlayer.stop_playing() + AudioFlinger.stop() self.finish() def player_finished(self, result=None): diff --git a/internal_filesystem/apps/com.micropythonos.showbattery/META-INF/MANIFEST.JSON b/internal_filesystem/apps/com.micropythonos.showbattery/META-INF/MANIFEST.JSON new file mode 100644 index 00000000..63fbca9e --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.showbattery/META-INF/MANIFEST.JSON @@ -0,0 +1,24 @@ +{ +"name": "ShowBattery", +"publisher": "MicroPythonOS", +"short_description": "Minimal app", +"long_description": "Demonstrates the simplest app.", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.helloworld/icons/com.micropythonos.helloworld_0.0.2_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.helloworld/mpks/com.micropythonos.helloworld_0.0.2.mpk", +"fullname": "com.micropythonos.showbattery", +"version": "0.0.2", +"category": "development", +"activities": [ + { + "entrypoint": "assets/hello.py", + "classname": "Hello", + "intent_filters": [ + { + "action": "main", + "category": "launcher" + } + ] + } + ] +} + diff --git a/internal_filesystem/apps/com.micropythonos.showbattery/assets/hello.py b/internal_filesystem/apps/com.micropythonos.showbattery/assets/hello.py new file mode 100644 index 00000000..7e0ac09e --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.showbattery/assets/hello.py @@ -0,0 +1,87 @@ +""" +8:44 4.15V +8:46 4.13V + +import time +v = mpos.battery_voltage.read_battery_voltage() +percent = mpos.battery_voltage.get_battery_percentage() +text = f"{time.localtime()}: {v}V is {percent}%" +text + +from machine import ADC, Pin # do this inside the try because it will fail on desktop +adc = ADC(Pin(13)) +# Set ADC to 11dB attenuation for 0–3.3V range (common for ESP32) +adc.atten(ADC.ATTN_11DB) +adc.read() + +scale factor 0.002 is (4.15 / 4095) * 2 +BUT shows 4.90 instead of 4.13 +BUT shows 5.018 instead of 4.65 (raw ADC read: 2366) +SO substract 0.77 +# at 2366 + +2506 is 4.71 (not 4.03) +scale factor 0.002 is (4.15 / 4095) * 2 +BUT shows 4.90 instead of 4.13 +BUT shows 5.018 instead of 4.65 (raw ADC read: 2366) +SO substract 0.77 +# at 2366 + +USB power: +2506 is 4.71 (not 4.03) +2498 +2491 + +battery power: +2482 is 4.180 +2470 is 4.170 +2457 is 4.147 +2433 is 4.109 +2429 is 4.102 +2393 is 4.044 +2369 is 4.000 +2343 is 3.957 +2319 is 3.916 +2269 is 3.831 + +""" + +import lvgl as lv +import time + +import mpos.battery_voltage +from mpos.apps import Activity + +class Hello(Activity): + + refresh_timer = None + + # Widgets: + raw_label = None + + def onCreate(self): + s = lv.obj() + self.raw_label = lv.label(s) + self.raw_label.set_text("starting...") + self.raw_label.center() + self.setContentView(s) + + def onResume(self, screen): + super().onResume(screen) + + def update_bat(timer): + #global l + r = mpos.battery_voltage.read_raw_adc() + v = mpos.battery_voltage.read_battery_voltage() + percent = mpos.battery_voltage.get_battery_percentage() + text = f"{time.localtime()}\n{r}\n{v}V\n{percent}%" + #text = f"{time.localtime()}: {r}" + print(text) + self.update_ui_threadsafe_if_foreground(self.raw_label.set_text, text) + + self.refresh_timer = lv.timer_create(update_bat,1000,None) #.set_repeat_count(10) + + def onPause(self, screen): + super().onPause(screen) + if self.refresh_timer: + self.refresh_timer.delete() diff --git a/internal_filesystem/apps/com.micropythonos.soundrecorder/META-INF/MANIFEST.JSON b/internal_filesystem/apps/com.micropythonos.soundrecorder/META-INF/MANIFEST.JSON new file mode 100644 index 00000000..eef5faf3 --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.soundrecorder/META-INF/MANIFEST.JSON @@ -0,0 +1,23 @@ +{ + "name": "Sound Recorder", + "publisher": "MicroPythonOS", + "short_description": "Record audio from microphone", + "long_description": "Record audio from the I2S microphone and save as WAV files. Recordings can be played back with the Music Player app.", + "icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.soundrecorder/icons/com.micropythonos.soundrecorder_0.0.1_64x64.png", + "download_url": "https://apps.micropythonos.com/apps/com.micropythonos.soundrecorder/mpks/com.micropythonos.soundrecorder_0.0.1.mpk", + "fullname": "com.micropythonos.soundrecorder", + "version": "0.0.1", + "category": "utilities", + "activities": [ + { + "entrypoint": "assets/sound_recorder.py", + "classname": "SoundRecorder", + "intent_filters": [ + { + "action": "main", + "category": "launcher" + } + ] + } + ] +} \ No newline at end of file diff --git a/internal_filesystem/apps/com.micropythonos.soundrecorder/assets/sound_recorder.py b/internal_filesystem/apps/com.micropythonos.soundrecorder/assets/sound_recorder.py new file mode 100644 index 00000000..b90a10fd --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.soundrecorder/assets/sound_recorder.py @@ -0,0 +1,401 @@ +# Sound Recorder App - Record audio from I2S microphone to WAV files +import os +import time + +from mpos.apps import Activity +import mpos.ui +import mpos.audio.audioflinger as AudioFlinger + + +def _makedirs(path): + """ + Create directory and all parent directories (like os.makedirs). + MicroPython doesn't have os.makedirs, so we implement it manually. + """ + if not path: + return + + parts = path.split('/') + current = '' + + for part in parts: + if not part: + continue + current = current + '/' + part if current else part + try: + os.mkdir(current) + except OSError: + pass # Directory may already exist + + +class SoundRecorder(Activity): + """ + Sound Recorder app for recording audio from I2S microphone. + Saves recordings as WAV files that can be played with Music Player. + """ + + # Constants + RECORDINGS_DIR = "data/recordings" + SAMPLE_RATE = 16000 # 16kHz + BYTES_PER_SAMPLE = 2 # 16-bit audio + BYTES_PER_SECOND = SAMPLE_RATE * BYTES_PER_SAMPLE # 32000 bytes/sec + MIN_DURATION_MS = 5000 # Minimum 5 seconds + MAX_DURATION_MS = 3600000 # Maximum 1 hour (absolute cap) + SAFETY_MARGIN = 0.80 # Use only 80% of available space + + # UI Widgets + _status_label = None + _timer_label = None + _record_button = None + _record_button_label = None + _play_button = None + _play_button_label = None + _delete_button = None + _last_file_label = None + + # State + _is_recording = False + _last_recording = None + _timer_task = None + _record_start_time = 0 + + def onCreate(self): + screen = lv.obj() + + # Calculate max duration based on available storage + self._current_max_duration_ms = self._calculate_max_duration() + + # Title + title = lv.label(screen) + title.set_text("Sound Recorder") + title.align(lv.ALIGN.TOP_MID, 0, 10) + title.set_style_text_font(lv.font_montserrat_20, 0) + + # Status label (shows microphone availability) + self._status_label = lv.label(screen) + self._status_label.align(lv.ALIGN.TOP_MID, 0, 40) + + # Timer display + self._timer_label = lv.label(screen) + self._timer_label.set_text(self._format_timer_text(0)) + self._timer_label.align(lv.ALIGN.CENTER, 0, -30) + self._timer_label.set_style_text_font(lv.font_montserrat_24, 0) + + # Record button + self._record_button = lv.button(screen) + self._record_button.set_size(120, 50) + self._record_button.align(lv.ALIGN.CENTER, 0, 30) + self._record_button.add_event_cb(self._on_record_clicked, lv.EVENT.CLICKED, None) + + self._record_button_label = lv.label(self._record_button) + self._record_button_label.set_text(lv.SYMBOL.AUDIO + " Record") + self._record_button_label.center() + + # Last recording info + self._last_file_label = lv.label(screen) + self._last_file_label.align(lv.ALIGN.BOTTOM_MID, 0, -70) + self._last_file_label.set_text("No recordings yet") + self._last_file_label.set_long_mode(lv.label.LONG_MODE.SCROLL_CIRCULAR) + self._last_file_label.set_width(lv.pct(90)) + + # Play button + self._play_button = lv.button(screen) + self._play_button.set_size(80, 40) + self._play_button.align(lv.ALIGN.BOTTOM_LEFT, 20, -20) + self._play_button.add_event_cb(self._on_play_clicked, lv.EVENT.CLICKED, None) + self._play_button.add_flag(lv.obj.FLAG.HIDDEN) + + self._play_button_label = lv.label(self._play_button) + self._play_button_label.set_text(lv.SYMBOL.PLAY + " Play") + self._play_button_label.center() + + # Delete button + self._delete_button = lv.button(screen) + self._delete_button.set_size(80, 40) + self._delete_button.align(lv.ALIGN.BOTTOM_RIGHT, -20, -20) + self._delete_button.add_event_cb(self._on_delete_clicked, lv.EVENT.CLICKED, None) + self._delete_button.add_flag(lv.obj.FLAG.HIDDEN) + + delete_label = lv.label(self._delete_button) + delete_label.set_text(lv.SYMBOL.TRASH + " Delete") + delete_label.center() + + self.setContentView(screen) + + def onResume(self, screen): + super().onResume(screen) + # Recalculate max duration (storage may have changed) + self._current_max_duration_ms = self._calculate_max_duration() + self._timer_label.set_text(self._format_timer_text(0)) + self._update_status() + self._find_last_recording() + + def onPause(self, screen): + super().onPause(screen) + # Stop recording if app goes to background + if self._is_recording: + self._stop_recording() + + def _update_status(self): + """Update status label based on microphone availability.""" + if AudioFlinger.has_microphone(): + self._status_label.set_text("Microphone ready") + self._status_label.set_style_text_color(lv.color_hex(0x00AA00), 0) + self._record_button.remove_flag(lv.obj.FLAG.HIDDEN) + else: + self._status_label.set_text("No microphone available") + self._status_label.set_style_text_color(lv.color_hex(0xAA0000), 0) + self._record_button.add_flag(lv.obj.FLAG.HIDDEN) + + def _find_last_recording(self): + """Find the most recent recording file.""" + try: + # Ensure recordings directory exists + _makedirs(self.RECORDINGS_DIR) + + # List recordings + files = os.listdir(self.RECORDINGS_DIR) + wav_files = [f for f in files if f.endswith('.wav')] + + if wav_files: + # Sort by name (which includes timestamp) + wav_files.sort(reverse=True) + self._last_recording = f"{self.RECORDINGS_DIR}/{wav_files[0]}" + self._last_file_label.set_text(f"Last: {wav_files[0]}") + self._play_button.remove_flag(lv.obj.FLAG.HIDDEN) + self._delete_button.remove_flag(lv.obj.FLAG.HIDDEN) + else: + self._last_recording = None + self._last_file_label.set_text("No recordings yet") + self._play_button.add_flag(lv.obj.FLAG.HIDDEN) + self._delete_button.add_flag(lv.obj.FLAG.HIDDEN) + + except Exception as e: + print(f"SoundRecorder: Error finding recordings: {e}") + self._last_recording = None + + def _calculate_max_duration(self): + """ + Calculate maximum recording duration based on available storage. + Returns duration in milliseconds. + """ + try: + # Ensure recordings directory exists + _makedirs(self.RECORDINGS_DIR) + + # Get filesystem stats for the recordings directory + stat = os.statvfs(self.RECORDINGS_DIR) + + # Calculate free space in bytes + # f_bavail = free blocks available to non-superuser + # f_frsize = fragment size (fundamental block size) + free_bytes = stat[0] * stat[4] # f_frsize * f_bavail + + # Apply safety margin (use only 80% of available space) + usable_bytes = int(free_bytes * self.SAFETY_MARGIN) + + # Calculate max duration in seconds + max_seconds = usable_bytes // self.BYTES_PER_SECOND + + # Convert to milliseconds + max_ms = max_seconds * 1000 + + # Clamp to min/max bounds + max_ms = max(self.MIN_DURATION_MS, min(max_ms, self.MAX_DURATION_MS)) + + print(f"SoundRecorder: Free space: {free_bytes} bytes, " + f"usable: {usable_bytes} bytes, max duration: {max_ms // 1000}s") + + return max_ms + + except Exception as e: + print(f"SoundRecorder: Error calculating max duration: {e}") + # Fall back to a conservative 60 seconds + return 60000 + + def _format_timer_text(self, elapsed_ms): + """Format timer display text showing elapsed / max time.""" + elapsed_sec = elapsed_ms // 1000 + max_sec = self._current_max_duration_ms // 1000 + + elapsed_min = elapsed_sec // 60 + elapsed_sec_display = elapsed_sec % 60 + max_min = max_sec // 60 + max_sec_display = max_sec % 60 + + return f"{elapsed_min:02d}:{elapsed_sec_display:02d} / {max_min:02d}:{max_sec_display:02d}" + + def _generate_filename(self): + """Generate a timestamped filename for the recording.""" + # Get current time + t = time.localtime() + timestamp = f"{t[0]:04d}-{t[1]:02d}-{t[2]:02d}_{t[3]:02d}-{t[4]:02d}-{t[5]:02d}" + return f"{self.RECORDINGS_DIR}/{timestamp}.wav" + + def _on_record_clicked(self, event): + """Handle record button click.""" + print(f"SoundRecorder: _on_record_clicked called, _is_recording={self._is_recording}") + if self._is_recording: + print("SoundRecorder: Stopping recording...") + self._stop_recording() + else: + print("SoundRecorder: Starting recording...") + self._start_recording() + + def _start_recording(self): + """Start recording audio.""" + print("SoundRecorder: _start_recording called") + print(f"SoundRecorder: has_microphone() = {AudioFlinger.has_microphone()}") + + if not AudioFlinger.has_microphone(): + print("SoundRecorder: No microphone available - aborting") + return + + # Generate filename + file_path = self._generate_filename() + print(f"SoundRecorder: Generated filename: {file_path}") + + # Recalculate max duration before starting (storage may have changed) + self._current_max_duration_ms = self._calculate_max_duration() + + if self._current_max_duration_ms < self.MIN_DURATION_MS: + print("SoundRecorder: Not enough storage space") + self._status_label.set_text("Not enough storage space") + self._status_label.set_style_text_color(lv.color_hex(0xAA0000), 0) + return + + # Start recording + print(f"SoundRecorder: Calling AudioFlinger.record_wav()") + print(f" file_path: {file_path}") + print(f" duration_ms: {self._current_max_duration_ms}") + print(f" sample_rate: {self.SAMPLE_RATE}") + + success = AudioFlinger.record_wav( + file_path=file_path, + duration_ms=self._current_max_duration_ms, + on_complete=self._on_recording_complete, + sample_rate=self.SAMPLE_RATE + ) + + print(f"SoundRecorder: record_wav returned: {success}") + + if success: + self._is_recording = True + self._record_start_time = time.ticks_ms() + self._last_recording = file_path + print(f"SoundRecorder: Recording started successfully") + + # Update UI + self._record_button_label.set_text(lv.SYMBOL.STOP + " Stop") + self._record_button.set_style_bg_color(lv.color_hex(0xAA0000), 0) + self._status_label.set_text("Recording...") + self._status_label.set_style_text_color(lv.color_hex(0xAA0000), 0) + + # Hide play/delete buttons during recording + self._play_button.add_flag(lv.obj.FLAG.HIDDEN) + self._delete_button.add_flag(lv.obj.FLAG.HIDDEN) + + # Start timer update + self._start_timer_update() + else: + print("SoundRecorder: record_wav failed!") + self._status_label.set_text("Failed to start recording") + self._status_label.set_style_text_color(lv.color_hex(0xAA0000), 0) + + def _stop_recording(self): + """Stop recording audio.""" + AudioFlinger.stop() + self._is_recording = False + + # Show "Saving..." status immediately (file finalization takes time on SD card) + self._status_label.set_text("Saving...") + self._status_label.set_style_text_color(lv.color_hex(0xFF8800), 0) # Orange + + # Disable record button while saving + self._record_button.add_flag(lv.obj.FLAG.HIDDEN) + + # Stop timer update but keep the elapsed time visible + if self._timer_task: + self._timer_task.delete() + self._timer_task = None + + def _on_recording_complete(self, message): + """Callback when recording finishes.""" + print(f"SoundRecorder: {message}") + + # Update UI on main thread + self.update_ui_threadsafe_if_foreground(self._recording_finished, message) + + def _recording_finished(self, message): + """Update UI after recording finishes (called on main thread).""" + self._is_recording = False + + # Re-enable and reset record button + self._record_button.remove_flag(lv.obj.FLAG.HIDDEN) + self._record_button_label.set_text(lv.SYMBOL.AUDIO + " Record") + self._record_button.set_style_bg_color(lv.theme_get_color_primary(None), 0) + + # Update status and find recordings + self._update_status() + self._find_last_recording() + + # Reset timer display + self._timer_label.set_text(self._format_timer_text(0)) + + def _start_timer_update(self): + """Start updating the timer display.""" + # Use LVGL timer for periodic updates + self._timer_task = lv.timer_create(self._update_timer, 100, None) + + def _stop_timer_update(self): + """Stop updating the timer display.""" + if self._timer_task: + self._timer_task.delete() + self._timer_task = None + self._timer_label.set_text(self._format_timer_text(0)) + + def _update_timer(self, timer): + """Update timer display (called periodically).""" + if not self._is_recording: + return + + elapsed_ms = time.ticks_diff(time.ticks_ms(), self._record_start_time) + self._timer_label.set_text(self._format_timer_text(elapsed_ms)) + + def _on_play_clicked(self, event): + """Handle play button click.""" + if self._last_recording and not self._is_recording: + # Stop any current playback + AudioFlinger.stop() + time.sleep_ms(100) + + # Play the recording + success = AudioFlinger.play_wav( + self._last_recording, + stream_type=AudioFlinger.STREAM_MUSIC, + on_complete=self._on_playback_complete + ) + + if success: + self._status_label.set_text("Playing...") + self._status_label.set_style_text_color(lv.color_hex(0x0000AA), 0) + else: + self._status_label.set_text("Playback failed") + self._status_label.set_style_text_color(lv.color_hex(0xAA0000), 0) + + def _on_playback_complete(self, message): + """Callback when playback finishes.""" + self.update_ui_threadsafe_if_foreground(self._update_status) + + def _on_delete_clicked(self, event): + """Handle delete button click.""" + if self._last_recording and not self._is_recording: + try: + os.remove(self._last_recording) + print(f"SoundRecorder: Deleted {self._last_recording}") + self._find_last_recording() + self._status_label.set_text("Recording deleted") + except Exception as e: + print(f"SoundRecorder: Delete failed: {e}") + self._status_label.set_text("Delete failed") + self._status_label.set_style_text_color(lv.color_hex(0xAA0000), 0) \ No newline at end of file diff --git a/internal_filesystem/apps/com.micropythonos.soundrecorder/generate_icon.py b/internal_filesystem/apps/com.micropythonos.soundrecorder/generate_icon.py new file mode 100644 index 00000000..f2cfa66c --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.soundrecorder/generate_icon.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +""" +Generate a 64x64 icon for the Sound Recorder app. +Creates a microphone icon with transparent background. + +Run this script to generate the icon: + python3 generate_icon.py + +The icon will be saved to res/mipmap-mdpi/icon_64x64.png +""" + +import os +from PIL import Image, ImageDraw + +def generate_icon(): + # Create a 64x64 image with transparent background + size = 64 + img = Image.new('RGBA', (size, size), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + + # Colors + mic_color = (220, 50, 50, 255) # Red microphone + mic_dark = (180, 40, 40, 255) # Darker red for shading + stand_color = (80, 80, 80, 255) # Gray stand + highlight = (255, 100, 100, 255) # Light red highlight + + # Microphone head (rounded rectangle / ellipse) + mic_top = 8 + mic_bottom = 36 + mic_left = 20 + mic_right = 44 + + # Draw microphone body (rounded top) + draw.ellipse([mic_left, mic_top, mic_right, mic_top + 16], fill=mic_color) + draw.rectangle([mic_left, mic_top + 8, mic_right, mic_bottom], fill=mic_color) + draw.ellipse([mic_left, mic_bottom - 8, mic_right, mic_bottom + 8], fill=mic_color) + + # Microphone grille lines (horizontal lines on mic head) + for y in range(mic_top + 6, mic_bottom - 4, 4): + draw.line([(mic_left + 4, y), (mic_right - 4, y)], fill=mic_dark, width=1) + + # Highlight on left side of mic + draw.arc([mic_left + 2, mic_top + 2, mic_left + 10, mic_top + 18], + start=120, end=240, fill=highlight, width=2) + + # Microphone stand (curved arc under the mic) + stand_top = mic_bottom + 4 + stand_width = 8 + + # Vertical stem from mic + stem_x = size // 2 + draw.rectangle([stem_x - 2, mic_bottom, stem_x + 2, stand_top + 8], fill=stand_color) + + # Curved holder around mic bottom + draw.arc([mic_left - 4, mic_bottom - 8, mic_right + 4, mic_bottom + 16], + start=0, end=180, fill=stand_color, width=3) + + # Stand base + base_y = 54 + draw.rectangle([stem_x - 2, stand_top + 8, stem_x + 2, base_y], fill=stand_color) + draw.ellipse([stem_x - 12, base_y - 2, stem_x + 12, base_y + 6], fill=stand_color) + + # Recording indicator (red dot with glow effect) + dot_x, dot_y = 52, 12 + dot_radius = 5 + + # Glow effect + for r in range(dot_radius + 3, dot_radius, -1): + alpha = int(100 * (dot_radius + 3 - r) / 3) + glow_color = (255, 0, 0, alpha) + draw.ellipse([dot_x - r, dot_y - r, dot_x + r, dot_y + r], fill=glow_color) + + # Solid red dot + draw.ellipse([dot_x - dot_radius, dot_y - dot_radius, + dot_x + dot_radius, dot_y + dot_radius], + fill=(255, 50, 50, 255)) + + # White highlight on dot + draw.ellipse([dot_x - 2, dot_y - 2, dot_x, dot_y], fill=(255, 200, 200, 255)) + + # Ensure output directory exists + output_dir = 'res/mipmap-mdpi' + os.makedirs(output_dir, exist_ok=True) + + # Save the icon + output_path = os.path.join(output_dir, 'icon_64x64.png') + img.save(output_path, 'PNG') + print(f"Icon saved to {output_path}") + + return img + +if __name__ == '__main__': + generate_icon() \ No newline at end of file diff --git a/internal_filesystem/apps/com.micropythonos.soundrecorder/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/apps/com.micropythonos.soundrecorder/res/mipmap-mdpi/icon_64x64.png new file mode 100644 index 00000000..a301f72f Binary files /dev/null and b/internal_filesystem/apps/com.micropythonos.soundrecorder/res/mipmap-mdpi/icon_64x64.png differ diff --git a/internal_filesystem/builtin/apps/com.micropythonos.about/META-INF/MANIFEST.JSON b/internal_filesystem/builtin/apps/com.micropythonos.about/META-INF/MANIFEST.JSON index 457f3494..a09cd929 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.about/META-INF/MANIFEST.JSON +++ b/internal_filesystem/builtin/apps/com.micropythonos.about/META-INF/MANIFEST.JSON @@ -3,10 +3,10 @@ "publisher": "MicroPythonOS", "short_description": "Info about MicroPythonOS", "long_description": "Shows current MicroPythonOS version, MicroPython version, build date and other useful info..", -"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.about/icons/com.micropythonos.about_0.0.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.0.7_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.about/mpks/com.micropythonos.about_0.0.7.mpk", "fullname": "com.micropythonos.about", -"version": "0.0.6", +"version": "0.0.7", "category": "development", "activities": [ { diff --git a/internal_filesystem/builtin/apps/com.micropythonos.about/assets/about.py b/internal_filesystem/builtin/apps/com.micropythonos.about/assets/about.py index d278f52c..00c9767e 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.about/assets/about.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.about/assets/about.py @@ -85,7 +85,32 @@ def onCreate(self): print("main.py: WARNING: could not import/run freezefs_mount_builtin: ", e) label11 = lv.label(screen) label11.set_text(f"freezefs_mount_builtin exception (normal on dev builds): {e}") - # TODO: - # - add total size, used and free space on internal storage - # - add total size, used and free space on SD card + # Disk usage: + import os + try: + stat = os.statvfs('/') + total_space = stat[0] * stat[2] + free_space = stat[0] * stat[3] + used_space = total_space - free_space + label20 = lv.label(screen) + label20.set_text(f"Total space in /: {total_space} bytes") + label21 = lv.label(screen) + label21.set_text(f"Free space in /: {free_space} bytes") + label22 = lv.label(screen) + label22.set_text(f"Used space in /: {used_space} bytes") + except Exception as e: + print(f"About app could not get info on / filesystem: {e}") + try: + stat = os.statvfs('/sdcard') + total_space = stat[0] * stat[2] + free_space = stat[0] * stat[3] + used_space = total_space - free_space + label23 = lv.label(screen) + label23.set_text(f"Total space /sdcard: {total_space} bytes") + label24 = lv.label(screen) + label24.set_text(f"Free space /sdcard: {free_space} bytes") + label25 = lv.label(screen) + label25.set_text(f"Used space /sdcard: {used_space} bytes") + except Exception as e: + print(f"About app could not get info on /sdcard filesystem: {e}") self.setContentView(screen) diff --git a/internal_filesystem/builtin/apps/com.micropythonos.appstore/META-INF/MANIFEST.JSON b/internal_filesystem/builtin/apps/com.micropythonos.appstore/META-INF/MANIFEST.JSON index 16713240..f7afe5a8 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.appstore/META-INF/MANIFEST.JSON +++ b/internal_filesystem/builtin/apps/com.micropythonos.appstore/META-INF/MANIFEST.JSON @@ -3,10 +3,10 @@ "publisher": "MicroPythonOS", "short_description": "Store for App(lication)s", "long_description": "This is the place to discover, find, install, uninstall and upgrade all the apps that make your device useless.", -"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.appstore/icons/com.micropythonos.appstore_0.0.8_64x64.png", -"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.appstore/mpks/com.micropythonos.appstore_0.0.8.mpk", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.appstore/icons/com.micropythonos.appstore_0.0.9_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.appstore/mpks/com.micropythonos.appstore_0.0.9.mpk", "fullname": "com.micropythonos.appstore", -"version": "0.0.8", +"version": "0.0.9", "category": "appstore", "activities": [ { diff --git a/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/appstore.py b/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/appstore.py index 3efecac2..d02a53e9 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/appstore.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/appstore.py @@ -3,18 +3,29 @@ import requests import gc import os -import time -import _thread from mpos.apps import Activity, Intent from mpos.app import App +from mpos import TaskManager, DownloadManager import mpos.ui from mpos.content.package_manager import PackageManager - class AppStore(Activity): + + _BADGEHUB_API_BASE_URL = "https://badgehub.p1m.nl/api/v3" + _BADGEHUB_LIST = "project-summaries?badge=fri3d_2024" + _BADGEHUB_DETAILS = "projects" + + _BACKEND_API_GITHUB = "github" + _BACKEND_API_BADGEHUB = "badgehub" + apps = [] - app_index_url = "https://apps.micropythonos.com/app_index.json" + # These might become configurations: + #backend_api = _BACKEND_API_BADGEHUB + backend_api = _BACKEND_API_GITHUB + app_index_url_github = "https://apps.micropythonos.com/app_index.json" + app_index_url_badgehub = _BADGEHUB_API_BASE_URL + "/" + _BADGEHUB_LIST + app_detail_url_badgehub = _BADGEHUB_API_BASE_URL + "/" + _BADGEHUB_DETAILS can_check_network = True # Widgets: @@ -43,41 +54,48 @@ def onResume(self, screen): if self.can_check_network and not network.WLAN(network.STA_IF).isconnected(): self.please_wait_label.set_text("Error: WiFi is not connected.") else: - _thread.stack_size(mpos.apps.good_stack_size()) - _thread.start_new_thread(self.download_app_index, (self.app_index_url,)) + if self.backend_api == self._BACKEND_API_BADGEHUB: + TaskManager.create_task(self.download_app_index(self.app_index_url_badgehub)) + else: + TaskManager.create_task(self.download_app_index(self.app_index_url_github)) - def download_app_index(self, json_url): + async def download_app_index(self, json_url): + response = await DownloadManager.download_url(json_url) + if not response: + self.please_wait_label.set_text(f"Could not download app index from\n{json_url}") + return + print(f"Got response text: {response[0:20]}") try: - response = requests.get(json_url, timeout=10) + parsed = json.loads(response) + print(f"parsed json: {parsed}") + for app in parsed: + try: + if self.backend_api == self._BACKEND_API_BADGEHUB: + self.apps.append(AppStore.badgehub_app_to_mpos_app(app)) + else: + self.apps.append(App(app["name"], app["publisher"], app["short_description"], app["long_description"], app["icon_url"], app["download_url"], app["fullname"], app["version"], app["category"], app["activities"])) + except Exception as e: + print(f"Warning: could not add app from {json_url} to apps list: {e}") except Exception as e: - print("Download failed:", e) - self.update_ui_threadsafe_if_foreground(self.please_wait_label.set_text, f"App index download \n{json_url}\ngot error: {e}") + self.please_wait_label.set_text(f"ERROR: could not parse reponse.text JSON: {e}") return - if response and response.status_code == 200: - #print(f"Got response text: {response.text}") - try: - for app in json.loads(response.text): - try: - self.apps.append(App(app["name"], app["publisher"], app["short_description"], app["long_description"], app["icon_url"], app["download_url"], app["fullname"], app["version"], app["category"], app["activities"])) - except Exception as e: - print(f"Warning: could not add app from {json_url} to apps list: {e}") - except Exception as e: - print(f"ERROR: could not parse reponse.text JSON: {e}") - finally: - response.close() - # Remove duplicates based on app.name - seen = set() - self.apps = [app for app in self.apps if not (app.fullname in seen or seen.add(app.fullname))] - # Sort apps by app.name - self.apps.sort(key=lambda x: x.name.lower()) # Use .lower() for case-insensitive sorting - time.sleep_ms(200) - self.update_ui_threadsafe_if_foreground(self.please_wait_label.add_flag, lv.obj.FLAG.HIDDEN) - self.update_ui_threadsafe_if_foreground(self.create_apps_list) - time.sleep(0.1) # give the UI time to display the app list before starting to download - self.download_icons() + self.please_wait_label.set_text(f"Download successful, building list...") + await TaskManager.sleep(0.1) # give the UI time to display the app list before starting to download + print("Remove duplicates based on app.name") + seen = set() + self.apps = [app for app in self.apps if not (app.fullname in seen or seen.add(app.fullname))] + print("Sort apps by app.name") + self.apps.sort(key=lambda x: x.name.lower()) # Use .lower() for case-insensitive sorting + print("Creating apps list...") + self.create_apps_list() + await TaskManager.sleep(0.1) # give the UI time to display the app list before starting to download + print("awaiting self.download_icons()") + await self.download_icons() def create_apps_list(self): print("create_apps_list") + print("Hiding please wait label...") + self.please_wait_label.add_flag(lv.obj.FLAG.HIDDEN) apps_list = lv.list(self.main_screen) apps_list.set_style_border_width(0, 0) apps_list.set_style_radius(0, 0) @@ -119,14 +137,19 @@ def create_apps_list(self): desc_label.set_style_text_font(lv.font_montserrat_12, 0) desc_label.add_event_cb(lambda e, a=app: self.show_app_detail(a), lv.EVENT.CLICKED, None) print("create_apps_list app done") - - def download_icons(self): + + async def download_icons(self): + print("Downloading icons...") for app in self.apps: if not self.has_foreground(): - print(f"App is stopping, aborting icon downloads.") + print(f"App is stopping, aborting icon downloads.") # maybe this can continue? but then update_ui_if_foreground is needed break if not app.icon_data: - app.icon_data = self.download_icon_data(app.icon_url) + try: + app.icon_data = await TaskManager.wait_for(DownloadManager.download_url(app.icon_url), 5) # max 5 seconds per icon + except Exception as e: + print(f"Download of {app.icon_url} got exception: {e}") + continue if app.icon_data: print("download_icons has icon_data, showing it...") image_icon_widget = None @@ -139,28 +162,95 @@ def download_icons(self): 'data_size': len(app.icon_data), 'data': app.icon_data }) - self.update_ui_threadsafe_if_foreground(image_icon_widget.set_src, image_dsc) # error: 'App' object has no attribute 'image' + image_icon_widget.set_src(image_dsc) # use some kind of new update_ui_if_foreground() ? print("Finished downloading icons.") def show_app_detail(self, app): intent = Intent(activity_class=AppDetail) intent.putExtra("app", app) + intent.putExtra("appstore", self) self.startActivity(intent) @staticmethod - def download_icon_data(url): - print(f"Downloading icon from {url}") + def badgehub_app_to_mpos_app(bhapp): + #print(f"Converting {bhapp} to MPOS app object...") + name = bhapp.get("name") + print(f"Got app name: {name}") + publisher = None + short_description = bhapp.get("description") + long_description = None try: - response = requests.get(url, timeout=5) - if response.status_code == 200: - image_data = response.content - print("Downloaded image, size:", len(image_data), "bytes") - return image_data - else: - print("Failed to download image: Status code", response.status_code) + icon_url = bhapp.get("icon_map").get("64x64").get("url") + except Exception as e: + icon_url = None + print("Could not find icon_map 64x64 url") + download_url = None + fullname = bhapp.get("slug") + version = None + try: + category = bhapp.get("categories")[0] + except Exception as e: + category = None + print("Could not parse category") + activities = None + return App(name, publisher, short_description, long_description, icon_url, download_url, fullname, version, category, activities) + + async def fetch_badgehub_app_details(self, app_obj): + details_url = self.app_detail_url_badgehub + "/" + app_obj.fullname + response = await DownloadManager.download_url(details_url) + if not response: + print(f"Could not download app details from from\n{details_url}") + return + print(f"Got response text: {response[0:20]}") + try: + parsed = json.loads(response) + print(f"parsed json: {parsed}") + print("Using short_description as long_description because backend doesn't support it...") + app_obj.long_description = app_obj.short_description + print("Finding version number...") + try: + version = parsed.get("version") + except Exception as e: + print(f"Could not get version object from appdetails: {e}") + return + print(f"got version object: {version}") + # Find .mpk download URL: + try: + files = version.get("files") + for file in files: + print(f"parsing file: {file}") + ext = file.get("ext").lower() + print(f"file has extension: {ext}") + if ext == ".mpk": + app_obj.download_url = file.get("url") + app_obj.download_url_size = file.get("size_of_content") + break # only one .mpk per app is supported + except Exception as e: + print(f"Could not get files from version: {e}") + try: + app_metadata = version.get("app_metadata") + except Exception as e: + print(f"Could not get app_metadata object from version object: {e}") + return + try: + author = app_metadata.get("author") + print("Using author as publisher because that's all the backend supports...") + app_obj.publisher = author + except Exception as e: + print(f"Could not get author from version object: {e}") + try: + app_version = app_metadata.get("version") + print(f"what: {version.get('app_metadata')}") + print(f"app has app_version: {app_version}") + app_obj.version = app_version + except Exception as e: + print(f"Could not get version from app_metadata: {e}") except Exception as e: - print(f"Exception during download of icon: {e}") - return None + err = f"ERROR: could not parse app details JSON: {e}" + print(err) + self.please_wait_label.set_text(err) + return + class AppDetail(Activity): @@ -174,10 +264,19 @@ class AppDetail(Activity): update_button = None progress_bar = None install_label = None + long_desc_label = None + version_label = None + buttoncont = None + publisher_label = None + + # Received from the Intent extras: + app = None + appstore = None def onCreate(self): print("Creating app detail screen...") - app = self.getIntent().extras.get("app") + self.app = self.getIntent().extras.get("app") + self.appstore = self.getIntent().extras.get("appstore") app_detail_screen = lv.obj() app_detail_screen.set_style_pad_all(5, 0) app_detail_screen.set_size(lv.pct(100), lv.pct(100)) @@ -192,10 +291,10 @@ def onCreate(self): headercont.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) icon_spacer = lv.image(headercont) icon_spacer.set_size(64, 64) - if app.icon_data: + if self.app.icon_data: image_dsc = lv.image_dsc_t({ - 'data_size': len(app.icon_data), - 'data': app.icon_data + 'data_size': len(self.app.icon_data), + 'data': self.app.icon_data }) icon_spacer.set_src(image_dsc) else: @@ -206,55 +305,82 @@ def onCreate(self): detail_cont.set_style_pad_all(0, 0) detail_cont.set_flex_flow(lv.FLEX_FLOW.COLUMN) detail_cont.set_size(lv.pct(75), lv.SIZE_CONTENT) + detail_cont.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) name_label = lv.label(detail_cont) - name_label.set_text(app.name) + name_label.set_text(self.app.name) name_label.set_style_text_font(lv.font_montserrat_24, 0) - publisher_label = lv.label(detail_cont) - publisher_label.set_text(app.publisher) - publisher_label.set_style_text_font(lv.font_montserrat_16, 0) + self.publisher_label = lv.label(detail_cont) + if self.app.publisher: + self.publisher_label.set_text(self.app.publisher) + else: + self.publisher_label.set_text("Unknown publisher") + self.publisher_label.set_style_text_font(lv.font_montserrat_16, 0) self.progress_bar = lv.bar(app_detail_screen) self.progress_bar.set_width(lv.pct(100)) self.progress_bar.set_range(0, 100) self.progress_bar.add_flag(lv.obj.FLAG.HIDDEN) # Always have this button: - buttoncont = lv.obj(app_detail_screen) - buttoncont.set_style_border_width(0, 0) - buttoncont.set_style_radius(0, 0) - buttoncont.set_style_pad_all(0, 0) - buttoncont.set_flex_flow(lv.FLEX_FLOW.ROW) - buttoncont.set_size(lv.pct(100), lv.SIZE_CONTENT) - buttoncont.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) - print(f"Adding (un)install button for url: {app.download_url}") + self.buttoncont = lv.obj(app_detail_screen) + self.buttoncont.set_style_border_width(0, 0) + self.buttoncont.set_style_radius(0, 0) + self.buttoncont.set_style_pad_all(0, 0) + self.buttoncont.set_flex_flow(lv.FLEX_FLOW.ROW) + self.buttoncont.set_size(lv.pct(100), lv.SIZE_CONTENT) + self.buttoncont.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) + self.add_action_buttons(self.buttoncont, self.app) + # version label: + self.version_label = lv.label(app_detail_screen) + self.version_label.set_width(lv.pct(100)) + if self.app.version: + self.version_label.set_text(f"Latest version: {self.app.version}") # would be nice to make this bold if this is newer than the currently installed one + else: + self.version_label.set_text(f"Unknown version") + self.version_label.set_style_text_font(lv.font_montserrat_12, 0) + self.version_label.align_to(self.install_button, lv.ALIGN.OUT_BOTTOM_MID, 0, lv.pct(5)) + self.long_desc_label = lv.label(app_detail_screen) + self.long_desc_label.align_to(self.version_label, lv.ALIGN.OUT_BOTTOM_MID, 0, lv.pct(5)) + if self.app.long_description: + self.long_desc_label.set_text(self.app.long_description) + else: + self.long_desc_label.set_text(self.app.short_description) + self.long_desc_label.set_style_text_font(lv.font_montserrat_12, 0) + self.long_desc_label.set_width(lv.pct(100)) + print("Loading app detail screen...") + self.setContentView(app_detail_screen) + + def onResume(self, screen): + if self.appstore.backend_api == self.appstore._BACKEND_API_BADGEHUB: + TaskManager.create_task(self.fetch_and_set_app_details()) + else: + print("No need to fetch app details as the github app index already contains all the app data.") + + def add_action_buttons(self, buttoncont, app): + buttoncont.clean() + print(f"Adding (un)install button for url: {self.app.download_url}") self.install_button = lv.button(buttoncont) - self.install_button.add_event_cb(lambda e, d=app.download_url, f=app.fullname: self.toggle_install(d,f), lv.EVENT.CLICKED, None) + self.install_button.add_event_cb(lambda e, a=self.app: self.toggle_install(a), lv.EVENT.CLICKED, None) self.install_button.set_size(lv.pct(100), 40) self.install_label = lv.label(self.install_button) self.install_label.center() - self.set_install_label(app.fullname) - if PackageManager.is_update_available(app.fullname, app.version): + self.set_install_label(self.app.fullname) + if app.version and PackageManager.is_update_available(self.app.fullname, app.version): self.install_button.set_size(lv.pct(47), 40) # make space for update button print("Update available, adding update button.") self.update_button = lv.button(buttoncont) self.update_button.set_size(lv.pct(47), 40) - self.update_button.add_event_cb(lambda e, d=app.download_url, f=app.fullname: self.update_button_click(d,f), lv.EVENT.CLICKED, None) + self.update_button.add_event_cb(lambda e, a=self.app: self.update_button_click(a), lv.EVENT.CLICKED, None) update_label = lv.label(self.update_button) update_label.set_text("Update") update_label.center() - # version label: - version_label = lv.label(app_detail_screen) - version_label.set_width(lv.pct(100)) - version_label.set_text(f"Latest version: {app.version}") # make this bold if this is newer than the currently installed one - version_label.set_style_text_font(lv.font_montserrat_12, 0) - version_label.align_to(self.install_button, lv.ALIGN.OUT_BOTTOM_MID, 0, lv.pct(5)) - long_desc_label = lv.label(app_detail_screen) - long_desc_label.align_to(version_label, lv.ALIGN.OUT_BOTTOM_MID, 0, lv.pct(5)) - long_desc_label.set_text(app.long_description) - long_desc_label.set_style_text_font(lv.font_montserrat_12, 0) - long_desc_label.set_width(lv.pct(100)) - print("Loading app detail screen...") - self.setContentView(app_detail_screen) - + + async def fetch_and_set_app_details(self): + await self.appstore.fetch_badgehub_app_details(self.app) + print(f"app has version: {self.app.version}") + self.version_label.set_text(self.app.version) + self.long_desc_label.set_text(self.app.long_description) + self.publisher_label.set_text(self.app.publisher) + self.add_action_buttons(self.buttoncont, self.app) def set_install_label(self, app_fullname): # Figure out whether to show: @@ -283,43 +409,37 @@ def set_install_label(self, app_fullname): action_label = self.action_label_install self.install_label.set_text(action_label) - def toggle_install(self, download_url, fullname): - print(f"Install button clicked for {download_url} and fullname {fullname}") + def toggle_install(self, app_obj): + print(f"Install button clicked for {app_obj}") + download_url = app_obj.download_url + fullname = app_obj.fullname + print(f"With {download_url} and fullname {fullname}") label_text = self.install_label.get_text() if label_text == self.action_label_install: - try: - _thread.stack_size(mpos.apps.good_stack_size()) - _thread.start_new_thread(self.download_and_install, (download_url, f"apps/{fullname}", fullname)) - except Exception as e: - print("Could not start download_and_install thread: ", e) + print("Starting install task...") + TaskManager.create_task(self.download_and_install(app_obj, f"apps/{fullname}")) elif label_text == self.action_label_uninstall or label_text == self.action_label_restore: - print("Uninstalling app....") - try: - _thread.stack_size(mpos.apps.good_stack_size()) - _thread.start_new_thread(self.uninstall_app, (fullname,)) - except Exception as e: - print("Could not start uninstall_app thread: ", e) + print("Starting uninstall task...") + TaskManager.create_task(self.uninstall_app(fullname)) - def update_button_click(self, download_url, fullname): + def update_button_click(self, app_obj): + download_url = app_obj.download_url + fullname = app_obj.fullname print(f"Update button clicked for {download_url} and fullname {fullname}") self.update_button.add_flag(lv.obj.FLAG.HIDDEN) self.install_button.set_size(lv.pct(100), 40) - try: - _thread.stack_size(mpos.apps.good_stack_size()) - _thread.start_new_thread(self.download_and_install, (download_url, f"apps/{fullname}", fullname)) - except Exception as e: - print("Could not start download_and_install thread: ", e) + TaskManager.create_task(self.download_and_install(app_obj, f"apps/{fullname}")) - def uninstall_app(self, app_fullname): + async def uninstall_app(self, app_fullname): self.install_button.add_state(lv.STATE.DISABLED) self.install_label.set_text("Please wait...") self.progress_bar.remove_flag(lv.obj.FLAG.HIDDEN) self.progress_bar.set_value(21, True) - time.sleep(1) # seems silly but otherwise it goes so quickly that the user can't tell something happened and gets confused + await TaskManager.sleep(1) # seems silly but otherwise it goes so quickly that the user can't tell something happened and gets confused self.progress_bar.set_value(42, True) - time.sleep(1) # seems silly but otherwise it goes so quickly that the user can't tell something happened and gets confused + await TaskManager.sleep(1) # seems silly but otherwise it goes so quickly that the user can't tell something happened and gets confused PackageManager.uninstall_app(app_fullname) - time.sleep(1) # seems silly but otherwise it goes so quickly that the user can't tell something happened and gets confused + await TaskManager.sleep(1) # seems silly but otherwise it goes so quickly that the user can't tell something happened and gets confused self.progress_bar.set_value(100, False) self.progress_bar.add_flag(lv.obj.FLAG.HIDDEN) self.progress_bar.set_value(0, False) @@ -329,48 +449,54 @@ def uninstall_app(self, app_fullname): self.update_button.remove_flag(lv.obj.FLAG.HIDDEN) self.install_button.set_size(lv.pct(47), 40) # if a builtin app was removed, then it was overridden, and a new version is available, so make space for update button - def download_and_install(self, zip_url, dest_folder, app_fullname): + async def pcb(self, percent): + print(f"pcb called: {percent}") + scaled_percent_start = 5 # before 5% is preparation + scaled_percent_finished = 60 # after 60% is unzip + scaled_percent_diff = scaled_percent_finished - scaled_percent_start + scale = 100 / scaled_percent_diff # 100 / 55 = 1.81 + scaled_percent = round(percent / scale) + scaled_percent += scaled_percent_start + self.progress_bar.set_value(scaled_percent, True) + + async def download_and_install(self, app_obj, dest_folder): + zip_url = app_obj.download_url + app_fullname = app_obj.fullname + download_url_size = None + if hasattr(app_obj, "download_url_size"): + download_url_size = app_obj.download_url_size self.install_button.add_state(lv.STATE.DISABLED) self.install_label.set_text("Please wait...") self.progress_bar.remove_flag(lv.obj.FLAG.HIDDEN) - self.progress_bar.set_value(20, True) - time.sleep(1) # seems silly but otherwise it goes so quickly that the user can't tell something happened and gets confused + self.progress_bar.set_value(5, True) + await TaskManager.sleep(1) # seems silly but otherwise it goes so quickly that the user can't tell something happened and gets confused + # Download the .mpk file to temporary location try: - # Step 1: Download the .mpk file - print(f"Downloading .mpk file from: {zip_url}") - response = requests.get(zip_url, timeout=10) # TODO: use stream=True and do it in chunks like in OSUpdate - if response.status_code != 200: - print("Download failed: Status code", response.status_code) - response.close() - self.set_install_label(app_fullname) - self.progress_bar.set_value(40, True) - # Save the .mpk file to a temporary location - try: - os.remove(temp_zip_path) - except Exception: - pass - try: - os.mkdir("tmp") - except Exception: - pass - temp_zip_path = "tmp/temp.mpk" - print(f"Writing to temporary mpk path: {temp_zip_path}") - # TODO: check free available space first! - with open(temp_zip_path, "wb") as f: - f.write(response.content) - self.progress_bar.set_value(60, True) - response.close() + # Make sure there's no leftover file filling the storage + os.remove(temp_zip_path) + except Exception: + pass + try: + os.mkdir("tmp") + except Exception: + pass + temp_zip_path = "tmp/temp.mpk" + print(f"Downloading .mpk file from: {zip_url} to {temp_zip_path}") + result = await DownloadManager.download_url(zip_url, outfile=temp_zip_path, total_size=download_url_size, progress_callback=self.pcb) + if result is not True: + print("Download failed...") # Would be good to show an error to the user if this failed... + else: print("Downloaded .mpk file, size:", os.stat(temp_zip_path)[6], "bytes") - except Exception as e: - print("Download failed:", str(e)) - # Would be good to show error message here if it fails... - finally: - if 'response' in locals(): - response.close() - # Step 2: install it: - PackageManager.install_mpk(temp_zip_path, dest_folder) # ERROR: temp_zip_path might not be set if download failed! + # Install it: + PackageManager.install_mpk(temp_zip_path, dest_folder) # 60 until 90 percent is the unzip but no progress there... + self.progress_bar.set_value(90, True) + # Make sure there's no leftover file filling the storage: + try: + os.remove(temp_zip_path) + except Exception: + pass # Success: - time.sleep(1) # seems silly but otherwise it goes so quickly that the user can't tell something happened and gets confused + await TaskManager.sleep(1) # seems silly but otherwise it goes so quickly that the user can't tell something happened and gets confused self.progress_bar.set_value(100, False) self.progress_bar.add_flag(lv.obj.FLAG.HIDDEN) self.progress_bar.set_value(0, False) diff --git a/internal_filesystem/builtin/apps/com.micropythonos.osupdate/META-INF/MANIFEST.JSON b/internal_filesystem/builtin/apps/com.micropythonos.osupdate/META-INF/MANIFEST.JSON index 87781fec..e4d62404 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.osupdate/META-INF/MANIFEST.JSON +++ b/internal_filesystem/builtin/apps/com.micropythonos.osupdate/META-INF/MANIFEST.JSON @@ -3,10 +3,10 @@ "publisher": "MicroPythonOS", "short_description": "Operating System Updater", "long_description": "Updates the operating system in a safe way, to a secondary partition. After the update, the device is restarted. If the system starts up successfully, it is marked as valid and kept. Otherwise, a rollback to the old, primary partition is performed.", -"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.osupdate/icons/com.micropythonos.osupdate_0.0.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.0.11_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.osupdate/mpks/com.micropythonos.osupdate_0.0.11.mpk", "fullname": "com.micropythonos.osupdate", -"version": "0.0.10", +"version": "0.0.11", "category": "osupdate", "activities": [ { diff --git a/internal_filesystem/builtin/apps/com.micropythonos.osupdate/assets/osupdate.py b/internal_filesystem/builtin/apps/com.micropythonos.osupdate/assets/osupdate.py index ee5e6a6c..20b05794 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.osupdate/assets/osupdate.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.osupdate/assets/osupdate.py @@ -2,10 +2,9 @@ import requests import ujson import time -import _thread from mpos.apps import Activity -from mpos import PackageManager, ConnectivityManager +from mpos import PackageManager, ConnectivityManager, TaskManager, DownloadManager import mpos.info import mpos.ui @@ -21,6 +20,7 @@ class OSUpdate(Activity): main_screen = None progress_label = None progress_bar = None + speed_label = None # State management current_state = None @@ -128,12 +128,21 @@ def network_changed(self, online): elif self.current_state == UpdateState.IDLE or self.current_state == UpdateState.CHECKING_UPDATE: # Was checking for updates when network dropped self.set_state(UpdateState.WAITING_WIFI) + elif self.current_state == UpdateState.ERROR: + # Was in error state, might be network-related + # Update UI to show we're waiting for network + self.set_state(UpdateState.WAITING_WIFI) else: # Went online if self.current_state == UpdateState.IDLE or self.current_state == UpdateState.WAITING_WIFI: # Was waiting for network, now can check for updates self.set_state(UpdateState.CHECKING_UPDATE) self.schedule_show_update_info() + elif self.current_state == UpdateState.ERROR: + # Was in error state (possibly network error), retry now that network is back + print("OSUpdate: Retrying update check after network came back online") + self.set_state(UpdateState.CHECKING_UPDATE) + self.schedule_show_update_info() elif self.current_state == UpdateState.DOWNLOAD_PAUSED: # Download was paused, will auto-resume in download thread pass @@ -193,7 +202,7 @@ def show_update_info(self, timer=None): update_info["changelog"] ) except ValueError as e: - # JSON parsing or validation error + # JSON parsing or validation error (not network related) self.set_state(UpdateState.ERROR) self.status_label.set_text(self._get_user_friendly_error(e)) except RuntimeError as e: @@ -202,9 +211,15 @@ def show_update_info(self, timer=None): self.status_label.set_text(self._get_user_friendly_error(e)) except Exception as e: print(f"show_update_info got exception: {e}") - # Unexpected error - self.set_state(UpdateState.ERROR) - self.status_label.set_text(self._get_user_friendly_error(e)) + # Check if this is a network connectivity error + if self.update_downloader._is_network_error(e): + # Network not available - wait for it to come back + print("OSUpdate: Network error while checking for updates, waiting for WiFi") + self.set_state(UpdateState.WAITING_WIFI) + else: + # Other unexpected error + self.set_state(UpdateState.ERROR) + self.status_label.set_text(self._get_user_friendly_error(e)) def handle_update_info(self, version, download_url, changelog): self.download_update_url = download_url @@ -235,17 +250,20 @@ 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.align(lv.ALIGN.BOTTOM_MID, 0, -50) self.progress_bar.set_range(0, 100) self.progress_bar.set_value(0, False) - try: - _thread.stack_size(mpos.apps.good_stack_size()) - _thread.start_new_thread(self.update_with_lvgl, (self.download_update_url,)) - except Exception as e: - print("Could not start update_with_lvgl thread: ", e) + + # Use TaskManager instead of _thread for async download + TaskManager.create_task(self.perform_update()) def force_update_clicked(self): if self.download_update_url and (self.force_update.get_state() & lv.STATE.CHECKED): @@ -260,33 +278,59 @@ def check_again_click(self): self.set_state(UpdateState.CHECKING_UPDATE) self.schedule_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_ms(2000) + self.status_label.set_text("Update finished! Restarting...") + await TaskManager.sleep(5) self.update_downloader.set_boot_partition_and_restart() return @@ -299,8 +343,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 @@ -308,18 +351,20 @@ def update_with_lvgl(self, url): while elapsed < max_wait and self.has_foreground(): if self.connectivity_manager.is_online(): - print("OSUpdate: Network reconnected, resuming download") + print("OSUpdate: Network reconnected, waiting for stabilization...") + await TaskManager.sleep(2) # Let routing table and DNS fully stabilize + print("OSUpdate: Resuming download") self.set_state(UpdateState.DOWNLOADING) break # Exit wait loop and retry download - 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 @@ -327,32 +372,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): + """Handle update error result - extracted for DRY.""" + error_msg = result.get('error', 'Unknown error') + bytes_written = result.get('bytes_written', 0) + total_size = result.get('total_size', 0) + + if "cancelled" in error_msg.lower(): + msg = ("Update cancelled by user.\n\n" + f"{bytes_written}/{total_size} bytes downloaded.\n" + "Press 'Update OS' to resume.") + else: + # Use friendly error message + friendly_msg = self._get_user_friendly_error(Exception(error_msg)) + progress_info = f"\n\nProgress: {bytes_written}/{total_size} bytes" + if bytes_written > 0: + progress_info += "\n\nPress 'Update OS' to resume." + msg = friendly_msg + progress_info + + self.set_state(UpdateState.ERROR) + self.status_label.set_text(msg) + self.install_button.remove_state(lv.STATE.DISABLED) # allow retry + + def _handle_update_exception(self, e): + """Handle update exception - extracted for DRY.""" + msg = self._get_user_friendly_error(e) + "\n\nPress 'Update OS' to retry." + self.set_state(UpdateState.ERROR) + self.status_label.set_text(msg) + self.install_button.remove_state(lv.STATE.DISABLED) # allow retry # Business Logic Classes: @@ -369,19 +422,22 @@ 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 module for async downloads (defaults to mpos.DownloadManager) """ - self.requests = requests_module if requests_module else requests self.partition_module = partition_module self.connectivity_manager = connectivity_manager + self.download_manager = download_manager # For testing injection self.simulate = False # Download state for pause/resume @@ -389,6 +445,13 @@ def __init__(self, requests_module=None, partition_module=None, connectivity_man self.bytes_written_so_far = 0 self.total_size_expected = 0 + # Internal state for chunk processing + self._current_partition = None + self._block_index = 0 + self._chunk_buffer = b'' + self._should_continue = True + self._progress_callback = None + # Try to import Partition if not provided if self.partition_module is None: try: @@ -398,14 +461,116 @@ def __init__(self, requests_module=None, partition_module=None, connectivity_man print("UpdateDownloader: Partition module not available, will simulate") self.simulate = True - def download_and_install(self, url, progress_callback=None, should_continue_callback=None): - """Download firmware and install to OTA partition. + def _is_network_error(self, exception): + """Check if exception is a network connectivity error that should trigger pause. + + Args: + exception: Exception to check + + Returns: + bool: True if this is a recoverable network error + """ + error_str = str(exception).lower() + error_repr = repr(exception).lower() + + # Check for common network error codes and messages + # -113 = ECONNABORTED (connection aborted) + # -104 = ECONNRESET (connection reset by peer) + # -110 = ETIMEDOUT (connection timed out) + # -118 = EHOSTUNREACH (no route to host) + network_indicators = [ + '-113', '-104', '-110', '-118', # Error codes + 'econnaborted', 'econnreset', 'etimedout', 'ehostunreach', # Error names + 'connection reset', 'connection aborted', # Error messages + 'broken pipe', 'network unreachable', 'host unreachable' + ] + + return any(indicator in error_str or indicator in error_repr + for indicator in network_indicators) + + def _setup_partition(self): + """Initialize the OTA partition for writing.""" + if not self.simulate and self._current_partition is None: + current = self.partition_module(self.partition_module.RUNNING) + self._current_partition = current.get_next_update() + print(f"UpdateDownloader: Writing to partition: {self._current_partition}") + + async def _process_chunk(self, chunk): + """Process a downloaded chunk - buffer and write to partition. + + Note: Progress reporting is handled by DownloadManager, not here. + This method only handles buffering and writing to partition. + + Args: + chunk: bytes data received from download + """ + # Check if we should continue (user cancelled) + if not self._should_continue: + return + + # Check network connection + if self.connectivity_manager: + is_online = self.connectivity_manager.is_online() + elif ConnectivityManager._instance: + is_online = ConnectivityManager._instance.is_online() + else: + is_online = True + + if not is_online: + print("UpdateDownloader: Network lost during chunk processing") + self.is_paused = True + raise OSError(-113, "Network lost during download") + + # Track total bytes received + self._total_bytes_received += len(chunk) + + # Add chunk to buffer + self._chunk_buffer += chunk + + # Write complete 4096-byte blocks + while len(self._chunk_buffer) >= self.CHUNK_SIZE: + block = self._chunk_buffer[:self.CHUNK_SIZE] + self._chunk_buffer = self._chunk_buffer[self.CHUNK_SIZE:] + + if not self.simulate: + self._current_partition.writeblocks(self._block_index, block) + + self._block_index += 1 + self.bytes_written_so_far += len(block) + + # Note: Progress is reported by DownloadManager via progress_callback parameter + # We don't calculate progress here to avoid duplicate/incorrect progress updates + + async def _flush_buffer(self): + """Flush remaining buffer with padding to complete the download.""" + if self._chunk_buffer: + # Pad the last chunk to 4096 bytes + remaining = len(self._chunk_buffer) + padded = self._chunk_buffer + b'\xFF' * (self.CHUNK_SIZE - remaining) + print(f"UpdateDownloader: Padding final chunk from {remaining} to {self.CHUNK_SIZE} bytes") + + if not self.simulate: + self._current_partition.writeblocks(self._block_index, padded) + + self.bytes_written_so_far += self.CHUNK_SIZE + self._chunk_buffer = b'' + + # Final progress update + if self._progress_callback and self.total_size_expected > 0: + percent = (self.bytes_written_so_far / self.total_size_expected) * 100 + await self._progress_callback(min(percent, 100.0)) + + async def download_and_install(self, url, progress_callback=None, speed_callback=None, should_continue_callback=None): + """Download firmware and install to OTA partition using async DownloadManager. Supports pause/resume on wifi loss using HTTP Range headers. Args: url: URL to download firmware from - progress_callback: Optional 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 @@ -416,9 +581,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, @@ -428,107 +590,101 @@ 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 + headers = None if self.bytes_written_so_far > 0: - headers['Range'] = f'bytes={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}") - response = self.requests.get(url, stream=True, headers=headers) + # Get the download manager (use injected one for testing, or global) + dm = self.download_manager if self.download_manager else DownloadManager - # 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 (if monitoring enabled) - if self.connectivity_manager: - is_online = self.connectivity_manager.is_online() - elif ConnectivityManager._instance: - # Use global instance if available - is_online = ConnectivityManager._instance.is_online() - else: - # No connectivity checking available - is_online = True - - if not is_online: - print("UpdateDownloader: Network lost, 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 - chunk = response.raw.read(chunk_size) - 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: - result['error'] = str(e) - print(f"UpdateDownloader: Error during download: {e}") + error_msg = str(e) + + # Check if cancelled by user + if "cancelled" in error_msg.lower(): + result['error'] = error_msg + result['bytes_written'] = self.bytes_written_so_far + result['total_size'] = self.total_size_expected + # Check if this is a network error that should trigger pause + elif self._is_network_error(e): + print(f"UpdateDownloader: Network error ({e}), pausing download") + self.is_paused = True + result['paused'] = True + result['bytes_written'] = self.bytes_written_so_far + result['total_size'] = self.total_size_expected + else: + # Non-network error + result['error'] = error_msg + result['bytes_written'] = self.bytes_written_so_far + result['total_size'] = self.total_size_expected + print(f"UpdateDownloader: Error during download: {e}") return result 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..65bce842 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.settings/META-INF/MANIFEST.JSON +++ b/internal_filesystem/builtin/apps/com.micropythonos.settings/META-INF/MANIFEST.JSON @@ -3,10 +3,10 @@ "publisher": "MicroPythonOS", "short_description": "View and change MicroPythonOS settings.", "long_description": "This is the official settings app for MicroPythonOS. It allows you to configure all aspects of MicroPythonOS.", -"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.settings/icons/com.micropythonos.settings_0.0.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.0.9_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.settings/mpks/com.micropythonos.settings_0.0.9.mpk", "fullname": "com.micropythonos.settings", -"version": "0.0.8", +"version": "0.0.9", "category": "development", "activities": [ { diff --git a/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/calibrate_imu.py b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/calibrate_imu.py new file mode 100644 index 00000000..750fa5c3 --- /dev/null +++ b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/calibrate_imu.py @@ -0,0 +1,230 @@ +"""Calibrate IMU Activity. + +Guides user through IMU calibration process: +1. Show calibration instructions +2. Check stationarity when user clicks "Calibrate Now" +3. Perform calibration +4. Show results +""" + +import lvgl as lv +import time +import sys +from mpos.app.activity import Activity +import mpos.ui +import mpos.sensor_manager as SensorManager +from mpos.ui.testing import wait_for_render + + +class CalibrationState: + """Enum for calibration states.""" + READY = 0 + CALIBRATING = 1 + COMPLETE = 2 + ERROR = 3 + + +class CalibrateIMUActivity(Activity): + """Guide user through IMU calibration process.""" + + # State + current_state = CalibrationState.READY + calibration_thread = None + + # Widgets + title_label = None + status_label = None + detail_label = None + action_button = None + action_button_label = None + cancel_button = None + + def __init__(self): + super().__init__() + self.is_desktop = sys.platform != "esp32" + + def onCreate(self): + screen = lv.obj() + screen.set_style_pad_all(mpos.ui.pct_of_display_width(3), 0) + screen.set_flex_flow(lv.FLEX_FLOW.COLUMN) + screen.set_flex_align(lv.FLEX_ALIGN.CENTER, lv.FLEX_ALIGN.START, lv.FLEX_ALIGN.CENTER) + focusgroup = lv.group_get_default() + if focusgroup: + focusgroup.add_obj(screen) + + # Title + self.title_label = lv.label(screen) + self.title_label.set_text("IMU Calibration") + self.title_label.set_style_text_font(lv.font_montserrat_16, 0) + + # Status label + self.status_label = lv.label(screen) + self.status_label.set_text("Initializing...") + self.status_label.set_style_text_font(lv.font_montserrat_12, 0) + self.status_label.set_long_mode(lv.label.LONG_MODE.WRAP) + self.status_label.set_width(lv.pct(100)) + + # Detail label (for additional info) + self.detail_label = lv.label(screen) + self.detail_label.set_text("") + self.detail_label.set_style_text_font(lv.font_montserrat_10, 0) + self.detail_label.set_style_text_color(lv.color_hex(0x888888), 0) + self.detail_label.set_long_mode(lv.label.LONG_MODE.WRAP) + self.detail_label.set_width(lv.pct(90)) + + # Button container + btn_cont = lv.obj(screen) + btn_cont.set_width(lv.pct(100)) + btn_cont.set_height(lv.SIZE_CONTENT) + btn_cont.set_style_border_width(0, 0) + btn_cont.set_flex_flow(lv.FLEX_FLOW.ROW) + btn_cont.set_style_flex_main_place(lv.FLEX_ALIGN.SPACE_BETWEEN, 0) + + # Action button + self.action_button = lv.button(btn_cont) + self.action_button.set_size(lv.pct(45), lv.SIZE_CONTENT) + self.action_button_label = lv.label(self.action_button) + self.action_button_label.set_text("Start") + self.action_button_label.center() + self.action_button.add_event_cb(self.action_button_clicked, lv.EVENT.CLICKED, None) + + # Cancel button + self.cancel_button = lv.button(btn_cont) + self.cancel_button.set_size(lv.pct(45), lv.SIZE_CONTENT) + cancel_label = lv.label(self.cancel_button) + cancel_label.set_text("Cancel") + cancel_label.center() + self.cancel_button.add_event_cb(lambda e: self.finish(), lv.EVENT.CLICKED, None) + + self.setContentView(screen) + + def onResume(self, screen): + super().onResume(screen) + + # Check if IMU is available + if not self.is_desktop and not SensorManager.is_available(): + self.set_state(CalibrationState.ERROR) + self.status_label.set_text("IMU not available on this device") + self.action_button.add_state(lv.STATE.DISABLED) + return + + # Show calibration instructions + self.set_state(CalibrationState.READY) + + def onPause(self, screen): + # Stop any running calibration + if self.current_state == CalibrationState.CALIBRATING: + # Calibration will detect activity is no longer in foreground + pass + super().onPause(screen) + + def set_state(self, new_state): + """Update state and UI accordingly.""" + self.current_state = new_state + self.update_ui_for_state() + + def update_ui_for_state(self): + """Update UI based on current state.""" + if self.current_state == CalibrationState.READY: + self.status_label.set_text("Place device on flat, stable surface\n\nKeep device completely still during calibration") + self.detail_label.set_text("Calibration will take ~1 seconds\nUI will freeze during calibration") + self.action_button_label.set_text("Calibrate Now") + self.action_button.remove_state(lv.STATE.DISABLED) + self.cancel_button.remove_flag(lv.obj.FLAG.HIDDEN) + + elif self.current_state == CalibrationState.CALIBRATING: + self.status_label.set_text("Calibrating IMU...") + self.detail_label.set_text("Do not move device!") + self.action_button.add_state(lv.STATE.DISABLED) + self.cancel_button.add_flag(lv.obj.FLAG.HIDDEN) + + elif self.current_state == CalibrationState.COMPLETE: + # Status text will be set by calibration results + self.action_button_label.set_text("Done") + self.action_button.remove_state(lv.STATE.DISABLED) + self.cancel_button.add_flag(lv.obj.FLAG.HIDDEN) + + elif self.current_state == CalibrationState.ERROR: + # Status text will be set by error handler + self.action_button_label.set_text("Retry") + self.action_button.remove_state(lv.STATE.DISABLED) + self.cancel_button.add_flag(lv.obj.FLAG.HIDDEN) + + def action_button_clicked(self, event): + """Handle action button clicks based on current state.""" + if self.current_state == CalibrationState.READY: + self.start_calibration_process() + elif self.current_state == CalibrationState.COMPLETE: + self.finish() + elif self.current_state == CalibrationState.ERROR: + self.set_state(CalibrationState.READY) + + + def start_calibration_process(self): + """Start the calibration process. + + Note: Runs in main thread - UI will freeze during calibration (~2 seconds). + This avoids threading issues with I2C/sensor access. + """ + try: + # Step 1: Check stationarity + self.set_state(CalibrationState.CALIBRATING) + wait_for_render() # Let UI update + + if self.is_desktop: + stationarity = {'is_stationary': True, 'message': 'Mock: Stationary'} + else: + stationarity = SensorManager.check_stationarity(samples=25) + + if stationarity is None or not stationarity['is_stationary']: + msg = stationarity['message'] if stationarity else "Stationarity check failed" + self.handle_calibration_error( + f"Device not stationary!\n\n{msg}\n\nPlace on flat surface and try again.") + return + + # Step 2: Perform calibration + if self.is_desktop: + time.sleep(2) + accel_offsets = (0.1, -0.05, 0.15) + gyro_offsets = (0.2, -0.1, 0.05) + else: + # Real calibration - UI will freeze here + accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) + gyro = SensorManager.get_default_sensor(SensorManager.TYPE_GYROSCOPE) + + if accel: + accel_offsets = SensorManager.calibrate_sensor(accel, samples=50) + else: + accel_offsets = None + + if gyro: + gyro_offsets = SensorManager.calibrate_sensor(gyro, samples=50) + else: + gyro_offsets = None + + # Step 3: Show results + result_msg = "Calibration successful!" + if accel_offsets: + result_msg += f"\n\nAccel offsets: X:{accel_offsets[0]:.3f} Y:{accel_offsets[1]:.3f} Z:{accel_offsets[2]:.3f}" + if gyro_offsets: + result_msg += f"\n\nGyro offsets: X:{gyro_offsets[0]:.3f} Y:{gyro_offsets[1]:.3f} Z:{gyro_offsets[2]:.3f}" + + self.show_calibration_complete(result_msg) + + except Exception as e: + import sys + sys.print_exception(e) + self.handle_calibration_error(str(e)) + + def show_calibration_complete(self, result_msg): + """Show calibration completion message.""" + self.status_label.set_text(result_msg) + self.detail_label.set_text("Calibration saved to storage.") + self.set_state(CalibrationState.COMPLETE) + + def handle_calibration_error(self, error_msg): + """Handle error during calibration.""" + self.set_state(CalibrationState.ERROR) + self.status_label.set_text(f"Calibration failed:\n\n{error_msg}") + self.detail_label.set_text("") + diff --git a/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/check_imu_calibration.py b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/check_imu_calibration.py new file mode 100644 index 00000000..097aa75e --- /dev/null +++ b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/check_imu_calibration.py @@ -0,0 +1,268 @@ +"""Check IMU Calibration Activity. + +Shows current IMU calibration quality with real-time sensor values, +variance, expected value comparison, and overall quality score. +""" + +import lvgl as lv +import time +import sys +from mpos.app.activity import Activity +import mpos.ui +import mpos.sensor_manager as SensorManager + + +class CheckIMUCalibrationActivity(Activity): + """Display IMU calibration quality with real-time monitoring.""" + + # Update interval for real-time display (milliseconds) + UPDATE_INTERVAL = 100 + + # State + updating = False + update_timer = None + + # Widgets + status_label = None + quality_label = None + accel_labels = [] # [x_label, y_label, z_label] + gyro_labels = [] + issues_label = None + quality_score_label = None + + def __init__(self): + super().__init__() + self.is_desktop = sys.platform != "esp32" + + def onCreate(self): + screen = lv.obj() + screen.set_style_pad_all(mpos.ui.pct_of_display_width(1), 0) + #screen.set_style_pad_all(0, 0) + screen.set_flex_flow(lv.FLEX_FLOW.COLUMN) + focusgroup = lv.group_get_default() + if focusgroup: + focusgroup.add_obj(screen) + self.setContentView(screen) + + def onResume(self, screen): + super().onResume(screen) + + # Clear the screen and recreate UI (to avoid stale widget references) + screen.clean() + + # Reset widget lists + self.accel_labels = [] + self.gyro_labels = [] + + # Status label + self.status_label = lv.label(screen) + self.status_label.set_text("Checking...") + self.status_label.set_style_text_font(lv.font_montserrat_14, 0) + + # Separator + sep1 = lv.obj(screen) + sep1.set_size(lv.pct(100), 2) + sep1.set_style_bg_color(lv.color_hex(0x666666), 0) + + # Quality score (large, prominent) + self.quality_score_label = lv.label(screen) + self.quality_score_label.set_text("Quality: --") + self.quality_score_label.set_style_text_font(lv.font_montserrat_16, 0) + + data_cont = lv.obj(screen) + data_cont.set_width(lv.pct(100)) + data_cont.set_height(lv.SIZE_CONTENT) + data_cont.set_style_pad_all(0, 0) + data_cont.set_style_bg_opa(lv.OPA.TRANSP, 0) + data_cont.set_style_border_width(0, 0) + data_cont.set_flex_flow(lv.FLEX_FLOW.ROW) + data_cont.set_style_flex_main_place(lv.FLEX_ALIGN.SPACE_BETWEEN, 0) + + # Accelerometer section + acc_cont = lv.obj(data_cont) + acc_cont.set_height(lv.SIZE_CONTENT) + acc_cont.set_width(lv.pct(45)) + acc_cont.set_style_border_width(0, 0) + acc_cont.set_style_pad_all(0, 0) + acc_cont.set_flex_flow(lv.FLEX_FLOW.COLUMN) + + accel_title = lv.label(acc_cont) + accel_title.set_text("Accel. (m/s^2)") + accel_title.set_style_text_font(lv.font_montserrat_12, 0) + + for axis in ['X', 'Y', 'Z']: + label = lv.label(acc_cont) + label.set_text(f"{axis}: --") + label.set_style_text_font(lv.font_montserrat_10, 0) + self.accel_labels.append(label) + + # Gyroscope section + gyro_cont = lv.obj(data_cont) + gyro_cont.set_width(mpos.ui.pct_of_display_width(45)) + gyro_cont.set_height(lv.SIZE_CONTENT) + gyro_cont.set_style_border_width(0, 0) + gyro_cont.set_style_pad_all(0, 0) + gyro_cont.set_flex_flow(lv.FLEX_FLOW.COLUMN) + + gyro_title = lv.label(gyro_cont) + gyro_title.set_text("Gyro (deg/s)") + gyro_title.set_style_text_font(lv.font_montserrat_12, 0) + + for axis in ['X', 'Y', 'Z']: + label = lv.label(gyro_cont) + label.set_text(f"{axis}: --") + label.set_style_text_font(lv.font_montserrat_10, 0) + self.gyro_labels.append(label) + + # Separator + #sep2 = lv.obj(screen) + #sep2.set_size(lv.pct(100), 2) + #sep2.set_style_bg_color(lv.color_hex(0x666666), 0) + + # Issues label + self.issues_label = lv.label(screen) + self.issues_label.set_text("Issues: None") + self.issues_label.set_style_text_font(lv.font_montserrat_12, 0) + self.issues_label.set_style_text_color(lv.color_hex(0xFF6666), 0) + self.issues_label.set_long_mode(lv.label.LONG_MODE.WRAP) + self.issues_label.set_width(lv.pct(95)) + + # Button container + btn_cont = lv.obj(screen) + btn_cont.set_style_pad_all(5, 0) + btn_cont.set_width(lv.pct(100)) + btn_cont.set_height(lv.SIZE_CONTENT) + btn_cont.set_style_border_width(0, 0) + btn_cont.set_flex_flow(lv.FLEX_FLOW.ROW) + btn_cont.set_style_flex_main_place(lv.FLEX_ALIGN.SPACE_BETWEEN, 0) + + # Back button + back_btn = lv.button(btn_cont) + back_btn.set_size(lv.pct(45), lv.SIZE_CONTENT) + back_label = lv.label(back_btn) + back_label.set_text("Back") + back_label.center() + back_btn.add_event_cb(lambda e: self.finish(), lv.EVENT.CLICKED, None) + + # Calibrate button + calibrate_btn = lv.button(btn_cont) + calibrate_btn.set_size(lv.pct(45), lv.SIZE_CONTENT) + calibrate_label = lv.label(calibrate_btn) + calibrate_label.set_text("Calibrate") + calibrate_label.center() + calibrate_btn.add_event_cb(self.start_calibration, lv.EVENT.CLICKED, None) + + # Check if IMU is available + if not self.is_desktop and not SensorManager.is_available(): + self.status_label.set_text("IMU not available on this device") + self.quality_score_label.set_text("N/A") + return + + # Start real-time updates + self.updating = True + self.update_timer = lv.timer_create(self.update_display, self.UPDATE_INTERVAL, None) + + def onPause(self, screen): + # Stop updates + self.updating = False + if self.update_timer: + self.update_timer.delete() + self.update_timer = None + super().onPause(screen) + + def update_display(self, timer=None): + """Update display with current sensor values and quality.""" + if not self.updating: + return + + try: + # Get quality check (desktop or hardware) + if self.is_desktop: + quality = self.get_mock_quality() + else: + # Use only 5 samples for real-time display (faster, less blocking) + quality = SensorManager.check_calibration_quality(samples=5) + + if quality is None: + self.status_label.set_text("Error reading IMU") + return + + # Update quality score + score = quality['quality_score'] + rating = quality['quality_rating'] + self.quality_score_label.set_text(f"Quality: {rating} ({score*100:.0f}%)") + + # Color based on rating + if rating == "Good": + color = 0x66FF66 # Green + elif rating == "Fair": + color = 0xFFFF66 # Yellow + else: + color = 0xFF6666 # Red + self.quality_score_label.set_style_text_color(lv.color_hex(color), 0) + + # Update accelerometer values + accel_mean = quality['accel_mean'] + accel_var = quality['accel_variance'] + for i, (mean, var) in enumerate(zip(accel_mean, accel_var)): + axis = ['X', 'Y', 'Z'][i] + self.accel_labels[i].set_text(f"{axis}: {mean:6.2f} (var: {var:.3f})") + + # Update gyroscope values + gyro_mean = quality['gyro_mean'] + gyro_var = quality['gyro_variance'] + for i, (mean, var) in enumerate(zip(gyro_mean, gyro_var)): + axis = ['X', 'Y', 'Z'][i] + self.gyro_labels[i].set_text(f"{axis}: {mean:6.2f} (var: {var:.3f})") + + # Update issues + issues = quality['issues'] + if issues: + issues_text = "Issues:\n" + "\n".join(f"- {issue}" for issue in issues) + else: + issues_text = "Issues: None - calibration looks good!" + self.issues_label.set_text(issues_text) + + self.status_label.set_text("Real-time monitoring (place on flat surface)") + except Exception as e: + # If widgets were deleted (activity closed), stop updating silently + self.updating = False + + def get_mock_quality(self): + """Generate mock quality data for desktop testing.""" + import random + + # Simulate good calibration with small random noise + return { + 'accel_mean': ( + random.uniform(-0.2, 0.2), + random.uniform(-0.2, 0.2), + 9.8 + random.uniform(-0.3, 0.3) + ), + 'accel_variance': ( + random.uniform(0.01, 0.1), + random.uniform(0.01, 0.1), + random.uniform(0.01, 0.1) + ), + 'gyro_mean': ( + random.uniform(-0.5, 0.5), + random.uniform(-0.5, 0.5), + random.uniform(-0.5, 0.5) + ), + 'gyro_variance': ( + random.uniform(0.1, 1.0), + random.uniform(0.1, 1.0), + random.uniform(0.1, 1.0) + ), + 'quality_score': random.uniform(0.75, 0.95), + 'quality_rating': "Good", + 'issues': [] + } + + def start_calibration(self, event): + """Navigate to calibration activity.""" + from mpos.content.intent import Intent + from calibrate_imu import CalibrateIMUActivity + + intent = Intent(activity_class=CalibrateIMUActivity) + self.startActivity(intent) diff --git a/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py index 37b84e5a..05acca6a 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py @@ -1,3 +1,4 @@ +import lvgl as lv from mpos.apps import Activity, Intent from mpos.activity_navigator import ActivityNavigator @@ -7,6 +8,10 @@ import mpos.ui import mpos.time +# Import IMU calibration activities +from check_imu_calibration import CheckIMUCalibrationActivity +from calibrate_imu import CalibrateIMUActivity + # Used to list and edit all settings: class SettingsActivity(Activity): def __init__(self): @@ -38,12 +43,16 @@ def __init__(self): ("Turquoise", "40e0d0") ] self.settings = [ - # Novice settings, alphabetically: + # Basic settings, alphabetically: {"title": "Light/Dark Theme", "key": "theme_light_dark", "value_label": None, "cont": None, "ui": "radiobuttons", "ui_options": [("Light", "light"), ("Dark", "dark")]}, {"title": "Theme Color", "key": "theme_primary_color", "value_label": None, "cont": None, "placeholder": "HTML hex color, like: EC048C", "ui": "dropdown", "ui_options": theme_colors}, {"title": "Timezone", "key": "timezone", "value_label": None, "cont": None, "ui": "dropdown", "ui_options": self.get_timezone_tuples(), "changed_callback": lambda : mpos.time.refresh_timezone_preference()}, # Advanced settings, alphabetically: + #{"title": "Audio Output Device", "key": "audio_device", "value_label": None, "cont": None, "ui": "radiobuttons", "ui_options": [("Auto-detect", "auto"), ("I2S (Digital Audio)", "i2s"), ("Buzzer (PWM Tones)", "buzzer"), ("Both I2S and Buzzer", "both"), ("Disabled", "null")], "changed_callback": self.audio_device_changed}, {"title": "Auto Start App", "key": "auto_start_app", "value_label": None, "cont": None, "ui": "radiobuttons", "ui_options": [(app.name, app.fullname) for app in PackageManager.get_app_list()]}, + {"title": "Check IMU Calibration", "key": "check_imu_calibration", "value_label": None, "cont": None, "ui": "activity", "activity_class": "CheckIMUCalibrationActivity"}, + {"title": "Calibrate IMU", "key": "calibrate_imu", "value_label": None, "cont": None, "ui": "activity", "activity_class": "CalibrateIMUActivity"}, + # Expert settings, alphabetically {"title": "Restart to Bootloader", "key": "boot_mode", "value_label": None, "cont": None, "ui": "radiobuttons", "ui_options": [("Normal", "normal"), ("Bootloader", "bootloader")]}, # special that doesn't get saved {"title": "Format internal data partition", "key": "format_internal_data_partition", "value_label": None, "cont": None, "ui": "radiobuttons", "ui_options": [("No, do not format", "no"), ("Yes, erase all settings, files and non-builtin apps", "yes")]}, # special that doesn't get saved # This is currently only in the drawer but would make sense to have it here for completeness: @@ -103,6 +112,20 @@ def onResume(self, screen): focusgroup.add_obj(setting_cont) def startSettingActivity(self, setting): + ui_type = setting.get("ui") + + # Handle activity-based settings (NEW) + if ui_type == "activity": + activity_class_name = setting.get("activity_class") + if activity_class_name == "CheckIMUCalibrationActivity": + intent = Intent(activity_class=CheckIMUCalibrationActivity) + self.startActivity(intent) + elif activity_class_name == "CalibrateIMUActivity": + intent = Intent(activity_class=CalibrateIMUActivity) + self.startActivity(intent) + return + + # Handle traditional settings (existing code) intent = Intent(activity_class=SettingActivity) intent.putExtra("setting", setting) self.startActivity(intent) @@ -111,6 +134,34 @@ def startSettingActivity(self, setting): def get_timezone_tuples(): return [(tz, tz) for tz in mpos.time.get_timezones()] + def audio_device_changed(self): + """ + Called when audio device setting changes. + Note: Changing device type at runtime requires a restart for full effect. + AudioFlinger initialization happens at boot. + """ + import mpos.audio.audioflinger as AudioFlinger + + new_value = self.prefs.get_string("audio_device", "auto") + print(f"Audio device setting changed to: {new_value}") + print("Note: Restart required for audio device change to take effect") + + # Map setting values to device types + device_map = { + "auto": AudioFlinger.get_device_type(), # Keep current + "i2s": AudioFlinger.DEVICE_I2S, + "buzzer": AudioFlinger.DEVICE_BUZZER, + "both": AudioFlinger.DEVICE_BOTH, + "null": AudioFlinger.DEVICE_NULL, + } + + desired_device = device_map.get(new_value, AudioFlinger.get_device_type()) + current_device = AudioFlinger.get_device_type() + + if desired_device != current_device: + print(f"Desired device type ({desired_device}) differs from current ({current_device})") + print("Full device type change requires restart - current session continues with existing device") + def focus_container(self, container): print(f"container {container} focused, setting border...") container.set_style_border_color(lv.theme_get_color_primary(None),lv.PART.MAIN) @@ -260,18 +311,18 @@ def radio_event_handler(self, event): 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: - print("it's not checked, nothing to do!") + if self.active_radio_index == current_checkbox_index: + print(f"unchecking {current_checkbox_index}") + self.active_radio_index = -1 # nothing checked return else: - new_checked = target_obj.get_index() - print(f"new_checked: {new_checked}") - if self.active_radio_index >= 0: + 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) - new_checked_obj = self.radio_container.get_child(new_checked) - new_checked_obj.add_state(lv.STATE.CHECKED) - self.active_radio_index = new_checked + self.active_radio_index = current_checkbox_index def create_radio_button(self, parent, text, index): cb = lv.checkbox(parent) diff --git a/internal_filesystem/builtin/apps/com.micropythonos.wifi/META-INF/MANIFEST.JSON b/internal_filesystem/builtin/apps/com.micropythonos.wifi/META-INF/MANIFEST.JSON index 0c09327e..6e23afc4 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.wifi/META-INF/MANIFEST.JSON +++ b/internal_filesystem/builtin/apps/com.micropythonos.wifi/META-INF/MANIFEST.JSON @@ -3,10 +3,10 @@ "publisher": "MicroPythonOS", "short_description": "WiFi Network Configuration", "long_description": "Scans for wireless networks, shows a list of SSIDs, allows for password entry, and connecting.", -"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.wifi/icons/com.micropythonos.wifi_0.0.10_64x64.png", -"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.wifi/mpks/com.micropythonos.wifi_0.0.10.mpk", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.wifi/icons/com.micropythonos.wifi_0.0.11_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.wifi/mpks/com.micropythonos.wifi_0.0.11.mpk", "fullname": "com.micropythonos.wifi", -"version": "0.0.10", +"version": "0.0.11", "category": "networking", "activities": [ { diff --git a/internal_filesystem/builtin/apps/com.micropythonos.wifi/assets/wifi.py b/internal_filesystem/builtin/apps/com.micropythonos.wifi/assets/wifi.py index 3b98029c..82aeab89 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.wifi/assets/wifi.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.wifi/assets/wifi.py @@ -160,7 +160,7 @@ def select_ssid_cb(self,ssid): def password_page_result_cb(self, result): print(f"PasswordPage finished, result: {result}") - if result.get("result_code"): + if result.get("result_code") is True: data = result.get("data") if data: self.start_attempt_connecting(data.get("ssid"), data.get("password")) @@ -237,7 +237,6 @@ def onCreate(self): 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) - self.password_ta.add_event_cb(lambda *args: self.show_keyboard(), lv.EVENT.CLICKED, None) print("PasswordPage: Creating Connect button") self.connect_button=lv.button(password_page) self.connect_button.set_size(100,40) @@ -262,16 +261,10 @@ def onCreate(self): self.keyboard=MposKeyboard(password_page) self.keyboard.align(lv.ALIGN.BOTTOM_MID,0,0) self.keyboard.set_textarea(self.password_ta) - self.keyboard.add_event_cb(lambda *args: self.hide_keyboard(), lv.EVENT.READY, None) - self.keyboard.add_event_cb(lambda *args: self.hide_keyboard(), lv.EVENT.CANCEL, None) self.keyboard.add_flag(lv.obj.FLAG.HIDDEN) - self.keyboard.add_event_cb(self.handle_keyboard_events, lv.EVENT.VALUE_CHANGED, None) print("PasswordPage: Loading password page") self.setContentView(password_page) - def onStop(self, screen): - self.hide_keyboard() - def connect_cb(self, event): global access_points print("connect_cb: Connect button clicked") @@ -290,28 +283,6 @@ def cancel_cb(self, event): print("cancel_cb: Cancel button clicked") self.finish() - def show_keyboard(self): - self.connect_button.add_flag(lv.obj.FLAG.HIDDEN) - self.cancel_button.add_flag(lv.obj.FLAG.HIDDEN) - mpos.ui.anim.smooth_show(self.keyboard) - focusgroup = lv.group_get_default() - if focusgroup: - focusgroup.focus_next() # move the focus to the keyboard to save the user a "next" button press (optional but nice) - - def hide_keyboard(self): - mpos.ui.anim.smooth_hide(self.keyboard) - self.connect_button.remove_flag(lv.obj.FLAG.HIDDEN) - self.cancel_button.remove_flag(lv.obj.FLAG.HIDDEN) - - def handle_keyboard_events(self, event): - target_obj=event.get_target_obj() # keyboard - button = target_obj.get_selected_button() - text = target_obj.get_button_text(button) - #print(f"button {button} and text {text}") - if text == lv.SYMBOL.NEW_LINE: - print("Newline pressed, closing the keyboard...") - self.hide_keyboard() - @staticmethod def setPassword(ssid, password): global access_points diff --git a/internal_filesystem/lib/README.md b/internal_filesystem/lib/README.md index 078e0c71..a5d0eafc 100644 --- a/internal_filesystem/lib/README.md +++ b/internal_filesystem/lib/README.md @@ -9,4 +9,5 @@ This /lib folder contains: - mip.install("collections") # used by aiohttp - mip.install("unittest") - mip.install("logging") +- mip.install("aiorepl") diff --git a/internal_filesystem/lib/aiorepl.mpy b/internal_filesystem/lib/aiorepl.mpy new file mode 100644 index 00000000..a8689549 Binary files /dev/null and b/internal_filesystem/lib/aiorepl.mpy differ diff --git a/internal_filesystem/lib/mpos/__init__.py b/internal_filesystem/lib/mpos/__init__.py index 6111795a..0746708d 100644 --- a/internal_filesystem/lib/mpos/__init__.py +++ b/internal_filesystem/lib/mpos/__init__.py @@ -2,9 +2,11 @@ from .app.app import App from .app.activity import Activity from .net.connectivity_manager import ConnectivityManager +from .net import download_manager as DownloadManager from .content.intent import Intent from .activity_navigator import ActivityNavigator from .content.package_manager import PackageManager +from .task_manager import TaskManager # Common activities (optional) from .app.activities.chooser import ChooserActivity @@ -12,7 +14,7 @@ from .app.activities.share import ShareActivity __all__ = [ - "App", "Activity", "ConnectivityManager", "Intent", - "ActivityNavigator", "PackageManager", + "App", "Activity", "ConnectivityManager", "DownloadManager", "Intent", + "ActivityNavigator", "PackageManager", "TaskManager", "ChooserActivity", "ViewActivity", "ShareActivity" ] diff --git a/internal_filesystem/lib/mpos/app/activity.py b/internal_filesystem/lib/mpos/app/activity.py index c8373710..e0cd71c2 100644 --- a/internal_filesystem/lib/mpos/app/activity.py +++ b/internal_filesystem/lib/mpos/app/activity.py @@ -73,10 +73,12 @@ def task_handler_callback(self, a, b): self.throttle_async_call_counter = 0 # Execute a function if the Activity is in the foreground - def if_foreground(self, func, *args, **kwargs): + def if_foreground(self, func, *args, event=None, **kwargs): if self._has_foreground: #print(f"executing {func} with args {args} and kwargs {kwargs}") result = func(*args, **kwargs) + if event: + event.set() return result else: #print(f"[if_foreground] Skipped {func} because _has_foreground=False") @@ -86,11 +88,11 @@ def if_foreground(self, func, *args, **kwargs): # 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): + def update_ui_threadsafe_if_foreground(self, func, *args, important=False, event=None, **kwargs): self.throttle_async_call_counter += 1 if not important and self.throttle_async_call_counter > 100: # 250 seems to be okay, so 100 is on the safe side print(f"update_ui_threadsafe_if_foreground called more than 100 times for one UI frame, which can overflow - throttling!") return None # lv.async_call() is needed to update the UI from another thread than the main one (as LVGL is not thread safe) - result = lv.async_call(lambda _: self.if_foreground(func, *args, **kwargs),None) + result = lv.async_call(lambda _: self.if_foreground(func, *args, event=event, **kwargs), None) return result diff --git a/internal_filesystem/lib/mpos/apps.py b/internal_filesystem/lib/mpos/apps.py index 366d914e..551e811a 100644 --- a/internal_filesystem/lib/mpos/apps.py +++ b/internal_filesystem/lib/mpos/apps.py @@ -10,7 +10,7 @@ from mpos.content.package_manager import PackageManager def good_stack_size(): - stacksize = 24*1024 + stacksize = 24*1024 # less than 20KB crashes on desktop when doing heavy apps, like LightningPiggy's Wallet connections import sys if sys.platform == "esp32": stacksize = 16*1024 @@ -37,7 +37,7 @@ def execute_script(script_source, is_file, cwd=None, classname=None): } print(f"Thread {thread_id}: starting script") import sys - path_before = sys.path + path_before = sys.path[:] # Make a copy, not a reference if cwd: sys.path.append(cwd) try: @@ -74,8 +74,10 @@ def execute_script(script_source, is_file, cwd=None, classname=None): tb = getattr(e, '__traceback__', None) traceback.print_exception(type(e), e, tb) return False - print(f"Thread {thread_id}: script {compile_name} finished, restoring sys.path to {sys.path}") - sys.path = path_before + 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:") diff --git a/internal_filesystem/lib/mpos/audio/__init__.py b/internal_filesystem/lib/mpos/audio/__init__.py new file mode 100644 index 00000000..37be5058 --- /dev/null +++ b/internal_filesystem/lib/mpos/audio/__init__.py @@ -0,0 +1,60 @@ +# AudioFlinger - Centralized Audio Management Service for MicroPythonOS +# Android-inspired audio routing with priority-based audio focus +# Simple routing: play_wav() -> I2S, play_rtttl() -> buzzer, record_wav() -> I2S mic + +from . import audioflinger + +# Re-export main API +from .audioflinger import ( + # Stream types (for priority-based audio focus) + STREAM_MUSIC, + STREAM_NOTIFICATION, + STREAM_ALARM, + + # Core playback functions + init, + play_wav, + play_rtttl, + stop, + pause, + resume, + set_volume, + get_volume, + is_playing, + + # Recording functions + record_wav, + is_recording, + + # Hardware availability checks + has_i2s, + has_buzzer, + has_microphone, +) + +__all__ = [ + # Stream types + 'STREAM_MUSIC', + 'STREAM_NOTIFICATION', + 'STREAM_ALARM', + + # Playback functions + 'init', + 'play_wav', + 'play_rtttl', + 'stop', + 'pause', + 'resume', + 'set_volume', + 'get_volume', + 'is_playing', + + # Recording functions + 'record_wav', + 'is_recording', + + # Hardware checks + 'has_i2s', + 'has_buzzer', + 'has_microphone', +] diff --git a/internal_filesystem/lib/mpos/audio/audioflinger.py b/internal_filesystem/lib/mpos/audio/audioflinger.py new file mode 100644 index 00000000..031c3956 --- /dev/null +++ b/internal_filesystem/lib/mpos/audio/audioflinger.py @@ -0,0 +1,370 @@ +# AudioFlinger - Core Audio Management Service +# Centralized audio routing with priority-based audio focus (Android-inspired) +# Supports I2S (digital audio) and PWM buzzer (tones/ringtones) +# +# Simple routing: play_wav() -> I2S, play_rtttl() -> buzzer, record_wav() -> I2S mic +# Uses _thread for non-blocking background playback/recording (separate thread from UI) + +import _thread +import mpos.apps + +# Stream type constants (priority order: higher number = higher priority) +STREAM_MUSIC = 0 # Background music (lowest priority) +STREAM_NOTIFICATION = 1 # Notification sounds (medium priority) +STREAM_ALARM = 2 # Alarms/alerts (highest priority) + +# Module-level state (singleton pattern, follows battery_voltage.py) +_i2s_pins = None # I2S pin configuration dict (created per-stream) +_buzzer_instance = None # PWM buzzer instance +_current_stream = None # Currently playing stream +_current_recording = None # Currently recording stream +_volume = 50 # System volume (0-100) + + +def init(i2s_pins=None, buzzer_instance=None): + """ + Initialize AudioFlinger with hardware configuration. + + Args: + i2s_pins: Dict with 'sck', 'ws', 'sd' pin numbers (for I2S/WAV playback) + buzzer_instance: PWM instance for buzzer (for RTTTL playback) + """ + global _i2s_pins, _buzzer_instance + + _i2s_pins = i2s_pins + _buzzer_instance = buzzer_instance + + # Build status message + capabilities = [] + if i2s_pins: + capabilities.append("I2S (WAV)") + if buzzer_instance: + capabilities.append("Buzzer (RTTTL)") + + if capabilities: + print(f"AudioFlinger initialized: {', '.join(capabilities)}") + else: + print("AudioFlinger initialized: No audio hardware") + + +def has_i2s(): + """Check if I2S audio is available for WAV playback.""" + return _i2s_pins is not None + + +def has_buzzer(): + """Check if buzzer is available for RTTTL playback.""" + return _buzzer_instance is not None + + +def has_microphone(): + """Check if I2S microphone is available for recording.""" + return _i2s_pins is not None and 'sd_in' in _i2s_pins + + +def _check_audio_focus(stream_type): + """ + Check if a stream with the given type can start playback. + Implements priority-based audio focus (Android-inspired). + + Args: + stream_type: Stream type (STREAM_MUSIC, STREAM_NOTIFICATION, STREAM_ALARM) + + Returns: + bool: True if stream can start, False if rejected + """ + global _current_stream + + if not _current_stream: + return True # No stream playing, OK to start + + if not _current_stream.is_playing(): + return True # Current stream finished, OK to start + + # Check priority + if stream_type <= _current_stream.stream_type: + print(f"AudioFlinger: Stream rejected (priority {stream_type} <= current {_current_stream.stream_type})") + return False + + # Higher priority stream - interrupt current + print(f"AudioFlinger: Interrupting stream (priority {stream_type} > current {_current_stream.stream_type})") + _current_stream.stop() + return True + + +def _playback_thread(stream): + """ + Thread function for audio playback. + Runs in a separate thread to avoid blocking the UI. + + Args: + stream: Stream instance (WAVStream or RTTTLStream) + """ + global _current_stream + + _current_stream = stream + + try: + # Run synchronous playback in this thread + stream.play() + except Exception as e: + print(f"AudioFlinger: Playback error: {e}") + finally: + # Clear current stream + if _current_stream == stream: + _current_stream = None + + +def play_wav(file_path, stream_type=STREAM_MUSIC, volume=None, on_complete=None): + """ + Play WAV file via I2S. + + Args: + file_path: Path to WAV file (e.g., "M:/sdcard/music/song.wav") + stream_type: Stream type (STREAM_MUSIC, STREAM_NOTIFICATION, STREAM_ALARM) + volume: Override volume (0-100), or None to use system volume + on_complete: Callback function(message) called when playback finishes + + Returns: + bool: True if playback started, False if rejected or unavailable + """ + if not _i2s_pins: + print("AudioFlinger: play_wav() failed - I2S not configured") + return False + + # Check audio focus + if not _check_audio_focus(stream_type): + return False + + # Create stream and start playback in separate thread + try: + from mpos.audio.stream_wav import WAVStream + + stream = WAVStream( + file_path=file_path, + stream_type=stream_type, + volume=volume if volume is not None else _volume, + i2s_pins=_i2s_pins, + on_complete=on_complete + ) + + _thread.stack_size(mpos.apps.good_stack_size()) + _thread.start_new_thread(_playback_thread, (stream,)) + return True + + except Exception as e: + print(f"AudioFlinger: play_wav() failed: {e}") + return False + + +def play_rtttl(rtttl_string, stream_type=STREAM_NOTIFICATION, volume=None, on_complete=None): + """ + Play RTTTL ringtone via buzzer. + + Args: + rtttl_string: RTTTL format string (e.g., "Nokia:d=4,o=5,b=225:8e6,8d6...") + stream_type: Stream type (STREAM_MUSIC, STREAM_NOTIFICATION, STREAM_ALARM) + volume: Override volume (0-100), or None to use system volume + on_complete: Callback function(message) called when playback finishes + + Returns: + bool: True if playback started, False if rejected or unavailable + """ + if not _buzzer_instance: + print("AudioFlinger: play_rtttl() failed - buzzer not configured") + return False + + # Check audio focus + if not _check_audio_focus(stream_type): + return False + + # Create stream and start playback in separate thread + try: + from mpos.audio.stream_rtttl import RTTTLStream + + stream = RTTTLStream( + rtttl_string=rtttl_string, + stream_type=stream_type, + volume=volume if volume is not None else _volume, + buzzer_instance=_buzzer_instance, + on_complete=on_complete + ) + + _thread.stack_size(mpos.apps.good_stack_size()) + _thread.start_new_thread(_playback_thread, (stream,)) + return True + + except Exception as e: + print(f"AudioFlinger: play_rtttl() failed: {e}") + return False + + +def _recording_thread(stream): + """ + Thread function for audio recording. + Runs in a separate thread to avoid blocking the UI. + + Args: + stream: RecordStream instance + """ + global _current_recording + + _current_recording = stream + + try: + # Run synchronous recording in this thread + stream.record() + except Exception as e: + print(f"AudioFlinger: Recording error: {e}") + finally: + # Clear current recording + if _current_recording == stream: + _current_recording = None + + +def record_wav(file_path, duration_ms=None, on_complete=None, sample_rate=16000): + """ + Record audio from I2S microphone to WAV file. + + Args: + file_path: Path to save WAV file (e.g., "data/recording.wav") + duration_ms: Recording duration in milliseconds (None = 60 seconds default) + on_complete: Callback function(message) when recording finishes + sample_rate: Sample rate in Hz (default 16000 for voice) + + Returns: + bool: True if recording started, False if rejected or unavailable + """ + print(f"AudioFlinger.record_wav() called") + print(f" file_path: {file_path}") + print(f" duration_ms: {duration_ms}") + print(f" sample_rate: {sample_rate}") + print(f" _i2s_pins: {_i2s_pins}") + print(f" has_microphone(): {has_microphone()}") + + if not has_microphone(): + print("AudioFlinger: record_wav() failed - microphone not configured") + return False + + # Cannot record while playing (I2S can only be TX or RX, not both) + if is_playing(): + print("AudioFlinger: Cannot record while playing") + return False + + # Cannot start new recording while already recording + if is_recording(): + print("AudioFlinger: Already recording") + return False + + # Create stream and start recording in separate thread + try: + print("AudioFlinger: Importing RecordStream...") + from mpos.audio.stream_record import RecordStream + + print("AudioFlinger: Creating RecordStream instance...") + stream = RecordStream( + file_path=file_path, + duration_ms=duration_ms, + sample_rate=sample_rate, + i2s_pins=_i2s_pins, + on_complete=on_complete + ) + + print("AudioFlinger: Starting recording thread...") + _thread.stack_size(mpos.apps.good_stack_size()) + _thread.start_new_thread(_recording_thread, (stream,)) + print("AudioFlinger: Recording thread started successfully") + return True + + except Exception as e: + import sys + print(f"AudioFlinger: record_wav() failed: {e}") + sys.print_exception(e) + return False + + +def stop(): + """Stop current audio playback or recording.""" + global _current_stream, _current_recording + + stopped = False + + if _current_stream: + _current_stream.stop() + print("AudioFlinger: Playback stopped") + stopped = True + + if _current_recording: + _current_recording.stop() + print("AudioFlinger: Recording stopped") + stopped = True + + if not stopped: + print("AudioFlinger: No playback or recording to stop") + + +def pause(): + """ + Pause current audio playback (if supported by stream). + Note: Most streams don't support pause, only stop. + """ + if _current_stream and hasattr(_current_stream, 'pause'): + _current_stream.pause() + print("AudioFlinger: Playback paused") + else: + print("AudioFlinger: Pause not supported or no playback active") + + +def resume(): + """ + Resume paused audio playback (if supported by stream). + Note: Most streams don't support resume, only play. + """ + if _current_stream and hasattr(_current_stream, 'resume'): + _current_stream.resume() + print("AudioFlinger: Playback resumed") + else: + print("AudioFlinger: Resume not supported or no playback active") + + +def set_volume(volume): + """ + Set system volume (affects new streams, not current playback). + + Args: + volume: Volume level (0-100) + """ + global _volume + _volume = max(0, min(100, volume)) + if _current_stream: + _current_stream.set_volume(_volume) + + +def get_volume(): + """ + Get system volume. + + Returns: + int: Current system volume (0-100) + """ + return _volume + + +def is_playing(): + """ + Check if audio is currently playing. + + Returns: + bool: True if playback active, False otherwise + """ + return _current_stream is not None and _current_stream.is_playing() + + +def is_recording(): + """ + Check if audio is currently being recorded. + + Returns: + bool: True if recording active, False otherwise + """ + return _current_recording is not None and _current_recording.is_recording() diff --git a/internal_filesystem/lib/mpos/audio/stream_record.py b/internal_filesystem/lib/mpos/audio/stream_record.py new file mode 100644 index 00000000..3a4990f7 --- /dev/null +++ b/internal_filesystem/lib/mpos/audio/stream_record.py @@ -0,0 +1,349 @@ +# RecordStream - WAV File Recording Stream for AudioFlinger +# Records 16-bit mono PCM audio from I2S microphone to WAV file +# Uses synchronous recording in a separate thread for non-blocking operation +# On desktop (no I2S hardware), generates a 440Hz sine wave for testing + +import math +import os +import sys +import time + +# Try to import machine module (not available on desktop) +try: + import machine + _HAS_MACHINE = True +except ImportError: + _HAS_MACHINE = False + + +def _makedirs(path): + """ + Create directory and all parent directories (like os.makedirs). + MicroPython doesn't have os.makedirs, so we implement it manually. + """ + if not path: + return + + parts = path.split('/') + current = '' + + for part in parts: + if not part: + continue + current = current + '/' + part if current else part + try: + os.mkdir(current) + except OSError: + pass # Directory may already exist + + +class RecordStream: + """ + WAV file recording stream with I2S input. + Records 16-bit mono PCM audio from I2S microphone. + """ + + # Default recording parameters + DEFAULT_SAMPLE_RATE = 16000 # 16kHz - good for voice + DEFAULT_MAX_DURATION_MS = 60000 # 60 seconds max + DEFAULT_FILESIZE = 1024 * 1024 * 1024 # 1GB data size because it can't be quickly set after recording + + def __init__(self, file_path, duration_ms, sample_rate, i2s_pins, on_complete): + """ + Initialize recording stream. + + Args: + file_path: Path to save WAV file + duration_ms: Recording duration in milliseconds (None = until stop()) + sample_rate: Sample rate in Hz + i2s_pins: Dict with 'sck', 'ws', 'sd_in' pin numbers + on_complete: Callback function(message) when recording finishes + """ + self.file_path = file_path + self.duration_ms = duration_ms if duration_ms else self.DEFAULT_MAX_DURATION_MS + self.sample_rate = sample_rate if sample_rate else self.DEFAULT_SAMPLE_RATE + self.i2s_pins = i2s_pins + self.on_complete = on_complete + self._keep_running = True + self._is_recording = False + self._i2s = None + self._bytes_recorded = 0 + + def is_recording(self): + """Check if stream is currently recording.""" + return self._is_recording + + def stop(self): + """Stop recording.""" + self._keep_running = False + + def get_elapsed_ms(self): + """Get elapsed recording time in milliseconds.""" + # Calculate from bytes recorded: bytes / (sample_rate * 2 bytes per sample) * 1000 + if self.sample_rate > 0: + return int((self._bytes_recorded / (self.sample_rate * 2)) * 1000) + return 0 + + # ---------------------------------------------------------------------- + # WAV header generation + # ---------------------------------------------------------------------- + @staticmethod + def _create_wav_header(sample_rate, num_channels, bits_per_sample, data_size): + """ + Create WAV file header. + + Args: + sample_rate: Sample rate in Hz + num_channels: Number of channels (1 for mono) + bits_per_sample: Bits per sample (16) + data_size: Size of audio data in bytes + + Returns: + bytes: 44-byte WAV header + """ + byte_rate = sample_rate * num_channels * (bits_per_sample // 8) + block_align = num_channels * (bits_per_sample // 8) + file_size = data_size + 36 # Total file size minus 8 bytes for RIFF header + + header = bytearray(44) + + # RIFF header + header[0:4] = b'RIFF' + header[4:8] = file_size.to_bytes(4, 'little') + header[8:12] = b'WAVE' + + # fmt chunk + header[12:16] = b'fmt ' + header[16:20] = (16).to_bytes(4, 'little') # fmt chunk size + header[20:22] = (1).to_bytes(2, 'little') # PCM format + header[22:24] = num_channels.to_bytes(2, 'little') + header[24:28] = sample_rate.to_bytes(4, 'little') + header[28:32] = byte_rate.to_bytes(4, 'little') + header[32:34] = block_align.to_bytes(2, 'little') + header[34:36] = bits_per_sample.to_bytes(2, 'little') + + # data chunk + header[36:40] = b'data' + header[40:44] = data_size.to_bytes(4, 'little') + + return bytes(header) + + @staticmethod + def _update_wav_header(file_path, data_size): + """ + Update WAV header with final data size. + + Args: + f: File object (must be opened in r+b mode) + data_size: Final size of audio data in bytes + """ + file_size = data_size + 36 + + f = open(file_path, 'r+b') + + # Update file size at offset 4 + f.seek(4) + f.write(file_size.to_bytes(4, 'little')) + + # Update data size at offset 40 + f.seek(40) + f.write(data_size.to_bytes(4, 'little')) + + f.close() + + + # ---------------------------------------------------------------------- + # Desktop simulation - generate 440Hz sine wave + # ---------------------------------------------------------------------- + def _generate_sine_wave_chunk(self, chunk_size, sample_offset): + """ + Generate a chunk of 440Hz sine wave samples for desktop testing. + + Args: + chunk_size: Number of bytes to generate (must be even for 16-bit samples) + sample_offset: Current sample offset for phase continuity + + Returns: + tuple: (bytearray of samples, number of samples generated) + """ + frequency = 440 # A4 note + amplitude = 16000 # ~50% of max 16-bit amplitude + + num_samples = chunk_size // 2 + buf = bytearray(chunk_size) + + for i in range(num_samples): + # Calculate sine wave sample + t = (sample_offset + i) / self.sample_rate + sample = int(amplitude * math.sin(2 * math.pi * frequency * t)) + + # Clamp to 16-bit range + if sample > 32767: + sample = 32767 + elif sample < -32768: + sample = -32768 + + # Write as little-endian 16-bit + buf[i * 2] = sample & 0xFF + buf[i * 2 + 1] = (sample >> 8) & 0xFF + + return buf, num_samples + + # ---------------------------------------------------------------------- + # Main recording routine + # ---------------------------------------------------------------------- + def record(self): + """Main synchronous recording routine (runs in separate thread).""" + print(f"RecordStream.record() called") + print(f" file_path: {self.file_path}") + print(f" duration_ms: {self.duration_ms}") + print(f" sample_rate: {self.sample_rate}") + print(f" i2s_pins: {self.i2s_pins}") + print(f" _HAS_MACHINE: {_HAS_MACHINE}") + + self._is_recording = True + self._bytes_recorded = 0 + + try: + # Ensure directory exists + dir_path = '/'.join(self.file_path.split('/')[:-1]) + print(f"RecordStream: Creating directory: {dir_path}") + if dir_path: + _makedirs(dir_path) + print(f"RecordStream: Directory created/verified") + + # Create file with placeholder header + print(f"RecordStream: Creating WAV file with header") + with open(self.file_path, 'wb') as f: + # Write placeholder header (will be updated at end) + header = self._create_wav_header( + self.sample_rate, + num_channels=1, + bits_per_sample=16, + data_size=self.DEFAULT_FILESIZE + ) + f.write(header) + print(f"RecordStream: Header written ({len(header)} bytes)") + + print(f"RecordStream: Recording to {self.file_path}") + print(f"RecordStream: {self.sample_rate} Hz, 16-bit, mono") + print(f"RecordStream: Max duration {self.duration_ms}ms") + + # Check if we have real I2S hardware or need to simulate + use_simulation = not _HAS_MACHINE + + if not use_simulation: + # Initialize I2S in RX mode with correct pins for microphone + try: + # Use sck_in if available (separate clock for mic), otherwise fall back to sck + sck_pin = self.i2s_pins.get('sck_in', self.i2s_pins.get('sck')) + print(f"RecordStream: Initializing I2S RX with sck={sck_pin}, ws={self.i2s_pins['ws']}, sd={self.i2s_pins['sd_in']}") + + self._i2s = machine.I2S( + 0, + sck=machine.Pin(sck_pin, machine.Pin.OUT), + ws=machine.Pin(self.i2s_pins['ws'], machine.Pin.OUT), + sd=machine.Pin(self.i2s_pins['sd_in'], machine.Pin.IN), + mode=machine.I2S.RX, + bits=16, + format=machine.I2S.MONO, + rate=self.sample_rate, + ibuf=8000 # 8KB input buffer + ) + print(f"RecordStream: I2S initialized successfully") + except Exception as e: + print(f"RecordStream: I2S init failed: {e}") + print(f"RecordStream: Falling back to simulation mode") + use_simulation = True + + if use_simulation: + print(f"RecordStream: Using desktop simulation (440Hz sine wave)") + + # Calculate recording parameters + chunk_size = 1024 # Read 1KB at a time + max_bytes = int((self.duration_ms / 1000) * self.sample_rate * 2) + start_time = time.ticks_ms() + sample_offset = 0 # For sine wave phase continuity + + # Flush every ~2 seconds of audio (64KB at 16kHz 16-bit mono) + # This spreads out the filesystem write overhead + flush_interval_bytes = 64 * 1024 + bytes_since_flush = 0 + last_flush_time = start_time + + print(f"RecordStream: max_bytes={max_bytes}, chunk_size={chunk_size}, flush_interval={flush_interval_bytes}") + + # Open file for appending audio data (append mode to avoid seek issues) + print(f"RecordStream: Opening file for audio data...") + t0 = time.ticks_ms() + f = open(self.file_path, 'ab') + print(f"RecordStream: File opened in {time.ticks_diff(time.ticks_ms(), t0)}ms") + + buf = bytearray(chunk_size) + + try: + while self._keep_running and self._bytes_recorded < max_bytes: + # Check elapsed time + elapsed = time.ticks_diff(time.ticks_ms(), start_time) + if elapsed >= self.duration_ms: + print(f"RecordStream: Duration limit reached ({elapsed}ms)") + break + + if use_simulation: + # Generate sine wave samples for desktop testing + buf, num_samples = self._generate_sine_wave_chunk(chunk_size, sample_offset) + sample_offset += num_samples + num_read = chunk_size + + # Simulate real-time recording speed + time.sleep_ms(int((chunk_size / 2) / self.sample_rate * 1000)) + else: + # Read from I2S + try: + num_read = self._i2s.readinto(buf) + except Exception as e: + print(f"RecordStream: Read error: {e}") + break + + if num_read > 0: + f.write(buf[:num_read]) + self._bytes_recorded += num_read + bytes_since_flush += num_read + + # Periodic flush to spread out filesystem overhead + if bytes_since_flush >= flush_interval_bytes: + t0 = time.ticks_ms() + f.flush() + flush_time = time.ticks_diff(time.ticks_ms(), t0) + print(f"RecordStream: Flushed {bytes_since_flush} bytes in {flush_time}ms") + bytes_since_flush = 0 + last_flush_time = time.ticks_ms() + finally: + # Explicitly close the file and measure time + print(f"RecordStream: Closing audio data file (remaining {bytes_since_flush} bytes)...") + t0 = time.ticks_ms() + f.close() + print(f"RecordStream: File closed in {time.ticks_diff(time.ticks_ms(), t0)}ms") + + # Disabled because seeking takes too long on LittleFS2: + #self._update_wav_header(self.file_path, self._bytes_recorded) + + elapsed_ms = time.ticks_diff(time.ticks_ms(), start_time) + print(f"RecordStream: Finished recording {self._bytes_recorded} bytes ({elapsed_ms}ms)") + + if self.on_complete: + self.on_complete(f"Recorded: {self.file_path}") + + except Exception as e: + import sys + print(f"RecordStream: Error: {e}") + sys.print_exception(e) + if self.on_complete: + self.on_complete(f"Error: {e}") + + finally: + self._is_recording = False + if self._i2s: + self._i2s.deinit() + self._i2s = None + print(f"RecordStream: Recording thread finished") \ No newline at end of file diff --git a/internal_filesystem/lib/mpos/audio/stream_rtttl.py b/internal_filesystem/lib/mpos/audio/stream_rtttl.py new file mode 100644 index 00000000..d02761f5 --- /dev/null +++ b/internal_filesystem/lib/mpos/audio/stream_rtttl.py @@ -0,0 +1,235 @@ +# RTTTLStream - RTTTL Ringtone Playback Stream for AudioFlinger +# Ring Tone Text Transfer Language parser and player +# Uses synchronous playback in a separate thread for non-blocking operation + +import math +import time + + +class RTTTLStream: + """ + RTTTL (Ring Tone Text Transfer Language) parser and player. + Format: "name:defaults:notes" + Example: "Nokia:d=4,o=5,b=225:8e6,8d6,8f#,8g#,8c#6,8b,d" + + See: https://en.wikipedia.org/wiki/Ring_Tone_Text_Transfer_Language + """ + + # Note frequency table (A-G, with sharps) + _NOTES = [ + 440.0, # A + 493.9, # B or H + 261.6, # C + 293.7, # D + 329.6, # E + 349.2, # F + 392.0, # G + 0.0, # pad + + 466.2, # A# + 0.0, # pad + 277.2, # C# + 311.1, # D# + 0.0, # pad + 370.0, # F# + 415.3, # G# + 0.0, # pad + ] + + def __init__(self, rtttl_string, stream_type, volume, buzzer_instance, on_complete): + """ + Initialize RTTTL stream. + + Args: + rtttl_string: RTTTL format string (e.g., "Nokia:d=4,o=5,b=225:...") + stream_type: Stream type (STREAM_MUSIC, STREAM_NOTIFICATION, STREAM_ALARM) + volume: Volume level (0-100) + buzzer_instance: PWM buzzer instance + on_complete: Callback function(message) when playback finishes + """ + self.stream_type = stream_type + self.volume = volume + self.buzzer = buzzer_instance + self.on_complete = on_complete + self._keep_running = True + self._is_playing = False + + # Parse RTTTL format + tune_pieces = rtttl_string.split(':') + if len(tune_pieces) != 3: + raise ValueError('RTTTL should contain exactly 2 colons') + + self.name = tune_pieces[0] + self.tune = tune_pieces[2] + self.tune_idx = 0 + self._parse_defaults(tune_pieces[1]) + + def is_playing(self): + """Check if stream is currently playing.""" + return self._is_playing + + def stop(self): + """Stop playback.""" + self._keep_running = False + + def _parse_defaults(self, defaults): + """ + Parse default values from RTTTL format. + Example: "d=4,o=5,b=140" + """ + self.default_duration = 4 + self.default_octave = 5 + self.bpm = 120 + + for item in defaults.split(','): + setting = item.split('=') + if len(setting) != 2: + continue + + key = setting[0].strip() + value = int(setting[1].strip()) + + if key == 'o': + self.default_octave = value + elif key == 'd': + self.default_duration = value + elif key == 'b': + self.bpm = value + + # Calculate milliseconds per whole note + # 240000 = 60 sec/min * 4 beats/whole-note * 1000 msec/sec + self.msec_per_whole_note = 240000.0 / self.bpm + + def _next_char(self): + """Get next character from tune string.""" + if self.tune_idx < len(self.tune): + char = self.tune[self.tune_idx] + self.tune_idx += 1 + if char == ',': + char = ' ' + return char + return '|' # End marker + + def _notes(self): + """ + Generator that yields (frequency, duration_ms) tuples. + + Yields: + tuple: (frequency_hz, duration_ms) for each note + """ + while True: + # Skip blank characters and commas + char = self._next_char() + while char == ' ': + char = self._next_char() + + # Parse duration (if present) + # Duration of 1 = whole note, 8 = 1/8 note + duration = 0 + while char.isdigit(): + duration *= 10 + duration += ord(char) - ord('0') + char = self._next_char() + + if duration == 0: + duration = self.default_duration + + if char == '|': # End of tune + return + + # Parse note letter + note = char.lower() + if 'a' <= note <= 'g': + note_idx = ord(note) - ord('a') + elif note == 'h': + note_idx = 1 # H is equivalent to B + elif note == 'p': + note_idx = 7 # Pause + else: + note_idx = 7 # Unknown = pause + + char = self._next_char() + + # Check for sharp + if char == '#': + note_idx += 8 + char = self._next_char() + + # Check for duration modifier (dot) before octave + duration_multiplier = 1.0 + if char == '.': + duration_multiplier = 1.5 + char = self._next_char() + + # Check for octave + if '4' <= char <= '7': + octave = ord(char) - ord('0') + char = self._next_char() + else: + octave = self.default_octave + + # Check for duration modifier (dot) after octave + if char == '.': + duration_multiplier = 1.5 + char = self._next_char() + + # Calculate frequency and duration + freq = self._NOTES[note_idx] * (1 << (octave - 4)) + msec = (self.msec_per_whole_note / duration) * duration_multiplier + + yield freq, msec + + def play(self): + """Play RTTTL tune via buzzer (runs in separate thread).""" + self._is_playing = True + + # Calculate exponential duty cycle for perceptually linear volume + if self.volume <= 0: + duty = 0 + else: + volume = min(100, self.volume) + + # Exponential volume curve + # Maximum volume is at 50% duty cycle (32768 when using duty_u16) + # Minimum is 4 (absolute minimum for audible PWM) + divider = 10 + duty = int( + ((math.exp(volume / divider) - math.exp(0.1)) / + (math.exp(10) - math.exp(0.1)) * (32768 - 4)) + 4 + ) + + print(f"RTTTLStream: Playing '{self.name}' (volume {self.volume}%)") + + try: + for freq, msec in self._notes(): + if not self._keep_running: + print("RTTTLStream: Playback stopped by user") + break + + # Play tone + if freq > 0: + self.buzzer.freq(int(freq)) + self.buzzer.duty_u16(duty) + + # Play for 90% of duration, silent for 10% (note separation) + # Blocking sleep is OK - we're in a separate thread + time.sleep_ms(int(msec * 0.9)) + self.buzzer.duty_u16(0) + time.sleep_ms(int(msec * 0.1)) + + print(f"RTTTLStream: Finished playing '{self.name}'") + if self.on_complete: + self.on_complete(f"Finished: {self.name}") + + except Exception as e: + print(f"RTTTLStream: Error: {e}") + if self.on_complete: + self.on_complete(f"Error: {e}") + + finally: + # Ensure buzzer is off + self.buzzer.duty_u16(0) + self._is_playing = False + + def set_volume(self, vol): + self.volume = vol diff --git a/internal_filesystem/lib/mpos/audio/stream_wav.py b/internal_filesystem/lib/mpos/audio/stream_wav.py new file mode 100644 index 00000000..10e4801a --- /dev/null +++ b/internal_filesystem/lib/mpos/audio/stream_wav.py @@ -0,0 +1,434 @@ +# WAVStream - WAV File Playback Stream for AudioFlinger +# Supports 8/16/24/32-bit PCM, mono+stereo, auto-upsampling, volume control +# Uses synchronous playback in a separate thread for non-blocking operation + +import machine +import micropython +import os +import sys +import time + +# Volume scaling function - Viper-optimized for ESP32 performance +# NOTE: The line below is automatically commented out by build_mpos.sh during +# Unix/macOS builds (cross-compiler doesn't support Viper), then uncommented after build. +@micropython.viper +def _scale_audio(buf: ptr8, num_bytes: int, scale_fixed: int): + """Fast volume scaling for 16-bit audio samples using Viper (ESP32 native code emitter).""" + for i in range(0, num_bytes, 2): + lo = int(buf[i]) + hi = int(buf[i + 1]) + sample = (hi << 8) | lo + if hi & 128: + sample -= 65536 + sample = (sample * scale_fixed) // 32768 + if sample > 32767: + sample = 32767 + elif sample < -32768: + sample = -32768 + buf[i] = sample & 255 + buf[i + 1] = (sample >> 8) & 255 + +@micropython.viper +def _scale_audio_optimized(buf: ptr8, num_bytes: int, scale_fixed: int): + if scale_fixed >= 32768: + return + if scale_fixed <= 0: + for i in range(num_bytes): + buf[i] = 0 + return + + mask: int = scale_fixed + + for i in range(0, num_bytes, 2): + s: int = int(buf[i]) | (int(buf[i+1]) << 8) + if s >= 0x8000: + s -= 0x10000 + + r: int = 0 + if mask & 0x8000: r += s + if mask & 0x4000: r += s>>1 + if mask & 0x2000: r += s>>2 + if mask & 0x1000: r += s>>3 + if mask & 0x0800: r += s>>4 + if mask & 0x0400: r += s>>5 + if mask & 0x0200: r += s>>6 + if mask & 0x0100: r += s>>7 + if mask & 0x0080: r += s>>8 + if mask & 0x0040: r += s>>9 + if mask & 0x0020: r += s>>10 + if mask & 0x0010: r += s>>11 + if mask & 0x0008: r += s>>12 + if mask & 0x0004: r += s>>13 + if mask & 0x0002: r += s>>14 + if mask & 0x0001: r += s>>15 + + if r > 32767: r = 32767 + if r < -32768: r = -32768 + + buf[i] = r & 0xFF + buf[i+1] = (r >> 8) & 0xFF + +@micropython.viper +def _scale_audio_rough(buf: ptr8, num_bytes: int, scale_fixed: int): + """Rough volume scaling for 16-bit audio samples using right shifts for performance.""" + if scale_fixed >= 32768: + return + + # Determine the shift amount + shift: int = 0 + threshold: int = 32768 + while shift < 16 and scale_fixed < threshold: + shift += 1 + threshold >>= 1 + + # If shift is 16 or more, set buffer to zero (volume too low) + if shift >= 16: + for i in range(num_bytes): + buf[i] = 0 + return + + # Apply right shift to each 16-bit sample + for i in range(0, num_bytes, 2): + lo: int = int(buf[i]) + hi: int = int(buf[i + 1]) + sample: int = (hi << 8) | lo + if hi & 128: + sample -= 65536 + sample >>= shift + buf[i] = sample & 255 + buf[i + 1] = (sample >> 8) & 255 + +@micropython.viper +def _scale_audio_shift(buf: ptr8, num_bytes: int, shift: int): + """Rough volume scaling for 16-bit audio samples using right shifts for performance.""" + if shift <= 0: + return + + # If shift is 16 or more, set buffer to zero (volume too low) + if shift >= 16: + for i in range(num_bytes): + buf[i] = 0 + return + + # Apply right shift to each 16-bit sample + for i in range(0, num_bytes, 2): + lo: int = int(buf[i]) + hi: int = int(buf[i + 1]) + sample: int = (hi << 8) | lo + if hi & 128: + sample -= 65536 + sample >>= shift + buf[i] = sample & 255 + buf[i + 1] = (sample >> 8) & 255 + +@micropython.viper +def _scale_audio_powers_of_2(buf: ptr8, num_bytes: int, shift: int): + if shift <= 0: + return + if shift >= 16: + for i in range(num_bytes): + buf[i] = 0 + return + + # Unroll the sign-extend + shift into one tight loop with no inner branch + inv_shift: int = 16 - shift + for i in range(0, num_bytes, 2): + s: int = int(buf[i]) | (int(buf[i+1]) << 8) + if s & 0x8000: # only one branch, highly predictable when shift fixed shift + s |= -65536 # sign extend using OR (faster than subtract!) + s <<= inv_shift # bring the bits we want into lower 16 + s >>= 16 # arithmetic shift right by 'shift' amount + buf[i] = s & 0xFF + buf[i+1] = (s >> 8) & 0xFF + +class WAVStream: + """ + WAV file playback stream with I2S output. + Supports 8/16/24/32-bit PCM, mono and stereo, auto-upsampling to >=22050 Hz. + """ + + def __init__(self, file_path, stream_type, volume, i2s_pins, on_complete): + """ + Initialize WAV stream. + + Args: + file_path: Path to WAV file + stream_type: Stream type (STREAM_MUSIC, STREAM_NOTIFICATION, STREAM_ALARM) + volume: Volume level (0-100) + i2s_pins: Dict with 'sck', 'ws', 'sd' pin numbers + on_complete: Callback function(message) when playback finishes + """ + self.file_path = file_path + self.stream_type = stream_type + self.volume = volume + self.i2s_pins = i2s_pins + self.on_complete = on_complete + self._keep_running = True + self._is_playing = False + self._i2s = None + + def is_playing(self): + """Check if stream is currently playing.""" + return self._is_playing + + def stop(self): + """Stop playback.""" + self._keep_running = False + + # ---------------------------------------------------------------------- + # WAV header parser - returns bit-depth and format info + # ---------------------------------------------------------------------- + @staticmethod + def _find_data_chunk(f): + """ + Parse WAV header and find data chunk. + + Returns: + tuple: (data_start, data_size, sample_rate, channels, bits_per_sample) + """ + f.seek(0) + if f.read(4) != b'RIFF': + raise ValueError("Not a RIFF (standard .wav) file") + + file_size = int.from_bytes(f.read(4), 'little') + 8 + + if f.read(4) != b'WAVE': + raise ValueError("Not a WAVE (standard .wav) file") + + pos = 12 + sample_rate = None + channels = None + bits_per_sample = None + + while pos < file_size: + f.seek(pos) + chunk_id = f.read(4) + if len(chunk_id) < 4: + break + + chunk_size = int.from_bytes(f.read(4), 'little') + + if chunk_id == b'fmt ': + fmt = f.read(chunk_size) + if len(fmt) < 16: + raise ValueError("Invalid fmt chunk") + + if int.from_bytes(fmt[0:2], 'little') != 1: + raise ValueError("Only PCM supported") + + channels = int.from_bytes(fmt[2:4], 'little') + if channels not in (1, 2): + raise ValueError("Only mono or stereo supported") + + sample_rate = int.from_bytes(fmt[4:8], 'little') + bits_per_sample = int.from_bytes(fmt[14:16], 'little') + + if bits_per_sample not in (8, 16, 24, 32): + raise ValueError("Only 8/16/24/32-bit PCM supported") + + elif chunk_id == b'data': + return f.tell(), chunk_size, sample_rate, channels, bits_per_sample + + pos += 8 + chunk_size + if chunk_size % 2: + pos += 1 + + raise ValueError("No 'data' chunk found") + + # ---------------------------------------------------------------------- + # Bit depth conversion functions + # ---------------------------------------------------------------------- + @staticmethod + def _convert_8_to_16(buf): + """Convert 8-bit unsigned PCM to 16-bit signed PCM.""" + out = bytearray(len(buf) * 2) + j = 0 + for i in range(len(buf)): + u8 = buf[i] + s16 = (u8 - 128) << 8 + out[j] = s16 & 0xFF + out[j + 1] = (s16 >> 8) & 0xFF + j += 2 + return out + + @staticmethod + def _convert_24_to_16(buf): + """Convert 24-bit PCM to 16-bit PCM.""" + samples = len(buf) // 3 + out = bytearray(samples * 2) + j = 0 + for i in range(samples): + b0 = buf[j] + b1 = buf[j + 1] + b2 = buf[j + 2] + s24 = (b2 << 16) | (b1 << 8) | b0 + if b2 & 0x80: + s24 -= 0x1000000 + s16 = s24 >> 8 + out[i * 2] = s16 & 0xFF + out[i * 2 + 1] = (s16 >> 8) & 0xFF + j += 3 + return out + + @staticmethod + def _convert_32_to_16(buf): + """Convert 32-bit PCM to 16-bit PCM.""" + samples = len(buf) // 4 + out = bytearray(samples * 2) + j = 0 + for i in range(samples): + b0 = buf[j] + b1 = buf[j + 1] + b2 = buf[j + 2] + b3 = buf[j + 3] + s32 = (b3 << 24) | (b2 << 16) | (b1 << 8) | b0 + if b3 & 0x80: + s32 -= 0x100000000 + s16 = s32 >> 16 + out[i * 2] = s16 & 0xFF + out[i * 2 + 1] = (s16 >> 8) & 0xFF + j += 4 + return out + + # ---------------------------------------------------------------------- + # Upsampling (zero-order-hold) + # ---------------------------------------------------------------------- + @staticmethod + def _upsample_buffer(raw, factor): + """Upsample 16-bit buffer by repeating samples.""" + if factor == 1: + return raw + + upsampled = bytearray(len(raw) * factor) + out_idx = 0 + for i in range(0, len(raw), 2): + lo = raw[i] + hi = raw[i + 1] + for _ in range(factor): + upsampled[out_idx] = lo + upsampled[out_idx + 1] = hi + out_idx += 2 + return upsampled + + # ---------------------------------------------------------------------- + # Main playback routine + # ---------------------------------------------------------------------- + def play(self): + """Main synchronous playback routine (runs in separate thread).""" + self._is_playing = True + + try: + with open(self.file_path, 'rb') as f: + st = os.stat(self.file_path) + file_size = st[6] + print(f"WAVStream: Playing {self.file_path} ({file_size} bytes)") + + # Parse WAV header + data_start, data_size, original_rate, channels, bits_per_sample = \ + self._find_data_chunk(f) + + # Decide playback rate (force >=22050 Hz) - but why?! the DAC should support down to 8kHz! + target_rate = 22050 + if original_rate >= target_rate: + playback_rate = original_rate + upsample_factor = 1 + else: + upsample_factor = (target_rate + original_rate - 1) // original_rate + playback_rate = original_rate * upsample_factor + + print(f"WAVStream: {original_rate} Hz, {bits_per_sample}-bit, {channels}-ch") + print(f"WAVStream: Playback at {playback_rate} Hz (factor {upsample_factor})") + + if data_size > file_size - data_start: + data_size = file_size - data_start + + # Initialize I2S (always 16-bit output) + try: + i2s_format = machine.I2S.MONO if channels == 1 else machine.I2S.STEREO + self._i2s = machine.I2S( + 0, + sck=machine.Pin(self.i2s_pins['sck'], machine.Pin.OUT), + ws=machine.Pin(self.i2s_pins['ws'], machine.Pin.OUT), + sd=machine.Pin(self.i2s_pins['sd'], machine.Pin.OUT), + mode=machine.I2S.TX, + bits=16, + format=i2s_format, + rate=playback_rate, + ibuf=32000 + ) + except Exception as e: + print(f"WAVStream: I2S init failed: {e}") + return + + print(f"WAVStream: Playing {data_size} bytes (volume {self.volume}%)") + f.seek(data_start) + + # Chunk size tuning notes: + # - Smaller chunks = more responsive to stop() + # - Larger chunks = less overhead, smoother audio + # - The 32KB I2S buffer handles timing smoothness + chunk_size = 8192 + bytes_per_original_sample = (bits_per_sample // 8) * channels + total_original = 0 + + while total_original < data_size: + if not self._keep_running: + print("WAVStream: Playback stopped by user") + break + + # Read chunk of original data + to_read = min(chunk_size, data_size - total_original) + to_read -= (to_read % bytes_per_original_sample) + if to_read <= 0: + break + + raw = bytearray(f.read(to_read)) + if not raw: + break + + # 1. Convert bit-depth to 16-bit + if bits_per_sample == 8: + raw = self._convert_8_to_16(raw) + elif bits_per_sample == 24: + raw = self._convert_24_to_16(raw) + elif bits_per_sample == 32: + raw = self._convert_32_to_16(raw) + # 16-bit unchanged + + # 2. Upsample if needed + if upsample_factor > 1: + raw = self._upsample_buffer(raw, upsample_factor) + + # 3. Volume scaling + scale = self.volume / 100.0 + if scale < 1.0: + scale_fixed = int(scale * 32768) + _scale_audio_optimized(raw, len(raw), scale_fixed) + + # 4. Output to I2S (blocking write is OK - we're in a separate thread) + if self._i2s: + self._i2s.write(raw) + else: + # Simulate playback timing if no I2S + num_samples = len(raw) // (2 * channels) + time.sleep(num_samples / playback_rate) + + total_original += to_read + + print(f"WAVStream: Finished playing {self.file_path}") + if self.on_complete: + self.on_complete(f"Finished: {self.file_path}") + + except Exception as e: + print(f"WAVStream: Error: {e}") + if self.on_complete: + self.on_complete(f"Error: {e}") + + finally: + self._is_playing = False + if self._i2s: + self._i2s.deinit() + self._i2s = None + + def set_volume(self, vol): + self.volume = vol diff --git a/internal_filesystem/lib/mpos/battery_voltage.py b/internal_filesystem/lib/mpos/battery_voltage.py index e25aaf0c..ca284272 100644 --- a/internal_filesystem/lib/mpos/battery_voltage.py +++ b/internal_filesystem/lib/mpos/battery_voltage.py @@ -4,41 +4,150 @@ MAX_VOLTAGE = 4.15 adc = None -scale_factor = 0 +conversion_func = None # Conversion function: ADC value -> voltage +adc_pin = None + +# Cache to reduce WiFi interruptions (ADC2 requires WiFi to be disabled) +_cached_raw_adc = None +_last_read_time = 0 +CACHE_DURATION_ADC2_MS = 300000 # 300 seconds (expensive: requires WiFi disable) +CACHE_DURATION_ADC1_MS = 30000 # 30 seconds (cheaper: no WiFi interference) + + +def _is_adc2_pin(pin): + """Check if pin is on ADC2 (ESP32-S3: GPIO11-20).""" + return 11 <= pin <= 20 + + +def init_adc(pinnr, adc_to_voltage_func): + """ + Initialize ADC for battery voltage monitoring. + + IMPORTANT for ESP32-S3: ADC2 (GPIO11-20) doesn't work when WiFi is active! + Use ADC1 pins (GPIO1-10) for battery monitoring if possible. + If using ADC2, WiFi will be temporarily disabled during readings. + + Args: + pinnr: GPIO pin number + adc_to_voltage_func: Conversion function that takes raw ADC value (0-4095) + and returns battery voltage in volts + """ + global adc, conversion_func, adc_pin + + conversion_func = adc_to_voltage_func + adc_pin = pinnr -# This gets called by (the device-specific) boot*.py -def init_adc(pinnr, sf): - global adc, scale_factor try: - print(f"Initializing ADC pin {pinnr} with scale_factor {scale_factor}") - from machine import ADC, Pin # do this inside the try because it will fail on desktop + print(f"Initializing ADC pin {pinnr} with conversion function") + if _is_adc2_pin(pinnr): + print(f" WARNING: GPIO{pinnr} is on ADC2 - WiFi will be disabled during readings") + from machine import ADC, Pin adc = ADC(Pin(pinnr)) - # Set ADC to 11dB attenuation for 0–3.3V range (common for ESP32) - adc.atten(ADC.ATTN_11DB) - scale_factor = sf + adc.atten(ADC.ATTN_11DB) # 0-3.3V range except Exception as e: - print("Info: this platform has no ADC for measuring battery voltage") + print(f"Info: this platform has no ADC for measuring battery voltage: {e}") -def read_battery_voltage(): + initial_adc_value = read_raw_adc() + print("Reading ADC at init to fill cache: {initial_adc_value} => {read_battery_voltage(raw_adc_value=initial_adc_value)}V => {get_battery_percentage(raw_adc_value=initial_adc_value)}%") + + +def read_raw_adc(force_refresh=False): + """ + Read raw ADC value (0-4095) with adaptive caching. + + On ESP32-S3 with ADC2, WiFi is temporarily disabled during reading. + Raises RuntimeError if WifiService is busy (connecting/scanning) when using ADC2. + + Args: + force_refresh: Bypass cache and force fresh reading + + Returns: + float: Raw ADC value (0-4095) + + Raises: + RuntimeError: If WifiService is busy (only when using ADC2) + """ + global _cached_raw_adc, _last_read_time + + # Desktop mode - return random value in typical ADC range if not adc: import random - random_voltage = random.randint(round(MIN_VOLTAGE*100),round(MAX_VOLTAGE*100)) / 100 - #print(f"returning random voltage: {random_voltage}") - return random_voltage - # Read raw ADC value - total = 0 - # Read multiple times to try to reduce variability. - # Reading 10 times takes around 3ms so it's fine... - for _ in range(10): - total = total + adc.read() - raw_value = total / 10 - #print(f"read_battery_voltage raw_value: {raw_value}") - voltage = raw_value * scale_factor - # Clamp to 0–4.2V range for LiPo battery - voltage = max(0, min(voltage, MAX_VOLTAGE)) + return random.randint(1900, 2600) + + # Check if this is an ADC2 pin (requires WiFi disable) + needs_wifi_disable = adc_pin is not None and _is_adc2_pin(adc_pin) + + # Use different cache durations based on cost + cache_duration = CACHE_DURATION_ADC2_MS if needs_wifi_disable else CACHE_DURATION_ADC1_MS + + # Check cache + current_time = time.ticks_ms() + if not force_refresh and _cached_raw_adc is not None: + age = time.ticks_diff(current_time, _last_read_time) + if age < cache_duration: + return _cached_raw_adc + + # Import WifiService only if needed + WifiService = None + if needs_wifi_disable: + try: + from mpos.net.wifi_service import WifiService + except ImportError: + pass + + # Temporarily disable WiFi for ADC2 reading + was_connected = False + if needs_wifi_disable and WifiService: + # This will raise RuntimeError if WiFi is already busy + was_connected = WifiService.temporarily_disable() + time.sleep(0.05) # Brief delay for WiFi to fully disable + + try: + # Read ADC (average of 10 samples) + total = sum(adc.read() for _ in range(10)) + raw_value = total / 10.0 + + # Update cache + _cached_raw_adc = raw_value + _last_read_time = current_time + + return raw_value + + finally: + # Re-enable WiFi (only if we disabled it) + if needs_wifi_disable and WifiService: + WifiService.temporarily_enable(was_connected) + + +def read_battery_voltage(force_refresh=False, raw_adc_value=None): + """ + Read battery voltage in volts. + + Args: + force_refresh: Bypass cache and force fresh reading + + Returns: + float: Battery voltage in volts (clamped to 0-MAX_VOLTAGE) + """ + raw = raw_adc_value if raw_adc_value else read_raw_adc(force_refresh) + voltage = conversion_func(raw) if conversion_func else 0.0 return voltage -# Could be interesting to keep a "rolling average" of the percentage so that it doesn't fluctuate too quickly -def get_battery_percentage(): - return (read_battery_voltage() - MIN_VOLTAGE) * 100 / (MAX_VOLTAGE - MIN_VOLTAGE) +def get_battery_percentage(raw_adc_value=None): + """ + Get battery charge percentage. + + Returns: + float: Battery percentage (0-100) + """ + voltage = read_battery_voltage(raw_adc_value=raw_adc_value) + percentage = (voltage - MIN_VOLTAGE) * 100.0 / (MAX_VOLTAGE - MIN_VOLTAGE) + return max(0,min(100.0, percentage)) # limit to 100.0% and make sure it's positive + + +def clear_cache(): + """Clear the battery voltage cache to force fresh reading on next call.""" + global _cached_raw_adc, _last_read_time + _cached_raw_adc = None + _last_read_time = 0 diff --git a/internal_filesystem/lib/mpos/board/fri3d_2024.py b/internal_filesystem/lib/mpos/board/fri3d_2024.py index 243c75c7..3f397cc5 100644 --- a/internal_filesystem/lib/mpos/board/fri3d_2024.py +++ b/internal_filesystem/lib/mpos/board/fri3d_2024.py @@ -70,8 +70,8 @@ color_space=lv.COLOR_FORMAT.RGB565, color_byte_order=st7789.BYTE_ORDER_BGR, rgb565_byte_swap=True, - reset_pin=LCD_RST, - reset_state=STATE_LOW + reset_pin=LCD_RST, # doesn't seem needed + reset_state=STATE_LOW # doesn't seem needed ) mpos.ui.main_display.init() @@ -258,10 +258,137 @@ def keypad_read_cb(indev, data): indev.enable(True) # NOQA # Battery voltage ADC measuring +# NOTE: GPIO13 is on ADC2, which requires WiFi to be disabled during reading on ESP32-S3. +# battery_voltage.py handles this automatically: disables WiFi, reads ADC, reconnects WiFi. import mpos.battery_voltage -mpos.battery_voltage.init_adc(13, 2 / 1000) +""" +best fit on battery power: +2482 is 4.180 +2470 is 4.170 +2457 is 4.147 +# 2444 is 4.12 +2433 is 4.109 +2429 is 4.102 +2393 is 4.044 +2369 is 4.000 +2343 is 3.957 +2319 is 3.916 +2269 is 3.831 +2227 is 3.769 +""" +def adc_to_voltage(adc_value): + """ + Convert raw ADC value to battery voltage using calibrated linear function. + Calibration data shows linear relationship: voltage = -0.0016237 * adc + 8.2035 + This is ~10x more accurate than simple scaling (error ~0.01V vs ~0.1V). + """ + return (0.001651* adc_value + 0.08709) + +mpos.battery_voltage.init_adc(13, adc_to_voltage) import mpos.sdcard mpos.sdcard.init(spi_bus, cs_pin=14) -print("boot.py finished") +# === AUDIO HARDWARE === +from machine import PWM, Pin +import mpos.audio.audioflinger as AudioFlinger + +# Initialize buzzer (GPIO 46) +buzzer = PWM(Pin(46), freq=550, duty=0) + +# I2S pin configuration for audio output (DAC) and input (microphone) +# Note: I2S is created per-stream, not at boot (only one instance can exist) +# The DAC uses BCK (bit clock) on GPIO 2, while the microphone uses SCLK on GPIO 17 +# See schematics: DAC has BCK=2, WS=47, SD=16; Microphone has SCLK=17, WS=47, DIN=15 +i2s_pins = { + # Output (DAC/speaker) pins + 'sck': 2, # BCK - Bit Clock for DAC output + 'ws': 47, # Word Select / LRCLK (shared between DAC and mic) + 'sd': 16, # Serial Data OUT (speaker/DAC) + # Input (microphone) pins + 'sck_in': 17, # SCLK - Serial Clock for microphone input + 'sd_in': 15, # DIN - Serial Data IN (microphone) +} + +# Initialize AudioFlinger with I2S and buzzer +AudioFlinger.init( + i2s_pins=i2s_pins, + buzzer_instance=buzzer +) + +# === LED HARDWARE === +import mpos.lights as LightsManager + +# Initialize 5 NeoPixel LEDs (GPIO 12) +LightsManager.init(neopixel_pin=12, num_leds=5) + +# === SENSOR HARDWARE === +import mpos.sensor_manager as SensorManager + +# Create I2C bus for IMU (different pins from display) +from machine import I2C +imu_i2c = I2C(0, sda=Pin(9), scl=Pin(18)) +SensorManager.init(imu_i2c, address=0x6B, mounted_position=SensorManager.FACING_EARTH) + +print("Fri3d hardware: Audio, LEDs, and sensors initialized") + +# === STARTUP "WOW" EFFECT === +import time +import _thread + +def startup_wow_effect(): + """ + Epic startup effect with rainbow LED chase and upbeat startup jingle. + Runs in background thread to avoid blocking boot. + """ + try: + # Startup jingle: Happy upbeat sequence (ascending scale with flourish) + startup_jingle = "Startup:d=8,o=6,b=200:c,d,e,g,4c7,4e,4c7" + + # Start the jingle + AudioFlinger.play_rtttl( + startup_jingle, + stream_type=AudioFlinger.STREAM_NOTIFICATION, + volume=60 + ) + + # Rainbow colors for the 5 LEDs + rainbow = [ + (255, 0, 0), # Red + (255, 128, 0), # Orange + (255, 255, 0), # Yellow + (0, 255, 0), # Green + (0, 0, 255), # Blue + ] + + # Rainbow sweep effect (3 passes, getting faster) + for pass_num in range(3): + for i in range(5): + # Light up LEDs progressively + for j in range(i + 1): + LightsManager.set_led(j, *rainbow[j]) + LightsManager.write() + time.sleep_ms(80 - pass_num * 20) # Speed up each pass + + # Flash all LEDs bright white + LightsManager.set_all(255, 255, 255) + LightsManager.write() + time.sleep_ms(150) + + # Rainbow finale + for i in range(5): + LightsManager.set_led(i, *rainbow[i]) + LightsManager.write() + time.sleep_ms(300) + + # Fade out + LightsManager.clear() + LightsManager.write() + + except Exception as e: + print(f"Startup effect error: {e}") + +_thread.stack_size(mpos.apps.good_stack_size()) # default stack size won't work, crashes! +_thread.start_new_thread(startup_wow_effect, ()) + +print("fri3d_2024.py finished") diff --git a/internal_filesystem/lib/mpos/board/linux.py b/internal_filesystem/lib/mpos/board/linux.py index 3256f53b..9522344c 100644 --- a/internal_filesystem/lib/mpos/board/linux.py +++ b/internal_filesystem/lib/mpos/board/linux.py @@ -85,7 +85,44 @@ def catch_escape_key(indev, indev_data): # print(f"boot_unix: code={event_code}") # target={event.get_target()}, user_data={event.get_user_data()}, param={event.get_param()} #keyboard.add_event_cb(keyboard_cb, lv.EVENT.ALL, None) -print("boot_unix.py finished") + +# Simulated battery voltage ADC measuring +import mpos.battery_voltage + +def adc_to_voltage(adc_value): + """Convert simulated ADC value to voltage.""" + return adc_value * (3.3 / 4095) * 2 + +mpos.battery_voltage.init_adc(999, adc_to_voltage) + +# === AUDIO HARDWARE === +import mpos.audio.audioflinger as AudioFlinger + +# Desktop builds have no real audio hardware, but we simulate microphone +# recording with a 440Hz sine wave for testing WAV file generation +# The i2s_pins dict with 'sd_in' enables has_microphone() to return True +i2s_pins = { + 'sck': 0, # Simulated - not used on desktop + 'ws': 0, # Simulated - not used on desktop + 'sd': 0, # Simulated - not used on desktop + 'sck_in': 0, # Simulated - not used on desktop + 'sd_in': 0, # Simulated - enables microphone simulation +} +AudioFlinger.init(i2s_pins=i2s_pins) + +# === LED HARDWARE === +# Note: Desktop builds have no LED hardware +# LightsManager will not be initialized (functions will return False) + +# === SENSOR HARDWARE === +# Note: Desktop builds have no sensor hardware +import mpos.sensor_manager as SensorManager + +# Initialize with no I2C bus - will detect MCU temp if available +# (On Linux desktop, this will fail gracefully but set _initialized flag) +SensorManager.init(None) + +print("linux.py finished") diff --git a/internal_filesystem/lib/mpos/board/waveshare_esp32_s3_touch_lcd_2.py b/internal_filesystem/lib/mpos/board/waveshare_esp32_s3_touch_lcd_2.py index 6f6b0cba..15642eec 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 @@ -61,11 +61,11 @@ frame_buffer2=fb2, display_width=TFT_VER_RES, display_height=TFT_HOR_RES, - backlight_pin=LCD_BL, - backlight_on_state=st7789.STATE_PWM, color_space=lv.COLOR_FORMAT.RGB565, color_byte_order=st7789.BYTE_ORDER_BGR, rgb565_byte_swap=True, + backlight_pin=LCD_BL, + backlight_on_state=st7789.STATE_PWM, ) mpos.ui.main_display.init() mpos.ui.main_display.set_power(True) @@ -81,7 +81,19 @@ # Battery voltage ADC measuring import mpos.battery_voltage -mpos.battery_voltage.init_adc(5, 262 / 100000) + +def adc_to_voltage(adc_value): + """ + Convert raw ADC value to battery voltage. + Currently uses simple linear scaling: voltage = adc * 0.00262 + + This could be improved with calibration data similar to Fri3d board. + To calibrate: measure actual battery voltages and corresponding ADC readings, + then fit a linear or polynomial function. + """ + return adc_value * 0.00262 + +mpos.battery_voltage.init_adc(5, adc_to_voltage) # On the Waveshare ESP32-S3-Touch-LCD-2, the camera is hard-wired to power on, # so it needs a software power off to prevent it from staying hot all the time and quickly draining the battery. @@ -98,4 +110,21 @@ except Exception as e: print(f"Warning: powering off camera got exception: {e}") -print("boot.py finished") +# === AUDIO HARDWARE === +import mpos.audio.audioflinger as AudioFlinger + +# Note: Waveshare board has no buzzer or I2S audio +AudioFlinger.init() + +# === LED HARDWARE === +# Note: Waveshare board has no NeoPixel LEDs +# LightsManager will not be initialized (functions will return False) + +# === SENSOR HARDWARE === +import mpos.sensor_manager as SensorManager + +# IMU is on I2C0 (same bus as touch): SDA=48, SCL=47, addr=0x6B +# i2c_bus was created on line 75 for touch, reuse it for IMU +SensorManager.init(i2c_bus, address=0x6B, mounted_position=SensorManager.FACING_EARTH) + +print("waveshare_esp32_s3_touch_lcd_2.py finished") diff --git a/internal_filesystem/lib/mpos/config.py b/internal_filesystem/lib/mpos/config.py index 1331a595..e42f45e6 100644 --- a/internal_filesystem/lib/mpos/config.py +++ b/internal_filesystem/lib/mpos/config.py @@ -2,10 +2,11 @@ import os class SharedPreferences: - def __init__(self, appname, filename="config.json"): - """Initialize with appname and filename for preferences.""" + def __init__(self, appname, filename="config.json", defaults=None): + """Initialize with appname, filename, and optional defaults for preferences.""" self.appname = appname self.filename = filename + self.defaults = defaults if defaults is not None else {} self.filepath = f"data/{self.appname}/{self.filename}" self.data = {} self.load() @@ -28,7 +29,7 @@ def load(self): try: with open(self.filepath, 'r') as f: self.data = ujson.load(f) - print(f"load: Loaded preferences: {self.data}") + print(f"load: Loaded preferences from {self.filepath}: {self.data}") except Exception as e: print(f"SharedPreferences.load didn't find preferences: {e}") self.data = {} @@ -36,31 +37,80 @@ def load(self): def get_string(self, key, default=None): """Retrieve a string value for the given key, with a default if not found.""" to_return = self.data.get(key) - if to_return is None and default is not None: - to_return = default + if to_return is None: + # Method default takes precedence + if default is not None: + to_return = default + # Fall back to constructor default + elif key in self.defaults: + to_return = self.defaults[key] return to_return def get_int(self, key, default=0): """Retrieve an integer value for the given key, with a default if not found.""" - try: - return int(self.data.get(key, default)) - except (TypeError, ValueError): + if key in self.data: + try: + return int(self.data[key]) + except (TypeError, ValueError): + return default + # Key not in stored data, check defaults + # Method default takes precedence if explicitly provided (not the hardcoded 0) + # Otherwise use constructor default if exists + if default != 0: return default + if key in self.defaults: + try: + return int(self.defaults[key]) + except (TypeError, ValueError): + return 0 + return 0 def get_bool(self, key, default=False): """Retrieve a boolean value for the given key, with a default if not found.""" - try: - return bool(self.data.get(key, default)) - except (TypeError, ValueError): + if key in self.data: + try: + return bool(self.data[key]) + except (TypeError, ValueError): + return default + # Key not in stored data, check defaults + # Method default takes precedence if explicitly provided (not the hardcoded False) + # Otherwise use constructor default if exists + if default != False: return default + if key in self.defaults: + try: + return bool(self.defaults[key]) + except (TypeError, ValueError): + return False + return False def get_list(self, key, default=None): """Retrieve a list for the given key, with a default if not found.""" - return self.data.get(key, default if default is not None else []) + if key in self.data: + return self.data[key] + # Key not in stored data, check defaults + # Method default takes precedence if provided + if default is not None: + return default + # Fall back to constructor default + if key in self.defaults: + return self.defaults[key] + # Return empty list as hardcoded fallback + return [] def get_dict(self, key, default=None): """Retrieve a dictionary for the given key, with a default if not found.""" - return self.data.get(key, default if default is not None else {}) + if key in self.data: + return self.data[key] + # Key not in stored data, check defaults + # Method default takes precedence if provided + if default is not None: + return default + # Fall back to constructor default + if key in self.defaults: + return self.defaults[key] + # Return empty dict as hardcoded fallback + return {} def edit(self): """Return an Editor object to modify preferences.""" @@ -193,14 +243,35 @@ def remove_dict_item(self, dict_key, item_key): pass return self + def remove_all(self): + self.temp_data = {} + return self + + def _filter_defaults(self, data): + """Remove keys from data that match constructor defaults.""" + if not self.preferences.defaults: + return data + + filtered = {} + for key, value in data.items(): + if key in self.preferences.defaults: + if value != self.preferences.defaults[key]: + filtered[key] = value + # else: skip saving, matches default + else: + filtered[key] = value # No default, always save + return filtered + def apply(self): """Save changes to the file asynchronously (emulated).""" - self.preferences.data = self.temp_data.copy() + filtered_data = self._filter_defaults(self.temp_data) + self.preferences.data = filtered_data self.preferences.save_config() def commit(self): """Save changes to the file synchronously.""" - self.preferences.data = self.temp_data.copy() + filtered_data = self._filter_defaults(self.temp_data) + self.preferences.data = filtered_data self.preferences.save_config() return True diff --git a/internal_filesystem/lib/mpos/hardware/drivers/__init__.py b/internal_filesystem/lib/mpos/hardware/drivers/__init__.py new file mode 100644 index 00000000..119fb43d --- /dev/null +++ b/internal_filesystem/lib/mpos/hardware/drivers/__init__.py @@ -0,0 +1 @@ +# IMU and sensor drivers for MicroPythonOS diff --git a/internal_filesystem/lib/qmi8658.py b/internal_filesystem/lib/mpos/hardware/drivers/qmi8658.py similarity index 100% rename from internal_filesystem/lib/qmi8658.py rename to internal_filesystem/lib/mpos/hardware/drivers/qmi8658.py diff --git a/internal_filesystem/lib/mpos/hardware/drivers/wsen_isds.py b/internal_filesystem/lib/mpos/hardware/drivers/wsen_isds.py new file mode 100644 index 00000000..7f6f7be9 --- /dev/null +++ b/internal_filesystem/lib/mpos/hardware/drivers/wsen_isds.py @@ -0,0 +1,340 @@ +"""WSEN_ISDS 6-axis IMU driver for MicroPython. + +This driver is for the Würth Elektronik WSEN-ISDS IMU sensor. +Source: https://github.com/Fri3dCamp/badge_2024_micropython/pull/10 + +MIT License + +Copyright (c) 2024 Fri3d Camp contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +import time + + +class Wsen_Isds: + """Driver for WSEN-ISDS 6-axis IMU (accelerometer + gyroscope).""" + + _ISDS_STATUS_REG = 0x1E # Status data register + _ISDS_WHO_AM_I = 0x0F # WHO_AM_I register + + _REG_TEMP_OUT_L = 0x20 + + _REG_G_X_OUT_L = 0x22 + _REG_G_Y_OUT_L = 0x24 + _REG_G_Z_OUT_L = 0x26 + + _REG_A_X_OUT_L = 0x28 + _REG_A_Y_OUT_L = 0x2A + _REG_A_Z_OUT_L = 0x2C + + _REG_A_TAP_CFG = 0x58 + + _options = { + 'acc_range': { + 'reg': 0x10, 'mask': 0b11110011, 'shift_left': 2, + 'val_to_bits': {"2g": 0b00, "4g": 0b10, "8g": 0b11, "16g": 0b01} + }, + 'acc_data_rate': { + 'reg': 0x10, 'mask': 0b00001111, 'shift_left': 4, + 'val_to_bits': { + "0": 0b0000, "1.6Hz": 0b1011, "12.5Hz": 0b0001, + "26Hz": 0b0010, "52Hz": 0b0011, "104Hz": 0b0100, + "208Hz": 0b0101, "416Hz": 0b0110, "833Hz": 0b0111, + "1.66kHz": 0b1000, "3.33kHz": 0b1001, "6.66kHz": 0b1010} + }, + 'gyro_range': { + 'reg': 0x11, 'mask': 0b11110000, 'shift_left': 0, + 'val_to_bits': { + "125dps": 0b0010, "250dps": 0b0000, + "500dps": 0b0100, "1000dps": 0b1000, "2000dps": 0b1100} + }, + 'gyro_data_rate': { + 'reg': 0x11, 'mask': 0b00001111, 'shift_left': 4, + 'val_to_bits': { + "0": 0b0000, "12.5Hz": 0b0001, "26Hz": 0b0010, + "52Hz": 0b0011, "104Hz": 0b0100, "208Hz": 0b0101, + "416Hz": 0b0110, "833Hz": 0b0111, "1.66kHz": 0b1000, + "3.33kHz": 0b1001, "6.66kHz": 0b1010} + }, + 'tap_double_enable': { + 'reg': 0x5B, 'mask': 0b01111111, 'shift_left': 7, + 'val_to_bits': {True: 0b01, False: 0b00} + }, + 'tap_threshold': { + 'reg': 0x59, 'mask': 0b11100000, 'shift_left': 0, + 'val_to_bits': {0: 0b00, 1: 0b01, 2: 0b10, 3: 0b11, 4: 0b100, 5: 0b101, + 6: 0b110, 7: 0b111, 8: 0b1000, 9: 0b1001} + }, + 'tap_quiet_time': { + 'reg': 0x5A, 'mask': 0b11110011, 'shift_left': 2, + 'val_to_bits': {0: 0b00, 1: 0b01, 2: 0b10, 3: 0b11} + }, + 'tap_duration_time': { + 'reg': 0x5A, 'mask': 0b00001111, 'shift_left': 2, + 'val_to_bits': {0: 0b00, 1: 0b01, 2: 0b10, 3: 0b11, 4: 0b100, 5: 0b101, + 6: 0b110, 7: 0b111, 8: 0b1000, 9: 0b1001} + }, + 'tap_shock_time': { + 'reg': 0x5A, 'mask': 0b11111100, 'shift_left': 0, + 'val_to_bits': {0: 0b00, 1: 0b01, 2: 0b10, 3: 0b11} + }, + 'tap_single_to_int0': { + 'reg': 0x5E, 'mask': 0b10111111, 'shift_left': 6, + 'val_to_bits': {0: 0b00, 1: 0b01} + }, + 'tap_double_to_int0': { + 'reg': 0x5E, 'mask': 0b11110111, 'shift_left': 3, + 'val_to_bits': {0: 0b00, 1: 0b01} + }, + 'int1_on_int0': { + 'reg': 0x13, 'mask': 0b11011111, 'shift_left': 5, + 'val_to_bits': {0: 0b00, 1: 0b01} + }, + 'ctrl_do_soft_reset': { + 'reg': 0x12, 'mask': 0b11111110, 'shift_left': 0, + 'val_to_bits': {True: 0b01, False: 0b00} + }, + 'ctrl_do_reboot': { + 'reg': 0x12, 'mask': 0b01111111, 'shift_left': 7, + 'val_to_bits': {True: 0b01, False: 0b00} + }, + } + + def __init__(self, i2c, address=0x6B, acc_range="2g", acc_data_rate="1.6Hz", + gyro_range="125dps", gyro_data_rate="12.5Hz"): + """Initialize WSEN-ISDS IMU. + + Args: + i2c: I2C bus instance + address: I2C address (default 0x6B) + acc_range: Accelerometer range ("2g", "4g", "8g", "16g") + acc_data_rate: Accelerometer data rate ("0", "1.6Hz", "12.5Hz", ...) + gyro_range: Gyroscope range ("125dps", "250dps", "500dps", "1000dps", "2000dps") + gyro_data_rate: Gyroscope data rate ("0", "12.5Hz", "26Hz", ...") + """ + self.i2c = i2c + self.address = address + + self.acc_range = 0 + self.acc_sensitivity = 0 + + self.gyro_range = 0 + self.gyro_sensitivity = 0 + + self.set_acc_range(acc_range) + self.set_acc_data_rate(acc_data_rate) + + self.set_gyro_range(gyro_range) + self.set_gyro_data_rate(gyro_data_rate) + + # Give sensors time to stabilize + time.sleep_ms(100) + + def get_chip_id(self): + """Get chip ID for detection. Returns WHO_AM_I register value.""" + try: + return self.i2c.readfrom_mem(self.address, self._ISDS_WHO_AM_I, 1)[0] + except: + return 0 + + def _write_option(self, option, value): + """Write configuration option to sensor register.""" + opt = Wsen_Isds._options[option] + try: + bits = opt["val_to_bits"][value] + old_value = self.i2c.readfrom_mem(self.address, opt["reg"], 1)[0] + config_value = old_value + config_value &= opt["mask"] + config_value |= (bits << opt["shift_left"]) + self.i2c.writeto_mem(self.address, opt["reg"], bytes([config_value])) + except KeyError as err: + print(f"Invalid option: {option}, or invalid option value: {value}.", err) + + def set_acc_range(self, acc_range): + """Set accelerometer range.""" + self._write_option('acc_range', acc_range) + self.acc_range = acc_range + self._acc_calc_sensitivity() + + def set_acc_data_rate(self, acc_rate): + """Set accelerometer data rate.""" + self._write_option('acc_data_rate', acc_rate) + + def set_gyro_range(self, gyro_range): + """Set gyroscope range.""" + self._write_option('gyro_range', gyro_range) + self.gyro_range = gyro_range + self._gyro_calc_sensitivity() + + def set_gyro_data_rate(self, gyro_rate): + """Set gyroscope data rate.""" + self._write_option('gyro_data_rate', gyro_rate) + + def _gyro_calc_sensitivity(self): + """Calculate gyroscope sensitivity based on range.""" + sensitivity_mapping = { + "125dps": 4.375, + "250dps": 8.75, + "500dps": 17.5, + "1000dps": 35, + "2000dps": 70 + } + + if self.gyro_range in sensitivity_mapping: + self.gyro_sensitivity = sensitivity_mapping[self.gyro_range] + else: + print("Invalid range value:", self.gyro_range) + + def soft_reset(self): + """Perform soft reset of the sensor.""" + self._write_option('ctrl_do_soft_reset', True) + + def reboot(self): + """Reboot the sensor.""" + self._write_option('ctrl_do_reboot', True) + + def set_interrupt(self, interrupts_enable=False, inact_en=False, slope_fds=False, + tap_x_en=True, tap_y_en=True, tap_z_en=True): + """Configure interrupt for tap gestures on INT0 pad.""" + config_value = 0b00000000 + + if interrupts_enable: + config_value |= (1 << 7) + if inact_en: + inact_en = 0x01 + config_value |= (inact_en << 5) + if slope_fds: + config_value |= (1 << 4) + if tap_x_en: + config_value |= (1 << 3) + if tap_y_en: + config_value |= (1 << 2) + if tap_z_en: + config_value |= (1 << 1) + + self.i2c.writeto_mem(self.address, Wsen_Isds._REG_A_TAP_CFG, + bytes([config_value])) + + self._write_option('tap_double_enable', False) + self._write_option('tap_threshold', 9) + self._write_option('tap_quiet_time', 1) + self._write_option('tap_duration_time', 5) + self._write_option('tap_shock_time', 2) + self._write_option('tap_single_to_int0', 1) + self._write_option('tap_double_to_int0', 1) + self._write_option('int1_on_int0', 1) + + def _acc_calc_sensitivity(self): + """Calculate accelerometer sensitivity based on range (in mg/digit).""" + sensitivity_mapping = { + "2g": 0.061, + "4g": 0.122, + "8g": 0.244, + "16g": 0.488 + } + if self.acc_range in sensitivity_mapping: + self.acc_sensitivity = sensitivity_mapping[self.acc_range] + else: + print("Invalid range value:", self.acc_range) + + def _read_raw_accelerations(self): + """Read raw accelerometer data.""" + if not self._acc_data_ready(): + raise Exception("sensor data not ready") + + raw = self.i2c.readfrom_mem(self.address, Wsen_Isds._REG_A_X_OUT_L, 6) + + raw_a_x = self._convert_from_raw(raw[0], raw[1]) + raw_a_y = self._convert_from_raw(raw[2], raw[3]) + raw_a_z = self._convert_from_raw(raw[4], raw[5]) + + return raw_a_x * self.acc_sensitivity, raw_a_y * self.acc_sensitivity, raw_a_z * self.acc_sensitivity + + + @property + def temperature(self) -> float: + temp_raw = self._read_raw_temperature() + return ((temp_raw / 256.0) + 25.0) + + def _read_raw_temperature(self): + """Read raw temperature data.""" + if not self._temp_data_ready(): + raise Exception("temp sensor data not ready") + + raw = self.i2c.readfrom_mem(self.address, Wsen_Isds._REG_TEMP_OUT_L, 2) + raw_temp = self._convert_from_raw(raw[0], raw[1]) + return raw_temp + + def _read_raw_angular_velocities(self): + """Read raw gyroscope data.""" + if not self._gyro_data_ready(): + raise Exception("sensor data not ready") + + raw = self.i2c.readfrom_mem(self.address, Wsen_Isds._REG_G_X_OUT_L, 6) + + raw_g_x = self._convert_from_raw(raw[0], raw[1]) + raw_g_y = self._convert_from_raw(raw[2], raw[3]) + raw_g_z = self._convert_from_raw(raw[4], raw[5]) + + return raw_g_x * self.gyro_sensitivity, raw_g_y * self.gyro_sensitivity, raw_g_z * self.gyro_sensitivity + + @staticmethod + def _convert_from_raw(b_l, b_h): + """Convert two bytes (little-endian) to signed 16-bit integer.""" + c = (b_h << 8) | b_l + if c & (1 << 15): + c -= 1 << 16 + return c + + def _acc_data_ready(self): + """Check if accelerometer data is ready.""" + return self._get_status_reg()[0] + + def _gyro_data_ready(self): + """Check if gyroscope data is ready.""" + return self._get_status_reg()[1] + + def _temp_data_ready(self): + """Check if accelerometer data is ready.""" + return self._get_status_reg()[2] + + def _acc_gyro_data_ready(self): + """Check if both accelerometer and gyroscope data are ready.""" + status_reg = self._get_status_reg() + return status_reg[0], status_reg[1] + + def _get_status_reg(self): + """Read status register. + + Returns: + Tuple (acc_data_ready, gyro_data_ready, temp_data_ready) + """ + # STATUS_REG (0x1E) is a single byte with bit flags: + # Bit 0: XLDA (accelerometer data available) + # Bit 1: GDA (gyroscope data available) + # Bit 2: TDA (temperature data available) + status = self.i2c.readfrom_mem(self.address, Wsen_Isds._ISDS_STATUS_REG, 1)[0] + + acc_data_ready = bool(status & 0x01) # Bit 0 + gyro_data_ready = bool(status & 0x02) # Bit 1 + temp_data_ready = bool(status & 0x04) # Bit 2 + + return acc_data_ready, gyro_data_ready, temp_data_ready diff --git a/internal_filesystem/lib/mpos/hardware/fri3d/__init__.py b/internal_filesystem/lib/mpos/hardware/fri3d/__init__.py new file mode 100644 index 00000000..18919b17 --- /dev/null +++ b/internal_filesystem/lib/mpos/hardware/fri3d/__init__.py @@ -0,0 +1,8 @@ +# Fri3d Camp 2024 Badge Hardware Drivers +# These are simple wrappers that can be used by services like AudioFlinger + +from .buzzer import BuzzerConfig +from .leds import LEDConfig +from .rtttl_data import RTTTL_SONGS + +__all__ = ['BuzzerConfig', 'LEDConfig', 'RTTTL_SONGS'] diff --git a/internal_filesystem/lib/mpos/hardware/fri3d/buzzer.py b/internal_filesystem/lib/mpos/hardware/fri3d/buzzer.py new file mode 100644 index 00000000..2ebfa98a --- /dev/null +++ b/internal_filesystem/lib/mpos/hardware/fri3d/buzzer.py @@ -0,0 +1,11 @@ +# Fri3d Camp 2024 Badge - Buzzer Configuration + +class BuzzerConfig: + """Configuration for PWM buzzer hardware.""" + + # GPIO pin for buzzer + PIN = 46 + + # Default PWM settings + DEFAULT_FREQ = 550 # Hz + DEFAULT_DUTY = 0 # Off by default diff --git a/internal_filesystem/lib/mpos/hardware/fri3d/leds.py b/internal_filesystem/lib/mpos/hardware/fri3d/leds.py new file mode 100644 index 00000000..f14b740d --- /dev/null +++ b/internal_filesystem/lib/mpos/hardware/fri3d/leds.py @@ -0,0 +1,10 @@ +# Fri3d Camp 2024 Badge - LED Configuration + +class LEDConfig: + """Configuration for NeoPixel RGB LED hardware.""" + + # GPIO pin for NeoPixel data line + PIN = 12 + + # Number of NeoPixel LEDs on badge + NUM_LEDS = 5 diff --git a/internal_filesystem/lib/mpos/hardware/fri3d/rtttl_data.py b/internal_filesystem/lib/mpos/hardware/fri3d/rtttl_data.py new file mode 100644 index 00000000..38174890 --- /dev/null +++ b/internal_filesystem/lib/mpos/hardware/fri3d/rtttl_data.py @@ -0,0 +1,18 @@ +# RTTTL Song Catalog +# Ring Tone Text Transfer Language songs for buzzer playback +# Format: "name:defaults:notes" +# Ported from Fri3d Camp 2024 Badge firmware + +RTTTL_SONGS = { + "nokia": "Nokia:d=4,o=5,b=225:8e6,8d6,8f#,8g#,8c#6,8b,d,8p,8b,8a,8c#,8e,8a,8p", + + "macarena": "Macarena:d=4,o=5,b=180:f,8f,8f,f,8f,8f,8f,8f,8f,8f,8f,8a,c,8c,f,8f,8f,f,8f,8f,8f,8f,8f,8f,8d,8c,p,f,8f,8f,f,8f,8f,8f,8f,8f,8f,8f,8a,p,2c,f,8f,8f,f,8f,8f,8f,8f,8f,8f,8d,8c", + + "takeonme": "TakeOnMe:d=4,o=4,b=160:8f#5,8f#5,8f#5,8d5,8p,8b,8p,8e5,8p,8e5,8p,8e5,8g#5,8g#5,8a5,8b5,8a5,8a5,8a5,8e5,8p,8d5,8p,8f#5,8p,8f#5,8p,8f#5,8e5,8e5,8f#5,8e5", + + "goodbadugly": "TheGoodTheBad:d=4,o=5,b=160:c,8d,8e,8d,c,8d,8e,8d,c,8d,e,8f,2g,8p,a,b,c6,8b,8a,8g,8f,e,8f,g,8e,8d,8c", + + "creeps": "Creeps:d=4,o=5,b=120:8c,8d,8e,8f,g,8e,8f,g,8f,8e,8d,c,8d,8e,f,8d,8e,f,8e,8d,8c,8b4", + + "william_tell": "WilliamTell:d=4,o=5,b=125:8e,8e,8e,2p,8e,8e,8e,2p,8e,8e,8e,8e,8e,8e,8e,8e,8e,8e,8e,8e,8e,8e,e" +} diff --git a/internal_filesystem/lib/mpos/info.py b/internal_filesystem/lib/mpos/info.py index fc1e04e4..84f78e00 100644 --- a/internal_filesystem/lib/mpos/info.py +++ b/internal_filesystem/lib/mpos/info.py @@ -1,4 +1,4 @@ -CURRENT_OS_VERSION = "0.5.0" +CURRENT_OS_VERSION = "0.5.2" # Unique string that defines the hardware, used by OSUpdate and the About app _hardware_id = "missing-hardware-info" diff --git a/internal_filesystem/lib/mpos/lights.py b/internal_filesystem/lib/mpos/lights.py new file mode 100644 index 00000000..2f0d7b7a --- /dev/null +++ b/internal_filesystem/lib/mpos/lights.py @@ -0,0 +1,153 @@ +# LightsManager - Simple LED Control Service for MicroPythonOS +# Provides one-shot LED control for NeoPixel RGB LEDs +# Apps implement custom animations using the update_frame() pattern + +# Module-level state (singleton pattern) +_neopixel = None +_num_leds = 0 + + +def init(neopixel_pin, num_leds=5): + """ + Initialize NeoPixel LEDs. + + Args: + neopixel_pin: GPIO pin number for NeoPixel data line + num_leds: Number of LEDs in the strip (default 5 for Fri3d badge) + """ + global _neopixel, _num_leds + + try: + from machine import Pin + from neopixel import NeoPixel + + _neopixel = NeoPixel(Pin(neopixel_pin, Pin.OUT), num_leds) + _num_leds = num_leds + + # Clear all LEDs on initialization + for i in range(num_leds): + _neopixel[i] = (0, 0, 0) + _neopixel.write() + + print(f"LightsManager initialized: {num_leds} LEDs on GPIO {neopixel_pin}") + except Exception as e: + print(f"LightsManager: Failed to initialize LEDs: {e}") + print(" - LED functions will return False (no-op)") + + +def is_available(): + """ + Check if LED hardware is available. + + Returns: + bool: True if LEDs are initialized and available + """ + return _neopixel is not None + + +def get_led_count(): + """ + Get the number of LEDs. + + Returns: + int: Number of LEDs, or 0 if not initialized + """ + return _num_leds + + +def set_led(index, r, g, b): + """ + Set a single LED color (buffered until write() is called). + + Args: + index: LED index (0 to num_leds-1) + r: Red value (0-255) + g: Green value (0-255) + b: Blue value (0-255) + + Returns: + bool: True if successful, False if LEDs unavailable or invalid index + """ + if not _neopixel: + return False + + if index < 0 or index >= _num_leds: + print(f"LightsManager: Invalid LED index {index} (valid range: 0-{_num_leds-1})") + return False + + _neopixel[index] = (r, g, b) + return True + + +def set_all(r, g, b): + """ + Set all LEDs to the same color (buffered until write() is called). + + Args: + r: Red value (0-255) + g: Green value (0-255) + b: Blue value (0-255) + + Returns: + bool: True if successful, False if LEDs unavailable + """ + if not _neopixel: + return False + + for i in range(_num_leds): + _neopixel[i] = (r, g, b) + return True + + +def clear(): + """ + Clear all LEDs (set to black, buffered until write() is called). + + Returns: + bool: True if successful, False if LEDs unavailable + """ + return set_all(0, 0, 0) + + +def write(): + """ + Update hardware with buffered LED colors. + Must be called after set_led(), set_all(), or clear() to make changes visible. + + Returns: + bool: True if successful, False if LEDs unavailable + """ + if not _neopixel: + return False + + _neopixel.write() + return True + + +def set_notification_color(color_name): + """ + Convenience method to set all LEDs to a common color and update immediately. + + Args: + color_name: Color name (red, green, blue, yellow, orange, purple, white) + + Returns: + bool: True if successful, False if LEDs unavailable or unknown color + """ + colors = { + "red": (255, 0, 0), + "green": (0, 255, 0), + "blue": (0, 0, 255), + "yellow": (255, 255, 0), + "orange": (255, 128, 0), + "purple": (128, 0, 255), + "white": (255, 255, 255), + } + + color = colors.get(color_name.lower()) + if not color: + print(f"LightsManager: Unknown color '{color_name}'") + print(f" - Available colors: {', '.join(colors.keys())}") + return False + + return set_all(*color) and write() diff --git a/internal_filesystem/lib/mpos/main.py b/internal_filesystem/lib/mpos/main.py index 36ea885a..e576195a 100644 --- a/internal_filesystem/lib/mpos/main.py +++ b/internal_filesystem/lib/mpos/main.py @@ -1,6 +1,7 @@ import task_handler import _thread import lvgl as lv +import mpos import mpos.apps import mpos.config import mpos.ui @@ -84,13 +85,32 @@ def custom_exception_handler(e): # 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) + result = mpos.apps.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() +mpos.TaskManager.create_task(asyncio_repl()) # only gets started when mpos.TaskManager.start() is created + +async def ota_rollback_cancel(): try: import ota.rollback ota.rollback.cancel() except Exception as e: print("main.py: warning: could not mark this update as valid:", e) + +if not started_launcher: + print(f"WARNING: launcher {launcher_app} failed to start, not cancelling OTA update rollback") +else: + mpos.TaskManager.create_task(ota_rollback_cancel()) # only gets started when mpos.TaskManager() is created + +try: + mpos.TaskManager.start() # do this at the end because it doesn't return +except KeyboardInterrupt as k: + print(f"mpos.TaskManager() got KeyboardInterrupt, falling back to REPL shell...") # only works if no aiorepl is running +except Exception as e: + print(f"mpos.TaskManager() got exception: {e}") diff --git a/internal_filesystem/lib/mpos/net/__init__.py b/internal_filesystem/lib/mpos/net/__init__.py 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/download_manager.py b/internal_filesystem/lib/mpos/net/download_manager.py new file mode 100644 index 00000000..ed9db2a6 --- /dev/null +++ b/internal_filesystem/lib/mpos/net/download_manager.py @@ -0,0 +1,397 @@ +""" +download_manager.py - Centralized download management for MicroPythonOS + +Provides async HTTP download with flexible output modes: +- Download to memory (returns bytes) +- Download to file (returns bool) +- Streaming with chunk callback (returns bool) + +Features: +- Shared aiohttp.ClientSession for performance +- Automatic session lifecycle management +- Thread-safe session access +- Retry logic (3 attempts per chunk, 10s timeout) +- Progress tracking with 2-decimal precision +- Download speed reporting +- Resume support via Range headers + +Example: + from mpos import DownloadManager + + # Download to memory + data = await DownloadManager.download_url("https://api.example.com/data.json") + + # Download to file with progress and speed + async def on_progress(pct): + print(f"{pct:.2f}%") # e.g., "45.67%" + + async def on_speed(speed_bps): + print(f"{speed_bps / 1024:.1f} KB/s") + + success = await DownloadManager.download_url( + "https://example.com/file.bin", + outfile="/sdcard/file.bin", + progress_callback=on_progress, + speed_callback=on_speed + ) + + # Stream processing + async def process_chunk(chunk): + # Process each chunk as it arrives + pass + + success = await DownloadManager.download_url( + "https://example.com/stream", + chunk_callback=process_chunk + ) +""" + +# Constants +_DEFAULT_CHUNK_SIZE = 1024 # 1KB chunks +_DEFAULT_TOTAL_SIZE = 100 * 1024 # 100KB default if Content-Length missing +_MAX_RETRIES = 3 # Retry attempts per chunk +_CHUNK_TIMEOUT_SECONDS = 10 # Timeout per chunk read +_SPEED_UPDATE_INTERVAL_MS = 1000 # Update speed every 1 second + +# Module-level state (singleton pattern) +_session = None +_session_lock = None +_session_refcount = 0 + + +def _init(): + """Initialize DownloadManager (called automatically on first use).""" + global _session_lock + + if _session_lock is not None: + return # Already initialized + + try: + import _thread + _session_lock = _thread.allocate_lock() + print("DownloadManager: Initialized with thread safety") + except ImportError: + # Desktop mode without threading support (or MicroPython without _thread) + _session_lock = None + print("DownloadManager: Initialized without thread safety") + + +def _get_session(): + """Get or create the shared aiohttp session (thread-safe). + + Returns: + aiohttp.ClientSession or None: The session instance, or None if aiohttp unavailable + """ + global _session, _session_lock + + # Lazy init lock + if _session_lock is None: + _init() + + # Thread-safe session creation + if _session_lock: + _session_lock.acquire() + + try: + if _session is None: + try: + import aiohttp + _session = aiohttp.ClientSession() + print("DownloadManager: Created new aiohttp session") + except ImportError: + print("DownloadManager: aiohttp not available") + return None + return _session + finally: + if _session_lock: + _session_lock.release() + + +async def _close_session_if_idle(): + """Close session if no downloads are active (thread-safe). + + Note: MicroPythonOS aiohttp implementation doesn't require explicit session closing. + Sessions are automatically closed via "Connection: close" header. + This function is kept for potential future enhancements. + """ + global _session, _session_refcount, _session_lock + + if _session_lock: + _session_lock.acquire() + + try: + if _session and _session_refcount == 0: + # MicroPythonOS aiohttp doesn't have close() method + # Sessions close automatically, so just clear the reference + _session = None + print("DownloadManager: Cleared idle session reference") + finally: + if _session_lock: + _session_lock.release() + + +def is_session_active(): + """Check if a session is currently active. + + Returns: + bool: True if session exists and is open + """ + global _session, _session_lock + + if _session_lock: + _session_lock.acquire() + + try: + return _session is not None + finally: + if _session_lock: + _session_lock.release() + + +async def close_session(): + """Explicitly close the session (optional, normally auto-managed). + + Useful for testing or forced cleanup. Session will be recreated + on next download_url() call. + + Note: MicroPythonOS aiohttp implementation doesn't require explicit session closing. + Sessions are automatically closed via "Connection: close" header. + This function clears the session reference to allow garbage collection. + """ + global _session, _session_lock + + if _session_lock: + _session_lock.acquire() + + try: + if _session: + # MicroPythonOS aiohttp doesn't have close() method + # Just clear the reference to allow garbage collection + _session = None + print("DownloadManager: Explicitly cleared session reference") + finally: + if _session_lock: + _session_lock.release() + + +async def download_url(url, outfile=None, total_size=None, + progress_callback=None, chunk_callback=None, headers=None, + speed_callback=None): + """Download a URL with flexible output modes. + + This async download function can be used in 3 ways: + - with just a url => returns the content + - with a url and an outfile => writes the content to the outfile + - with a url and a chunk_callback => calls the chunk_callback(chunk_data) for each chunk + + Args: + url (str): URL to download + outfile (str, optional): Path to write file. If None, returns bytes. + total_size (int, optional): Expected size in bytes for progress tracking. + If None, uses Content-Length header or defaults to 100KB. + progress_callback (coroutine, optional): async def callback(percent: float) + Called with progress 0.00-100.00 (2 decimal places). + Only called when progress changes by at least 0.01%. + chunk_callback (coroutine, optional): async def callback(chunk: bytes) + Called for each chunk. Cannot use with outfile. + headers (dict, optional): HTTP headers (e.g., {'Range': 'bytes=1000-'}) + speed_callback (coroutine, optional): async def callback(bytes_per_second: float) + Called periodically (every ~1 second) with download speed. + + Returns: + bytes: Downloaded content (if outfile and chunk_callback are None) + bool: True if successful, False if failed (when using outfile or chunk_callback) + + Raises: + ValueError: If both outfile and chunk_callback are provided + + Example: + # Download to memory + data = await DownloadManager.download_url("https://example.com/file.json") + + # Download to file with progress and speed + async def on_progress(percent): + print(f"Progress: {percent:.2f}%") + + async def on_speed(bps): + print(f"Speed: {bps / 1024:.1f} KB/s") + + success = await DownloadManager.download_url( + "https://example.com/large.bin", + outfile="/sdcard/large.bin", + progress_callback=on_progress, + speed_callback=on_speed + ) + + # Stream processing + async def on_chunk(chunk): + process(chunk) + + success = await DownloadManager.download_url( + "https://example.com/stream", + chunk_callback=on_chunk + ) + """ + # Validate parameters + if outfile and chunk_callback: + raise ValueError( + "Cannot use both outfile and chunk_callback. " + "Use outfile for saving to disk, or chunk_callback for streaming." + ) + + # Lazy init + if _session_lock is None: + _init() + + # Get/create session + session = _get_session() + if session is None: + print("DownloadManager: Cannot download, aiohttp not available") + return False if (outfile or chunk_callback) else None + + # Increment refcount + global _session_refcount + if _session_lock: + _session_lock.acquire() + _session_refcount += 1 + if _session_lock: + _session_lock.release() + + print(f"DownloadManager: Downloading {url}") + + fd = None + try: + # Ensure headers is a dict (aiohttp expects dict, not None) + if headers is None: + headers = {} + + async with session.get(url, headers=headers) as response: + if response.status < 200 or response.status >= 400: + print(f"DownloadManager: HTTP error {response.status}") + return False if (outfile or chunk_callback) else None + + # Figure out total size + print("DownloadManager: Response headers:", response.headers) + if total_size is None: + # response.headers is a dict (after parsing) or None/list (before parsing) + try: + if isinstance(response.headers, dict): + content_length = response.headers.get('Content-Length') + if content_length: + total_size = int(content_length) + except (AttributeError, TypeError, ValueError) as e: + print(f"DownloadManager: Could not parse Content-Length: {e}") + + if total_size is None: + print(f"DownloadManager: WARNING: Unable to determine total_size, assuming {_DEFAULT_TOTAL_SIZE} bytes") + total_size = _DEFAULT_TOTAL_SIZE + + # Setup output + if outfile: + fd = open(outfile, 'wb') + if not fd: + print(f"DownloadManager: WARNING: could not open {outfile} for writing!") + return False + + chunks = [] + partial_size = 0 + chunk_size = _DEFAULT_CHUNK_SIZE + + # Progress tracking with 2-decimal precision + last_progress_pct = -1.0 # Track last reported progress to avoid duplicates + + # Speed tracking + speed_bytes_since_last_update = 0 + speed_last_update_time = None + try: + import time + speed_last_update_time = time.ticks_ms() + except ImportError: + pass # time module not available + + print(f"DownloadManager: {'Writing to ' + outfile if outfile else 'Downloading'} {total_size} bytes in chunks of size {chunk_size}") + + # Download loop with retry logic + while True: + tries_left = _MAX_RETRIES + chunk_data = None + while tries_left > 0: + try: + # Import TaskManager here to avoid circular imports + from mpos import TaskManager + chunk_data = await TaskManager.wait_for( + response.content.read(chunk_size), + _CHUNK_TIMEOUT_SECONDS + ) + break + except Exception as e: + print(f"DownloadManager: Chunk read error: {e}") + tries_left -= 1 + + if tries_left == 0: + print("DownloadManager: ERROR: failed to download chunk after retries!") + if fd: + fd.close() + return False if (outfile or chunk_callback) else None + + if chunk_data: + # Output chunk + if fd: + fd.write(chunk_data) + elif chunk_callback: + await chunk_callback(chunk_data) + else: + chunks.append(chunk_data) + + # Track bytes for speed calculation + chunk_len = len(chunk_data) + partial_size += chunk_len + speed_bytes_since_last_update += chunk_len + + # Report progress with 2-decimal precision + # Only call callback if progress changed by at least 0.01% + progress_pct = round((partial_size * 100) / int(total_size), 2) + if progress_callback and progress_pct != last_progress_pct: + print(f"DownloadManager: Progress: {partial_size} / {total_size} bytes = {progress_pct:.2f}%") + await progress_callback(progress_pct) + last_progress_pct = progress_pct + + # Report speed periodically + if speed_callback and speed_last_update_time is not None: + import time + current_time = time.ticks_ms() + elapsed_ms = time.ticks_diff(current_time, speed_last_update_time) + if elapsed_ms >= _SPEED_UPDATE_INTERVAL_MS: + # Calculate bytes per second + bytes_per_second = (speed_bytes_since_last_update * 1000) / elapsed_ms + await speed_callback(bytes_per_second) + # Reset for next interval + speed_bytes_since_last_update = 0 + speed_last_update_time = current_time + else: + # Chunk is None, download complete + print(f"DownloadManager: Finished downloading {url}") + if fd: + fd.close() + fd = None + return True + elif chunk_callback: + return True + else: + return b''.join(chunks) + + except Exception as e: + print(f"DownloadManager: Exception during download: {e}") + if fd: + fd.close() + return False if (outfile or chunk_callback) else None + finally: + # Decrement refcount + if _session_lock: + _session_lock.acquire() + _session_refcount -= 1 + if _session_lock: + _session_lock.release() + + # Close session if idle + await _close_session_if_idle() diff --git a/internal_filesystem/lib/mpos/net/wifi_service.py b/internal_filesystem/lib/mpos/net/wifi_service.py index bfa76188..25d777a7 100644 --- a/internal_filesystem/lib/mpos/net/wifi_service.py +++ b/internal_filesystem/lib/mpos/net/wifi_service.py @@ -197,6 +197,63 @@ def auto_connect(network_module=None, time_module=None): WifiService.wifi_busy = False print("WifiService: Auto-connect thread finished") + @staticmethod + def temporarily_disable(network_module=None): + """ + Temporarily disable WiFi for operations that require it (e.g., ESP32-S3 ADC2). + + This method sets wifi_busy flag and disconnects WiFi if connected. + Caller must call temporarily_enable() in a finally block. + + Args: + network_module: Network module for dependency injection (testing) + + Returns: + bool: True if WiFi was connected before disabling, False otherwise + + Raises: + RuntimeError: If WiFi operations are already in progress + """ + if WifiService.wifi_busy: + raise RuntimeError("Cannot disable WiFi: WifiService is already busy") + + # Check actual connection status BEFORE setting wifi_busy + was_connected = False + if HAS_NETWORK_MODULE or network_module: + try: + net = network_module if network_module else network + wlan = net.WLAN(net.STA_IF) + was_connected = wlan.isconnected() + except Exception as e: + print(f"WifiService: Error checking connection: {e}") + + # Now set busy flag and disconnect + WifiService.wifi_busy = True + WifiService.disconnect(network_module=network_module) + + return was_connected + + @staticmethod + def temporarily_enable(was_connected, network_module=None): + """ + Re-enable WiFi after temporary disable operation. + + Must be called in a finally block after temporarily_disable(). + + Args: + was_connected: Return value from temporarily_disable() + network_module: Network module for dependency injection (testing) + """ + WifiService.wifi_busy = False + + # Only reconnect if WiFi was connected before we disabled it + if was_connected: + try: + import _thread + _thread.start_new_thread(WifiService.auto_connect, ()) + except Exception as e: + print(f"WifiService: Failed to start reconnect thread: {e}") + @staticmethod def is_connected(network_module=None): """ @@ -247,7 +304,8 @@ def disconnect(network_module=None): wlan.active(False) print("WifiService: Disconnected and WiFi disabled") except Exception as e: - print(f"WifiService: Error disconnecting: {e}") + #print(f"WifiService: Error disconnecting: {e}") # probably "Wifi Not Started" so harmless + pass @staticmethod def get_saved_networks(): diff --git a/internal_filesystem/lib/mpos/sensor_manager.py b/internal_filesystem/lib/mpos/sensor_manager.py new file mode 100644 index 00000000..8068c73c --- /dev/null +++ b/internal_filesystem/lib/mpos/sensor_manager.py @@ -0,0 +1,898 @@ +"""Android-inspired SensorManager for MicroPythonOS. + +Provides unified access to IMU sensors (QMI8658, WSEN_ISDS) and other sensors. +Follows module-level singleton pattern (like AudioFlinger, LightsManager). + +Example usage: + import mpos.sensor_manager as SensorManager + + # In board init file: + SensorManager.init(i2c_bus, address=0x6B) + + # In app: + if SensorManager.is_available(): + accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) + ax, ay, az = SensorManager.read_sensor(accel) # Returns m/s² + +MIT License +Copyright (c) 2024 MicroPythonOS contributors +""" + +import time +try: + import _thread + _lock = _thread.allocate_lock() +except ImportError: + _lock = None + + +# Sensor type constants (matching Android SensorManager) +TYPE_ACCELEROMETER = 1 # Units: m/s² (meters per second squared) +TYPE_GYROSCOPE = 4 # Units: deg/s (degrees per second) +TYPE_TEMPERATURE = 13 # Units: °C (generic, returns first available - deprecated) +TYPE_IMU_TEMPERATURE = 14 # Units: °C (IMU chip temperature) +TYPE_SOC_TEMPERATURE = 15 # Units: °C (MCU/SoC internal temperature) + +# mounted_position: +FACING_EARTH = 20 # underside of PCB, like fri3d_2024 +FACING_SKY = 21 # top of PCB, like waveshare_esp32_s3_lcd_touch_2 (default) + +# Gravity constant for unit conversions +_GRAVITY = 9.80665 # m/s² + +IMU_CALIBRATION_FILENAME = "imu_calibration.json" + +# Module state +_initialized = False +_imu_driver = None +_sensor_list = [] +_i2c_bus = None +_i2c_address = None +_mounted_position = FACING_SKY +_has_mcu_temperature = False + + +class Sensor: + """Sensor metadata (lightweight data class, Android-inspired).""" + + def __init__(self, name, sensor_type, vendor, version, max_range, resolution, power_ma): + """Initialize sensor metadata. + + Args: + name: Human-readable sensor name + sensor_type: Sensor type constant (TYPE_ACCELEROMETER, etc.) + vendor: Sensor vendor/manufacturer + version: Driver version + max_range: Maximum measurement range (with units) + resolution: Measurement resolution (with units) + power_ma: Power consumption in mA (or 0 if unknown) + """ + self.name = name + self.type = sensor_type + self.vendor = vendor + self.version = version + self.max_range = max_range + self.resolution = resolution + self.power = power_ma + + def __repr__(self): + return f"Sensor({self.name}, type={self.type})" + + +def init(i2c_bus, address=0x6B, mounted_position=FACING_SKY): + """Initialize SensorManager. MCU temperature initializes immediately, IMU initializes on first use. + + Args: + i2c_bus: machine.I2C instance (can be None if only MCU temperature needed) + address: I2C address (default 0x6B for both QMI8658 and WSEN_ISDS) + + Returns: + bool: True if initialized successfully + """ + global _i2c_bus, _i2c_address, _initialized, _has_mcu_temperature, _mounted_position + + _i2c_bus = i2c_bus + _i2c_address = address + _mounted_position = mounted_position + + # Initialize MCU temperature sensor immediately (fast, no I2C needed) + try: + import esp32 + _ = esp32.mcu_temperature() + _has_mcu_temperature = True + _register_mcu_temperature_sensor() + except: + pass + + _initialized = True + return True + + +def _ensure_imu_initialized(): + """Perform IMU initialization on first use (lazy initialization). + + Tries to detect QMI8658 (chip ID 0x05) or WSEN_ISDS (WHO_AM_I 0x6A). + Loads calibration from SharedPreferences if available. + + Returns: + bool: True if IMU detected and initialized successfully + """ + global _imu_driver, _sensor_list + + if not _initialized or _imu_driver is not None: + return _imu_driver is not None + + # Try QMI8658 first (Waveshare board) + if _i2c_bus: + try: + from mpos.hardware.drivers.qmi8658 import QMI8658 + chip_id = _i2c_bus.readfrom_mem(_i2c_address, 0x00, 1)[0] # PARTID register + if chip_id == 0x05: # QMI8685_PARTID + _imu_driver = _QMI8658Driver(_i2c_bus, _i2c_address) + _register_qmi8658_sensors() + _load_calibration() + return True + except: + pass + + # Try WSEN_ISDS (Fri3d badge) + try: + from mpos.hardware.drivers.wsen_isds import Wsen_Isds + chip_id = _i2c_bus.readfrom_mem(_i2c_address, 0x0F, 1)[0] # WHO_AM_I register + if chip_id == 0x6A: # WSEN_ISDS WHO_AM_I + _imu_driver = _WsenISDSDriver(_i2c_bus, _i2c_address) + _register_wsen_isds_sensors() + _load_calibration() + return True + except: + pass + + return False + + +def is_available(): + """Check if sensors are available. + + Does NOT trigger IMU initialization (to avoid boot-time initialization). + Use get_default_sensor() or read_sensor() to lazily initialize IMU. + + Returns: + bool: True if SensorManager is initialized (may only have MCU temp, not IMU) + """ + return _initialized + + +def get_sensor_list(): + """Get list of all available sensors. + + Performs lazy IMU initialization on first call. + + Returns: + list: List of Sensor objects + """ + _ensure_imu_initialized() + return _sensor_list.copy() if _sensor_list else [] + + +def get_default_sensor(sensor_type): + """Get default sensor of given type. + + Performs lazy IMU initialization on first call. + + Args: + sensor_type: Sensor type constant (TYPE_ACCELEROMETER, etc.) + + Returns: + Sensor object or None if not available + """ + # Only initialize IMU if requesting IMU sensor types + if sensor_type in (TYPE_ACCELEROMETER, TYPE_GYROSCOPE): + _ensure_imu_initialized() + + for sensor in _sensor_list: + if sensor.type == sensor_type: + return sensor + return None + + +def read_sensor(sensor): + """Read sensor data synchronously. + + Performs lazy IMU initialization on first call for IMU sensors. + + Args: + sensor: Sensor object from get_default_sensor() + + Returns: + For motion sensors: tuple (x, y, z) in appropriate units + For scalar sensors: single value + None if sensor not available or error + """ + if sensor is None: + return None + + # Only initialize IMU if reading IMU sensor + if sensor.type in (TYPE_ACCELEROMETER, TYPE_GYROSCOPE): + _ensure_imu_initialized() + + if _lock: + _lock.acquire() + + try: + # Retry logic for "sensor data not ready" (WSEN_ISDS needs time after init) + max_retries = 3 + retry_delay_ms = 20 # Wait 20ms between retries + + for attempt in range(max_retries): + try: + if sensor.type == TYPE_ACCELEROMETER: + if _imu_driver: + ax, ay, az = _imu_driver.read_acceleration() + if _mounted_position == FACING_EARTH: + az *= -1 + return (ax, ay, az) + elif sensor.type == TYPE_GYROSCOPE: + if _imu_driver: + return _imu_driver.read_gyroscope() + elif sensor.type == TYPE_IMU_TEMPERATURE: + if _imu_driver: + return _imu_driver.read_temperature() + elif sensor.type == TYPE_SOC_TEMPERATURE: + if _has_mcu_temperature: + import esp32 + return esp32.mcu_temperature() + elif sensor.type == TYPE_TEMPERATURE: + # Generic temperature - return first available (backward compatibility) + if _imu_driver: + temp = _imu_driver.read_temperature() + if temp is not None: + return temp + if _has_mcu_temperature: + import esp32 + return esp32.mcu_temperature() + return None + except Exception as e: + error_msg = str(e) + # Retry if sensor data not ready, otherwise fail immediately + if "data not ready" in error_msg and attempt < max_retries - 1: + import time + time.sleep_ms(retry_delay_ms) + continue + else: + return None + + return None + finally: + if _lock: + _lock.release() + + +def calibrate_sensor(sensor, samples=100): + """Calibrate sensor and save to SharedPreferences. + + Performs lazy IMU initialization on first call. + Device must be stationary for accelerometer/gyroscope calibration. + + Args: + sensor: Sensor object to calibrate + samples: Number of samples to average (default 100) + + Returns: + tuple: Calibration offsets (x, y, z) or None if failed + """ + _ensure_imu_initialized() + if not is_available() or sensor is None: + return None + + if _lock: + _lock.acquire() + + try: + if sensor.type == TYPE_ACCELEROMETER: + offsets = _imu_driver.calibrate_accelerometer(samples) + elif sensor.type == TYPE_GYROSCOPE: + offsets = _imu_driver.calibrate_gyroscope(samples) + else: + return None + + if offsets: + _save_calibration() + + return offsets + except Exception as e: + print(f"[SensorManager] Calibration error: {e}") + return None + finally: + if _lock: + _lock.release() + + +# Helper functions for calibration quality checking (module-level to avoid nested def issues) +def _calc_mean_variance(samples_list): + """Calculate mean and variance for a list of samples.""" + if not samples_list: + return 0.0, 0.0 + n = len(samples_list) + mean = sum(samples_list) / n + variance = sum((x - mean) ** 2 for x in samples_list) / n + return mean, variance + + +def _calc_variance(samples_list): + """Calculate variance for a list of samples.""" + if not samples_list: + return 0.0 + n = len(samples_list) + mean = sum(samples_list) / n + return sum((x - mean) ** 2 for x in samples_list) / n + + +def check_calibration_quality(samples=50): + """Check quality of current calibration. + + Performs lazy IMU initialization on first call. + + Args: + samples: Number of samples to collect (default 50) + + Returns: + dict with: + - accel_mean: (x, y, z) mean values in m/s² + - accel_variance: (x, y, z) variance values + - gyro_mean: (x, y, z) mean values in deg/s + - gyro_variance: (x, y, z) variance values + - quality_score: float 0.0-1.0 (1.0 = perfect) + - quality_rating: string ("Good", "Fair", "Poor") + - issues: list of strings describing problems + None if IMU not available + """ + _ensure_imu_initialized() + if not is_available(): + return None + + # Don't acquire lock here - let read_sensor() handle it per-read + # (avoids deadlock since read_sensor also acquires the lock) + try: + accel = get_default_sensor(TYPE_ACCELEROMETER) + gyro = get_default_sensor(TYPE_GYROSCOPE) + + # Collect samples + accel_samples = [[], [], []] # x, y, z lists + gyro_samples = [[], [], []] + + for _ in range(samples): + if accel: + data = read_sensor(accel) + if data: + ax, ay, az = data + accel_samples[0].append(ax) + accel_samples[1].append(ay) + accel_samples[2].append(az) + if gyro: + data = read_sensor(gyro) + if data: + gx, gy, gz = data + gyro_samples[0].append(gx) + gyro_samples[1].append(gy) + gyro_samples[2].append(gz) + time.sleep_ms(10) + + # Calculate statistics using module-level helper + accel_stats = [_calc_mean_variance(s) for s in accel_samples] + gyro_stats = [_calc_mean_variance(s) for s in gyro_samples] + + accel_mean = tuple(s[0] for s in accel_stats) + accel_variance = tuple(s[1] for s in accel_stats) + gyro_mean = tuple(s[0] for s in gyro_stats) + gyro_variance = tuple(s[1] for s in gyro_stats) + + # Calculate quality score (0.0 - 1.0) + issues = [] + scores = [] + + # Check accelerometer + if accel: + # Variance check (lower is better) + accel_max_variance = max(accel_variance) + variance_score = max(0.0, 1.0 - (accel_max_variance / 1.0)) # 1.0 m/s² variance threshold + scores.append(variance_score) + if accel_max_variance > 0.5: + issues.append(f"High accelerometer variance: {accel_max_variance:.3f} m/s²") + + # Expected values check (Xā‰ˆ0, Yā‰ˆ0, Zā‰ˆ9.8) + ax, ay, az = accel_mean + xy_error = (abs(ax) + abs(ay)) / 2.0 + z_error = abs(az - _GRAVITY) + expected_score = max(0.0, 1.0 - ((xy_error + z_error) / 5.0)) # 5.0 m/s² error threshold + scores.append(expected_score) + if xy_error > 1.0: + issues.append(f"Accel X/Y not near zero: X={ax:.2f}, Y={ay:.2f} m/s²") + if z_error > 1.0: + issues.append(f"Accel Z not near 9.8: Z={az:.2f} m/s²") + + # Check gyroscope + if gyro: + # Variance check + gyro_max_variance = max(gyro_variance) + variance_score = max(0.0, 1.0 - (gyro_max_variance / 10.0)) # 10 deg/s variance threshold + scores.append(variance_score) + if gyro_max_variance > 5.0: + issues.append(f"High gyroscope variance: {gyro_max_variance:.3f} deg/s") + + # Expected values check (all ā‰ˆ0) + gx, gy, gz = gyro_mean + error = (abs(gx) + abs(gy) + abs(gz)) / 3.0 + expected_score = max(0.0, 1.0 - (error / 10.0)) # 10 deg/s error threshold + scores.append(expected_score) + if error > 2.0: + issues.append(f"Gyro not near zero: X={gx:.2f}, Y={gy:.2f}, Z={gz:.2f} deg/s") + + # Overall quality score + quality_score = sum(scores) / len(scores) if scores else 0.0 + + # Rating + if quality_score >= 0.8: + quality_rating = "Good" + elif quality_score >= 0.5: + quality_rating = "Fair" + else: + quality_rating = "Poor" + + return { + 'accel_mean': accel_mean, + 'accel_variance': accel_variance, + 'gyro_mean': gyro_mean, + 'gyro_variance': gyro_variance, + 'quality_score': quality_score, + 'quality_rating': quality_rating, + 'issues': issues + } + + except Exception as e: + print(f"[SensorManager] Error checking calibration quality: {e}") + return None + + +def check_stationarity(samples=30, variance_threshold_accel=0.5, variance_threshold_gyro=5.0): + """Check if device is stationary (required for calibration). + + Args: + samples: Number of samples to collect (default 30) + variance_threshold_accel: Max acceptable accel variance in m/s² (default 0.5) + variance_threshold_gyro: Max acceptable gyro variance in deg/s (default 5.0) + + Returns: + dict with: + - is_stationary: bool + - accel_variance: max variance across axes + - gyro_variance: max variance across axes + - message: string describing result + None if IMU not available + """ + _ensure_imu_initialized() + if not is_available(): + return None + + # Don't acquire lock here - let read_sensor() handle it per-read + # (avoids deadlock since read_sensor also acquires the lock) + try: + accel = get_default_sensor(TYPE_ACCELEROMETER) + gyro = get_default_sensor(TYPE_GYROSCOPE) + + # Collect samples + accel_samples = [[], [], []] + gyro_samples = [[], [], []] + + for _ in range(samples): + if accel: + data = read_sensor(accel) + if data: + ax, ay, az = data + accel_samples[0].append(ax) + accel_samples[1].append(ay) + accel_samples[2].append(az) + if gyro: + data = read_sensor(gyro) + if data: + gx, gy, gz = data + gyro_samples[0].append(gx) + gyro_samples[1].append(gy) + gyro_samples[2].append(gz) + time.sleep_ms(10) + + # Calculate variance using module-level helper + accel_var = [_calc_variance(s) for s in accel_samples] + gyro_var = [_calc_variance(s) for s in gyro_samples] + + max_accel_var = max(accel_var) if accel_var else 0.0 + max_gyro_var = max(gyro_var) if gyro_var else 0.0 + + # Check thresholds + accel_stationary = max_accel_var < variance_threshold_accel + gyro_stationary = max_gyro_var < variance_threshold_gyro + is_stationary = accel_stationary and gyro_stationary + + # Generate message + if is_stationary: + message = "Device is stationary - ready to calibrate" + else: + problems = [] + if not accel_stationary: + problems.append(f"movement detected (accel variance: {max_accel_var:.3f})") + if not gyro_stationary: + problems.append(f"rotation detected (gyro variance: {max_gyro_var:.3f})") + message = f"Device NOT stationary: {', '.join(problems)}" + + return { + 'is_stationary': is_stationary, + 'accel_variance': max_accel_var, + 'gyro_variance': max_gyro_var, + 'message': message + } + + except Exception as e: + print(f"[SensorManager] Error checking stationarity: {e}") + return None + + +# ============================================================================ +# Internal driver abstraction layer +# ============================================================================ + +class _IMUDriver: + """Base class for IMU drivers (internal use only).""" + + def read_acceleration(self): + """Returns (x, y, z) in m/s²""" + raise NotImplementedError + + def read_gyroscope(self): + """Returns (x, y, z) in deg/s""" + raise NotImplementedError + + def read_temperature(self): + """Returns temperature in °C""" + raise NotImplementedError + + def calibrate_accelerometer(self, samples): + """Calibrate accel, return (x, y, z) offsets in m/s²""" + raise NotImplementedError + + def calibrate_gyroscope(self, samples): + """Calibrate gyro, return (x, y, z) offsets in deg/s""" + raise NotImplementedError + + def get_calibration(self): + """Return dict with 'accel_offsets' and 'gyro_offsets' keys""" + raise NotImplementedError + + def set_calibration(self, accel_offsets, gyro_offsets): + """Set calibration offsets from saved values""" + raise NotImplementedError + + +class _QMI8658Driver(_IMUDriver): + """Wrapper for QMI8658 IMU (Waveshare board).""" + + def __init__(self, i2c_bus, address): + from mpos.hardware.drivers.qmi8658 import QMI8658 + # QMI8658 scale constants (can't import const() values) + _ACCELSCALE_RANGE_8G = 0b10 + _GYROSCALE_RANGE_256DPS = 0b100 + self.sensor = QMI8658( + i2c_bus, + address=address, + accel_scale=_ACCELSCALE_RANGE_8G, + gyro_scale=_GYROSCALE_RANGE_256DPS + ) + # Software calibration offsets (QMI8658 has no built-in calibration) + self.accel_offset = [0.0, 0.0, 0.0] + self.gyro_offset = [0.0, 0.0, 0.0] + + def read_acceleration(self): + """Read acceleration in m/s² (converts from G).""" + ax, ay, az = self.sensor.acceleration + # Convert G to m/s² and apply calibration + return ( + (ax * _GRAVITY) - self.accel_offset[0], + (ay * _GRAVITY) - self.accel_offset[1], + (az * _GRAVITY) - self.accel_offset[2] + ) + + def read_gyroscope(self): + """Read gyroscope in deg/s (already in correct units).""" + gx, gy, gz = self.sensor.gyro + # Apply calibration + return ( + gx - self.gyro_offset[0], + gy - self.gyro_offset[1], + gz - self.gyro_offset[2] + ) + + def read_temperature(self): + """Read temperature in °C.""" + return self.sensor.temperature + + def calibrate_accelerometer(self, samples): + """Calibrate accelerometer (device must be stationary).""" + sum_x, sum_y, sum_z = 0.0, 0.0, 0.0 + + for _ in range(samples): + ax, ay, az = self.sensor.acceleration + sum_x += ax * _GRAVITY + sum_y += ay * _GRAVITY + sum_z += az * _GRAVITY + time.sleep_ms(10) + + if _mounted_position == FACING_EARTH: + sum_z *= -1 + + # Average offsets (assuming Z-axis should read +9.8 m/s²) + self.accel_offset[0] = sum_x / samples + self.accel_offset[1] = sum_y / samples + self.accel_offset[2] = (sum_z / samples) - _GRAVITY + + return tuple(self.accel_offset) + + def calibrate_gyroscope(self, samples): + """Calibrate gyroscope (device must be stationary).""" + sum_x, sum_y, sum_z = 0.0, 0.0, 0.0 + + for _ in range(samples): + gx, gy, gz = self.sensor.gyro + sum_x += gx + sum_y += gy + sum_z += gz + time.sleep_ms(10) + + # Average offsets (should be 0 when stationary) + self.gyro_offset[0] = sum_x / samples + self.gyro_offset[1] = sum_y / samples + self.gyro_offset[2] = sum_z / samples + + return tuple(self.gyro_offset) + + def get_calibration(self): + """Get current calibration.""" + return { + 'accel_offsets': self.accel_offset, + 'gyro_offsets': self.gyro_offset + } + + def set_calibration(self, accel_offsets, gyro_offsets): + """Set calibration from saved values.""" + if accel_offsets: + self.accel_offset = list(accel_offsets) + if gyro_offsets: + self.gyro_offset = list(gyro_offsets) + + +class _WsenISDSDriver(_IMUDriver): + """Wrapper for WSEN_ISDS IMU (Fri3d badge).""" + + def __init__(self, i2c_bus, address): + from mpos.hardware.drivers.wsen_isds import Wsen_Isds + self.sensor = Wsen_Isds( + i2c_bus, + address=address, + acc_range="8g", + acc_data_rate="104Hz", + gyro_range="500dps", + gyro_data_rate="104Hz" + ) + # Software calibration offsets + self.accel_offset = [0.0, 0.0, 0.0] + self.gyro_offset = [0.0, 0.0, 0.0] + + + def read_acceleration(self): + + """Read acceleration in m/s² (converts from mg).""" + ax, ay, az = self.sensor._read_raw_accelerations() + + # Convert G to m/s² and apply calibration + return ( + ((ax / 1000) * _GRAVITY) - self.accel_offset[0], + ((ay / 1000) * _GRAVITY) - self.accel_offset[1], + ((az / 1000) * _GRAVITY) - self.accel_offset[2] + ) + + + def read_gyroscope(self): + """Read gyroscope in deg/s (converts from mdps).""" + gx, gy, gz = self.sensor._read_raw_angular_velocities() + # Convert mdps to deg/s and apply calibration + return ( + gx / 1000.0 - self.gyro_offset[0], + gy / 1000.0 - self.gyro_offset[1], + gz / 1000.0 - self.gyro_offset[2] + ) + + def read_temperature(self): + return self.sensor.temperature + + def calibrate_accelerometer(self, samples): + """Calibrate accelerometer (device must be stationary).""" + sum_x, sum_y, sum_z = 0.0, 0.0, 0.0 + + for _ in range(samples): + ax, ay, az = self.sensor._read_raw_accelerations() + sum_x += (ax / 1000.0) * _GRAVITY + sum_y += (ay / 1000.0) * _GRAVITY + sum_z += (az / 1000.0) * _GRAVITY + time.sleep_ms(10) + + print(f"sumz: {sum_z}") + z_offset = 0 + if _mounted_position == FACING_EARTH: + sum_z *= -1 + print(f"sumz: {sum_z}") + + # Average offsets (assuming Z-axis should read +9.8 m/s²) + self.accel_offset[0] = sum_x / samples + self.accel_offset[1] = sum_y / samples + self.accel_offset[2] = (sum_z / samples) - _GRAVITY + print(f"offsets: {self.accel_offset}") + + return tuple(self.accel_offset) + + def calibrate_gyroscope(self, samples): + """Calibrate gyroscope (device must be stationary).""" + sum_x, sum_y, sum_z = 0.0, 0.0, 0.0 + + for _ in range(samples): + gx, gy, gz = self.sensor._read_raw_angular_velocities() + sum_x += gx / 1000.0 + sum_y += gy / 1000.0 + sum_z += gz / 1000.0 + time.sleep_ms(10) + + # Average offsets (should be 0 when stationary) + self.gyro_offset[0] = sum_x / samples + self.gyro_offset[1] = sum_y / samples + self.gyro_offset[2] = sum_z / samples + + return tuple(self.gyro_offset) + + def get_calibration(self): + """Get current calibration.""" + return { + 'accel_offsets': self.accel_offset, + 'gyro_offsets': self.gyro_offset + } + + def set_calibration(self, accel_offsets, gyro_offsets): + """Set calibration from saved values.""" + if accel_offsets: + self.accel_offset = list(accel_offsets) + if gyro_offsets: + self.gyro_offset = list(gyro_offsets) + + +# ============================================================================ +# Sensor registration (internal) +# ============================================================================ + +def _register_qmi8658_sensors(): + """Register QMI8658 sensors in sensor list.""" + global _sensor_list + _sensor_list = [ + Sensor( + name="QMI8658 Accelerometer", + sensor_type=TYPE_ACCELEROMETER, + vendor="QST Corporation", + version=1, + max_range="±8G (78.4 m/s²)", + resolution="0.0024 m/s²", + power_ma=0.2 + ), + Sensor( + name="QMI8658 Gyroscope", + sensor_type=TYPE_GYROSCOPE, + vendor="QST Corporation", + version=1, + max_range="±256 deg/s", + resolution="0.002 deg/s", + power_ma=0.7 + ), + Sensor( + name="QMI8658 Temperature", + sensor_type=TYPE_IMU_TEMPERATURE, + vendor="QST Corporation", + version=1, + max_range="-40°C to +85°C", + resolution="0.004°C", + power_ma=0 + ) + ] + + +def _register_wsen_isds_sensors(): + """Register WSEN_ISDS sensors in sensor list.""" + global _sensor_list + _sensor_list = [ + Sensor( + name="WSEN_ISDS Accelerometer", + sensor_type=TYPE_ACCELEROMETER, + vendor="Würth Elektronik", + version=1, + max_range="±8G (78.4 m/s²)", + resolution="0.0024 m/s²", + power_ma=0.2 + ), + Sensor( + name="WSEN_ISDS Gyroscope", + sensor_type=TYPE_GYROSCOPE, + vendor="Würth Elektronik", + version=1, + max_range="±500 deg/s", + resolution="0.0175 deg/s", + power_ma=0.65 + ), + Sensor( + name="WSEN_ISDS Temperature", + sensor_type=TYPE_IMU_TEMPERATURE, + vendor="Würth Elektronik", + version=1, + max_range="-40°C to +85°C", + resolution="0.004°C", + power_ma=0 + ) + ] + + +def _register_mcu_temperature_sensor(): + """Register MCU internal temperature sensor in sensor list.""" + global _sensor_list + _sensor_list.append( + Sensor( + name="ESP32 MCU Temperature", + sensor_type=TYPE_SOC_TEMPERATURE, + vendor="Espressif", + version=1, + max_range="-40°C to +125°C", + resolution="0.5°C", + power_ma=0 + ) + ) + + +# ============================================================================ +# Calibration persistence (internal) +# ============================================================================ + +def _load_calibration(): + """Load calibration from SharedPreferences (with migration support).""" + if not _imu_driver: + return + + try: + from mpos.config import SharedPreferences + + # Try NEW location first + prefs_new = SharedPreferences("com.micropythonos.settings", filename=IMU_CALIBRATION_FILENAME) + accel_offsets = prefs_new.get_list("accel_offsets") + gyro_offsets = prefs_new.get_list("gyro_offsets") + + if accel_offsets or gyro_offsets: + _imu_driver.set_calibration(accel_offsets, gyro_offsets) + except: + pass + + +def _save_calibration(): + """Save calibration to SharedPreferences.""" + if not _imu_driver: + return + + try: + from mpos.config import SharedPreferences + prefs = SharedPreferences("com.micropythonos.settings", filename=IMU_CALIBRATION_FILENAME) + editor = prefs.edit() + + cal = _imu_driver.get_calibration() + editor.put_list("accel_offsets", list(cal['accel_offsets'])) + editor.put_list("gyro_offsets", list(cal['gyro_offsets'])) + editor.commit() + except: + pass diff --git a/internal_filesystem/lib/mpos/task_manager.py b/internal_filesystem/lib/mpos/task_manager.py new file mode 100644 index 00000000..995bb5b1 --- /dev/null +++ b/internal_filesystem/lib/mpos/task_manager.py @@ -0,0 +1,72 @@ +import asyncio # this is the only place where asyncio is allowed to be imported - apps should not use it directly but use this TaskManager +import _thread +import mpos.apps + +class TaskManager: + + task_list = [] # might be good to periodically remove tasks that are done, to prevent this list from growing huge + keep_running = None + disabled = False + + @classmethod + async def _asyncio_thread(cls, sleep_ms): + print("asyncio_thread started") + while cls.keep_running is True: + #print(f"asyncio_thread tick because cls.keep_running:{cls.keep_running}") + # According to the docs, lv.timer_handler should be called periodically, but everything seems to work fine without it. + # Perhaps lvgl_micropython is doing this somehow, although I can't find it... I guess the task_handler...? + # sleep_ms can't handle too big values, so limit it to 30 ms, which equals 33 fps + # sleep_ms = min(lv.timer_handler(), 30) # lv.timer_handler() will return LV_NO_TIMER_READY (UINT32_MAX) if there are no running timers + await asyncio.sleep_ms(sleep_ms) + print("WARNING: asyncio_thread exited, now asyncio.create_task() won't work anymore") + + @classmethod + def start(cls): + if cls.disabled is True: + print("Not starting TaskManager because it's been disabled.") + return + cls.keep_running = True + # New thread works but LVGL isn't threadsafe so it's preferred to do this in the same thread: + #_thread.stack_size(mpos.apps.good_stack_size()) + #_thread.start_new_thread(asyncio.run, (self._asyncio_thread(100), )) + # Same thread works, although it blocks the real REPL, but aiorepl works: + asyncio.run(TaskManager._asyncio_thread(10)) # 100ms is too high, causes lag. 10ms is fine. not sure if 1ms would be better... + + @classmethod + def stop(cls): + cls.keep_running = False + + @classmethod + def enable(cls): + cls.disabled = False + + @classmethod + def disable(cls): + cls.disabled = True + + @classmethod + def create_task(cls, coroutine): + task = asyncio.create_task(coroutine) + cls.task_list.append(task) + return task + + @classmethod + def list_tasks(cls): + for index, task in enumerate(cls.task_list): + print(f"task {index}: ph_key:{task.ph_key} done:{task.done()} running {task.coro}") + + @staticmethod + def sleep_ms(ms): + return asyncio.sleep_ms(ms) + + @staticmethod + def sleep(s): + return asyncio.sleep(s) + + @staticmethod + def notify_event(): + return asyncio.Event() + + @staticmethod + def wait_for(awaitable, timeout): + return asyncio.wait_for(awaitable, timeout) diff --git a/internal_filesystem/lib/mpos/testing/__init__.py b/internal_filesystem/lib/mpos/testing/__init__.py new file mode 100644 index 00000000..cb0d219a --- /dev/null +++ b/internal_filesystem/lib/mpos/testing/__init__.py @@ -0,0 +1,85 @@ +""" +MicroPythonOS Testing Module + +Provides mock implementations for testing without actual hardware. +These mocks work on both desktop (unit tests) and device (integration tests). + +Usage: + from mpos.testing import MockMachine, MockTaskManager, MockNetwork + + # Inject mocks before importing modules that use hardware + import sys + sys.modules['machine'] = MockMachine() + + # Or use the helper function + from mpos.testing import inject_mocks + inject_mocks(['machine', 'mpos.task_manager']) +""" + +from .mocks import ( + # Hardware mocks + MockMachine, + MockPin, + MockPWM, + MockI2S, + MockTimer, + MockSocket, + + # MPOS mocks + MockTaskManager, + MockTask, + MockDownloadManager, + + # Threading mocks + MockThread, + MockApps, + + # Network mocks + MockNetwork, + MockRequests, + MockResponse, + MockRaw, + + # Utility mocks + MockTime, + MockJSON, + MockModule, + + # Helper functions + inject_mocks, + create_mock_module, +) + +__all__ = [ + # Hardware mocks + 'MockMachine', + 'MockPin', + 'MockPWM', + 'MockI2S', + 'MockTimer', + 'MockSocket', + + # MPOS mocks + 'MockTaskManager', + 'MockTask', + 'MockDownloadManager', + + # Threading mocks + 'MockThread', + 'MockApps', + + # Network mocks + 'MockNetwork', + 'MockRequests', + 'MockResponse', + 'MockRaw', + + # Utility mocks + 'MockTime', + 'MockJSON', + 'MockModule', + + # Helper functions + 'inject_mocks', + 'create_mock_module', +] \ No newline at end of file diff --git a/internal_filesystem/lib/mpos/testing/mocks.py b/internal_filesystem/lib/mpos/testing/mocks.py new file mode 100644 index 00000000..df650a51 --- /dev/null +++ b/internal_filesystem/lib/mpos/testing/mocks.py @@ -0,0 +1,783 @@ +""" +Mock implementations for MicroPythonOS testing. + +This module provides mock implementations of hardware and system modules +for testing without actual hardware. Works on both desktop and device. +""" + +import sys + + +# ============================================================================= +# Helper Functions +# ============================================================================= + +class MockModule: + """ + Simple class that acts as a module container. + MicroPython doesn't have types.ModuleType, so we use this instead. + """ + pass + + +def create_mock_module(name, **attrs): + """ + Create a mock module with the given attributes. + + Args: + name: Module name (for debugging) + **attrs: Attributes to set on the module + + Returns: + MockModule instance with attributes set + """ + module = MockModule() + module.__name__ = name + for key, value in attrs.items(): + setattr(module, key, value) + return module + + +def inject_mocks(mock_specs): + """ + Inject mock modules into sys.modules. + + Args: + mock_specs: Dict mapping module names to mock instances/classes + e.g., {'machine': MockMachine(), 'mpos.task_manager': mock_tm} + """ + for name, mock in mock_specs.items(): + sys.modules[name] = mock + + +# ============================================================================= +# Hardware Mocks - machine module +# ============================================================================= + +class MockPin: + """Mock machine.Pin for testing GPIO operations.""" + + IN = 0 + OUT = 1 + PULL_UP = 2 + PULL_DOWN = 3 + + def __init__(self, pin_number, mode=None, pull=None): + self.pin_number = pin_number + self.mode = mode + self.pull = pull + self._value = 0 + + def value(self, val=None): + """Get or set pin value.""" + if val is None: + return self._value + self._value = val + + def on(self): + """Set pin high.""" + self._value = 1 + + def off(self): + """Set pin low.""" + self._value = 0 + + +class MockPWM: + """Mock machine.PWM for testing PWM operations (buzzer, etc.).""" + + def __init__(self, pin, freq=0, duty=0): + self.pin = pin + self.last_freq = freq + self.last_duty = duty + + def freq(self, value=None): + """Get or set frequency.""" + if value is not None: + self.last_freq = value + return self.last_freq + + def duty_u16(self, value=None): + """Get or set duty cycle (16-bit).""" + if value is not None: + self.last_duty = value + return self.last_duty + + def duty(self, value=None): + """Get or set duty cycle (10-bit).""" + if value is not None: + self.last_duty = value * 64 # Convert to 16-bit + return self.last_duty // 64 + + def deinit(self): + """Deinitialize PWM.""" + self.last_freq = 0 + self.last_duty = 0 + + +class MockI2S: + """Mock machine.I2S for testing audio I2S operations.""" + + TX = 0 + RX = 1 + MONO = 0 + STEREO = 1 + + def __init__(self, id, sck=None, ws=None, sd=None, mode=None, + bits=16, format=None, rate=44100, ibuf=None): + self.id = id + self.sck = sck + self.ws = ws + self.sd = sd + self.mode = mode + self.bits = bits + self.format = format + self.rate = rate + self.ibuf = ibuf + self._write_buffer = bytearray(1024) + self._bytes_written = 0 + + def write(self, buf): + """Write audio data (blocking).""" + self._bytes_written += len(buf) + return len(buf) + + def write_readinto(self, write_buf, read_buf): + """Non-blocking write with readback.""" + self._bytes_written += len(write_buf) + return len(write_buf) + + def deinit(self): + """Deinitialize I2S.""" + pass + + +class MockTimer: + """Mock machine.Timer for testing periodic callbacks.""" + + _all_timers = {} + + PERIODIC = 1 + ONE_SHOT = 0 + + def __init__(self, timer_id=-1): + self.timer_id = timer_id + self.callback = None + self.period = None + self.mode = None + self.active = False + if timer_id >= 0: + MockTimer._all_timers[timer_id] = self + + def init(self, period=None, mode=None, callback=None): + """Initialize/configure the timer.""" + self.period = period + self.mode = mode + self.callback = callback + self.active = True + + def deinit(self): + """Deinitialize the timer.""" + self.active = False + self.callback = None + + def trigger(self, *args, **kwargs): + """Manually trigger the timer callback (for testing).""" + if self.callback and self.active: + self.callback(*args, **kwargs) + + @classmethod + def get_timer(cls, timer_id): + """Get a timer by ID.""" + return cls._all_timers.get(timer_id) + + @classmethod + def trigger_all(cls): + """Trigger all active timers (for testing).""" + for timer in cls._all_timers.values(): + if timer.active: + timer.trigger() + + @classmethod + def reset_all(cls): + """Reset all timers (clear registry).""" + cls._all_timers.clear() + + +class MockMachine: + """ + Mock machine module containing all hardware mocks. + + Usage: + sys.modules['machine'] = MockMachine() + """ + + Pin = MockPin + PWM = MockPWM + I2S = MockI2S + Timer = MockTimer + + @staticmethod + def freq(freq=None): + """Get or set CPU frequency.""" + return 240000000 # 240 MHz + + @staticmethod + def reset(): + """Reset the device (no-op in mock).""" + pass + + @staticmethod + def soft_reset(): + """Soft reset the device (no-op in mock).""" + pass + + +# ============================================================================= +# MPOS Mocks - TaskManager +# ============================================================================= + +class MockTask: + """Mock asyncio Task for testing.""" + + def __init__(self): + self.ph_key = 0 + self._done = False + self.coro = None + self._result = None + self._exception = None + + def done(self): + """Check if task is done.""" + return self._done + + def cancel(self): + """Cancel the task.""" + self._done = True + + def result(self): + """Get task result.""" + if self._exception: + raise self._exception + return self._result + + +class MockTaskManager: + """ + Mock TaskManager for testing async operations. + + Usage: + mock_tm = create_mock_module('mpos.task_manager', TaskManager=MockTaskManager) + sys.modules['mpos.task_manager'] = mock_tm + """ + + task_list = [] + + @classmethod + def create_task(cls, coroutine): + """Create a mock task from a coroutine.""" + task = MockTask() + task.coro = coroutine + cls.task_list.append(task) + return task + + @staticmethod + async def sleep(seconds): + """Mock async sleep (no actual delay).""" + pass + + @staticmethod + async def sleep_ms(milliseconds): + """Mock async sleep in milliseconds (no actual delay).""" + pass + + @staticmethod + async def wait_for(awaitable, timeout): + """Mock wait_for with timeout.""" + return await awaitable + + @staticmethod + def notify_event(): + """Create a mock async event.""" + class MockEvent: + def __init__(self): + self._set = False + + async def wait(self): + pass + + def set(self): + self._set = True + + def is_set(self): + return self._set + + return MockEvent() + + @classmethod + def clear_tasks(cls): + """Clear all tracked tasks (for test cleanup).""" + cls.task_list = [] + + +# ============================================================================= +# Network Mocks +# ============================================================================= + +class MockNetwork: + """Mock network module for testing network connectivity.""" + + STA_IF = 0 + AP_IF = 1 + + class MockWLAN: + """Mock WLAN interface.""" + + def __init__(self, interface, connected=True): + self.interface = interface + self._connected = connected + self._active = True + self._config = {} + self._scan_results = [] + + def isconnected(self): + """Return whether the WLAN is connected.""" + return self._connected + + def active(self, is_active=None): + """Get/set whether the interface is active.""" + if is_active is None: + return self._active + self._active = is_active + + def connect(self, ssid, password): + """Simulate connecting to a network.""" + self._connected = True + self._config['ssid'] = ssid + + def disconnect(self): + """Simulate disconnecting from network.""" + self._connected = False + + def config(self, param): + """Get configuration parameter.""" + return self._config.get(param) + + def ifconfig(self): + """Get IP configuration.""" + if self._connected: + return ('192.168.1.100', '255.255.255.0', '192.168.1.1', '8.8.8.8') + return ('0.0.0.0', '0.0.0.0', '0.0.0.0', '0.0.0.0') + + def scan(self): + """Scan for available networks.""" + return self._scan_results + + def __init__(self, connected=True): + self._connected = connected + self._wlan_instances = {} + + def WLAN(self, interface): + """Create or return a WLAN interface.""" + if interface not in self._wlan_instances: + self._wlan_instances[interface] = self.MockWLAN(interface, self._connected) + return self._wlan_instances[interface] + + def set_connected(self, connected): + """Change the connection state of all WLAN interfaces.""" + self._connected = connected + for wlan in self._wlan_instances.values(): + wlan._connected = connected + + +class MockRaw: + """Mock raw HTTP response for streaming.""" + + def __init__(self, content, fail_after_bytes=None): + self.content = content + self.position = 0 + self.fail_after_bytes = fail_after_bytes + + def read(self, size): + """Read a chunk of data.""" + if self.fail_after_bytes is not None and self.position >= self.fail_after_bytes: + raise OSError(-113, "ECONNABORTED") + + chunk = self.content[self.position:self.position + size] + self.position += len(chunk) + return chunk + + +class MockResponse: + """Mock HTTP response.""" + + def __init__(self, status_code=200, text='', headers=None, content=b'', fail_after_bytes=None): + self.status_code = status_code + self.text = text + self.headers = headers or {} + self.content = content + self._closed = False + self.raw = MockRaw(content, fail_after_bytes=fail_after_bytes) + + def close(self): + """Close the response.""" + self._closed = True + + def json(self): + """Parse response as JSON.""" + import json + return json.loads(self.text) + + +class MockRequests: + """Mock requests module for testing HTTP operations.""" + + def __init__(self): + self.last_url = None + self.last_headers = None + self.last_timeout = None + self.last_stream = None + self.last_request = None + self.next_response = None + self.raise_exception = None + self.call_history = [] + + def get(self, url, stream=False, timeout=None, headers=None): + """Mock GET request.""" + self.last_url = url + self.last_headers = headers + self.last_timeout = timeout + self.last_stream = stream + + self.last_request = { + 'method': 'GET', + 'url': url, + 'stream': stream, + 'timeout': timeout, + 'headers': headers or {} + } + self.call_history.append(self.last_request.copy()) + + if self.raise_exception: + exc = self.raise_exception + self.raise_exception = None + raise exc + + if self.next_response: + response = self.next_response + self.next_response = None + return response + + return MockResponse() + + def post(self, url, data=None, json=None, timeout=None, headers=None): + """Mock POST request.""" + self.last_url = url + self.last_headers = headers + self.last_timeout = timeout + + self.call_history.append({ + 'method': 'POST', + 'url': url, + 'data': data, + 'json': json, + 'timeout': timeout, + 'headers': headers + }) + + if self.raise_exception: + exc = self.raise_exception + self.raise_exception = None + raise exc + + if self.next_response: + response = self.next_response + self.next_response = None + return response + + return MockResponse() + + def set_next_response(self, status_code=200, text='', headers=None, content=b'', fail_after_bytes=None): + """Configure the next response to return.""" + self.next_response = MockResponse(status_code, text, headers, content, fail_after_bytes=fail_after_bytes) + return self.next_response + + def set_exception(self, exception): + """Configure an exception to raise on the next request.""" + self.raise_exception = exception + + def clear_history(self): + """Clear the call history.""" + self.call_history = [] + + +class MockSocket: + """Mock socket for testing socket operations.""" + + AF_INET = 2 + SOCK_STREAM = 1 + + def __init__(self, af=None, sock_type=None): + self.af = af + self.sock_type = sock_type + self.connected = False + self.bound = False + self.listening = False + self.address = None + self._send_exception = None + self._recv_data = b'' + self._recv_position = 0 + + def connect(self, address): + """Simulate connecting to an address.""" + self.connected = True + self.address = address + + def bind(self, address): + """Simulate binding to an address.""" + self.bound = True + self.address = address + + def listen(self, backlog): + """Simulate listening for connections.""" + self.listening = True + + def send(self, data): + """Simulate sending data.""" + if self._send_exception: + exc = self._send_exception + self._send_exception = None + raise exc + return len(data) + + def recv(self, size): + """Simulate receiving data.""" + chunk = self._recv_data[self._recv_position:self._recv_position + size] + self._recv_position += len(chunk) + return chunk + + def close(self): + """Close the socket.""" + self.connected = False + + def set_send_exception(self, exception): + """Configure an exception to raise on next send().""" + self._send_exception = exception + + def set_recv_data(self, data): + """Configure data to return from recv().""" + self._recv_data = data + self._recv_position = 0 + + +# ============================================================================= +# Utility Mocks +# ============================================================================= + +class MockTime: + """Mock time module for testing time-dependent code.""" + + def __init__(self, start_time=0): + self._current_time_ms = start_time + self._sleep_calls = [] + + def ticks_ms(self): + """Get current time in milliseconds.""" + return self._current_time_ms + + def ticks_diff(self, ticks1, ticks2): + """Calculate difference between two tick values.""" + return ticks1 - ticks2 + + def sleep(self, seconds): + """Simulate sleep (doesn't actually sleep).""" + self._sleep_calls.append(seconds) + + def sleep_ms(self, milliseconds): + """Simulate sleep in milliseconds.""" + self._sleep_calls.append(milliseconds / 1000.0) + + def advance(self, milliseconds): + """Advance the mock time.""" + self._current_time_ms += milliseconds + + def get_sleep_calls(self): + """Get history of sleep calls.""" + return self._sleep_calls + + def clear_sleep_calls(self): + """Clear the sleep call history.""" + self._sleep_calls = [] + + +class MockJSON: + """Mock JSON module for testing JSON parsing.""" + + def __init__(self): + self.raise_exception = None + + def loads(self, text): + """Parse JSON string.""" + if self.raise_exception: + exc = self.raise_exception + self.raise_exception = None + raise exc + + import json + return json.loads(text) + + def dumps(self, obj): + """Serialize object to JSON string.""" + import json + return json.dumps(obj) + + def set_exception(self, exception): + """Configure an exception to raise on the next loads() call.""" + self.raise_exception = exception + + +class MockDownloadManager: + """Mock DownloadManager for testing async downloads.""" + + def __init__(self): + self.download_data = b'' + self.should_fail = False + self.fail_after_bytes = None + self.headers_received = None + self.url_received = None + self.call_history = [] + self.chunk_size = 1024 + self.simulated_speed_bps = 100 * 1024 + + async def download_url(self, url, outfile=None, total_size=None, + progress_callback=None, chunk_callback=None, headers=None, + speed_callback=None): + """Mock async download with flexible output modes.""" + self.url_received = url + self.headers_received = headers + + self.call_history.append({ + 'url': url, + 'outfile': outfile, + 'total_size': total_size, + 'headers': headers, + 'has_progress_callback': progress_callback is not None, + 'has_chunk_callback': chunk_callback is not None, + 'has_speed_callback': speed_callback is not None + }) + + if self.should_fail: + if outfile or chunk_callback: + return False + return None + + if self.fail_after_bytes is not None and self.fail_after_bytes == 0: + raise OSError(-113, "ECONNABORTED") + + bytes_sent = 0 + chunks = [] + total_data_size = len(self.download_data) + effective_total_size = total_size if total_size else total_data_size + last_progress_pct = -1.0 + bytes_since_speed_update = 0 + speed_update_threshold = 1000 + + while bytes_sent < total_data_size: + if self.fail_after_bytes is not None and bytes_sent >= self.fail_after_bytes: + raise OSError(-113, "ECONNABORTED") + + chunk = self.download_data[bytes_sent:bytes_sent + self.chunk_size] + + if chunk_callback: + await chunk_callback(chunk) + elif outfile: + pass + else: + chunks.append(chunk) + + bytes_sent += len(chunk) + bytes_since_speed_update += len(chunk) + + if progress_callback and effective_total_size > 0: + percent = round((bytes_sent * 100) / effective_total_size, 2) + if percent != last_progress_pct: + await progress_callback(percent) + last_progress_pct = percent + + if speed_callback and bytes_since_speed_update >= speed_update_threshold: + await speed_callback(self.simulated_speed_bps) + bytes_since_speed_update = 0 + + if outfile or chunk_callback: + return True + else: + return b''.join(chunks) + + def set_download_data(self, data): + """Configure the data to return from downloads.""" + self.download_data = data + + def set_should_fail(self, should_fail): + """Configure whether downloads should fail.""" + self.should_fail = should_fail + + def set_fail_after_bytes(self, bytes_count): + """Configure network failure after specified bytes.""" + self.fail_after_bytes = bytes_count + + def clear_history(self): + """Clear the call history.""" + self.call_history = [] + + +# ============================================================================= +# Threading Mocks +# ============================================================================= + +class MockThread: + """ + Mock _thread module for testing threaded operations. + + Usage: + sys.modules['_thread'] = MockThread + """ + + _started_threads = [] + _stack_size = 0 + + @classmethod + def start_new_thread(cls, func, args): + """Record thread start but don't actually start a thread.""" + cls._started_threads.append((func, args)) + return len(cls._started_threads) + + @classmethod + def stack_size(cls, size=None): + """Mock stack_size.""" + if size is not None: + cls._stack_size = size + return cls._stack_size + + @classmethod + def clear_threads(cls): + """Clear recorded threads (for test cleanup).""" + cls._started_threads = [] + + @classmethod + def get_started_threads(cls): + """Get list of started threads (for test assertions).""" + return cls._started_threads + + +class MockApps: + """ + Mock mpos.apps module for testing. + + Usage: + sys.modules['mpos.apps'] = MockApps + """ + + @staticmethod + def good_stack_size(): + """Return a reasonable stack size for testing.""" + return 8192 \ No newline at end of file diff --git a/internal_filesystem/lib/mpos/ui/anim.py b/internal_filesystem/lib/mpos/ui/anim.py index 0ae5068a..1f8310ac 100644 --- a/internal_filesystem/lib/mpos/ui/anim.py +++ b/internal_filesystem/lib/mpos/ui/anim.py @@ -41,19 +41,18 @@ class WidgetAnimator: # show_widget and hide_widget could have a (lambda) callback that sets the final state (eg: drawer_open) at the end @staticmethod def show_widget(widget, anim_type="fade", duration=500, delay=0): - """Show a widget with an animation (fade or slide).""" - lv.anim_delete(widget, None) # stop all ongoing animations to prevent visual glitches - widget.remove_flag(lv.obj.FLAG.HIDDEN) # Clear HIDDEN flag to make widget visible for animation + anim = lv.anim_t() + anim.init() + anim.set_var(widget) + anim.set_delay(delay) + anim.set_duration(duration) + # Clear HIDDEN flag to make widget visible for animation: + anim.set_start_cb(lambda *args: safe_widget_access(lambda: widget.remove_flag(lv.obj.FLAG.HIDDEN))) if anim_type == "fade": # Create fade-in animation (opacity from 0 to 255) - anim = lv.anim_t() - anim.init() - anim.set_var(widget) anim.set_values(0, 255) - anim.set_duration(duration) - anim.set_delay(delay) anim.set_custom_exec_cb(lambda anim, value: 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 @@ -63,50 +62,38 @@ def show_widget(widget, anim_type="fade", duration=500, delay=0): # Create slide-down animation (y from -height to original y) original_y = widget.get_y() height = widget.get_height() - anim = lv.anim_t() - anim.init() - anim.set_var(widget) anim.set_values(original_y - height, original_y) - anim.set_duration(duration) - anim.set_delay(delay) anim.set_custom_exec_cb(lambda anim, value: 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))) - elif anim_type == "slide_up": + else: # "slide_up": # Create slide-up animation (y from +height to original y) # Seems to cause scroll bars to be added somehow if done to a keyboard at the bottom of the screen... original_y = widget.get_y() height = widget.get_height() - anim = lv.anim_t() - anim.init() - anim.set_var(widget) anim.set_values(original_y + height, original_y) - anim.set_duration(duration) - anim.set_delay(delay) anim.set_custom_exec_cb(lambda anim, value: 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))) - # Store and start animation - #self.animations[widget] = anim anim.start() return anim @staticmethod def hide_widget(widget, anim_type="fade", duration=500, delay=0, hide=True): lv.anim_delete(widget, None) # stop all ongoing animations to prevent visual glitches + anim = lv.anim_t() + anim.init() + anim.set_var(widget) + anim.set_duration(duration) + anim.set_delay(delay) """Hide a widget with an animation (fade or slide).""" if anim_type == "fade": # Create fade-out animation (opacity from 255 to 0) - anim = lv.anim_t() - anim.init() - anim.set_var(widget) anim.set_values(255, 0) - anim.set_duration(duration) - anim.set_delay(delay) anim.set_custom_exec_cb(lambda anim, value: 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 @@ -116,34 +103,22 @@ def hide_widget(widget, anim_type="fade", duration=500, delay=0, hide=True): # Seems to cause scroll bars to be added somehow if done to a keyboard at the bottom of the screen... original_y = widget.get_y() height = widget.get_height() - anim = lv.anim_t() - anim.init() - anim.set_var(widget) anim.set_values(original_y, original_y + height) - anim.set_duration(duration) - anim.set_delay(delay) anim.set_custom_exec_cb(lambda anim, value: 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))) - elif anim_type == "slide_up": + else: # "slide_up": print("hide with slide_up") # Create slide-up animation (y from original y to -height) original_y = widget.get_y() height = widget.get_height() - anim = lv.anim_t() - anim.init() - anim.set_var(widget) anim.set_values(original_y, original_y - height) - anim.set_duration(duration) - anim.set_delay(delay) anim.set_custom_exec_cb(lambda anim, value: 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))) - # Store and start animation - #self.animations[widget] = anim anim.start() return anim @@ -156,8 +131,8 @@ def hide_complete_cb(widget, original_y=None, hide=True): widget.set_y(original_y) # in case it shifted slightly due to rounding etc -def smooth_show(widget): - return WidgetAnimator.show_widget(widget, anim_type="fade", duration=500, delay=0) +def smooth_show(widget, duration=500, delay=0): + return WidgetAnimator.show_widget(widget, anim_type="fade", duration=duration, delay=delay) -def smooth_hide(widget, hide=True): - return WidgetAnimator.hide_widget(widget, anim_type="fade", duration=500, delay=0, hide=hide) +def smooth_hide(widget, hide=True, duration=500, delay=0): + return WidgetAnimator.hide_widget(widget, anim_type="fade", duration=duration, delay=delay, hide=hide) diff --git a/internal_filesystem/lib/mpos/ui/display.py b/internal_filesystem/lib/mpos/ui/display.py index 50ae7fab..991e1657 100644 --- a/internal_filesystem/lib/mpos/ui/display.py +++ b/internal_filesystem/lib/mpos/ui/display.py @@ -24,9 +24,13 @@ def get_pointer_xy(): return -1, -1 def pct_of_display_width(pct): + if pct == 100: + return _horizontal_resolution return round(_horizontal_resolution * pct / 100) def pct_of_display_height(pct): + if pct == 100: + return _vertical_resolution return round(_vertical_resolution * pct / 100) def min_resolution(): diff --git a/internal_filesystem/lib/mpos/ui/gesture_navigation.py b/internal_filesystem/lib/mpos/ui/gesture_navigation.py index c43a25ad..df95f6ed 100644 --- a/internal_filesystem/lib/mpos/ui/gesture_navigation.py +++ b/internal_filesystem/lib/mpos/ui/gesture_navigation.py @@ -2,7 +2,7 @@ from lvgl import LvReferenceError from .anim import smooth_show, smooth_hide from .view import back_screen -from .topmenu import open_drawer, drawer_open, NOTIFICATION_BAR_HEIGHT +from mpos.ui import topmenu as topmenu from .display import get_display_width, get_display_height downbutton = None @@ -31,10 +31,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() @@ -61,13 +57,16 @@ def _back_swipe_cb(event): backbutton_visible = False smooth_hide(backbutton) if x > get_display_width() / 5: - back_screen() + 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 @@ -99,7 +98,7 @@ def _top_swipe_cb(event): dx = abs(x - down_start_x) dy = abs(y - down_start_y) if y > get_display_height() / 5: - open_drawer() + topmenu.open_drawer() elif is_short_movement(dx, dy): # print("Short movement - treating as tap") _passthrough_click(x, y, indev) @@ -107,10 +106,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(topmenu.NOTIFICATION_BAR_HEIGHT, lv.layer_top().get_height()-topmenu.NOTIFICATION_BAR_HEIGHT) # narrow because it overlaps buttons rect.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) rect.set_scroll_dir(lv.DIR.NONE) - rect.set_pos(0, NOTIFICATION_BAR_HEIGHT) + rect.set_pos(0, topmenu.NOTIFICATION_BAR_HEIGHT) style = lv.style_t() style.init() style.set_bg_opa(lv.OPA.TRANSP) @@ -138,7 +137,7 @@ def handle_back_swipe(): 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), topmenu.NOTIFICATION_BAR_HEIGHT) rect.set_pos(0, 0) rect.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) style = lv.style_t() diff --git a/internal_filesystem/lib/mpos/ui/keyboard.py b/internal_filesystem/lib/mpos/ui/keyboard.py index 6d47d070..50164b4b 100644 --- a/internal_filesystem/lib/mpos/ui/keyboard.py +++ b/internal_filesystem/lib/mpos/ui/keyboard.py @@ -125,8 +125,13 @@ def __init__(self, parent): self._keyboard.set_style_min_height(175, 0) def _handle_events(self, event): - # Only process VALUE_CHANGED events for actual typing - if event.get_code() != lv.EVENT.VALUE_CHANGED: + code = event.get_code() + #print(f"keyboard event code = {code}") + if code == lv.EVENT.READY or code == lv.EVENT.CANCEL: + self.hide_keyboard() + return + # Process VALUE_CHANGED events for actual typing + if code != lv.EVENT.VALUE_CHANGED: return # Get the pressed button and its text @@ -207,6 +212,7 @@ def set_textarea(self, textarea): self._textarea = textarea # NOTE: We deliberately DO NOT call self._keyboard.set_textarea() # to avoid LVGL's automatic character insertion + self._textarea.add_event_cb(lambda *args: self.show_keyboard(), lv.EVENT.CLICKED, None) def get_textarea(self): """ @@ -243,3 +249,9 @@ def __getattr__(self, name): """ # Forward to the underlying keyboard object return getattr(self._keyboard, name) + + def show_keyboard(self): + mpos.ui.anim.smooth_show(self._keyboard) + + def hide_keyboard(self): + mpos.ui.anim.smooth_hide(self._keyboard) diff --git a/internal_filesystem/lib/mpos/ui/testing.py b/internal_filesystem/lib/mpos/ui/testing.py index dc3fa063..1f660b2e 100644 --- a/internal_filesystem/lib/mpos/ui/testing.py +++ b/internal_filesystem/lib/mpos/ui/testing.py @@ -41,6 +41,7 @@ """ import lvgl as lv +import time # Simulation globals for touch input _touch_x = 0 @@ -517,7 +518,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 +543,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 +568,209 @@ 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() + + # Wait for press duration + time.sleep(press_duration_ms / 1000.0) - 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 + # Release the touch + _touch_pressed = False + + # Process the release event - this triggers the CLICKED event + lv.task_handler() + time.sleep(0.02) + lv.task_handler() + time.sleep(0.02) + lv.task_handler() - # Schedule the release - timer = lv.timer_create(release_timer_cb, press_duration_ms, None) - timer.set_repeat_count(1) +def click_button(button_text, timeout=5, use_send_event=True): + """Find and click a button with given text. + + Args: + button_text: Text to search for in button labels + timeout: Maximum time to wait for button to appear (default: 5s) + use_send_event: If True, use send_event() which is more reliable for + triggering button actions. If False, use simulate_click() + which simulates actual touch input. (default: True) + + Returns: + True if button was found and clicked, False otherwise + """ + start = time.time() + while time.time() - start < timeout: + button = find_button_with_text(lv.screen_active(), button_text) + if button: + coords = get_widget_coords(button) + if coords: + print(f"Clicking button '{button_text}' at ({coords['center_x']}, {coords['center_y']})") + if use_send_event: + # Use send_event for more reliable button triggering + button.send_event(lv.EVENT.CLICKED, None) + else: + # Use simulate_click for actual touch simulation + simulate_click(coords['center_x'], coords['center_y']) + wait_for_render(iterations=20) + return True + wait_for_render(iterations=5) + print(f"ERROR: Button '{button_text}' not found after {timeout}s") + return False + +def click_label(label_text, timeout=5, use_send_event=True): + """Find a label with given text and click on it (or its clickable parent). + + This function finds a label, scrolls it into view (with multiple attempts + if needed), verifies it's within the visible viewport, and then clicks it. + If the label itself is not clickable, it will try clicking the parent container. + + Args: + label_text: Text to search for in labels + timeout: Maximum time to wait for label to appear (default: 5s) + use_send_event: If True, use send_event() on clickable parent which is more + reliable. If False, use simulate_click(). (default: True) + + Returns: + True if label was found and clicked, False otherwise + """ + start = time.time() + while time.time() - start < timeout: + label = find_label_with_text(lv.screen_active(), label_text) + if label: + # Get screen dimensions for viewport check + screen = lv.screen_active() + screen_coords = get_widget_coords(screen) + if not screen_coords: + screen_coords = {'x1': 0, 'y1': 0, 'x2': 320, 'y2': 240} + + # Try scrolling multiple times to ensure label is fully visible + max_scroll_attempts = 5 + for scroll_attempt in range(max_scroll_attempts): + print(f"Scrolling label to view (attempt {scroll_attempt + 1}/{max_scroll_attempts})...") + label.scroll_to_view_recursive(True) + wait_for_render(iterations=50) # needs quite a bit of time for scroll animation + + # Get updated coordinates after scroll + coords = get_widget_coords(label) + if not coords: + break + + # Check if label center is within visible viewport + # Account for some margin (e.g., status bar at top, nav bar at bottom) + # Use a larger bottom margin to ensure the element is fully clickable + viewport_top = screen_coords['y1'] + 30 # Account for status bar + viewport_bottom = screen_coords['y2'] - 30 # Larger margin at bottom for clickability + viewport_left = screen_coords['x1'] + viewport_right = screen_coords['x2'] + + center_x = coords['center_x'] + center_y = coords['center_y'] + + is_visible = (viewport_left <= center_x <= viewport_right and + viewport_top <= center_y <= viewport_bottom) + + if is_visible: + print(f"Label '{label_text}' is visible at ({center_x}, {center_y})") + + # Try to find a clickable parent (container) - many UIs have clickable containers + # with non-clickable labels inside. We'll click on the label's position but + # the event should bubble up to the clickable parent. + click_target = label + clickable_parent = None + click_coords = coords + try: + parent = label.get_parent() + if parent and parent.has_flag(lv.obj.FLAG.CLICKABLE): + # The parent is clickable - we can use send_event on it + clickable_parent = parent + parent_coords = get_widget_coords(parent) + if parent_coords: + print(f"Found clickable parent container: ({parent_coords['x1']}, {parent_coords['y1']}) to ({parent_coords['x2']}, {parent_coords['y2']})") + # Use label's x but ensure y is within parent bounds + click_x = center_x + click_y = center_y + # Clamp to parent bounds with some margin + if click_y < parent_coords['y1'] + 5: + click_y = parent_coords['y1'] + 5 + if click_y > parent_coords['y2'] - 5: + click_y = parent_coords['y2'] - 5 + click_coords = {'center_x': click_x, 'center_y': click_y} + except Exception as e: + print(f"Could not check parent clickability: {e}") + + print(f"Clicking label '{label_text}' at ({click_coords['center_x']}, {click_coords['center_y']})") + if use_send_event and clickable_parent: + # Use send_event on the clickable parent for more reliable triggering + print(f"Using send_event on clickable parent") + clickable_parent.send_event(lv.EVENT.CLICKED, None) + else: + # Use simulate_click for actual touch simulation + simulate_click(click_coords['center_x'], click_coords['center_y']) + wait_for_render(iterations=20) + return True + else: + print(f"Label '{label_text}' at ({center_x}, {center_y}) not fully visible " + f"(viewport: y={viewport_top}-{viewport_bottom}), scrolling more...") + # Additional scroll - try scrolling the parent container + try: + parent = label.get_parent() + if parent: + # Try to find a scrollable ancestor + scrollable = parent + for _ in range(5): # Check up to 5 levels up + try: + grandparent = scrollable.get_parent() + if grandparent: + scrollable = grandparent + except: + break + + # Scroll by a fixed amount to bring label more into view + current_scroll = scrollable.get_scroll_y() + if center_y > viewport_bottom: + # Need to scroll down (increase scroll_y) + scrollable.scroll_to_y(current_scroll + 60, True) + elif center_y < viewport_top: + # Need to scroll up (decrease scroll_y) + scrollable.scroll_to_y(max(0, current_scroll - 60), True) + wait_for_render(iterations=30) + except Exception as e: + print(f"Additional scroll failed: {e}") + + # If we exhausted scroll attempts, try clicking anyway + coords = get_widget_coords(label) + if coords: + # Try to find a clickable parent even for fallback click + click_coords = coords + try: + parent = label.get_parent() + if parent and parent.has_flag(lv.obj.FLAG.CLICKABLE): + parent_coords = get_widget_coords(parent) + if parent_coords: + click_coords = parent_coords + print(f"Using clickable parent for fallback click") + except: + pass + + print(f"Clicking at ({click_coords['center_x']}, {click_coords['center_y']}) after max scroll attempts") + # Try to use send_event if we have a clickable parent + try: + parent = label.get_parent() + if use_send_event and parent and parent.has_flag(lv.obj.FLAG.CLICKABLE): + print(f"Using send_event on clickable parent for fallback") + parent.send_event(lv.EVENT.CLICKED, None) + else: + simulate_click(click_coords['center_x'], click_coords['center_y']) + except: + simulate_click(click_coords['center_x'], click_coords['center_y']) + wait_for_render(iterations=20) + return True + + wait_for_render(iterations=5) + print(f"ERROR: Label '{label_text}' not found after {timeout}s") + return False + +def find_text_on_screen(text): + """Check if text is present on screen.""" + return find_label_with_text(lv.screen_active(), text) is not None diff --git a/internal_filesystem/lib/mpos/ui/topmenu.py b/internal_filesystem/lib/mpos/ui/topmenu.py index ac59bbc3..96486428 100644 --- a/internal_filesystem/lib/mpos/ui/topmenu.py +++ b/internal_filesystem/lib/mpos/ui/topmenu.py @@ -1,6 +1,7 @@ import lvgl as lv import mpos.ui +import mpos.time import mpos.battery_voltage from .display import (get_display_width, get_display_height) from .util import (get_foreground_app) @@ -11,7 +12,7 @@ CLOCK_UPDATE_INTERVAL = 1000 # 10 or even 1 ms doesn't seem to change the framerate but 100ms is enough WIFI_ICON_UPDATE_INTERVAL = 1500 -BATTERY_ICON_UPDATE_INTERVAL = 5000 +BATTERY_ICON_UPDATE_INTERVAL = 15000 # not too often, but not too short, otherwise it takes a while to appear TEMPERATURE_UPDATE_INTERVAL = 2000 MEMFREE_UPDATE_INTERVAL = 5000 # not too frequent because there's a forced gc.collect() to give it a reliable value @@ -92,9 +93,10 @@ def create_notification_bar(): temp_label = lv.label(notification_bar) temp_label.set_text("00°C") temp_label.align_to(time_label, lv.ALIGN.OUT_RIGHT_MID, mpos.ui.pct_of_display_width(7) , 0) - memfree_label = lv.label(notification_bar) - memfree_label.set_text("") - memfree_label.align_to(temp_label, lv.ALIGN.OUT_RIGHT_MID, mpos.ui.pct_of_display_width(7), 0) + if False: + memfree_label = lv.label(notification_bar) + memfree_label.set_text("") + memfree_label.align_to(temp_label, lv.ALIGN.OUT_RIGHT_MID, mpos.ui.pct_of_display_width(7), 0) #style = lv.style_t() #style.init() #style.set_text_font(lv.font_montserrat_8) # tiny font @@ -134,16 +136,20 @@ def update_time(timer): print("Warning: could not check WLAN status:", str(e)) def update_battery_icon(timer=None): - percent = mpos.battery_voltage.get_battery_percentage() - if percent > 80: # 4.1V + try: + percent = mpos.battery_voltage.get_battery_percentage() + except Exception as e: + print(f"battery_voltage.get_battery_percentage got exception, not updating battery_icon: {e}") + return + if percent > 80: battery_icon.set_text(lv.SYMBOL.BATTERY_FULL) - elif percent > 60: # 4.0V + elif percent > 60: battery_icon.set_text(lv.SYMBOL.BATTERY_3) - elif percent > 40: # 3.9V + elif percent > 40: battery_icon.set_text(lv.SYMBOL.BATTERY_2) - elif percent > 20: # 3.8V + elif percent > 20: battery_icon.set_text(lv.SYMBOL.BATTERY_1) - else: # > 3.7V + else: battery_icon.set_text(lv.SYMBOL.BATTERY_EMPTY) battery_icon.remove_flag(lv.obj.FLAG.HIDDEN) # Percentage is not shown for now: @@ -158,16 +164,22 @@ def update_wifi_icon(timer): else: wifi_icon.add_flag(lv.obj.FLAG.HIDDEN) - can_check_temperature = False - try: - import esp32 - can_check_temperature = True - except Exception as e: - print("Warning: can't check temperature sensor:", str(e)) - + # Get temperature sensor via SensorManager + import mpos.sensor_manager as SensorManager + temp_sensor = None + if SensorManager.is_available(): + # Prefer MCU temperature (more stable) over IMU temperature + temp_sensor = SensorManager.get_default_sensor(SensorManager.TYPE_SOC_TEMPERATURE) + if not temp_sensor: + temp_sensor = SensorManager.get_default_sensor(SensorManager.TYPE_IMU_TEMPERATURE) + def update_temperature(timer): - if can_check_temperature: - temp_label.set_text(f"{esp32.mcu_temperature()}°C") + if temp_sensor: + temp = SensorManager.read_sensor(temp_sensor) + if temp is not None: + temp_label.set_text(f"{round(temp)}°C") + else: + temp_label.set_text("--°C") else: temp_label.set_text("42°C") @@ -182,7 +194,7 @@ def update_memfree(timer): lv.timer_create(update_time, CLOCK_UPDATE_INTERVAL, None) lv.timer_create(update_temperature, TEMPERATURE_UPDATE_INTERVAL, None) - lv.timer_create(update_memfree, MEMFREE_UPDATE_INTERVAL, None) + #lv.timer_create(update_memfree, MEMFREE_UPDATE_INTERVAL, None) lv.timer_create(update_wifi_icon, WIFI_ICON_UPDATE_INTERVAL, None) lv.timer_create(update_battery_icon, BATTERY_ICON_UPDATE_INTERVAL, None) diff --git a/internal_filesystem/lib/websocket.py b/internal_filesystem/lib/websocket.py index 01930275..c76d1e7e 100644 --- a/internal_filesystem/lib/websocket.py +++ b/internal_filesystem/lib/websocket.py @@ -229,7 +229,10 @@ async def run_forever( # Run the event loop in the main thread try: - self._loop.run_until_complete(self._async_main()) + print("doing run_until_complete") + #self._loop.run_until_complete(self._async_main()) # this doesn't always finish! + asyncio.create_task(self._async_main()) + print("after run_until_complete") except KeyboardInterrupt: _log_debug("run_forever got KeyboardInterrupt") self.close() @@ -272,7 +275,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/micropython-camera-API b/micropython-camera-API index 2dd97117..a84c8459 160000 --- a/micropython-camera-API +++ b/micropython-camera-API @@ -1 +1 @@ -Subproject commit 2dd97117359d00729d50448df19404d18f67ac30 +Subproject commit a84c84595b415894b9b4ca3dc05ffd3d7d9d9a22 diff --git a/patches/micropython-camera-API.patch b/patches/micropython-camera-API.patch new file mode 100644 index 00000000..c56cc025 --- /dev/null +++ b/patches/micropython-camera-API.patch @@ -0,0 +1,167 @@ +diff --git a/src/manifest.py b/src/manifest.py +index ff69f76..929ff84 100644 +--- a/src/manifest.py ++++ b/src/manifest.py +@@ -1,4 +1,5 @@ + # Include the board's default manifest. + include("$(PORT_DIR)/boards/manifest.py") + # Add custom driver +-module("acamera.py") +\ No newline at end of file ++module("acamera.py") ++include("/home/user/projects/MicroPythonOS/claude/MicroPythonOS/lvgl_micropython/build/manifest.py") # workaround to prevent micropython-camera-API from overriding the lvgl_micropython manifest... +diff --git a/src/modcamera.c b/src/modcamera.c +index 5a0bd05..c84f09d 100644 +--- a/src/modcamera.c ++++ b/src/modcamera.c +@@ -252,7 +252,7 @@ const mp_rom_map_elem_t mp_camera_hal_pixel_format_table[] = { + const mp_rom_map_elem_t mp_camera_hal_frame_size_table[] = { + { MP_ROM_QSTR(MP_QSTR_R96X96), MP_ROM_INT((mp_uint_t)FRAMESIZE_96X96) }, + { MP_ROM_QSTR(MP_QSTR_QQVGA), MP_ROM_INT((mp_uint_t)FRAMESIZE_QQVGA) }, +- { MP_ROM_QSTR(MP_QSTR_R128x128), MP_ROM_INT((mp_uint_t)FRAMESIZE_128X128) }, ++ { MP_ROM_QSTR(MP_QSTR_R128X128), MP_ROM_INT((mp_uint_t)FRAMESIZE_128X128) }, + { MP_ROM_QSTR(MP_QSTR_QCIF), MP_ROM_INT((mp_uint_t)FRAMESIZE_QCIF) }, + { MP_ROM_QSTR(MP_QSTR_HQVGA), MP_ROM_INT((mp_uint_t)FRAMESIZE_HQVGA) }, + { MP_ROM_QSTR(MP_QSTR_R240X240), MP_ROM_INT((mp_uint_t)FRAMESIZE_240X240) }, +@@ -260,10 +260,17 @@ const mp_rom_map_elem_t mp_camera_hal_frame_size_table[] = { + { MP_ROM_QSTR(MP_QSTR_R320X320), MP_ROM_INT((mp_uint_t)FRAMESIZE_320X320) }, + { MP_ROM_QSTR(MP_QSTR_CIF), MP_ROM_INT((mp_uint_t)FRAMESIZE_CIF) }, + { MP_ROM_QSTR(MP_QSTR_HVGA), MP_ROM_INT((mp_uint_t)FRAMESIZE_HVGA) }, ++ { MP_ROM_QSTR(MP_QSTR_R480X480), MP_ROM_INT((mp_uint_t)FRAMESIZE_480X480) }, + { MP_ROM_QSTR(MP_QSTR_VGA), MP_ROM_INT((mp_uint_t)FRAMESIZE_VGA) }, ++ { MP_ROM_QSTR(MP_QSTR_R640X640), MP_ROM_INT((mp_uint_t)FRAMESIZE_640X640) }, ++ { MP_ROM_QSTR(MP_QSTR_R720X720), MP_ROM_INT((mp_uint_t)FRAMESIZE_720X720) }, + { MP_ROM_QSTR(MP_QSTR_SVGA), MP_ROM_INT((mp_uint_t)FRAMESIZE_SVGA) }, ++ { MP_ROM_QSTR(MP_QSTR_R800X800), MP_ROM_INT((mp_uint_t)FRAMESIZE_800X800) }, ++ { MP_ROM_QSTR(MP_QSTR_R960X960), MP_ROM_INT((mp_uint_t)FRAMESIZE_960X960) }, + { MP_ROM_QSTR(MP_QSTR_XGA), MP_ROM_INT((mp_uint_t)FRAMESIZE_XGA) }, ++ { MP_ROM_QSTR(MP_QSTR_R1024X1024),MP_ROM_INT((mp_uint_t)FRAMESIZE_1024X1024) }, + { MP_ROM_QSTR(MP_QSTR_HD), MP_ROM_INT((mp_uint_t)FRAMESIZE_HD) }, ++ { MP_ROM_QSTR(MP_QSTR_R1280X1280),MP_ROM_INT((mp_uint_t)FRAMESIZE_1280X1280) }, + { MP_ROM_QSTR(MP_QSTR_SXGA), MP_ROM_INT((mp_uint_t)FRAMESIZE_SXGA) }, + { MP_ROM_QSTR(MP_QSTR_UXGA), MP_ROM_INT((mp_uint_t)FRAMESIZE_UXGA) }, + { MP_ROM_QSTR(MP_QSTR_FHD), MP_ROM_INT((mp_uint_t)FRAMESIZE_FHD) }, +@@ -435,3 +442,22 @@ int mp_camera_hal_get_pixel_height(mp_camera_obj_t *self) { + framesize_t framesize = sensor->status.framesize; + return resolution[framesize].height; + } ++ ++int mp_camera_hal_set_res_raw(mp_camera_obj_t *self, int startX, int startY, int endX, int endY, int offsetX, int offsetY, int totalX, int totalY, int outputX, int outputY, bool scale, bool binning) { ++ check_init(self); ++ sensor_t *sensor = esp_camera_sensor_get(); ++ if (!sensor->set_res_raw) { ++ mp_raise_ValueError(MP_ERROR_TEXT("Sensor does not support set_res_raw")); ++ } ++ ++ if (self->captured_buffer) { ++ esp_camera_return_all(); ++ self->captured_buffer = NULL; ++ } ++ ++ int ret = sensor->set_res_raw(sensor, startX, startY, endX, endY, offsetX, offsetY, totalX, totalY, outputX, outputY, scale, binning); ++ if (ret < 0) { ++ mp_raise_ValueError(MP_ERROR_TEXT("Failed to set raw resolution")); ++ } ++ return ret; ++} +diff --git a/src/modcamera.h b/src/modcamera.h +index a3ce749..a8771bd 100644 +--- a/src/modcamera.h ++++ b/src/modcamera.h +@@ -211,7 +211,7 @@ extern const mp_rom_map_elem_t mp_camera_hal_pixel_format_table[9]; + * @brief Table mapping frame sizes API to their corresponding values at HAL. + * @details Needs to be defined in the port-specific implementation. + */ +-extern const mp_rom_map_elem_t mp_camera_hal_frame_size_table[24]; ++extern const mp_rom_map_elem_t mp_camera_hal_frame_size_table[31]; + + /** + * @brief Table mapping gainceiling API to their corresponding values at HAL. +@@ -278,4 +278,24 @@ DECLARE_CAMERA_HAL_GET(int, pixel_width) + DECLARE_CAMERA_HAL_GET(const char *, sensor_name) + DECLARE_CAMERA_HAL_GET(bool, supports_jpeg) + +-#endif // MICROPY_INCLUDED_MODCAMERA_H +\ No newline at end of file ++/** ++ * @brief Sets the raw resolution parameters including ROI (Region of Interest). ++ * ++ * @param self Pointer to the camera object. ++ * @param startX X start position. ++ * @param startY Y start position. ++ * @param endX X end position. ++ * @param endY Y end position. ++ * @param offsetX X offset. ++ * @param offsetY Y offset. ++ * @param totalX Total X size. ++ * @param totalY Total Y size. ++ * @param outputX Output X size. ++ * @param outputY Output Y size. ++ * @param scale Enable scaling. ++ * @param binning Enable binning. ++ * @return 0 on success, negative value on error. ++ */ ++extern int mp_camera_hal_set_res_raw(mp_camera_obj_t *self, int startX, int startY, int endX, int endY, int offsetX, int offsetY, int totalX, int totalY, int outputX, int outputY, bool scale, bool binning); ++ ++#endif // MICROPY_INCLUDED_MODCAMERA_H +diff --git a/src/modcamera_api.c b/src/modcamera_api.c +index 39afa71..8f888ca 100644 +--- a/src/modcamera_api.c ++++ b/src/modcamera_api.c +@@ -285,6 +285,48 @@ CREATE_GETSET_FUNCTIONS(wpc, mp_obj_new_bool, mp_obj_is_true); + CREATE_GETSET_FUNCTIONS(raw_gma, mp_obj_new_bool, mp_obj_is_true); + CREATE_GETSET_FUNCTIONS(lenc, mp_obj_new_bool, mp_obj_is_true); + ++// set_res_raw function for ROI (Region of Interest) / digital zoom ++static mp_obj_t camera_set_res_raw(size_t n_args, const mp_obj_t *pos_args, mp_map_t *kw_args) { ++ mp_camera_obj_t *self = MP_OBJ_TO_PTR(pos_args[0]); ++ enum { ARG_startX, ARG_startY, ARG_endX, ARG_endY, ARG_offsetX, ARG_offsetY, ARG_totalX, ARG_totalY, ARG_outputX, ARG_outputY, ARG_scale, ARG_binning }; ++ static const mp_arg_t allowed_args[] = { ++ { MP_QSTR_startX, MP_ARG_INT | MP_ARG_REQUIRED }, ++ { MP_QSTR_startY, MP_ARG_INT | MP_ARG_REQUIRED }, ++ { MP_QSTR_endX, MP_ARG_INT | MP_ARG_REQUIRED }, ++ { MP_QSTR_endY, MP_ARG_INT | MP_ARG_REQUIRED }, ++ { MP_QSTR_offsetX, MP_ARG_INT | MP_ARG_REQUIRED }, ++ { MP_QSTR_offsetY, MP_ARG_INT | MP_ARG_REQUIRED }, ++ { MP_QSTR_totalX, MP_ARG_INT | MP_ARG_REQUIRED }, ++ { MP_QSTR_totalY, MP_ARG_INT | MP_ARG_REQUIRED }, ++ { MP_QSTR_outputX, MP_ARG_INT | MP_ARG_REQUIRED }, ++ { MP_QSTR_outputY, MP_ARG_INT | MP_ARG_REQUIRED }, ++ { MP_QSTR_scale, MP_ARG_BOOL, {.u_bool = false} }, ++ { MP_QSTR_binning, MP_ARG_BOOL, {.u_bool = false} }, ++ }; ++ ++ mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; ++ mp_arg_parse_all(n_args - 1, pos_args + 1, kw_args, MP_ARRAY_SIZE(allowed_args), allowed_args, args); ++ ++ int ret = mp_camera_hal_set_res_raw( ++ self, ++ args[ARG_startX].u_int, ++ args[ARG_startY].u_int, ++ args[ARG_endX].u_int, ++ args[ARG_endY].u_int, ++ args[ARG_offsetX].u_int, ++ args[ARG_offsetY].u_int, ++ args[ARG_totalX].u_int, ++ args[ARG_totalY].u_int, ++ args[ARG_outputX].u_int, ++ args[ARG_outputY].u_int, ++ args[ARG_scale].u_bool, ++ args[ARG_binning].u_bool ++ ); ++ ++ return mp_obj_new_int(ret); ++} ++static MP_DEFINE_CONST_FUN_OBJ_KW(camera_set_res_raw_obj, 1, camera_set_res_raw); ++ + //API-Tables + static const mp_rom_map_elem_t camera_camera_locals_table[] = { + { MP_ROM_QSTR(MP_QSTR_reconfigure), MP_ROM_PTR(&camera_reconfigure_obj) }, +@@ -293,6 +335,7 @@ static const mp_rom_map_elem_t camera_camera_locals_table[] = { + { MP_ROM_QSTR(MP_QSTR_free_buffer), MP_ROM_PTR(&camera_free_buf_obj) }, + { MP_ROM_QSTR(MP_QSTR_init), MP_ROM_PTR(&camera_init_obj) }, + { MP_ROM_QSTR(MP_QSTR_deinit), MP_ROM_PTR(&mp_camera_deinit_obj) }, ++ { MP_ROM_QSTR(MP_QSTR_set_res_raw), MP_ROM_PTR(&camera_set_res_raw_obj) }, + { MP_ROM_QSTR(MP_QSTR___del__), MP_ROM_PTR(&mp_camera_deinit_obj) }, + { MP_ROM_QSTR(MP_QSTR___enter__), MP_ROM_PTR(&mp_identity_obj) }, + { MP_ROM_QSTR(MP_QSTR___exit__), MP_ROM_PTR(&mp_camera___exit___obj) }, diff --git a/scripts/build_mpos.sh b/scripts/build_mpos.sh index 7b77ee46..5f0903e9 100755 --- a/scripts/build_mpos.sh +++ b/scripts/build_mpos.sh @@ -101,12 +101,23 @@ if [ "$target" == "esp32" ]; then elif [ "$target" == "unix" -o "$target" == "macOS" ]; then manifest=$(readlink -f "$codebasedir"/manifests/manifest.py) frozenmanifest="FROZEN_MANIFEST=$manifest" + + # Comment out @micropython.viper decorator for Unix/macOS builds + # (cross-compiler doesn't support Viper native code emitter) + echo "Temporarily commenting out @micropython.viper decorator for Unix/macOS build..." + stream_wav_file="$codebasedir"/internal_filesystem/lib/mpos/audio/stream_wav.py + sed -i.backup 's/^@micropython\.viper$/#@micropython.viper/' "$stream_wav_file" + # LV_CFLAGS are passed to USER_C_MODULES # STRIP= makes it so that debug symbols are kept pushd "$codebasedir"/lvgl_micropython/ # USER_C_MODULE doesn't seem to work properly so there are symlinks in lvgl_micropython/extmod/ python3 make.py "$target" LV_CFLAGS="-g -O0 -ggdb -ljpeg" STRIP= DISPLAY=sdl_display INDEV=sdl_pointer INDEV=sdl_keyboard "$frozenmanifest" popd + + # Restore @micropython.viper decorator after build + echo "Restoring @micropython.viper decorator..." + sed -i.backup 's/^#@micropython\.viper$/@micropython.viper/' "$stream_wav_file" else echo "invalid target $target" fi diff --git a/scripts/bundle_apps.sh b/scripts/bundle_apps.sh index d22724dd..e939ebc3 100755 --- a/scripts/bundle_apps.sh +++ b/scripts/bundle_apps.sh @@ -21,7 +21,8 @@ rm "$outputjson" # 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) -blacklist="com.micropythonos.filemanager com.quasikili.quasidoodle com.micropythonos.confetti com.micropythonos.showfonts com.micropythonos.draw com.micropythonos.errortest" +# com.micropythonos.showbattery is just a test +blacklist="com.micropythonos.filemanager com.quasikili.quasidoodle com.micropythonos.confetti com.micropythonos.showfonts com.micropythonos.draw com.micropythonos.errortest com.micropythonos.showbattery" echo "[" | tee -a "$outputjson" diff --git a/scripts/cleanup_pyc.sh b/scripts/cleanup_pyc.sh new file mode 100755 index 00000000..55f63f4b --- /dev/null +++ b/scripts/cleanup_pyc.sh @@ -0,0 +1 @@ +find internal_filesystem -iname "*.pyc" -exec rm {} \; diff --git a/scripts/convert_raw_to_png.sh b/scripts/convert_raw_to_png.sh new file mode 100644 index 00000000..ae1c5350 --- /dev/null +++ b/scripts/convert_raw_to_png.sh @@ -0,0 +1,12 @@ +inputfile="$1" +if [ -z "$inputfile" ]; then + echo "Usage: $0 inputfile" + echo "Example: $0 camera_capture_1764503331_960x960_GRAY.raw" + exit 1 +fi + +outputfile="$inputfile".png +echo "Converting $inputfile to $outputfile" + +# For now it's pretty hard coded but the format could be extracted from the filename... +convert -size 960x960 -depth 8 gray:"$inputfile" "$outputfile" diff --git a/scripts/install.sh b/scripts/install.sh index 9946e893..9e4aa66b 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -15,6 +15,11 @@ mpremote=$(readlink -f "$mydir/../lvgl_micropython/lib/micropython/tools/mpremot pushd internal_filesystem/ +# Maybe also do: import mpos ; mpos.TaskManager.stop() +echo "Disabling wifi because it writes to REPL from time to time when doing disconnect/reconnect for ADC2..." +$mpremote exec "import mpos ; mpos.net.wifi_service.WifiService.disconnect()" +sleep 2 + if [ ! -z "$appname" ]; then echo "Installing one app: $appname" appdir="apps/$appname/" @@ -43,6 +48,8 @@ fi # The issue is that this brings all the .git folders with it: #$mpremote fs cp -r apps :/ +$mpremote fs cp -r lib :/ + $mpremote fs mkdir :/apps $mpremote fs cp -r apps/com.micropythonos.* :/apps/ find apps/ -maxdepth 1 -type l | while read symlink; do @@ -55,11 +62,14 @@ 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/ +$mpremote fs mkdir :/data +$mpremote fs mkdir :/data/com.micropythonos.system.wifiservice +$mpremote fs cp ../internal_filesystem_excluded/data/com.micropythonos.system.wifiservice/config.json :/data/com.micropythonos.system.wifiservice/ + popd # Install test infrastructure (for running ondevice tests) diff --git a/scripts/run_desktop.sh b/scripts/run_desktop.sh index 177cd29b..63becd24 100755 --- a/scripts/run_desktop.sh +++ b/scripts/run_desktop.sh @@ -56,15 +56,17 @@ binary=$(readlink -f "$binary") chmod +x "$binary" pushd internal_filesystem/ - if [ -f "$script" ]; then - "$binary" -v -i "$script" - elif [ ! -z "$script" ]; then # it's an app name - scriptdir="$script" - echo "Running app from $scriptdir" - "$binary" -X heapsize=$HEAPSIZE -v -i -c "$(cat main.py) ; import mpos.apps; mpos.apps.start_app('$scriptdir')" - else - "$binary" -X heapsize=$HEAPSIZE -v -i -c "$(cat main.py)" - fi - + +if [ -f "$script" ]; then + echo "Running script $script" + "$binary" -v -i "$script" +else + echo "Running app $script" + mv data/com.micropythonos.settings/config.json data/com.micropythonos.settings/config.json.backup + # When $script is empty, it just doesn't find the app and stays at the launcher + echo '{"auto_start_app": "'$script'"}' > data/com.micropythonos.settings/config.json + "$binary" -X heapsize=$HEAPSIZE -v -i -c "$(cat main.py)" + mv data/com.micropythonos.settings/config.json.backup data/com.micropythonos.settings/config.json +fi popd diff --git a/tests/analyze_screenshot.py b/tests/analyze_screenshot.py new file mode 100755 index 00000000..328c19e0 --- /dev/null +++ b/tests/analyze_screenshot.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +""" +Analyze RGB565 screenshots for color correctness. + +Usage: + python3 analyze_screenshot.py screenshot.raw [width] [height] + +Checks: +- Color channel distribution (detect pale/washed out colors) +- Histogram analysis +- Average brightness +- Color saturation levels +""" + +import sys +import struct +from pathlib import Path + +def rgb565_to_rgb888(pixel): + """Convert RGB565 pixel to RGB888.""" + r5 = (pixel >> 11) & 0x1F + g6 = (pixel >> 5) & 0x3F + b5 = pixel & 0x1F + + r8 = (r5 << 3) | (r5 >> 2) + g8 = (g6 << 2) | (g6 >> 4) + b8 = (b5 << 3) | (b5 >> 2) + + return r8, g8, b8 + +def analyze_screenshot(filepath, width=320, height=240): + """Analyze RGB565 screenshot file.""" + print(f"Analyzing: {filepath}") + print(f"Dimensions: {width}x{height}") + + # Read raw data + try: + with open(filepath, 'rb') as f: + data = f.read() + except FileNotFoundError: + print(f"ERROR: File not found: {filepath}") + return + + expected_size = width * height * 2 + if len(data) != expected_size: + print(f"ERROR: File size mismatch. Expected {expected_size}, got {len(data)}") + print(f" Note: Expected size is for {width}x{height} RGB565 format") + return + + # Parse RGB565 pixels + pixels = [] + for i in range(0, len(data), 2): + # Little-endian RGB565 + pixel = struct.unpack(' 200: + print(" ⚠ WARNING: Very high brightness (overexposed)") + elif avg_brightness < 40: + print(" ⚠ WARNING: Very low brightness (underexposed)") + + # Simple histogram (10 bins) + print(f"\nChannel Histograms:") + for channel_name, channel_values in [('Red', red_values), ('Green', green_values), ('Blue', blue_values)]: + print(f" {channel_name}:") + + # Create 10 bins + bins = [0] * 10 + for val in channel_values: + bin_idx = min(9, val // 26) # 256 / 10 ā‰ˆ 26 + bins[bin_idx] += 1 + + for i, count in enumerate(bins): + bar_length = int((count / len(channel_values)) * 50) + bar = 'ā–ˆ' * bar_length + bin_start = i * 26 + bin_end = (i + 1) * 26 - 1 + print(f" {bin_start:3d}-{bin_end:3d}: {bar} ({count})") + + # Detect common YUV conversion issues + print(f"\nYUV Conversion Checks:") + + # Check if colors are clamped (many pixels at 0 or 255) + clamped_count = sum(1 for r, g, b in pixels if r == 0 or r == 255 or g == 0 or g == 255 or b == 0 or b == 255) + total_pixels = len(pixels) + clamp_percent = (clamped_count / total_pixels) * 100 + print(f" Clamped pixels: {clamp_percent:.1f}%") + if clamp_percent > 5: + print(" ⚠ WARNING: High clamp rate suggests color conversion overflow") + + # Check for green tint (common YUYV issue) + avg_red = sum(red_values) / len(red_values) + avg_green = sum(green_values) / len(green_values) + avg_blue = sum(blue_values) / len(blue_values) + + green_dominance = avg_green - ((avg_red + avg_blue) / 2) + if green_dominance > 20: + print(f" ⚠ WARNING: Green channel dominance ({green_dominance:.1f}) - possible YUYV U/V swap") + + # Sample pixels for visual inspection + print(f"\nSample Pixels (first 10):") + for i in range(min(10, len(pixels))): + r, g, b = pixels[i] + print(f" Pixel {i}: RGB({r:3d}, {g:3d}, {b:3d})") + +if __name__ == '__main__': + if len(sys.argv) < 2: + print("Usage: python3 analyze_screenshot.py [width] [height]") + print("") + print("Examples:") + print(" python3 analyze_screenshot.py camera_capture.raw") + print(" python3 analyze_screenshot.py camera_640x480.raw 640 480") + sys.exit(1) + + filepath = sys.argv[1] + width = int(sys.argv[2]) if len(sys.argv) > 2 else 320 + height = int(sys.argv[3]) if len(sys.argv) > 3 else 240 + + analyze_screenshot(filepath, width, height) diff --git a/tests/mocks/hardware_mocks.py b/tests/mocks/hardware_mocks.py new file mode 100644 index 00000000..b2d2e97e --- /dev/null +++ b/tests/mocks/hardware_mocks.py @@ -0,0 +1,102 @@ +# Hardware Mocks for Testing AudioFlinger and LightsManager +# Provides mock implementations of PWM, I2S, NeoPixel, and Pin classes + + +class MockPin: + """Mock machine.Pin for testing.""" + + IN = 0 + OUT = 1 + PULL_UP = 2 + + def __init__(self, pin_number, mode=None, pull=None): + self.pin_number = pin_number + self.mode = mode + self.pull = pull + self._value = 0 + + def value(self, val=None): + if val is not None: + self._value = val + return self._value + + +class MockPWM: + """Mock machine.PWM for testing buzzer.""" + + def __init__(self, pin, freq=0, duty=0): + self.pin = pin + self.last_freq = freq + self.last_duty = duty + self.freq_history = [] + self.duty_history = [] + + def freq(self, value=None): + """Set or get frequency.""" + if value is not None: + self.last_freq = value + self.freq_history.append(value) + return self.last_freq + + def duty_u16(self, value=None): + """Set or get duty cycle (0-65535).""" + if value is not None: + self.last_duty = value + self.duty_history.append(value) + return self.last_duty + + +class MockI2S: + """Mock machine.I2S for testing audio playback.""" + + TX = 0 + MONO = 1 + STEREO = 2 + + def __init__(self, id, sck, ws, sd, mode, bits, format, rate, ibuf): + self.id = id + self.sck = sck + self.ws = ws + self.sd = sd + self.mode = mode + self.bits = bits + self.format = format + self.rate = rate + self.ibuf = ibuf + self.written_bytes = [] + self.total_bytes_written = 0 + + def write(self, buf): + """Simulate writing to I2S hardware.""" + self.written_bytes.append(bytes(buf)) + self.total_bytes_written += len(buf) + return len(buf) + + def deinit(self): + """Deinitialize I2S.""" + pass + + +class MockNeoPixel: + """Mock neopixel.NeoPixel for testing LEDs.""" + + def __init__(self, pin, num_leds): + self.pin = pin + self.num_leds = num_leds + self.pixels = [(0, 0, 0)] * num_leds + self.write_count = 0 + + def __setitem__(self, index, value): + """Set LED color (R, G, B) tuple.""" + if 0 <= index < self.num_leds: + self.pixels[index] = value + + def __getitem__(self, index): + """Get LED color.""" + if 0 <= index < self.num_leds: + return self.pixels[index] + return (0, 0, 0) + + def write(self): + """Update hardware (mock - just increment counter).""" + self.write_count += 1 diff --git a/tests/network_test_helper.py b/tests/network_test_helper.py index e3e60b25..1a6d235b 100644 --- a/tests/network_test_helper.py +++ b/tests/network_test_helper.py @@ -2,577 +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): - """ - Initialize mock raw response. - - Args: - content: Binary content to stream - """ - self.content = content - self.position = 0 - - 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) - """ - 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''): - """ - 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'') - """ - 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) - - 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.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 - - # Record call in history - self.call_history.append({ - 'method': 'GET', - 'url': url, - 'stream': stream, - 'timeout': timeout, - 'headers': headers - }) - - 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''): - """ - 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'') - - Returns: - MockResponse: The configured response object - """ - self.next_response = MockResponse(status_code, text, headers, content) - 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. @@ -587,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_audioflinger.py b/tests/test_audioflinger.py new file mode 100644 index 00000000..da9414ee --- /dev/null +++ b/tests/test_audioflinger.py @@ -0,0 +1,206 @@ +# Unit tests for AudioFlinger service +import unittest +import sys + +# Import centralized mocks +from mpos.testing import ( + MockMachine, + MockPWM, + MockPin, + MockThread, + MockApps, + inject_mocks, +) + +# Inject mocks before importing AudioFlinger +inject_mocks({ + 'machine': MockMachine(), + '_thread': MockThread, + 'mpos.apps': MockApps, +}) + +# Now import the module to test +import mpos.audio.audioflinger as AudioFlinger + + +class TestAudioFlinger(unittest.TestCase): + """Test cases for AudioFlinger service.""" + + def setUp(self): + """Initialize AudioFlinger before each test.""" + self.buzzer = MockPWM(MockPin(46)) + self.i2s_pins = {'sck': 2, 'ws': 47, 'sd': 16} + + # Reset volume to default before each test + AudioFlinger.set_volume(70) + + AudioFlinger.init( + i2s_pins=self.i2s_pins, + buzzer_instance=self.buzzer + ) + + def tearDown(self): + """Clean up after each test.""" + AudioFlinger.stop() + + def test_initialization(self): + """Test that AudioFlinger initializes correctly.""" + self.assertEqual(AudioFlinger._i2s_pins, self.i2s_pins) + self.assertEqual(AudioFlinger._buzzer_instance, self.buzzer) + + def test_has_i2s(self): + """Test has_i2s() returns correct value.""" + # With I2S configured + AudioFlinger.init(i2s_pins=self.i2s_pins, buzzer_instance=None) + self.assertTrue(AudioFlinger.has_i2s()) + + # Without I2S configured + AudioFlinger.init(i2s_pins=None, buzzer_instance=self.buzzer) + self.assertFalse(AudioFlinger.has_i2s()) + + def test_has_buzzer(self): + """Test has_buzzer() returns correct value.""" + # With buzzer configured + AudioFlinger.init(i2s_pins=None, buzzer_instance=self.buzzer) + self.assertTrue(AudioFlinger.has_buzzer()) + + # Without buzzer configured + AudioFlinger.init(i2s_pins=self.i2s_pins, buzzer_instance=None) + self.assertFalse(AudioFlinger.has_buzzer()) + + def test_stream_types(self): + """Test stream type constants and priority order.""" + self.assertEqual(AudioFlinger.STREAM_MUSIC, 0) + self.assertEqual(AudioFlinger.STREAM_NOTIFICATION, 1) + self.assertEqual(AudioFlinger.STREAM_ALARM, 2) + + # Higher number = higher priority + self.assertTrue(AudioFlinger.STREAM_MUSIC < AudioFlinger.STREAM_NOTIFICATION) + self.assertTrue(AudioFlinger.STREAM_NOTIFICATION < AudioFlinger.STREAM_ALARM) + + def test_volume_control(self): + """Test volume get/set operations.""" + # Set volume + AudioFlinger.set_volume(50) + self.assertEqual(AudioFlinger.get_volume(), 50) + + # Test clamping to 0-100 range + AudioFlinger.set_volume(150) + self.assertEqual(AudioFlinger.get_volume(), 100) + + AudioFlinger.set_volume(-10) + self.assertEqual(AudioFlinger.get_volume(), 0) + + def test_no_hardware_rejects_playback(self): + """Test that no hardware rejects all playback requests.""" + # Re-initialize with no hardware + AudioFlinger.init(i2s_pins=None, buzzer_instance=None) + + # WAV should be rejected (no I2S) + result = AudioFlinger.play_wav("test.wav") + self.assertFalse(result) + + # RTTTL should be rejected (no buzzer) + result = AudioFlinger.play_rtttl("Test:d=4,o=5,b=120:c") + self.assertFalse(result) + + def test_i2s_only_rejects_rtttl(self): + """Test that I2S-only config rejects buzzer playback.""" + # Re-initialize with I2S only + AudioFlinger.init(i2s_pins=self.i2s_pins, buzzer_instance=None) + + # RTTTL should be rejected (no buzzer) + result = AudioFlinger.play_rtttl("Test:d=4,o=5,b=120:c") + self.assertFalse(result) + + def test_buzzer_only_rejects_wav(self): + """Test that buzzer-only config rejects I2S playback.""" + # Re-initialize with buzzer only + AudioFlinger.init(i2s_pins=None, buzzer_instance=self.buzzer) + + # WAV should be rejected (no I2S) + result = AudioFlinger.play_wav("test.wav") + self.assertFalse(result) + + def test_is_playing_initially_false(self): + """Test that is_playing() returns False initially.""" + self.assertFalse(AudioFlinger.is_playing()) + + def test_stop_with_no_playback(self): + """Test that stop() can be called when nothing is playing.""" + # Should not raise exception + AudioFlinger.stop() + self.assertFalse(AudioFlinger.is_playing()) + + def test_audio_focus_check_no_current_stream(self): + """Test audio focus allows playback when no stream is active.""" + result = AudioFlinger._check_audio_focus(AudioFlinger.STREAM_MUSIC) + self.assertTrue(result) + + def test_volume_default_value(self): + """Test that default volume is reasonable.""" + # After init, volume should be at default (70) + AudioFlinger.init(i2s_pins=None, buzzer_instance=None) + self.assertEqual(AudioFlinger.get_volume(), 70) + + +class TestAudioFlingerRecording(unittest.TestCase): + """Test cases for AudioFlinger recording functionality.""" + + def setUp(self): + """Initialize AudioFlinger with microphone before each test.""" + self.buzzer = MockPWM(MockPin(46)) + # I2S pins with microphone input + self.i2s_pins_with_mic = {'sck': 2, 'ws': 47, 'sd': 16, 'sd_in': 15} + # I2S pins without microphone input + self.i2s_pins_no_mic = {'sck': 2, 'ws': 47, 'sd': 16} + + # Reset state + AudioFlinger._current_recording = None + AudioFlinger.set_volume(70) + + AudioFlinger.init( + i2s_pins=self.i2s_pins_with_mic, + buzzer_instance=self.buzzer + ) + + def tearDown(self): + """Clean up after each test.""" + AudioFlinger.stop() + + def test_has_microphone_with_sd_in(self): + """Test has_microphone() returns True when sd_in pin is configured.""" + AudioFlinger.init(i2s_pins=self.i2s_pins_with_mic, buzzer_instance=None) + self.assertTrue(AudioFlinger.has_microphone()) + + def test_has_microphone_without_sd_in(self): + """Test has_microphone() returns False when sd_in pin is not configured.""" + AudioFlinger.init(i2s_pins=self.i2s_pins_no_mic, buzzer_instance=None) + self.assertFalse(AudioFlinger.has_microphone()) + + def test_has_microphone_no_i2s(self): + """Test has_microphone() returns False when no I2S is configured.""" + AudioFlinger.init(i2s_pins=None, buzzer_instance=self.buzzer) + self.assertFalse(AudioFlinger.has_microphone()) + + def test_is_recording_initially_false(self): + """Test that is_recording() returns False initially.""" + self.assertFalse(AudioFlinger.is_recording()) + + def test_record_wav_no_microphone(self): + """Test that record_wav() fails when no microphone is configured.""" + AudioFlinger.init(i2s_pins=self.i2s_pins_no_mic, buzzer_instance=None) + result = AudioFlinger.record_wav("test.wav") + self.assertFalse(result) + + def test_record_wav_no_i2s(self): + """Test that record_wav() fails when no I2S is configured.""" + AudioFlinger.init(i2s_pins=None, buzzer_instance=self.buzzer) + result = AudioFlinger.record_wav("test.wav") + self.assertFalse(result) + + def test_stop_with_no_recording(self): + """Test that stop() can be called when nothing is recording.""" + # Should not raise exception + AudioFlinger.stop() + self.assertFalse(AudioFlinger.is_recording()) diff --git a/tests/test_battery_voltage.py b/tests/test_battery_voltage.py new file mode 100644 index 00000000..3f3336af --- /dev/null +++ b/tests/test_battery_voltage.py @@ -0,0 +1,438 @@ +""" +Unit tests for mpos.battery_voltage module. + +Tests ADC1/ADC2 detection, caching, WiFi coordination, and voltage calculations. +""" + +import unittest +import sys + +# Add parent directory to path for imports +sys.path.insert(0, '../internal_filesystem') + +# Mock modules before importing battery_voltage +class MockADC: + """Mock ADC for testing.""" + ATTN_11DB = 3 + + def __init__(self, pin): + self.pin = pin + self._atten = None + self._read_value = 2048 # Default mid-range value + + def atten(self, value): + self._atten = value + + def read(self): + return self._read_value + + def set_read_value(self, value): + """Test helper to set ADC reading.""" + self._read_value = value + + +class MockPin: + """Mock Pin for testing.""" + def __init__(self, pin_num): + self.pin_num = pin_num + + +class MockMachine: + """Mock machine module.""" + ADC = MockADC + Pin = MockPin + + +class MockWifiService: + """Mock WifiService for testing.""" + wifi_busy = False + _connected = False + _temporarily_disabled = False + + @classmethod + def is_connected(cls): + return cls._connected + + @classmethod + def disconnect(cls): + cls._connected = False + + @classmethod + def temporarily_disable(cls): + """Temporarily disable WiFi and return whether it was connected.""" + if cls.wifi_busy: + raise RuntimeError("Cannot disable WiFi: WifiService is already busy") + was_connected = cls._connected + cls.wifi_busy = True + cls._connected = False + cls._temporarily_disabled = True + return was_connected + + @classmethod + def temporarily_enable(cls, was_connected): + """Re-enable WiFi and reconnect if it was connected before.""" + cls.wifi_busy = False + cls._temporarily_disabled = False + if was_connected: + cls._connected = True # Simulate reconnection + + @classmethod + def reset(cls): + """Test helper to reset state.""" + cls.wifi_busy = False + cls._connected = False + cls._temporarily_disabled = False + + +# Inject mocks +sys.modules['machine'] = MockMachine +sys.modules['mpos.net.wifi_service'] = type('module', (), {'WifiService': MockWifiService})() + +# Now import battery_voltage +import mpos.battery_voltage as bv + + +class TestADC2Detection(unittest.TestCase): + """Test ADC1 vs ADC2 pin detection.""" + + def test_adc1_pins_detected(self): + """Test that ADC1 pins (GPIO1-10) are detected correctly.""" + for pin in range(1, 11): + self.assertFalse(bv._is_adc2_pin(pin), f"GPIO{pin} should be ADC1") + + def test_adc2_pins_detected(self): + """Test that ADC2 pins (GPIO11-20) are detected correctly.""" + for pin in range(11, 21): + self.assertTrue(bv._is_adc2_pin(pin), f"GPIO{pin} should be ADC2") + + def test_out_of_range_pins(self): + """Test pins outside ADC range.""" + self.assertFalse(bv._is_adc2_pin(0)) + self.assertFalse(bv._is_adc2_pin(21)) + self.assertFalse(bv._is_adc2_pin(30)) + self.assertFalse(bv._is_adc2_pin(100)) + + +class TestInitADC(unittest.TestCase): + """Test ADC initialization.""" + + def setUp(self): + """Reset module state.""" + bv.adc = None + bv.conversion_func = None + bv.adc_pin = None + + def test_init_adc1_pin(self): + """Test initializing with ADC1 pin.""" + def adc_to_voltage(adc_value): + return adc_value * 0.00161 + + bv.init_adc(5, adc_to_voltage) + + self.assertIsNotNone(bv.adc) + self.assertEqual(bv.conversion_func, adc_to_voltage) + self.assertEqual(bv.adc_pin, 5) + self.assertEqual(bv.adc._atten, MockADC.ATTN_11DB) + + def test_init_adc2_pin(self): + """Test initializing with ADC2 pin (should warn but work).""" + def adc_to_voltage(adc_value): + return adc_value * 0.00197 + + bv.init_adc(13, adc_to_voltage) + + self.assertIsNotNone(bv.adc) + self.assertIsNotNone(bv.conversion_func) + self.assertEqual(bv.adc_pin, 13) + + def test_conversion_func_stored(self): + """Test that conversion function is stored correctly.""" + def my_conversion(adc_value): + return adc_value * 0.12345 + + bv.init_adc(5, my_conversion) + self.assertEqual(bv.conversion_func, my_conversion) + + +class TestCaching(unittest.TestCase): + """Test caching mechanism.""" + + def setUp(self): + """Reset module state.""" + bv.clear_cache() + def adc_to_voltage(adc_value): + return adc_value * 0.00161 + bv.init_adc(5, adc_to_voltage) # Use ADC1 to avoid WiFi complexity + MockWifiService.reset() + + def tearDown(self): + """Clean up.""" + bv.clear_cache() + + def test_cache_hit_on_first_read(self): + """Test that first read already has a cache (because of read during init) """ + self.assertIsNotNone(bv._cached_raw_adc) + raw = bv.read_raw_adc() + self.assertIsNotNone(bv._cached_raw_adc) + self.assertEqual(raw, bv._cached_raw_adc) + + def test_cache_hit_within_duration(self): + """Test that subsequent reads use cache within duration.""" + raw1 = bv.read_raw_adc() + + # Change ADC value but should still get cached value + bv.adc.set_read_value(3000) + raw2 = bv.read_raw_adc() + + self.assertEqual(raw1, raw2, "Should return cached value") + + def test_force_refresh_bypasses_cache(self): + """Test that force_refresh bypasses cache.""" + bv.adc.set_read_value(2000) + raw1 = bv.read_raw_adc() + + # Change value and force refresh + bv.adc.set_read_value(3000) + raw2 = bv.read_raw_adc(force_refresh=True) + + self.assertNotEqual(raw1, raw2, "force_refresh should bypass cache") + self.assertEqual(raw2, 3000.0) + + def test_clear_cache_works(self): + """Test that clear_cache() clears the cache.""" + bv.read_raw_adc() + self.assertIsNotNone(bv._cached_raw_adc) + + bv.clear_cache() + self.assertIsNone(bv._cached_raw_adc) + self.assertEqual(bv._last_read_time, 0) + + +class TestADC1Reading(unittest.TestCase): + """Test ADC reading with ADC1 (no WiFi interference).""" + + def setUp(self): + """Reset module state.""" + bv.clear_cache() + def adc_to_voltage(adc_value): + return adc_value * 0.00161 + bv.init_adc(5, adc_to_voltage) # GPIO5 is ADC1 + MockWifiService.reset() + MockWifiService._connected = True + + def tearDown(self): + """Clean up.""" + bv.clear_cache() + MockWifiService.reset() + + def test_adc1_doesnt_disable_wifi(self): + """Test that ADC1 reading doesn't disable WiFi.""" + MockWifiService._connected = True + + bv.read_raw_adc(force_refresh=True) + + # WiFi should still be connected + self.assertTrue(MockWifiService.is_connected()) + self.assertFalse(MockWifiService.wifi_busy) + + def test_adc1_ignores_wifi_busy(self): + """Test that ADC1 reading works even if WiFi is busy.""" + MockWifiService.wifi_busy = True + + # Should not raise error + try: + raw = bv.read_raw_adc(force_refresh=True) + self.assertIsNotNone(raw) + except RuntimeError: + self.fail("ADC1 should not raise error when WiFi is busy") + + +class TestADC2Reading(unittest.TestCase): + """Test ADC reading with ADC2 (requires WiFi disable).""" + + def setUp(self): + """Reset module state.""" + bv.clear_cache() + def adc_to_voltage(adc_value): + return adc_value * 0.00197 + bv.init_adc(13, adc_to_voltage) # GPIO13 is ADC2 + MockWifiService.reset() + + def tearDown(self): + """Clean up.""" + bv.clear_cache() + MockWifiService.reset() + + def test_adc2_disables_wifi_when_connected(self): + """Test that ADC2 reading disables WiFi when connected.""" + MockWifiService._connected = True + + bv.read_raw_adc(force_refresh=True) + + # WiFi should be reconnected after reading (if it was connected before) + self.assertTrue(MockWifiService.is_connected()) + + def test_adc2_sets_wifi_busy_flag(self): + """Test that ADC2 reading sets wifi_busy flag.""" + MockWifiService._connected = False + + # wifi_busy should be False before + self.assertFalse(MockWifiService.wifi_busy) + + bv.read_raw_adc(force_refresh=True) + + # wifi_busy should be False after (cleared in finally) + self.assertFalse(MockWifiService.wifi_busy) + + def test_adc2_raises_error_if_wifi_busy(self): + """Test that ADC2 reading raises error if WiFi is busy.""" + MockWifiService.wifi_busy = True + + with self.assertRaises(RuntimeError) as ctx: + bv.read_raw_adc(force_refresh=True) + + self.assertIn("WifiService is already busy", str(ctx.exception)) + + def test_adc2_uses_cache_when_wifi_busy(self): + """Test that ADC2 uses cache even when WiFi is busy.""" + # First read to populate cache + MockWifiService.wifi_busy = False + raw1 = bv.read_raw_adc(force_refresh=True) + + # Now set WiFi busy + MockWifiService.wifi_busy = True + + # Should return cached value without error + raw2 = bv.read_raw_adc() + self.assertEqual(raw1, raw2) + + def test_adc2_only_reconnects_if_was_connected(self): + """Test that ADC2 only reconnects WiFi if it was connected before.""" + # WiFi is NOT connected + MockWifiService._connected = False + + bv.read_raw_adc(force_refresh=True) + + # WiFi should still be disconnected (no unwanted reconnection) + self.assertFalse(MockWifiService.is_connected()) + + +class TestVoltageCalculations(unittest.TestCase): + """Test voltage and percentage calculations.""" + + def setUp(self): + """Reset module state.""" + bv.clear_cache() + def adc_to_voltage(adc_value): + return adc_value * 0.00161 + bv.init_adc(5, adc_to_voltage) # ADC1 pin, scale factor for 2:1 divider + bv.adc.set_read_value(2048) # Mid-range + + def tearDown(self): + """Clean up.""" + bv.clear_cache() + + def test_read_battery_voltage_applies_scale_factor(self): + """Test that voltage is calculated correctly.""" + bv.adc.set_read_value(2048) # Mid-range + bv.clear_cache() + + voltage = bv.read_battery_voltage(force_refresh=True) + expected = 2048 * 0.00161 + self.assertAlmostEqual(voltage, expected, places=4) + + def test_voltage_clamped_to_zero(self): + """Test that negative voltage is clamped to 0.""" + bv.adc.set_read_value(0) + bv.clear_cache() + + voltage = bv.read_battery_voltage(force_refresh=True) + self.assertGreaterEqual(voltage, 0.0) + + def test_get_battery_percentage_calculation(self): + """Test percentage calculation.""" + # Set voltage to mid-range between MIN and MAX + mid_voltage = (bv.MIN_VOLTAGE + bv.MAX_VOLTAGE) / 2 + # Inverse of conversion function: if voltage = adc * 0.00161, then adc = voltage / 0.00161 + raw_adc = mid_voltage / 0.00161 + bv.adc.set_read_value(int(raw_adc)) + bv.clear_cache() + + percentage = bv.get_battery_percentage() + self.assertAlmostEqual(percentage, 50.0, places=0) + + def test_percentage_clamped_to_0_100(self): + """Test that percentage is clamped to 0-100 range.""" + # Test minimum + bv.adc.set_read_value(0) + bv.clear_cache() + percentage = bv.get_battery_percentage() + self.assertGreaterEqual(percentage, 0.0) + self.assertLessEqual(percentage, 100.0) + + # Test maximum + bv.adc.set_read_value(4095) + bv.clear_cache() + percentage = bv.get_battery_percentage() + self.assertGreaterEqual(percentage, 0.0) + self.assertLessEqual(percentage, 100.0) + + +class TestAveragingLogic(unittest.TestCase): + """Test that ADC readings are averaged.""" + + def setUp(self): + """Reset module state.""" + bv.clear_cache() + def adc_to_voltage(adc_value): + return adc_value * 0.00161 + bv.init_adc(5, adc_to_voltage) + + def tearDown(self): + """Clean up.""" + bv.clear_cache() + + def test_adc_read_averages_10_samples(self): + """Test that 10 samples are averaged.""" + bv.adc.set_read_value(2000) + bv.clear_cache() + + raw = bv.read_raw_adc(force_refresh=True) + + # Should be average of 10 reads + self.assertEqual(raw, 2000.0) + + +class TestDesktopMode(unittest.TestCase): + """Test behavior when ADC is not available (desktop mode).""" + + def setUp(self): + """Disable ADC.""" + bv.adc = None + def adc_to_voltage(adc_value): + return adc_value * 0.00161 + bv.conversion_func = adc_to_voltage + + def test_read_raw_adc_returns_random_value(self): + """Test that desktop mode returns random ADC value.""" + raw = bv.read_raw_adc() + self.assertIsNotNone(raw) + self.assertTrue(raw > 0, f"Expected raw > 0, got {raw}") + self.assertTrue(raw < 4096, f"Expected raw < 4096, got {raw}") + + def test_read_battery_voltage_works_without_adc(self): + """Test that voltage reading works in desktop mode.""" + voltage = bv.read_battery_voltage() + self.assertIsNotNone(voltage) + self.assertTrue(voltage > 0, f"Expected voltage > 0, got {voltage}") + + def test_get_battery_percentage_works_without_adc(self): + """Test that percentage reading works in desktop mode.""" + percentage = bv.get_battery_percentage() + self.assertIsNotNone(percentage) + self.assertGreaterEqual(percentage, 0) + self.assertLessEqual(percentage, 100) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_calibration_check_bug.py b/tests/test_calibration_check_bug.py new file mode 100644 index 00000000..14e72d80 --- /dev/null +++ b/tests/test_calibration_check_bug.py @@ -0,0 +1,162 @@ +"""Test for calibration check bug after calibrating. + +Reproduces issue where check_calibration_quality() returns None after calibration. +""" +import unittest +import sys + +# Mock hardware before importing SensorManager +class MockI2C: + def __init__(self, bus_id, sda=None, scl=None): + self.bus_id = bus_id + self.sda = sda + self.scl = scl + self.memory = {} + + def readfrom_mem(self, addr, reg, nbytes): + if addr not in self.memory: + raise OSError("I2C device not found") + if reg not in self.memory[addr]: + return bytes([0] * nbytes) + return bytes(self.memory[addr][reg]) + + def writeto_mem(self, addr, reg, data): + if addr not in self.memory: + self.memory[addr] = {} + self.memory[addr][reg] = list(data) + + +class MockQMI8658: + def __init__(self, i2c_bus, address=0x6B, accel_scale=0b10, gyro_scale=0b100): + self.i2c = i2c_bus + self.address = address + self.accel_scale = accel_scale + self.gyro_scale = gyro_scale + + @property + def temperature(self): + return 25.5 + + @property + def acceleration(self): + return (0.0, 0.0, 1.0) # At rest, Z-axis = 1G + + @property + def gyro(self): + return (0.0, 0.0, 0.0) # Stationary + + +# Mock constants +_QMI8685_PARTID = 0x05 +_REG_PARTID = 0x00 +_ACCELSCALE_RANGE_8G = 0b10 +_GYROSCALE_RANGE_256DPS = 0b100 + +# Create mock modules +mock_machine = type('module', (), { + 'I2C': MockI2C, + 'Pin': type('Pin', (), {}) +})() + +mock_qmi8658 = type('module', (), { + 'QMI8658': MockQMI8658, + '_QMI8685_PARTID': _QMI8685_PARTID, + '_REG_PARTID': _REG_PARTID, + '_ACCELSCALE_RANGE_8G': _ACCELSCALE_RANGE_8G, + '_GYROSCALE_RANGE_256DPS': _GYROSCALE_RANGE_256DPS +})() + +def _mock_mcu_temperature(*args, **kwargs): + return 42.0 + +mock_esp32 = type('module', (), { + 'mcu_temperature': _mock_mcu_temperature +})() + +# Inject mocks +sys.modules['machine'] = mock_machine +sys.modules['mpos.hardware.drivers.qmi8658'] = mock_qmi8658 +sys.modules['esp32'] = mock_esp32 + +try: + import _thread +except ImportError: + mock_thread = type('module', (), { + 'allocate_lock': lambda: type('lock', (), { + 'acquire': lambda self: None, + 'release': lambda self: None + })() + })() + sys.modules['_thread'] = mock_thread + +# Now import the module to test +import mpos.sensor_manager as SensorManager + + +class TestCalibrationCheckBug(unittest.TestCase): + """Test case for calibration check bug.""" + + def setUp(self): + """Set up test fixtures before each test.""" + # Reset SensorManager state + SensorManager._initialized = False + SensorManager._imu_driver = None + SensorManager._sensor_list = [] + SensorManager._has_mcu_temperature = False + + # Create mock I2C bus with QMI8658 + self.i2c_bus = MockI2C(0, sda=48, scl=47) + self.i2c_bus.memory[0x6B] = {_REG_PARTID: [_QMI8685_PARTID]} + + def test_check_quality_after_calibration(self): + """Test that check_calibration_quality() works after calibration. + + This reproduces the bug where check_calibration_quality() returns + None or shows "--" after performing calibration. + """ + # Initialize + SensorManager.init(self.i2c_bus, address=0x6B) + + # Step 1: Check calibration quality BEFORE calibration (should work) + print("\n=== Step 1: Check quality BEFORE calibration ===") + quality_before = SensorManager.check_calibration_quality(samples=10) + self.assertIsNotNone(quality_before, "Quality check BEFORE calibration should return data") + self.assertIn('quality_score', quality_before) + print(f"Quality before: {quality_before['quality_rating']} ({quality_before['quality_score']:.2f})") + + # Step 2: Calibrate sensors + print("\n=== Step 2: Calibrate sensors ===") + accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) + gyro = SensorManager.get_default_sensor(SensorManager.TYPE_GYROSCOPE) + + self.assertIsNotNone(accel, "Accelerometer should be available") + self.assertIsNotNone(gyro, "Gyroscope should be available") + + accel_offsets = SensorManager.calibrate_sensor(accel, samples=10) + print(f"Accel offsets: {accel_offsets}") + self.assertIsNotNone(accel_offsets, "Accelerometer calibration should succeed") + + gyro_offsets = SensorManager.calibrate_sensor(gyro, samples=10) + print(f"Gyro offsets: {gyro_offsets}") + self.assertIsNotNone(gyro_offsets, "Gyroscope calibration should succeed") + + # Step 3: Check calibration quality AFTER calibration (BUG: returns None) + print("\n=== Step 3: Check quality AFTER calibration ===") + quality_after = SensorManager.check_calibration_quality(samples=10) + self.assertIsNotNone(quality_after, "Quality check AFTER calibration should return data (BUG: returns None)") + self.assertIn('quality_score', quality_after) + print(f"Quality after: {quality_after['quality_rating']} ({quality_after['quality_score']:.2f})") + + # Verify sensor reads still work + print("\n=== Step 4: Verify sensor reads still work ===") + accel_data = SensorManager.read_sensor(accel) + self.assertIsNotNone(accel_data, "Accelerometer should still be readable") + print(f"Accel data: {accel_data}") + + gyro_data = SensorManager.read_sensor(gyro) + self.assertIsNotNone(gyro_data, "Gyroscope should still be readable") + print(f"Gyro data: {gyro_data}") + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_download_manager.py b/tests/test_download_manager.py new file mode 100644 index 00000000..0eee1410 --- /dev/null +++ b/tests/test_download_manager.py @@ -0,0 +1,417 @@ +""" +test_download_manager.py - Tests for DownloadManager module + +Tests the centralized download manager functionality including: +- Session lifecycle management +- Download modes (memory, file, streaming) +- Progress tracking +- Error handling +- Resume support with Range headers +- Concurrent downloads +""" + +import unittest +import os +import sys + +# Import the module under test +sys.path.insert(0, '../internal_filesystem/lib') +import mpos.net.download_manager as DownloadManager + + +class TestDownloadManager(unittest.TestCase): + """Test cases for DownloadManager module.""" + + def setUp(self): + """Reset module state before each test.""" + # Reset module-level state + DownloadManager._session = None + DownloadManager._session_refcount = 0 + DownloadManager._session_lock = None + + # Create temp directory for file downloads + self.temp_dir = "/tmp/test_download_manager" + try: + os.mkdir(self.temp_dir) + except OSError: + pass # Directory already exists + + def tearDown(self): + """Clean up after each test.""" + # Close any open sessions + import asyncio + if DownloadManager._session: + asyncio.run(DownloadManager.close_session()) + + # Clean up temp files + try: + import os + for file in os.listdir(self.temp_dir): + try: + os.remove(f"{self.temp_dir}/{file}") + except OSError: + pass + os.rmdir(self.temp_dir) + except OSError: + pass + + # ==================== Session Lifecycle Tests ==================== + + def test_lazy_session_creation(self): + """Test that session is created lazily on first download.""" + import asyncio + + async def run_test(): + # Verify no session exists initially + self.assertFalse(DownloadManager.is_session_active()) + + # Perform a download + data = await DownloadManager.download_url("https://httpbin.org/bytes/100") + + # Verify session was created + # Note: Session may be closed immediately after download if refcount == 0 + # So we can't reliably check is_session_active() here + self.assertIsNotNone(data) + self.assertEqual(len(data), 100) + + asyncio.run(run_test()) + + def test_session_reuse_across_downloads(self): + """Test that the same session is reused for multiple downloads.""" + import asyncio + + async def run_test(): + # Perform first download + data1 = await DownloadManager.download_url("https://httpbin.org/bytes/50") + self.assertIsNotNone(data1) + + # Perform second download + data2 = await DownloadManager.download_url("https://httpbin.org/bytes/75") + self.assertIsNotNone(data2) + + # Verify different data was downloaded + self.assertEqual(len(data1), 50) + self.assertEqual(len(data2), 75) + + asyncio.run(run_test()) + + def test_explicit_session_close(self): + """Test explicit session closure.""" + import asyncio + + async def run_test(): + # Create session by downloading + data = await DownloadManager.download_url("https://httpbin.org/bytes/10") + self.assertIsNotNone(data) + + # Explicitly close session + await DownloadManager.close_session() + + # Verify session is closed + self.assertFalse(DownloadManager.is_session_active()) + + # Verify new download recreates session + data2 = await DownloadManager.download_url("https://httpbin.org/bytes/20") + self.assertIsNotNone(data2) + self.assertEqual(len(data2), 20) + + asyncio.run(run_test()) + + # ==================== Download Mode Tests ==================== + + def test_download_to_memory(self): + """Test downloading content to memory (returns bytes).""" + import asyncio + + async def run_test(): + data = await DownloadManager.download_url("https://httpbin.org/bytes/1024") + + self.assertIsInstance(data, bytes) + self.assertEqual(len(data), 1024) + + asyncio.run(run_test()) + + def test_download_to_file(self): + """Test downloading content to file (returns True/False).""" + import asyncio + + async def run_test(): + outfile = f"{self.temp_dir}/test_download.bin" + + success = await DownloadManager.download_url( + "https://httpbin.org/bytes/2048", + outfile=outfile + ) + + self.assertTrue(success) + self.assertEqual(os.stat(outfile)[6], 2048) + + # Clean up + os.remove(outfile) + + asyncio.run(run_test()) + + def test_download_with_chunk_callback(self): + """Test streaming download with chunk callback.""" + import asyncio + + async def run_test(): + chunks_received = [] + + async def collect_chunks(chunk): + chunks_received.append(chunk) + + success = await DownloadManager.download_url( + "https://httpbin.org/bytes/512", + chunk_callback=collect_chunks + ) + + self.assertTrue(success) + self.assertTrue(len(chunks_received) > 0) + + # Verify total size matches + total_size = sum(len(chunk) for chunk in chunks_received) + self.assertEqual(total_size, 512) + + asyncio.run(run_test()) + + def test_parameter_validation_conflicting_params(self): + """Test that outfile and chunk_callback cannot both be provided.""" + import asyncio + + async def run_test(): + with self.assertRaises(ValueError) as context: + await DownloadManager.download_url( + "https://httpbin.org/bytes/100", + outfile="/tmp/test.bin", + chunk_callback=lambda chunk: None + ) + + self.assertIn("Cannot use both", str(context.exception)) + + asyncio.run(run_test()) + + # ==================== Progress Tracking Tests ==================== + + def test_progress_callback(self): + """Test that progress callback is called with percentages.""" + import asyncio + + async def run_test(): + progress_calls = [] + + async def track_progress(percent): + progress_calls.append(percent) + + data = await DownloadManager.download_url( + "https://httpbin.org/bytes/5120", # 5KB + progress_callback=track_progress + ) + + self.assertIsNotNone(data) + self.assertTrue(len(progress_calls) > 0) + + # Verify progress values are in valid range + for pct in progress_calls: + self.assertTrue(0 <= pct <= 100) + + # Verify progress generally increases (allowing for some rounding variations) + # Note: Due to chunking and rounding, progress might not be strictly increasing + self.assertTrue(progress_calls[-1] >= 90) # Should end near 100% + + asyncio.run(run_test()) + + def test_progress_with_explicit_total_size(self): + """Test progress tracking with explicitly provided total_size.""" + import asyncio + + async def run_test(): + progress_calls = [] + + async def track_progress(percent): + progress_calls.append(percent) + + data = await DownloadManager.download_url( + "https://httpbin.org/bytes/3072", # 3KB + total_size=3072, + progress_callback=track_progress + ) + + self.assertIsNotNone(data) + self.assertTrue(len(progress_calls) > 0) + + asyncio.run(run_test()) + + # ==================== Error Handling Tests ==================== + + def test_http_error_status(self): + """Test handling of HTTP error status codes.""" + import asyncio + + async def run_test(): + # Request 404 error from httpbin + data = await DownloadManager.download_url("https://httpbin.org/status/404") + + # Should return None for memory download + self.assertIsNone(data) + + asyncio.run(run_test()) + + def test_http_error_with_file_output(self): + """Test that file download returns False on HTTP error.""" + import asyncio + + async def run_test(): + outfile = f"{self.temp_dir}/error_test.bin" + + success = await DownloadManager.download_url( + "https://httpbin.org/status/500", + outfile=outfile + ) + + # Should return False for file download + self.assertFalse(success) + + # File should not be created + try: + os.stat(outfile) + self.fail("File should not exist after failed download") + except OSError: + pass # Expected - file doesn't exist + + asyncio.run(run_test()) + + def test_invalid_url(self): + """Test handling of invalid URL.""" + import asyncio + + async def run_test(): + # Invalid URL should raise exception or return None + try: + data = await DownloadManager.download_url("http://invalid-url-that-does-not-exist.local/") + # If it doesn't raise, it should return None + self.assertIsNone(data) + except Exception: + # Exception is acceptable + pass + + asyncio.run(run_test()) + + # ==================== Headers Support Tests ==================== + + def test_custom_headers(self): + """Test that custom headers are passed to the request.""" + import asyncio + + async def run_test(): + # httpbin.org/headers echoes back the headers sent + data = await DownloadManager.download_url( + "https://httpbin.org/headers", + headers={"X-Custom-Header": "TestValue"} + ) + + self.assertIsNotNone(data) + # Verify the custom header was included (httpbin echoes it back) + response_text = data.decode('utf-8') + self.assertIn("X-Custom-Header", response_text) + self.assertIn("TestValue", response_text) + + asyncio.run(run_test()) + + # ==================== Edge Cases Tests ==================== + + def test_empty_response(self): + """Test handling of empty (0-byte) downloads.""" + import asyncio + + async def run_test(): + # Download 0 bytes + data = await DownloadManager.download_url("https://httpbin.org/bytes/0") + + self.assertIsNotNone(data) + self.assertEqual(len(data), 0) + self.assertEqual(data, b'') + + asyncio.run(run_test()) + + def test_small_download(self): + """Test downloading very small files (smaller than chunk size).""" + import asyncio + + async def run_test(): + # Download 10 bytes (much smaller than 1KB chunk size) + data = await DownloadManager.download_url("https://httpbin.org/bytes/10") + + self.assertIsNotNone(data) + self.assertEqual(len(data), 10) + + asyncio.run(run_test()) + + def test_json_download(self): + """Test downloading JSON data.""" + import asyncio + import json + + async def run_test(): + data = await DownloadManager.download_url("https://httpbin.org/json") + + self.assertIsNotNone(data) + # Verify it's valid JSON + parsed = json.loads(data.decode('utf-8')) + self.assertIsInstance(parsed, dict) + + asyncio.run(run_test()) + + # ==================== File Operations Tests ==================== + + def test_file_download_creates_directory_if_needed(self): + """Test that parent directories are NOT created (caller's responsibility).""" + import asyncio + + async def run_test(): + # Try to download to non-existent directory + outfile = "/tmp/nonexistent_dir_12345/test.bin" + + try: + success = await DownloadManager.download_url( + "https://httpbin.org/bytes/100", + outfile=outfile + ) + # Should fail because directory doesn't exist + self.assertFalse(success) + except Exception: + # Exception is acceptable + pass + + asyncio.run(run_test()) + + def test_file_overwrite(self): + """Test that downloading overwrites existing files.""" + import asyncio + + async def run_test(): + outfile = f"{self.temp_dir}/overwrite_test.bin" + + # Create initial file + with open(outfile, 'wb') as f: + f.write(b'old content') + + # Download and overwrite + success = await DownloadManager.download_url( + "https://httpbin.org/bytes/100", + outfile=outfile + ) + + self.assertTrue(success) + self.assertEqual(os.stat(outfile)[6], 100) + + # Verify old content is gone + with open(outfile, 'rb') as f: + content = f.read() + self.assertNotEqual(content, b'old content') + self.assertEqual(len(content), 100) + + # Clean up + os.remove(outfile) + + asyncio.run(run_test()) diff --git a/tests/test_graphical_camera_settings.py b/tests/test_graphical_camera_settings.py new file mode 100644 index 00000000..9ccd7955 --- /dev/null +++ b/tests/test_graphical_camera_settings.py @@ -0,0 +1,258 @@ +""" +Graphical test for Camera app settings functionality. + +This test verifies that: +1. The camera app settings button can be clicked without crashing +2. The settings dialog opens correctly +3. Resolution can be changed without causing segfault +4. The camera continues to work after resolution change + +This specifically tests the fixes for: +- Segfault when clicking settings button +- Pale colors after resolution change +- Buffer size mismatches + +Usage: + Desktop: ./tests/unittest.sh tests/test_graphical_camera_settings.py + Device: ./tests/unittest.sh tests/test_graphical_camera_settings.py --ondevice +""" + +import unittest +import lvgl as lv +import mpos.apps +import mpos.ui +import os +from mpos.ui.testing import ( + wait_for_render, + capture_screenshot, + find_label_with_text, + find_button_with_text, + verify_text_present, + print_screen_labels, + simulate_click, + get_widget_coords +) + + +class TestGraphicalCameraSettings(unittest.TestCase): + """Test suite for Camera app settings verification.""" + + def setUp(self): + """Set up test fixtures before each test method.""" + # Check if webcam module is available + try: + import webcam + self.has_webcam = True + except: + try: + import camera + self.has_webcam = False # Has internal camera instead + except: + self.skipTest("No camera module available (webcam or internal)") + + # Get absolute path to screenshots directory + import sys + if sys.platform == "esp32": + self.screenshot_dir = "tests/screenshots" + else: + self.screenshot_dir = "../tests/screenshots" + + # Ensure screenshots directory exists + try: + os.mkdir(self.screenshot_dir) + except OSError: + pass # Directory already exists + + def tearDown(self): + """Clean up after each test method.""" + # Navigate back to launcher (closes the camera app) + try: + mpos.ui.back_screen() + wait_for_render(10) # Allow navigation and cleanup to complete + except: + pass # Already on launcher or error + + def test_settings_button_click_no_crash(self): + """ + Test that clicking the settings button doesn't cause a segfault. + + This is the critical test that verifies the fix for the segfault + that occurred when clicking settings due to stale image_dsc.data pointer. + + Steps: + 1. Start camera app + 2. Wait for camera to initialize + 3. Capture initial screenshot + 4. Click settings button (top-right corner) + 5. Verify settings dialog opened + 6. If we get here without crash, test passes + """ + print("\n=== Testing settings button click (no crash) ===") + + # Start the Camera app + result = mpos.apps.start_app("com.micropythonos.camera") + self.assertTrue(result, "Failed to start Camera app") + + # Wait for camera to initialize and first frame to render + wait_for_render(iterations=30) + + # Get current screen + screen = lv.screen_active() + + # Debug: Print all text on screen + print("\nInitial screen labels:") + print_screen_labels(screen) + + # Capture screenshot before clicking settings + screenshot_path = f"{self.screenshot_dir}/camera_before_settings.raw" + print(f"\nCapturing initial screenshot: {screenshot_path}") + capture_screenshot(screenshot_path, width=320, height=240) + + # Find and click settings button + # The settings button is positioned at TOP_RIGHT with offset (0, 60) + # On a 320x240 screen, this is approximately x=260, y=90 + # We'll click slightly inside the button to ensure we hit it + settings_x = 300 # Right side of screen, inside the 60px button + settings_y = 100 # 60px down from top, center of 60px button + + print(f"\nClicking settings button at ({settings_x}, {settings_y})") + simulate_click(settings_x, settings_y, press_duration_ms=100) + + # Wait for settings dialog to appear + wait_for_render(iterations=20) + + # Get screen again (might have changed after navigation) + screen = lv.screen_active() + + # Debug: Print labels after clicking + print("\nScreen labels after clicking settings:") + print_screen_labels(screen) + + # Verify settings screen opened + # Look for "Camera Settings" or "resolution" text + has_settings_ui = ( + verify_text_present(screen, "Camera Settings") or + verify_text_present(screen, "Resolution") or + verify_text_present(screen, "resolution") or + verify_text_present(screen, "Save") or + verify_text_present(screen, "Cancel") + ) + + self.assertTrue( + has_settings_ui, + "Settings screen did not open (no expected UI elements found)" + ) + + # Capture screenshot of settings dialog + screenshot_path = f"{self.screenshot_dir}/camera_settings_dialog.raw" + print(f"\nCapturing settings dialog screenshot: {screenshot_path}") + capture_screenshot(screenshot_path, width=320, height=240) + + # If we got here without segfault, the test passes! + print("\nāœ“ Settings button clicked successfully without crash!") + + def test_resolution_change_no_crash(self): + """ + Test that changing resolution doesn't cause a crash. + + This tests the full resolution change workflow: + 1. Start camera app + 2. Open settings + 3. Change resolution + 4. Save settings + 5. Verify camera continues working + + This verifies fixes for: + - Segfault during reconfiguration + - Buffer size mismatches + - Stale data pointers + """ + print("\n=== Testing resolution change (no crash) ===") + + # Start the Camera app + result = mpos.apps.start_app("com.micropythonos.camera") + self.assertTrue(result, "Failed to start Camera app") + + # Wait for camera to initialize + wait_for_render(iterations=30) + + # Click settings button + print("\nOpening settings...") + simulate_click(290, 90, press_duration_ms=100) + wait_for_render(iterations=20) + + screen = lv.screen_active() + + # Try to find the dropdown/resolution selector + # The CameraSettingsActivity creates a dropdown widget + # Let's look for any dropdown on screen + print("\nLooking for resolution dropdown...") + + # Find all clickable objects (dropdowns are clickable) + # We'll try clicking in the middle area where the dropdown should be + # Dropdown is typically centered, so around x=160, y=120 + dropdown_x = 160 + dropdown_y = 120 + + print(f"Clicking dropdown area at ({dropdown_x}, {dropdown_y})") + simulate_click(dropdown_x, dropdown_y, press_duration_ms=100) + wait_for_render(iterations=15) + + # The dropdown should now be open showing resolution options + # Let's capture what we see + screenshot_path = f"{self.screenshot_dir}/camera_dropdown_open.raw" + print(f"Capturing dropdown screenshot: {screenshot_path}") + capture_screenshot(screenshot_path, width=320, height=240) + + screen = lv.screen_active() + print("\nScreen after opening dropdown:") + print_screen_labels(screen) + + # Try to select a different resolution + # Options are typically stacked vertically + # Let's click a bit lower to select a different option + option_x = 160 + option_y = 150 # Below the current selection + + print(f"\nSelecting different resolution at ({option_x}, {option_y})") + simulate_click(option_x, option_y, press_duration_ms=100) + wait_for_render(iterations=15) + + # Now find and click the Save button + print("\nLooking for Save button...") + save_button = find_button_with_text(lv.screen_active(), "Save") + + if save_button: + coords = get_widget_coords(save_button) + print(f"Found Save button at {coords}") + simulate_click(coords['center_x'], coords['center_y'], press_duration_ms=100) + else: + # Fallback: Save button is typically at bottom-left + # Based on CameraSettingsActivity code: ALIGN.BOTTOM_LEFT + print("Save button not found via text, trying bottom-left corner") + simulate_click(80, 220, press_duration_ms=100) + + # Wait for reconfiguration to complete + print("\nWaiting for reconfiguration...") + wait_for_render(iterations=30) + + # Capture screenshot after reconfiguration + screenshot_path = f"{self.screenshot_dir}/camera_after_resolution_change.raw" + print(f"Capturing post-change screenshot: {screenshot_path}") + capture_screenshot(screenshot_path, width=320, height=240) + + # If we got here without segfault, the test passes! + print("\nāœ“ Resolution changed successfully without crash!") + + # Verify camera is still showing something + screen = lv.screen_active() + # The camera app should still be active (not crashed back to launcher) + # We can check this by looking for camera-specific UI elements + # or just the fact that we haven't crashed + + print("\nāœ“ Camera app still running after resolution change!") + + +if __name__ == '__main__': + # Note: Don't include unittest.main() - handled by unittest.sh + pass diff --git a/tests/test_graphical_imu_calibration.py b/tests/test_graphical_imu_calibration.py new file mode 100644 index 00000000..601905a9 --- /dev/null +++ b/tests/test_graphical_imu_calibration.py @@ -0,0 +1,187 @@ +""" +Graphical test for IMU calibration activities. + +Tests both CheckIMUCalibrationActivity and CalibrateIMUActivity +with mock data on desktop. + +Usage: + Desktop: ./tests/unittest.sh tests/test_graphical_imu_calibration.py + Device: ./tests/unittest.sh tests/test_graphical_imu_calibration.py --ondevice +""" + +import unittest +import lvgl as lv +import mpos.apps +import mpos.ui +import os +import sys +import time +from mpos.ui.testing import ( + wait_for_render, + capture_screenshot, + find_label_with_text, + verify_text_present, + print_screen_labels, + simulate_click, + get_widget_coords, + find_button_with_text, + click_label, + click_button, + find_text_on_screen +) + + +class TestIMUCalibration(unittest.TestCase): + """Test suite for IMU calibration activities.""" + + def setUp(self): + """Set up test fixtures.""" + # Get screenshot directory + if sys.platform == "esp32": + self.screenshot_dir = "tests/screenshots" + else: + self.screenshot_dir = "../tests/screenshots" # it runs from internal_filesystem/ + + # Ensure directory exists + try: + os.mkdir(self.screenshot_dir) + except OSError: + pass + + def tearDown(self): + """Clean up after test.""" + # Navigate back to launcher + try: + for _ in range(3): # May need multiple backs + mpos.ui.back_screen() + wait_for_render(5) + except: + pass + + def test_check_calibration_activity_loads(self): + """Test that CheckIMUCalibrationActivity loads and displays.""" + print("\n=== Testing CheckIMUCalibrationActivity ===") + + # Navigate: Launcher -> Settings -> Check IMU Calibration + result = mpos.apps.start_app("com.micropythonos.settings") + self.assertTrue(result, "Failed to start Settings app") + wait_for_render(15) + + # Initialize touch device with dummy click + simulate_click(10, 10) + wait_for_render(10) + + print("Clicking 'Check IMU Calibration' menu item...") + self.assertTrue(click_label("Check IMU Calibration"), "Could not find Check IMU Calibration menu item") + wait_for_render(iterations=20) + + # Verify key elements are present + screen = lv.screen_active() + print_screen_labels(screen) + self.assertTrue(verify_text_present(screen, "Quality:"), "Quality label not found") + self.assertTrue(verify_text_present(screen, "Accel."), "Accel. label not found") + self.assertTrue(verify_text_present(screen, "Gyro"), "Gyro label not found") + + # Capture screenshot + screenshot_path = f"{self.screenshot_dir}/check_imu_calibration.raw" + print(f"Capturing screenshot: {screenshot_path}") + capture_screenshot(screenshot_path) + + # Verify screenshot saved + stat = os.stat(screenshot_path) + self.assertTrue(stat[6] > 0, "Screenshot file is empty") + + print("=== CheckIMUCalibrationActivity test complete ===") + + def test_calibrate_activity_flow(self): + """Test CalibrateIMUActivity full calibration flow.""" + print("\n=== Testing CalibrateIMUActivity Flow ===") + + # Navigate: Launcher -> Settings -> Calibrate IMU + result = mpos.apps.start_app("com.micropythonos.settings") + self.assertTrue(result, "Failed to start Settings app") + wait_for_render(15) + + # Initialize touch device with dummy click + simulate_click(10, 10) + wait_for_render(10) + + print("Clicking 'Calibrate IMU' menu item...") + self.assertTrue(click_label("Calibrate IMU"), "Could not find Calibrate IMU item") + wait_for_render(iterations=20) + + # Verify activity loaded and shows instructions + screen = lv.screen_active() + print_screen_labels(screen) + self.assertTrue(verify_text_present(screen, "IMU Calibration"), + "CalibrateIMUActivity title not found") + self.assertTrue(verify_text_present(screen, "Place device on flat"), + "Instructions not shown") + + # Capture initial state + screenshot_path = f"{self.screenshot_dir}/calibrate_imu_01_initial.raw" + capture_screenshot(screenshot_path) + + # Click "Calibrate Now" button to start calibration + calibrate_btn = find_button_with_text(screen, "Calibrate Now") + self.assertIsNotNone(calibrate_btn, "Could not find 'Calibrate Now' button") + coords = get_widget_coords(calibrate_btn) + simulate_click(coords['center_x'], coords['center_y']) + wait_for_render(10) + + # Wait for calibration to complete (mock takes ~3 seconds) + time.sleep(4) + wait_for_render(40) + + # Verify calibration completed + screen = lv.screen_active() + print_screen_labels(screen) + self.assertTrue(verify_text_present(screen, "Calibration successful!"), + "Calibration completion message not found") + + # Verify offsets are shown + self.assertTrue(verify_text_present(screen, "Accel offsets") or + verify_text_present(screen, "offsets"), + "Calibration offsets not shown") + + # Capture completion state + screenshot_path = f"{self.screenshot_dir}/calibrate_imu_02_complete.raw" + capture_screenshot(screenshot_path) + + print("=== CalibrateIMUActivity flow test complete ===") + + def test_navigation_from_check_to_calibrate(self): + """Test navigation from Check to Calibrate activity via button.""" + print("\n=== Testing Check -> Calibrate Navigation ===") + + # Navigate to Check activity + result = mpos.apps.start_app("com.micropythonos.settings") + self.assertTrue(result) + wait_for_render(15) + + # Initialize touch device with dummy click + simulate_click(10, 10) + wait_for_render(10) + + print("Clicking 'Check IMU Calibration' menu item...") + self.assertTrue(click_label("Check IMU Calibration"), "Could not find Check IMU Calibration menu item") + wait_for_render(iterations=20) + + # Click "Calibrate" button to navigate to Calibrate activity + screen = lv.screen_active() + calibrate_btn = find_button_with_text(screen, "Calibrate") + self.assertIsNotNone(calibrate_btn, "Could not find 'Calibrate' button") + + # Use send_event instead of simulate_click (more reliable for navigation) + calibrate_btn.send_event(lv.EVENT.CLICKED, None) + wait_for_render(30) + + # Verify CalibrateIMUActivity loaded + screen = lv.screen_active() + print_screen_labels(screen) + self.assertTrue(verify_text_present(screen, "Calibrate Now"), + "Did not navigate to CalibrateIMUActivity") + self.assertTrue(verify_text_present(screen, "Place device on flat"), + "CalibrateIMUActivity instructions not shown") + + print("=== Navigation test complete ===") diff --git a/tests/test_graphical_imu_calibration_ui_bug.py b/tests/test_graphical_imu_calibration_ui_bug.py new file mode 100755 index 00000000..c44430e0 --- /dev/null +++ b/tests/test_graphical_imu_calibration_ui_bug.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +"""Automated UI test for IMU calibration bug. + +Tests the complete flow: +1. Open Settings → IMU → Check Calibration +2. Verify values are shown +3. Click "Calibrate" → Calibrate IMU +4. Click "Calibrate Now" +5. Go back to Check Calibration +6. BUG: Verify values are shown (not "--") +""" + +import sys +import time +import unittest + +# Import graphical test infrastructure +import lvgl as lv +from mpos.ui.testing import ( + wait_for_render, + simulate_click, + find_button_with_text, + find_label_with_text, + get_widget_coords, + print_screen_labels, + capture_screenshot, + click_label, + click_button, + find_text_on_screen +) + + +class TestIMUCalibrationUI(unittest.TestCase): + + def test_imu_calibration_bug_test(self): + print("=== IMU Calibration UI Bug Test ===\n") + + # Initialize the OS (boot.py and main.py) + print("Step 1: Initializing MicroPythonOS...") + import mpos.main + wait_for_render(iterations=30) + print("OS initialized\n") + + # Step 2: Open Settings app + print("Step 2: Opening Settings app...") + import mpos.apps + + # Start Settings app by name + mpos.apps.start_app("com.micropythonos.settings") + wait_for_render(iterations=30) + print("Settings app opened\n") + + # Initialize touch device with dummy click (required for simulate_click to work) + print("Initializing touch input device...") + simulate_click(10, 10) + wait_for_render(iterations=10) + + print("Current screen content:") + print_screen_labels(lv.screen_active()) + print() + + # Check if we're on the main Settings screen (should see multiple settings options) + # The Settings app shows a list with items like "Calibrate IMU", "Check IMU Calibration", "Theme Color", etc. + on_settings_main = (find_text_on_screen("Calibrate IMU") and + find_text_on_screen("Check IMU Calibration") and + find_text_on_screen("Theme Color")) + + # If we're on a sub-screen (like Calibrate IMU or Check IMU Calibration screens), + # we need to go back to Settings main. We can detect this by looking for screen titles. + if not on_settings_main: + print("Step 3: Not on Settings main screen, clicking Back or Cancel to return...") + self.assertTrue(click_button("Back") or click_button("Cancel"), "Could not click 'Back' or 'Cancel' button") + wait_for_render(iterations=20) + print("Current screen content:") + print_screen_labels(lv.screen_active()) + print() + + # Step 4: Click "Check IMU Calibration" (it's a clickable label/container, not a button) + print("Step 4: Clicking 'Check IMU Calibration' menu item...") + self.assertTrue(click_label("Check IMU Calibration"), "Could not find Check IMU Calibration menu item") + wait_for_render(iterations=40) + + print("Step 5: Checking BEFORE calibration...") + print("Current screen content:") + print_screen_labels(lv.screen_active()) + print() + + # Capture screenshot before + capture_screenshot("../tests/screenshots/check_imu_before_calib.raw") + + # Look for actual values (not "--") + has_values_before = False + widgets = [] + from mpos.ui.testing import get_all_widgets_with_text + for widget in get_all_widgets_with_text(lv.screen_active()): + text = widget.get_text() + # Look for patterns like "X: 0.00" or "Quality: Good" + if ":" in text and "--" not in text: + if any(char.isdigit() for char in text): + print(f"Found value: {text}") + has_values_before = True + + if not has_values_before: + print("WARNING: No values found before calibration (all showing '--')") + else: + print("GOOD: Values are showing before calibration") + print() + + # Step 6: Click "Calibrate" button to go to calibration screen + print("Step 6: Finding 'Calibrate' button...") + calibrate_btn = find_button_with_text(lv.screen_active(), "Calibrate") + self.assertIsNotNone(calibrate_btn, "Could not find 'Calibrate' button") + + print(f"Found Calibrate button: {calibrate_btn}") + print("Manually sending CLICKED event to button...") + # Instead of using simulate_click, manually send the event + calibrate_btn.send_event(lv.EVENT.CLICKED, None) + wait_for_render(iterations=20) + + # Wait for navigation to complete (activity transition can take some time) + time.sleep(0.5) + wait_for_render(iterations=50) + print("Calibrate IMU screen should be open now\n") + + print("Current screen content:") + print_screen_labels(lv.screen_active()) + print() + + # Step 7: Click "Calibrate Now" button + print("Step 7: Clicking 'Calibrate Now' button...") + self.assertTrue(click_button("Calibrate Now"), "Could not click 'Calibrate Now' button") + print("Calibration started...\n") + + # Wait for calibration to complete (~2 seconds + UI updates) + time.sleep(3) + wait_for_render(iterations=50) + + print("Current screen content after calibration:") + print_screen_labels(lv.screen_active()) + print() + + # Step 8: Click "Done" to go back + print("Step 8: Clicking 'Done' button...") + self.assertTrue(click_button("Done"), "Could not click 'Done' button") + print("Going back to Check Calibration\n") + + # Wait for screen to load + time.sleep(0.5) + wait_for_render(iterations=30) + + # Step 9: Check AFTER calibration (BUG: should show values, not "--") + print("Step 9: Checking AFTER calibration (testing for bug)...") + print("Current screen content:") + print_screen_labels(lv.screen_active()) + print() + + # Capture screenshot after + capture_screenshot("../tests/screenshots/check_imu_after_calib.raw") + + # Look for actual values (not "--") + has_values_after = False + for widget in get_all_widgets_with_text(lv.screen_active()): + text = widget.get_text() + # Look for patterns like "X: 0.00" or "Quality: Good" + if ":" in text and "--" not in text: + if any(char.isdigit() for char in text): + print(f"Found value: {text}") + has_values_after = True + + print() + print("="*60) + print("TEST RESULTS:") + print(f" Values shown BEFORE calibration: {has_values_before}") + print(f" Values shown AFTER calibration: {has_values_after}") + + if has_values_before and not has_values_after: + print("\n āŒ BUG REPRODUCED: Values disappeared after calibration!") + print(" Expected: Values should still be shown") + print(" Actual: All showing '--'") + #return False + elif has_values_after: + print("\n āœ… PASS: Values are showing correctly after calibration") + #return True + else: + print("\n āš ļø WARNING: No values shown before or after (might be desktop mock issue)") + #return True + + diff --git a/tests/test_graphical_keyboard_animation.py b/tests/test_graphical_keyboard_animation.py index 548cfe07..f1e0c54b 100644 --- a/tests/test_graphical_keyboard_animation.py +++ b/tests/test_graphical_keyboard_animation.py @@ -11,9 +11,10 @@ import unittest import lvgl as lv +import time import mpos.ui.anim from mpos.ui.keyboard import MposKeyboard - +from mpos.ui.testing import wait_for_render class TestKeyboardAnimation(unittest.TestCase): """Test MposKeyboard compatibility with animation system.""" @@ -86,6 +87,7 @@ def test_keyboard_smooth_show(self): # This should work without raising AttributeError try: mpos.ui.anim.smooth_show(keyboard) + wait_for_render(100) print("smooth_show called successfully") except AttributeError as e: self.fail(f"smooth_show raised AttributeError: {e}\n" @@ -144,6 +146,7 @@ def test_keyboard_show_hide_cycle(self): # Show keyboard (simulates textarea click) try: mpos.ui.anim.smooth_show(keyboard) + wait_for_render(100) except AttributeError as e: self.fail(f"Failed during smooth_show: {e}") @@ -153,6 +156,7 @@ def test_keyboard_show_hide_cycle(self): # Hide keyboard (simulates pressing Enter) try: mpos.ui.anim.smooth_hide(keyboard) + wait_for_render(100) except AttributeError as e: self.fail(f"Failed during smooth_hide: {e}") diff --git a/tests/test_lightsmanager.py b/tests/test_lightsmanager.py new file mode 100644 index 00000000..016ccf6b --- /dev/null +++ b/tests/test_lightsmanager.py @@ -0,0 +1,126 @@ +# Unit tests for LightsManager service +import unittest +import sys + + +# Mock hardware before importing LightsManager +class MockPin: + IN = 0 + OUT = 1 + + def __init__(self, pin_number, mode=None): + self.pin_number = pin_number + self.mode = mode + + +class MockNeoPixel: + def __init__(self, pin, num_leds): + self.pin = pin + self.num_leds = num_leds + self.pixels = [(0, 0, 0)] * num_leds + self.write_count = 0 + + def __setitem__(self, index, value): + if 0 <= index < self.num_leds: + self.pixels[index] = value + + def __getitem__(self, index): + if 0 <= index < self.num_leds: + return self.pixels[index] + return (0, 0, 0) + + def write(self): + self.write_count += 1 + + +# Inject mocks +sys.modules['machine'] = type('module', (), {'Pin': MockPin})() +sys.modules['neopixel'] = type('module', (), {'NeoPixel': MockNeoPixel})() + + +# Now import the module to test +import mpos.lights as LightsManager + + +class TestLightsManager(unittest.TestCase): + """Test cases for LightsManager service.""" + + def setUp(self): + """Initialize LightsManager before each test.""" + LightsManager.init(neopixel_pin=12, num_leds=5) + + def test_initialization(self): + """Test that LightsManager initializes correctly.""" + self.assertTrue(LightsManager.is_available()) + self.assertEqual(LightsManager.get_led_count(), 5) + + def test_set_single_led(self): + """Test setting a single LED color.""" + result = LightsManager.set_led(0, 255, 0, 0) + self.assertTrue(result) + + # Verify color was set (via internal _neopixel mock) + neopixel = LightsManager._neopixel + self.assertEqual(neopixel[0], (255, 0, 0)) + + def test_set_led_invalid_index(self): + """Test that invalid LED indices are rejected.""" + # Negative index + result = LightsManager.set_led(-1, 255, 0, 0) + self.assertFalse(result) + + # Index too large + result = LightsManager.set_led(10, 255, 0, 0) + self.assertFalse(result) + + def test_set_all_leds(self): + """Test setting all LEDs to same color.""" + result = LightsManager.set_all(0, 255, 0) + self.assertTrue(result) + + # Verify all LEDs were set + neopixel = LightsManager._neopixel + for i in range(5): + self.assertEqual(neopixel[i], (0, 255, 0)) + + def test_clear(self): + """Test clearing all LEDs.""" + # First set some colors + LightsManager.set_all(255, 255, 255) + + # Then clear + result = LightsManager.clear() + self.assertTrue(result) + + # Verify all LEDs are black + neopixel = LightsManager._neopixel + for i in range(5): + self.assertEqual(neopixel[i], (0, 0, 0)) + + def test_write(self): + """Test that write() updates hardware.""" + neopixel = LightsManager._neopixel + initial_count = neopixel.write_count + + result = LightsManager.write() + self.assertTrue(result) + + # Verify write was called + self.assertEqual(neopixel.write_count, initial_count + 1) + + def test_notification_colors(self): + """Test convenience notification color method.""" + # Valid colors + self.assertTrue(LightsManager.set_notification_color("red")) + self.assertTrue(LightsManager.set_notification_color("green")) + self.assertTrue(LightsManager.set_notification_color("blue")) + + # Invalid color + result = LightsManager.set_notification_color("invalid_color") + self.assertFalse(result) + + def test_case_insensitive_colors(self): + """Test that color names are case-insensitive.""" + self.assertTrue(LightsManager.set_notification_color("RED")) + self.assertTrue(LightsManager.set_notification_color("Green")) + self.assertTrue(LightsManager.set_notification_color("BLUE")) diff --git a/tests/test_osupdate.py b/tests/test_osupdate.py index a087f074..88687edd 100644 --- a/tests/test_osupdate.py +++ b/tests/test_osupdate.py @@ -1,12 +1,13 @@ 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 class MockPartition: @@ -42,6 +43,11 @@ 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.""" @@ -218,38 +224,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 +262,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 +285,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,60 +332,162 @@ 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)) + + def test_network_error_detection_econnreset(self): + """Test that ECONNRESET error is detected as network error.""" + error = OSError(-104, "ECONNRESET") + self.assertTrue(self.downloader._is_network_error(error)) + + def test_network_error_detection_etimedout(self): + """Test that ETIMEDOUT error is detected as network error.""" + error = OSError(-110, "ETIMEDOUT") + self.assertTrue(self.downloader._is_network_error(error)) + + def test_network_error_detection_ehostunreach(self): + """Test that EHOSTUNREACH error is detected as network error.""" + error = OSError(-118, "EHOSTUNREACH") + self.assertTrue(self.downloader._is_network_error(error)) + + def test_network_error_detection_by_message(self): + """Test that network errors are detected by message.""" + self.assertTrue(self.downloader._is_network_error(Exception("Connection reset by peer"))) + self.assertTrue(self.downloader._is_network_error(Exception("Connection aborted"))) + self.assertTrue(self.downloader._is_network_error(Exception("Broken pipe"))) + + def test_non_network_error_not_detected(self): + """Test that non-network errors are not detected as network errors.""" + self.assertFalse(self.downloader._is_network_error(ValueError("Invalid data"))) + self.assertFalse(self.downloader._is_network_error(Exception("File not found"))) + self.assertFalse(self.downloader._is_network_error(KeyError("missing"))) + + def test_download_pauses_on_network_error_during_read(self): + """Test that download pauses when network error occurs during read.""" + # Set up mock to raise network error after first chunk + test_data = b'G' * 16384 # 4 chunks + self.mock_download_manager.set_download_data(test_data) + self.mock_download_manager.chunk_size = 4096 + self.mock_download_manager.set_fail_after_bytes(4096) # Fail after first chunk + + async def run_test(): + return await self.downloader.download_and_install( + "http://example.com/update.bin" + ) + + result = run_async(run_test()) + + self.assertFalse(result['success']) + self.assertTrue(result['paused']) + self.assertEqual(result['bytes_written'], 4096) # Should have written first chunk + self.assertIsNone(result['error']) # Pause, not error + + def test_download_resumes_from_saved_position(self): + """Test that download resumes from the last written position.""" + # Simulate partial download + self.downloader.bytes_written_so_far = 8192 # Already downloaded 2 chunks + self.downloader.total_size_expected = 12288 + + # Server should receive Range header - only remaining data + remaining_data = b'H' * 4096 # Last chunk + self.mock_download_manager.set_download_data(remaining_data) + self.mock_download_manager.chunk_size = 4096 + + async def run_test(): + return await self.downloader.download_and_install( + "http://example.com/update.bin" + ) + + result = run_async(run_test()) + + self.assertTrue(result['success']) + self.assertEqual(result['bytes_written'], 12288) + # Check that Range header was set + self.assertIsNotNone(self.mock_download_manager.headers_received) + self.assertIn('Range', self.mock_download_manager.headers_received) + self.assertEqual(self.mock_download_manager.headers_received['Range'], 'bytes=8192-') + + def test_resume_failure_preserves_state(self): + """Test that resume failures preserve download state for retry.""" + # Simulate partial download state + self.downloader.bytes_written_so_far = 245760 # 60 chunks already downloaded + self.downloader.total_size_expected = 3391488 + + # Resume attempt fails immediately with network error + self.mock_download_manager.set_download_data(b'') + self.mock_download_manager.set_fail_after_bytes(0) # Fail immediately + + async def run_test(): + return await self.downloader.download_and_install( + "http://example.com/update.bin" + ) + + result = run_async(run_test()) + + # Should pause, not fail + self.assertFalse(result['success']) + self.assertTrue(result['paused']) + self.assertIsNone(result['error']) + + # Critical: Must preserve progress for next retry + self.assertEqual(result['bytes_written'], 245760, "Must preserve bytes_written") + self.assertEqual(result['total_size'], 3391488, "Must preserve total_size") + self.assertEqual(self.downloader.bytes_written_so_far, 245760, "Must preserve internal state") + diff --git a/tests/test_rtttl.py b/tests/test_rtttl.py new file mode 100644 index 00000000..07dbc801 --- /dev/null +++ b/tests/test_rtttl.py @@ -0,0 +1,173 @@ +# Unit tests for RTTTL parser (RTTTLStream) +import unittest +import sys + + +# Mock hardware before importing +class MockPWM: + def __init__(self, pin, freq=0, duty=0): + self.pin = pin + self.last_freq = freq + self.last_duty = duty + self.freq_history = [] + self.duty_history = [] + + def freq(self, value=None): + if value is not None: + self.last_freq = value + self.freq_history.append(value) + return self.last_freq + + def duty_u16(self, value=None): + if value is not None: + self.last_duty = value + self.duty_history.append(value) + return self.last_duty + + +# Inject mock +sys.modules['machine'] = type('module', (), {'PWM': MockPWM, 'Pin': lambda x: x})() + + +# Now import the module to test +from mpos.audio.stream_rtttl import RTTTLStream + + +class TestRTTTL(unittest.TestCase): + """Test cases for RTTTL parser.""" + + def setUp(self): + """Create a mock buzzer before each test.""" + self.buzzer = MockPWM(46) + + def test_parse_simple_rtttl(self): + """Test parsing a simple RTTTL string.""" + rtttl = "Nokia:d=4,o=5,b=225:8e6,8d6,8f#,8g#" + stream = RTTTLStream(rtttl, 0, 100, self.buzzer, None) + + self.assertEqual(stream.name, "Nokia") + self.assertEqual(stream.default_duration, 4) + self.assertEqual(stream.default_octave, 5) + self.assertEqual(stream.bpm, 225) + + def test_parse_defaults(self): + """Test parsing default values.""" + rtttl = "Test:d=8,o=6,b=180:c" + stream = RTTTLStream(rtttl, 0, 100, self.buzzer, None) + + self.assertEqual(stream.default_duration, 8) + self.assertEqual(stream.default_octave, 6) + self.assertEqual(stream.bpm, 180) + + # Check calculated msec_per_whole_note + # 240000 / 180 = 1333.33... + self.assertAlmostEqual(stream.msec_per_whole_note, 1333.33, places=1) + + def test_invalid_rtttl_format(self): + """Test that invalid RTTTL format raises ValueError.""" + # Missing colons + with self.assertRaises(ValueError): + RTTTLStream("invalid", 0, 100, self.buzzer, None) + + # Too many colons + with self.assertRaises(ValueError): + RTTTLStream("a:b:c:d", 0, 100, self.buzzer, None) + + def test_note_parsing(self): + """Test parsing individual notes.""" + rtttl = "Test:d=4,o=5,b=120:c,d,e" + stream = RTTTLStream(rtttl, 0, 100, self.buzzer, None) + + # Generate notes + notes = list(stream._notes()) + + # Should have 3 notes + self.assertEqual(len(notes), 3) + + # Each note should be a tuple of (frequency, duration) + for freq, duration in notes: + self.assertTrue(freq > 0, "Frequency should be non-zero") + self.assertTrue(duration > 0, "Duration should be non-zero") + + def test_sharp_notes(self): + """Test parsing sharp notes.""" + rtttl = "Test:d=4,o=5,b=120:c#,d#,f#" + stream = RTTTLStream(rtttl, 0, 100, self.buzzer, None) + + notes = list(stream._notes()) + self.assertEqual(len(notes), 3) + + # Sharp notes should have different frequencies than natural notes + # (can't test exact values without knowing frequency table) + + def test_pause_notes(self): + """Test parsing pause notes.""" + rtttl = "Test:d=4,o=5,b=120:c,p,e" + stream = RTTTLStream(rtttl, 0, 100, self.buzzer, None) + + notes = list(stream._notes()) + self.assertEqual(len(notes), 3) + + # Pause (p) should have frequency 0 + freq, duration = notes[1] + self.assertEqual(freq, 0.0) + + def test_duration_modifiers(self): + """Test note duration modifiers (dots).""" + rtttl = "Test:d=4,o=5,b=120:c,c." + stream = RTTTLStream(rtttl, 0, 100, self.buzzer, None) + + notes = list(stream._notes()) + self.assertEqual(len(notes), 2) + + # Dotted note should be 1.5x longer + normal_duration = notes[0][1] + dotted_duration = notes[1][1] + self.assertAlmostEqual(dotted_duration / normal_duration, 1.5, places=1) + + def test_octave_variations(self): + """Test notes with different octaves.""" + rtttl = "Test:d=4,o=5,b=120:c4,c5,c6,c7" + stream = RTTTLStream(rtttl, 0, 100, self.buzzer, None) + + notes = list(stream._notes()) + self.assertEqual(len(notes), 4) + + # Higher octaves should have higher frequencies + freqs = [freq for freq, dur in notes] + self.assertTrue(freqs[0] < freqs[1], "c4 should be lower than c5") + self.assertTrue(freqs[1] < freqs[2], "c5 should be lower than c6") + self.assertTrue(freqs[2] < freqs[3], "c6 should be lower than c7") + + def test_volume_scaling(self): + """Test volume to duty cycle conversion.""" + # Test various volume levels + for volume in [0, 25, 50, 75, 100]: + stream = RTTTLStream("Test:d=4,o=5,b=120:c", 0, volume, self.buzzer, None) + + # Volume 0 should result in duty 0 + if volume == 0: + # Note: play() method calculates duty, not __init__ + pass # Can't easily test without calling play() + else: + # Volume > 0 should result in duty > 0 + # (duty calculation happens in play() method) + pass + + def test_stream_type(self): + """Test that stream type is stored correctly.""" + stream = RTTTLStream("Test:d=4,o=5,b=120:c", 2, 100, self.buzzer, None) + self.assertEqual(stream.stream_type, 2) + + def test_stop_flag(self): + """Test that stop flag can be set.""" + stream = RTTTLStream("Test:d=4,o=5,b=120:c", 0, 100, self.buzzer, None) + self.assertTrue(stream._keep_running) + + stream.stop() + self.assertFalse(stream._keep_running) + + def test_is_playing_flag(self): + """Test playing flag is initially false.""" + stream = RTTTLStream("Test:d=4,o=5,b=120:c", 0, 100, self.buzzer, None) + self.assertFalse(stream.is_playing()) diff --git a/tests/test_sensor_manager.py b/tests/test_sensor_manager.py new file mode 100644 index 00000000..85e77701 --- /dev/null +++ b/tests/test_sensor_manager.py @@ -0,0 +1,376 @@ +# Unit tests for SensorManager service +import unittest +import sys + + +# Mock hardware before importing SensorManager +class MockI2C: + """Mock I2C bus for testing.""" + def __init__(self, bus_id, sda=None, scl=None): + self.bus_id = bus_id + self.sda = sda + self.scl = scl + self.memory = {} # addr -> {reg -> value} + + def readfrom_mem(self, addr, reg, nbytes): + """Read from memory (simulates I2C read).""" + if addr not in self.memory: + raise OSError("I2C device not found") + if reg not in self.memory[addr]: + return bytes([0] * nbytes) + return bytes(self.memory[addr][reg]) + + def writeto_mem(self, addr, reg, data): + """Write to memory (simulates I2C write).""" + if addr not in self.memory: + self.memory[addr] = {} + self.memory[addr][reg] = list(data) + + +class MockQMI8658: + """Mock QMI8658 IMU sensor.""" + def __init__(self, i2c_bus, address=0x6B, accel_scale=0b10, gyro_scale=0b100): + self.i2c = i2c_bus + self.address = address + self.accel_scale = accel_scale + self.gyro_scale = gyro_scale + + @property + def temperature(self): + """Return mock temperature.""" + return 25.5 # Mock temperature in °C + + @property + def acceleration(self): + """Return mock acceleration (in G).""" + return (0.0, 0.0, 1.0) # At rest, Z-axis = 1G + + @property + def gyro(self): + """Return mock gyroscope (in deg/s).""" + return (0.0, 0.0, 0.0) # Stationary + + +class MockWsenIsds: + """Mock WSEN_ISDS IMU sensor.""" + def __init__(self, i2c, address=0x6B, acc_range="8g", acc_data_rate="104Hz", + gyro_range="500dps", gyro_data_rate="104Hz"): + self.i2c = i2c + self.address = address + self.acc_range = acc_range + self.gyro_range = gyro_range + self.acc_sensitivity = 0.244 # mg/digit for 8g + self.gyro_sensitivity = 17.5 # mdps/digit for 500dps + self.acc_offset_x = 0 + self.acc_offset_y = 0 + self.acc_offset_z = 0 + self.gyro_offset_x = 0 + self.gyro_offset_y = 0 + self.gyro_offset_z = 0 + + def get_chip_id(self): + """Return WHO_AM_I value.""" + return 0x6A + + def _read_raw_accelerations(self): + """Return mock acceleration (in mg).""" + return (0.0, 0.0, 1000.0) # At rest, Z-axis = 1000 mg + + def read_angular_velocities(self): + """Return mock gyroscope (in mdps).""" + return (0.0, 0.0, 0.0) + + def acc_calibrate(self, samples=None): + """Mock calibration.""" + pass + + def gyro_calibrate(self, samples=None): + """Mock calibration.""" + pass + + +# Mock constants from drivers +_QMI8685_PARTID = 0x05 +_REG_PARTID = 0x00 +_ACCELSCALE_RANGE_8G = 0b10 +_GYROSCALE_RANGE_256DPS = 0b100 + + +# Create mock modules +mock_machine = type('module', (), { + 'I2C': MockI2C, + 'Pin': type('Pin', (), {}) +})() + +mock_qmi8658 = type('module', (), { + 'QMI8658': MockQMI8658, + '_QMI8685_PARTID': _QMI8685_PARTID, + '_REG_PARTID': _REG_PARTID, + '_ACCELSCALE_RANGE_8G': _ACCELSCALE_RANGE_8G, + '_GYROSCALE_RANGE_256DPS': _GYROSCALE_RANGE_256DPS +})() + +mock_wsen_isds = type('module', (), { + 'Wsen_Isds': MockWsenIsds +})() + +# Mock esp32 module +def _mock_mcu_temperature(*args, **kwargs): + """Mock MCU temperature sensor.""" + return 42.0 + +mock_esp32 = type('module', (), { + 'mcu_temperature': _mock_mcu_temperature +})() + +# Inject mocks into sys.modules +sys.modules['machine'] = mock_machine +sys.modules['mpos.hardware.drivers.qmi8658'] = mock_qmi8658 +sys.modules['mpos.hardware.drivers.wsen_isds'] = mock_wsen_isds +sys.modules['esp32'] = mock_esp32 + +# Mock _thread for thread safety testing +try: + import _thread +except ImportError: + mock_thread = type('module', (), { + 'allocate_lock': lambda: type('lock', (), { + 'acquire': lambda self: None, + 'release': lambda self: None + })() + })() + sys.modules['_thread'] = mock_thread + +# Now import the module to test +import mpos.sensor_manager as SensorManager + + +class TestSensorManagerQMI8658(unittest.TestCase): + """Test cases for SensorManager with QMI8658 IMU.""" + + def setUp(self): + """Set up test fixtures before each test.""" + # Reset SensorManager state + SensorManager._initialized = False + SensorManager._imu_driver = None + SensorManager._sensor_list = [] + SensorManager._has_mcu_temperature = False + + # Create mock I2C bus with QMI8658 + self.i2c_bus = MockI2C(0, sda=48, scl=47) + # Set QMI8658 chip ID + self.i2c_bus.memory[0x6B] = {_REG_PARTID: [_QMI8685_PARTID]} + + def test_initialization_qmi8658(self): + """Test that SensorManager initializes with QMI8658.""" + result = SensorManager.init(self.i2c_bus, address=0x6B) + self.assertTrue(result) + self.assertTrue(SensorManager.is_available()) + + def test_sensor_list_qmi8658(self): + """Test getting sensor list for QMI8658.""" + SensorManager.init(self.i2c_bus, address=0x6B) + sensors = SensorManager.get_sensor_list() + + # QMI8658 provides: Accelerometer, Gyroscope, IMU Temperature, MCU Temperature + self.assertGreaterEqual(len(sensors), 3) + + # Check sensor types present + sensor_types = [s.type for s in sensors] + self.assertIn(SensorManager.TYPE_ACCELEROMETER, sensor_types) + self.assertIn(SensorManager.TYPE_GYROSCOPE, sensor_types) + self.assertIn(SensorManager.TYPE_IMU_TEMPERATURE, sensor_types) + + def test_get_default_sensor(self): + """Test getting default sensor by type.""" + SensorManager.init(self.i2c_bus, address=0x6B) + + accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) + self.assertIsNotNone(accel) + self.assertEqual(accel.type, SensorManager.TYPE_ACCELEROMETER) + + gyro = SensorManager.get_default_sensor(SensorManager.TYPE_GYROSCOPE) + self.assertIsNotNone(gyro) + self.assertEqual(gyro.type, SensorManager.TYPE_GYROSCOPE) + + def test_get_nonexistent_sensor(self): + """Test getting a sensor type that doesn't exist.""" + SensorManager.init(self.i2c_bus, address=0x6B) + + # Type 999 doesn't exist + sensor = SensorManager.get_default_sensor(999) + self.assertIsNone(sensor) + + def test_read_accelerometer(self): + """Test reading accelerometer data.""" + SensorManager.init(self.i2c_bus, address=0x6B) + accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) + + data = SensorManager.read_sensor(accel) + self.assertTrue(data is not None, f"read_sensor returned None, expected tuple") + self.assertEqual(len(data), 3) # (x, y, z) + + ax, ay, az = data + # At rest, Z should be ~9.8 m/s² (1G converted to m/s²) + self.assertAlmostEqual(az, 9.80665, places=2) + + def test_read_gyroscope(self): + """Test reading gyroscope data.""" + SensorManager.init(self.i2c_bus, address=0x6B) + gyro = SensorManager.get_default_sensor(SensorManager.TYPE_GYROSCOPE) + + data = SensorManager.read_sensor(gyro) + self.assertTrue(data is not None, f"read_sensor returned None, expected tuple") + self.assertEqual(len(data), 3) # (x, y, z) + + gx, gy, gz = data + # Stationary, all should be ~0 deg/s + self.assertAlmostEqual(gx, 0.0, places=1) + self.assertAlmostEqual(gy, 0.0, places=1) + self.assertAlmostEqual(gz, 0.0, places=1) + + def test_read_temperature(self): + """Test reading temperature data.""" + SensorManager.init(self.i2c_bus, address=0x6B) + + # Try IMU temperature + imu_temp = SensorManager.get_default_sensor(SensorManager.TYPE_IMU_TEMPERATURE) + if imu_temp: + temp = SensorManager.read_sensor(imu_temp) + self.assertIsNotNone(temp) + self.assertIsInstance(temp, (int, float)) + + # Try MCU temperature + mcu_temp = SensorManager.get_default_sensor(SensorManager.TYPE_SOC_TEMPERATURE) + if mcu_temp: + temp = SensorManager.read_sensor(mcu_temp) + self.assertIsNotNone(temp) + self.assertEqual(temp, 42.0) # Mock value + + def test_read_sensor_without_init(self): + """Test reading sensor without initialization.""" + accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) + self.assertIsNone(accel) + + def test_is_available_before_init(self): + """Test is_available before initialization.""" + self.assertFalse(SensorManager.is_available()) + + +class TestSensorManagerWsenIsds(unittest.TestCase): + """Test cases for SensorManager with WSEN_ISDS IMU.""" + + def setUp(self): + """Set up test fixtures before each test.""" + # Reset SensorManager state + SensorManager._initialized = False + SensorManager._imu_driver = None + SensorManager._sensor_list = [] + SensorManager._has_mcu_temperature = False + + # Create mock I2C bus with WSEN_ISDS + self.i2c_bus = MockI2C(0, sda=9, scl=18) + # Set WSEN_ISDS WHO_AM_I + self.i2c_bus.memory[0x6B] = {0x0F: [0x6A]} + + def test_initialization_wsen_isds(self): + """Test that SensorManager initializes with WSEN_ISDS.""" + result = SensorManager.init(self.i2c_bus, address=0x6B) + self.assertTrue(result) + self.assertTrue(SensorManager.is_available()) + + def test_sensor_list_wsen_isds(self): + """Test getting sensor list for WSEN_ISDS.""" + SensorManager.init(self.i2c_bus, address=0x6B) + sensors = SensorManager.get_sensor_list() + + # WSEN_ISDS provides: Accelerometer, Gyroscope, MCU Temperature + # (no IMU temperature) + self.assertGreaterEqual(len(sensors), 2) + + # Check sensor types + sensor_types = [s.type for s in sensors] + self.assertIn(SensorManager.TYPE_ACCELEROMETER, sensor_types) + self.assertIn(SensorManager.TYPE_GYROSCOPE, sensor_types) + + def test_read_accelerometer_wsen_isds(self): + """Test reading accelerometer from WSEN_ISDS.""" + SensorManager.init(self.i2c_bus, address=0x6B) + accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) + + data = SensorManager.read_sensor(accel) + self.assertTrue(data is not None, f"read_sensor returned None, expected tuple") + self.assertEqual(len(data), 3) + + ax, ay, az = data + # WSEN_ISDS mock returns 1000mg = 1G = 9.80665 m/s² + self.assertAlmostEqual(az, 9.80665, places=2) + + +class TestSensorManagerNoHardware(unittest.TestCase): + """Test cases for SensorManager without hardware (desktop mode).""" + + def setUp(self): + """Set up test fixtures before each test.""" + # Reset SensorManager state + SensorManager._initialized = False + SensorManager._imu_driver = None + SensorManager._sensor_list = [] + SensorManager._has_mcu_temperature = False + + # Create mock I2C bus with no devices + self.i2c_bus = MockI2C(0, sda=48, scl=47) + # No chip ID registered - simulates no hardware + + def test_no_imu_detected(self): + """Test behavior when no IMU is present.""" + result = SensorManager.init(self.i2c_bus, address=0x6B) + # Returns True if MCU temp is available (even without IMU) + self.assertTrue(result) + + def test_graceful_degradation(self): + """Test graceful degradation when no sensors available.""" + SensorManager.init(self.i2c_bus, address=0x6B) + + # Should have at least MCU temperature + sensors = SensorManager.get_sensor_list() + self.assertGreaterEqual(len(sensors), 0) + + # Reading non-existent sensor should return None + accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) + if accel is None: + # Expected when no IMU + pass + else: + # If somehow initialized, reading should handle gracefully + data = SensorManager.read_sensor(accel) + # Should either work or return None, not crash + self.assertTrue(data is None or len(data) == 3) + + +class TestSensorManagerMultipleInit(unittest.TestCase): + """Test cases for multiple initialization calls.""" + + def setUp(self): + """Set up test fixtures before each test.""" + # Reset SensorManager state + SensorManager._initialized = False + SensorManager._imu_driver = None + SensorManager._sensor_list = [] + SensorManager._has_mcu_temperature = False + + # Create mock I2C bus with QMI8658 + self.i2c_bus = MockI2C(0, sda=48, scl=47) + self.i2c_bus.memory[0x6B] = {_REG_PARTID: [_QMI8685_PARTID]} + + def test_multiple_init_calls(self): + """Test that multiple init calls are handled gracefully.""" + result1 = SensorManager.init(self.i2c_bus, address=0x6B) + self.assertTrue(result1) + + # Second init should return True but not re-initialize + result2 = SensorManager.init(self.i2c_bus, address=0x6B) + self.assertTrue(result2) + + # Should still work normally + self.assertTrue(SensorManager.is_available()) diff --git a/tests/test_shared_preferences.py b/tests/test_shared_preferences.py index 04c47e82..f8e28215 100644 --- a/tests/test_shared_preferences.py +++ b/tests/test_shared_preferences.py @@ -475,4 +475,213 @@ def test_large_nested_structure(self): self.assertEqual(loaded["settings"]["theme"], "dark") self.assertEqual(loaded["settings"]["limits"][2], 30) + # Tests for default values feature + def test_constructor_defaults_basic(self): + """Test that constructor defaults are returned when key is missing.""" + defaults = {"brightness": -1, "enabled": True, "name": "default"} + prefs = SharedPreferences(self.test_app_name, defaults=defaults) + + # No values stored yet, should return constructor defaults + self.assertEqual(prefs.get_int("brightness"), -1) + self.assertEqual(prefs.get_bool("enabled"), True) + self.assertEqual(prefs.get_string("name"), "default") + + def test_method_default_precedence(self): + """Test that method defaults override constructor defaults.""" + defaults = {"brightness": -1, "enabled": False, "name": "default"} + prefs = SharedPreferences(self.test_app_name, defaults=defaults) + + # Method defaults should take precedence when different from hardcoded defaults + self.assertEqual(prefs.get_int("brightness", 50), 50) + # For booleans, we can only test when method default differs from hardcoded False + self.assertEqual(prefs.get_bool("enabled", True), True) + self.assertEqual(prefs.get_string("name", "override"), "override") + + def test_stored_value_precedence(self): + """Test that stored values override all defaults.""" + defaults = {"brightness": -1, "enabled": True, "name": "default"} + prefs = SharedPreferences(self.test_app_name, defaults=defaults) + + # Store some values + prefs.edit().put_int("brightness", 75).put_bool("enabled", False).put_string("name", "stored").commit() + + # Reload and verify stored values override defaults + prefs2 = SharedPreferences(self.test_app_name, defaults=defaults) + self.assertEqual(prefs2.get_int("brightness"), 75) + self.assertEqual(prefs2.get_bool("enabled"), False) + self.assertEqual(prefs2.get_string("name"), "stored") + + # Method defaults should not override stored values + self.assertEqual(prefs2.get_int("brightness", 100), 75) + self.assertEqual(prefs2.get_bool("enabled", True), False) + self.assertEqual(prefs2.get_string("name", "method"), "stored") + + def test_default_values_not_saved(self): + """Test that values matching defaults are not written to disk.""" + defaults = {"brightness": -1, "enabled": True, "name": "default"} + prefs = SharedPreferences(self.test_app_name, defaults=defaults) + + # Set values matching defaults + prefs.edit().put_int("brightness", -1).put_bool("enabled", True).put_string("name", "default").commit() + + # Reload and verify values are returned correctly + prefs2 = SharedPreferences(self.test_app_name, defaults=defaults) + self.assertEqual(prefs2.get_int("brightness"), -1) + self.assertEqual(prefs2.get_bool("enabled"), True) + self.assertEqual(prefs2.get_string("name"), "default") + + # Verify raw data doesn't contain the keys (they weren't saved) + self.assertFalse("brightness" in prefs2.data) + self.assertFalse("enabled" in prefs2.data) + self.assertFalse("name" in prefs2.data) + + def test_cleanup_removes_defaults(self): + """Test that setting a value to its default removes it from storage.""" + defaults = {"brightness": -1} + prefs = SharedPreferences(self.test_app_name, defaults=defaults) + + # Store a non-default value + prefs.edit().put_int("brightness", 75).commit() + prefs2 = SharedPreferences(self.test_app_name, defaults=defaults) + self.assertIn("brightness", prefs2.data) + self.assertEqual(prefs2.get_int("brightness"), 75) + + # Change it back to default + prefs2.edit().put_int("brightness", -1).commit() + + # Reload and verify it's been removed from storage + prefs3 = SharedPreferences(self.test_app_name, defaults=defaults) + self.assertFalse("brightness" in prefs3.data) + self.assertEqual(prefs3.get_int("brightness"), -1) + + def test_none_as_valid_default(self): + """Test that None can be used as a constructor default value.""" + defaults = {"optional_string": None, "optional_list": None} + prefs = SharedPreferences(self.test_app_name, defaults=defaults) + + # Should return None for these keys + self.assertIsNone(prefs.get_string("optional_string")) + self.assertIsNone(prefs.get_list("optional_list")) + + # Store some values + prefs.edit().put_string("optional_string", "value").put_list("optional_list", [1, 2]).commit() + + # Reload + prefs2 = SharedPreferences(self.test_app_name, defaults=defaults) + self.assertEqual(prefs2.get_string("optional_string"), "value") + self.assertEqual(prefs2.get_list("optional_list"), [1, 2]) + + def test_empty_collection_defaults(self): + """Test empty lists and dicts as constructor defaults.""" + defaults = {"items": [], "settings": {}} + prefs = SharedPreferences(self.test_app_name, defaults=defaults) + + # Should return empty collections + self.assertEqual(prefs.get_list("items"), []) + self.assertEqual(prefs.get_dict("settings"), {}) + + # These should not be saved to disk + prefs.edit().put_list("items", []).put_dict("settings", {}).commit() + prefs2 = SharedPreferences(self.test_app_name, defaults=defaults) + self.assertFalse("items" in prefs2.data) + self.assertFalse("settings" in prefs2.data) + + def test_defaults_with_nested_structures(self): + """Test that defaults work with complex nested structures.""" + defaults = { + "config": {"theme": "dark", "size": 12}, + "items": [1, 2, 3] + } + prefs = SharedPreferences(self.test_app_name, defaults=defaults) + + # Constructor defaults should work + self.assertEqual(prefs.get_dict("config"), {"theme": "dark", "size": 12}) + self.assertEqual(prefs.get_list("items"), [1, 2, 3]) + + # Exact match should not be saved + prefs.edit().put_dict("config", {"theme": "dark", "size": 12}).commit() + prefs2 = SharedPreferences(self.test_app_name, defaults=defaults) + self.assertFalse("config" in prefs2.data) + + # Modified value should be saved + prefs2.edit().put_dict("config", {"theme": "light", "size": 12}).commit() + prefs3 = SharedPreferences(self.test_app_name, defaults=defaults) + self.assertIn("config", prefs3.data) + self.assertEqual(prefs3.get_dict("config")["theme"], "light") + + def test_backward_compatibility(self): + """Test that existing code without defaults parameter still works.""" + # Old style initialization (no defaults parameter) + prefs = SharedPreferences(self.test_app_name) + + # Should work exactly as before + prefs.edit().put_string("key", "value").put_int("count", 42).commit() + + prefs2 = SharedPreferences(self.test_app_name) + self.assertEqual(prefs2.get_string("key"), "value") + self.assertEqual(prefs2.get_int("count"), 42) + + def test_type_conversion_with_defaults(self): + """Test type conversion works correctly with constructor defaults.""" + defaults = {"number": -1, "flag": True} + prefs = SharedPreferences(self.test_app_name, defaults=defaults) + + # Store string representations + prefs.edit().put_string("number", "123").put_string("flag", "false").commit() + + # get_int and get_bool should handle conversion + prefs2 = SharedPreferences(self.test_app_name, defaults=defaults) + # Note: the stored values are strings, not ints/bools, so they're different from defaults + self.assertIn("number", prefs2.data) + self.assertIn("flag", prefs2.data) + + def test_multiple_editors_with_defaults(self): + """Test that multiple edit sessions work correctly with defaults.""" + defaults = {"brightness": -1, "volume": 50} + prefs = SharedPreferences(self.test_app_name, defaults=defaults) + + # First editor session + editor1 = prefs.edit() + editor1.put_int("brightness", 75) + editor1.commit() + + # Second editor session + editor2 = prefs.edit() + editor2.put_int("volume", 80) + editor2.commit() + + # Verify both values + prefs2 = SharedPreferences(self.test_app_name, defaults=defaults) + self.assertEqual(prefs2.get_int("brightness"), 75) + self.assertEqual(prefs2.get_int("volume"), 80) + self.assertIn("brightness", prefs2.data) + self.assertIn("volume", prefs2.data) + + # Set one back to default + prefs2.edit().put_int("brightness", -1).commit() + prefs3 = SharedPreferences(self.test_app_name, defaults=defaults) + self.assertFalse("brightness" in prefs3.data) + self.assertEqual(prefs3.get_int("brightness"), -1) + + def test_partial_defaults(self): + """Test that some keys can have defaults while others don't.""" + defaults = {"brightness": -1} # Only brightness has a default + prefs = SharedPreferences(self.test_app_name, defaults=defaults) + + # Save multiple values + prefs.edit().put_int("brightness", -1).put_int("volume", 50).put_string("name", "test").commit() + + # Reload + prefs2 = SharedPreferences(self.test_app_name, defaults=defaults) + + # brightness matches default, should not be in data + self.assertFalse("brightness" in prefs2.data) + self.assertEqual(prefs2.get_int("brightness"), -1) + + # volume and name have no defaults, should be in data + self.assertIn("volume", prefs2.data) + self.assertIn("name", prefs2.data) + self.assertEqual(prefs2.get_int("volume"), 50) + self.assertEqual(prefs2.get_string("name"), "test") + diff --git a/tests/test_syspath_restore.py b/tests/test_syspath_restore.py new file mode 100644 index 00000000..36d668d8 --- /dev/null +++ b/tests/test_syspath_restore.py @@ -0,0 +1,78 @@ +import unittest +import sys +import os + +class TestSysPathRestore(unittest.TestCase): + """Test that sys.path is properly restored after execute_script""" + + def test_syspath_restored_after_execute_script(self): + """Test that sys.path is restored to original state after script execution""" + # Import here to ensure we're in the right context + import mpos.apps + + # Capture original sys.path + original_path = sys.path[:] + original_length = len(sys.path) + + # Create a test directory path that would be added + test_cwd = "apps/com.test.app/assets/" + + # Verify the test path is not already in sys.path + self.assertFalse(test_cwd in original_path, + f"Test path {test_cwd} should not be in sys.path initially") + + # Create a simple test script + test_script = ''' +import sys +# Just a simple script that does nothing +x = 42 +''' + + # Call execute_script with cwd parameter + # Note: This will fail because there's no Activity to start, + # but that's fine - we're testing the sys.path restoration + result = mpos.apps.execute_script( + test_script, + is_file=False, + cwd=test_cwd, + classname="NonExistentClass" + ) + + # After execution, sys.path should be restored + current_path = sys.path + current_length = len(sys.path) + + # Verify sys.path has been restored to original + self.assertEqual(current_length, original_length, + f"sys.path length should be restored. Original: {original_length}, Current: {current_length}") + + # Verify the test directory is not in sys.path anymore + self.assertFalse(test_cwd in current_path, + f"Test path {test_cwd} should not be in sys.path after execution. sys.path={current_path}") + + # Verify sys.path matches original + self.assertEqual(current_path, original_path, + f"sys.path should match original.\nOriginal: {original_path}\nCurrent: {current_path}") + + def test_syspath_not_affected_when_no_cwd(self): + """Test that sys.path is unchanged when cwd is None""" + import mpos.apps + + # Capture original sys.path + original_path = sys.path[:] + + test_script = ''' +x = 42 +''' + + # Call without cwd parameter + result = mpos.apps.execute_script( + test_script, + is_file=False, + cwd=None, + classname="NonExistentClass" + ) + + # sys.path should be unchanged + self.assertEqual(sys.path, original_path, + "sys.path should be unchanged when cwd is None") diff --git a/tests/test_websocket.py b/tests/test_websocket.py index 8f7cd4c5..ed81e8ea 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -4,6 +4,7 @@ import time from mpos import App, PackageManager +from mpos import TaskManager import mpos.apps from websocket import WebSocketApp @@ -12,6 +13,8 @@ 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 +54,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 +92,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 +110,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/unittest.sh b/tests/unittest.sh index f93cc111..b7959cba 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 @@ -59,14 +60,14 @@ 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') ; import mpos ; mpos.TaskManager.disable() ; $(cat main.py) ; import mpos.apps; sys.path.append(\"$tests_abs_path\") $(cat $file) result = unittest.main() ; sys.exit(0 if result.wasSuccessful() else 1) " result=$? else # Regular test: no boot files - "$binary" -X heapsize=8M -c "$(cat main.py) + "$binary" -X heapsize=8M -c "import sys ; sys.path.insert(0, 'lib') ; import mpos ; mpos.TaskManager.disable() ; $(cat main.py) $(cat $file) result = unittest.main() ; sys.exit(0 if result.wasSuccessful() else 1) " result=$? @@ -86,7 +87,7 @@ 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') ; import mpos ; mpos.TaskManager.disable() ; $(cat main.py) ; sys.path.append('tests') $(cat $file) result = unittest.main() if result.wasSuccessful(): @@ -96,7 +97,7 @@ else: " | tee "$testlog" else # Regular test: no boot files - "$mpremote" exec "$(cat main.py) + "$mpremote" exec "import sys ; sys.path.insert(0, 'lib') ; import mpos ; mpos.TaskManager.disable() ; $(cat main.py) $(cat $file) result = unittest.main() if result.wasSuccessful(): @@ -124,7 +125,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,7 +136,7 @@ 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")