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/CHANGELOG.md b/CHANGELOG.md index 4dc26818..9915e623 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,33 @@ +0.5.2 +===== +- AudioFlinger: optimize WAV volume scaling for speed and immediately set volume +- AudioFlinger: add support for I2S microphone recording to WAV +- About app: add mpy info +- AppStore app: eliminate all threads by using TaskManager +- AppStore app: add support for BadgeHub backend (not default) +- OSUpdate app: show download speed +- WiFi app: new "Add network" functionality for out-of-range networks +- WiFi app: add support for hidden networks +- WiFi app: add "Forget" button to delete networks +- 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 IMU/accelerometers, temperature sensors etc. +- API: add SensorManager for generic handling of IMUs and temperature sensors +- UI: back swipe gesture closes topmenu when open (thanks, @Mark19000 !) - About app: add free, used and total storage space info - AppStore app: remove unnecessary scrollbar over publisher's name - Camera app: massive overhaul! @@ -20,6 +38,8 @@ - ImageView app: add support for grayscale images - OSUpdate app: pause download when wifi is lost, resume when reconnected - Settings app: fix un-checking of radio button +- Settings app: add IMU calibration +- Wifi app: simplify on-screen keyboard handling, fix cancel button handling 0.5.0 ===== diff --git a/CLAUDE.md b/CLAUDE.md index 05137f09..b00e3722 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -483,6 +483,8 @@ 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` +- 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) @@ -572,6 +574,8 @@ 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 @@ -581,6 +585,85 @@ def defocus_handler(self, obj): - `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. diff --git a/internal_filesystem/apps/com.micropythonos.camera/META-INF/MANIFEST.JSON b/internal_filesystem/apps/com.micropythonos.camera/META-INF/MANIFEST.JSON index 1a2cde4f..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": [ { 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.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.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/music_player.py b/internal_filesystem/apps/com.micropythonos.musicplayer/assets/music_player.py index 14380937..428f773f 100644 --- a/internal_filesystem/apps/com.micropythonos.musicplayer/assets/music_player.py +++ b/internal_filesystem/apps/com.micropythonos.musicplayer/assets/music_player.py @@ -69,12 +69,12 @@ def onCreate(self): self._slider_label.set_text(f"Volume: {AudioFlinger.get_volume()}%") self._slider_label.align(lv.ALIGN.TOP_MID,0,lv.pct(4)) self._slider=lv.slider(qr_screen) - self._slider.set_range(0,100) - self._slider.set_value(AudioFlinger.get_volume(), False) + 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}%") AudioFlinger.set_volume(volume_int) self._slider.add_event_cb(volume_slider_changed,lv.EVENT.VALUE_CHANGED,None) diff --git a/internal_filesystem/apps/com.micropythonos.soundrecorder/META-INF/MANIFEST.JSON b/internal_filesystem/apps/com.micropythonos.soundrecorder/META-INF/MANIFEST.JSON new file mode 100644 index 00000000..eef5faf3 --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.soundrecorder/META-INF/MANIFEST.JSON @@ -0,0 +1,23 @@ +{ + "name": "Sound Recorder", + "publisher": "MicroPythonOS", + "short_description": "Record audio from microphone", + "long_description": "Record audio from the I2S microphone and save as WAV files. Recordings can be played back with the Music Player app.", + "icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.soundrecorder/icons/com.micropythonos.soundrecorder_0.0.1_64x64.png", + "download_url": "https://apps.micropythonos.com/apps/com.micropythonos.soundrecorder/mpks/com.micropythonos.soundrecorder_0.0.1.mpk", + "fullname": "com.micropythonos.soundrecorder", + "version": "0.0.1", + "category": "utilities", + "activities": [ + { + "entrypoint": "assets/sound_recorder.py", + "classname": "SoundRecorder", + "intent_filters": [ + { + "action": "main", + "category": "launcher" + } + ] + } + ] +} \ No newline at end of file diff --git a/internal_filesystem/apps/com.micropythonos.soundrecorder/assets/sound_recorder.py b/internal_filesystem/apps/com.micropythonos.soundrecorder/assets/sound_recorder.py new file mode 100644 index 00000000..3fe52476 --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.soundrecorder/assets/sound_recorder.py @@ -0,0 +1,407 @@ +# Sound Recorder App - Record audio from I2S microphone to WAV files +import os +import time + +from mpos.apps import Activity +import mpos.ui +import mpos.audio.audioflinger as AudioFlinger + + +def _makedirs(path): + """ + Create directory and all parent directories (like os.makedirs). + MicroPython doesn't have os.makedirs, so we implement it manually. + """ + if not path: + return + + parts = path.split('/') + current = '' + + for part in parts: + if not part: + continue + current = current + '/' + part if current else part + try: + os.mkdir(current) + except OSError: + pass # Directory may already exist + + +class SoundRecorder(Activity): + """ + Sound Recorder app for recording audio from I2S microphone. + Saves recordings as WAV files that can be played with Music Player. + """ + + # Constants + RECORDINGS_DIR = "data/recordings" + SAMPLE_RATE = 16000 # 16kHz + BYTES_PER_SAMPLE = 2 # 16-bit audio + BYTES_PER_SECOND = SAMPLE_RATE * BYTES_PER_SAMPLE # 32000 bytes/sec + MIN_DURATION_MS = 5000 # Minimum 5 seconds + MAX_DURATION_MS = 3600000 # Maximum 1 hour (absolute cap) + SAFETY_MARGIN = 0.80 # Use only 80% of available space + + # UI Widgets + _status_label = None + _timer_label = None + _record_button = None + _record_button_label = None + _play_button = None + _play_button_label = None + _delete_button = None + _last_file_label = None + + # State + _is_recording = False + _last_recording = None + _timer_task = None + _record_start_time = 0 + + def onCreate(self): + screen = lv.obj() + + # Calculate max duration based on available storage + self._current_max_duration_ms = self._calculate_max_duration() + + # Title + title = lv.label(screen) + title.set_text("Sound Recorder") + title.align(lv.ALIGN.TOP_MID, 0, 10) + title.set_style_text_font(lv.font_montserrat_20, 0) + + # Status label (shows microphone availability) + self._status_label = lv.label(screen) + self._status_label.align(lv.ALIGN.TOP_MID, 0, 40) + + # Timer display + self._timer_label = lv.label(screen) + self._timer_label.set_text(self._format_timer_text(0)) + self._timer_label.align(lv.ALIGN.CENTER, 0, -30) + self._timer_label.set_style_text_font(lv.font_montserrat_24, 0) + + # Record button + self._record_button = lv.button(screen) + self._record_button.set_size(120, 50) + self._record_button.align(lv.ALIGN.CENTER, 0, 30) + self._record_button.add_event_cb(self._on_record_clicked, lv.EVENT.CLICKED, None) + + self._record_button_label = lv.label(self._record_button) + self._record_button_label.set_text(lv.SYMBOL.AUDIO + " Record") + self._record_button_label.center() + + # Last recording info + self._last_file_label = lv.label(screen) + self._last_file_label.align(lv.ALIGN.BOTTOM_MID, 0, -70) + self._last_file_label.set_text("No recordings yet") + self._last_file_label.set_long_mode(lv.label.LONG_MODE.SCROLL_CIRCULAR) + self._last_file_label.set_width(lv.pct(90)) + + # Play button + self._play_button = lv.button(screen) + self._play_button.set_size(80, 40) + self._play_button.align(lv.ALIGN.BOTTOM_LEFT, 20, -20) + self._play_button.add_event_cb(self._on_play_clicked, lv.EVENT.CLICKED, None) + self._play_button.add_flag(lv.obj.FLAG.HIDDEN) + + self._play_button_label = lv.label(self._play_button) + self._play_button_label.set_text(lv.SYMBOL.PLAY + " Play") + self._play_button_label.center() + + # Delete button + self._delete_button = lv.button(screen) + self._delete_button.set_size(80, 40) + self._delete_button.align(lv.ALIGN.BOTTOM_RIGHT, -20, -20) + self._delete_button.add_event_cb(self._on_delete_clicked, lv.EVENT.CLICKED, None) + self._delete_button.add_flag(lv.obj.FLAG.HIDDEN) + + delete_label = lv.label(self._delete_button) + delete_label.set_text(lv.SYMBOL.TRASH + " Delete") + delete_label.center() + + self.setContentView(screen) + + def onResume(self, screen): + super().onResume(screen) + # Recalculate max duration (storage may have changed) + self._current_max_duration_ms = self._calculate_max_duration() + self._timer_label.set_text(self._format_timer_text(0)) + self._update_status() + self._find_last_recording() + + def onPause(self, screen): + super().onPause(screen) + # Stop recording if app goes to background + if self._is_recording: + self._stop_recording() + + def _update_status(self): + """Update status label based on microphone availability.""" + if AudioFlinger.has_microphone(): + self._status_label.set_text("Microphone ready") + self._status_label.set_style_text_color(lv.color_hex(0x00AA00), 0) + self._record_button.remove_flag(lv.obj.FLAG.HIDDEN) + else: + self._status_label.set_text("No microphone available") + self._status_label.set_style_text_color(lv.color_hex(0xAA0000), 0) + self._record_button.add_flag(lv.obj.FLAG.HIDDEN) + + def _find_last_recording(self): + """Find the most recent recording file.""" + try: + # Ensure recordings directory exists + _makedirs(self.RECORDINGS_DIR) + + # List recordings + files = os.listdir(self.RECORDINGS_DIR) + wav_files = [f for f in files if f.endswith('.wav')] + + if wav_files: + # Sort by name (which includes timestamp) + wav_files.sort(reverse=True) + self._last_recording = f"{self.RECORDINGS_DIR}/{wav_files[0]}" + self._last_file_label.set_text(f"Last: {wav_files[0]}") + self._play_button.remove_flag(lv.obj.FLAG.HIDDEN) + self._delete_button.remove_flag(lv.obj.FLAG.HIDDEN) + else: + self._last_recording = None + self._last_file_label.set_text("No recordings yet") + self._play_button.add_flag(lv.obj.FLAG.HIDDEN) + self._delete_button.add_flag(lv.obj.FLAG.HIDDEN) + + except Exception as e: + print(f"SoundRecorder: Error finding recordings: {e}") + self._last_recording = None + + def _calculate_max_duration(self): + """ + Calculate maximum recording duration based on available storage. + Returns duration in milliseconds. + """ + try: + # Ensure recordings directory exists + _makedirs(self.RECORDINGS_DIR) + + # Get filesystem stats for the recordings directory + stat = os.statvfs(self.RECORDINGS_DIR) + + # Calculate free space in bytes + # f_bavail = free blocks available to non-superuser + # f_frsize = fragment size (fundamental block size) + free_bytes = stat[0] * stat[4] # f_frsize * f_bavail + + # Apply safety margin (use only 80% of available space) + usable_bytes = int(free_bytes * self.SAFETY_MARGIN) + + # Calculate max duration in seconds + max_seconds = usable_bytes // self.BYTES_PER_SECOND + + # Convert to milliseconds + max_ms = max_seconds * 1000 + + # Clamp to min/max bounds + max_ms = max(self.MIN_DURATION_MS, min(max_ms, self.MAX_DURATION_MS)) + + print(f"SoundRecorder: Free space: {free_bytes} bytes, " + f"usable: {usable_bytes} bytes, max duration: {max_ms // 1000}s") + + return max_ms + + except Exception as e: + print(f"SoundRecorder: Error calculating max duration: {e}") + # Fall back to a conservative 60 seconds + return 60000 + + def _format_timer_text(self, elapsed_ms): + """Format timer display text showing elapsed / max time.""" + elapsed_sec = elapsed_ms // 1000 + max_sec = self._current_max_duration_ms // 1000 + + elapsed_min = elapsed_sec // 60 + elapsed_sec_display = elapsed_sec % 60 + max_min = max_sec // 60 + max_sec_display = max_sec % 60 + + return f"{elapsed_min:02d}:{elapsed_sec_display:02d} / {max_min:02d}:{max_sec_display:02d}" + + def _generate_filename(self): + """Generate a timestamped filename for the recording.""" + # Get current time + t = time.localtime() + timestamp = f"{t[0]:04d}-{t[1]:02d}-{t[2]:02d}_{t[3]:02d}-{t[4]:02d}-{t[5]:02d}" + return f"{self.RECORDINGS_DIR}/{timestamp}.wav" + + def _on_record_clicked(self, event): + """Handle record button click.""" + print(f"SoundRecorder: _on_record_clicked called, _is_recording={self._is_recording}") + if self._is_recording: + print("SoundRecorder: Stopping recording...") + self._stop_recording() + else: + print("SoundRecorder: Starting recording...") + self._start_recording() + + def _start_recording(self): + """Start recording audio.""" + print("SoundRecorder: _start_recording called") + print(f"SoundRecorder: has_microphone() = {AudioFlinger.has_microphone()}") + + if not AudioFlinger.has_microphone(): + print("SoundRecorder: No microphone available - aborting") + return + + # Generate filename + file_path = self._generate_filename() + print(f"SoundRecorder: Generated filename: {file_path}") + + # Recalculate max duration before starting (storage may have changed) + self._current_max_duration_ms = self._calculate_max_duration() + + if self._current_max_duration_ms < self.MIN_DURATION_MS: + print("SoundRecorder: Not enough storage space") + self._status_label.set_text("Not enough storage space") + self._status_label.set_style_text_color(lv.color_hex(0xAA0000), 0) + return + + # Start recording + print(f"SoundRecorder: Calling AudioFlinger.record_wav()") + print(f" file_path: {file_path}") + print(f" duration_ms: {self._current_max_duration_ms}") + print(f" sample_rate: {self.SAMPLE_RATE}") + + success = AudioFlinger.record_wav( + file_path=file_path, + duration_ms=self._current_max_duration_ms, + on_complete=self._on_recording_complete, + sample_rate=self.SAMPLE_RATE + ) + + print(f"SoundRecorder: record_wav returned: {success}") + + if success: + self._is_recording = True + self._record_start_time = time.ticks_ms() + self._last_recording = file_path + print(f"SoundRecorder: Recording started successfully") + + # Update UI + self._record_button_label.set_text(lv.SYMBOL.STOP + " Stop") + self._record_button.set_style_bg_color(lv.color_hex(0xAA0000), 0) + self._status_label.set_text("Recording...") + self._status_label.set_style_text_color(lv.color_hex(0xAA0000), 0) + + # Hide play/delete buttons during recording + self._play_button.add_flag(lv.obj.FLAG.HIDDEN) + self._delete_button.add_flag(lv.obj.FLAG.HIDDEN) + + # Start timer update + self._start_timer_update() + else: + print("SoundRecorder: record_wav failed!") + self._status_label.set_text("Failed to start recording") + self._status_label.set_style_text_color(lv.color_hex(0xAA0000), 0) + + def _stop_recording(self): + """Stop recording audio.""" + AudioFlinger.stop() + self._is_recording = False + + # Show "Saving..." status immediately (file finalization takes time on SD card) + self._status_label.set_text("Saving...") + self._status_label.set_style_text_color(lv.color_hex(0xFF8800), 0) # Orange + + # Disable record button while saving + self._record_button.add_flag(lv.obj.FLAG.HIDDEN) + + # Stop timer update but keep the elapsed time visible + if self._timer_task: + self._timer_task.delete() + self._timer_task = None + + def _on_recording_complete(self, message): + """Callback when recording finishes.""" + print(f"SoundRecorder: {message}") + + # Update UI on main thread + self.update_ui_threadsafe_if_foreground(self._recording_finished, message) + + def _recording_finished(self, message): + """Update UI after recording finishes (called on main thread).""" + self._is_recording = False + + # Re-enable and reset record button + self._record_button.remove_flag(lv.obj.FLAG.HIDDEN) + self._record_button_label.set_text(lv.SYMBOL.AUDIO + " Record") + self._record_button.set_style_bg_color(lv.theme_get_color_primary(None), 0) + + # Update status and find recordings + self._update_status() + self._find_last_recording() + + # Reset timer display + self._timer_label.set_text(self._format_timer_text(0)) + + def _start_timer_update(self): + """Start updating the timer display.""" + # Use LVGL timer for periodic updates + self._timer_task = lv.timer_create(self._update_timer, 100, None) + + def _stop_timer_update(self): + """Stop updating the timer display.""" + if self._timer_task: + self._timer_task.delete() + self._timer_task = None + self._timer_label.set_text(self._format_timer_text(0)) + + def _update_timer(self, timer): + """Update timer display (called periodically).""" + if not self._is_recording: + return + + elapsed_ms = time.ticks_diff(time.ticks_ms(), self._record_start_time) + self._timer_label.set_text(self._format_timer_text(elapsed_ms)) + + def _on_play_clicked(self, event): + """Handle play button click.""" + if self._last_recording and not self._is_recording: + # Stop any current playback + AudioFlinger.stop() + time.sleep_ms(100) + + # Play the recording + success = AudioFlinger.play_wav( + self._last_recording, + stream_type=AudioFlinger.STREAM_MUSIC, + on_complete=self._on_playback_complete, + volume=100 + ) + + if success: + self._status_label.set_text("Playing...") + self._status_label.set_style_text_color(lv.color_hex(0x0000AA), 0) + else: + self._status_label.set_text("Playback failed") + self._status_label.set_style_text_color(lv.color_hex(0xAA0000), 0) + + def _on_playback_complete(self, message): + """Callback when playback finishes.""" + self.update_ui_threadsafe_if_foreground(self._update_status) + + def _on_delete_clicked(self, event): + """Handle delete button click.""" + if self._last_recording and not self._is_recording: + try: + os.remove(self._last_recording) + print(f"SoundRecorder: Deleted {self._last_recording}") + self._find_last_recording() + + # Recalculate max duration (more space available now) + self._current_max_duration_ms = self._calculate_max_duration() + self._timer_label.set_text(self._format_timer_text(0)) + + self._status_label.set_text("Recording deleted") + except Exception as e: + print(f"SoundRecorder: Delete failed: {e}") + self._status_label.set_text("Delete failed") + self._status_label.set_style_text_color(lv.color_hex(0xAA0000), 0) \ No newline at end of file diff --git a/internal_filesystem/apps/com.micropythonos.soundrecorder/generate_icon.py b/internal_filesystem/apps/com.micropythonos.soundrecorder/generate_icon.py new file mode 100644 index 00000000..f2cfa66c --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.soundrecorder/generate_icon.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +""" +Generate a 64x64 icon for the Sound Recorder app. +Creates a microphone icon with transparent background. + +Run this script to generate the icon: + python3 generate_icon.py + +The icon will be saved to res/mipmap-mdpi/icon_64x64.png +""" + +import os +from PIL import Image, ImageDraw + +def generate_icon(): + # Create a 64x64 image with transparent background + size = 64 + img = Image.new('RGBA', (size, size), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + + # Colors + mic_color = (220, 50, 50, 255) # Red microphone + mic_dark = (180, 40, 40, 255) # Darker red for shading + stand_color = (80, 80, 80, 255) # Gray stand + highlight = (255, 100, 100, 255) # Light red highlight + + # Microphone head (rounded rectangle / ellipse) + mic_top = 8 + mic_bottom = 36 + mic_left = 20 + mic_right = 44 + + # Draw microphone body (rounded top) + draw.ellipse([mic_left, mic_top, mic_right, mic_top + 16], fill=mic_color) + draw.rectangle([mic_left, mic_top + 8, mic_right, mic_bottom], fill=mic_color) + draw.ellipse([mic_left, mic_bottom - 8, mic_right, mic_bottom + 8], fill=mic_color) + + # Microphone grille lines (horizontal lines on mic head) + for y in range(mic_top + 6, mic_bottom - 4, 4): + draw.line([(mic_left + 4, y), (mic_right - 4, y)], fill=mic_dark, width=1) + + # Highlight on left side of mic + draw.arc([mic_left + 2, mic_top + 2, mic_left + 10, mic_top + 18], + start=120, end=240, fill=highlight, width=2) + + # Microphone stand (curved arc under the mic) + stand_top = mic_bottom + 4 + stand_width = 8 + + # Vertical stem from mic + stem_x = size // 2 + draw.rectangle([stem_x - 2, mic_bottom, stem_x + 2, stand_top + 8], fill=stand_color) + + # Curved holder around mic bottom + draw.arc([mic_left - 4, mic_bottom - 8, mic_right + 4, mic_bottom + 16], + start=0, end=180, fill=stand_color, width=3) + + # Stand base + base_y = 54 + draw.rectangle([stem_x - 2, stand_top + 8, stem_x + 2, base_y], fill=stand_color) + draw.ellipse([stem_x - 12, base_y - 2, stem_x + 12, base_y + 6], fill=stand_color) + + # Recording indicator (red dot with glow effect) + dot_x, dot_y = 52, 12 + dot_radius = 5 + + # Glow effect + for r in range(dot_radius + 3, dot_radius, -1): + alpha = int(100 * (dot_radius + 3 - r) / 3) + glow_color = (255, 0, 0, alpha) + draw.ellipse([dot_x - r, dot_y - r, dot_x + r, dot_y + r], fill=glow_color) + + # Solid red dot + draw.ellipse([dot_x - dot_radius, dot_y - dot_radius, + dot_x + dot_radius, dot_y + dot_radius], + fill=(255, 50, 50, 255)) + + # White highlight on dot + draw.ellipse([dot_x - 2, dot_y - 2, dot_x, dot_y], fill=(255, 200, 200, 255)) + + # Ensure output directory exists + output_dir = 'res/mipmap-mdpi' + os.makedirs(output_dir, exist_ok=True) + + # Save the icon + output_path = os.path.join(output_dir, 'icon_64x64.png') + img.save(output_path, 'PNG') + print(f"Icon saved to {output_path}") + + return img + +if __name__ == '__main__': + generate_icon() \ No newline at end of file diff --git a/internal_filesystem/apps/com.micropythonos.soundrecorder/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/apps/com.micropythonos.soundrecorder/res/mipmap-mdpi/icon_64x64.png new file mode 100644 index 00000000..a301f72f Binary files /dev/null and b/internal_filesystem/apps/com.micropythonos.soundrecorder/res/mipmap-mdpi/icon_64x64.png differ diff --git a/internal_filesystem/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 00c9767e..7c5e05c0 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.about/assets/about.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.about/assets/about.py @@ -23,10 +23,29 @@ def onCreate(self): label2.set_text(f"sys.version: {sys.version}") label3 = lv.label(screen) label3.set_text(f"sys.implementation: {sys.implementation}") + + sys_mpy = sys.implementation._mpy + label30 = lv.label(screen) + label30.set_text(f'mpy version: {sys_mpy & 0xff}') + label31 = lv.label(screen) + label31.set_text(f'mpy sub-version: {sys_mpy >> 8 & 3}') + arch = [None, 'x86', 'x64', + 'armv6', 'armv6m', 'armv7m', 'armv7em', 'armv7emsp', 'armv7emdp', + 'xtensa', 'xtensawin', 'rv32imc', 'rv64imc'][(sys_mpy >> 10) & 0x0F] + flags = "" + if arch: + flags += ' -march=' + arch + if (sys_mpy >> 16) != 0: + flags += ' -march-flags=' + (sys_mpy >> 16) + if len(flags) > 0: + label32 = lv.label(screen) + label32.set_text('mpy flags: ' + flags) + label4 = lv.label(screen) label4.set_text(f"sys.platform: {sys.platform}") label15 = lv.label(screen) label15.set_text(f"sys.path: {sys.path}") + import micropython label16 = lv.label(screen) label16.set_text(f"micropython.opt_level(): {micropython.opt_level()}") 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 ff1674dd..0461d4f5 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,93 @@ 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: + app_obj.publisher = app_metadata.get("author") + except Exception as e: + print(f"Could not get author from version object: {e}") + try: + app_version = app_metadata.get("version") + print(f"what: {version.get('app_metadata')}") + print(f"app has app_version: {app_version}") + app_obj.version = app_version + except Exception as e: + print(f"Could not get version from app_metadata: {e}") except Exception as e: - 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 +262,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 +289,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: @@ -208,54 +305,80 @@ def onCreate(self): 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: @@ -284,43 +407,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) @@ -330,48 +447,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 deceb590..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 @@ -250,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): @@ -275,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(5) + self.status_label.set_text("Update finished! Restarting...") + await TaskManager.sleep(5) self.update_downloader.set_boot_partition_and_restart() return @@ -314,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 @@ -324,19 +352,19 @@ def update_with_lvgl(self, url): while elapsed < max_wait and self.has_foreground(): if self.connectivity_manager.is_online(): print("OSUpdate: Network reconnected, waiting for stabilization...") - time.sleep(2) # Let routing table and DNS fully stabilize + await TaskManager.sleep(2) # Let routing table and DNS fully stabilize print("OSUpdate: Resuming download") self.set_state(UpdateState.DOWNLOADING) break # Exit wait loop and retry download - time.sleep(check_interval) + await TaskManager.sleep(check_interval) elapsed += check_interval if elapsed >= max_wait: # Timeout waiting for network msg = f"Network timeout during download.\n{bytes_written}/{total_size} bytes written.\nPress 'Update OS' to retry." - self.update_ui_threadsafe_if_foreground(self.status_label.set_text, msg) - self.update_ui_threadsafe_if_foreground(self.install_button.remove_state, lv.STATE.DISABLED) + self.status_label.set_text(msg) + self.install_button.remove_state(lv.STATE.DISABLED) self.set_state(UpdateState.ERROR) return @@ -344,32 +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: @@ -386,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 @@ -406,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: @@ -442,14 +488,89 @@ def _is_network_error(self, exception): return any(indicator in error_str or indicator in error_repr for indicator in network_indicators) - def download_and_install(self, url, progress_callback=None, should_continue_callback=None): - """Download firmware and install to OTA partition. + 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 @@ -460,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, @@ -472,135 +590,100 @@ 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 + + # 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, get total size + # For initial download, we need to get total size first + # DownloadManager doesn't expose Content-Length directly, so we estimate if self.bytes_written_so_far == 0: - total_size = int(response.headers.get('Content-Length', 0)) - result['total_size'] = round_up_to_multiple(total_size, 4096) - self.total_size_expected = result['total_size'] - else: - # For resume, use the stored total size - # (Content-Length will be the remaining bytes, not total) - result['total_size'] = self.total_size_expected + # We'll update total_size_expected as we download + # For now, set a placeholder that will be updated + self.total_size_expected = 0 - print(f"UpdateDownloader: Download target {result['total_size']} bytes") + # Download with streaming chunk callback + # Progress and speed are reported by DownloadManager via callbacks + print(f"UpdateDownloader: Starting async download from {url}") + success = await dm.download_url( + url, + chunk_callback=chunk_handler, + progress_callback=progress_callback, # Let DownloadManager handle progress + speed_callback=speed_callback, # Let DownloadManager handle speed + headers=headers + ) - chunk_size = 4096 - bytes_written = self.bytes_written_so_far - block_index = bytes_written // chunk_size + if success: + # Flush any remaining buffered data + await self._flush_buffer() - while True: - # Check if we should continue (user cancelled) - if should_continue_callback and not should_continue_callback(): - result['error'] = "Download cancelled by user" - response.close() - return result - - # Check network connection before reading - if self.connectivity_manager: - is_online = self.connectivity_manager.is_online() - elif ConnectivityManager._instance: - is_online = ConnectivityManager._instance.is_online() - else: - is_online = True - - if not is_online: - print("UpdateDownloader: Network lost (pre-check), pausing download") - self.is_paused = True - self.bytes_written_so_far = bytes_written - result['paused'] = True - result['bytes_written'] = bytes_written - response.close() - return result - - # Read next chunk (may raise exception if network drops) - try: - chunk = response.raw.read(chunk_size) - except Exception as read_error: - # Check if this is a network error that should trigger pause - if self._is_network_error(read_error): - print(f"UpdateDownloader: Network error during read ({read_error}), pausing") - self.is_paused = True - self.bytes_written_so_far = bytes_written - result['paused'] = True - result['bytes_written'] = bytes_written - try: - response.close() - except: - pass - return result - else: - # Non-network error, re-raise - raise - - if not chunk: - break - - # Pad last chunk if needed - if len(chunk) < chunk_size: - print(f"UpdateDownloader: Padding chunk {block_index} from {len(chunk)} to {chunk_size} bytes") - chunk = chunk + b'\xFF' * (chunk_size - len(chunk)) - - # Write to partition - if not self.simulate: - next_partition.writeblocks(block_index, chunk) - - bytes_written += len(chunk) - self.bytes_written_so_far = bytes_written - block_index += 1 - - # Update progress - if progress_callback and result['total_size'] > 0: - percent = (bytes_written / result['total_size']) * 100 - progress_callback(percent) - - # Small delay to avoid hogging CPU - time.sleep_ms(100) - - response.close() - result['bytes_written'] = bytes_written - - # Check if complete - if bytes_written >= result['total_size']: result['success'] = True + result['bytes_written'] = self.bytes_written_so_far + result['total_size'] = self.bytes_written_so_far # Actual size downloaded + + # Final 100% progress callback + if self._progress_callback: + await self._progress_callback(100.0) + + # Reset state for next download self.is_paused = False - self.bytes_written_so_far = 0 # Reset for next download + self.bytes_written_so_far = 0 self.total_size_expected = 0 - print(f"UpdateDownloader: Download complete ({bytes_written} bytes)") + self._current_partition = None + self._block_index = 0 + self._chunk_buffer = b'' + self._total_bytes_received = 0 + + print(f"UpdateDownloader: Download complete ({result['bytes_written']} bytes)") else: - result['error'] = f"Incomplete download: {bytes_written} < {result['total_size']}" - print(f"UpdateDownloader: {result['error']}") + # Download failed but not due to exception + result['error'] = "Download failed" + result['bytes_written'] = self.bytes_written_so_far + result['total_size'] = self.total_size_expected except Exception as e: + error_msg = str(e) + + # Check if cancelled by user + if "cancelled" in error_msg.lower(): + result['error'] = error_msg + result['bytes_written'] = self.bytes_written_so_far + result['total_size'] = self.total_size_expected # Check if this is a network error that should trigger pause - if self._is_network_error(e): + elif self._is_network_error(e): print(f"UpdateDownloader: Network error ({e}), pausing download") self.is_paused = True - # Only update bytes_written_so_far if we actually wrote bytes in this attempt - # Otherwise preserve the existing state (important for resume failures) - if result.get('bytes_written', 0) > 0: - self.bytes_written_so_far = result['bytes_written'] result['paused'] = True result['bytes_written'] = self.bytes_written_so_far - result['total_size'] = self.total_size_expected # Preserve total size for UI + result['total_size'] = self.total_size_expected else: # Non-network error - result['error'] = str(e) + result['error'] = error_msg + result['bytes_written'] = self.bytes_written_so_far + result['total_size'] = self.total_size_expected print(f"UpdateDownloader: Error during download: {e}") return result 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 index 009a2e75..750fa5c3 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/calibrate_imu.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/calibrate_imu.py @@ -62,7 +62,7 @@ def onCreate(self): 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(90)) + self.status_label.set_width(lv.pct(100)) # Detail label (for additional info) self.detail_label = lv.label(screen) @@ -205,9 +205,9 @@ def start_calibration_process(self): # Step 3: Show results result_msg = "Calibration successful!" if accel_offsets: - result_msg += f"\n\nAccel offsets:\nX:{accel_offsets[0]:.3f} Y:{accel_offsets[1]:.3f} Z:{accel_offsets[2]:.3f}" + result_msg += f"\n\nAccel offsets: X:{accel_offsets[0]:.3f} Y:{accel_offsets[1]:.3f} Z:{accel_offsets[2]:.3f}" if gyro_offsets: - result_msg += f"\n\nGyro offsets:\nX:{gyro_offsets[0]:.3f} Y:{gyro_offsets[1]:.3f} Z:{gyro_offsets[2]:.3f}" + result_msg += f"\n\nGyro offsets: X:{gyro_offsets[0]:.3f} Y:{gyro_offsets[1]:.3f} Z:{gyro_offsets[2]:.3f}" self.show_calibration_complete(result_msg) diff --git a/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py index 8dac9420..05acca6a 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py @@ -43,15 +43,16 @@ def __init__(self): ("Turquoise", "40e0d0") ] self.settings = [ - # Novice settings, alphabetically: - {"title": "Calibrate IMU", "key": "calibrate_imu", "value_label": None, "cont": None, "ui": "activity", "activity_class": "CalibrateIMUActivity"}, - {"title": "Check IMU Calibration", "key": "check_imu_calibration", "value_label": None, "cont": None, "ui": "activity", "activity_class": "CheckIMUCalibrationActivity"}, + # 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": "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: 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 82aeab89..71238656 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.wifi/assets/wifi.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.wifi/assets/wifi.py @@ -1,5 +1,3 @@ -import ujson -import os import time import lvgl as lv import _thread @@ -7,29 +5,24 @@ from mpos.apps import Activity, Intent from mpos.ui.keyboard import MposKeyboard -import mpos.config -import mpos.ui.anim -import mpos.ui.theme +import mpos.apps from mpos.net.wifi_service import WifiService -have_network = True -try: - import network -except Exception as e: - have_network = False -# Global variables because they're used by multiple Activities: -access_points={} -last_tried_ssid = "" -last_tried_result = "" - -# This is basically the wifi settings app class WiFi(Activity): + """ + WiFi settings app for MicroPythonOS. + + This is a pure UI layer - all WiFi operations are delegated to WifiService. + """ + + last_tried_ssid = "" + last_tried_result = "" scan_button_scan_text = "Rescan" scan_button_scanning_text = "Scanning..." - ssids=[] + scanned_ssids = [] busy_scanning = False busy_connecting = False error_timer = None @@ -44,33 +37,38 @@ def onCreate(self): print("wifi.py onCreate") main_screen = lv.obj() main_screen.set_style_pad_all(15, 0) - print("create_ui: Creating list widget") - self.aplist=lv.list(main_screen) - self.aplist.set_size(lv.pct(100),lv.pct(75)) - self.aplist.align(lv.ALIGN.TOP_MID,0,0) - print("create_ui: Creating error label") - self.error_label=lv.label(main_screen) + self.aplist = lv.list(main_screen) + self.aplist.set_size(lv.pct(100), lv.pct(75)) + self.aplist.align(lv.ALIGN.TOP_MID, 0, 0) + self.error_label = lv.label(main_screen) self.error_label.set_text("THIS IS ERROR TEXT THAT WILL BE SET LATER") - self.error_label.align_to(self.aplist, lv.ALIGN.OUT_BOTTOM_MID,0,0) + self.error_label.align_to(self.aplist, lv.ALIGN.OUT_BOTTOM_MID, 0, 0) self.error_label.add_flag(lv.obj.FLAG.HIDDEN) - print("create_ui: Creating Scan button") - self.scan_button=lv.button(main_screen) - self.scan_button.set_size(lv.SIZE_CONTENT,lv.pct(15)) - self.scan_button.align(lv.ALIGN.BOTTOM_MID,0,0) - self.scan_button_label=lv.label(self.scan_button) + self.add_network_button = lv.button(main_screen) + self.add_network_button.set_size(lv.SIZE_CONTENT, lv.pct(15)) + self.add_network_button.align(lv.ALIGN.BOTTOM_LEFT, 0, 0) + self.add_network_button.add_event_cb(self.add_network_callback, lv.EVENT.CLICKED, None) + self.add_network_button_label = lv.label(self.add_network_button) + self.add_network_button_label.set_text("Add network") + self.add_network_button_label.center() + self.scan_button = lv.button(main_screen) + self.scan_button.set_size(lv.SIZE_CONTENT, lv.pct(15)) + self.scan_button.align(lv.ALIGN.BOTTOM_RIGHT, 0, 0) + self.scan_button.add_event_cb(self.scan_cb, lv.EVENT.CLICKED, None) + self.scan_button_label = lv.label(self.scan_button) self.scan_button_label.set_text(self.scan_button_scan_text) self.scan_button_label.center() - self.scan_button.add_event_cb(self.scan_cb,lv.EVENT.CLICKED,None) self.setContentView(main_screen) def onResume(self, screen): print("wifi.py onResume") super().onResume(screen) - global access_points - access_points = mpos.config.SharedPreferences("com.micropythonos.system.wifiservice").get_dict("access_points") - if len(self.ssids) == 0: - if WifiService.wifi_busy == False: - WifiService.wifi_busy = True + + # Ensure WifiService has loaded saved networks + WifiService.get_saved_networks() + + if len(self.scanned_ssids) == 0: + if not WifiService.is_busy(): self.start_scan_networks() else: self.show_error("Wifi is busy, please try again later.") @@ -80,35 +78,23 @@ def show_error(self, message): print(f"show_error: Displaying error: {message}") self.update_ui_threadsafe_if_foreground(self.error_label.set_text, message) self.update_ui_threadsafe_if_foreground(self.error_label.remove_flag, lv.obj.FLAG.HIDDEN) - self.error_timer = lv.timer_create(self.hide_error,5000,None) + self.error_timer = lv.timer_create(self.hide_error, 5000, None) self.error_timer.set_repeat_count(1) def hide_error(self, timer): - self.update_ui_threadsafe_if_foreground(self.error_label.add_flag,lv.obj.FLAG.HIDDEN) + self.update_ui_threadsafe_if_foreground(self.error_label.add_flag, lv.obj.FLAG.HIDDEN) def scan_networks_thread(self): - global have_network print("scan_networks: Scanning for Wi-Fi networks") - if have_network: - wlan=network.WLAN(network.STA_IF) - if not wlan.isconnected(): # restart WiFi hardware in case it's in a bad state - wlan.active(False) - wlan.active(True) try: - if have_network: - networks = wlan.scan() - self.ssids = list(set(n[0].decode() for n in networks)) - else: - time.sleep(1) - self.ssids = ["Home WiFi", "Pretty Fly for a Wi Fi", "Winternet is coming", "The Promised LAN"] - print(f"scan_networks: Found networks: {self.ssids}") + self.scanned_ssids = WifiService.scan_networks() + print(f"scan_networks: Found networks: {self.scanned_ssids}") except Exception as e: print(f"scan_networks: Scan failed: {e}") self.show_error("Wi-Fi scan failed") - # scan done: + # scan done - WifiService.scan_networks() manages wifi_busy flag internally self.busy_scanning = False - WifiService.wifi_busy = False - self.update_ui_threadsafe_if_foreground(self.scan_button_label.set_text,self.scan_button_scan_text) + self.update_ui_threadsafe_if_foreground(self.scan_button_label.set_text, self.scan_button_scan_text) self.update_ui_threadsafe_if_foreground(self.scan_button.remove_state, lv.STATE.DISABLED) self.update_ui_threadsafe_if_foreground(self.refresh_list) @@ -123,181 +109,213 @@ def start_scan_networks(self): _thread.start_new_thread(self.scan_networks_thread, ()) def refresh_list(self): - global have_network print("refresh_list: Clearing current list") - self.aplist.clean() # this causes an issue with lost taps if an ssid is clicked that has been removed + self.aplist.clean() # this causes an issue with lost taps if an ssid is clicked that has been removed print("refresh_list: Populating list with scanned networks") - for ssid in self.ssids: + + # Combine scanned SSIDs with saved networks + saved_networks = WifiService.get_saved_networks() + all_ssids = set(self.scanned_ssids + saved_networks) + + for ssid in all_ssids: if len(ssid) < 1 or len(ssid) > 32: print(f"Skipping too short or long SSID: {ssid}") continue print(f"refresh_list: Adding SSID: {ssid}") - button=self.aplist.add_button(None,ssid) - button.add_event_cb(lambda e, s=ssid: self.select_ssid_cb(s),lv.EVENT.CLICKED,None) + button = self.aplist.add_button(None, ssid) + button.add_event_cb(lambda e, s=ssid: self.select_ssid_cb(s), lv.EVENT.CLICKED, None) + + # Determine status status = "" - if have_network: - wlan=network.WLAN(network.STA_IF) - if wlan.isconnected() and wlan.config('essid')==ssid: - status="connected" - if status != "connected": - if last_tried_ssid == ssid: # implies not connected because not wlan.isconnected() - status=last_tried_result - elif ssid in access_points: - status="saved" - label=lv.label(button) + current_ssid = WifiService.get_current_ssid() + if current_ssid == ssid: + status = "connected" + elif self.last_tried_ssid == ssid: + # Show last connection attempt result + status = self.last_tried_result + elif ssid in saved_networks: + status = "saved" + + label = lv.label(button) label.set_text(status) - label.align(lv.ALIGN.RIGHT_MID,0,0) + label.align(lv.ALIGN.RIGHT_MID, 0, 0) + + def add_network_callback(self, event): + print(f"add_network_callback clicked") + intent = Intent(activity_class=EditNetwork) + intent.putExtra("selected_ssid", None) + self.startActivityForResult(intent, self.edit_network_result_callback) def scan_cb(self, event): print("scan_cb: Scan button clicked, refreshing list") self.start_scan_networks() - def select_ssid_cb(self,ssid): + def select_ssid_cb(self, ssid): print(f"select_ssid_cb: SSID selected: {ssid}") - intent = Intent(activity_class=PasswordPage) + intent = Intent(activity_class=EditNetwork) intent.putExtra("selected_ssid", ssid) - self.startActivityForResult(intent, self.password_page_result_cb) - - def password_page_result_cb(self, result): - print(f"PasswordPage finished, result: {result}") + intent.putExtra("known_password", WifiService.get_network_password(ssid)) + self.startActivityForResult(intent, self.edit_network_result_callback) + + def edit_network_result_callback(self, result): + print(f"EditNetwork finished, result: {result}") if result.get("result_code") is True: data = result.get("data") if data: - self.start_attempt_connecting(data.get("ssid"), data.get("password")) + ssid = data.get("ssid") + forget = data.get("forget") + if forget: + WifiService.forget_network(ssid) + self.refresh_list() + else: + # Save or update the network + password = data.get("password") + hidden = data.get("hidden") + WifiService.save_network(ssid, password, hidden) + self.start_attempt_connecting(ssid, password) def start_attempt_connecting(self, ssid, password): print(f"start_attempt_connecting: Attempting to connect to SSID '{ssid}' with password '{password}'") self.scan_button.add_state(lv.STATE.DISABLED) - self.scan_button_label.set_text(f"Connecting to '{ssid}'") + self.scan_button_label.set_text("Connecting...") if self.busy_connecting: print("Not attempting connect because busy_connecting.") else: self.busy_connecting = True _thread.stack_size(mpos.apps.good_stack_size()) - _thread.start_new_thread(self.attempt_connecting_thread, (ssid,password)) + _thread.start_new_thread(self.attempt_connecting_thread, (ssid, password)) def attempt_connecting_thread(self, ssid, password): - global last_tried_ssid, last_tried_result, have_network - print(f"attempt_connecting_thread: Attempting to connect to SSID '{ssid}' with password '{password}'") - result="connected" + print(f"attempt_connecting_thread: Attempting to connect to SSID '{ssid}'") + result = "connected" try: - if have_network: - wlan=network.WLAN(network.STA_IF) - wlan.disconnect() - wlan.connect(ssid,password) - for i in range(10): - if wlan.isconnected(): - print(f"attempt_connecting: Connected to {ssid} after {i+1} seconds") - break - print(f"attempt_connecting: Waiting for connection, attempt {i+1}/10") - time.sleep(1) - if not wlan.isconnected(): - result="timeout" + if WifiService.attempt_connecting(ssid, password): + result = "connected" else: - print("Warning: not trying to connect because not have_network, just waiting a bit...") - time.sleep(5) + result = "timeout" except Exception as e: print(f"attempt_connecting: Connection error: {e}") - result=f"{e}" - self.show_error("Connecting to {ssid} failed!") + result = f"{e}" + self.show_error(f"Connecting to {ssid} failed!") + print(f"Connecting to {ssid} got result: {result}") - last_tried_ssid = ssid - last_tried_result = result - # also do a time sync, otherwise some apps (Nostr Wallet Connect) won't work: - if have_network and wlan.isconnected(): - mpos.time.sync_time() - self.busy_connecting=False + self.last_tried_ssid = ssid + self.last_tried_result = result + + # Note: Time sync is handled by WifiService.attempt_connecting() + + self.busy_connecting = False self.update_ui_threadsafe_if_foreground(self.scan_button_label.set_text, self.scan_button_scan_text) self.update_ui_threadsafe_if_foreground(self.scan_button.remove_state, lv.STATE.DISABLED) self.update_ui_threadsafe_if_foreground(self.refresh_list) - -class PasswordPage(Activity): - # Would be good to add some validation here so the password is not too short etc... +class EditNetwork(Activity): selected_ssid = None # Widgets: - password_ta=None - keyboard=None - connect_button=None - cancel_button=None + ssid_ta = None + password_ta = None + hidden_cb = None + keyboard = None + connect_button = None + cancel_button = None def onCreate(self): + password_page = lv.obj() + password_page.set_style_pad_all(0, lv.PART.MAIN) + password_page.set_flex_flow(lv.FLEX_FLOW.COLUMN) self.selected_ssid = self.getIntent().extras.get("selected_ssid") - print("PasswordPage: Creating new password page") - password_page=lv.obj() - print(f"show_password_page: Creating label for SSID: {self.selected_ssid}") - label=lv.label(password_page) - label.set_text(f"Password for: {self.selected_ssid}") - label.align(lv.ALIGN.TOP_MID,0,5) - print("PasswordPage: Creating password textarea") - self.password_ta=lv.textarea(password_page) + known_password = self.getIntent().extras.get("known_password") + + # SSID: + if self.selected_ssid is None: + print("No ssid selected, the user should fill it out.") + label = lv.label(password_page) + label.set_text(f"Network name:") + self.ssid_ta = lv.textarea(password_page) + self.ssid_ta.set_width(lv.pct(90)) + self.ssid_ta.set_style_margin_left(5, lv.PART.MAIN) + self.ssid_ta.set_one_line(True) + self.ssid_ta.set_placeholder_text("Enter the SSID") + self.keyboard = MposKeyboard(password_page) + self.keyboard.set_textarea(self.ssid_ta) + self.keyboard.add_flag(lv.obj.FLAG.HIDDEN) + + # Password: + label = lv.label(password_page) + if self.selected_ssid is None: + label.set_text("Password:") + else: + label.set_text(f"Password for '{self.selected_ssid}':") + self.password_ta = lv.textarea(password_page) self.password_ta.set_width(lv.pct(90)) + self.password_ta.set_style_margin_left(5, lv.PART.MAIN) self.password_ta.set_one_line(True) - self.password_ta.align_to(label, lv.ALIGN.OUT_BOTTOM_MID, 0, 5) - print("PasswordPage: Creating Connect button") - self.connect_button=lv.button(password_page) - self.connect_button.set_size(100,40) - self.connect_button.align(lv.ALIGN.BOTTOM_LEFT,10,-40) - self.connect_button.add_event_cb(self.connect_cb,lv.EVENT.CLICKED,None) - label=lv.label(self.connect_button) - label.set_text("Connect") - label.center() - print("PasswordPage: Creating Cancel button") - self.cancel_button=lv.button(password_page) - self.cancel_button.set_size(100,40) - self.cancel_button.align(lv.ALIGN.BOTTOM_RIGHT,-10,-40) - self.cancel_button.add_event_cb(self.cancel_cb,lv.EVENT.CLICKED,None) - label=lv.label(self.cancel_button) - label.set_text("Close") - label.center() - pwd = self.findSavedPassword(self.selected_ssid) - if pwd: - self.password_ta.set_text(pwd) + if known_password: + self.password_ta.set_text(known_password) self.password_ta.set_placeholder_text("Password") - print("PasswordPage: Creating keyboard (hidden by default)") - self.keyboard=MposKeyboard(password_page) - self.keyboard.align(lv.ALIGN.BOTTOM_MID,0,0) + self.keyboard = MposKeyboard(password_page) self.keyboard.set_textarea(self.password_ta) self.keyboard.add_flag(lv.obj.FLAG.HIDDEN) - print("PasswordPage: Loading password page") + + # Hidden network: + self.hidden_cb = lv.checkbox(password_page) + self.hidden_cb.set_text("Hidden network (always try connecting)") + self.hidden_cb.set_style_margin_left(5, lv.PART.MAIN) + + # Action buttons: + buttons = lv.obj(password_page) + buttons.set_width(lv.pct(100)) + buttons.set_height(lv.SIZE_CONTENT) + buttons.set_style_bg_opa(lv.OPA.TRANSP, 0) + buttons.set_style_border_width(0, lv.PART.MAIN) + # Delete button + if self.selected_ssid: + self.forget_button = lv.button(buttons) + self.forget_button.align(lv.ALIGN.LEFT_MID, 0, 0) + self.forget_button.add_event_cb(self.forget_cb, lv.EVENT.CLICKED, None) + label = lv.label(self.forget_button) + label.set_text("Forget") + label.center() + # Close button + self.cancel_button = lv.button(buttons) + self.cancel_button.center() + self.cancel_button.add_event_cb(lambda *args: self.finish(), lv.EVENT.CLICKED, None) + label = lv.label(self.cancel_button) + label.set_text("Close") + label.center() + # Connect button + self.connect_button = lv.button(buttons) + self.connect_button.align(lv.ALIGN.RIGHT_MID, 0, 0) + self.connect_button.add_event_cb(self.connect_cb, lv.EVENT.CLICKED, None) + label = lv.label(self.connect_button) + label.set_text("Connect") + label.center() + self.setContentView(password_page) def connect_cb(self, event): - global access_points - print("connect_cb: Connect button clicked") - password=self.password_ta.get_text() - print(f"connect_cb: Got password: {password}") - self.setPassword(self.selected_ssid, password) - print(f"connect_cb: Updated access_points: {access_points}") - editor = mpos.config.SharedPreferences("com.micropythonos.system.wifiservice").edit() - editor.put_dict("access_points", access_points) - editor.commit() - self.setResult(True, {"ssid": self.selected_ssid, "password": password}) - print("connect_cb: Restoring main_screen") - self.finish() - - def cancel_cb(self, event): - print("cancel_cb: Cancel button clicked") + # Validate the form + if self.selected_ssid is None: + new_ssid = self.ssid_ta.get_text() + if not new_ssid: + self.ssid_ta.set_style_bg_color(lv.color_hex(0xff8080), 0) + return + else: + self.selected_ssid = new_ssid + # If a password is filled, then it should be at least 8 characters: + pwd = self.password_ta.get_text() + if len(pwd) > 0 and len(pwd) < 8: + self.password_ta.set_style_bg_color(lv.color_hex(0xff8080), 0) + return + + # Return the result + hidden_checked = True if self.hidden_cb.get_state() & lv.STATE.CHECKED else False + self.setResult(True, {"ssid": self.selected_ssid, "password": pwd, "hidden": hidden_checked}) self.finish() - @staticmethod - def setPassword(ssid, password): - global access_points - ap = access_points.get(ssid) - if ap: - ap["password"] = password - return - # if not found, then add it: - access_points[ssid] = { "password": password } - - @staticmethod - def findSavedPassword(ssid): - if not access_points: - return None - ap = access_points.get(ssid) - if ap: - return ap.get("password") - return None + def forget_cb(self, event): + self.setResult(True, {"ssid": self.selected_ssid, "forget": True}) + self.finish() 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 a66102ec..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 diff --git a/internal_filesystem/lib/mpos/audio/__init__.py b/internal_filesystem/lib/mpos/audio/__init__.py index 86526aa9..37be5058 100644 --- a/internal_filesystem/lib/mpos/audio/__init__.py +++ b/internal_filesystem/lib/mpos/audio/__init__.py @@ -1,22 +1,17 @@ # 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 ( - # Device types - DEVICE_NULL, - DEVICE_I2S, - DEVICE_BUZZER, - DEVICE_BOTH, - - # Stream types + # Stream types (for priority-based audio focus) STREAM_MUSIC, STREAM_NOTIFICATION, STREAM_ALARM, - # Core functions + # Core playback functions init, play_wav, play_rtttl, @@ -25,23 +20,25 @@ resume, set_volume, get_volume, - get_device_type, is_playing, + + # Recording functions + record_wav, + is_recording, + + # Hardware availability checks + has_i2s, + has_buzzer, + has_microphone, ) __all__ = [ - # Device types - 'DEVICE_NULL', - 'DEVICE_I2S', - 'DEVICE_BUZZER', - 'DEVICE_BOTH', - # Stream types 'STREAM_MUSIC', 'STREAM_NOTIFICATION', 'STREAM_ALARM', - # Functions + # Playback functions 'init', 'play_wav', 'play_rtttl', @@ -50,6 +47,14 @@ 'resume', 'set_volume', 'get_volume', - 'get_device_type', '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 index 47dfcd98..031c3956 100644 --- a/internal_filesystem/lib/mpos/audio/audioflinger.py +++ b/internal_filesystem/lib/mpos/audio/audioflinger.py @@ -1,12 +1,12 @@ # 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) -# Device type constants -DEVICE_NULL = 0 # No audio hardware (desktop fallback) -DEVICE_I2S = 1 # Digital audio output (WAV playback) -DEVICE_BUZZER = 2 # PWM buzzer (tones/RTTTL) -DEVICE_BOTH = 3 # Both I2S and buzzer available +import _thread +import mpos.apps # Stream type constants (priority order: higher number = higher priority) STREAM_MUSIC = 0 # Background music (lowest priority) @@ -14,45 +14,52 @@ STREAM_ALARM = 2 # Alarms/alerts (highest priority) # Module-level state (singleton pattern, follows battery_voltage.py) -_device_type = DEVICE_NULL _i2s_pins = None # I2S pin configuration dict (created per-stream) _buzzer_instance = None # PWM buzzer instance _current_stream = None # Currently playing stream -_volume = 70 # System volume (0-100) -_stream_lock = None # Thread lock for stream management +_current_recording = None # Currently recording stream +_volume = 50 # System volume (0-100) -def init(device_type, i2s_pins=None, buzzer_instance=None): +def init(i2s_pins=None, buzzer_instance=None): """ Initialize AudioFlinger with hardware configuration. Args: - device_type: One of DEVICE_NULL, DEVICE_I2S, DEVICE_BUZZER, DEVICE_BOTH - i2s_pins: Dict with 'sck', 'ws', 'sd' pin numbers (for I2S devices) - buzzer_instance: PWM instance for buzzer (for buzzer devices) + i2s_pins: Dict with 'sck', 'ws', 'sd' pin numbers (for I2S/WAV playback) + buzzer_instance: PWM instance for buzzer (for RTTTL playback) """ - global _device_type, _i2s_pins, _buzzer_instance, _stream_lock + global _i2s_pins, _buzzer_instance - _device_type = device_type _i2s_pins = i2s_pins _buzzer_instance = buzzer_instance - # Initialize thread lock for stream management - try: - import _thread - _stream_lock = _thread.allocate_lock() - except ImportError: - # Desktop mode - no threading support - _stream_lock = None + # 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 + - device_names = { - DEVICE_NULL: "NULL (no audio)", - DEVICE_I2S: "I2S (digital audio)", - DEVICE_BUZZER: "Buzzer (PWM tones)", - DEVICE_BOTH: "Both (I2S + Buzzer)" - } +def has_buzzer(): + """Check if buzzer is available for RTTTL playback.""" + return _buzzer_instance is not None - print(f"AudioFlinger initialized: {device_names.get(device_type, 'Unknown')}") + +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): @@ -87,33 +94,25 @@ def _check_audio_focus(stream_type): def _playback_thread(stream): """ - Background thread function for audio playback. + 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 - # Acquire lock and set as current stream - if _stream_lock: - _stream_lock.acquire() _current_stream = stream - if _stream_lock: - _stream_lock.release() try: - # Run playback (blocks until complete or stopped) + # Run synchronous playback in this thread stream.play() except Exception as e: print(f"AudioFlinger: Playback error: {e}") finally: # Clear current stream - if _stream_lock: - _stream_lock.acquire() if _current_stream == stream: _current_stream = None - if _stream_lock: - _stream_lock.release() def play_wav(file_path, stream_type=STREAM_MUSIC, volume=None, on_complete=None): @@ -129,29 +128,17 @@ def play_wav(file_path, stream_type=STREAM_MUSIC, volume=None, on_complete=None) Returns: bool: True if playback started, False if rejected or unavailable """ - if _device_type not in (DEVICE_I2S, DEVICE_BOTH): - print("AudioFlinger: play_wav() failed - no I2S device available") - return False - if not _i2s_pins: - print("AudioFlinger: play_wav() failed - I2S pins not configured") + print("AudioFlinger: play_wav() failed - I2S not configured") return False # Check audio focus - if _stream_lock: - _stream_lock.acquire() - can_start = _check_audio_focus(stream_type) - if _stream_lock: - _stream_lock.release() - - if not can_start: + if not _check_audio_focus(stream_type): return False - # Create stream and start playback in background thread + # Create stream and start playback in separate thread try: from mpos.audio.stream_wav import WAVStream - import _thread - import mpos.apps stream = WAVStream( file_path=file_path, @@ -183,29 +170,17 @@ def play_rtttl(rtttl_string, stream_type=STREAM_NOTIFICATION, volume=None, on_co Returns: bool: True if playback started, False if rejected or unavailable """ - if _device_type not in (DEVICE_BUZZER, DEVICE_BOTH): - print("AudioFlinger: play_rtttl() failed - no buzzer device available") - return False - if not _buzzer_instance: - print("AudioFlinger: play_rtttl() failed - buzzer not initialized") + print("AudioFlinger: play_rtttl() failed - buzzer not configured") return False # Check audio focus - if _stream_lock: - _stream_lock.acquire() - can_start = _check_audio_focus(stream_type) - if _stream_lock: - _stream_lock.release() - - if not can_start: + if not _check_audio_focus(stream_type): return False - # Create stream and start playback in background thread + # Create stream and start playback in separate thread try: from mpos.audio.stream_rtttl import RTTTLStream - import _thread - import mpos.apps stream = RTTTLStream( rtttl_string=rtttl_string, @@ -224,21 +199,108 @@ def play_rtttl(rtttl_string, stream_type=STREAM_NOTIFICATION, volume=None, on_co 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.""" - global _current_stream + """Stop current audio playback or recording.""" + global _current_stream, _current_recording - if _stream_lock: - _stream_lock.acquire() + stopped = False if _current_stream: _current_stream.stop() print("AudioFlinger: Playback stopped") - else: - print("AudioFlinger: No playback to stop") + stopped = True - if _stream_lock: - _stream_lock.release() + 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(): @@ -246,40 +308,24 @@ def pause(): Pause current audio playback (if supported by stream). Note: Most streams don't support pause, only stop. """ - global _current_stream - - if _stream_lock: - _stream_lock.acquire() - if _current_stream and hasattr(_current_stream, 'pause'): _current_stream.pause() print("AudioFlinger: Playback paused") else: print("AudioFlinger: Pause not supported or no playback active") - if _stream_lock: - _stream_lock.release() - def resume(): """ Resume paused audio playback (if supported by stream). Note: Most streams don't support resume, only play. """ - global _current_stream - - if _stream_lock: - _stream_lock.acquire() - if _current_stream and hasattr(_current_stream, 'resume'): _current_stream.resume() print("AudioFlinger: Playback resumed") else: print("AudioFlinger: Resume not supported or no playback active") - if _stream_lock: - _stream_lock.release() - def set_volume(volume): """ @@ -290,6 +336,8 @@ def set_volume(volume): """ global _volume _volume = max(0, min(100, volume)) + if _current_stream: + _current_stream.set_volume(_volume) def get_volume(): @@ -302,16 +350,6 @@ def get_volume(): return _volume -def get_device_type(): - """ - Get configured audio device type. - - Returns: - int: Device type (DEVICE_NULL, DEVICE_I2S, DEVICE_BUZZER, DEVICE_BOTH) - """ - return _device_type - - def is_playing(): """ Check if audio is currently playing. @@ -319,12 +357,14 @@ def is_playing(): Returns: bool: True if playback active, False otherwise """ - if _stream_lock: - _stream_lock.acquire() + return _current_stream is not None and _current_stream.is_playing() - result = _current_stream is not None and _current_stream.is_playing() - if _stream_lock: - _stream_lock.release() +def is_recording(): + """ + Check if audio is currently being recorded. - return result + 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 index 00bae756..d02761f5 100644 --- a/internal_filesystem/lib/mpos/audio/stream_rtttl.py +++ b/internal_filesystem/lib/mpos/audio/stream_rtttl.py @@ -1,6 +1,6 @@ # RTTTLStream - RTTTL Ringtone Playback Stream for AudioFlinger # Ring Tone Text Transfer Language parser and player -# Ported from Fri3d Camp 2024 Badge firmware +# Uses synchronous playback in a separate thread for non-blocking operation import math import time @@ -180,7 +180,7 @@ def _notes(self): yield freq, msec def play(self): - """Play RTTTL tune via buzzer (runs in background thread).""" + """Play RTTTL tune via buzzer (runs in separate thread).""" self._is_playing = True # Calculate exponential duty cycle for perceptually linear volume @@ -212,6 +212,7 @@ def play(self): self.buzzer.duty_u16(duty) # Play for 90% of duration, silent for 10% (note separation) + # Blocking sleep is OK - we're in a separate thread time.sleep_ms(int(msec * 0.9)) self.buzzer.duty_u16(0) time.sleep_ms(int(msec * 0.1)) @@ -229,3 +230,6 @@ def play(self): # Ensure buzzer is off self.buzzer.duty_u16(0) self._is_playing = False + + def set_volume(self, vol): + self.volume = vol diff --git a/internal_filesystem/lib/mpos/audio/stream_wav.py b/internal_filesystem/lib/mpos/audio/stream_wav.py index 884d936f..10e4801a 100644 --- a/internal_filesystem/lib/mpos/audio/stream_wav.py +++ b/internal_filesystem/lib/mpos/audio/stream_wav.py @@ -1,16 +1,16 @@ # WAVStream - WAV File Playback Stream for AudioFlinger # Supports 8/16/24/32-bit PCM, mono+stereo, auto-upsampling, volume control -# Ported from MusicPlayer's AudioPlayer class +# Uses synchronous playback in a separate thread for non-blocking operation import machine +import micropython import os -import time import sys +import time # Volume scaling function - Viper-optimized for ESP32 performance # NOTE: The line below is automatically commented out by build_mpos.sh during # Unix/macOS builds (cross-compiler doesn't support Viper), then uncommented after build. -import micropython @micropython.viper def _scale_audio(buf: ptr8, num_bytes: int, scale_fixed: int): """Fast volume scaling for 16-bit audio samples using Viper (ESP32 native code emitter).""" @@ -28,6 +28,118 @@ def _scale_audio(buf: ptr8, num_bytes: int, scale_fixed: int): buf[i] = sample & 255 buf[i + 1] = (sample >> 8) & 255 +@micropython.viper +def _scale_audio_optimized(buf: ptr8, num_bytes: int, scale_fixed: int): + if scale_fixed >= 32768: + return + if scale_fixed <= 0: + for i in range(num_bytes): + buf[i] = 0 + return + + mask: int = scale_fixed + + for i in range(0, num_bytes, 2): + s: int = int(buf[i]) | (int(buf[i+1]) << 8) + if s >= 0x8000: + s -= 0x10000 + + r: int = 0 + if mask & 0x8000: r += s + if mask & 0x4000: r += s>>1 + if mask & 0x2000: r += s>>2 + if mask & 0x1000: r += s>>3 + if mask & 0x0800: r += s>>4 + if mask & 0x0400: r += s>>5 + if mask & 0x0200: r += s>>6 + if mask & 0x0100: r += s>>7 + if mask & 0x0080: r += s>>8 + if mask & 0x0040: r += s>>9 + if mask & 0x0020: r += s>>10 + if mask & 0x0010: r += s>>11 + if mask & 0x0008: r += s>>12 + if mask & 0x0004: r += s>>13 + if mask & 0x0002: r += s>>14 + if mask & 0x0001: r += s>>15 + + if r > 32767: r = 32767 + if r < -32768: r = -32768 + + buf[i] = r & 0xFF + buf[i+1] = (r >> 8) & 0xFF + +@micropython.viper +def _scale_audio_rough(buf: ptr8, num_bytes: int, scale_fixed: int): + """Rough volume scaling for 16-bit audio samples using right shifts for performance.""" + if scale_fixed >= 32768: + return + + # Determine the shift amount + shift: int = 0 + threshold: int = 32768 + while shift < 16 and scale_fixed < threshold: + shift += 1 + threshold >>= 1 + + # If shift is 16 or more, set buffer to zero (volume too low) + if shift >= 16: + for i in range(num_bytes): + buf[i] = 0 + return + + # Apply right shift to each 16-bit sample + for i in range(0, num_bytes, 2): + lo: int = int(buf[i]) + hi: int = int(buf[i + 1]) + sample: int = (hi << 8) | lo + if hi & 128: + sample -= 65536 + sample >>= shift + buf[i] = sample & 255 + buf[i + 1] = (sample >> 8) & 255 + +@micropython.viper +def _scale_audio_shift(buf: ptr8, num_bytes: int, shift: int): + """Rough volume scaling for 16-bit audio samples using right shifts for performance.""" + if shift <= 0: + return + + # If shift is 16 or more, set buffer to zero (volume too low) + if shift >= 16: + for i in range(num_bytes): + buf[i] = 0 + return + + # Apply right shift to each 16-bit sample + for i in range(0, num_bytes, 2): + lo: int = int(buf[i]) + hi: int = int(buf[i + 1]) + sample: int = (hi << 8) | lo + if hi & 128: + sample -= 65536 + sample >>= shift + buf[i] = sample & 255 + buf[i + 1] = (sample >> 8) & 255 + +@micropython.viper +def _scale_audio_powers_of_2(buf: ptr8, num_bytes: int, shift: int): + if shift <= 0: + return + if shift >= 16: + for i in range(num_bytes): + buf[i] = 0 + return + + # Unroll the sign-extend + shift into one tight loop with no inner branch + inv_shift: int = 16 - shift + for i in range(0, num_bytes, 2): + s: int = int(buf[i]) | (int(buf[i+1]) << 8) + if s & 0x8000: # only one branch, highly predictable when shift fixed shift + s |= -65536 # sign extend using OR (faster than subtract!) + s <<= inv_shift # bring the bits we want into lower 16 + s >>= 16 # arithmetic shift right by 'shift' amount + buf[i] = s & 0xFF + buf[i+1] = (s >> 8) & 0xFF class WAVStream: """ @@ -202,7 +314,7 @@ def _upsample_buffer(raw, factor): # Main playback routine # ---------------------------------------------------------------------- def play(self): - """Main playback routine (runs in background thread).""" + """Main synchronous playback routine (runs in separate thread).""" self._is_playing = True try: @@ -215,7 +327,7 @@ def play(self): data_start, data_size, original_rate, channels, bits_per_sample = \ self._find_data_chunk(f) - # Decide playback rate (force >=22050 Hz) + # 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 @@ -251,7 +363,11 @@ def play(self): print(f"WAVStream: Playing {data_size} bytes (volume {self.volume}%)") f.seek(data_start) - chunk_size = 4096 + # 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 @@ -287,9 +403,9 @@ def play(self): scale = self.volume / 100.0 if scale < 1.0: scale_fixed = int(scale * 32768) - _scale_audio(raw, len(raw), scale_fixed) + _scale_audio_optimized(raw, len(raw), scale_fixed) - # 4. Output to I2S + # 4. Output to I2S (blocking write is OK - we're in a separate thread) if self._i2s: self._i2s.write(raw) else: @@ -313,3 +429,6 @@ def play(self): 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 ca284272..6e0c8d57 100644 --- a/internal_filesystem/lib/mpos/battery_voltage.py +++ b/internal_filesystem/lib/mpos/battery_voltage.py @@ -48,7 +48,7 @@ def init_adc(pinnr, adc_to_voltage_func): print(f"Info: this platform has no ADC for measuring battery voltage: {e}") initial_adc_value = read_raw_adc() - print("Reading ADC at init to fill cache: {initial_adc_value} => {read_battery_voltage(raw_adc_value=initial_adc_value)}V => {get_battery_percentage(raw_adc_value=initial_adc_value)}%") + print(f"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): diff --git a/internal_filesystem/lib/mpos/board/fri3d_2024.py b/internal_filesystem/lib/mpos/board/fri3d_2024.py index 88f7e131..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() @@ -296,17 +296,22 @@ def adc_to_voltage(adc_value): # Initialize buzzer (GPIO 46) buzzer = PWM(Pin(46), freq=550, duty=0) -# I2S pin configuration (GPIO 2, 47, 16) +# I2S pin configuration for audio output (DAC) and input (microphone) # Note: I2S is created per-stream, not at boot (only one instance can exist) +# 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 = { - 'sck': 2, - 'ws': 47, - 'sd': 16, + # 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 (both I2S and buzzer available) +# Initialize AudioFlinger with I2S and buzzer AudioFlinger.init( - device_type=AudioFlinger.DEVICE_BOTH, i2s_pins=i2s_pins, buzzer_instance=buzzer ) @@ -386,4 +391,4 @@ def startup_wow_effect(): _thread.stack_size(mpos.apps.good_stack_size()) # default stack size won't work, crashes! _thread.start_new_thread(startup_wow_effect, ()) -print("boot.py finished") +print("fri3d_2024.py finished") diff --git a/internal_filesystem/lib/mpos/board/linux.py b/internal_filesystem/lib/mpos/board/linux.py index 0b055568..9522344c 100644 --- a/internal_filesystem/lib/mpos/board/linux.py +++ b/internal_filesystem/lib/mpos/board/linux.py @@ -98,13 +98,17 @@ def adc_to_voltage(adc_value): # === AUDIO HARDWARE === import mpos.audio.audioflinger as AudioFlinger -# Note: Desktop builds have no audio hardware -# AudioFlinger functions will return False (no-op) -AudioFlinger.init( - device_type=AudioFlinger.DEVICE_NULL, - i2s_pins=None, - buzzer_instance=None -) +# 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 @@ -116,7 +120,7 @@ def adc_to_voltage(adc_value): # Initialize with no I2C bus - will detect MCU temp if available # (On Linux desktop, this will fail gracefully but set _initialized flag) -SensorManager.init(None, mounted_position=SensorManager.FACING_EARTH) +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 096e64c9..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) @@ -113,14 +113,8 @@ def adc_to_voltage(adc_value): # === AUDIO HARDWARE === import mpos.audio.audioflinger as AudioFlinger -# Note: Waveshare board has no buzzer or LEDs, only I2S audio -# I2S pin configuration will be determined by the board's audio hardware -# For now, initialize with I2S only (pins will be configured per-stream if available) -AudioFlinger.init( - device_type=AudioFlinger.DEVICE_I2S, - i2s_pins={'sck': 2, 'ws': 47, 'sd': 16}, # Default ESP32-S3 I2S pins - buzzer_instance=None -) +# Note: Waveshare board has no buzzer or I2S audio +AudioFlinger.init() # === LED HARDWARE === # Note: Waveshare board has no NeoPixel LEDs @@ -131,6 +125,6 @@ def adc_to_voltage(adc_value): # 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) +SensorManager.init(i2c_bus, address=0x6B, mounted_position=SensorManager.FACING_EARTH) -print("boot.py finished") +print("waveshare_esp32_s3_touch_lcd_2.py finished") diff --git a/internal_filesystem/lib/mpos/hardware/drivers/wsen_isds.py b/internal_filesystem/lib/mpos/hardware/drivers/wsen_isds.py index 97cf7d00..7f6f7be9 100644 --- a/internal_filesystem/lib/mpos/hardware/drivers/wsen_isds.py +++ b/internal_filesystem/lib/mpos/hardware/drivers/wsen_isds.py @@ -35,6 +35,8 @@ class Wsen_Isds: _ISDS_STATUS_REG = 0x1E # Status data register _ISDS_WHO_AM_I = 0x0F # WHO_AM_I register + _REG_TEMP_OUT_L = 0x20 + _REG_G_X_OUT_L = 0x22 _REG_G_Y_OUT_L = 0x24 _REG_G_Z_OUT_L = 0x26 @@ -131,24 +133,12 @@ def __init__(self, i2c, address=0x6B, acc_range="2g", acc_data_rate="1.6Hz", self.i2c = i2c self.address = address - self.acc_offset_x = 0 - self.acc_offset_y = 0 - self.acc_offset_z = 0 self.acc_range = 0 self.acc_sensitivity = 0 - self.gyro_offset_x = 0 - self.gyro_offset_y = 0 - self.gyro_offset_z = 0 self.gyro_range = 0 self.gyro_sensitivity = 0 - self.ACC_NUM_SAMPLES_CALIBRATION = 5 - self.ACC_CALIBRATION_DELAY_MS = 10 - - self.GYRO_NUM_SAMPLES_CALIBRATION = 5 - self.GYRO_CALIBRATION_DELAY_MS = 10 - self.set_acc_range(acc_range) self.set_acc_data_rate(acc_data_rate) @@ -252,30 +242,6 @@ def set_interrupt(self, interrupts_enable=False, inact_en=False, slope_fds=False self._write_option('tap_double_to_int0', 1) self._write_option('int1_on_int0', 1) - def acc_calibrate(self, samples=None): - """Calibrate accelerometer by averaging samples while device is stationary. - - Args: - samples: Number of samples to average (default: ACC_NUM_SAMPLES_CALIBRATION) - """ - if samples is None: - samples = self.ACC_NUM_SAMPLES_CALIBRATION - - self.acc_offset_x = 0 - self.acc_offset_y = 0 - self.acc_offset_z = 0 - - for _ in range(samples): - x, y, z = self._read_raw_accelerations() - self.acc_offset_x += x - self.acc_offset_y += y - self.acc_offset_z += z - time.sleep_ms(self.ACC_CALIBRATION_DELAY_MS) - - self.acc_offset_x //= samples - self.acc_offset_y //= samples - self.acc_offset_z //= samples - def _acc_calc_sensitivity(self): """Calculate accelerometer sensitivity based on range (in mg/digit).""" sensitivity_mapping = { @@ -289,20 +255,6 @@ def _acc_calc_sensitivity(self): else: print("Invalid range value:", self.acc_range) - def read_accelerations(self): - """Read calibrated accelerometer data. - - Returns: - Tuple (x, y, z) in mg (milligrams) - """ - raw_a_x, raw_a_y, raw_a_z = self._read_raw_accelerations() - - a_x = (raw_a_x - self.acc_offset_x) * self.acc_sensitivity - a_y = (raw_a_y - self.acc_offset_y) * self.acc_sensitivity - a_z = (raw_a_z - self.acc_offset_z) * self.acc_sensitivity - - return a_x, a_y, a_z - def _read_raw_accelerations(self): """Read raw accelerometer data.""" if not self._acc_data_ready(): @@ -314,45 +266,22 @@ def _read_raw_accelerations(self): raw_a_y = self._convert_from_raw(raw[2], raw[3]) raw_a_z = self._convert_from_raw(raw[4], raw[5]) - return raw_a_x, raw_a_y, raw_a_z + return raw_a_x * self.acc_sensitivity, raw_a_y * self.acc_sensitivity, raw_a_z * self.acc_sensitivity - def gyro_calibrate(self, samples=None): - """Calibrate gyroscope by averaging samples while device is stationary. - Args: - samples: Number of samples to average (default: GYRO_NUM_SAMPLES_CALIBRATION) - """ - if samples is None: - samples = self.GYRO_NUM_SAMPLES_CALIBRATION - - self.gyro_offset_x = 0 - self.gyro_offset_y = 0 - self.gyro_offset_z = 0 - - for _ in range(samples): - x, y, z = self._read_raw_angular_velocities() - self.gyro_offset_x += x - self.gyro_offset_y += y - self.gyro_offset_z += z - time.sleep_ms(self.GYRO_CALIBRATION_DELAY_MS) + @property + def temperature(self) -> float: + temp_raw = self._read_raw_temperature() + return ((temp_raw / 256.0) + 25.0) - self.gyro_offset_x //= samples - self.gyro_offset_y //= samples - self.gyro_offset_z //= samples + def _read_raw_temperature(self): + """Read raw temperature data.""" + if not self._temp_data_ready(): + raise Exception("temp sensor data not ready") - def read_angular_velocities(self): - """Read calibrated gyroscope data. - - Returns: - Tuple (x, y, z) in mdps (milli-degrees per second) - """ - raw_g_x, raw_g_y, raw_g_z = self._read_raw_angular_velocities() - - g_x = (raw_g_x - self.gyro_offset_x) * self.gyro_sensitivity - g_y = (raw_g_y - self.gyro_offset_y) * self.gyro_sensitivity - g_z = (raw_g_z - self.gyro_offset_z) * self.gyro_sensitivity - - return g_x, g_y, g_z + raw = self.i2c.readfrom_mem(self.address, Wsen_Isds._REG_TEMP_OUT_L, 2) + raw_temp = self._convert_from_raw(raw[0], raw[1]) + return raw_temp def _read_raw_angular_velocities(self): """Read raw gyroscope data.""" @@ -365,44 +294,7 @@ def _read_raw_angular_velocities(self): raw_g_y = self._convert_from_raw(raw[2], raw[3]) raw_g_z = self._convert_from_raw(raw[4], raw[5]) - return raw_g_x, raw_g_y, raw_g_z - - def read_angular_velocities_accelerations(self): - """Read both gyroscope and accelerometer in one call. - - Returns: - Tuple (gx, gy, gz, ax, ay, az) where gyro is in mdps, accel is in mg - """ - raw_g_x, raw_g_y, raw_g_z, raw_a_x, raw_a_y, raw_a_z = \ - self._read_raw_gyro_acc() - - g_x = (raw_g_x - self.gyro_offset_x) * self.gyro_sensitivity - g_y = (raw_g_y - self.gyro_offset_y) * self.gyro_sensitivity - g_z = (raw_g_z - self.gyro_offset_z) * self.gyro_sensitivity - - a_x = (raw_a_x - self.acc_offset_x) * self.acc_sensitivity - a_y = (raw_a_y - self.acc_offset_y) * self.acc_sensitivity - a_z = (raw_a_z - self.acc_offset_z) * self.acc_sensitivity - - return g_x, g_y, g_z, a_x, a_y, a_z - - def _read_raw_gyro_acc(self): - """Read raw gyroscope and accelerometer data in one call.""" - acc_data_ready, gyro_data_ready = self._acc_gyro_data_ready() - if not acc_data_ready or not gyro_data_ready: - raise Exception("sensor data not ready") - - raw = self.i2c.readfrom_mem(self.address, Wsen_Isds._REG_G_X_OUT_L, 12) - - raw_g_x = self._convert_from_raw(raw[0], raw[1]) - raw_g_y = self._convert_from_raw(raw[2], raw[3]) - raw_g_z = self._convert_from_raw(raw[4], raw[5]) - - raw_a_x = self._convert_from_raw(raw[6], raw[7]) - raw_a_y = self._convert_from_raw(raw[8], raw[9]) - raw_a_z = self._convert_from_raw(raw[10], raw[11]) - - return raw_g_x, raw_g_y, raw_g_z, raw_a_x, raw_a_y, raw_a_z + 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): @@ -420,6 +312,10 @@ def _gyro_data_ready(self): """Check if gyroscope data is ready.""" return self._get_status_reg()[1] + def _temp_data_ready(self): + """Check if accelerometer data is ready.""" + return self._get_status_reg()[2] + def _acc_gyro_data_ready(self): """Check if both accelerometer and gyroscope data are ready.""" status_reg = self._get_status_reg() diff --git a/internal_filesystem/lib/mpos/info.py b/internal_filesystem/lib/mpos/info.py index 22bb09cd..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.1" +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/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 25d777a7..e3f43184 100644 --- a/internal_filesystem/lib/mpos/net/wifi_service.py +++ b/internal_filesystem/lib/mpos/net/wifi_service.py @@ -36,17 +36,21 @@ class WifiService: """ # Class-level lock to prevent concurrent WiFi operations - # Used by WiFi app when scanning to avoid conflicts with connection attempts + # Use is_busy() to check state; operations like scan_networks() manage this automatically wifi_busy = False # Dictionary of saved access points {ssid: {password: "..."}} access_points = {} + # Desktop mode: simulated connected SSID (None = not connected) + _desktop_connected_ssid = None + @staticmethod def connect(network_module=None): """ Scan for available networks and connect to the first saved network found. Networks are tried in order of signal strength (strongest first). + Hidden networks are also tried even if they don't appear in the scan. Args: network_module: Network module for dependency injection (testing) @@ -54,23 +58,20 @@ def connect(network_module=None): Returns: bool: True if successfully connected, False otherwise """ - net = network_module if network_module else network - wlan = net.WLAN(net.STA_IF) - - # Restart WiFi hardware in case it's in a bad state - wlan.active(False) - wlan.active(True) - - # Scan for available networks - networks = wlan.scan() + # Scan for available networks using internal method + networks = WifiService._scan_networks_raw(network_module) # Sort networks by RSSI (signal strength) in descending order # RSSI is at index 3, higher values (less negative) = stronger signal networks = sorted(networks, key=lambda n: n[3], reverse=True) + # Track which SSIDs we've tried (to avoid retrying hidden networks) + tried_ssids = set() + for n in networks: ssid = n[0].decode() rssi = n[3] + tried_ssids.add(ssid) print(f"WifiService: Found network '{ssid}' (RSSI: {rssi} dBm)") if ssid in WifiService.access_points: @@ -85,6 +86,18 @@ def connect(network_module=None): else: print(f"WifiService: Skipping '{ssid}' (not configured)") + # Try hidden networks that weren't in the scan results + for ssid, config in WifiService.access_points.items(): + if config.get("hidden") and ssid not in tried_ssids: + password = config.get("password") + print(f"WifiService: Attempting hidden network '{ssid}'") + + if WifiService.attempt_connecting(ssid, password, network_module=network_module): + print(f"WifiService: Connected to hidden network '{ssid}'") + return True + else: + print(f"WifiService: Failed to connect to hidden network '{ssid}'") + print("WifiService: No saved networks found or connected") return False @@ -104,9 +117,18 @@ def attempt_connecting(ssid, password, network_module=None, time_module=None): """ print(f"WifiService: Connecting to SSID: {ssid}") - net = network_module if network_module else network time_mod = time_module if time_module else time + # Desktop mode - simulate successful connection + if not HAS_NETWORK_MODULE and network_module is None: + print("WifiService: Desktop mode, simulating connection...") + time_mod.sleep(2) + WifiService._desktop_connected_ssid = ssid + print(f"WifiService: Simulated connection to '{ssid}' successful") + return True + + net = network_module if network_module else network + try: wlan = net.WLAN(net.STA_IF) wlan.connect(ssid, password) @@ -307,6 +329,19 @@ def disconnect(network_module=None): #print(f"WifiService: Error disconnecting: {e}") # probably "Wifi Not Started" so harmless pass + @staticmethod + def is_busy(): + """ + Check if WiFi operations are currently in progress. + + Use this to check if scanning or other WiFi operations can be started. + Operations like scan_networks() manage the busy flag automatically. + + Returns: + bool: True if WiFi is busy, False if available + """ + return WifiService.wifi_busy + @staticmethod def get_saved_networks(): """ @@ -323,20 +358,128 @@ def get_saved_networks(): return list(WifiService.access_points.keys()) @staticmethod - def save_network(ssid, password): + def _scan_networks_raw(network_module=None): + """ + Internal method to scan for available WiFi networks and return raw data. + + Args: + network_module: Network module for dependency injection (testing) + + Returns: + list: Raw network tuples from wlan.scan(), or empty list on desktop + """ + if not HAS_NETWORK_MODULE and network_module is None: + # Desktop mode - return empty (no raw data available) + return [] + + net = network_module if network_module else network + wlan = net.WLAN(net.STA_IF) + + # Restart WiFi hardware in case it is in a bad state (only if not connected) + if not wlan.isconnected(): + wlan.active(False) + wlan.active(True) + + return wlan.scan() + + @staticmethod + def scan_networks(network_module=None): + """ + Scan for available WiFi networks. + + This method manages the wifi_busy flag internally. If WiFi is already busy, + returns an empty list. The busy flag is automatically cleared when scanning + completes (even on error). + + Args: + network_module: Network module for dependency injection (testing) + + Returns: + list: List of SSIDs found, empty list if busy, or mock data on desktop + """ + # Desktop mode - return mock SSIDs (no busy flag needed) + if not HAS_NETWORK_MODULE and network_module is None: + time.sleep(1) + return ["Home WiFi", "Pretty Fly for a Wi Fi", "Winternet is coming", "The Promised LAN"] + + # Check if already busy + if WifiService.wifi_busy: + print("WifiService: scan_networks() - WiFi is busy, returning empty list") + return [] + + WifiService.wifi_busy = True + try: + networks = WifiService._scan_networks_raw(network_module) + # Return unique SSIDs, filtering out empty ones and invalid lengths + ssids = list(set(n[0].decode() for n in networks if n[0])) + return [s for s in ssids if 0 < len(s) <= 32] + finally: + WifiService.wifi_busy = False + + @staticmethod + def get_current_ssid(network_module=None): + """ + Get the SSID of the currently connected network. + + Args: + network_module: Network module for dependency injection (testing) + + Returns: + str or None: Current SSID if connected, None otherwise + """ + if not HAS_NETWORK_MODULE and network_module is None: + # Desktop mode - return simulated connected SSID + return WifiService._desktop_connected_ssid + + net = network_module if network_module else network + try: + wlan = net.WLAN(net.STA_IF) + if wlan.isconnected(): + return wlan.config('essid') + except Exception as e: + print(f"WifiService: Error getting current SSID: {e}") + return None + + @staticmethod + def get_network_password(ssid): + """ + Get the saved password for a network. + + Args: + ssid: Network SSID + + Returns: + str or None: Password if found, None otherwise + """ + if not WifiService.access_points: + WifiService.access_points = mpos.config.SharedPreferences( + "com.micropythonos.system.wifiservice" + ).get_dict("access_points") + + ap = WifiService.access_points.get(ssid) + if ap: + return ap.get("password") + return None + + @staticmethod + def save_network(ssid, password, hidden=False): """ Save a new WiFi network credential. Args: ssid: Network SSID password: Network password + hidden: Whether this is a hidden network (always try connecting) """ # Load current saved networks prefs = mpos.config.SharedPreferences("com.micropythonos.system.wifiservice") access_points = prefs.get_dict("access_points") # Add or update the network - access_points[ssid] = {"password": password} + network_config = {"password": password} + if hidden: + network_config["hidden"] = True + access_points[ssid] = network_config # Save back to config editor = prefs.edit() @@ -346,7 +489,7 @@ def save_network(ssid, password): # Update class-level cache WifiService.access_points = access_points - print(f"WifiService: Saved network '{ssid}'") + print(f"WifiService: Saved network '{ssid}' (hidden={hidden})") @staticmethod def forget_network(ssid): diff --git a/internal_filesystem/lib/mpos/sensor_manager.py b/internal_filesystem/lib/mpos/sensor_manager.py index cf10b70c..8068c73c 100644 --- a/internal_filesystem/lib/mpos/sensor_manager.py +++ b/internal_filesystem/lib/mpos/sensor_manager.py @@ -40,6 +40,8 @@ # Gravity constant for unit conversions _GRAVITY = 9.80665 # m/s² +IMU_CALIBRATION_FILENAME = "imu_calibration.json" + # Module state _initialized = False _imu_driver = None @@ -227,7 +229,7 @@ def read_sensor(sensor): if _imu_driver: ax, ay, az = _imu_driver.read_acceleration() if _mounted_position == FACING_EARTH: - az += _GRAVITY + az *= -1 return (ax, ay, az) elif sensor.type == TYPE_GYROSCOPE: if _imu_driver: @@ -622,6 +624,9 @@ def calibrate_accelerometer(self, samples): 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 @@ -675,77 +680,93 @@ def __init__(self, i2c_bus, address): 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_accelerations() - # Convert mg to m/s²: mg → g → m/s² + ax, ay, az = self.sensor._read_raw_accelerations() + + # Convert G to m/s² and apply calibration return ( - (ax / 1000.0) * _GRAVITY, - (ay / 1000.0) * _GRAVITY, - (az / 1000.0) * _GRAVITY + ((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_angular_velocities() - # Convert mdps to deg/s + gx, gy, gz = self.sensor._read_raw_angular_velocities() + # Convert mdps to deg/s and apply calibration return ( - gx / 1000.0, - gy / 1000.0, - gz / 1000.0 + 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): - """Read temperature in °C (not implemented in WSEN_ISDS driver).""" - # WSEN_ISDS has temperature sensor but not exposed in current driver - return None + return self.sensor.temperature def calibrate_accelerometer(self, samples): - """Calibrate accelerometer using hardware calibration.""" - self.sensor.acc_calibrate(samples) - # Return offsets in m/s² (convert from raw offsets) - return ( - (self.sensor.acc_offset_x * self.sensor.acc_sensitivity / 1000.0) * _GRAVITY, - (self.sensor.acc_offset_y * self.sensor.acc_sensitivity / 1000.0) * _GRAVITY, - (self.sensor.acc_offset_z * self.sensor.acc_sensitivity / 1000.0) * _GRAVITY - ) + """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 using hardware calibration.""" - self.sensor.gyro_calibrate(samples) - # Return offsets in deg/s (convert from raw offsets) - return ( - (self.sensor.gyro_offset_x * self.sensor.gyro_sensitivity) / 1000.0, - (self.sensor.gyro_offset_y * self.sensor.gyro_sensitivity) / 1000.0, - (self.sensor.gyro_offset_z * self.sensor.gyro_sensitivity) / 1000.0 - ) + """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 (raw offsets from hardware).""" + """Get current calibration.""" return { - 'accel_offsets': [ - self.sensor.acc_offset_x, - self.sensor.acc_offset_y, - self.sensor.acc_offset_z - ], - 'gyro_offsets': [ - self.sensor.gyro_offset_x, - self.sensor.gyro_offset_y, - self.sensor.gyro_offset_z - ] + 'accel_offsets': self.accel_offset, + 'gyro_offsets': self.gyro_offset } def set_calibration(self, accel_offsets, gyro_offsets): - """Set calibration from saved values (raw offsets).""" + """Set calibration from saved values.""" if accel_offsets: - self.sensor.acc_offset_x = accel_offsets[0] - self.sensor.acc_offset_y = accel_offsets[1] - self.sensor.acc_offset_z = accel_offsets[2] + self.accel_offset = list(accel_offsets) if gyro_offsets: - self.sensor.gyro_offset_x = gyro_offsets[0] - self.sensor.gyro_offset_y = gyro_offsets[1] - self.sensor.gyro_offset_z = gyro_offsets[2] + self.gyro_offset = list(gyro_offsets) # ============================================================================ @@ -807,6 +828,15 @@ def _register_wsen_isds_sensors(): 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 ) ] @@ -840,25 +870,10 @@ def _load_calibration(): from mpos.config import SharedPreferences # Try NEW location first - prefs_new = SharedPreferences("com.micropythonos.settings", filename="sensors.json") + 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 not found, try OLD location and migrate - if not accel_offsets and not gyro_offsets: - prefs_old = SharedPreferences("com.micropythonos.sensors") - accel_offsets = prefs_old.get_list("accel_offsets") - gyro_offsets = prefs_old.get_list("gyro_offsets") - - if accel_offsets or gyro_offsets: - # Save to new location - editor = prefs_new.edit() - if accel_offsets: - editor.put_list("accel_offsets", accel_offsets) - if gyro_offsets: - editor.put_list("gyro_offsets", gyro_offsets) - editor.commit() - if accel_offsets or gyro_offsets: _imu_driver.set_calibration(accel_offsets, gyro_offsets) except: @@ -872,7 +887,7 @@ def _save_calibration(): try: from mpos.config import SharedPreferences - prefs = SharedPreferences("com.micropythonos.settings", filename="sensors.json") + prefs = SharedPreferences("com.micropythonos.settings", filename=IMU_CALIBRATION_FILENAME) editor = prefs.edit() cal = _imu_driver.get_calibration() 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..71d9f7ee --- /dev/null +++ b/internal_filesystem/lib/mpos/testing/__init__.py @@ -0,0 +1,87 @@ +""" +MicroPythonOS Testing Module + +Provides mock implementations for testing without actual hardware. +These mocks work on both desktop (unit tests) and device (integration tests). + +Usage: + from mpos.testing import MockMachine, MockTaskManager, MockNetwork + + # Inject mocks before importing modules that use hardware + import sys + sys.modules['machine'] = MockMachine() + + # Or use the helper function + from mpos.testing import inject_mocks + inject_mocks(['machine', 'mpos.task_manager']) +""" + +from .mocks import ( + # Hardware mocks + MockMachine, + MockPin, + MockPWM, + MockI2S, + MockTimer, + MockSocket, + MockNeoPixel, + + # MPOS mocks + MockTaskManager, + MockTask, + MockDownloadManager, + + # Threading mocks + MockThread, + MockApps, + + # Network mocks + MockNetwork, + MockRequests, + MockResponse, + MockRaw, + + # Utility mocks + MockTime, + MockJSON, + MockModule, + + # Helper functions + inject_mocks, + create_mock_module, +) + +__all__ = [ + # Hardware mocks + 'MockMachine', + 'MockPin', + 'MockPWM', + 'MockI2S', + 'MockTimer', + 'MockSocket', + 'MockNeoPixel', + + # MPOS mocks + 'MockTaskManager', + 'MockTask', + 'MockDownloadManager', + + # Threading mocks + 'MockThread', + 'MockApps', + + # Network mocks + 'MockNetwork', + 'MockRequests', + 'MockResponse', + 'MockRaw', + + # Utility mocks + 'MockTime', + 'MockJSON', + 'MockModule', + + # Helper functions + 'inject_mocks', + 'create_mock_module', +] \ No newline at end of file diff --git a/internal_filesystem/lib/mpos/testing/mocks.py b/internal_filesystem/lib/mpos/testing/mocks.py new file mode 100644 index 00000000..a3b2ba4c --- /dev/null +++ b/internal_filesystem/lib/mpos/testing/mocks.py @@ -0,0 +1,827 @@ +""" +Mock implementations for MicroPythonOS testing. + +This module provides mock implementations of hardware and system modules +for testing without actual hardware. Works on both desktop and device. +""" + +import sys + + +# ============================================================================= +# Helper Functions +# ============================================================================= + +class MockModule: + """ + Simple class that acts as a module container. + MicroPython doesn't have types.ModuleType, so we use this instead. + """ + pass + + +def create_mock_module(name, **attrs): + """ + Create a mock module with the given attributes. + + Args: + name: Module name (for debugging) + **attrs: Attributes to set on the module + + Returns: + MockModule instance with attributes set + """ + module = MockModule() + module.__name__ = name + for key, value in attrs.items(): + setattr(module, key, value) + return module + + +def inject_mocks(mock_specs): + """ + Inject mock modules into sys.modules. + + Args: + mock_specs: Dict mapping module names to mock instances/classes + e.g., {'machine': MockMachine(), 'mpos.task_manager': mock_tm} + """ + for name, mock in mock_specs.items(): + sys.modules[name] = mock + + +# ============================================================================= +# Hardware Mocks - machine module +# ============================================================================= + +class MockPin: + """Mock machine.Pin for testing GPIO operations.""" + + IN = 0 + OUT = 1 + PULL_UP = 2 + PULL_DOWN = 3 + + def __init__(self, pin_number, mode=None, pull=None): + self.pin_number = pin_number + self.mode = mode + self.pull = pull + self._value = 0 + + def value(self, val=None): + """Get or set pin value.""" + if val is None: + return self._value + self._value = val + + def on(self): + """Set pin high.""" + self._value = 1 + + def off(self): + """Set pin low.""" + self._value = 0 + + +class MockPWM: + """Mock machine.PWM for testing PWM operations (buzzer, etc.).""" + + def __init__(self, pin, freq=0, duty=0): + self.pin = pin + self.last_freq = freq + self.last_duty = duty + + def freq(self, value=None): + """Get or set frequency.""" + if value is not None: + self.last_freq = value + return self.last_freq + + def duty_u16(self, value=None): + """Get or set duty cycle (16-bit).""" + if value is not None: + self.last_duty = value + return self.last_duty + + def duty(self, value=None): + """Get or set duty cycle (10-bit).""" + if value is not None: + self.last_duty = value * 64 # Convert to 16-bit + return self.last_duty // 64 + + def deinit(self): + """Deinitialize PWM.""" + self.last_freq = 0 + self.last_duty = 0 + + +class MockI2S: + """Mock machine.I2S for testing audio I2S operations.""" + + TX = 0 + RX = 1 + MONO = 0 + STEREO = 1 + + def __init__(self, id, sck=None, ws=None, sd=None, mode=None, + bits=16, format=None, rate=44100, ibuf=None): + self.id = id + self.sck = sck + self.ws = ws + self.sd = sd + self.mode = mode + self.bits = bits + self.format = format + self.rate = rate + self.ibuf = ibuf + self._write_buffer = bytearray(1024) + self._bytes_written = 0 + + def write(self, buf): + """Write audio data (blocking).""" + self._bytes_written += len(buf) + return len(buf) + + def write_readinto(self, write_buf, read_buf): + """Non-blocking write with readback.""" + self._bytes_written += len(write_buf) + return len(write_buf) + + def deinit(self): + """Deinitialize I2S.""" + pass + + +class MockTimer: + """Mock machine.Timer for testing periodic callbacks.""" + + _all_timers = {} + + PERIODIC = 1 + ONE_SHOT = 0 + + def __init__(self, timer_id=-1): + self.timer_id = timer_id + self.callback = None + self.period = None + self.mode = None + self.active = False + if timer_id >= 0: + MockTimer._all_timers[timer_id] = self + + def init(self, period=None, mode=None, callback=None): + """Initialize/configure the timer.""" + self.period = period + self.mode = mode + self.callback = callback + self.active = True + + def deinit(self): + """Deinitialize the timer.""" + self.active = False + self.callback = None + + def trigger(self, *args, **kwargs): + """Manually trigger the timer callback (for testing).""" + if self.callback and self.active: + self.callback(*args, **kwargs) + + @classmethod + def get_timer(cls, timer_id): + """Get a timer by ID.""" + return cls._all_timers.get(timer_id) + + @classmethod + def trigger_all(cls): + """Trigger all active timers (for testing).""" + for timer in cls._all_timers.values(): + if timer.active: + timer.trigger() + + @classmethod + def reset_all(cls): + """Reset all timers (clear registry).""" + cls._all_timers.clear() + + +class MockNeoPixel: + """Mock neopixel.NeoPixel for testing LED operations.""" + + def __init__(self, pin, num_leds, bpp=3, timing=1): + self.pin = pin + self.num_leds = num_leds + self.bpp = bpp + self.timing = timing + self.pixels = [(0, 0, 0)] * num_leds + self.write_count = 0 + + def __setitem__(self, index, value): + """Set LED color (R, G, B) or (R, G, B, W) tuple.""" + if 0 <= index < self.num_leds: + self.pixels[index] = value + + def __getitem__(self, index): + """Get LED color.""" + if 0 <= index < self.num_leds: + return self.pixels[index] + return (0, 0, 0) + + def __len__(self): + """Return number of LEDs.""" + return self.num_leds + + def fill(self, color): + """Fill all LEDs with the same color.""" + for i in range(self.num_leds): + self.pixels[i] = color + + def write(self): + """Update hardware (mock - just increment counter).""" + self.write_count += 1 + + def get_all_colors(self): + """Get all LED colors (for testing assertions).""" + return self.pixels.copy() + + def reset_write_count(self): + """Reset the write counter (for testing).""" + self.write_count = 0 + + +class MockMachine: + """ + Mock machine module containing all hardware mocks. + + Usage: + sys.modules['machine'] = MockMachine() + """ + + Pin = MockPin + PWM = MockPWM + I2S = MockI2S + Timer = MockTimer + + @staticmethod + def freq(freq=None): + """Get or set CPU frequency.""" + return 240000000 # 240 MHz + + @staticmethod + def reset(): + """Reset the device (no-op in mock).""" + pass + + @staticmethod + def soft_reset(): + """Soft reset the device (no-op in mock).""" + pass + + +# ============================================================================= +# MPOS Mocks - TaskManager +# ============================================================================= + +class MockTask: + """Mock asyncio Task for testing.""" + + def __init__(self): + self.ph_key = 0 + self._done = False + self.coro = None + self._result = None + self._exception = None + + def done(self): + """Check if task is done.""" + return self._done + + def cancel(self): + """Cancel the task.""" + self._done = True + + def result(self): + """Get task result.""" + if self._exception: + raise self._exception + return self._result + + +class MockTaskManager: + """ + Mock TaskManager for testing async operations. + + Usage: + mock_tm = create_mock_module('mpos.task_manager', TaskManager=MockTaskManager) + sys.modules['mpos.task_manager'] = mock_tm + """ + + task_list = [] + + @classmethod + def create_task(cls, coroutine): + """Create a mock task from a coroutine.""" + task = MockTask() + task.coro = coroutine + cls.task_list.append(task) + return task + + @staticmethod + async def sleep(seconds): + """Mock async sleep (no actual delay).""" + pass + + @staticmethod + async def sleep_ms(milliseconds): + """Mock async sleep in milliseconds (no actual delay).""" + pass + + @staticmethod + async def wait_for(awaitable, timeout): + """Mock wait_for with timeout.""" + return await awaitable + + @staticmethod + def notify_event(): + """Create a mock async event.""" + class MockEvent: + def __init__(self): + self._set = False + + async def wait(self): + pass + + def set(self): + self._set = True + + def is_set(self): + return self._set + + return MockEvent() + + @classmethod + def clear_tasks(cls): + """Clear all tracked tasks (for test cleanup).""" + cls.task_list = [] + + +# ============================================================================= +# Network Mocks +# ============================================================================= + +class MockNetwork: + """Mock network module for testing network connectivity.""" + + STA_IF = 0 + AP_IF = 1 + + class MockWLAN: + """Mock WLAN interface.""" + + def __init__(self, interface, connected=True): + self.interface = interface + self._connected = connected + self._active = True + self._config = {} + self._scan_results = [] + + def isconnected(self): + """Return whether the WLAN is connected.""" + return self._connected + + def active(self, is_active=None): + """Get/set whether the interface is active.""" + if is_active is None: + return self._active + self._active = is_active + + def connect(self, ssid, password): + """Simulate connecting to a network.""" + self._connected = True + self._config['ssid'] = ssid + + def disconnect(self): + """Simulate disconnecting from network.""" + self._connected = False + + def config(self, param): + """Get configuration parameter.""" + return self._config.get(param) + + def ifconfig(self): + """Get IP configuration.""" + if self._connected: + return ('192.168.1.100', '255.255.255.0', '192.168.1.1', '8.8.8.8') + return ('0.0.0.0', '0.0.0.0', '0.0.0.0', '0.0.0.0') + + def 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/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 50164b4b..8c2d8228 100644 --- a/internal_filesystem/lib/mpos/ui/keyboard.py +++ b/internal_filesystem/lib/mpos/ui/keyboard.py @@ -101,16 +101,18 @@ class MposKeyboard: } _current_mode = None + _parent = None # used for scroll_to_y + _saved_scroll_y = 0 + # Store textarea reference (we DON'T pass it to LVGL to avoid double-typing) + _textarea = None def __init__(self, parent): # Create underlying LVGL keyboard widget self._keyboard = lv.keyboard(parent) + self._parent = parent # store it for later # self._keyboard.set_popovers(True) # disabled for now because they're quite ugly on LVGL 9.3 - maybe better on 9.4? self._keyboard.set_style_text_font(lv.font_montserrat_20,0) - # Store textarea reference (we DON'T pass it to LVGL to avoid double-typing) - self._textarea = None - self.set_mode(self.MODE_LOWERCASE) # Remove default event handler(s) @@ -250,8 +252,33 @@ def __getattr__(self, name): # Forward to the underlying keyboard object return getattr(self._keyboard, name) + def scroll_after_show(self, timer): + #self._textarea.scroll_to_view_recursive(True) # makes sense but doesn't work and breaks the keyboard scroll + self._keyboard.scroll_to_view_recursive(True) + + def focus_on_keyboard(self, timer=None): + default_group = lv.group_get_default() + if default_group: + from .focus_direction import emulate_focus_obj, move_focus_direction + emulate_focus_obj(default_group, self._keyboard) + + def scroll_back_after_hide(self, timer): + self._parent.scroll_to_y(self._saved_scroll_y, True) + def show_keyboard(self): - mpos.ui.anim.smooth_show(self._keyboard) + self._saved_scroll_y = self._parent.get_scroll_y() + mpos.ui.anim.smooth_show(self._keyboard, duration=500) + # Scroll to view on a timer because it will be hidden initially + lv.timer_create(self.scroll_after_show, 250, None).set_repeat_count(1) + # When this is done from a timer, focus styling is not applied so the user doesn't see which button is selected. + # Maybe because there's no active indev anymore? + # Maybe it will be fixed in an update of LVGL 9.3? + # focus_timer = lv.timer_create(self.focus_on_keyboard,750,None).set_repeat_count(1) + # Workaround: show the keyboard immediately and then focus on it - that works, and doesn't seem to flicker as feared: + self._keyboard.remove_flag(lv.obj.FLAG.HIDDEN) + self.focus_on_keyboard() def hide_keyboard(self): - mpos.ui.anim.smooth_hide(self._keyboard) + mpos.ui.anim.smooth_hide(self._keyboard, duration=500) + # Do this after the hide so the scrollbars disappear automatically if not needed + scroll_timer = lv.timer_create(self.scroll_back_after_hide,550,None).set_repeat_count(1) diff --git a/internal_filesystem/lib/mpos/ui/testing.py b/internal_filesystem/lib/mpos/ui/testing.py index dc3fa063..44738f91 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 @@ -278,6 +279,29 @@ def verify_text_present(obj, expected_text): return find_label_with_text(obj, expected_text) is not None +def text_to_hex(text): + """ + Convert text to hex representation for debugging. + + Useful for identifying Unicode symbols like lv.SYMBOL.SETTINGS + which may not display correctly in terminal output. + + Args: + text: String to convert + + Returns: + str: Hex representation of the text bytes (UTF-8 encoded) + + Example: + >>> text_to_hex("āš™") # lv.SYMBOL.SETTINGS + 'e29a99' + """ + try: + return text.encode('utf-8').hex() + except: + return "" + + def print_screen_labels(obj): """ Debug helper: Print all text found on screen from any widget. @@ -285,6 +309,10 @@ def print_screen_labels(obj): Useful for debugging tests to see what text is actually present. Prints to stdout with numbered list. Includes text from labels, checkboxes, buttons, and any other widgets with text. + + For each text, also prints the hex representation to help identify + Unicode symbols (like lv.SYMBOL.SETTINGS) that may not display + correctly in terminal output. Args: obj: LVGL object to search (typically lv.screen_active()) @@ -294,16 +322,17 @@ def print_screen_labels(obj): print_screen_labels(lv.screen_active()) # Output: # Found 5 text widgets on screen: - # 0: MicroPythonOS - # 1: Version 0.3.3 - # 2: Settings - # 3: Force Update (checkbox) - # 4: WiFi + # 0: MicroPythonOS (hex: 4d6963726f507974686f6e4f53) + # 1: Version 0.3.3 (hex: 56657273696f6e20302e332e33) + # 2: āš™ (hex: e29a99) <- lv.SYMBOL.SETTINGS + # 3: Force Update (hex: 466f7263652055706461746) + # 4: WiFi (hex: 57694669) """ texts = get_screen_text_content(obj) print(f"Found {len(texts)} text widgets on screen:") for i, text in enumerate(texts): - print(f" {i}: {text}") + hex_repr = text_to_hex(text) + print(f" {i}: {text} (hex: {hex_repr})") def get_widget_coords(widget): @@ -517,7 +546,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 +571,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 +596,306 @@ def simulate_click(x, y, press_duration_ms=50): _touch_y = y _touch_pressed = True - # Process the press immediately + # Process the press event lv.task_handler() + time.sleep(0.02) + lv.task_handler() + + # Wait for press duration + time.sleep(press_duration_ms / 1000.0) + + # Release the touch + _touch_pressed = False - 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 + # 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 + + +def click_keyboard_button(keyboard, button_text, use_direct=True): + """ + Click a keyboard button reliably. + + This function handles the complexity of clicking keyboard buttons. + For MposKeyboard, it directly manipulates the textarea (most reliable). + For raw lv.keyboard, it uses simulate_click with coordinates. + + Args: + keyboard: MposKeyboard instance or lv.keyboard widget + button_text: Text of the button to click (e.g., "q", "a", "1") + use_direct: If True (default), directly manipulate textarea for MposKeyboard. + If False, use simulate_click with coordinates. + + Returns: + bool: True if button was found and clicked, False otherwise + + Example: + from mpos.ui.keyboard import MposKeyboard + from mpos.ui.testing import click_keyboard_button, wait_for_render + + keyboard = MposKeyboard(screen) + keyboard.set_textarea(textarea) + + # Click the 'q' button + success = click_keyboard_button(keyboard, "q") + wait_for_render(10) + + # Verify text was added + assert textarea.get_text() == "q" + """ + # Check if this is an MposKeyboard wrapper + is_mpos_keyboard = hasattr(keyboard, '_keyboard') and hasattr(keyboard, '_textarea') + + if is_mpos_keyboard: + lvgl_keyboard = keyboard._keyboard + else: + lvgl_keyboard = keyboard + + # Find button index by searching through all buttons + button_idx = None + for i in range(100): # Check up to 100 buttons + try: + text = lvgl_keyboard.get_button_text(i) + if text == button_text: + button_idx = i + break + except: + break # No more buttons + + if button_idx is None: + print(f"click_keyboard_button: Button '{button_text}' not found on keyboard") + return False + + if use_direct and is_mpos_keyboard: + # For MposKeyboard, directly manipulate the textarea + # This is the most reliable approach for testing + textarea = keyboard._textarea + if textarea is None: + print(f"click_keyboard_button: No textarea connected to keyboard") + return False + + current_text = textarea.get_text() + + # Handle special keys (matching keyboard.py logic) + if button_text == lv.SYMBOL.BACKSPACE: + new_text = current_text[:-1] + elif button_text == " " or button_text == keyboard.LABEL_SPACE: + new_text = current_text + " " + elif button_text in [lv.SYMBOL.UP, lv.SYMBOL.DOWN, keyboard.LABEL_LETTERS, + keyboard.LABEL_NUMBERS_SPECIALS, keyboard.LABEL_SPECIALS, + lv.SYMBOL.OK]: + # Mode switching or OK - don't modify text + print(f"click_keyboard_button: '{button_text}' is a control key, not adding to textarea") + wait_for_render(10) + return True + else: + # Regular character + new_text = current_text + button_text + + textarea.set_text(new_text) + wait_for_render(10) + print(f"click_keyboard_button: Clicked '{button_text}' at index {button_idx} using direct textarea manipulation") + else: + # Use coordinate-based clicking + coords = get_keyboard_button_coords(keyboard, button_text) + if coords: + simulate_click(coords['center_x'], coords['center_y']) + wait_for_render(20) # More time for event processing + print(f"click_keyboard_button: Clicked '{button_text}' at ({coords['center_x']}, {coords['center_y']}) using simulate_click") + else: + print(f"click_keyboard_button: Could not get coordinates for '{button_text}'") + return False + + return True diff --git a/internal_filesystem/lib/mpos/ui/topmenu.py b/internal_filesystem/lib/mpos/ui/topmenu.py index 7911c957..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) 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/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 4ee57487..5f0903e9 100755 --- a/scripts/build_mpos.sh +++ b/scripts/build_mpos.sh @@ -106,7 +106,7 @@ elif [ "$target" == "unix" -o "$target" == "macOS" ]; then # (cross-compiler doesn't support Viper native code emitter) echo "Temporarily commenting out @micropython.viper decorator for Unix/macOS build..." stream_wav_file="$codebasedir"/internal_filesystem/lib/mpos/audio/stream_wav.py - sed -i 's/^@micropython\.viper$/#@micropython.viper/' "$stream_wav_file" + sed -i.backup 's/^@micropython\.viper$/#@micropython.viper/' "$stream_wav_file" # LV_CFLAGS are passed to USER_C_MODULES # STRIP= makes it so that debug symbols are kept @@ -117,7 +117,7 @@ elif [ "$target" == "unix" -o "$target" == "macOS" ]; then # Restore @micropython.viper decorator after build echo "Restoring @micropython.viper decorator..." - sed -i 's/^#@micropython\.viper$/@micropython.viper/' "$stream_wav_file" + sed -i.backup 's/^#@micropython\.viper$/@micropython.viper/' "$stream_wav_file" else echo "invalid target $target" fi 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/install.sh b/scripts/install.sh index 7dd15113..9e4aa66b 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -15,8 +15,9 @@ 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 "mpos.net.wifi_service.WifiService.disconnect()" +$mpremote exec "import mpos ; mpos.net.wifi_service.WifiService.disconnect()" sleep 2 if [ ! -z "$appname" ]; then @@ -47,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 @@ -59,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/README.md b/tests/README.md new file mode 100644 index 00000000..dcb344b9 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,300 @@ +# MicroPythonOS Testing Guide + +This directory contains the test suite for MicroPythonOS. Tests can run on both desktop (for fast iteration) and on-device (for hardware verification). + +## Quick Start + +```bash +# Run all tests +./tests/unittest.sh + +# Run a specific test +./tests/unittest.sh tests/test_graphical_keyboard_q_button_bug.py + +# Run on device +./tests/unittest.sh tests/test_graphical_keyboard_q_button_bug.py --ondevice +``` + +## Test Architecture + +### Directory Structure + +``` +tests/ +ā”œā”€ā”€ base/ # Base test classes (DRY patterns) +│ ā”œā”€ā”€ __init__.py # Exports GraphicalTestBase, KeyboardTestBase +│ ā”œā”€ā”€ graphical_test_base.py +│ └── keyboard_test_base.py +ā”œā”€ā”€ screenshots/ # Captured screenshots for visual regression +ā”œā”€ā”€ test_*.py # Test files +ā”œā”€ā”€ unittest.sh # Test runner script +└── README.md # This file +``` + +### Testing Modules + +MicroPythonOS provides two testing modules: + +1. **`mpos.testing`** - Hardware and system mocks + - Location: `internal_filesystem/lib/mpos/testing/` + - Use for: Mocking hardware (Pin, PWM, I2S, NeoPixel), network, async operations + +2. **`mpos.ui.testing`** - LVGL/UI testing utilities + - Location: `internal_filesystem/lib/mpos/ui/testing.py` + - Use for: UI interaction, screenshots, widget inspection + +## Base Test Classes + +### GraphicalTestBase + +Base class for all graphical (LVGL) tests. Provides: +- Automatic screen creation/cleanup +- Screenshot capture +- Widget finding utilities +- Custom assertions + +```python +from base import GraphicalTestBase + +class TestMyUI(GraphicalTestBase): + def test_something(self): + # self.screen is already created + label = lv.label(self.screen) + label.set_text("Hello") + + self.wait_for_render() + self.assertTextPresent("Hello") + self.capture_screenshot("my_test.raw") +``` + +**Key Methods:** +- `wait_for_render(iterations=5)` - Process LVGL tasks +- `capture_screenshot(filename)` - Save screenshot +- `find_label_with_text(text)` - Find label widget +- `click_button(button)` - Simulate button click +- `assertTextPresent(text)` - Assert text is on screen +- `assertWidgetVisible(widget)` - Assert widget is visible + +### KeyboardTestBase + +Extends GraphicalTestBase for keyboard tests. Provides: +- Keyboard and textarea creation +- Reliable keyboard button clicking +- Textarea assertions + +```python +from base import KeyboardTestBase + +class TestMyKeyboard(KeyboardTestBase): + def test_typing(self): + self.create_keyboard_scene() + + self.click_keyboard_button("h") + self.click_keyboard_button("i") + + self.assertTextareaText("hi") +``` + +**Key Methods:** +- `create_keyboard_scene()` - Create textarea + MposKeyboard +- `click_keyboard_button(text)` - Click keyboard button reliably +- `type_text(text)` - Type a string +- `get_textarea_text()` - Get textarea content +- `clear_textarea()` - Clear textarea +- `assertTextareaText(expected)` - Assert textarea content +- `assertTextareaEmpty()` - Assert textarea is empty + +## Mock Classes + +Import mocks from `mpos.testing`: + +```python +from mpos.testing import ( + # Hardware mocks + MockMachine, # Full machine module mock + MockPin, # GPIO pins + MockPWM, # PWM for buzzer + MockI2S, # Audio I2S + MockTimer, # Hardware timers + MockNeoPixel, # LED strips + MockSocket, # Network sockets + + # MPOS mocks + MockTaskManager, # Async task management + MockDownloadManager, # HTTP downloads + + # Network mocks + MockNetwork, # WiFi/network module + MockRequests, # HTTP requests + MockResponse, # HTTP responses + + # Utility mocks + MockTime, # Time functions + MockJSON, # JSON parsing + + # Helpers + inject_mocks, # Inject mocks into sys.modules + create_mock_module, # Create mock module +) +``` + +### Injecting Mocks + +```python +from mpos.testing import inject_mocks, MockMachine, MockNetwork + +# Inject before importing modules that use hardware +inject_mocks({ + 'machine': MockMachine(), + 'network': MockNetwork(connected=True), +}) + +# Now import the module under test +from mpos.hardware import some_module +``` + +### Mock Examples + +**MockNeoPixel:** +```python +from mpos.testing import MockNeoPixel, MockPin + +pin = MockPin(5) +leds = MockNeoPixel(pin, 10) + +leds[0] = (255, 0, 0) # Set first LED to red +leds.write() + +assert leds.write_count == 1 +assert leds[0] == (255, 0, 0) +``` + +**MockRequests:** +```python +from mpos.testing import MockRequests + +mock_requests = MockRequests() +mock_requests.set_next_response( + status_code=200, + text='{"status": "ok"}', + headers={'Content-Type': 'application/json'} +) + +response = mock_requests.get("https://api.example.com/data") +assert response.status_code == 200 +``` + +**MockTimer:** +```python +from mpos.testing import MockTimer + +timer = MockTimer(0) +timer.init(period=1000, mode=MockTimer.PERIODIC, callback=my_callback) + +# Manually trigger for testing +timer.trigger() + +# Or trigger all timers +MockTimer.trigger_all() +``` + +## Test Naming Conventions + +- `test_*.py` - Standard unit tests +- `test_graphical_*.py` - Tests requiring LVGL/UI (detected by unittest.sh) +- `manual_test_*.py` - Manual tests (not run automatically) + +## Writing New Tests + +### Simple Unit Test + +```python +import unittest + +class TestMyFeature(unittest.TestCase): + def test_something(self): + result = my_function() + self.assertEqual(result, expected) +``` + +### Graphical Test + +```python +from base import GraphicalTestBase +import lvgl as lv + +class TestMyUI(GraphicalTestBase): + def test_button_click(self): + button = lv.button(self.screen) + label = lv.label(button) + label.set_text("Click Me") + + self.wait_for_render() + self.click_button(button) + + # Verify result +``` + +### Keyboard Test + +```python +from base import KeyboardTestBase + +class TestMyKeyboard(KeyboardTestBase): + def test_input(self): + self.create_keyboard_scene() + + self.type_text("hello") + self.assertTextareaText("hello") + + self.click_keyboard_button("Enter") +``` + +### Test with Mocks + +```python +import unittest +from mpos.testing import MockNetwork, inject_mocks + +class TestNetworkFeature(unittest.TestCase): + def setUp(self): + self.mock_network = MockNetwork(connected=True) + inject_mocks({'network': self.mock_network}) + + def test_connected(self): + from my_module import check_connection + self.assertTrue(check_connection()) + + def test_disconnected(self): + self.mock_network.set_connected(False) + from my_module import check_connection + self.assertFalse(check_connection()) +``` + +## Best Practices + +1. **Use base classes** - Extend `GraphicalTestBase` or `KeyboardTestBase` for UI tests +2. **Use mpos.testing mocks** - Don't create inline mocks; use the centralized ones +3. **Clean up in tearDown** - Base classes handle this, but custom tests should clean up +4. **Don't include `if __name__ == '__main__'`** - The test runner handles this +5. **Use descriptive test names** - `test_keyboard_q_button_works` not `test_1` +6. **Add docstrings** - Explain what the test verifies and why + +## Debugging Tests + +```bash +# Run with verbose output +./tests/unittest.sh tests/test_my_test.py + +# Run with GDB (desktop only) +gdb --args ./lvgl_micropython/build/lvgl_micropy_unix -X heapsize=8M tests/test_my_test.py +``` + +## Screenshots + +Screenshots are saved to `tests/screenshots/` in raw format. Convert to PNG: + +```bash +cd tests/screenshots +./convert_to_png.sh +``` diff --git a/tests/base/__init__.py b/tests/base/__init__.py new file mode 100644 index 00000000..f83aed8e --- /dev/null +++ b/tests/base/__init__.py @@ -0,0 +1,24 @@ +""" +Base test classes for MicroPythonOS testing. + +This module provides base classes that encapsulate common test patterns: +- GraphicalTestBase: For tests that require LVGL/UI +- KeyboardTestBase: For tests that involve keyboard interaction + +Usage: + from base import GraphicalTestBase, KeyboardTestBase + + class TestMyApp(GraphicalTestBase): + def test_something(self): + # self.screen is already set up + # self.screenshot_dir is configured + pass +""" + +from .graphical_test_base import GraphicalTestBase +from .keyboard_test_base import KeyboardTestBase + +__all__ = [ + 'GraphicalTestBase', + 'KeyboardTestBase', +] diff --git a/tests/base/graphical_test_base.py b/tests/base/graphical_test_base.py new file mode 100644 index 00000000..25927c8f --- /dev/null +++ b/tests/base/graphical_test_base.py @@ -0,0 +1,237 @@ +""" +Base class for graphical tests in MicroPythonOS. + +This class provides common setup/teardown patterns for tests that require +LVGL/UI initialization. It handles: +- Screen creation and cleanup +- Screenshot directory configuration +- Common UI testing utilities + +Usage: + from base import GraphicalTestBase + + class TestMyApp(GraphicalTestBase): + def test_something(self): + # self.screen is already set up (320x240) + # self.screenshot_dir is configured + label = lv.label(self.screen) + label.set_text("Hello") + self.wait_for_render() + self.capture_screenshot("my_test") +""" + +import unittest +import lvgl as lv +import sys +import os + + +class GraphicalTestBase(unittest.TestCase): + """ + Base class for all graphical tests. + + Provides: + - Automatic screen creation and cleanup + - Screenshot directory configuration + - Common UI testing utilities + + Class Attributes: + SCREEN_WIDTH: Default screen width (320) + SCREEN_HEIGHT: Default screen height (240) + DEFAULT_RENDER_ITERATIONS: Default iterations for wait_for_render (5) + + Instance Attributes: + screen: The LVGL screen object for the test + screenshot_dir: Path to the screenshots directory + """ + + SCREEN_WIDTH = 320 + SCREEN_HEIGHT = 240 + DEFAULT_RENDER_ITERATIONS = 5 + + @classmethod + def setUpClass(cls): + """ + Set up class-level fixtures. + + Configures the screenshot directory based on platform. + """ + # Determine screenshot directory based on platform + if sys.platform == "esp32": + cls.screenshot_dir = "tests/screenshots" + else: + # On desktop, tests directory is in parent + cls.screenshot_dir = "../tests/screenshots" + + # Ensure screenshots directory exists + try: + os.mkdir(cls.screenshot_dir) + except OSError: + pass # Directory already exists + + def setUp(self): + """ + Set up test fixtures before each test method. + + Creates a new screen and loads it. + """ + # Create and load a new screen + self.screen = lv.obj() + self.screen.set_size(self.SCREEN_WIDTH, self.SCREEN_HEIGHT) + lv.screen_load(self.screen) + self.wait_for_render() + + def tearDown(self): + """ + Clean up after each test method. + + Loads an empty screen to clean up. + """ + # Load an empty screen to clean up + lv.screen_load(lv.obj()) + self.wait_for_render() + + def wait_for_render(self, iterations=None): + """ + Wait for LVGL to render. + + Args: + iterations: Number of render iterations (default: DEFAULT_RENDER_ITERATIONS) + """ + from mpos.ui.testing import wait_for_render + if iterations is None: + iterations = self.DEFAULT_RENDER_ITERATIONS + wait_for_render(iterations) + + def capture_screenshot(self, name, width=None, height=None): + """ + Capture a screenshot with standardized naming. + + Args: + name: Name for the screenshot (without extension) + width: Screenshot width (default: SCREEN_WIDTH) + height: Screenshot height (default: SCREEN_HEIGHT) + + Returns: + bytes: The screenshot buffer + """ + from mpos.ui.testing import capture_screenshot + + if width is None: + width = self.SCREEN_WIDTH + if height is None: + height = self.SCREEN_HEIGHT + + path = f"{self.screenshot_dir}/{name}.raw" + return capture_screenshot(path, width=width, height=height) + + def find_label_with_text(self, text, parent=None): + """ + Find a label containing the specified text. + + Args: + text: Text to search for + parent: Parent widget to search in (default: current screen) + + Returns: + The label widget if found, None otherwise + """ + from mpos.ui.testing import find_label_with_text + if parent is None: + parent = lv.screen_active() + return find_label_with_text(parent, text) + + def verify_text_present(self, text, parent=None): + """ + Verify that text is present on screen. + + Args: + text: Text to search for + parent: Parent widget to search in (default: current screen) + + Returns: + bool: True if text is found + """ + from mpos.ui.testing import verify_text_present + if parent is None: + parent = lv.screen_active() + return verify_text_present(parent, text) + + def print_screen_labels(self, parent=None): + """ + Print all labels on screen (for debugging). + + Args: + parent: Parent widget to search in (default: current screen) + """ + from mpos.ui.testing import print_screen_labels + if parent is None: + parent = lv.screen_active() + print_screen_labels(parent) + + def click_button(self, text, use_send_event=True): + """ + Click a button by its text. + + Args: + text: Button text to find and click + use_send_event: If True, use send_event (more reliable) + + Returns: + bool: True if button was found and clicked + """ + from mpos.ui.testing import click_button + return click_button(text, use_send_event=use_send_event) + + def click_label(self, text, use_send_event=True): + """ + Click a label by its text. + + Args: + text: Label text to find and click + use_send_event: If True, use send_event (more reliable) + + Returns: + bool: True if label was found and clicked + """ + from mpos.ui.testing import click_label + return click_label(text, use_send_event=use_send_event) + + def simulate_click(self, x, y): + """ + Simulate a click at specific coordinates. + + Note: For most UI testing, prefer click_button() or click_label() + which are more reliable. Use this only when testing touch behavior. + + Args: + x: X coordinate + y: Y coordinate + """ + from mpos.ui.testing import simulate_click + simulate_click(x, y) + self.wait_for_render() + + def assertTextPresent(self, text, msg=None): + """ + Assert that text is present on screen. + + Args: + text: Text to search for + msg: Optional failure message + """ + if msg is None: + msg = f"Text '{text}' not found on screen" + self.assertTrue(self.verify_text_present(text), msg) + + def assertTextNotPresent(self, text, msg=None): + """ + Assert that text is NOT present on screen. + + Args: + text: Text to search for + msg: Optional failure message + """ + if msg is None: + msg = f"Text '{text}' should not be on screen" + self.assertFalse(self.verify_text_present(text), msg) diff --git a/tests/base/keyboard_test_base.py b/tests/base/keyboard_test_base.py new file mode 100644 index 00000000..f49be8e8 --- /dev/null +++ b/tests/base/keyboard_test_base.py @@ -0,0 +1,223 @@ +""" +Base class for keyboard tests in MicroPythonOS. + +This class extends GraphicalTestBase with keyboard-specific functionality: +- Keyboard and textarea creation +- Keyboard button clicking +- Textarea text assertions + +Usage: + from base import KeyboardTestBase + + class TestMyKeyboard(KeyboardTestBase): + def test_typing(self): + keyboard, textarea = self.create_keyboard_scene() + self.click_keyboard_button("h") + self.click_keyboard_button("i") + self.assertTextareaText("hi") +""" + +import lvgl as lv +from .graphical_test_base import GraphicalTestBase + + +class KeyboardTestBase(GraphicalTestBase): + """ + Base class for keyboard tests. + + Extends GraphicalTestBase with keyboard-specific functionality. + + Instance Attributes: + keyboard: The MposKeyboard instance (after create_keyboard_scene) + textarea: The textarea widget (after create_keyboard_scene) + """ + + # Increase render iterations for keyboard tests + DEFAULT_RENDER_ITERATIONS = 10 + + def setUp(self): + """Set up test fixtures.""" + super().setUp() + self.keyboard = None + self.textarea = None + + def create_keyboard_scene(self, initial_text="", textarea_width=200, textarea_height=30): + """ + Create a standard keyboard test scene with textarea and keyboard. + + Args: + initial_text: Initial text in the textarea + textarea_width: Width of the textarea + textarea_height: Height of the textarea + + Returns: + tuple: (keyboard, textarea) + """ + from mpos.ui.keyboard import MposKeyboard + + # Create textarea + self.textarea = lv.textarea(self.screen) + self.textarea.set_size(textarea_width, textarea_height) + self.textarea.set_one_line(True) + self.textarea.align(lv.ALIGN.TOP_MID, 0, 10) + self.textarea.set_text(initial_text) + self.wait_for_render() + + # Create keyboard and connect to textarea + self.keyboard = MposKeyboard(self.screen) + self.keyboard.set_textarea(self.textarea) + self.keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + self.wait_for_render() + + return self.keyboard, self.textarea + + def click_keyboard_button(self, button_text): + """ + Click a keyboard button by its text. + + This uses the reliable click_keyboard_button helper which + directly manipulates the textarea for MposKeyboard instances. + + Args: + button_text: The text of the button to click (e.g., "q", "a", "Enter") + + Returns: + bool: True if button was clicked successfully + """ + from mpos.ui.testing import click_keyboard_button + + if self.keyboard is None: + raise RuntimeError("No keyboard created. Call create_keyboard_scene() first.") + + return click_keyboard_button(self.keyboard, button_text) + + def get_textarea_text(self): + """ + Get the current text in the textarea. + + Returns: + str: The textarea text + """ + if self.textarea is None: + raise RuntimeError("No textarea created. Call create_keyboard_scene() first.") + return self.textarea.get_text() + + def set_textarea_text(self, text): + """ + Set the textarea text. + + Args: + text: The text to set + """ + if self.textarea is None: + raise RuntimeError("No textarea created. Call create_keyboard_scene() first.") + self.textarea.set_text(text) + self.wait_for_render() + + def clear_textarea(self): + """Clear the textarea.""" + self.set_textarea_text("") + + def type_text(self, text): + """ + Type a string by clicking each character on the keyboard. + + Args: + text: The text to type + + Returns: + bool: True if all characters were typed successfully + """ + for char in text: + if not self.click_keyboard_button(char): + return False + return True + + def assertTextareaText(self, expected, msg=None): + """ + Assert that the textarea contains the expected text. + + Args: + expected: Expected text + msg: Optional failure message + """ + actual = self.get_textarea_text() + if msg is None: + msg = f"Textarea text mismatch. Expected '{expected}', got '{actual}'" + self.assertEqual(actual, expected, msg) + + def assertTextareaEmpty(self, msg=None): + """ + Assert that the textarea is empty. + + Args: + msg: Optional failure message + """ + if msg is None: + msg = f"Textarea should be empty, but contains '{self.get_textarea_text()}'" + self.assertEqual(self.get_textarea_text(), "", msg) + + def assertTextareaContains(self, substring, msg=None): + """ + Assert that the textarea contains a substring. + + Args: + substring: Substring to search for + msg: Optional failure message + """ + actual = self.get_textarea_text() + if msg is None: + msg = f"Textarea should contain '{substring}', but has '{actual}'" + self.assertIn(substring, actual, msg) + + def get_keyboard_button_text(self, index): + """ + Get the text of a keyboard button by index. + + Args: + index: Button index + + Returns: + str: Button text, or None if not found + """ + if self.keyboard is None: + raise RuntimeError("No keyboard created. Call create_keyboard_scene() first.") + + try: + return self.keyboard.get_button_text(index) + except: + return None + + def find_keyboard_button_index(self, button_text): + """ + Find the index of a keyboard button by its text. + + Args: + button_text: Text to search for + + Returns: + int: Button index, or None if not found + """ + for i in range(100): # Check first 100 indices + text = self.get_keyboard_button_text(i) + if text is None: + break + if text == button_text: + return i + return None + + def get_all_keyboard_buttons(self): + """ + Get all keyboard buttons as a list of (index, text) tuples. + + Returns: + list: List of (index, text) tuples + """ + buttons = [] + for i in range(100): + text = self.get_keyboard_button_text(i) + if text is None: + break + if text: # Skip empty strings + buttons.append((i, text)) + return buttons diff --git a/tests/mocks/hardware_mocks.py b/tests/mocks/hardware_mocks.py deleted file mode 100644 index b2d2e97e..00000000 --- a/tests/mocks/hardware_mocks.py +++ /dev/null @@ -1,102 +0,0 @@ -# Hardware Mocks for Testing AudioFlinger and LightsManager -# Provides mock implementations of PWM, I2S, NeoPixel, and Pin classes - - -class MockPin: - """Mock machine.Pin for testing.""" - - IN = 0 - OUT = 1 - PULL_UP = 2 - - def __init__(self, pin_number, mode=None, pull=None): - self.pin_number = pin_number - self.mode = mode - self.pull = pull - self._value = 0 - - def value(self, val=None): - if val is not None: - self._value = val - return self._value - - -class MockPWM: - """Mock machine.PWM for testing buzzer.""" - - def __init__(self, pin, freq=0, duty=0): - self.pin = pin - self.last_freq = freq - self.last_duty = duty - self.freq_history = [] - self.duty_history = [] - - def freq(self, value=None): - """Set or get frequency.""" - if value is not None: - self.last_freq = value - self.freq_history.append(value) - return self.last_freq - - def duty_u16(self, value=None): - """Set or get duty cycle (0-65535).""" - if value is not None: - self.last_duty = value - self.duty_history.append(value) - return self.last_duty - - -class MockI2S: - """Mock machine.I2S for testing audio playback.""" - - TX = 0 - MONO = 1 - STEREO = 2 - - def __init__(self, id, sck, ws, sd, mode, bits, format, rate, ibuf): - self.id = id - self.sck = sck - self.ws = ws - self.sd = sd - self.mode = mode - self.bits = bits - self.format = format - self.rate = rate - self.ibuf = ibuf - self.written_bytes = [] - self.total_bytes_written = 0 - - def write(self, buf): - """Simulate writing to I2S hardware.""" - self.written_bytes.append(bytes(buf)) - self.total_bytes_written += len(buf) - return len(buf) - - def deinit(self): - """Deinitialize I2S.""" - pass - - -class MockNeoPixel: - """Mock neopixel.NeoPixel for testing LEDs.""" - - def __init__(self, pin, num_leds): - self.pin = pin - self.num_leds = num_leds - self.pixels = [(0, 0, 0)] * num_leds - self.write_count = 0 - - def __setitem__(self, index, value): - """Set LED color (R, G, B) tuple.""" - if 0 <= index < self.num_leds: - self.pixels[index] = value - - def __getitem__(self, index): - """Get LED color.""" - if 0 <= index < self.num_leds: - return self.pixels[index] - return (0, 0, 0) - - def write(self): - """Update hardware (mock - just increment counter).""" - self.write_count += 1 diff --git a/tests/network_test_helper.py b/tests/network_test_helper.py index c811c1fe..1a6d235b 100644 --- a/tests/network_test_helper.py +++ b/tests/network_test_helper.py @@ -2,592 +2,50 @@ Network testing helper module for MicroPythonOS. This module provides mock implementations of network-related modules -for testing without requiring actual network connectivity. These mocks -are designed to be used with dependency injection in the classes being tested. +for testing without requiring actual network connectivity. + +NOTE: This module re-exports mocks from mpos.testing for backward compatibility. +New code should import directly from mpos.testing. Usage: from network_test_helper import MockNetwork, MockRequests, MockTimer - - # Create mocks - mock_network = MockNetwork(connected=True) - mock_requests = MockRequests() - - # Configure mock responses - mock_requests.set_next_response(status_code=200, text='{"key": "value"}') - - # Pass to class being tested - obj = MyClass(network_module=mock_network, requests_module=mock_requests) - - # Test behavior - result = obj.fetch_data() - assert mock_requests.last_url == "http://expected.url" + + # Or use the centralized module directly: + from mpos.testing import MockNetwork, MockRequests, MockTimer """ -import time - - -class MockNetwork: - """ - Mock network module for testing network connectivity. - - Simulates the MicroPython 'network' module with WLAN interface. - """ - - STA_IF = 0 # Station interface constant - AP_IF = 1 # Access Point interface constant - - class MockWLAN: - """Mock WLAN interface.""" - - def __init__(self, interface, connected=True): - self.interface = interface - self._connected = connected - self._active = True - self._config = {} - self._scan_results = [] # Can be configured for testing - - def isconnected(self): - """Return whether the WLAN is connected.""" - return self._connected - - def active(self, is_active=None): - """Get/set whether the interface is active.""" - if is_active is None: - return self._active - self._active = is_active - - def connect(self, ssid, password): - """Simulate connecting to a network.""" - self._connected = True - self._config['ssid'] = ssid - - def disconnect(self): - """Simulate disconnecting from network.""" - self._connected = False - - def config(self, param): - """Get configuration parameter.""" - return self._config.get(param) - - def ifconfig(self): - """Get IP configuration.""" - if self._connected: - return ('192.168.1.100', '255.255.255.0', '192.168.1.1', '8.8.8.8') - return ('0.0.0.0', '0.0.0.0', '0.0.0.0', '0.0.0.0') - - def scan(self): - """Scan for available networks.""" - return self._scan_results - - def __init__(self, connected=True): - """ - Initialize mock network module. - - Args: - connected: Initial connection state (default: True) - """ - self._connected = connected - self._wlan_instances = {} - - def WLAN(self, interface): - """ - Create or return a WLAN interface. - - Args: - interface: Interface type (STA_IF or AP_IF) - - Returns: - MockWLAN instance - """ - if interface not in self._wlan_instances: - self._wlan_instances[interface] = self.MockWLAN(interface, self._connected) - return self._wlan_instances[interface] - - def set_connected(self, connected): - """ - Change the connection state of all WLAN interfaces. - - Args: - connected: New connection state - """ - self._connected = connected - for wlan in self._wlan_instances.values(): - wlan._connected = connected - - -class MockRaw: - """ - Mock raw HTTP response for streaming. - - Simulates the 'raw' attribute of requests.Response for chunked reading. - """ - - def __init__(self, content, fail_after_bytes=None): - """ - Initialize mock raw response. - - Args: - content: Binary content to stream - fail_after_bytes: If set, raise OSError(-113) after reading this many bytes - """ - self.content = content - self.position = 0 - self.fail_after_bytes = fail_after_bytes - - def read(self, size): - """ - Read a chunk of data. - - Args: - size: Number of bytes to read - - Returns: - bytes: Chunk of data (may be smaller than size at end of stream) - - Raises: - OSError: If fail_after_bytes is set and reached - """ - # Check if we should simulate network failure - if self.fail_after_bytes is not None and self.position >= self.fail_after_bytes: - raise OSError(-113, "ECONNABORTED") - - chunk = self.content[self.position:self.position + size] - self.position += len(chunk) - return chunk - - -class MockResponse: - """ - Mock HTTP response. - - Simulates requests.Response object with status code, text, headers, etc. - """ - - def __init__(self, status_code=200, text='', headers=None, content=b'', fail_after_bytes=None): - """ - Initialize mock response. - - Args: - status_code: HTTP status code (default: 200) - text: Response text content (default: '') - headers: Response headers dict (default: {}) - content: Binary response content (default: b'') - fail_after_bytes: If set, raise OSError after reading this many bytes - """ - self.status_code = status_code - self.text = text - self.headers = headers or {} - self.content = content - self._closed = False - - # Mock raw attribute for streaming - self.raw = MockRaw(content, fail_after_bytes=fail_after_bytes) - - def close(self): - """Close the response.""" - self._closed = True - - def json(self): - """Parse response as JSON.""" - import json - return json.loads(self.text) - - -class MockRequests: - """ - Mock requests module for testing HTTP operations. - - Provides configurable mock responses and exception injection for testing - HTTP client code without making actual network requests. - """ - - def __init__(self): - """Initialize mock requests module.""" - self.last_url = None - self.last_headers = None - self.last_timeout = None - self.last_stream = None - self.last_request = None # Full request info dict - self.next_response = None - self.raise_exception = None - self.call_history = [] - - def get(self, url, stream=False, timeout=None, headers=None): - """ - Mock GET request. - - Args: - url: URL to fetch - stream: Whether to stream the response - timeout: Request timeout in seconds - headers: Request headers dict - - Returns: - MockResponse object - - Raises: - Exception: If an exception was configured via set_exception() - """ - self.last_url = url - self.last_headers = headers - self.last_timeout = timeout - self.last_stream = stream - - # Store full request info - self.last_request = { - 'method': 'GET', - 'url': url, - 'stream': stream, - 'timeout': timeout, - 'headers': headers or {} - } - - # Record call in history - self.call_history.append(self.last_request.copy()) - - if self.raise_exception: - exc = self.raise_exception - self.raise_exception = None # Clear after raising - raise exc - - if self.next_response: - response = self.next_response - self.next_response = None # Clear after returning - return response - - # Default response - return MockResponse() - - def post(self, url, data=None, json=None, timeout=None, headers=None): - """ - Mock POST request. - - Args: - url: URL to post to - data: Form data to send - json: JSON data to send - timeout: Request timeout in seconds - headers: Request headers dict - - Returns: - MockResponse object - - Raises: - Exception: If an exception was configured via set_exception() - """ - self.last_url = url - self.last_headers = headers - self.last_timeout = timeout - - # Record call in history - self.call_history.append({ - 'method': 'POST', - 'url': url, - 'data': data, - 'json': json, - 'timeout': timeout, - 'headers': headers - }) - - if self.raise_exception: - exc = self.raise_exception - self.raise_exception = None - raise exc - - if self.next_response: - response = self.next_response - self.next_response = None - return response - - return MockResponse() - - def set_next_response(self, status_code=200, text='', headers=None, content=b'', fail_after_bytes=None): - """ - Configure the next response to return. - - Args: - status_code: HTTP status code (default: 200) - text: Response text (default: '') - headers: Response headers dict (default: {}) - content: Binary response content (default: b'') - fail_after_bytes: If set, raise OSError after reading this many bytes - - Returns: - MockResponse: The configured response object - """ - self.next_response = MockResponse(status_code, text, headers, content, fail_after_bytes=fail_after_bytes) - return self.next_response - - def set_exception(self, exception): - """ - Configure an exception to raise on the next request. - - Args: - exception: Exception instance to raise - """ - self.raise_exception = exception - - def clear_history(self): - """Clear the call history.""" - self.call_history = [] - - -class MockJSON: - """ - Mock JSON module for testing JSON parsing. - - Allows injection of parse errors for testing error handling. - """ - - def __init__(self): - """Initialize mock JSON module.""" - self.raise_exception = None - - def loads(self, text): - """ - Parse JSON string. - - Args: - text: JSON string to parse - - Returns: - Parsed JSON object - - Raises: - Exception: If an exception was configured via set_exception() - """ - if self.raise_exception: - exc = self.raise_exception - self.raise_exception = None - raise exc - - # Use Python's real json module for actual parsing - import json - return json.loads(text) - - def dumps(self, obj): - """ - Serialize object to JSON string. - - Args: - obj: Object to serialize - - Returns: - str: JSON string - """ - import json - return json.dumps(obj) - - def set_exception(self, exception): - """ - Configure an exception to raise on the next loads() call. - - Args: - exception: Exception instance to raise - """ - self.raise_exception = exception - - -class MockTimer: - """ - Mock Timer for testing periodic callbacks. - - Simulates machine.Timer without actual delays. Useful for testing - code that uses timers for periodic tasks. - """ - - # Class-level registry of all timers - _all_timers = {} - _next_timer_id = 0 - - PERIODIC = 1 - ONE_SHOT = 0 - - def __init__(self, timer_id): - """ - Initialize mock timer. - - Args: - timer_id: Timer ID (0-3 on most MicroPython platforms) - """ - self.timer_id = timer_id - self.callback = None - self.period = None - self.mode = None - self.active = False - MockTimer._all_timers[timer_id] = self - - def init(self, period=None, mode=None, callback=None): - """ - Initialize/configure the timer. - - Args: - period: Timer period in milliseconds - mode: Timer mode (PERIODIC or ONE_SHOT) - callback: Callback function to call on timer fire - """ - self.period = period - self.mode = mode - self.callback = callback - self.active = True - - def deinit(self): - """Deinitialize the timer.""" - self.active = False - self.callback = None - - def trigger(self, *args, **kwargs): - """ - Manually trigger the timer callback (for testing). - - Args: - *args: Arguments to pass to callback - **kwargs: Keyword arguments to pass to callback - """ - if self.callback and self.active: - self.callback(*args, **kwargs) - - @classmethod - def get_timer(cls, timer_id): - """ - Get a timer by ID. - - Args: - timer_id: Timer ID to retrieve - - Returns: - MockTimer instance or None if not found - """ - return cls._all_timers.get(timer_id) - - @classmethod - def trigger_all(cls): - """Trigger all active timers (for testing).""" - for timer in cls._all_timers.values(): - if timer.active: - timer.trigger() - - @classmethod - def reset_all(cls): - """Reset all timers (clear registry).""" - cls._all_timers.clear() - - -class MockSocket: - """ - Mock socket for testing socket operations. - - Simulates usocket module without actual network I/O. - """ - - AF_INET = 2 - SOCK_STREAM = 1 - - def __init__(self, af=None, sock_type=None): - """ - Initialize mock socket. - - Args: - af: Address family (AF_INET, etc.) - sock_type: Socket type (SOCK_STREAM, etc.) - """ - self.af = af - self.sock_type = sock_type - self.connected = False - self.bound = False - self.listening = False - self.address = None - self.port = None - self._send_exception = None - self._recv_data = b'' - self._recv_position = 0 - - def connect(self, address): - """ - Simulate connecting to an address. - - Args: - address: Tuple of (host, port) - """ - self.connected = True - self.address = address - - def bind(self, address): - """ - Simulate binding to an address. - - Args: - address: Tuple of (host, port) - """ - self.bound = True - self.address = address - - def listen(self, backlog): - """ - Simulate listening for connections. - - Args: - backlog: Maximum number of queued connections - """ - self.listening = True - - def send(self, data): - """ - Simulate sending data. - - Args: - data: Bytes to send - - Returns: - int: Number of bytes sent - - Raises: - Exception: If configured via set_send_exception() - """ - if self._send_exception: - exc = self._send_exception - self._send_exception = None - raise exc - return len(data) - - def recv(self, size): - """ - Simulate receiving data. - - Args: - size: Maximum bytes to receive - - Returns: - bytes: Received data - """ - chunk = self._recv_data[self._recv_position:self._recv_position + size] - self._recv_position += len(chunk) - return chunk - - def close(self): - """Close the socket.""" - self.connected = False - - def set_send_exception(self, exception): - """ - Configure an exception to raise on next send(). - - Args: - exception: Exception instance to raise - """ - self._send_exception = exception - - def set_recv_data(self, data): - """ - Configure data to return from recv(). - - Args: - data: Bytes to return from recv() calls - """ - self._recv_data = data - self._recv_position = 0 - - +# Re-export all mocks from centralized module for backward compatibility +from mpos.testing import ( + # Hardware mocks + MockMachine, + MockPin, + MockPWM, + MockI2S, + MockTimer, + MockSocket, + + # MPOS mocks + MockTaskManager, + MockTask, + MockDownloadManager, + + # Network mocks + MockNetwork, + MockRequests, + MockResponse, + MockRaw, + + # Utility mocks + MockTime, + MockJSON, + MockModule, + + # Helper functions + inject_mocks, + create_mock_module, +) + +# For backward compatibility, also provide socket() function def socket(af=MockSocket.AF_INET, sock_type=MockSocket.SOCK_STREAM): """ Create a mock socket. @@ -602,81 +60,33 @@ def socket(af=MockSocket.AF_INET, sock_type=MockSocket.SOCK_STREAM): return MockSocket(af, sock_type) -class MockTime: - """ - Mock time module for testing time-dependent code. - - Allows manual control of time progression for deterministic testing. - """ - - def __init__(self, start_time=0): - """ - Initialize mock time module. - - Args: - start_time: Initial time in milliseconds (default: 0) - """ - self._current_time_ms = start_time - self._sleep_calls = [] - - def ticks_ms(self): - """ - Get current time in milliseconds. - - Returns: - int: Current time in milliseconds - """ - return self._current_time_ms - - def ticks_diff(self, ticks1, ticks2): - """ - Calculate difference between two tick values. - - Args: - ticks1: End time - ticks2: Start time - - Returns: - int: Difference in milliseconds - """ - return ticks1 - ticks2 - - def sleep(self, seconds): - """ - Simulate sleep (doesn't actually sleep). - - Args: - seconds: Number of seconds to sleep - """ - self._sleep_calls.append(seconds) - - def sleep_ms(self, milliseconds): - """ - Simulate sleep in milliseconds. - - Args: - milliseconds: Number of milliseconds to sleep - """ - self._sleep_calls.append(milliseconds / 1000.0) - - def advance(self, milliseconds): - """ - Advance the mock time. - - Args: - milliseconds: Number of milliseconds to advance - """ - self._current_time_ms += milliseconds - - def get_sleep_calls(self): - """ - Get history of sleep calls. - - Returns: - list: List of sleep durations in seconds - """ - return self._sleep_calls - - def clear_sleep_calls(self): - """Clear the sleep call history.""" - self._sleep_calls = [] +__all__ = [ + # Hardware mocks + 'MockMachine', + 'MockPin', + 'MockPWM', + 'MockI2S', + 'MockTimer', + 'MockSocket', + + # MPOS mocks + 'MockTaskManager', + 'MockTask', + 'MockDownloadManager', + + # Network mocks + 'MockNetwork', + 'MockRequests', + 'MockResponse', + 'MockRaw', + + # Utility mocks + 'MockTime', + 'MockJSON', + 'MockModule', + + # Helper functions + 'inject_mocks', + 'create_mock_module', + 'socket', +] diff --git a/tests/test_audioflinger.py b/tests/test_audioflinger.py index 039d6b1d..da9414ee 100644 --- a/tests/test_audioflinger.py +++ b/tests/test_audioflinger.py @@ -2,66 +2,22 @@ import unittest import sys - -# Mock hardware before importing -class MockPWM: - def __init__(self, pin, freq=0, duty=0): - self.pin = pin - self.last_freq = freq - self.last_duty = duty - - def freq(self, value=None): - if value is not None: - self.last_freq = value - return self.last_freq - - def duty_u16(self, value=None): - if value is not None: - self.last_duty = value - return self.last_duty - - -class MockPin: - IN = 0 - OUT = 1 - - def __init__(self, pin_number, mode=None): - self.pin_number = pin_number - self.mode = mode - - -# Inject mocks -class MockMachine: - PWM = MockPWM - Pin = MockPin -sys.modules['machine'] = MockMachine() - -class MockLock: - def acquire(self): - pass - def release(self): - pass - -class MockThread: - @staticmethod - def allocate_lock(): - return MockLock() - @staticmethod - def start_new_thread(func, args, **kwargs): - pass # No-op for testing - @staticmethod - def stack_size(size=None): - return 16384 if size is None else None - -sys.modules['_thread'] = MockThread() - -class MockMposApps: - @staticmethod - def good_stack_size(): - return 16384 - -sys.modules['mpos.apps'] = MockMposApps() - +# 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 @@ -79,7 +35,6 @@ def setUp(self): AudioFlinger.set_volume(70) AudioFlinger.init( - device_type=AudioFlinger.DEVICE_BOTH, i2s_pins=self.i2s_pins, buzzer_instance=self.buzzer ) @@ -90,16 +45,28 @@ def tearDown(self): def test_initialization(self): """Test that AudioFlinger initializes correctly.""" - self.assertEqual(AudioFlinger.get_device_type(), AudioFlinger.DEVICE_BOTH) self.assertEqual(AudioFlinger._i2s_pins, self.i2s_pins) self.assertEqual(AudioFlinger._buzzer_instance, self.buzzer) - def test_device_types(self): - """Test device type constants.""" - self.assertEqual(AudioFlinger.DEVICE_NULL, 0) - self.assertEqual(AudioFlinger.DEVICE_I2S, 1) - self.assertEqual(AudioFlinger.DEVICE_BUZZER, 2) - self.assertEqual(AudioFlinger.DEVICE_BOTH, 3) + def test_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.""" @@ -124,61 +91,37 @@ def test_volume_control(self): AudioFlinger.set_volume(-10) self.assertEqual(AudioFlinger.get_volume(), 0) - def test_device_null_rejects_playback(self): - """Test that DEVICE_NULL rejects all playback requests.""" - # Re-initialize with no device - AudioFlinger.init( - device_type=AudioFlinger.DEVICE_NULL, - i2s_pins=None, - buzzer_instance=None - ) + 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 + # WAV should be rejected (no I2S) result = AudioFlinger.play_wav("test.wav") self.assertFalse(result) - # RTTTL should be rejected + # RTTTL should be rejected (no buzzer) result = AudioFlinger.play_rtttl("Test:d=4,o=5,b=120:c") self.assertFalse(result) - def test_device_i2s_only_rejects_rtttl(self): - """Test that DEVICE_I2S rejects buzzer playback.""" + def test_i2s_only_rejects_rtttl(self): + """Test that I2S-only config rejects buzzer playback.""" # Re-initialize with I2S only - AudioFlinger.init( - device_type=AudioFlinger.DEVICE_I2S, - i2s_pins=self.i2s_pins, - buzzer_instance=None - ) + 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_device_buzzer_only_rejects_wav(self): - """Test that DEVICE_BUZZER rejects I2S playback.""" + def test_buzzer_only_rejects_wav(self): + """Test that buzzer-only config rejects I2S playback.""" # Re-initialize with buzzer only - AudioFlinger.init( - device_type=AudioFlinger.DEVICE_BUZZER, - i2s_pins=None, - buzzer_instance=self.buzzer - ) + 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_missing_i2s_pins_rejects_wav(self): - """Test that missing I2S pins rejects WAV playback.""" - # Re-initialize with I2S device but no pins - AudioFlinger.init( - device_type=AudioFlinger.DEVICE_I2S, - i2s_pins=None, - buzzer_instance=None - ) - - result = AudioFlinger.play_wav("test.wav") - self.assertFalse(result) - def test_is_playing_initially_false(self): """Test that is_playing() returns False initially.""" self.assertFalse(AudioFlinger.is_playing()) @@ -189,55 +132,75 @@ def test_stop_with_no_playback(self): AudioFlinger.stop() self.assertFalse(AudioFlinger.is_playing()) - def test_get_device_type(self): - """Test that get_device_type() returns correct value.""" - # Test DEVICE_BOTH - AudioFlinger.init( - device_type=AudioFlinger.DEVICE_BOTH, - i2s_pins=self.i2s_pins, - buzzer_instance=self.buzzer - ) - self.assertEqual(AudioFlinger.get_device_type(), AudioFlinger.DEVICE_BOTH) - - # Test DEVICE_I2S - AudioFlinger.init( - device_type=AudioFlinger.DEVICE_I2S, - i2s_pins=self.i2s_pins, - buzzer_instance=None - ) - self.assertEqual(AudioFlinger.get_device_type(), AudioFlinger.DEVICE_I2S) - - # Test DEVICE_BUZZER - AudioFlinger.init( - device_type=AudioFlinger.DEVICE_BUZZER, - i2s_pins=None, - buzzer_instance=self.buzzer - ) - self.assertEqual(AudioFlinger.get_device_type(), AudioFlinger.DEVICE_BUZZER) - - # Test DEVICE_NULL - AudioFlinger.init( - device_type=AudioFlinger.DEVICE_NULL, - i2s_pins=None, - buzzer_instance=None - ) - self.assertEqual(AudioFlinger.get_device_type(), AudioFlinger.DEVICE_NULL) - def test_audio_focus_check_no_current_stream(self): """Test audio focus allows playback when no stream is active.""" result = AudioFlinger._check_audio_focus(AudioFlinger.STREAM_MUSIC) self.assertTrue(result) - def test_init_creates_lock(self): - """Test that initialization creates a stream lock.""" - self.assertIsNotNone(AudioFlinger._stream_lock) - def test_volume_default_value(self): """Test that default volume is reasonable.""" # After init, volume should be at default (70) + AudioFlinger.init(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( - device_type=AudioFlinger.DEVICE_NULL, - i2s_pins=None, - buzzer_instance=None + i2s_pins=self.i2s_pins_with_mic, + buzzer_instance=self.buzzer ) - self.assertEqual(AudioFlinger.get_volume(), 70) + + 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_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 index 9ccd7955..6bf71883 100644 --- a/tests/test_graphical_camera_settings.py +++ b/tests/test_graphical_camera_settings.py @@ -72,6 +72,32 @@ def tearDown(self): except: pass # Already on launcher or error + def _find_and_click_settings_button(self, screen, use_send_event=True): + """Find and click the settings button using lv.SYMBOL.SETTINGS. + + Args: + screen: LVGL screen object to search + use_send_event: If True (default), use send_event() which is more reliable. + If False, use simulate_click() with coordinates. + + Returns True if button was found and clicked, False otherwise. + """ + settings_button = find_button_with_text(screen, lv.SYMBOL.SETTINGS) + if settings_button: + coords = get_widget_coords(settings_button) + print(f"Found settings button at ({coords['center_x']}, {coords['center_y']})") + if use_send_event: + # Use send_event for more reliable button triggering + settings_button.send_event(lv.EVENT.CLICKED, None) + print("Clicked settings button using send_event()") + else: + simulate_click(coords['center_x'], coords['center_y'], press_duration_ms=100) + print("Clicked settings button using simulate_click()") + return True + else: + print("Settings button not found via lv.SYMBOL.SETTINGS") + return False + def test_settings_button_click_no_crash(self): """ Test that clicking the settings button doesn't cause a segfault. @@ -83,7 +109,7 @@ def test_settings_button_click_no_crash(self): 1. Start camera app 2. Wait for camera to initialize 3. Capture initial screenshot - 4. Click settings button (top-right corner) + 4. Click settings button (found dynamically by lv.SYMBOL.SETTINGS) 5. Verify settings dialog opened 6. If we get here without crash, test passes """ @@ -108,18 +134,12 @@ def test_settings_button_click_no_crash(self): print(f"\nCapturing initial screenshot: {screenshot_path}") capture_screenshot(screenshot_path, width=320, height=240) - # Find and click settings button - # The settings button is positioned at TOP_RIGHT with offset (0, 60) - # On a 320x240 screen, this is approximately x=260, y=90 - # We'll click slightly inside the button to ensure we hit it - settings_x = 300 # Right side of screen, inside the 60px button - settings_y = 100 # 60px down from top, center of 60px button + # Find and click settings button dynamically + found = self._find_and_click_settings_button(screen) + self.assertTrue(found, "Settings button with lv.SYMBOL.SETTINGS not found on screen") - print(f"\nClicking settings button at ({settings_x}, {settings_y})") - simulate_click(settings_x, settings_y, press_duration_ms=100) - - # Wait for settings dialog to appear - wait_for_render(iterations=20) + # Wait for settings dialog to appear - needs more time for Activity transition + wait_for_render(iterations=50) # Get screen again (might have changed after navigation) screen = lv.screen_active() @@ -128,19 +148,26 @@ def test_settings_button_click_no_crash(self): print("\nScreen labels after clicking settings:") print_screen_labels(screen) - # Verify settings screen opened - # Look for "Camera Settings" or "resolution" text - has_settings_ui = ( - verify_text_present(screen, "Camera Settings") or - verify_text_present(screen, "Resolution") or - verify_text_present(screen, "resolution") or - verify_text_present(screen, "Save") or - verify_text_present(screen, "Cancel") - ) + # Verify settings screen opened by looking for the Save button + # This is more reliable than text search since buttons are always present + save_button = find_button_with_text(screen, "Save") + cancel_button = find_button_with_text(screen, "Cancel") + + has_settings_ui = save_button is not None or cancel_button is not None + + # Also try text-based verification as fallback + if not has_settings_ui: + has_settings_ui = ( + verify_text_present(screen, "Camera Settings") or + verify_text_present(screen, "Resolution") or + verify_text_present(screen, "resolution") or + verify_text_present(screen, "Basic") or # Tab name + verify_text_present(screen, "Color Mode") # Setting name + ) self.assertTrue( has_settings_ui, - "Settings screen did not open (no expected UI elements found)" + "Settings screen did not open (no Save/Cancel buttons or expected UI elements found)" ) # Capture screenshot of settings dialog @@ -151,15 +178,68 @@ def test_settings_button_click_no_crash(self): # If we got here without segfault, the test passes! print("\nāœ“ Settings button clicked successfully without crash!") + def _find_and_click_button(self, screen, text, use_send_event=True): + """Find and click a button by its text label. + + Args: + screen: LVGL screen object to search + text: Text to search for in button labels + use_send_event: If True (default), use send_event() which is more reliable. + If False, use simulate_click() with coordinates. + + Returns True if button was found and clicked, False otherwise. + """ + button = find_button_with_text(screen, text) + if button: + coords = get_widget_coords(button) + print(f"Found '{text}' button at ({coords['center_x']}, {coords['center_y']})") + if use_send_event: + # Use send_event for more reliable button triggering + button.send_event(lv.EVENT.CLICKED, None) + print(f"Clicked '{text}' button using send_event()") + else: + simulate_click(coords['center_x'], coords['center_y'], press_duration_ms=100) + print(f"Clicked '{text}' button using simulate_click()") + return True + else: + print(f"Button with text '{text}' not found") + return False + + def _find_dropdown(self, screen): + """Find a dropdown widget on the screen. + + Returns the dropdown widget or None if not found. + """ + def find_dropdown_recursive(obj): + # Check if this object is a dropdown + try: + if obj.__class__.__name__ == 'dropdown' or hasattr(obj, 'get_selected'): + # Verify it's actually a dropdown by checking for dropdown-specific method + if hasattr(obj, 'get_options'): + return obj + except: + pass + + # Check children + child_count = obj.get_child_count() + for i in range(child_count): + child = obj.get_child(i) + result = find_dropdown_recursive(child) + if result: + return result + return None + + return find_dropdown_recursive(screen) + def test_resolution_change_no_crash(self): """ Test that changing resolution doesn't cause a crash. This tests the full resolution change workflow: 1. Start camera app - 2. Open settings - 3. Change resolution - 4. Save settings + 2. Open settings (found dynamically by lv.SYMBOL.SETTINGS) + 3. Change resolution via dropdown + 4. Save settings (found dynamically by "Save" text) 5. Verify camera continues working This verifies fixes for: @@ -176,61 +256,63 @@ def test_resolution_change_no_crash(self): # Wait for camera to initialize wait_for_render(iterations=30) - # Click settings button + # Click settings button dynamically + screen = lv.screen_active() print("\nOpening settings...") - simulate_click(290, 90, press_duration_ms=100) + found = self._find_and_click_settings_button(screen) + self.assertTrue(found, "Settings button with lv.SYMBOL.SETTINGS not found on screen") wait_for_render(iterations=20) screen = lv.screen_active() - # Try to find the dropdown/resolution selector - # The CameraSettingsActivity creates a dropdown widget - # Let's look for any dropdown on screen + # Try to find the dropdown/resolution selector dynamically print("\nLooking for resolution dropdown...") + dropdown = self._find_dropdown(screen) + + if dropdown: + # Click the dropdown to open it + coords = get_widget_coords(dropdown) + print(f"Found dropdown at ({coords['center_x']}, {coords['center_y']})") + simulate_click(coords['center_x'], coords['center_y'], press_duration_ms=100) + wait_for_render(iterations=15) + + # Get current selection and try to change it + try: + current = dropdown.get_selected() + option_count = dropdown.get_option_count() + print(f"Dropdown has {option_count} options, current selection: {current}") + + # Select a different option (next one, or first if at end) + new_selection = (current + 1) % option_count + dropdown.set_selected(new_selection) + print(f"Changed selection to: {new_selection}") + except Exception as e: + print(f"Could not change dropdown selection: {e}") + # Fallback: click below current position to select different option + simulate_click(coords['center_x'], coords['center_y'] + 30, press_duration_ms=100) + else: + print("Dropdown not found, test may not fully exercise resolution change") - # Find all clickable objects (dropdowns are clickable) - # We'll try clicking in the middle area where the dropdown should be - # Dropdown is typically centered, so around x=160, y=120 - dropdown_x = 160 - dropdown_y = 120 - - print(f"Clicking dropdown area at ({dropdown_x}, {dropdown_y})") - simulate_click(dropdown_x, dropdown_y, press_duration_ms=100) wait_for_render(iterations=15) - # The dropdown should now be open showing resolution options - # Let's capture what we see + # Capture screenshot screenshot_path = f"{self.screenshot_dir}/camera_dropdown_open.raw" print(f"Capturing dropdown screenshot: {screenshot_path}") capture_screenshot(screenshot_path, width=320, height=240) screen = lv.screen_active() - print("\nScreen after opening dropdown:") + print("\nScreen after dropdown interaction:") print_screen_labels(screen) - # Try to select a different resolution - # Options are typically stacked vertically - # Let's click a bit lower to select a different option - option_x = 160 - option_y = 150 # Below the current selection - - print(f"\nSelecting different resolution at ({option_x}, {option_y})") - simulate_click(option_x, option_y, press_duration_ms=100) - wait_for_render(iterations=15) - - # Now find and click the Save button + # Find and click the Save button dynamically print("\nLooking for Save button...") - save_button = find_button_with_text(lv.screen_active(), "Save") - - if save_button: - coords = get_widget_coords(save_button) - print(f"Found Save button at {coords}") - simulate_click(coords['center_x'], coords['center_y'], press_duration_ms=100) - else: - # Fallback: Save button is typically at bottom-left - # Based on CameraSettingsActivity code: ALIGN.BOTTOM_LEFT - print("Save button not found via text, trying bottom-left corner") - simulate_click(80, 220, press_duration_ms=100) + save_found = self._find_and_click_button(lv.screen_active(), "Save") + + if not save_found: + # Try "OK" as alternative + save_found = self._find_and_click_button(lv.screen_active(), "OK") + + self.assertTrue(save_found, "Save/OK button not found on settings screen") # Wait for reconfiguration to complete print("\nWaiting for reconfiguration...") @@ -244,12 +326,18 @@ def test_resolution_change_no_crash(self): # If we got here without segfault, the test passes! print("\nāœ“ Resolution changed successfully without crash!") - # Verify camera is still showing something + # Verify camera is still showing something by checking for camera UI elements screen = lv.screen_active() # The camera app should still be active (not crashed back to launcher) - # We can check this by looking for camera-specific UI elements - # or just the fact that we haven't crashed - + # Check for camera-specific buttons (close, settings, snap, qr) + has_camera_ui = ( + find_button_with_text(screen, lv.SYMBOL.CLOSE) or + find_button_with_text(screen, lv.SYMBOL.SETTINGS) or + find_button_with_text(screen, lv.SYMBOL.OK) or + find_button_with_text(screen, lv.SYMBOL.EYE_OPEN) + ) + + self.assertTrue(has_camera_ui, "Camera app UI not found after resolution change - app may have crashed") print("\nāœ“ Camera app still running after resolution change!") diff --git a/tests/test_graphical_imu_calibration.py b/tests/test_graphical_imu_calibration.py index be761b35..08457d27 100644 --- a/tests/test_graphical_imu_calibration.py +++ b/tests/test_graphical_imu_calibration.py @@ -24,7 +24,10 @@ print_screen_labels, simulate_click, get_widget_coords, - find_button_with_text + find_button_with_text, + click_label, + click_button, + find_text_on_screen ) @@ -68,16 +71,9 @@ def test_check_calibration_activity_loads(self): simulate_click(10, 10) wait_for_render(10) - # Find and click "Check IMU Calibration" setting - screen = lv.screen_active() - check_cal_label = find_label_with_text(screen, "Check IMU Calibration") - self.assertIsNotNone(check_cal_label, "Could not find 'Check IMU Calibration' setting") - - # Click on the setting container - coords = get_widget_coords(check_cal_label.get_parent()) - self.assertIsNotNone(coords, "Could not get coordinates of setting") - simulate_click(coords['center_x'], coords['center_y']) - wait_for_render(30) + print("Clicking 'Check IMU Calibration' menu item...") + self.assertTrue(click_label("Check IMU Calibration"), "Could not find Check IMU Calibration menu item") + wait_for_render(iterations=20) # Verify key elements are present screen = lv.screen_active() @@ -110,15 +106,9 @@ def test_calibrate_activity_flow(self): simulate_click(10, 10) wait_for_render(10) - # Find and click "Calibrate IMU" setting - screen = lv.screen_active() - calibrate_label = find_label_with_text(screen, "Calibrate IMU") - self.assertIsNotNone(calibrate_label, "Could not find 'Calibrate IMU' setting") - - coords = get_widget_coords(calibrate_label.get_parent()) - self.assertIsNotNone(coords) - simulate_click(coords['center_x'], coords['center_y']) - wait_for_render(30) + print("Clicking 'Calibrate IMU' menu item...") + self.assertTrue(click_label("Calibrate IMU"), "Could not find Calibrate IMU item") + wait_for_render(iterations=20) # Verify activity loaded and shows instructions screen = lv.screen_active() @@ -135,13 +125,13 @@ def test_calibrate_activity_flow(self): # Click "Calibrate Now" button to start calibration calibrate_btn = find_button_with_text(screen, "Calibrate Now") self.assertIsNotNone(calibrate_btn, "Could not find 'Calibrate Now' button") - coords = get_widget_coords(calibrate_btn) - simulate_click(coords['center_x'], coords['center_y']) + # Use send_event instead of simulate_click (more reliable) + calibrate_btn.send_event(lv.EVENT.CLICKED, None) wait_for_render(10) # Wait for calibration to complete (mock takes ~3 seconds) - time.sleep(3.5) - wait_for_render(20) + time.sleep(4) + wait_for_render(40) # Verify calibration completed screen = lv.screen_active() @@ -173,17 +163,12 @@ def test_navigation_from_check_to_calibrate(self): simulate_click(10, 10) wait_for_render(10) - screen = lv.screen_active() - check_cal_label = find_label_with_text(screen, "Check IMU Calibration") - coords = get_widget_coords(check_cal_label.get_parent()) - simulate_click(coords['center_x'], coords['center_y']) - wait_for_render(30) # Wait for real-time updates - - # Verify Check activity loaded - screen = lv.screen_active() - self.assertTrue(verify_text_present(screen, "on flat surface"), "Check activity did not load") + print("Clicking 'Check IMU Calibration' menu item...") + self.assertTrue(click_label("Check IMU Calibration"), "Could not find Check IMU Calibration menu item") + wait_for_render(iterations=20) # Click "Calibrate" button to navigate to Calibrate activity + screen = lv.screen_active() calibrate_btn = find_button_with_text(screen, "Calibrate") self.assertIsNotNone(calibrate_btn, "Could not find 'Calibrate' button") diff --git a/tests/test_graphical_imu_calibration_ui_bug.py b/tests/test_graphical_imu_calibration_ui_bug.py new file mode 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 f1e0c54b..adeb6f8b 100644 --- a/tests/test_graphical_keyboard_animation.py +++ b/tests/test_graphical_keyboard_animation.py @@ -13,31 +13,11 @@ import lvgl as lv import time import mpos.ui.anim -from mpos.ui.keyboard import MposKeyboard -from mpos.ui.testing import wait_for_render +from base import KeyboardTestBase -class TestKeyboardAnimation(unittest.TestCase): - """Test MposKeyboard compatibility with animation system.""" - - def setUp(self): - """Set up test fixtures.""" - # Create a test screen - self.screen = lv.obj() - self.screen.set_size(320, 240) - lv.screen_load(self.screen) - - # Create textarea - self.textarea = lv.textarea(self.screen) - self.textarea.set_size(280, 40) - self.textarea.align(lv.ALIGN.TOP_MID, 0, 10) - self.textarea.set_one_line(True) - - print("\n=== Animation Test Setup Complete ===") - def tearDown(self): - """Clean up after test.""" - lv.screen_load(lv.obj()) - print("=== Test Cleanup Complete ===\n") +class TestKeyboardAnimation(KeyboardTestBase): + """Test MposKeyboard compatibility with animation system.""" def test_keyboard_has_set_style_opa(self): """ @@ -47,24 +27,22 @@ def test_keyboard_has_set_style_opa(self): """ print("Testing that MposKeyboard has set_style_opa...") - keyboard = MposKeyboard(self.screen) - keyboard.set_textarea(self.textarea) - keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) - keyboard.add_flag(lv.obj.FLAG.HIDDEN) + self.create_keyboard_scene() + self.keyboard.add_flag(lv.obj.FLAG.HIDDEN) # Verify method exists self.assertTrue( - hasattr(keyboard, 'set_style_opa'), + hasattr(self.keyboard, 'set_style_opa'), "MposKeyboard missing set_style_opa method" ) self.assertTrue( - callable(getattr(keyboard, 'set_style_opa')), + callable(getattr(self.keyboard, 'set_style_opa')), "MposKeyboard.set_style_opa is not callable" ) # Try calling it (should not raise AttributeError) try: - keyboard.set_style_opa(128, 0) + self.keyboard.set_style_opa(128, 0) print("set_style_opa called successfully") except AttributeError as e: self.fail(f"set_style_opa raised AttributeError: {e}") @@ -79,15 +57,13 @@ def test_keyboard_smooth_show(self): """ print("Testing smooth_show animation...") - keyboard = MposKeyboard(self.screen) - keyboard.set_textarea(self.textarea) - keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) - keyboard.add_flag(lv.obj.FLAG.HIDDEN) + self.create_keyboard_scene() + self.keyboard.add_flag(lv.obj.FLAG.HIDDEN) # This should work without raising AttributeError try: - mpos.ui.anim.smooth_show(keyboard) - wait_for_render(100) + mpos.ui.anim.smooth_show(self.keyboard) + self.wait_for_render(100) print("smooth_show called successfully") except AttributeError as e: self.fail(f"smooth_show raised AttributeError: {e}\n" @@ -95,7 +71,7 @@ def test_keyboard_smooth_show(self): # Verify keyboard is no longer hidden self.assertFalse( - keyboard.has_flag(lv.obj.FLAG.HIDDEN), + self.keyboard.has_flag(lv.obj.FLAG.HIDDEN), "Keyboard should not be hidden after smooth_show" ) @@ -109,15 +85,13 @@ def test_keyboard_smooth_hide(self): """ print("Testing smooth_hide animation...") - keyboard = MposKeyboard(self.screen) - keyboard.set_textarea(self.textarea) - keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + self.create_keyboard_scene() # Start visible - keyboard.remove_flag(lv.obj.FLAG.HIDDEN) + self.keyboard.remove_flag(lv.obj.FLAG.HIDDEN) # This should work without raising AttributeError try: - mpos.ui.anim.smooth_hide(keyboard) + mpos.ui.anim.smooth_hide(self.keyboard) print("smooth_hide called successfully") except AttributeError as e: self.fail(f"smooth_hide raised AttributeError: {e}\n" @@ -135,28 +109,26 @@ def test_keyboard_show_hide_cycle(self): """ print("Testing full show/hide cycle...") - keyboard = MposKeyboard(self.screen) - keyboard.set_textarea(self.textarea) - keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) - keyboard.add_flag(lv.obj.FLAG.HIDDEN) + self.create_keyboard_scene() + self.keyboard.add_flag(lv.obj.FLAG.HIDDEN) # Initial state: hidden - self.assertTrue(keyboard.has_flag(lv.obj.FLAG.HIDDEN)) + self.assertTrue(self.keyboard.has_flag(lv.obj.FLAG.HIDDEN)) # Show keyboard (simulates textarea click) try: - mpos.ui.anim.smooth_show(keyboard) - wait_for_render(100) + mpos.ui.anim.smooth_show(self.keyboard) + self.wait_for_render(100) except AttributeError as e: self.fail(f"Failed during smooth_show: {e}") # Should be visible now - self.assertFalse(keyboard.has_flag(lv.obj.FLAG.HIDDEN)) + self.assertFalse(self.keyboard.has_flag(lv.obj.FLAG.HIDDEN)) # Hide keyboard (simulates pressing Enter) try: - mpos.ui.anim.smooth_hide(keyboard) - wait_for_render(100) + mpos.ui.anim.smooth_hide(self.keyboard) + self.wait_for_render(100) except AttributeError as e: self.fail(f"Failed during smooth_hide: {e}") @@ -170,22 +142,19 @@ def test_keyboard_has_get_y_and_set_y(self): """ print("Testing get_y and set_y methods...") - keyboard = MposKeyboard(self.screen) - keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + self.create_keyboard_scene() # Verify methods exist - self.assertTrue(hasattr(keyboard, 'get_y'), "Missing get_y method") - self.assertTrue(hasattr(keyboard, 'set_y'), "Missing set_y method") + self.assertTrue(hasattr(self.keyboard, 'get_y'), "Missing get_y method") + self.assertTrue(hasattr(self.keyboard, 'set_y'), "Missing set_y method") # Try using them try: - y = keyboard.get_y() - keyboard.set_y(y + 10) - new_y = keyboard.get_y() + y = self.keyboard.get_y() + self.keyboard.set_y(y + 10) + new_y = self.keyboard.get_y() print(f"Position test: {y} -> {new_y}") except AttributeError as e: self.fail(f"Position methods raised AttributeError: {e}") print("=== Position methods test PASSED ===") - - diff --git a/tests/test_graphical_keyboard_q_button_bug.py b/tests/test_graphical_keyboard_q_button_bug.py index dae8e307..f9de244f 100644 --- a/tests/test_graphical_keyboard_q_button_bug.py +++ b/tests/test_graphical_keyboard_q_button_bug.py @@ -14,32 +14,11 @@ """ import unittest -import lvgl as lv -from mpos.ui.keyboard import MposKeyboard -from mpos.ui.testing import ( - wait_for_render, - find_button_with_text, - get_widget_coords, - get_keyboard_button_coords, - simulate_click, - print_screen_labels -) - - -class TestKeyboardQButton(unittest.TestCase): - """Test keyboard button functionality (especially 'q' which was at index 0).""" +from base import KeyboardTestBase - def setUp(self): - """Set up test fixtures.""" - self.screen = lv.obj() - self.screen.set_size(320, 240) - lv.screen_load(self.screen) - wait_for_render(5) - def tearDown(self): - """Clean up.""" - lv.screen_load(lv.obj()) - wait_for_render(5) +class TestKeyboardQButton(KeyboardTestBase): + """Test keyboard button functionality (especially 'q' which was at index 0).""" def test_q_button_works(self): """ @@ -51,82 +30,50 @@ def test_q_button_works(self): Steps: 1. Create textarea and keyboard - 2. Find 'q' button index in keyboard map - 3. Get button coordinates from keyboard widget - 4. Click it using simulate_click() - 5. Verify 'q' appears in textarea (should PASS after fix) - 6. Repeat with 'a' button - 7. Verify 'a' appears correctly (should PASS) + 2. Click 'q' button using click_keyboard_button helper + 3. Verify 'q' appears in textarea (should PASS after fix) + 4. Repeat with 'a' button + 5. Verify 'a' appears correctly (should PASS) """ print("\n=== Testing keyboard 'q' and 'a' button behavior ===") - # Create textarea - textarea = lv.textarea(self.screen) - textarea.set_size(200, 30) - textarea.set_one_line(True) - textarea.align(lv.ALIGN.TOP_MID, 0, 10) - textarea.set_text("") # Start empty - wait_for_render(5) - - # Create keyboard and connect to textarea - keyboard = MposKeyboard(self.screen) - keyboard.set_textarea(textarea) - keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) - wait_for_render(20) # increased from 10 to 20 because on macOS this didnt work + # Create keyboard scene (textarea + keyboard) + self.create_keyboard_scene() - print(f"Initial textarea: '{textarea.get_text()}'") - self.assertEqual(textarea.get_text(), "", "Textarea should start empty") + print(f"Initial textarea: '{self.get_textarea_text()}'") + self.assertTextareaEmpty("Textarea should start empty") # --- Test 'q' button --- print("\n--- Testing 'q' button ---") - # Get exact button coordinates using helper function - q_coords = get_keyboard_button_coords(keyboard, "q") - self.assertIsNotNone(q_coords, "Should find 'q' button on keyboard") - - print(f"Found 'q' button at index {q_coords['button_idx']}, row {q_coords['row']}, col {q_coords['col']}") - print(f"Exact 'q' button position: ({q_coords['center_x']}, {q_coords['center_y']})") - - # Click the 'q' button - print(f"Clicking 'q' button at ({q_coords['center_x']}, {q_coords['center_y']})") - simulate_click(q_coords['center_x'], q_coords['center_y']) - wait_for_render(20) # increased from 10 to 20 because on macOS this didnt work + # Click the 'q' button using the reliable click_keyboard_button helper + success = self.click_keyboard_button("q") + self.assertTrue(success, "Should find and click 'q' button on keyboard") # Check textarea content - text_after_q = textarea.get_text() + text_after_q = self.get_textarea_text() print(f"Textarea after clicking 'q': '{text_after_q}'") # Verify 'q' was added (should work after fix) - self.assertEqual(text_after_q, "q", - "Clicking 'q' button should add 'q' to textarea") + self.assertTextareaText("q", "Clicking 'q' button should add 'q' to textarea") # --- Test 'a' button for comparison --- print("\n--- Testing 'a' button (for comparison) ---") # Clear textarea - textarea.set_text("") - wait_for_render(5) + self.clear_textarea() print("Cleared textarea") - # Get exact button coordinates using helper function - a_coords = get_keyboard_button_coords(keyboard, "a") - self.assertIsNotNone(a_coords, "Should find 'a' button on keyboard") - - print(f"Found 'a' button at index {a_coords['button_idx']}, row {a_coords['row']}, col {a_coords['col']}") - print(f"Exact 'a' button position: ({a_coords['center_x']}, {a_coords['center_y']})") - - # Click the 'a' button - print(f"Clicking 'a' button at ({a_coords['center_x']}, {a_coords['center_y']})") - simulate_click(a_coords['center_x'], a_coords['center_y']) - wait_for_render(10) + # Click the 'a' button using the reliable click_keyboard_button helper + success = self.click_keyboard_button("a") + self.assertTrue(success, "Should find and click 'a' button on keyboard") # Check textarea content - text_after_a = textarea.get_text() + text_after_a = self.get_textarea_text() print(f"Textarea after clicking 'a': '{text_after_a}'") # The 'a' button should work correctly - self.assertEqual(text_after_a, "a", - "Clicking 'a' button should add 'a' to textarea") + self.assertTextareaText("a", "Clicking 'a' button should add 'a' to textarea") print("\nSummary:") print(f" 'q' button result: '{text_after_q}' (expected 'q') āœ“") @@ -142,26 +89,16 @@ def test_keyboard_button_discovery(self): """ print("\n=== Discovering keyboard buttons ===") - # Create keyboard without textarea to inspect it - keyboard = MposKeyboard(self.screen) - keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) - wait_for_render(10) + # Create keyboard scene + self.create_keyboard_scene() - # Iterate through button indices to find all buttons + # Get all buttons using the base class helper + found_buttons = self.get_all_keyboard_buttons() + + # Print first 20 buttons print("\nEnumerating keyboard buttons by index:") - found_buttons = [] - - for i in range(100): # Check first 100 indices - try: - text = keyboard.get_button_text(i) - if text: # Skip None/empty - found_buttons.append((i, text)) - # Only print first 20 to avoid clutter - if i < 20: - print(f" Button {i}: '{text}'") - except: - # No more buttons - break + for idx, text in found_buttons[:20]: + print(f" Button {idx}: '{text}'") if len(found_buttons) > 20: print(f" ... (showing first 20 of {len(found_buttons)} buttons)") @@ -173,16 +110,12 @@ def test_keyboard_button_discovery(self): print("\nLooking for specific letters:") for letter in letters_to_test: - found = False - for idx, text in found_buttons: - if text == letter: - print(f" '{letter}' at index {idx}") - found = True - break - if not found: + idx = self.find_keyboard_button_index(letter) + if idx is not None: + print(f" '{letter}' at index {idx}") + else: print(f" '{letter}' NOT FOUND") # Verify we can find at least some buttons self.assertTrue(len(found_buttons) > 0, "Should find at least some buttons on keyboard") - diff --git a/tests/test_imu_calibration_ui_bug.py b/tests/test_imu_calibration_ui_bug.py deleted file mode 100755 index 59e55d70..00000000 --- a/tests/test_imu_calibration_ui_bug.py +++ /dev/null @@ -1,230 +0,0 @@ -#!/usr/bin/env python3 -"""Automated UI test for IMU calibration bug. - -Tests the complete flow: -1. Open Settings → IMU → Check Calibration -2. Verify values are shown -3. Click "Calibrate" → Calibrate IMU -4. Click "Calibrate Now" -5. Go back to Check Calibration -6. BUG: Verify values are shown (not "--") -""" - -import sys -import time - -# Import graphical test infrastructure -import lvgl as lv -from mpos.ui.testing import ( - wait_for_render, - simulate_click, - find_button_with_text, - find_label_with_text, - get_widget_coords, - print_screen_labels, - capture_screenshot -) - -def click_button(button_text, timeout=5): - """Find and click a button with given text.""" - start = time.time() - while time.time() - start < timeout: - button = find_button_with_text(lv.screen_active(), button_text) - if button: - coords = get_widget_coords(button) - if coords: - print(f"Clicking button '{button_text}' at ({coords['center_x']}, {coords['center_y']})") - simulate_click(coords['center_x'], coords['center_y']) - wait_for_render(iterations=20) - return True - wait_for_render(iterations=5) - print(f"ERROR: Button '{button_text}' not found after {timeout}s") - return False - -def click_label(label_text, timeout=5): - """Find a label with given text and click on it (or its clickable parent).""" - start = time.time() - while time.time() - start < timeout: - label = find_label_with_text(lv.screen_active(), label_text) - if label: - coords = get_widget_coords(label) - if coords: - print(f"Clicking label '{label_text}' at ({coords['center_x']}, {coords['center_y']})") - simulate_click(coords['center_x'], coords['center_y']) - wait_for_render(iterations=20) - return True - wait_for_render(iterations=5) - print(f"ERROR: Label '{label_text}' not found after {timeout}s") - return False - -def find_text_on_screen(text): - """Check if text is present on screen.""" - return find_label_with_text(lv.screen_active(), text) is not None - -def main(): - print("=== IMU Calibration UI Bug Test ===\n") - - # Initialize the OS (boot.py and main.py) - print("Step 1: Initializing MicroPythonOS...") - import mpos.main - wait_for_render(iterations=30) - print("OS initialized\n") - - # Step 2: Open Settings app - print("Step 2: Opening Settings app...") - import mpos.apps - - # Start Settings app by name - mpos.apps.start_app("com.micropythonos.settings") - wait_for_render(iterations=30) - print("Settings app opened\n") - - print("Current screen content:") - print_screen_labels(lv.screen_active()) - print() - - # Check if we're on the main Settings screen (should see multiple settings options) - # The Settings app shows a list with items like "Calibrate IMU", "Check IMU Calibration", "Theme Color", etc. - on_settings_main = (find_text_on_screen("Calibrate IMU") and - find_text_on_screen("Check IMU Calibration") and - find_text_on_screen("Theme Color")) - - # If we're on a sub-screen (like Calibrate IMU or Check IMU Calibration screens), - # we need to go back to Settings main. We can detect this by looking for screen titles. - if not on_settings_main: - print("Step 3: Not on Settings main screen, clicking Back to return...") - if not click_button("Back"): - print("WARNING: Could not find Back button, trying Cancel...") - if not click_button("Cancel"): - print("FAILED: Could not navigate back to Settings main") - return False - wait_for_render(iterations=20) - print("Current screen content:") - print_screen_labels(lv.screen_active()) - print() - - # Step 4: Click "Check IMU Calibration" (it's a clickable label/container, not a button) - print("Step 4: Clicking 'Check IMU Calibration' menu item...") - if not click_label("Check IMU Calibration"): - print("FAILED: Could not find Check IMU Calibration menu item") - return False - print("Check IMU Calibration opened\n") - - # Wait for quality check to complete - time.sleep(0.5) - wait_for_render(iterations=30) - - print("Step 5: Checking BEFORE calibration...") - print("Current screen content:") - print_screen_labels(lv.screen_active()) - print() - - # Capture screenshot before - capture_screenshot("../tests/screenshots/check_imu_before_calib.raw") - - # Look for actual values (not "--") - has_values_before = False - widgets = [] - from mpos.ui.testing import get_all_widgets_with_text - for widget in get_all_widgets_with_text(lv.screen_active()): - text = widget.get_text() - # Look for patterns like "X: 0.00" or "Quality: Good" - if ":" in text and "--" not in text: - if any(char.isdigit() for char in text): - print(f"Found value: {text}") - has_values_before = True - - if not has_values_before: - print("WARNING: No values found before calibration (all showing '--')") - else: - print("GOOD: Values are showing before calibration") - print() - - # Step 6: Click "Calibrate" button to go to calibration screen - print("Step 6: Finding 'Calibrate' button...") - calibrate_btn = find_button_with_text(lv.screen_active(), "Calibrate") - if not calibrate_btn: - print("FAILED: Could not find Calibrate button") - return False - - print(f"Found Calibrate button: {calibrate_btn}") - print("Manually sending CLICKED event to button...") - # Instead of using simulate_click, manually send the event - calibrate_btn.send_event(lv.EVENT.CLICKED, None) - wait_for_render(iterations=20) - - # Wait for navigation to complete (activity transition can take some time) - time.sleep(0.5) - wait_for_render(iterations=50) - print("Calibrate IMU screen should be open now\n") - - print("Current screen content:") - print_screen_labels(lv.screen_active()) - print() - - # Step 7: Click "Calibrate Now" button - print("Step 7: Clicking 'Calibrate Now' button...") - if not click_button("Calibrate Now"): - print("FAILED: Could not find 'Calibrate Now' button") - return False - print("Calibration started...\n") - - # Wait for calibration to complete (~2 seconds + UI updates) - time.sleep(3) - wait_for_render(iterations=50) - - print("Current screen content after calibration:") - print_screen_labels(lv.screen_active()) - print() - - # Step 8: Click "Done" to go back - print("Step 8: Clicking 'Done' button...") - if not click_button("Done"): - print("FAILED: Could not find Done button") - return False - print("Going back to Check Calibration\n") - - # Wait for screen to load - time.sleep(0.5) - wait_for_render(iterations=30) - - # Step 9: Check AFTER calibration (BUG: should show values, not "--") - print("Step 9: Checking AFTER calibration (testing for bug)...") - print("Current screen content:") - print_screen_labels(lv.screen_active()) - print() - - # Capture screenshot after - capture_screenshot("../tests/screenshots/check_imu_after_calib.raw") - - # Look for actual values (not "--") - has_values_after = False - for widget in get_all_widgets_with_text(lv.screen_active()): - text = widget.get_text() - # Look for patterns like "X: 0.00" or "Quality: Good" - if ":" in text and "--" not in text: - if any(char.isdigit() for char in text): - print(f"Found value: {text}") - has_values_after = True - - print() - print("="*60) - print("TEST RESULTS:") - print(f" Values shown BEFORE calibration: {has_values_before}") - print(f" Values shown AFTER calibration: {has_values_after}") - - if has_values_before and not has_values_after: - print("\n āŒ BUG REPRODUCED: Values disappeared after calibration!") - print(" Expected: Values should still be shown") - print(" Actual: All showing '--'") - return False - elif has_values_after: - print("\n āœ… PASS: Values are showing correctly after calibration") - return True - else: - print("\n āš ļø WARNING: No values shown before or after (might be desktop mock issue)") - return True - -if __name__ == '__main__': - success = main() - sys.exit(0 if success else 1) diff --git a/tests/test_osupdate.py b/tests/test_osupdate.py index 16e52fd2..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,58 @@ 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): @@ -417,16 +422,16 @@ def test_download_pauses_on_network_error_during_read(self): """Test that download pauses when network error occurs during read.""" # Set up mock to raise network error after first chunk test_data = b'G' * 16384 # 4 chunks - self.mock_requests.set_next_response( - status_code=200, - headers={'Content-Length': '16384'}, - content=test_data, - fail_after_bytes=4096 # Fail after first chunk - ) + self.mock_download_manager.set_download_data(test_data) + self.mock_download_manager.chunk_size = 4096 + self.mock_download_manager.set_fail_after_bytes(4096) # Fail after first chunk - result = self.downloader.download_and_install( - "http://example.com/update.bin" - ) + async def run_test(): + return await self.downloader.download_and_install( + "http://example.com/update.bin" + ) + + result = run_async(run_test()) self.assertFalse(result['success']) self.assertTrue(result['paused']) @@ -436,29 +441,27 @@ def test_download_pauses_on_network_error_during_read(self): def test_download_resumes_from_saved_position(self): """Test that download resumes from the last written position.""" # Simulate partial download - test_data = b'H' * 12288 # 3 chunks self.downloader.bytes_written_so_far = 8192 # Already downloaded 2 chunks self.downloader.total_size_expected = 12288 - # Server should receive Range header + # Server should receive Range header - only remaining data remaining_data = b'H' * 4096 # Last chunk - self.mock_requests.set_next_response( - status_code=206, # Partial content - headers={'Content-Length': '4096'}, # Remaining bytes - content=remaining_data - ) + self.mock_download_manager.set_download_data(remaining_data) + self.mock_download_manager.chunk_size = 4096 - result = self.downloader.download_and_install( - "http://example.com/update.bin" - ) + async def run_test(): + return await self.downloader.download_and_install( + "http://example.com/update.bin" + ) + + result = run_async(run_test()) self.assertTrue(result['success']) self.assertEqual(result['bytes_written'], 12288) # Check that Range header was set - last_request = self.mock_requests.last_request - self.assertIsNotNone(last_request) - self.assertIn('Range', last_request['headers']) - self.assertEqual(last_request['headers']['Range'], 'bytes=8192-') + self.assertIsNotNone(self.mock_download_manager.headers_received) + self.assertIn('Range', self.mock_download_manager.headers_received) + self.assertEqual(self.mock_download_manager.headers_received['Range'], 'bytes=8192-') def test_resume_failure_preserves_state(self): """Test that resume failures preserve download state for retry.""" @@ -466,12 +469,16 @@ def test_resume_failure_preserves_state(self): self.downloader.bytes_written_so_far = 245760 # 60 chunks already downloaded self.downloader.total_size_expected = 3391488 - # Resume attempt fails immediately with EHOSTUNREACH (network not ready) - self.mock_requests.set_exception(OSError(-118, "EHOSTUNREACH")) + # Resume attempt fails immediately with network error + self.mock_download_manager.set_download_data(b'') + self.mock_download_manager.set_fail_after_bytes(0) # Fail immediately - result = self.downloader.download_and_install( - "http://example.com/update.bin" - ) + async def run_test(): + return await self.downloader.download_and_install( + "http://example.com/update.bin" + ) + + result = run_async(run_test()) # Should pause, not fail self.assertFalse(result['success']) diff --git a/tests/test_sensor_manager.py b/tests/test_sensor_manager.py index 1584e22b..85e77701 100644 --- a/tests/test_sensor_manager.py +++ b/tests/test_sensor_manager.py @@ -72,7 +72,7 @@ def get_chip_id(self): """Return WHO_AM_I value.""" return 0x6A - def read_accelerations(self): + def _read_raw_accelerations(self): """Return mock acceleration (in mg).""" return (0.0, 0.0, 1000.0) # At rest, Z-axis = 1000 mg 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")